Skip to content

Commit 15bf22b

Browse files
committed
feat(models): gate forced tool use by model capability
1 parent 80dccd1 commit 15bf22b

5 files changed

Lines changed: 65 additions & 16 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
8080
import { useOperationAccess } from '@/hooks/use-operation-access'
8181
import { usePermissionConfig } from '@/hooks/use-permission-config'
8282
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
83+
import { supportsForcedToolUse } from '@/providers/models'
8384
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
8485
import type { ActiveSearchTarget } from '@/stores/panel/editor/store'
8586
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -561,10 +562,11 @@ export const ToolInput = memo(function ToolInput({
561562
})
562563
}, [mcpTools, mcpServers])
563564

564-
const modelValue = useSubBlockStore.getState().getValue(blockId, 'model')
565+
const modelValue = useSubBlockStore((state) => state.getValue(blockId, 'model'))
565566
const model = typeof modelValue === 'string' ? modelValue : ''
566567
const provider = model ? getProviderFromModel(model) : ''
567568
const supportsToolControl = provider ? supportsToolUsageControl(provider) : false
569+
const supportsForce = supportsForcedToolUse(model)
568570

569571
const {
570572
filterBlocks,
@@ -1710,12 +1712,16 @@ export const ToolInput = memo(function ToolInput({
17101712
</PopoverItem>
17111713
<PopoverItem
17121714
active={tool.usageControl === 'force'}
1715+
disabled={!supportsForce}
17131716
onClick={() => {
17141717
handleUsageControlChange(toolIndex, 'force')
17151718
setUsageControlPopoverIndex(null)
17161719
}}
17171720
>
1718-
Force <span className='text-[var(--text-tertiary)]'>(always use)</span>
1721+
Force{' '}
1722+
<span className='text-[var(--text-tertiary)]'>
1723+
{supportsForce ? '(always use)' : '(not supported by model)'}
1724+
</span>
17191725
</PopoverItem>
17201726
<PopoverItem
17211727
active={tool.usageControl === 'none'}

apps/sim/providers/anthropic/core.request.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,13 @@ describe('executeAnthropicProviderRequest forced tool use', () => {
296296
expect(payload.tool_choice).toEqual({ type: 'tool', name: 'publish' })
297297
})
298298

299-
it('drops forced tool_choice on Claude Fable 5.1 because the API rejects it', async () => {
300-
const { payload, warn } = await runWithForcedTool('claude-fable-5-1')
299+
it.each([
300+
'claude-fable-5-1',
301+
'claude-fable-5-1-20260901',
302+
'azure-anthropic/claude-fable-5-1',
303+
'claude-mythos-5-1',
304+
])('drops forced tool_choice on %s because the API rejects it', async (model) => {
305+
const { payload, warn } = await runWithForcedTool(model)
301306
expect(payload.tools?.map((tool) => tool.name)).toEqual(['publish'])
302307
expect(payload).not.toHaveProperty('tool_choice')
303308
expect(warn).toHaveBeenCalledWith(expect.stringContaining('rejects forced tool_choice'))

apps/sim/providers/anthropic/core.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import {
2222
getMaxOutputTokensForModel,
2323
getThinkingCapability,
24+
supportsForcedToolUse,
2425
supportsNativeStructuredOutputs,
2526
supportsTemperature,
2627
} from '@/providers/models'
@@ -155,17 +156,6 @@ function supportsAdaptiveThinking(modelId: string): boolean {
155156
)
156157
}
157158

158-
/**
159-
* Claude Fable 5.1 and Claude Mythos 5.1 reject forced tool use: a `tool_choice` of
160-
* type `tool` or `any` returns a 400 (`tool_choice: type "tool" and "any" are not
161-
* supported for this model.`). Thinking is always on for these models, so a forced
162-
* call would skip it. The request is sent with the default `auto` instead.
163-
*/
164-
function rejectsForcedToolChoice(modelId: string): boolean {
165-
const normalizedModel = modelId.toLowerCase()
166-
return normalizedModel.includes('fable-5-1') || normalizedModel.includes('mythos-5-1')
167-
}
168-
169159
/**
170160
* Builds the thinking configuration for the Anthropic API based on model capabilities and level.
171161
*
@@ -428,7 +418,7 @@ export async function executeAnthropicProviderRequest(
428418
} else if (toolChoice === 'none') {
429419
payload.tool_choice = { type: 'none' }
430420
} else if (toolChoice !== 'auto') {
431-
if (rejectsForcedToolChoice(request.model)) {
421+
if (!supportsForcedToolUse(request.model)) {
432422
logger.warn(
433423
`Model ${modelId} rejects forced tool_choice; sending tool "${toolChoice.name}" with tool_choice auto`
434424
)

apps/sim/providers/models.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
55
import {
66
getBaseModelProviders,
77
getHostedModels,
8+
getModelCapabilities,
89
getModelPricing,
910
getModelsWithPromptCaching,
1011
getPromptCachingMinimumTokens,
@@ -13,10 +14,40 @@ import {
1314
isModelDeprecated,
1415
orderModelIdsByReleaseDate,
1516
PROVIDER_DEFINITIONS,
17+
supportsForcedToolUse,
1618
updateFireworksModels,
1719
} from '@/providers/models'
1820
import { supportsPromptCaching } from '@/providers/utils'
1921

22+
describe('forced tool use capability', () => {
23+
it('keeps Auto and None support when a model disables Force', () => {
24+
expect(getModelCapabilities('claude-fable-5-1')).toMatchObject({
25+
toolUsageControl: true,
26+
forcedToolUse: false,
27+
})
28+
})
29+
30+
it.each([
31+
'claude-fable-5-1',
32+
'CLAUDE-FABLE-5-1-20260901',
33+
'azure-anthropic/claude-fable-5-1',
34+
'claude-mythos-5-1',
35+
])('disables forced tool use for %s', (model) => {
36+
expect(supportsForcedToolUse(model)).toBe(false)
37+
})
38+
39+
it.each(['claude-sonnet-5', 'claude-fable-5', 'claude-opus-5', 'gpt-5.5'])(
40+
'inherits provider tool-control support for %s',
41+
(model) => {
42+
expect(supportsForcedToolUse(model)).toBe(true)
43+
}
44+
)
45+
46+
it('does not enable Force for an unknown model without tool-control capabilities', () => {
47+
expect(supportsForcedToolUse('unknown-model')).toBe(false)
48+
})
49+
})
50+
2051
describe('Anthropic thinking stream visibility', () => {
2152
it('classifies visible Claude thinking as summarized rather than raw', () => {
2253
for (const providerId of ['anthropic', 'azure-anthropic'] as const) {

apps/sim/providers/models.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface ModelCapabilities {
4545
max: number
4646
}
4747
toolUsageControl?: boolean
48+
/** Whether tools can be forced. Defaults to toolUsageControl when omitted. */
49+
forcedToolUse?: boolean
4850
computerUse?: boolean
4951
nativeStructuredOutputs?: boolean
5052
/** Maximum supported output tokens for this model */
@@ -834,6 +836,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
834836
updatedAt: '2026-09-01',
835837
},
836838
capabilities: {
839+
forcedToolUse: false,
837840
nativeStructuredOutputs: true,
838841
maxOutputTokens: 128000,
839842
promptCaching: { minimumCacheableTokens: 512 },
@@ -4391,6 +4394,20 @@ export function supportsToolUsageControl(providerId: string): boolean {
43914394
return getProvidersWithToolUsageControl().includes(providerId)
43924395
}
43934396

4397+
/** Whether the model accepts forced tool choice, including uncataloged Claude aliases. */
4398+
export function supportsForcedToolUse(modelId: string): boolean {
4399+
const capabilities = getModelCapabilities(modelId)
4400+
if (capabilities?.forcedToolUse !== undefined) return capabilities.forcedToolUse
4401+
4402+
/** Preserve the restriction for date-suffixed and reseller IDs outside the catalog. */
4403+
const normalizedModel = modelId.toLowerCase()
4404+
if (normalizedModel.includes('fable-5-1') || normalizedModel.includes('mythos-5-1')) {
4405+
return false
4406+
}
4407+
4408+
return capabilities?.toolUsageControl ?? false
4409+
}
4410+
43944411
export function updateOllamaModels(models: string[]): void {
43954412
PROVIDER_DEFINITIONS.ollama.models = models.map((modelId) => ({
43964413
id: modelId,

0 commit comments

Comments
 (0)