Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions packages/core/src/session-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ export namespace Reasoning {
export type Ended = typeof Ended.Type
}

export namespace Model {
export const Updated = EventV2.define({
type: "session.next.model.updated",
...options,
schema: {
...Base,
model: ModelV2.Ref,
},
})
export type Updated = typeof Updated.Type
}

export namespace Tool {
export namespace Input {
export const Started = EventV2.define({
Expand Down Expand Up @@ -366,6 +378,7 @@ export const All = Schema.Union(
[
AgentSwitched,
ModelSwitched,
Model.Updated,
Prompted,
Synthetic,
Shell.Started,
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/session-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ export class AssistantTool extends Schema.Class<AssistantTool>("Session.Message.
export class AssistantText extends Schema.Class<AssistantText>("Session.Message.Assistant.Text")({
type: Schema.Literal("text"),
text: Schema.String,
ignored: Schema.optional(Schema.Boolean),
synthetic: Schema.optional(Schema.Boolean),
fallbackNotice: Schema.optional(
Schema.Union([Schema.Literal("using"), Schema.Literal("switch"), Schema.Literal("resume")]),
),
}) {}

export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Session.Message.Assistant.Reasoning")({
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ export const Info = Schema.Struct({
}),
),
variant: Schema.optional(Schema.String),
fallbacks: Schema.optional(
Schema.Array(
Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
}),
),
),
prompt: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
steps: Schema.optional(Schema.Finite),
Expand Down Expand Up @@ -292,6 +300,9 @@ export const layer = Layer.effect(
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
if (value.fallbacks) {
item.fallbacks = value.fallbacks.map((f) => Provider.parseModel(f))
}
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
})

event.on("llm.fallback.triggered", (evt) => {
toast.show({
message: `Falling back to ${evt.properties.modelID} (${evt.properties.reason})`,
variant: "warning",
})
})

event.on("session.error", (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
})
break
case "session.next.model.updated":
update(event.properties.sessionID, (draft) => {
const match =
activeAssistant(draft) ??
[...draft].reverse().find((m): m is SessionMessageAssistant => m.type === "assistant")
if (match) match.model = event.properties.model
})
break
case "session.next.step.failed":
update(event.properties.sessionID, (draft) => {
const currentAssistant = activeAssistant(draft)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,18 +365,28 @@ function AssistantMessage(props: {
}

function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) {
const { theme } = useTheme()
const { theme, subtleSyntax } = useTheme()
const fallbackNotice = props.part.fallbackNotice
const isFallback = fallbackNotice != null
const fg = fallbackNotice === "resume"
? theme.success
: isFallback
? theme.error
: props.part.ignored
? theme.textMuted
: theme.text
const syntaxStyle = isFallback || props.part.ignored ? subtleSyntax() : props.syntax
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={props.syntax}
syntaxStyle={syntaxStyle}
content={props.part.text.trim()}
conceal={true}
fg={theme.text}
fg={fg}
/>
</box>
</Show>
Expand Down
24 changes: 20 additions & 4 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1408,6 +1408,10 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
const sync = useSync()
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID))
const providerName = createMemo(() => {
const p = ctx.providers()?.get(props.message.providerID)
return p?.name ?? props.message.providerID
})

const final = createMemo(() => {
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
Expand Down Expand Up @@ -1477,7 +1481,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
▣{" "}
</span>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
<span style={{ fg: theme.textMuted }}> · {model()}</span>
<span style={{ fg: theme.textMuted }}> · {model()} ({providerName()})</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show>
Expand Down Expand Up @@ -1578,18 +1582,30 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass

function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
const ctx = use()
const { theme, syntax } = useTheme()
const { theme, syntax, subtleSyntax } = useTheme()
const fallbackNotice = () => (props.part as { fallbackNotice?: "using" | "switch" | "resume" }).fallbackNotice
const isFallback = () => fallbackNotice() != null
const fg = () =>
fallbackNotice() === "resume"
? theme.success
: isFallback()
? theme.error
: (props.part as { ignored?: boolean }).ignored
? theme.textMuted
: theme.markdownText
const syntaxStyle = () =>
isFallback() || (props.part as { ignored?: boolean }).ignored ? subtleSyntax() : syntax()
return (
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<markdown
syntaxStyle={syntax()}
syntaxStyle={syntaxStyle()}
streaming={true}
internalBlockMode="top-level"
content={props.part.text.trim()}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
fg={fg()}
bg={theme.background}
/>
</box>
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ const AgentSchema = Schema.StructWithRest(
}),
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
permission: Schema.optional(ConfigPermission.Info),
fallbacks: Schema.optional(Schema.mutable(Schema.Array(ConfigModelID))).annotate({
description: "Fallback models to try when the primary model fails, in provider/model format",
}),
}),
[Schema.Record(Schema.String, Schema.Any)],
)
Expand All @@ -65,6 +68,7 @@ const KNOWN_KEYS = new Set([
"maxSteps",
"options",
"permission",
"fallbacks",
"disable",
"tools",
])
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ export const Info = Schema.Struct({
model: Schema.optional(ConfigModelID).annotate({
description: "Model to use in the format of provider/model, eg anthropic/claude-2",
}),
fallbacks: Schema.optional(Schema.mutable(Schema.Array(ConfigModelID))).annotate({
description: "Fallback models to try when the primary model fails, in provider/model format",
}),
cooldown_seconds: Schema.optional(Schema.Number.check(Schema.isGreaterThan(0))).annotate({
description: "Duration in seconds to put a provider/model in cooldown after a retryable error (default: 300)",
}),
small_model: Schema.optional(ConfigModelID).annotate({
description: "Small model to use for tasks like title generation in the format of provider/model",
}),
Expand Down
Loading
Loading