Skip to content

Commit e54c5c1

Browse files
committed
feat: Add model fallbacks
1 parent d25cc42 commit e54c5c1

16 files changed

Lines changed: 1253 additions & 400 deletions

File tree

packages/opencode/src/agent/agent.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export const Info = Schema.Struct({
4242
}),
4343
),
4444
variant: Schema.optional(Schema.String),
45+
fallbacks: Schema.optional(
46+
Schema.Array(Schema.Struct({
47+
providerID: ProviderID,
48+
modelID: ModelID,
49+
})),
50+
),
4551
prompt: Schema.optional(Schema.String),
4652
options: Schema.Record(Schema.String, Schema.Unknown),
4753
steps: Schema.optional(Schema.Finite),
@@ -292,6 +298,9 @@ export const layer = Layer.effect(
292298
native: false,
293299
}
294300
if (value.model) item.model = Provider.parseModel(value.model)
301+
if (value.fallbacks) {
302+
item.fallbacks = value.fallbacks.map((f) => Provider.parseModel(f))
303+
}
295304
item.variant = value.variant ?? item.variant
296305
item.prompt = value.prompt ?? item.prompt
297306
item.description = value.description ?? item.description

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -877,6 +877,20 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
877877
}
878878
})
879879

880+
event.on("llm.fallback.triggered", (evt) => {
881+
toast.show({
882+
message: `Falling back to ${evt.properties.modelID} (${evt.properties.reason})`,
883+
variant: "warning",
884+
})
885+
})
886+
887+
event.on("llm.fallback.used", (evt) => {
888+
toast.show({
889+
message: `Switched to ${evt.properties.modelID}`,
890+
variant: "info",
891+
})
892+
})
893+
880894
event.on("session.error", (evt) => {
881895
const error = evt.properties.error
882896
if (error && typeof error === "object" && error.name === "MessageAbortedError") return

packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,18 +360,22 @@ function AssistantMessage(props: {
360360
}
361361

362362
function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) {
363-
const { theme } = useTheme()
363+
const { theme, subtleSyntax } = useTheme()
364+
const fallbackNotice = props.part.fallbackNotice
365+
const isFallback = fallbackNotice != null
366+
const fg = fallbackNotice === "resume" ? theme.success : isFallback ? theme.error : props.part.ignored ? theme.textMuted : theme.text
367+
const syntaxStyle = isFallback || props.part.ignored ? subtleSyntax() : props.syntax
364368
return (
365369
<Show when={props.part.text.trim()}>
366370
<box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
367371
<code
368372
filetype="markdown"
369373
drawUnstyledText={false}
370374
streaming={true}
371-
syntaxStyle={props.syntax}
375+
syntaxStyle={syntaxStyle}
372376
content={props.part.text.trim()}
373377
conceal={true}
374-
fg={theme.text}
378+
fg={fg}
375379
/>
376380
</box>
377381
</Show>

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,6 +1399,10 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
13991399
const sync = useSync()
14001400
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
14011401
const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID))
1402+
const providerName = createMemo(() => {
1403+
const p = ctx.providers()?.get(props.message.providerID)
1404+
return p?.name ?? props.message.providerID
1405+
})
14021406

14031407
const final = createMemo(() => {
14041408
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
@@ -1468,7 +1472,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
14681472
{" "}
14691473
</span>{" "}
14701474
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
1471-
<span style={{ fg: theme.textMuted }}> · {model()}</span>
1475+
<span style={{ fg: theme.textMuted }}> · {model()} ({providerName()})</span>
14721476
<Show when={duration()}>
14731477
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
14741478
</Show>
@@ -1524,17 +1528,21 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
15241528

15251529
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
15261530
const ctx = use()
1527-
const { theme, syntax } = useTheme()
1531+
const { theme, syntax, subtleSyntax } = useTheme()
1532+
const fallbackNotice = (props.part as any).fallbackNotice as "using" | "switch" | "resume" | undefined
1533+
const isFallback = fallbackNotice != null
1534+
const fg = fallbackNotice === "resume" ? theme.success : isFallback ? theme.error : (props.part as any).ignored ? theme.textMuted : theme.markdownText
1535+
const syntaxStyle = isFallback || (props.part as any).ignored ? subtleSyntax() : syntax()
15281536
return (
15291537
<Show when={props.part.text.trim()}>
15301538
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
15311539
<markdown
1532-
syntaxStyle={syntax()}
1540+
syntaxStyle={syntaxStyle}
15331541
streaming={true}
15341542
internalBlockMode="top-level"
15351543
content={props.part.text.trim()}
15361544
conceal={ctx.conceal()}
1537-
fg={theme.markdownText}
1545+
fg={fg}
15381546
bg={theme.background}
15391547
/>
15401548
</box>

packages/opencode/src/config/agent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ const AgentSchema = Schema.StructWithRest(
4646
}),
4747
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
4848
permission: Schema.optional(ConfigPermission.Info),
49+
fallbacks: Schema.optional(Schema.mutable(Schema.Array(ConfigModelID))).annotate({
50+
description: "Fallback models to try when the primary model fails, in provider/model format",
51+
}),
4952
}),
5053
[Schema.Record(Schema.String, Schema.Any)],
5154
)
@@ -65,6 +68,7 @@ const KNOWN_KEYS = new Set([
6568
"maxSteps",
6669
"options",
6770
"permission",
71+
"fallbacks",
6872
"disable",
6973
"tools",
7074
])

packages/opencode/src/config/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,12 @@ export const Info = Schema.Struct({
166166
model: Schema.optional(ConfigModelID).annotate({
167167
description: "Model to use in the format of provider/model, eg anthropic/claude-2",
168168
}),
169+
fallbacks: Schema.optional(Schema.mutable(Schema.Array(ConfigModelID))).annotate({
170+
description: "Fallback models to try when the primary model fails, in provider/model format",
171+
}),
172+
cooldown_seconds: Schema.optional(Schema.Number.check(Schema.isGreaterThan(0))).annotate({
173+
description: "Duration in seconds to put a provider/model in cooldown after a retryable error (default: 300)",
174+
}),
169175
small_model: Schema.optional(ConfigModelID).annotate({
170176
description: "Small model to use for tasks like title generation in the format of provider/model",
171177
}),

0 commit comments

Comments
 (0)