Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3f65674
feat(workflow-types): register the model-fallback-list subblock type
mzxchandra Sep 18, 2026
425bc7d
feat(agent): add the fallbackModels subblock and its shared helpers
mzxchandra Sep 18, 2026
2b93caa
feat(executor): walk fallback models when the agent's provider reques…
mzxchandra Sep 18, 2026
b6cc5e0
feat(workflows): validate, sanitize, index, and label fallback model …
mzxchandra Sep 18, 2026
259e391
feat(editor): fallback models list on the Agent block
mzxchandra Sep 18, 2026
89db473
docs(agent): document fallback models and their interplay with retry
mzxchandra Sep 18, 2026
54b8fb6
Merge remote-tracking branch 'origin/staging' into feat/agent-fallbac…
mzxchandra Sep 18, 2026
c77f940
fix(agent): harden fallback models after pre-landing review
mzxchandra Sep 18, 2026
0b35cd7
fix(agent): retry the selected model before falling back
mzxchandra Sep 18, 2026
dbf585c
Merge remote-tracking branch 'origin/staging' into feat/agent-fallbac…
mzxchandra Sep 18, 2026
93abc4c
fix(agent): address the pre-landing review of retry-then-fallback
mzxchandra Sep 18, 2026
c14bd4d
fix(agent): close the adversarial review's fallback findings
mzxchandra Sep 18, 2026
c5c1422
docs(agent): name the fallback row cap and the empty tuning field
mzxchandra Sep 18, 2026
a45ac65
fix(agent): address the first review round on the fallback models PR
mzxchandra Sep 18, 2026
b4481fe
Merge remote-tracking branch 'origin/staging' into feat/agent-fallbac…
mzxchandra Sep 18, 2026
2728156
docs(agent): say which side of a fallback the catalog may not know
mzxchandra Sep 18, 2026
0d90116
feat(workflows): share model fallbacks across LLM blocks
icecrasher321 Sep 18, 2026
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
5 changes: 4 additions & 1 deletion apps/docs/content/docs/workflows/blocks/agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Answer in two sentences, cite the doc you used, and never guess a price.

### Model

The model that runs the step. Defaults to `claude-sonnet-4-6`. Type or pick any model from OpenAI, Anthropic, Google, xAI, Groq, Cerebras, DeepSeek, Azure, AWS Bedrock, Google Vertex, or OpenRouter, or a local model through Ollama or VLLM.
The model that runs the step. Defaults to `claude-sonnet-5`. Type or pick any model from OpenAI, Anthropic, Google, xAI, Groq, Cerebras, DeepSeek, Azure, AWS Bedrock, Google Vertex, or OpenRouter, or a local model through Ollama or VLLM.

For a custom cloud deployment, enter its provider prefix and model ID: `azure/my-deployment`, `azure-anthropic/my-deployment`, `bedrock/my-inference-profile`, or `vertex/my-gemini-model`. The prefix selects the provider and shows its credential fields even when the ID is absent from the catalog. Bedrock accepts full inference profile ARNs after `bedrock/`; Vertex uses the Gemini API and accepts Google model resource names. The deployment must support the selected provider's API. Custom IDs have no catalog pricing or token limits.

Expand Down Expand Up @@ -87,6 +87,8 @@ Some settings live under advanced, or appear only for models that support them:
- **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.
- **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.
- **API key.** Your key for the chosen provider. Hidden on hosted Sim, which supplies one.
- **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.
- **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.

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

Expand Down Expand Up @@ -148,4 +150,5 @@ The Agent reads the message from Start with `<start.input>` and returns a result
<FAQ items={[
{ question: "How does max output tokens work with Anthropic models?", answer: "The Agent block uses each Anthropic model's full max output token limit by default (for example, 64,000 tokens). You can override this with the Max Output Tokens setting. For non-streaming requests that exceed the SDK's internal threshold, the provider automatically uses internal streaming to avoid timeouts." },
{ question: "Can I use the Agent block with a custom or self-hosted model?", answer: "Yes. Use any Ollama or VLLM-compatible model by typing the model name directly into the model combobox, as long as it exposes a compatible API endpoint." },
{ question: "What happens when my model's provider is down?", answer: "Add fallback models under Additional fields. When the request to the selected model fails, Sim tries the 2nd choice, then the 3rd, in order, and the block succeeds if any of them answers. The log detail shows the model that answered and the models it fell back from. Turn on Retry on fail as well to give the selected model a few tries first; the fallbacks are tried once each after its last try fails." },
]} />
6 changes: 6 additions & 0 deletions apps/docs/content/docs/workflows/blocks/evaluator.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ The content to score. Usually an earlier output like `<agent.content>`. Structur

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.

### Fallback models

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.

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.

## Outputs

The Evaluator returns a number for each metric, read by the metric's lowercase name:
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/content/docs/workflows/blocks/router.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ Each route is a **title** and a **description** of when to choose it ("Route her

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.

### Fallback models

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.

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.

## Outputs

| Output | What it is |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,9 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
if (span.tries !== undefined) metaEntries.push({ label: 'Tries', value: String(span.tries) })
if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider })
if (span.model) metaEntries.push({ label: 'Model', value: span.model })
for (const failedModel of span.modelFallbacks ?? []) {
metaEntries.push({ label: 'Failed model', value: failedModel })
}
if (span.finishReason) metaEntries.push({ label: 'Finish reason', value: span.finishReason })
const ttftFormatted = formatTtft(span.ttft)
if (ttftFormatted) metaEntries.push({ label: 'TTFT', value: ttftFormatted })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export { LongInput } from './long-input'
export { McpDynamicArgs } from './mcp-dynamic-args'
export { McpServerSelector, McpToolSelector } from './mcp-server-modal'
export { MessagesInput } from './messages-input'
export { ModelFallbackList } from './model-fallback-list'
export { maskSecretText, PASSWORD_MASKED_SUBBLOCK_TYPES } from './password-mask'
export { ResponseFormat } from './response'
export { ScheduleInfo } from './schedule-info'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ModelFallbackList } from './model-fallback-list'
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getDeploymentShape,
resetDeploymentShape,
resolveDeploymentShape,
seedDeploymentShape,
} from '@/lib/core/config/deployment-shape'

const { subBlockValues, mockSetValue } = vi.hoisted(() => ({
subBlockValues: {
model: 'claude-sonnet-5' as string,
fallbackModels: [] as Array<{
id: string
model: string
apiKey?: string
reasoningEffort?: string
}>,
},
mockSetValue: vi.fn(),
}))

vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-1' }),
}))

vi.mock('@sim/emcn', () => ({
Chip: ({
children,
disabled,
'aria-label': ariaLabel,
}: {
children?: React.ReactNode
disabled?: boolean
'aria-label'?: string
}) => (
<button type='button' disabled={disabled} aria-label={ariaLabel}>
{children}
</button>
),
ChipCombobox: ({
options,
value,
placeholder,
}: {
options: Array<{ value: string; label: string; disabled?: boolean }>
value?: string
placeholder?: string
}) => (
<div data-combobox={placeholder} data-value={value}>
{options.map((option) => (
<span key={option.value} data-disabled={option.disabled ? 'true' : undefined}>
{option.label}
</span>
))}
</div>
),
ChipDropdown: ({
options,
value,
placeholder,
}: {
options: Array<{ value: string; label: string }>
value?: string
placeholder?: string
}) => (
<div data-combobox={placeholder} data-value={value}>
{options.map((option) => (
<span key={option.value}>{option.label}</span>
))}
</div>
),
Label: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
Tooltip: {
Root: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
Trigger: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
Content: () => null,
},
}))

vi.mock('@sim/emcn/icons', () => ({
ChevronDown: () => null,
ChevronUp: () => null,
Plus: () => null,
Trash: () => null,
}))

vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value',
() => ({
useSubBlockValue: (_blockId: string, subBlockId: string) => [
subBlockId === 'model' || subBlockId === 'fallbackModels' ? subBlockValues[subBlockId] : null,
mockSetValue,
],
})
)

vi.mock('@/hooks/queries/environment', () => ({
usePersonalEnvironment: () => ({ data: { PERSONAL_KEY: 'x' } }),
useWorkspaceEnvironment: () => ({
data: { workspace: { OPENROUTER_API_KEY: 'x' }, personal: {}, conflicts: [] },
}),
}))

vi.mock('@/hooks/use-permission-config', () => ({
usePermissionConfig: () => ({ isModelUsable: (model: string) => model !== 'denied-model' }),
}))

vi.mock('@/hooks/use-settings-navigation', () => ({
useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }),
}))

vi.mock('@/lib/credentials/client-state', () => ({
writePendingCredentialCreateRequest: vi.fn(),
}))

vi.mock('@/stores/providers/store', () => ({
useProvidersStore: (selector: (state: { providers: object }) => unknown) =>
selector({ providers: {} }),
}))

vi.mock('@/blocks/utils', () => ({
shouldRequireApiKeyForModel: (model: string) =>
model.startsWith('openrouter/') || (model.startsWith('gpt') && !getDeploymentShape().hosted),
getModelOptions: () => [
{ id: 'claude-sonnet-5', label: 'claude-sonnet-5' },
{ id: 'gpt-5', label: 'gpt-5' },
{ id: 'denied-model', label: 'denied-model' },
{ id: 'openrouter/x', label: 'openrouter/x' },
{ id: 'sim-auto', label: 'Auto' },
],
}))

vi.mock('@/lib/workflows/blocks/fallback-models', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/workflows/blocks/fallback-models')>()
return {
...actual,
isViableFallbackModel: (model: string, primary: string) =>
model !== 'sim-auto' && model !== primary,
getFallbackTuningKnobsToShow: (model: string) => (model === 'gpt-5' ? ['reasoningEffort'] : []),
getTuningOptionsForModel: (model: string, knob: string) =>
model === 'gpt-5' && knob === 'reasoningEffort' ? ['auto', 'low', 'high'] : null,
}
})

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

beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true })
})

afterEach(() => {
resetDeploymentShape()
vi.unstubAllGlobals()
})

function render(extra: Partial<React.ComponentProps<typeof ModelFallbackList>> = {}) {
return renderToStaticMarkup(
<ModelFallbackList blockId='block-1' subBlockId='fallbackModels' disabled={false} {...extra} />
)
}

describe('ModelFallbackList', () => {
beforeEach(() => {
subBlockValues.model = 'claude-sonnet-5'
subBlockValues.fallbackModels = []
mockSetValue.mockReset()
})

it('renders only the add affordance when nothing is configured', () => {
const html = render()
expect(html).toContain('Add fallback model')
expect(html).not.toContain('choice')
})

it('labels rows as ordinal choices and offers viable, permitted models', () => {
subBlockValues.fallbackModels = [
{ id: 'r1', model: 'gpt-5' },
{ id: 'r2', model: '' },
]
const html = render()
expect(html).toContain('2nd choice')
expect(html).toContain('3rd choice')
expect(html).not.toContain('Auto')
expect(html).not.toContain('denied-model')
/** The primary is never offered. A model another row holds is disabled there, never in its own row. */
expect(html).not.toContain('>claude-sonnet-5<')
expect(html.match(/data-disabled="true">gpt-5</g)).toHaveLength(1)
expect(html.match(/>gpt-5</g)).toHaveLength(2)
expect(html).toContain('aria-label="Move up"')
})

it('renders no move controls for a single row and never shows a non-reference key', () => {
subBlockValues.fallbackModels = [
{ id: 'r1', model: 'openrouter/x', apiKey: 'sk-raw-through-socket' },
]
const html = render()
expect(html).not.toContain('aria-label="Move up"')
expect(html).not.toContain('sk-raw-through-socket')
expect(html).toContain('data-combobox="Select a secret" data-value=""')
})

it('asks for an environment variable only when the row model needs its own key', () => {
subBlockValues.fallbackModels = [{ id: 'r1', model: 'gpt-5' }]
expect(render()).not.toContain('data-combobox="Select a secret"')

subBlockValues.fallbackModels = [
{ id: 'r1', model: 'openrouter/x', apiKey: '{{OPENROUTER_API_KEY}}' },
]
const html = render()
expect(html).toContain('data-combobox="Select a secret"')
expect(html).toContain('data-value="{{OPENROUTER_API_KEY}}"')
expect(html).toContain('OPENROUTER_API_KEY')
expect(html).toContain('Create Secret')
})

it('updates key visibility when hosted context arrives after mount, without rewriting the rows', async () => {
seedDeploymentShape({ ...resolveDeploymentShape(), hosted: false })
subBlockValues.fallbackModels = [{ id: 'r1', model: 'gpt-5' }]
const container = document.createElement('div')
const root = createRoot(container)
try {
await act(async () => {
root.render(<ModelFallbackList blockId='block-1' subBlockId='fallbackModels' />)
})
expect(container.querySelector('[data-combobox="Select a secret"]')).not.toBeNull()

await act(async () => {
seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true })
})
expect(container.querySelector('[data-combobox="Select a secret"]')).toBeNull()
expect(mockSetValue).not.toHaveBeenCalled()
} finally {
await act(async () => root.unmount())
}
})

it('shows a tuning field only for the knobs the helper says need one', () => {
subBlockValues.fallbackModels = [
{ id: 'r1', model: 'gpt-5', reasoningEffort: 'low' },
{ id: 'r2', model: 'openrouter/x' },
]
const html = render()
expect(html).toContain('data-combobox="Select reasoning effort" data-value="low"')
expect(html.match(/Select reasoning effort/g)).toHaveLength(1)
expect(html).not.toContain('Thinking level')
})

it('gates a preview against the previewed primary, not the live block', () => {
/** The live block selects claude-sonnet-5; the previewed version selected gpt-5. */
const html = render({
isPreview: true,
previewValue: [{ id: 'r1', model: 'openrouter/x' }],
previewPrimary: { model: 'gpt-5' },
})
expect(html).not.toContain('>gpt-5<')
expect(html).toContain('>claude-sonnet-5<')
expect(html).not.toContain('Add fallback model')
})

it('disables the add affordance at the cap', () => {
subBlockValues.fallbackModels = Array.from({ length: 5 }, (_, i) => ({
id: `r${i}`,
model: `m-${i}`,
}))
const html = render()
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Add fallback model/)
})
})
Loading
Loading