improvement(agent): allow variable references in reasoning effort, verbosity, and thinking level (#6233)

* improvement(agent): allow variable references in reasoning effort and verbosity

Reasoning Effort and Verbosity were select-only dropdowns, so a workflow could
not sweep them from a variable or an upstream block the way it already can with
the model. Both become editable comboboxes, matching the model field directly
above them and the managed-agent selectors.

- switch both subblocks to `combobox`, keeping their fetched per-model option
  lists intact
- keep them visible when `model` itself holds a reference, since the concrete
  model id is only known at execution time and cannot be matched against the
  static capability list
- normalize the resolved level in the provider chokepoint so a reference that
  resolves to `"High"` or to nothing behaves sanely instead of hitting a
  provider 400

* improvement(agent): allow variable references in thinking level

Extends the same treatment to Thinking Level so all three model-tuning fields
behave consistently, and logs a level a model does not declare.

- switch `thinkingLevel` to `combobox` with the reference-aware condition
- normalize it alongside the other two; an empty resolve now takes the
  deliberate "send nothing" path rather than the incoherent half-state it hit
  before, and stays distinct from an explicit `none`
- warn when a level is not one the model declares, still forwarding it: Sim's
  per-model lists drive the pickers and can lag a provider, and a sweep needs
  the provider's own error rather than a silent fallback to the default

* improvement(agent): report a model level the sanitizer discards

A model bound to a variable or block reference only resolves at execution time,
so a run whose reference landed on a model outside Sim's catalogue had its
requested level cleared with no signal and quietly fell back to that model's
default. Dropping stays the safe default — a provider with no such parameter
rejects the whole request — but it is now reported.

- log the field, model, and value whenever an unsupported-field level is cleared
- cover both diagnostics, including that they stay quiet for a declared level
  and for the `auto` / `none` sentinels

* fix(providers): redact resolved level content from sanitizer diagnostics

The model-level fields accept environment and block references, so an
unrecognized level is not necessarily a mistyped level — it is whatever the
reference resolved to, which can be secret content. The diagnostics added for
dropped and undeclared levels echoed it straight into server logs.

- log a level only when the catalogue declares it somewhere, or it is an
  `auto` / `none` sentinel; anything else is reported by length alone
- stop discarding levels for a model the catalogue has never seen. Absent is
  unknown, not known-incapable, and a reference is exactly how a newly released
  model arrives before Sim catalogues it — the provider decides instead. Models
  the catalogue knows, and every dynamic-provider id, keep the protective drop

* fix(providers): redact the level in Anthropic's unsupported-thinking warning

Forwarding an undeclared level is deliberate, but it means the Anthropic adapter
receives it and interpolates it straight into its "not supported, ignoring"
warning. Since the field is reference-bound, that value can be whatever a
mistyped `{{ENV_VAR}}` or block reference resolved to — so the redaction added
for the sanitizer's own diagnostics was leaking one layer downstream.

- promote the level renderer to `providers/utils` as `describeModelLevel`, the
  single gate every site echoing a caller-supplied level goes through
- use it in Anthropic's warning and in both sanitizer diagnostics

* refactor(providers): drop the sanitizer's level diagnostics

The two warnings logged server-side, where the workflow author who set the level
never sees them, and the surprising case they described — a level discarded for
a model newer than the catalogue — is now fixed at the source rather than
narrated. They also carried the redaction that leaked resolved content before it
was caught, so removing them removes that surface entirely.

Levels still normalize, and still drop for a catalogued model that does not take
the field. `describeModelLevel` stays for Anthropic's unsupported-thinking
warning, which is a pre-existing log this feature newly exposes to resolved
reference content.
This commit is contained in:
Waleed
2026-08-03 18:56:12 -07:00
committed by GitHub
parent d05289cfa2
commit 894083390e
13 changed files with 441 additions and 23 deletions
+36
View File
@@ -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 `<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)
+10 -18
View File
@@ -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',
+18
View File
@@ -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<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.
@@ -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')
})
})
@@ -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('<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)
})
})
@@ -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 []
@@ -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')
})
})
+6 -2
View File
@@ -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`
)
}
}
+142
View File
@@ -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<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()
})
/**
* 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()
})
})
+31 -3
View File
@@ -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
}
+27
View File
@@ -4567,6 +4567,33 @@ export function getThinkingLevelsForModel(modelId: string): string[] | null {
return capability?.levels ?? null
}
const ALL_MODEL_LEVEL_VALUES = new Set<string>()
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
+30
View File
@@ -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)')
})
})
+25
View File
@@ -44,6 +44,7 @@ import {
getReasoningEffortValuesForModel as getReasoningEffortValuesForModelFromDefinitions,
getThinkingLevelsForModel as getThinkingLevelsForModelFromDefinitions,
getVerbosityValuesForModel as getVerbosityValuesForModelFromDefinitions,
isKnownModelLevelValue,
PROVIDER_DEFINITIONS,
supportsTemperature as supportsTemperatureFromDefinitions,
supportsToolUsageControl as supportsToolUsageControlFromDefinitions,
@@ -1405,6 +1406,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())
}