diff --git a/.opencode/agent/johny.md b/.opencode/agent/johny.md index 82122f70f1..2431c1eaa6 100644 --- a/.opencode/agent/johny.md +++ b/.opencode/agent/johny.md @@ -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 diff --git a/.opencode/agent/stanley.md b/.opencode/agent/stanley.md index ef19268be0..c8cd579232 100644 --- a/.opencode/agent/stanley.md +++ b/.opencode/agent/stanley.md @@ -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 diff --git a/.opencode/agent/zee.md b/.opencode/agent/zee.md index 464dc8c9dd..2702d46eb0 100644 --- a/.opencode/agent/zee.md +++ b/.opencode/agent/zee.md @@ -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 diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 7c10cb96e4..375512b32e 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -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 + } + }, } diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index d058ce54fb..9b69fd5b96 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -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 }>({ diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1294ab849e..935670c609 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -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 = { + 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 ( + + {/* ASCII Art Name */} + + + {(line) => ( + + {line} + + )} + + + + {/* Hint */} + + Tab to switch · Enter to start + + + ) +} + 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 } + 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() {
- (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()} + 0} + fallback={} > - + (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()} + > + {(message, index) => ( @@ -1075,8 +1164,9 @@ export function Session() { )} - - + + + 0}> diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ddb3af4b0a..d2cc9b56a2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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({ diff --git a/packages/opencode/src/provider/circuit-breaker.ts b/packages/opencode/src/provider/circuit-breaker.ts new file mode 100644 index 0000000000..1337f819f1 --- /dev/null +++ b/packages/opencode/src/provider/circuit-breaker.ts @@ -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() + let config: Config = { ...DEFAULT_CONFIG } + + /** + * Configure circuit breaker settings. + */ + export function configure(newConfig: Partial): 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 { + const result: Record = {} + 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") + } +} diff --git a/packages/opencode/src/provider/equivalence.ts b/packages/opencode/src/provider/equivalence.ts new file mode 100644 index 0000000000..f2940c2dd7 --- /dev/null +++ b/packages/opencode/src/provider/equivalence.ts @@ -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 = { + 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 = { ...DEFAULT_TIERS } + + /** + * Configure custom tier mappings. + */ + export function configure(tiers: Partial>): 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 { + 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 { + 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"] + } +} diff --git a/packages/opencode/src/provider/fallback-chain.ts b/packages/opencode/src/provider/fallback-chain.ts new file mode 100644 index 0000000000..b2a912eef3 --- /dev/null +++ b/packages/opencode/src/provider/fallback-chain.ts @@ -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): 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 + + // 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, + attempted: string[], + config: Config = DEFAULT_CONFIG, + ): Promise { + 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, 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 { + 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, + } + } +} diff --git a/packages/opencode/src/provider/fallback.ts b/packages/opencode/src/provider/fallback.ts new file mode 100644 index 0000000000..596c1fdbb2 --- /dev/null +++ b/packages/opencode/src/provider/fallback.ts @@ -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 + /** 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 { + // 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 { + const cfg = await Config.get() + return cfg.fallback?.enabled ?? FallbackChain.DEFAULT_CONFIG.enabled + } + + /** + * Get the current fallback configuration. + */ + export async function getConfig(): Promise { + const cfg = await Config.get() + return FallbackChain.mergeConfig(cfg.fallback) + } + + /** + * Get circuit breaker states for all providers. + */ + export function getCircuitBreakerStates(): Record { + 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() + } +} diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 71db7f1367..38244474a1 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -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 = {} - const stream = await LLM.stream(streamInput) + const stream = await Fallback.stream(streamInput) for await (const value of stream.fullStream) { input.abort.throwIfAborted()