feat(personas): add model config, TUI banner, and fallback system

- Configure per-persona models with temperature/top_p:
  - Zee: cerebras/zai-glm-4.7 (0.8/0.9)
  - Stanley: openrouter/xai/grok-4.1-fast (0.7/0.85)
  - Johny: antigravity/claude-opus-4-5 (0.8/0.9)
- Configure internal agents:
  - title: cerebras/gpt-oss-120b (0.5)
  - compaction: antigravity/gemini-3-flash (0.3)
- Add ASCII art banner on startup with Tab switching
- Sort agents reverse alphabetically (Zee, Stanley, Johny)
- Add provider fallback system with circuit breaker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Artur Do Lago
2026-01-09 15:33:57 +01:00
parent 00691cd28e
commit 171e2db8b3
12 changed files with 1307 additions and 22 deletions
+3
View File
@@ -1,6 +1,9 @@
---
description: Learning system - study, knowledge graph, spaced repetition
color: "#c4f042"
model: antigravity/claude-opus-4-5
temperature: 0.8
top_p: 0.9
includes:
- johny
- personas
+3
View File
@@ -1,6 +1,9 @@
---
description: Investing system - markets, portfolio, SEC filings, NautilusTrader
color: "#ffcb47"
model: openrouter/xai/grok-4.1-fast
temperature: 0.7
top_p: 0.85
includes:
- stanley
- personas
+3
View File
@@ -1,6 +1,9 @@
---
description: Personal life assistant - memory, messaging, calendar, contacts
color: "#8ae8ff"
model: cerebras/zai-glm-4.7
temperature: 0.8
top_p: 0.9
includes:
- zee
- personas
+10
View File
@@ -13,4 +13,14 @@
"github-triage": false,
"github-pr-search": false,
},
"agent": {
"title": {
"model": "cerebras/gpt-oss-120b",
"temperature": 0.5
},
"compaction": {
"model": "antigravity/gemini-3-flash",
"temperature": 0.3
}
},
}
@@ -34,7 +34,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const agents = createMemo(() =>
sync.data.agent
.filter((x) => x.mode !== "subagent" && !x.hidden)
.sort((a, b) => b.name.localeCompare(a.name)), // Reverse alpha: Zee, Stanley, Johny
)
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
@@ -7,6 +7,7 @@ import {
For,
Match,
on,
onCleanup,
Show,
Switch,
useContext,
@@ -77,6 +78,62 @@ import { formatTranscript } from "../../util/transcript"
addDefaultParsers(parsers.parsers)
// ASCII art for Personas (Figlet-style)
const ASCII_ART: Record<string, string[]> = {
zee: [
"███████╗███████╗███████╗",
"╚══███╔╝██╔════╝██╔════╝",
" ███╔╝ █████╗ █████╗ ",
" ███╔╝ ██╔══╝ ██╔══╝ ",
"███████╗███████╗███████╗",
"╚══════╝╚══════╝╚══════╝",
],
stanley: [
"███████╗████████╗ █████╗ ███╗ ██╗██╗ ███████╗██╗ ██╗",
"██╔════╝╚══██╔══╝██╔══██╗████╗ ██║██║ ██╔════╝╚██╗ ██╔╝",
"███████╗ ██║ ███████║██╔██╗ ██║██║ █████╗ ╚████╔╝ ",
"╚════██║ ██║ ██╔══██║██║╚██╗██║██║ ██╔══╝ ╚██╔╝ ",
"███████║ ██║ ██║ ██║██║ ╚████║███████╗███████╗ ██║ ",
"╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝ ╚═╝ ",
],
johny: [
" ██╗ ██████╗ ██╗ ██╗███╗ ██╗██╗ ██╗",
" ██║██╔═══██╗██║ ██║████╗ ██║╚██╗ ██╔╝",
" ██║██║ ██║███████║██╔██╗ ██║ ╚████╔╝ ",
"██ ██║██║ ██║██╔══██║██║╚██╗██║ ╚██╔╝ ",
"╚█████╔╝╚██████╔╝██║ ██║██║ ╚████║ ██║ ",
" ╚════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ",
],
}
function AgentBanner() {
const local = useLocal()
const { theme } = useTheme()
const agent = createMemo(() => local.agent.current())
const color = createMemo(() => local.agent.color(agent().name))
const art = createMemo(() => ASCII_ART[agent().name.toLowerCase()] || [])
return (
<box flexDirection="column" alignItems="center" justifyContent="center" flexGrow={1} gap={1}>
{/* ASCII Art Name */}
<box flexDirection="column" alignItems="center">
<For each={art()}>
{(line) => (
<text fg={color()} bold={true}>
{line}
</text>
)}
</For>
</box>
{/* Hint */}
<text fg={theme.textMuted} dimColor={true}>
Tab to switch · Enter to start
</text>
</box>
)
}
class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}
@@ -189,6 +246,34 @@ export function Session() {
const toast = useToast()
const sdk = useSDK()
// Handle fallback notifications
// Note: fallback.used is a new event type not yet in SDK types
createEffect(() => {
const unsub = sdk.event.listen((e) => {
const event = e.details as { type: string; properties: Record<string, unknown> }
if (event.type === "fallback.used") {
const props = event.properties as {
sessionID: string
originalProvider: string
originalModel: string
fallbackProvider: string
fallbackModel: string
reason: string
}
// Only show toast for current session
if (props.sessionID === route.sessionID) {
toast.show({
variant: "warning",
title: "Model Fallback",
message: `Switched from ${props.originalProvider}/${props.originalModel} to ${props.fallbackProvider}/${props.fallbackModel}`,
duration: 5000,
})
}
}
})
onCleanup(unsub)
})
// Handle initial prompt from fork
createEffect(() => {
if (route.initialPrompt && prompt) {
@@ -963,25 +1048,29 @@ export function Session() {
<Show when={!sidebarVisible() || !wide()}>
<Header />
</Show>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.backgroundElement,
foregroundColor: theme.border,
},
}}
stickyScroll={true}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
<Show
when={messages().length > 0}
fallback={<AgentBanner />}
>
<For each={messages()}>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.backgroundElement,
foregroundColor: theme.border,
},
}}
stickyScroll={true}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={messages()}>
{(message, index) => (
<Switch>
<Match when={message.id === revert()?.messageID}>
@@ -1075,8 +1164,9 @@ export function Session() {
</Match>
</Switch>
)}
</For>
</scrollbox>
</For>
</scrollbox>
</Show>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
+56
View File
@@ -1074,6 +1074,62 @@ export namespace Config {
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
})
.optional(),
fallback: z
.object({
enabled: z
.boolean()
.default(true)
.describe("Enable automatic fallback to alternative providers/models on failure"),
maxAttempts: z
.number()
.int()
.positive()
.default(3)
.describe("Maximum total attempts including the original request"),
circuitBreaker: z
.object({
failureThreshold: z
.number()
.int()
.positive()
.default(3)
.describe("Number of consecutive failures before opening the circuit"),
successThreshold: z
.number()
.int()
.positive()
.default(2)
.describe("Number of consecutive successes in half_open to close the circuit"),
timeout: z
.number()
.int()
.positive()
.default(60000)
.describe("Time in ms before transitioning from open to half_open"),
})
.optional()
.describe("Circuit breaker configuration for provider health management"),
rules: z
.array(
z.object({
condition: z
.enum(["rate_limit", "unavailable", "timeout", "error", "circuit_open", "any"])
.describe("Error condition that triggers this rule"),
fallbacks: z
.array(z.string())
.describe("Fallback options - 'providerID/modelID' or just 'providerID' for equivalent tier"),
}),
)
.optional()
.describe("Fallback rules in priority order"),
costAware: z
.boolean()
.default(false)
.describe("Skip fallbacks that cost more than the original model"),
notifyOnFallback: z.boolean().default(true).describe("Emit event/notification when fallback is used"),
})
.optional()
.describe("Provider/model fallback configuration for automatic failover"),
})
.strict()
.meta({
@@ -0,0 +1,283 @@
import { Log } from "@/util/log"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { z } from "zod"
/**
* Circuit Breaker pattern for provider health management.
*
* States:
* - CLOSED: Normal operation, requests pass through
* - OPEN: Provider is failing, requests are blocked
* - HALF_OPEN: Testing if provider has recovered
*
* Adapted from tiara's circuit-breaker.ts but standalone for agent-core.
*/
export namespace CircuitBreaker {
const log = Log.create({ service: "circuit-breaker" })
export type State = "closed" | "open" | "half_open"
export type Config = {
/** Number of consecutive failures before opening the circuit (default: 3) */
failureThreshold: number
/** Number of consecutive successes in half_open to close the circuit (default: 2) */
successThreshold: number
/** Time in ms before transitioning from open to half_open (default: 60000) */
timeout: number
/** Max concurrent requests in half_open state (default: 1) */
halfOpenLimit: number
}
export type ProviderState = {
state: State
failures: number
successes: number
lastFailure?: number
lastError?: string
halfOpenRequests: number
}
// Events
export const Event = {
StateChanged: BusEvent.define(
"circuit-breaker.state-changed",
z.object({
providerID: z.string(),
previousState: z.enum(["closed", "open", "half_open"]),
newState: z.enum(["closed", "open", "half_open"]),
reason: z.string(),
}),
),
}
// Default configuration
const DEFAULT_CONFIG: Config = {
failureThreshold: 3,
successThreshold: 2,
timeout: 60_000,
halfOpenLimit: 1,
}
// In-memory state (resets on restart)
const breakers = new Map<string, ProviderState>()
let config: Config = { ...DEFAULT_CONFIG }
/**
* Configure circuit breaker settings.
*/
export function configure(newConfig: Partial<Config>): void {
config = { ...DEFAULT_CONFIG, ...newConfig }
log.info("configured", { config })
}
/**
* Get the current configuration.
*/
export function getConfig(): Config {
return { ...config }
}
/**
* Get or initialize state for a provider.
*/
function getOrCreateState(providerID: string): ProviderState {
let state = breakers.get(providerID)
if (!state) {
state = {
state: "closed",
failures: 0,
successes: 0,
halfOpenRequests: 0,
}
breakers.set(providerID, state)
}
return state
}
/**
* Check if a provider can accept requests.
*
* @returns true if requests should be allowed, false if circuit is open
*/
export function canUse(providerID: string): boolean {
const state = getOrCreateState(providerID)
switch (state.state) {
case "closed":
return true
case "open": {
// Check if timeout has passed to transition to half_open
if (state.lastFailure && Date.now() - state.lastFailure >= config.timeout) {
transitionTo(providerID, state, "half_open", "timeout expired")
return state.halfOpenRequests < config.halfOpenLimit
}
return false
}
case "half_open":
// Allow limited requests in half_open
return state.halfOpenRequests < config.halfOpenLimit
}
}
/**
* Record a successful request to a provider.
*/
export function recordSuccess(providerID: string): void {
const state = getOrCreateState(providerID)
switch (state.state) {
case "closed":
// Reset failure count on success
state.failures = 0
break
case "half_open":
state.halfOpenRequests = Math.max(0, state.halfOpenRequests - 1)
state.successes++
if (state.successes >= config.successThreshold) {
transitionTo(providerID, state, "closed", "success threshold reached")
}
break
case "open":
// Shouldn't happen, but handle gracefully
log.warn("success recorded in open state", { providerID })
break
}
log.info("success", {
providerID,
state: state.state,
successes: state.successes,
failures: state.failures,
})
}
/**
* Record a failed request to a provider.
*/
export function recordFailure(providerID: string, error: Error): void {
const state = getOrCreateState(providerID)
state.lastFailure = Date.now()
state.lastError = error.message
switch (state.state) {
case "closed":
state.failures++
if (state.failures >= config.failureThreshold) {
transitionTo(providerID, state, "open", `failure threshold reached: ${error.message}`)
}
break
case "half_open":
state.halfOpenRequests = Math.max(0, state.halfOpenRequests - 1)
// Any failure in half_open immediately opens the circuit
transitionTo(providerID, state, "open", `failure in half_open: ${error.message}`)
break
case "open":
// Already open, just update failure info
state.failures++
break
}
log.info("failure", {
providerID,
state: state.state,
failures: state.failures,
error: error.message,
})
}
/**
* Increment half-open request counter when starting a request.
*/
export function startHalfOpenRequest(providerID: string): void {
const state = getOrCreateState(providerID)
if (state.state === "half_open") {
state.halfOpenRequests++
}
}
/**
* Transition to a new state.
*/
function transitionTo(providerID: string, state: ProviderState, newState: State, reason: string): void {
const previousState = state.state
state.state = newState
// Reset counters on transition
if (newState === "closed") {
state.failures = 0
state.successes = 0
state.halfOpenRequests = 0
} else if (newState === "half_open") {
state.successes = 0
state.halfOpenRequests = 0
} else if (newState === "open") {
state.successes = 0
state.halfOpenRequests = 0
}
log.info("state transition", {
providerID,
previousState,
newState,
reason,
})
Bus.publish(Event.StateChanged, {
providerID,
previousState,
newState,
reason,
})
}
/**
* Get the current state for a provider.
*/
export function getState(providerID: string): ProviderState {
return { ...getOrCreateState(providerID) }
}
/**
* Get all provider states.
*/
export function getAllStates(): Record<string, ProviderState> {
const result: Record<string, ProviderState> = {}
for (const [id, state] of breakers) {
result[id] = { ...state }
}
return result
}
/**
* Reset state for a specific provider.
*/
export function reset(providerID: string): void {
breakers.delete(providerID)
log.info("reset", { providerID })
}
/**
* Reset all circuit breakers.
*/
export function resetAll(): void {
breakers.clear()
log.info("reset all")
}
/**
* Force a provider into a specific state (for testing/admin).
*/
export function forceState(providerID: string, newState: State): void {
const state = getOrCreateState(providerID)
transitionTo(providerID, state, newState, "forced by admin")
}
}
@@ -0,0 +1,281 @@
import { Log } from "@/util/log"
import { Provider } from "./provider"
/**
* Model Equivalence Mapper - Maps models across providers by capability tier.
*
* When a provider fails, this module finds equivalent models from other providers
* that can serve as fallbacks with similar capabilities.
*/
export namespace ModelEquivalence {
const log = Log.create({ service: "model-equivalence" })
/**
* Capability tiers from highest to lowest.
*/
export type Tier = "flagship" | "standard" | "fast" | "mini"
/**
* Built-in tier mappings. Models listed first are preferred within each tier.
* Format: "providerID/modelID" or patterns like "anthropic/claude-opus*"
*/
const DEFAULT_TIERS: Record<Tier, string[]> = {
flagship: [
"anthropic/claude-opus-4-5",
"anthropic/claude-opus-4",
"openai/gpt-5",
"openai/o3",
"google/gemini-2.5-pro",
"google/gemini-2.0-pro",
],
standard: [
"anthropic/claude-sonnet-4",
"anthropic/claude-sonnet-3-5",
"openai/gpt-4.1",
"openai/gpt-4o",
"google/gemini-2.5-flash",
"google/gemini-2.0-flash",
"mistral/mistral-large",
],
fast: [
"anthropic/claude-haiku-4",
"anthropic/claude-haiku-3-5",
"openai/gpt-4.1-mini",
"openai/gpt-4o-mini",
"google/gemini-2.0-flash-lite",
"mistral/mistral-small",
"groq/llama-3.3-70b",
],
mini: [
"anthropic/claude-haiku-3",
"openai/gpt-4o-mini",
"google/gemini-2.0-flash-lite",
"groq/llama-3.1-8b",
],
}
// Custom tiers from config (merged with defaults)
let customTiers: Record<Tier, string[]> = { ...DEFAULT_TIERS }
/**
* Configure custom tier mappings.
*/
export function configure(tiers: Partial<Record<Tier, string[]>>): void {
customTiers = {
flagship: [...(tiers.flagship ?? DEFAULT_TIERS.flagship)],
standard: [...(tiers.standard ?? DEFAULT_TIERS.standard)],
fast: [...(tiers.fast ?? DEFAULT_TIERS.fast)],
mini: [...(tiers.mini ?? DEFAULT_TIERS.mini)],
}
log.info("configured", { tiers: Object.keys(customTiers) })
}
/**
* Get the tier mappings.
*/
export function getTiers(): Record<Tier, string[]> {
return { ...customTiers }
}
/**
* Normalize model ID for matching.
* Handles variations like "claude-opus-4-5" vs "claude-opus-4.5"
*/
function normalizeModelID(model: string): string {
return model
.toLowerCase()
.replace(/\./g, "-") // dots to dashes
.replace(/-+/g, "-") // collapse multiple dashes
.replace(/latest$/, "") // remove "latest" suffix
}
/**
* Check if a model matches a pattern.
* Supports exact match and prefix patterns (e.g., "anthropic/claude-opus*")
*/
function matchesPattern(model: string, pattern: string): boolean {
const normalizedModel = normalizeModelID(model)
const normalizedPattern = normalizeModelID(pattern)
if (normalizedPattern.endsWith("*")) {
const prefix = normalizedPattern.slice(0, -1)
return normalizedModel.startsWith(prefix)
}
return normalizedModel === normalizedPattern || normalizedModel.startsWith(normalizedPattern + "-")
}
/**
* Get the tier for a model.
*
* @param model Full model string "providerID/modelID" or just "modelID"
* @returns The tier, or undefined if model isn't in any tier
*/
export function getTier(model: string): Tier | undefined {
for (const [tier, models] of Object.entries(customTiers) as [Tier, string[]][]) {
for (const pattern of models) {
if (matchesPattern(model, pattern)) {
return tier
}
}
}
// Try to infer tier from model name patterns
const lower = model.toLowerCase()
if (lower.includes("opus") || lower.includes("gpt-5") || lower.includes("o3") || lower.includes("pro")) {
return "flagship"
}
if (lower.includes("sonnet") || lower.includes("4o") || lower.includes("4.1") || lower.includes("flash")) {
return "standard"
}
if (lower.includes("haiku") || lower.includes("mini") || lower.includes("small")) {
return "fast"
}
return undefined
}
/**
* Get all models in the same tier as the given model.
*
* @param model Full model string "providerID/modelID"
* @returns Array of equivalent models, excluding the input model
*/
export function getEquivalents(model: string): string[] {
const tier = getTier(model)
if (!tier) {
log.warn("no tier found", { model })
return []
}
return customTiers[tier].filter((m) => !matchesPattern(model, m))
}
/**
* Find a fallback model from a different provider.
*
* @param model The original model that failed
* @param excludeProviders Providers to exclude (already tried or failing)
* @param preferredProviders Optional ordered list of preferred providers
* @returns A fallback model string, or undefined if none available
*/
export async function findFallback(
model: string,
excludeProviders: string[],
preferredProviders?: string[],
): Promise<string | undefined> {
const tier = getTier(model)
if (!tier) {
log.warn("cannot find fallback - no tier for model", { model })
return undefined
}
// Get available providers
const providers = await Provider.list()
const availableProviderIDs = new Set(
Object.values(providers)
.filter((p) => !excludeProviders.includes(p.id))
.map((p) => p.id),
)
// Order candidates by preference
const tieredModels = customTiers[tier]
const candidates: string[] = []
// First, add preferred providers' models
if (preferredProviders) {
for (const providerID of preferredProviders) {
if (availableProviderIDs.has(providerID)) {
for (const m of tieredModels) {
if (m.startsWith(providerID + "/")) {
candidates.push(m)
}
}
}
}
}
// Then add remaining models
for (const m of tieredModels) {
if (!candidates.includes(m)) {
const [providerID] = m.split("/")
if (availableProviderIDs.has(providerID)) {
candidates.push(m)
}
}
}
// Exclude the original model
const filtered = candidates.filter((m) => !matchesPattern(model, m))
// Verify model exists
for (const candidate of filtered) {
const [providerID, modelID] = candidate.split("/")
try {
await Provider.getModel(providerID, modelID)
log.info("found fallback", { original: model, fallback: candidate, tier })
return candidate
} catch {
// Model not available, try next
continue
}
}
// Try lower tier if no same-tier fallback found
const tierOrder: Tier[] = ["flagship", "standard", "fast", "mini"]
const currentTierIndex = tierOrder.indexOf(tier)
for (let i = currentTierIndex + 1; i < tierOrder.length; i++) {
const lowerTier = tierOrder[i]
const lowerModels = customTiers[lowerTier]
for (const m of lowerModels) {
const [providerID, modelID] = m.split("/")
if (!availableProviderIDs.has(providerID)) continue
try {
await Provider.getModel(providerID, modelID)
log.info("found lower-tier fallback", {
original: model,
fallback: m,
originalTier: tier,
fallbackTier: lowerTier,
})
return m
} catch {
continue
}
}
}
log.warn("no fallback found", { model, excludeProviders, tier })
return undefined
}
/**
* Parse a model string into provider and model IDs.
*/
export function parseModel(model: string): { providerID: string; modelID: string } {
const [providerID, ...rest] = model.split("/")
return {
providerID,
modelID: rest.join("/"),
}
}
/**
* Check if two models are equivalent (same tier).
*/
export function areEquivalent(model1: string, model2: string): boolean {
const tier1 = getTier(model1)
const tier2 = getTier(model2)
return tier1 !== undefined && tier1 === tier2
}
/**
* Get all tiers in order from highest to lowest capability.
*/
export function getTierOrder(): Tier[] {
return ["flagship", "standard", "fast", "mini"]
}
}
@@ -0,0 +1,307 @@
import { Log } from "@/util/log"
import { MessageV2 } from "@/session/message-v2"
import { ModelEquivalence } from "./equivalence"
import type { NamedError } from "@opencode-ai/util/error"
/**
* Fallback Chain - User-configurable fallback sequences.
*
* Determines which provider/model to try next when the current one fails,
* based on the error type and configured rules.
*/
export namespace FallbackChain {
const log = Log.create({ service: "fallback-chain" })
/**
* Error conditions that can trigger fallback.
*/
export type ErrorCondition = "rate_limit" | "unavailable" | "timeout" | "error" | "circuit_open" | "any"
/**
* A fallback rule that maps an error condition to fallback options.
*/
export type Rule = {
/** Error condition that triggers this rule */
condition: ErrorCondition
/** Fallback options - can be "providerID/modelID" or just "providerID" (use equivalent tier) */
fallbacks: string[]
}
/**
* Configuration for the fallback chain.
*/
export type Config = {
/** Whether fallback is enabled */
enabled: boolean
/** Maximum total attempts including the original (default: 3) */
maxAttempts: number
/** Fallback rules in priority order */
rules: Rule[]
/** Skip fallbacks that cost more than the original model (default: false) */
costAware?: boolean
/** Emit event when fallback is used (default: true) */
notifyOnFallback?: boolean
}
/**
* Default fallback rules.
*/
export const DEFAULT_RULES: Rule[] = [
{
condition: "rate_limit",
fallbacks: ["openai", "google", "anthropic"],
},
{
condition: "unavailable",
fallbacks: ["openai", "google", "anthropic"],
},
{
condition: "timeout",
fallbacks: ["openai", "google"],
},
{
condition: "circuit_open",
fallbacks: ["openai", "google", "anthropic"],
},
{
condition: "any",
fallbacks: ["openai"],
},
]
/**
* Default configuration.
*/
export const DEFAULT_CONFIG: Config = {
enabled: true,
maxAttempts: 3,
rules: DEFAULT_RULES,
costAware: false,
notifyOnFallback: true,
}
/**
* Classify an error into an ErrorCondition.
*/
export function classifyError(error: Error | ReturnType<NamedError["toObject"]>): ErrorCondition {
// Handle Error objects
const message = "message" in error ? error.message : ""
const lowerMessage = message.toLowerCase()
// Check for timeout
if (
lowerMessage.includes("timeout") ||
lowerMessage.includes("timed out") ||
lowerMessage.includes("etimedout") ||
lowerMessage.includes("aborted")
) {
return "timeout"
}
// Check for circuit breaker
if (lowerMessage.includes("circuit_open") || lowerMessage.includes("circuit open")) {
return "circuit_open"
}
// Check APIError for detailed classification
if ("data" in error && error.data) {
const data = error.data as Record<string, unknown>
// Check isRetryable flag and status code
if (data.statusCode === 429 || data.statusCode === "429") {
return "rate_limit"
}
if (data.statusCode === 503 || data.statusCode === "503") {
return "unavailable"
}
if (data.statusCode === 502 || data.statusCode === 504) {
return "unavailable"
}
// Check message content
if (typeof data.message === "string") {
const errorMessage = data.message.toLowerCase()
if (errorMessage.includes("rate") && errorMessage.includes("limit")) {
return "rate_limit"
}
if (errorMessage.includes("overloaded") || errorMessage.includes("capacity")) {
return "rate_limit"
}
if (errorMessage.includes("unavailable") || errorMessage.includes("exhausted")) {
return "unavailable"
}
}
}
// Try to parse JSON error body
try {
const json = JSON.parse(message)
if (json.type === "error") {
if (json.error?.type === "too_many_requests" || json.error?.code?.includes("rate_limit")) {
return "rate_limit"
}
if (json.error?.type === "server_error" || json.error?.type === "overloaded_error") {
return "unavailable"
}
}
if (json.code?.includes("exhausted") || json.code?.includes("unavailable")) {
return "unavailable"
}
} catch {
// Not JSON, continue
}
// Check for rate limit patterns
if (lowerMessage.includes("rate") && lowerMessage.includes("limit")) {
return "rate_limit"
}
if (lowerMessage.includes("too many requests") || lowerMessage.includes("429")) {
return "rate_limit"
}
if (lowerMessage.includes("overloaded") || lowerMessage.includes("capacity")) {
return "rate_limit"
}
// Check for unavailable patterns
if (
lowerMessage.includes("unavailable") ||
lowerMessage.includes("503") ||
lowerMessage.includes("502") ||
lowerMessage.includes("bad gateway")
) {
return "unavailable"
}
// Default to generic error
return "error"
}
/**
* Find a matching rule for the given error condition.
*/
function findRule(condition: ErrorCondition, rules: Rule[]): Rule | undefined {
// First, look for exact match
const exactMatch = rules.find((r) => r.condition === condition)
if (exactMatch) {
return exactMatch
}
// Fall back to "any" rule
return rules.find((r) => r.condition === "any")
}
/**
* Resolve the next fallback model to try.
*
* @param originalModel The original model that was requested "providerID/modelID"
* @param error The error that occurred
* @param attempted List of models already attempted
* @param config Fallback configuration
* @returns The next model to try, or undefined if no fallback available
*/
export async function resolve(
originalModel: string,
error: Error | ReturnType<NamedError["toObject"]>,
attempted: string[],
config: Config = DEFAULT_CONFIG,
): Promise<string | undefined> {
if (!config.enabled) {
log.info("fallback disabled")
return undefined
}
if (attempted.length >= config.maxAttempts) {
log.info("max attempts reached", { maxAttempts: config.maxAttempts, attempted })
return undefined
}
const condition = classifyError(error)
log.info("classifying error", {
condition,
error: "message" in error ? error.message : String(error),
})
const rule = findRule(condition, config.rules)
if (!rule) {
log.info("no matching rule", { condition })
return undefined
}
log.info("found rule", { condition: rule.condition, fallbacks: rule.fallbacks })
// Extract providers already tried
const attemptedProviders = new Set(attempted.map((m) => m.split("/")[0]))
// Try each fallback option
for (const fallback of rule.fallbacks) {
// Skip if this provider was already tried
const fallbackProvider = fallback.split("/")[0]
if (attemptedProviders.has(fallbackProvider)) {
continue
}
// Check if it's a full model spec or just a provider
if (fallback.includes("/")) {
// Full model spec - use directly if not attempted
if (!attempted.includes(fallback)) {
log.info("using explicit fallback", { original: originalModel, fallback })
return fallback
}
} else {
// Just provider - find equivalent model
const equivalent = await ModelEquivalence.findFallback(originalModel, Array.from(attemptedProviders), [
fallbackProvider,
])
if (equivalent) {
log.info("using equivalent fallback", { original: originalModel, fallback: equivalent })
return equivalent
}
}
}
// No rule-based fallback found, try general equivalence
const generalFallback = await ModelEquivalence.findFallback(originalModel, Array.from(attemptedProviders))
if (generalFallback) {
log.info("using general equivalence fallback", { original: originalModel, fallback: generalFallback })
return generalFallback
}
log.warn("no fallback available", { originalModel, attempted, condition })
return undefined
}
/**
* Check if an error should trigger fallback.
*/
export function shouldFallback(error: Error | ReturnType<NamedError["toObject"]>, config: Config): boolean {
if (!config.enabled) {
return false
}
const condition = classifyError(error)
const rule = findRule(condition, config.rules)
return rule !== undefined && rule.fallbacks.length > 0
}
/**
* Merge user config with defaults.
*/
export function mergeConfig(userConfig?: Partial<Config>): Config {
if (!userConfig) {
return { ...DEFAULT_CONFIG }
}
return {
enabled: userConfig.enabled ?? DEFAULT_CONFIG.enabled,
maxAttempts: userConfig.maxAttempts ?? DEFAULT_CONFIG.maxAttempts,
rules: userConfig.rules ?? DEFAULT_CONFIG.rules,
costAware: userConfig.costAware ?? DEFAULT_CONFIG.costAware,
notifyOnFallback: userConfig.notifyOnFallback ?? DEFAULT_CONFIG.notifyOnFallback,
}
}
}
+244
View File
@@ -0,0 +1,244 @@
import { Log } from "@/util/log"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { LLM } from "@/session/llm"
import { Provider } from "./provider"
import { CircuitBreaker } from "./circuit-breaker"
import { FallbackChain } from "./fallback-chain"
import { ModelEquivalence } from "./equivalence"
import z from "zod"
/**
* Fallback Orchestrator - Main entry point for LLM streaming with automatic fallback.
*
* Wraps LLM.stream() to provide:
* - Circuit breaker protection for unhealthy providers
* - Automatic fallback to equivalent models on failure
* - Configurable fallback rules based on error types
* - Event emission for UI notifications
*/
export namespace Fallback {
const log = Log.create({ service: "fallback" })
// Events
export const Event = {
FallbackUsed: BusEvent.define(
"fallback.used",
z.object({
sessionID: z.string(),
originalProvider: z.string(),
originalModel: z.string(),
fallbackProvider: z.string(),
fallbackModel: z.string(),
reason: z.string(),
attempt: z.number(),
}),
),
AllFallbacksExhausted: BusEvent.define(
"fallback.exhausted",
z.object({
sessionID: z.string(),
originalProvider: z.string(),
originalModel: z.string(),
attempted: z.array(z.string()),
lastError: z.string(),
}),
),
}
/**
* Extended stream input with fallback configuration.
*/
export type StreamInput = LLM.StreamInput & {
/** Override default fallback config */
fallbackConfig?: Partial<FallbackChain.Config>
/** Skip fallback for this request */
skipFallback?: boolean
}
/**
* Stream with automatic fallback support.
*
* This is the main entry point - use this instead of LLM.stream() directly.
*/
export async function stream(input: StreamInput): Promise<LLM.StreamOutput> {
// Get fallback configuration
const cfg = await Config.get()
const fallbackConfig = FallbackChain.mergeConfig({
...cfg.fallback,
...input.fallbackConfig,
})
// Skip fallback if disabled or explicitly requested
if (!fallbackConfig.enabled || input.skipFallback) {
log.info("fallback disabled, using direct stream", {
enabled: fallbackConfig.enabled,
skipFallback: input.skipFallback,
})
return LLM.stream(input)
}
// Configure circuit breaker from config
if (cfg.fallback?.circuitBreaker) {
CircuitBreaker.configure(cfg.fallback.circuitBreaker)
}
const originalModel = `${input.model.providerID}/${input.model.id}`
const attempted: string[] = []
let currentModel = input.model
let lastError: Error | undefined
for (let attempt = 0; attempt < fallbackConfig.maxAttempts; attempt++) {
const modelKey = `${currentModel.providerID}/${currentModel.id}`
log.info("attempting stream", {
attempt,
provider: currentModel.providerID,
model: currentModel.id,
originalModel,
})
// Check circuit breaker before attempting
if (!CircuitBreaker.canUse(currentModel.providerID)) {
log.warn("circuit breaker open", { provider: currentModel.providerID })
const fallbackModel = await FallbackChain.resolve(
modelKey,
new Error("circuit_open"),
attempted,
fallbackConfig,
)
if (fallbackModel) {
const { providerID, modelID } = ModelEquivalence.parseModel(fallbackModel)
try {
currentModel = await Provider.getModel(providerID, modelID)
continue
} catch (e) {
log.error("failed to get fallback model", { fallbackModel, error: e })
}
}
// No fallback available for circuit-open provider
throw new Error(`Provider ${currentModel.providerID} is unavailable (circuit breaker open)`)
}
// Track half-open requests
const state = CircuitBreaker.getState(currentModel.providerID)
if (state.state === "half_open") {
CircuitBreaker.startHalfOpenRequest(currentModel.providerID)
}
attempted.push(modelKey)
try {
// Attempt the stream
const result = await LLM.stream({
...input,
model: currentModel,
})
// Success - record it and emit event if fallback was used
CircuitBreaker.recordSuccess(currentModel.providerID)
if (attempt > 0 && fallbackConfig.notifyOnFallback) {
Bus.publish(Event.FallbackUsed, {
sessionID: input.sessionID,
originalProvider: input.model.providerID,
originalModel: input.model.id,
fallbackProvider: currentModel.providerID,
fallbackModel: currentModel.id,
reason: lastError?.message ?? "unknown",
attempt,
})
}
return result
} catch (error) {
lastError = error as Error
CircuitBreaker.recordFailure(currentModel.providerID, lastError)
log.warn("stream failed", {
attempt,
provider: currentModel.providerID,
model: currentModel.id,
error: lastError.message,
})
// Find fallback
const fallbackModel = await FallbackChain.resolve(modelKey, lastError, attempted, fallbackConfig)
if (!fallbackModel) {
log.error("no fallback available", {
originalModel,
attempted,
error: lastError.message,
})
break
}
// Switch to fallback model
const { providerID, modelID } = ModelEquivalence.parseModel(fallbackModel)
try {
currentModel = await Provider.getModel(providerID, modelID)
log.info("switching to fallback", {
from: modelKey,
to: fallbackModel,
})
} catch (e) {
log.error("failed to get fallback model", { fallbackModel, error: e })
break
}
}
}
// All fallbacks exhausted
Bus.publish(Event.AllFallbacksExhausted, {
sessionID: input.sessionID,
originalProvider: input.model.providerID,
originalModel: input.model.id,
attempted,
lastError: lastError?.message ?? "unknown",
})
throw lastError ?? new Error("All fallbacks exhausted")
}
/**
* Check if fallback is available for the current configuration.
*/
export async function isEnabled(): Promise<boolean> {
const cfg = await Config.get()
return cfg.fallback?.enabled ?? FallbackChain.DEFAULT_CONFIG.enabled
}
/**
* Get the current fallback configuration.
*/
export async function getConfig(): Promise<FallbackChain.Config> {
const cfg = await Config.get()
return FallbackChain.mergeConfig(cfg.fallback)
}
/**
* Get circuit breaker states for all providers.
*/
export function getCircuitBreakerStates(): Record<string, CircuitBreaker.ProviderState> {
return CircuitBreaker.getAllStates()
}
/**
* Reset circuit breaker for a specific provider.
*/
export function resetCircuitBreaker(providerID: string): void {
CircuitBreaker.reset(providerID)
}
/**
* Reset all circuit breakers.
*/
export function resetAllCircuitBreakers(): void {
CircuitBreaker.resetAll()
}
}
+2 -1
View File
@@ -11,6 +11,7 @@ import { SessionStatus } from "./status"
import { Plugin } from "@/plugin"
import type { Provider } from "@/provider/provider"
import { LLM } from "./llm"
import { Fallback } from "@/provider/fallback"
import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
@@ -50,7 +51,7 @@ export namespace SessionProcessor {
try {
let currentText: MessageV2.TextPart | undefined
let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
const stream = await LLM.stream(streamInput)
const stream = await Fallback.stream(streamInput)
for await (const value of stream.fullStream) {
input.abort.throwIfAborted()