diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index c6230c41d23..70991e860a5 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -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, @@ -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 `` / `{{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: '' })).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) diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index 29619bff5b7..363fd39b21c 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -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, @@ -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' }, @@ -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' }, @@ -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' }, @@ -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), }, { id: 'promptCaching', diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 848b8d36363..d5e954744df 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -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 { @@ -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) => { + 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. diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts index bd512bfa464..85d5c8b9ed4 100644 --- a/apps/sim/executor/variables/resolver.test.ts +++ b/apps/sim/executor/variables/resolver.test.ts @@ -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: '', + verbosity: '', + 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') + }) +}) diff --git a/apps/sim/lib/workflows/sanitization/references.test.ts b/apps/sim/lib/workflows/sanitization/references.test.ts index 83b86138472..80ebb43ada9 100644 --- a/apps/sim/lib/workflows/sanitization/references.test.ts +++ b/apps/sim/lib/workflows/sanitization/references.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + containsReference, isLikelyReferenceSegment, splitReferenceSegment, } from '@/lib/workflows/sanitization/references' @@ -53,3 +54,37 @@ describe('isLikelyReferenceSegment', () => { expect(isLikelyReferenceSegment('<123>')).toBe(false) }) }) + +describe('containsReference', () => { + it('detects block and variable references', () => { + expect(containsReference('')).toBe(true) + expect(containsReference('')).toBe(true) + expect(containsReference('')).toBe(true) + }) + + it('detects environment variable placeholders', () => { + expect(containsReference('{{MODEL_ID}}')).toBe(true) + }) + + it('detects a reference embedded in surrounding text', () => { + expect(containsReference('gpt-')).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) + }) +}) diff --git a/apps/sim/lib/workflows/sanitization/references.ts b/apps/sim/lib/workflows/sanitization/references.ts index ecc65f60f71..0c0865765b3 100644 --- a/apps/sim/lib/workflows/sanitization/references.ts +++ b/apps/sim/lib/workflows/sanitization/references.ts @@ -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 `` / `` 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 [] diff --git a/apps/sim/providers/anthropic/core.thinking.test.ts b/apps/sim/providers/anthropic/core.thinking.test.ts index 8fcad2e438d..ae014380dc1 100644 --- a/apps/sim/providers/anthropic/core.thinking.test.ts +++ b/apps/sim/providers/anthropic/core.thinking.test.ts @@ -8,6 +8,7 @@ */ import { describe, expect, it } from 'vitest' import { buildThinkingConfig } from '@/providers/anthropic/core' +import { describeModelLevel } from '@/providers/utils' describe('buildThinkingConfig', () => { it('requests summarized display for omitted-display models on agent-events runs', () => { @@ -55,3 +56,21 @@ describe('buildThinkingConfig', () => { expect(buildThinkingConfig('gpt-4o', 'high', true)).toBeNull() }) }) + +/** + * A thinking level that is not one the model declares reaches this adapter, by design — Sim's + * per-model lists can lag a provider. The adapter logs that it is ignoring it, and since the + * field is reference-bound, the value it logs can be whatever a mistyped `{{ENV_VAR}}` or block + * reference resolved to. + */ +describe('unsupported thinking level logging', () => { + it('returns null for a level the model does not declare', () => { + expect(buildThinkingConfig('claude-sonnet-5', 'sk-proj-abcdef0123456789', false)).toBeNull() + }) + + it('redacts the level in the ignore warning instead of echoing it', () => { + const secret = 'sk-proj-abcdef0123456789' + expect(describeModelLevel(secret)).toBe(`[redacted ${secret.length} chars]`) + expect(describeModelLevel('high')).toBe('high') + }) +}) diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index 384a29f4ef7..eaa823e9ad3 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -31,7 +31,11 @@ import { adaptAnthropicToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' -import { prepareToolExecution, prepareToolsWithUsageControl } from '@/providers/utils' +import { + describeModelLevel, + prepareToolExecution, + prepareToolsWithUsageControl, +} from '@/providers/utils' /** * Configuration for creating an Anthropic provider instance. @@ -396,7 +400,7 @@ export async function executeAnthropicProviderRequest( ) } else { logger.warn( - `Thinking level "${request.thinkingLevel}" not supported for model: ${modelId}, ignoring` + `Thinking level "${describeModelLevel(request.thinkingLevel)}" not supported for model: ${modelId}, ignoring` ) } } diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index 7b0ee9697e8..042081af2b4 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -412,3 +412,145 @@ 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 + + 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() + }) + + /** + * A model the catalogue has never seen is unknown, not known-incapable — which is exactly + * how a newly released model arrives through a reference before Sim catalogues it. The + * provider decides, rather than the level being discarded on a stale list. + */ + it('forwards levels for a model absent from the catalogue', async () => { + await executeProviderRequest('openai', { + model: 'gpt-6-unreleased', + workspaceId: 'ws-1', + reasoningEffort: 'high', + }) + + expect(sentRequest().reasoningEffort).toBe('high') + }) + + it('still drops levels for a dynamic-provider model that does not take them', async () => { + await executeProviderRequest('ollama', { + model: 'ollama/llama3', + workspaceId: 'ws-1', + reasoningEffort: 'high', + }) + + expect(sentRequest().reasoningEffort).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index afda36e3f27..49b3294dd03 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -15,6 +15,7 @@ import { attachLargeFileRemoteUrls, uploadLargeFilesToProvider, } from '@/providers/file-attachments.server' +import { isKnownModelId } from '@/providers/models' import { getProviderExecutor } from '@/providers/registry' import { type ProviderRuntimeContext, @@ -39,23 +40,50 @@ const logger = createLogger('Providers') */ export const MAX_TOOL_ITERATIONS = 20 +/** + * Normalizes a model-tuning level that may have arrived from a variable or block reference + * rather than a picker. Every level a model declares is lower-case, so trimming and + * lower-casing lets a reference resolve to `"High"` or `" high "` and still apply. A level + * that resolves to nothing becomes `undefined` so the field reads as untouched instead of + * sending an empty string the provider rejects. + */ +function normalizeModelLevel(value: string | undefined): string | undefined { + if (typeof value !== 'string') return undefined + const normalized = value.trim().toLowerCase() + return normalized || undefined +} + function sanitizeRequest(request: ProviderRequest): ProviderRequest { const sanitizedRequest = { ...request } const model = sanitizedRequest.model + sanitizedRequest.reasoningEffort = normalizeModelLevel(sanitizedRequest.reasoningEffort) + sanitizedRequest.verbosity = normalizeModelLevel(sanitizedRequest.verbosity) + sanitizedRequest.thinkingLevel = normalizeModelLevel(sanitizedRequest.thinkingLevel) + if (model && !supportsTemperature(model)) { sanitizedRequest.temperature = undefined } - if (model && !supportsReasoningEffort(model)) { + /** + * A model absent from the catalogue is unknown, not known-incapable. The model field is an + * editable combobox, so a model newer than `models.ts` reaches this point routed by pattern + * and executing normally — discarding its levels on the strength of a list that has not + * caught up loses a setting the provider would have honoured. Those levels are forwarded and + * the provider decides. Models the catalogue does know, and every dynamic-provider id, keep + * the protective drop. + */ + const isCatalogued = Boolean(model) && isKnownModelId(model) + + if (model && isCatalogued && !supportsReasoningEffort(model)) { sanitizedRequest.reasoningEffort = undefined } - if (model && !supportsVerbosity(model)) { + if (model && isCatalogued && !supportsVerbosity(model)) { sanitizedRequest.verbosity = undefined } - if (model && !supportsThinking(model)) { + if (model && isCatalogued && !supportsThinking(model)) { sanitizedRequest.thinkingLevel = undefined } diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 98be92cd78d..393cacf33fa 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -4553,6 +4553,33 @@ export function getThinkingLevelsForModel(modelId: string): string[] | null { return capability?.levels ?? null } +const ALL_MODEL_LEVEL_VALUES = new Set() +for (const provider of Object.values(PROVIDER_DEFINITIONS)) { + for (const model of provider.models) { + for (const value of model.capabilities.reasoningEffort?.values ?? []) { + ALL_MODEL_LEVEL_VALUES.add(value) + } + for (const value of model.capabilities.verbosity?.values ?? []) { + ALL_MODEL_LEVEL_VALUES.add(value) + } + for (const level of model.capabilities.thinking?.levels ?? []) { + ALL_MODEL_LEVEL_VALUES.add(level) + } + } +} + +/** + * Whether a string is a tuning level some model in the catalogue declares, regardless of which. + * + * Callers that need to put a caller-supplied level into a log or an error gate on this first. + * These fields accept variable and environment references, so an unrecognized value is not + * necessarily a mistyped level — it can be whatever that reference resolved to, up to and + * including secret content that must never be echoed. + */ +export function isKnownModelLevelValue(value: string): boolean { + return ALL_MODEL_LEVEL_VALUES.has(value) +} + /** * Per-provider defaults for thinking stream visibility, used when a model does * not declare `capabilities.thinking.streamed` explicitly. Gemini and OpenAI diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index f959cf7911c..980839059da 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -2,6 +2,7 @@ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { calculateCost, + describeModelLevel, extractAndParseJSON, filterBlacklistedModels, formatCost, @@ -1767,3 +1768,32 @@ describe('prepareToolExecution invoker identity hand-off', () => { expect(executionParams._context.executionId).toBeUndefined() }) }) + +/** + * The agent block's tuning-level fields accept variable and environment references, so any + * message that echoes a caller-supplied level can otherwise carry whatever that reference + * resolved to — including secret content. + */ +describe('describeModelLevel', () => { + it('echoes a level the catalogue declares', () => { + expect(describeModelLevel('high')).toBe('high') + expect(describeModelLevel('minimal')).toBe('minimal') + expect(describeModelLevel('xhigh')).toBe('xhigh') + }) + + it('echoes the auto and none sentinels', () => { + expect(describeModelLevel('auto')).toBe('auto') + expect(describeModelLevel('none')).toBe('none') + }) + + it('redacts anything else to a length', () => { + const secret = 'sk-proj-abcdef0123456789' + expect(describeModelLevel(secret)).toBe(`[redacted ${secret.length} chars]`) + expect(describeModelLevel(secret)).not.toContain('abcdef') + }) + + it('reports an absent level without throwing', () => { + expect(describeModelLevel(undefined)).toBe('(unset)') + expect(describeModelLevel('')).toBe('(unset)') + }) +}) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 12b7c0cc966..c5b6869e634 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -44,6 +44,7 @@ import { getReasoningEffortValuesForModel as getReasoningEffortValuesForModelFromDefinitions, getThinkingLevelsForModel as getThinkingLevelsForModelFromDefinitions, getVerbosityValuesForModel as getVerbosityValuesForModelFromDefinitions, + isKnownModelLevelValue, PROVIDER_DEFINITIONS, supportsTemperature as supportsTemperatureFromDefinitions, supportsToolUsageControl as supportsToolUsageControlFromDefinitions, @@ -1381,6 +1382,30 @@ export function supportsTemperature(model: string): boolean { return supportsTemperatureFromDefinitions(model) } +/** + * Levels the pickers offer on top of what a model declares. `auto` means "say nothing" and + * `none` means "explicitly off"; provider adapters special-case both, so neither is an + * unrecognized level. + */ +const MODEL_LEVEL_SENTINELS = new Set(['auto', 'none']) + +/** + * Renders a tuning level for a log line or an error message. + * + * The agent block's reasoning effort, verbosity, and thinking level fields accept variable and + * environment references, so an unrecognized level is not necessarily a mistyped level — it is + * whatever the reference resolved to, up to and including secret content. Only a level the + * catalogue declares somewhere is safe to echo; anything else is reported by length alone, + * which still distinguishes a stray level from a resolved blob. + * + * Every site that puts a caller-supplied level into a message must go through this. + */ +export function describeModelLevel(value: string | undefined): string { + if (!value) return '(unset)' + const isSafe = MODEL_LEVEL_SENTINELS.has(value) || isKnownModelLevelValue(value) + return isSafe ? value : `[redacted ${value.length} chars]` +} + export function supportsReasoningEffort(model: string): boolean { return MODELS_WITH_REASONING_EFFORT.includes(model.toLowerCase()) }