Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/sim/blocks/blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'

vi.unmock('@/blocks/registry')

import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility'
import { generateRouterPrompt } from '@/blocks/blocks/router'
import {
getAllBlocks,
Expand Down Expand Up @@ -842,6 +843,41 @@ describe.concurrent('Blocks Module', () => {
expect(modelSubBlock?.commandSearchable).toBe(true)
})

/** Each model-tuning field with a model that accepts it and one that does not. */
const AGENT_MODEL_LEVEL_FIELDS = [
{ id: 'reasoningEffort', capable: 'gpt-5.1', incapable: 'claude-sonnet-5' },
{ id: 'verbosity', capable: 'gpt-5.1', incapable: 'claude-sonnet-5' },
{ id: 'thinkingLevel', capable: 'claude-sonnet-5', incapable: 'gpt-5.1' },
] as const

it('should let the agent model-tuning fields take a typed reference', () => {
const agentBlock = getBlock('agent')

for (const { id } of AGENT_MODEL_LEVEL_FIELDS) {
const subBlock = agentBlock?.subBlocks.find((sb) => sb.id === id)
// A combobox is editable, so a `<block.output>` / `{{ENV_VAR}}` reference can be
// typed into it; the option list still offers every level the model accepts.
expect(subBlock?.type).toBe('combobox')
expect(typeof subBlock?.condition).toBe('function')
}
})

it('should keep the agent model-tuning fields visible when the model is a reference', () => {
const agentBlock = getBlock('agent')

for (const { id, capable, incapable } of AGENT_MODEL_LEVEL_FIELDS) {
const subBlock = agentBlock?.subBlocks.find((sb) => sb.id === id)
const condition = subBlock?.condition
if (typeof condition !== 'function') throw new Error(`${id} condition is not a function`)

expect(evaluateSubBlockCondition(condition, { model: '<start.model>' })).toBe(true)
expect(evaluateSubBlockCondition(condition, { model: '{{MODEL_ID}}' })).toBe(true)
// Gating on the capability list is unchanged for a literal model.
expect(evaluateSubBlockCondition(condition, { model: capable })).toBe(true)
expect(evaluateSubBlockCondition(condition, { model: incapable })).toBe(false)
}
})

it('should hide generator API keys on hosted only for Fal.ai providers', () => {
for (const blockType of ['image_generator_v2', 'video_generator_v3']) {
const block = getBlock(blockType)
Expand Down
28 changes: 10 additions & 18 deletions apps/sim/blocks/blocks/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AgentIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import {
getModelCapabilityCondition,
getModelOptions,
getProviderCredentialSubBlocks,
normalizeFileInput,
Expand Down Expand Up @@ -159,8 +160,8 @@ Return ONLY the JSON array.`,
{
id: 'reasoningEffort',
title: 'Reasoning Effort',
type: 'dropdown',
placeholder: 'Select reasoning effort...',
type: 'combobox',
placeholder: 'Type or select reasoning effort...',
options: [
{ label: 'auto', id: 'auto' },
{ label: 'low', id: 'low' },
Expand Down Expand Up @@ -207,16 +208,13 @@ Return ONLY the JSON array.`,
return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
},
mode: 'advanced',
condition: {
field: 'model',
value: MODELS_WITH_REASONING_EFFORT,
},
condition: getModelCapabilityCondition(MODELS_WITH_REASONING_EFFORT),
},
{
id: 'verbosity',
title: 'Verbosity',
type: 'dropdown',
placeholder: 'Select verbosity...',
type: 'combobox',
placeholder: 'Type or select verbosity...',
options: [
{ label: 'auto', id: 'auto' },
{ label: 'low', id: 'low' },
Expand Down Expand Up @@ -263,16 +261,13 @@ Return ONLY the JSON array.`,
return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
},
mode: 'advanced',
condition: {
field: 'model',
value: MODELS_WITH_VERBOSITY,
},
condition: getModelCapabilityCondition(MODELS_WITH_VERBOSITY),
},
{
id: 'thinkingLevel',
title: 'Thinking Level',
type: 'dropdown',
placeholder: 'Select thinking level...',
type: 'combobox',
placeholder: 'Type or select thinking level...',
options: [
{ label: 'none', id: 'none' },
{ label: 'minimal', id: 'minimal' },
Expand Down Expand Up @@ -306,10 +301,7 @@ Return ONLY the JSON array.`,
return [noneOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
},
mode: 'advanced',
condition: {
field: 'model',
value: MODELS_WITH_THINKING,
},
condition: getModelCapabilityCondition(MODELS_WITH_THINKING),
Comment thread
cursor[bot] marked this conversation as resolved.
},
{
id: 'promptCaching',
Expand Down
18 changes: 18 additions & 0 deletions apps/sim/blocks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isOllamaConfigured,
} from '@/lib/core/config/env-flags'
import { getScopesForService } from '@/lib/oauth/utils'
import { containsReference } from '@/lib/workflows/sanitization/references'
import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility'
import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types'
import {
Expand Down Expand Up @@ -235,6 +236,23 @@ function shouldRequireApiKeyForModel(model: string): boolean {
return true
}

/**
* Visibility condition for a model-tuning field that only some models accept, such as
* reasoning effort or verbosity. Gates on the capability list, but keeps the field visible
* when `model` itself holds a variable or block reference — the concrete model id is only
* known at execution time then, so matching a reference against a static list would hide
* the field for every workflow that binds its model dynamically.
*/
export function getModelCapabilityCondition(capableModels: string[]) {
return (values?: Record<string, unknown>) => {
const model = typeof values?.model === 'string' ? values.model : ''
if (containsReference(model)) {
return buildModelVisibilityCondition(model, true)
}
return { field: 'model', value: capableModels }
}
}

/**
* Get the API key condition for provider credential subblocks.
* Handles hosted vs self-hosted environments and excludes providers that don't need API key.
Expand Down
47 changes: 47 additions & 0 deletions apps/sim/executor/variables/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1341,3 +1341,50 @@ describe('VariableResolver function context overflow offload', () => {
expect(result.resolvedInputs.code).toBe('return globals()["__blockRef_0"]')
})
})

/**
* The agent block's Reasoning Effort and Verbosity fields are editable comboboxes, so a
* workflow can bind them to a reference instead of picking a level. These lock in that the
* generic input resolution actually reaches those two fields.
*/
describe('VariableResolver agent model levels', () => {
it('resolves block, workflow-variable, and env references in reasoning effort and verbosity', async () => {
const producer = createBlock('producer', 'Producer', BlockType.API)
const agent = createBlock('agent', 'Agent', BlockType.AGENT, {
model: 'gpt-5',
reasoningEffort: '<Producer.result>',
verbosity: '<variable.Detail>',
thinkingLevel: '{{THINKING}}',
})
const workflow: SerializedWorkflow = {
version: '1',
blocks: [producer, agent],
connections: [],
loops: {},
parallels: {},
}

const state = new ExecutionState()
state.setBlockOutput('producer', { result: 'high' })
const ctx = {
blockStates: state.getBlockStates(),
blockLogs: [],
environmentVariables: { THINKING: 'medium' },
workflowVariables: { 'var-1': { id: 'var-1', name: 'Detail', type: 'string', value: 'low' } },
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
metadata: {},
} as unknown as ExecutionContext

const resolver = new VariableResolver(workflow, { THINKING: 'medium' }, state)
const result = await resolver.resolveInputs(ctx, 'agent', agent.config.params, agent)

expect(result.reasoningEffort).toBe('high')
expect(result.verbosity).toBe('low')
expect(result.thinkingLevel).toBe('medium')
expect(result.model).toBe('gpt-5')
})
})
35 changes: 35 additions & 0 deletions apps/sim/lib/workflows/sanitization/references.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
containsReference,
isLikelyReferenceSegment,
splitReferenceSegment,
} from '@/lib/workflows/sanitization/references'
Expand Down Expand Up @@ -53,3 +54,37 @@ describe('isLikelyReferenceSegment', () => {
expect(isLikelyReferenceSegment('<123>')).toBe(false)
})
})

describe('containsReference', () => {
it('detects block and variable references', () => {
expect(containsReference('<start.input>')).toBe(true)
expect(containsReference('<variable.model>')).toBe(true)
expect(containsReference('<loop.index>')).toBe(true)
})

it('detects environment variable placeholders', () => {
expect(containsReference('{{MODEL_ID}}')).toBe(true)
})

it('detects a reference embedded in surrounding text', () => {
expect(containsReference('gpt-<start.suffix>')).toBe(true)
})

it('returns false for literal model ids', () => {
expect(containsReference('gpt-5.1')).toBe(false)
expect(containsReference('claude-sonnet-5')).toBe(false)
expect(containsReference('azure/gpt-5.1-codex')).toBe(false)
})

it('returns false for empty and non-string values', () => {
expect(containsReference('')).toBe(false)
expect(containsReference(undefined)).toBe(false)
expect(containsReference(null)).toBe(false)
expect(containsReference(42)).toBe(false)
})

it('returns false for stray brackets that are not references', () => {
expect(containsReference('a < b')).toBe(false)
expect(containsReference('<123>')).toBe(false)
})
})
15 changes: 15 additions & 0 deletions apps/sim/lib/workflows/sanitization/references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ export function isLikelyReferenceSegment(segment: string): boolean {
return true
}

const ENV_VAR_PATTERN = new RegExp(`\\${REFERENCE.ENV_VAR_START}[^}]+\\${REFERENCE.ENV_VAR_END}`)

/**
* Whether a subblock value carries a `<block.path>` / `<variable.name>` reference or a
* `{{ENV_VAR}}` placeholder instead of a literal value — i.e. its real value is only known
* once the workflow runs. Conditions that gate one field on a sibling's literal value use
* this to stay visible while the sibling is bound dynamically.
*/
export function containsReference(value: unknown): boolean {
if (typeof value !== 'string' || !value) {
return false
}
return extractReferencePrefixes(value).length > 0 || ENV_VAR_PATTERN.test(value)
}

export function extractReferencePrefixes(value: string): Array<{ raw: string; prefix: string }> {
if (!value || typeof value !== 'string') {
return []
Expand Down
117 changes: 117 additions & 0 deletions apps/sim/providers/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,120 @@ describe('executeProviderRequest — streaming cost policy', () => {
})
})
})

/**
* `reasoningEffort`, `verbosity`, and `thinkingLevel` can be bound to a variable or block
* reference in the agent block, so by the time they reach the provider they hold whatever
* that reference resolved to rather than a value picked from a list.
*/
describe('executeProviderRequest — model level normalization', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false })
mockExecuteRequest.mockResolvedValue({
content: 'hi',
model: 'gpt-5',
tokens: { input: 1, output: 1, total: 2 },
} as ProviderResponse)
})

const sentRequest = () => mockExecuteRequest.mock.calls[0][0] as Record<string, unknown>

it('trims and lower-cases levels a reference resolved to', async () => {
await executeProviderRequest('openai', {
model: 'gpt-5',
workspaceId: 'ws-1',
reasoningEffort: ' High ',
verbosity: 'LOW',
})

expect(sentRequest().reasoningEffort).toBe('high')
expect(sentRequest().verbosity).toBe('low')
})

it('trims and lower-cases a thinking level a reference resolved to', async () => {
await executeProviderRequest('anthropic', {
model: 'claude-sonnet-5',
workspaceId: 'ws-1',
thinkingLevel: ' High ',
})

expect(sentRequest().thinkingLevel).toBe('high')
})

it('treats a level that resolved to nothing as unset rather than an empty string', async () => {
await executeProviderRequest('openai', {
model: 'gpt-5',
workspaceId: 'ws-1',
reasoningEffort: '',
verbosity: ' ',
})

expect(sentRequest().reasoningEffort).toBeUndefined()
expect(sentRequest().verbosity).toBeUndefined()
})

/**
* Providers treat an explicit `'none'` as "thinking off" and an absent value as "send
* nothing", so a reference that resolved to nothing must land on the latter.
*/
it('treats a thinking level that resolved to nothing as unset, not as none', async () => {
await executeProviderRequest('anthropic', {
model: 'claude-sonnet-5',
workspaceId: 'ws-1',
thinkingLevel: ' ',
})

expect(sentRequest().thinkingLevel).toBeUndefined()
})

it('preserves an explicit none thinking level', async () => {
await executeProviderRequest('anthropic', {
model: 'claude-sonnet-5',
workspaceId: 'ws-1',
thinkingLevel: 'none',
})

expect(sentRequest().thinkingLevel).toBe('none')
})

it('leaves an already-valid level untouched', async () => {
await executeProviderRequest('openai', {
model: 'gpt-5',
workspaceId: 'ws-1',
reasoningEffort: 'medium',
verbosity: 'high',
})

expect(sentRequest().reasoningEffort).toBe('medium')
expect(sentRequest().verbosity).toBe('high')
})

/**
* Sim's per-model level lists drive the pickers and can lag a provider that has started
* accepting a new level, so an unrecognized level is forwarded rather than dropped: the
* provider answers with an error naming the values it accepts, instead of Sim silently
* substituting the model default and quietly corrupting a sweep.
*/
it('forwards a level the model does not declare so the provider reports it', async () => {
await executeProviderRequest('openai', {
model: 'gpt-5',
workspaceId: 'ws-1',
reasoningEffort: 'xhigh',
})

expect(sentRequest().reasoningEffort).toBe('xhigh')
})

it('still drops levels the resolved model does not support', async () => {
await executeProviderRequest('anthropic', {
model: 'claude-opus-4-6',
workspaceId: 'ws-1',
reasoningEffort: 'high',
verbosity: 'high',
})

expect(sentRequest().reasoningEffort).toBeUndefined()
expect(sentRequest().verbosity).toBeUndefined()
})
})
Loading
Loading