Skip to content

Commit 0d90116

Browse files
committed
feat(workflows): share model fallbacks across LLM blocks
1 parent 2728156 commit 0d90116

18 files changed

Lines changed: 1059 additions & 207 deletions

File tree

‎apps/docs/content/docs/workflows/blocks/agent.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Some settings live under advanced, or appear only for models that support them:
8787
- **Reasoning effort / Thinking level.** For models with extended reasoning, how much the model thinks before answering. Higher is more thorough but slower and costs more tokens.
8888
- **Prompt caching.** For Anthropic Claude models, reuses the system prompt and tool definitions between runs instead of re-reading them every time. Cached input costs a tenth of the normal rate, but writing the cache costs 1.25x, so leave it off for one-off runs and turn it on when the same agent runs repeatedly. The cache covers a prefix only if it reaches 1,024 tokens (2,048 on Haiku) — below that Anthropic ignores it and nothing changes. Entries expire after five minutes of no use.
8989
- **API key.** Your key for the chosen provider. Hidden on hosted Sim, which supplies one.
90-
- **Fallback models.** An ordered list of up to five models to try when the request to the selected model fails, whether the provider is overloaded, rate-limited, or down. Sim tries the 2nd choice, then the 3rd, and so on, once each, and `<agent.model>` reports the model that answered. Hosted models need no setup. A model that needs its own key takes it from a workspace environment variable you pick on the row; a model on the same provider as the selected model reuses the block's key. Azure, Bedrock, and Vertex models can only be fallbacks for a selected model of the same family, since they use that model's credentials. The Auto model cannot be a fallback. A fallback runs with the selected model's settings where its provider accepts them: temperature and max output tokens are clamped to the fallback's limits, and when the fallback has a reasoning effort, thinking level, or verbosity setting that the selected model's value does not fit, the row shows that field so you can pick a value for it; leave it empty and the provider's default applies.
90+
- **Fallback models.** An ordered list of up to five models to try when the request to the selected model fails, whether the provider is overloaded, rate-limited, or down. Sim tries the 2nd choice, then the 3rd, and so on, once each, and `<agent.model>` reports the model that answered. On hosted Sim, hosted models use your workspace's BYOK or platform credentials; local and self-hosted installations may still require a key. A model that needs its own key takes it from a workspace environment variable you pick on the row; a model on the same provider as the selected model reuses the block's key. A stored row key stops applying when its key field is hidden. Providers that require family-specific credentials, such as Vertex, can only be fallbacks for a selected model of the same family. The Auto model cannot be a fallback. A fallback runs with the selected model's settings where its provider accepts them: temperature and max output tokens are clamped to the fallback's limits, and when the fallback has a reasoning effort, thinking level, or verbosity setting that the selected model's value does not fit, the row shows that field so you can pick a value for it; leave it empty and the provider's default applies.
9191
- **Retry on fail.** Retries the selected model after a failure, up to a maximum number of tries with a wait between them. When its tries run out, the fallback models are tried in order, once each, with no wait before the first of them. A fallback is never retried. A failure that happens after the model already called a tool runs that conversation again on the next try or the next model, so keep fallbacks and retry off for agents whose tools must not repeat.
9292

9393
OpenAI and Gemini cache automatically at no extra cost and need no setting; their discount is already reflected in what you are charged.

‎apps/docs/content/docs/workflows/blocks/evaluator.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ The content to score. Usually an earlier output like `<agent.content>`. Structur
3232

3333
The model that does the scoring, defaulting to `claude-sonnet-4-6`. Stronger reasoning models give more consistent scores. Type or pick any supported model. **Temperature** and a **System Prompt** are available under advanced, and on hosted Sim the API key is supplied for you.
3434

35+
### Fallback models
36+
37+
Under **Additional fields**, add up to five models to try in order when a model request fails. With **Retry on fail** enabled, the selected model exhausts its tries first; each fallback is then tried once. Every attempt uses the same content, metrics, and response schema. `<evaluator.model>` reports the model that answered.
38+
39+
Hosted models on hosted Sim use workspace BYOK or platform credentials. On local or self-hosted installations, a fallback on another provider may need a secret selected on its row; same-provider fallbacks reuse the selected model's key. The picker shows supported tuning fields when the selected model's settings cannot be inherited. Auto cannot be a fallback.
40+
3541
## Outputs
3642

3743
The Evaluator returns a number for each metric, read by the metric's lowercase name:

‎apps/docs/content/docs/workflows/blocks/router.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ Each route is a **title** and a **description** of when to choose it ("Route her
3535

3636
The model that makes the decision, defaulting to `claude-sonnet-4-6`. Stronger reasoning models route more accurately; a faster, cheaper model is fine when the routes are clearly distinct. Type or pick any supported model, or a local one through Ollama or VLLM. On hosted Sim the API key is supplied for you.
3737

38+
### Fallback models
39+
40+
Under **Additional fields**, add up to five models to try in order when a model request fails. With **Retry on fail** enabled, the selected model exhausts its tries first; each fallback is then tried once. Every attempt uses the same context and route definitions. `<router.model>` reports the model that answered. This also works for existing legacy Router blocks.
41+
42+
Hosted models on hosted Sim use workspace BYOK or platform credentials. On local or self-hosted installations, a fallback on another provider may need a secret selected on its row; same-provider fallbacks reuse the selected model's key. Auto cannot be a fallback. A completed `NO_MATCH` decision still takes the error path; it does not trigger another model request.
43+
3844
## Outputs
3945

4046
| Output | What it is |

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list.test.tsx‎

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
/**
2-
* @vitest-environment node
2+
* @vitest-environment jsdom
33
*/
4+
import { act } from 'react'
5+
import { createRoot } from 'react-dom/client'
46
import { renderToStaticMarkup } from 'react-dom/server'
5-
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import {
9+
getDeploymentShape,
10+
resetDeploymentShape,
11+
resolveDeploymentShape,
12+
seedDeploymentShape,
13+
} from '@/lib/core/config/deployment-shape'
614

715
const { subBlockValues, mockSetValue } = vi.hoisted(() => ({
816
subBlockValues: {
@@ -22,7 +30,7 @@ vi.mock('next/navigation', () => ({
2230
}))
2331

2432
vi.mock('@sim/emcn', () => ({
25-
Button: ({
33+
Chip: ({
2634
children,
2735
disabled,
2836
'aria-label': ariaLabel,
@@ -35,7 +43,7 @@ vi.mock('@sim/emcn', () => ({
3543
{children}
3644
</button>
3745
),
38-
Combobox: ({
46+
ChipCombobox: ({
3947
options,
4048
value,
4149
placeholder,
@@ -52,6 +60,21 @@ vi.mock('@sim/emcn', () => ({
5260
))}
5361
</div>
5462
),
63+
ChipDropdown: ({
64+
options,
65+
value,
66+
placeholder,
67+
}: {
68+
options: Array<{ value: string; label: string }>
69+
value?: string
70+
placeholder?: string
71+
}) => (
72+
<div data-combobox={placeholder} data-value={value}>
73+
{options.map((option) => (
74+
<span key={option.value}>{option.label}</span>
75+
))}
76+
</div>
77+
),
5578
Label: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
5679
Tooltip: {
5780
Root: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
@@ -102,6 +125,8 @@ vi.mock('@/stores/providers/store', () => ({
102125
}))
103126

104127
vi.mock('@/blocks/utils', () => ({
128+
shouldRequireApiKeyForModel: (model: string) =>
129+
model.startsWith('openrouter/') || (model.startsWith('gpt') && !getDeploymentShape().hosted),
105130
getModelOptions: () => [
106131
{ id: 'claude-sonnet-5', label: 'claude-sonnet-5' },
107132
{ id: 'gpt-5', label: 'gpt-5' },
@@ -117,7 +142,6 @@ vi.mock('@/lib/workflows/blocks/fallback-models', async (importOriginal) => {
117142
...actual,
118143
isViableFallbackModel: (model: string, primary: string) =>
119144
model !== 'sim-auto' && model !== primary,
120-
fallbackRowNeedsApiKey: (model: string) => model.startsWith('openrouter/'),
121145
getFallbackTuningKnobsToShow: (model: string) => (model === 'gpt-5' ? ['reasoningEffort'] : []),
122146
getTuningOptionsForModel: (model: string, knob: string) =>
123147
model === 'gpt-5' && knob === 'reasoningEffort' ? ['auto', 'low', 'high'] : null,
@@ -126,6 +150,16 @@ vi.mock('@/lib/workflows/blocks/fallback-models', async (importOriginal) => {
126150

127151
import { ModelFallbackList } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list'
128152

153+
beforeEach(() => {
154+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
155+
seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true })
156+
})
157+
158+
afterEach(() => {
159+
resetDeploymentShape()
160+
vi.unstubAllGlobals()
161+
})
162+
129163
function render(extra: Partial<React.ComponentProps<typeof ModelFallbackList>> = {}) {
130164
return renderToStaticMarkup(
131165
<ModelFallbackList blockId='block-1' subBlockId='fallbackModels' disabled={false} {...extra} />
@@ -186,6 +220,27 @@ describe('ModelFallbackList', () => {
186220
expect(html).toContain('Create Secret')
187221
})
188222

223+
it('updates key visibility when hosted context arrives after mount, without rewriting the rows', async () => {
224+
seedDeploymentShape({ ...resolveDeploymentShape(), hosted: false })
225+
subBlockValues.fallbackModels = [{ id: 'r1', model: 'gpt-5' }]
226+
const container = document.createElement('div')
227+
const root = createRoot(container)
228+
try {
229+
await act(async () => {
230+
root.render(<ModelFallbackList blockId='block-1' subBlockId='fallbackModels' />)
231+
})
232+
expect(container.querySelector('[data-combobox="Select a secret"]')).not.toBeNull()
233+
234+
await act(async () => {
235+
seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true })
236+
})
237+
expect(container.querySelector('[data-combobox="Select a secret"]')).toBeNull()
238+
expect(mockSetValue).not.toHaveBeenCalled()
239+
} finally {
240+
await act(async () => root.unmount())
241+
}
242+
})
243+
189244
it('shows a tuning field only for the knobs the helper says need one', () => {
190245
subBlockValues.fallbackModels = [
191246
{ id: 'r1', model: 'gpt-5', reasoningEffort: 'low' },

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list.tsx‎

Lines changed: 52 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
'use client'
22

33
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
4-
import { Button, Combobox, type ComboboxOption, Label, Tooltip } from '@sim/emcn'
4+
import { Chip, ChipCombobox, ChipDropdown, type ComboboxOption, Label, Tooltip } from '@sim/emcn'
55
import { ChevronDown, ChevronUp, Plus, Trash } from '@sim/emcn/icons'
66
import { generateShortId } from '@sim/utils/id'
77
import { useParams } from 'next/navigation'
8+
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
89
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
910
import {
1011
addFallbackRow,
@@ -96,6 +97,8 @@ const FallbackRow = memo(function FallbackRow({
9697
onMove,
9798
onRemove,
9899
}: FallbackRowProps) {
100+
/** Credential visibility follows the server-resolved shape, including late hydration. */
101+
useDeploymentShape()
99102
const modelOptions = useMemo(
100103
(): ComboboxOption[] =>
101104
viableOptions.map((option) => ({
@@ -105,104 +108,90 @@ const FallbackRow = memo(function FallbackRow({
105108
[viableOptions, takenModels, row.model]
106109
)
107110

108-
const { needsApiKey, tuningFields } = useMemo(() => {
109-
if (!row.model) return { needsApiKey: false, tuningFields: [] }
110-
return {
111-
needsApiKey: fallbackRowNeedsApiKey(row.model, primaryModel),
112-
tuningFields: getFallbackTuningKnobsToShow(row.model, primaryModel, primaryTuning).map(
113-
(knob) => ({
114-
knob,
115-
options: (getTuningOptionsForModel(row.model, knob) ?? []).map(
116-
(value): ComboboxOption => ({ label: value, value })
117-
),
118-
})
119-
),
120-
}
121-
}, [row.model, primaryModel, primaryTuning])
111+
const needsApiKey = fallbackRowNeedsApiKey(row.model, primaryModel)
112+
const tuningFields = getFallbackTuningKnobsToShow(row.model, primaryModel, primaryTuning).map(
113+
(knob) => ({
114+
knob,
115+
options: (getTuningOptionsForModel(row.model, knob) ?? []).map((value) => ({
116+
label: value,
117+
value,
118+
})),
119+
})
120+
)
122121

123122
/** Only a reference is ever shown; anything else that reached the store reads as unset. */
124123
const apiKeyValue = isWholeEnvVarReference(row.apiKey) ? row.apiKey : ''
125124

126125
return (
127126
<div
128127
data-fallback-row-id={row.id}
129-
className='overflow-visible rounded-sm border border-[var(--border-1)]'
128+
className='space-y-3 rounded-lg border border-[var(--border)] p-3'
130129
>
131-
<div className='flex items-center justify-between rounded-t-[4px] border-[var(--border-1)] border-b bg-[var(--surface-4)] px-2.5 py-[5px]'>
132-
<span className='text-[var(--text-tertiary)] text-sm'>{ordinalChoiceLabel(index)}</span>
133-
<div className='flex items-center gap-2'>
130+
<div className='flex items-center justify-between'>
131+
<span className='text-[var(--text-muted)] text-small'>{ordinalChoiceLabel(index)}</span>
132+
<div className='flex items-center'>
134133
{canMove && (
135134
<>
136135
<Tooltip.Root>
137136
<Tooltip.Trigger asChild>
138-
<Button
139-
variant='ghost'
137+
<Chip
138+
leftIcon={ChevronUp}
140139
onClick={() => onMove(row.id, -1)}
141140
disabled={readOnly || index === 0}
142-
className='h-auto p-0'
143141
aria-label='Move up'
144-
>
145-
<ChevronUp className='size-[14px]' />
146-
</Button>
142+
/>
147143
</Tooltip.Trigger>
148144
<Tooltip.Content>Move up</Tooltip.Content>
149145
</Tooltip.Root>
150146
<Tooltip.Root>
151147
<Tooltip.Trigger asChild>
152-
<Button
153-
variant='ghost'
148+
<Chip
149+
leftIcon={ChevronDown}
154150
onClick={() => onMove(row.id, 1)}
155151
disabled={readOnly || isLast}
156-
className='h-auto p-0'
157152
aria-label='Move down'
158-
>
159-
<ChevronDown className='size-[14px]' />
160-
</Button>
153+
/>
161154
</Tooltip.Trigger>
162155
<Tooltip.Content>Move down</Tooltip.Content>
163156
</Tooltip.Root>
164157
</>
165158
)}
166159
<Tooltip.Root>
167160
<Tooltip.Trigger asChild>
168-
<Button
169-
variant='ghost'
161+
<Chip
162+
leftIcon={Trash}
170163
onClick={() => onRemove(row.id)}
171164
disabled={readOnly}
172-
className='h-auto p-0 text-[var(--text-error)] hover-hover:text-[var(--text-error)]'
173165
aria-label='Remove fallback model'
174-
>
175-
<Trash className='size-[14px]' />
176-
</Button>
166+
/>
177167
</Tooltip.Trigger>
178168
<Tooltip.Content>Remove</Tooltip.Content>
179169
</Tooltip.Root>
180170
</div>
181171
</div>
182172

183-
<div className='flex flex-col gap-2 px-2.5 pt-1.5 pb-2.5'>
184-
<div className='flex flex-col gap-1.5'>
185-
<Label>Model</Label>
186-
<Combobox
187-
options={modelOptions}
188-
value={row.model}
189-
onChange={(model) => onChangeModel(row.id, model)}
190-
placeholder='Select a model'
191-
disabled={readOnly}
192-
searchable
193-
searchPlaceholder='Search models...'
194-
maxHeight={240}
195-
emptyMessage='No models available'
196-
/>
197-
</div>
173+
<div className='flex flex-col gap-3'>
174+
<ChipCombobox
175+
options={modelOptions}
176+
value={row.model}
177+
onChange={(model) => onChangeModel(row.id, model)}
178+
placeholder='Select a model'
179+
aria-label={`${ordinalChoiceLabel(index)} model`}
180+
disabled={readOnly}
181+
searchable
182+
searchPlaceholder='Search models...'
183+
maxHeight={240}
184+
emptyMessage='No models available'
185+
/>
198186
{needsApiKey && (
199187
<div className='flex flex-col gap-1.5'>
200188
<Label>API key</Label>
201-
<Combobox
189+
<ChipCombobox
202190
options={envVarOptions}
203191
value={apiKeyValue}
204192
onChange={(apiKey) => onChangeApiKey(row.id, apiKey)}
205193
placeholder='Select a secret'
194+
aria-label={`${ordinalChoiceLabel(index)} API key`}
206195
disabled={readOnly}
207196
searchable
208197
searchPlaceholder='Search secrets...'
@@ -214,12 +203,14 @@ const FallbackRow = memo(function FallbackRow({
214203
{tuningFields.map(({ knob, options }) => (
215204
<div key={knob} className='flex flex-col gap-1.5'>
216205
<Label>{FALLBACK_TUNING_LABELS[knob]}</Label>
217-
<Combobox
206+
<ChipDropdown
218207
options={options}
219208
value={row[knob] ?? options[0]?.value ?? ''}
220209
onChange={(value) => onChangeTuning(row.id, knob, value)}
221210
placeholder={`Select ${FALLBACK_TUNING_LABELS[knob].toLowerCase()}`}
211+
aria-label={`${ordinalChoiceLabel(index)} ${FALLBACK_TUNING_LABELS[knob].toLowerCase()}`}
222212
disabled={readOnly}
213+
className='w-full'
223214
/>
224215
</div>
225216
))}
@@ -250,6 +241,7 @@ export function ModelFallbackList({
250241
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : ''
251242
const { navigateToSettings } = useSettingsNavigation()
252243
const { isModelUsable } = usePermissionConfig()
244+
const deploymentShape = useDeploymentShape()
253245
const providers = useProvidersStore((state) => state.providers)
254246
const [storeValue, setStoreValue] = useSubBlockValue<FallbackModelEntry[]>(blockId, subBlockId)
255247
const [primaryModelValue] = useSubBlockValue<string>(blockId, 'model')
@@ -311,7 +303,7 @@ export function ModelFallbackList({
311303
value: option.id,
312304
...(option.icon ? { icon: option.icon } : {}),
313305
})),
314-
[primaryModel, isModelUsable, providers]
306+
[primaryModel, isModelUsable, providers, deploymentShape]
315307
)
316308

317309
const takenModels = useMemo(() => new Set(rows.map((row) => row.model).filter(Boolean)), [rows])
@@ -417,15 +409,15 @@ export function ModelFallbackList({
417409
/>
418410
))}
419411
{!readOnly && (
420-
<Button
421-
variant='ghost'
412+
<Chip
413+
variant='outline'
414+
leftIcon={Plus}
415+
fullWidth
422416
onClick={handleAdd}
423417
disabled={rows.length >= MAX_FALLBACK_MODELS}
424-
className='h-7 w-full justify-start gap-1.5 border border-[var(--border-1)] border-dashed text-[var(--text-muted)] text-small'
425418
>
426-
<Plus className='size-[14px]' />
427419
Add fallback model
428-
</Button>
420+
</Chip>
429421
)}
430422
</div>
431423
)

0 commit comments

Comments
 (0)