mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 02:36:57 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]} />
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user