diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 7ecbd40cb5..4121acb70a 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -8,6 +8,19 @@ "$schema": { "type": "string" }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, "disabled_mcps": { "type": "array", "items": { @@ -77,7 +90,8 @@ "delegate-task-retry", "prometheus-md-only", "start-work", - "atlas" + "atlas", + "smart-failover" ] } }, @@ -98,7 +112,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -224,7 +248,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -350,7 +384,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -476,7 +520,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -602,7 +656,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -728,7 +792,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -854,7 +928,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -980,7 +1064,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1106,7 +1200,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1232,7 +1336,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1358,7 +1472,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1484,7 +1608,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1610,7 +1744,17 @@ "type": "object", "properties": { "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" @@ -1746,7 +1890,17 @@ "type": "string" }, "model": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "variant": { "type": "string" diff --git a/docs/features/smart-failover.md b/docs/features/smart-failover.md new file mode 100644 index 0000000000..b10a826610 --- /dev/null +++ b/docs/features/smart-failover.md @@ -0,0 +1,59 @@ +# Smart Provider Failover + +## 1. Overview +In multi-model environments, providers often hit **429 (Rate Limits)**, **Insufficient Balance**, or **Quota Exhaustion**. +The Smart Failover system provides an automated detection and recovery mechanism, ensuring uninterrupted service by switching to healthy fallback models instantly. + +## 2. Key Features +- **Pipe Syntax (`|`)**: Minimalist fallback chain definitions. +- **Array Syntax (`string[]`)**: Equivalent to pipe syntax, easier to edit. +- **Instant Failover**: Aborts OpenCode's internal retry loops to trigger immediate model swapping. +- **Error Diagnosis (Best-Effort)**: Classifies common failures (rate-limit, quota, balance) via pattern matching. +- **Guardrails**: + - **Context Compatibility**: Skips fallbacks with insufficient context windows. + - **Probation Recovery**: After a cooldown elapses, a model becomes eligible again (PROBATION) and is cleared back to healthy after the session becomes idle. + - **Memory Safety**: Automatic cleanup upon session deletion. + +## 3. Configuration +Smart Failover is enabled by default (unless you disable the `smart-failover` hook). Configure a fallback chain in `model` to use it. + +### 3.1 Model Fallback Chain +You can define the fallback chain using either: + +- **Pipe syntax** (string) +- **Array syntax** (string[]) + +Both forms are equivalent: the first entry is the primary model, and the rest are fallbacks. + +### 3.2 Hook Toggle +If you need to disable it, add `smart-failover` to `disabled_hooks` in your `oh-my-opencode.json`. + +### Example +```jsonc +{ + "model": "openai/gpt-5.2-codex | google/gemini-3-pro" +} +``` + +### Array Example +```jsonc +{ + "model": ["openai/gpt-5.2-codex", "google/gemini-3-pro"] +} +``` + +`model` can also be configured per-agent (e.g. `agents.Sisyphus.model`) and in category configs. Those locations also accept either pipe syntax or an array. + +## 4. Default Behavior +- **Triggers**: Retry-loop detection (`session.status: retry`) and certain session errors (`session.error`) mark the current `provider/model` as unavailable and switch to the next available fallback. +- **Cooling + Backoff**: Retry-loop cooling uses a fixed 5-minute cooldown. Session-error cooling uses exponential backoff based on repeated failures. +- **Locking**: Balance/quota exhaustion signals lock a specific `provider/model` pair (model key) until reset. +- **Fallback Selection**: Only HEALTHY/PROBATION models are eligible; fallbacks with too-small context windows are skipped. + +## 5. Limitations +- **Retry-After**: The implementation does not reliably receive response headers in events, so header-based cooldown is best-effort. +- **Probation**: Recovery is approximated by the session becoming idle, not a dedicated health-check request. + +## 6. UI/UX +- **Notification**: A yellow toast appears: `⚠️ Switched to google/gemini-3-pro`. +- **Throttling**: Toasts are shown only once per session to prevent UI spam. diff --git a/docs/features/smart-failover.zh-CN.md b/docs/features/smart-failover.zh-CN.md new file mode 100644 index 0000000000..ac4023c6fc --- /dev/null +++ b/docs/features/smart-failover.zh-CN.md @@ -0,0 +1,65 @@ +# 智能供应商故障切换 (Smart Provider Failover) + +## 1. 简介 +在多模型协作环境下,API 供应商经常会遇到 **429 (频率限制)**、**余额不足** 或 **订阅配额耗尽** 的情况。 +Smart Failover 系统为 `oh-my-opencode` 引入了一套自动化的故障检测与恢复机制。它能确保在主模型不可用时,系统瞬间接管请求并切换到备用模型,实现“永不掉线”的 AI 助手体验。 + +## 2. 核心特性 +- **管道符配置 (`|`)**: 极简的备选链定义方式。 +- **数组配置(string[])**: 与管道符等价,更易维护。 +- **秒级无感切换**: 自动强行终止 OpenCode 原生的卡顿重试循环,秒切备用线路。 +- **错误诊断(Best-Effort)**: 主要通过模式匹配识别常见失败原因(限流、配额、余额等)。 +- **安全防护栏**: + - **上下文窗口对齐**: 自动跳过窗口过小的备用模型。 + - **PROBATION 恢复**: 冷却结束后模型进入 PROBATION,可再次被选用;会话进入 idle 后清理回健康状态。 + - **内存管理**: 随会话销毁自动清理缓存,无内存泄露风险。 + +## 3. 使用方法 +Smart Failover 默认启用(除非你显式禁用 `smart-failover` hook)。只需要通过 `model` 定义“主模型 + 备用模型链”即可使用。 + +### 3.1 模型备用链写法 +支持两种等价写法: + +- **管道符写法**(string) +- **数组写法**(string[]) + +两者语义一致:第一个是主模型,后续是备用模型。 + +### 3.2 Hook 开关 +如需禁用,可在 `oh-my-opencode.json` 中把 `smart-failover` 加入 `disabled_hooks`。 + +### 示例配置 +```jsonc +{ + "model": "openai/gpt-5.2-codex | google/gemini-3-pro" +} +``` + +### 数组示例 +```jsonc +{ + "model": ["openai/gpt-5.2-codex", "google/gemini-3-pro"] +} +``` + +`model` 也可以写在单个 agent(例如 `agents.Sisyphus.model`)或 category 配置里,上述两种写法同样支持。 + +## 4. 默认行为说明 +- **触发条件**:检测到 retry loop(`session.status: retry`)或部分会话错误(`session.error`)后,会把当前 `provider/model` 标记为不可用,并切换到下一个可用的备用模型。 +- **冷却与退避**:retry loop 的冷却时间固定为 5 分钟;会话错误触发的冷却会按失败次数做指数退避。 +- **锁定**:余额不足/配额耗尽等信号会锁定特定的 `provider/model` 组合(modelKey),直到重置。 +- **fallback 选择**:只会选择 HEALTHY/PROBATION 的模型;上下文窗口过小的 fallback 会被跳过。 + +## 5. 限制说明 +- **Retry-After**:事件里不一定能拿到响应头,因此基于 header 的冷却时间属于 best-effort。 +- **PROBATION**:当前以会话进入 idle 作为“恢复健康”的近似信号,并非专门的 health-check 请求闭环。 + +## 6. 故障状态说明 +- **HEALTHY (健康)**: 正常使用。 +- **COOLING (冷却中)**: 触发 429 或 5xx 错误,根据指数退避算法进入等待期。 +- **LOCKED (锁定)**: 触发余额不足或配额耗尽,除非重启或修改配置,否则不再尝试。 +- **PROBATION (试用期)**: 冷却结束后可再次被选用;会话进入 idle 后清理回健康状态。 + +## 7. UI 交互 +- **故障提示**: 切换时会弹出黄色 Toast,内容如 `⚠️ Switched to google/gemini-3-pro`。 +- **静默机制**: 每个会话仅提示一次,后续切换保持静默,不干扰工作。 diff --git a/src/agents/sisyphus-junior.test.ts b/src/agents/sisyphus-junior.test.ts index 43d75610ac..2e638e3034 100644 --- a/src/agents/sisyphus-junior.test.ts +++ b/src/agents/sisyphus-junior.test.ts @@ -14,6 +14,18 @@ describe("createSisyphusJuniorAgentWithOverrides", () => { expect(result.model).toBe("openai/gpt-5.2") }) + test("applies model override from array (uses first non-empty)", () => { + // #given + const override = { model: ["", "openai/gpt-5.2", "google/gemini-3-pro"] } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.model).toBe("openai/gpt-5.2") + }) + + test("applies temperature override", () => { // #given const override = { temperature: 0.5 } @@ -83,6 +95,29 @@ describe("createSisyphusJuniorAgentWithOverrides", () => { expect(result.model).toBe(SISYPHUS_JUNIOR_DEFAULTS.model) }) + test("uses systemDefaultModel when override.model is empty array", () => { + // #given + const override = { model: [] as string[] } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override, "openai/gpt-5.2") + + // #then + expect(result.model).toBe("openai/gpt-5.2") + }) + + test("falls back to defaults when override.model is empty array and systemDefaultModel missing", () => { + // #given + const override = { model: [] as string[] } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.model).toBe(SISYPHUS_JUNIOR_DEFAULTS.model) + }) + + test("uses default temperature when no override", () => { // #given const override = {} diff --git a/src/agents/sisyphus-junior.ts b/src/agents/sisyphus-junior.ts index a9f592ddb0..1aa7dbe551 100644 --- a/src/agents/sisyphus-junior.ts +++ b/src/agents/sisyphus-junior.ts @@ -91,7 +91,15 @@ export function createSisyphusJuniorAgentWithOverrides( override = undefined } - const model = override?.model ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model + const overrideModel = override?.model + const primaryOverrideModel = Array.isArray(overrideModel) + ? overrideModel.map((m) => m.trim()).find((m) => m.length > 0) + : typeof overrideModel === "string" + ? overrideModel.trim() || undefined + : undefined + + const trimmedSystemDefaultModel = systemDefaultModel?.trim() || undefined + const primaryModel = primaryOverrideModel ?? trimmedSystemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature const promptAppend = override?.prompt_append @@ -112,7 +120,7 @@ export function createSisyphusJuniorAgentWithOverrides( description: override?.description ?? "Sisyphus-Junior - Focused task executor. Same discipline, no delegation.", mode: "subagent" as const, - model, + model: primaryModel, temperature, maxTokens: 64000, prompt, @@ -124,7 +132,7 @@ export function createSisyphusJuniorAgentWithOverrides( base.top_p = override.top_p } - if (isGptModel(model)) { + if (isGptModel(primaryModel)) { return { ...base, reasoningEffort: "medium" } as AgentConfig } diff --git a/src/agents/types.ts b/src/agents/types.ts index 5c21c3320a..65439b86f8 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -72,7 +72,8 @@ export type OverridableAgentName = export type AgentName = BuiltinAgentName -export type AgentOverrideConfig = Partial & { +export type AgentOverrideConfig = Partial> & { + model?: string | string[] prompt_append?: string variant?: string } diff --git a/src/agents/utils.ts b/src/agents/utils.ts index f3959e3a45..e2beccbb7d 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -48,21 +48,24 @@ function isFactory(source: AgentSource): source is AgentFactory { export function buildAgent( source: AgentSource, - model: string, + model: string | string[], categories?: CategoriesConfig, gitMasterConfig?: GitMasterConfig ): AgentConfig { - const base = isFactory(source) ? source(model) : source const categoryConfigs: Record = categories ? { ...DEFAULT_CATEGORIES, ...categories } : DEFAULT_CATEGORIES + const primaryModel = Array.isArray(model) ? model[0] : model + const base = isFactory(source) ? source(primaryModel) : source + const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string } if (agentWithCategory.category) { const categoryConfig = categoryConfigs[agentWithCategory.category] if (categoryConfig) { if (!base.model) { - base.model = categoryConfig.model + const categoryModel = categoryConfig.model + base.model = Array.isArray(categoryModel) ? categoryModel[0] : categoryModel } if (base.temperature === undefined && categoryConfig.temperature !== undefined) { base.temperature = categoryConfig.temperature @@ -122,9 +125,25 @@ function mergeAgentConfig( base: AgentConfig, override: AgentOverrideConfig ): AgentConfig { - const { prompt_append, ...rest } = override + const { prompt_append, model, ...rest } = override + + let sanitizedModel: string | undefined + if (model) { + if (Array.isArray(model)) { + sanitizedModel = model[0] + } else if (model.includes("|")) { + sanitizedModel = model.split("|")[0].trim() + } else { + sanitizedModel = model + } + } + const merged = deepMerge(base, rest as Partial) + if (sanitizedModel) { + merged.model = sanitizedModel + } + if (prompt_append && merged.prompt) { merged.prompt = merged.prompt + "\n" + prompt_append } @@ -132,6 +151,17 @@ function mergeAgentConfig( return merged } +function extractPrimaryModel(model?: string | string[]): string | undefined { + if (Array.isArray(model)) { + return model.map((m) => m.trim()).find((m) => m.length > 0) + } + if (typeof model === "string") { + if (model.includes("|")) return model.split("|")[0].trim() || undefined + return model.trim() || undefined + } + return undefined +} + function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] { if (scope === "user" || scope === "opencode") return "user" if (scope === "project" || scope === "opencode-project") return "project" @@ -186,27 +216,25 @@ export async function createBuiltinAgents( const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable] - for (const [name, source] of Object.entries(agentSources)) { - const agentName = name as BuiltinAgentName + for (const [name, source] of Object.entries(agentSources)) { + const agentName = name as BuiltinAgentName - if (agentName === "sisyphus") continue - if (agentName === "atlas") continue - if (includesCaseInsensitive(disabledAgents, agentName)) continue + if (agentName === "sisyphus") continue + if (agentName === "atlas") continue + if (includesCaseInsensitive(disabledAgents, agentName)) continue const override = findCaseInsensitive(agentOverrides, agentName) const requirement = AGENT_MODEL_REQUIREMENTS[agentName] - - // Use resolver to determine model + const { model, variant: resolvedVariant } = resolveModelWithFallback({ - userModel: override?.model, + userModel: extractPrimaryModel(override?.model), fallbackChain: requirement?.fallbackChain, availableModels, systemDefaultModel, }) let config = buildAgent(source, model, mergedCategories, gitMasterConfig) - - // Apply variant from override or resolved fallback chain + if (override?.variant) { config = { ...config, variant: override.variant } } else if (resolvedVariant) { @@ -234,13 +262,12 @@ export async function createBuiltinAgents( } } - if (!disabledAgents.includes("sisyphus")) { - const sisyphusOverride = agentOverrides["sisyphus"] - const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"] - - // Use resolver to determine model + if (!includesCaseInsensitive(disabledAgents, "sisyphus")) { + const sisyphusOverride = findCaseInsensitive(agentOverrides, "sisyphus") + const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"] + const { model: sisyphusModel, variant: sisyphusResolvedVariant } = resolveModelWithFallback({ - userModel: sisyphusOverride?.model, + userModel: extractPrimaryModel(sisyphusOverride?.model), fallbackChain: sisyphusRequirement?.fallbackChain, availableModels, systemDefaultModel, @@ -253,8 +280,7 @@ export async function createBuiltinAgents( availableSkills, availableCategories ) - - // Apply variant from override or resolved fallback chain + if (sisyphusOverride?.variant) { sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant } } else if (sisyphusResolvedVariant) { @@ -270,29 +296,27 @@ export async function createBuiltinAgents( sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride) } - result["sisyphus"] = sisyphusConfig - } + result["sisyphus"] = sisyphusConfig + } + + if (!includesCaseInsensitive(disabledAgents, "atlas")) { + const orchestratorOverride = findCaseInsensitive(agentOverrides, "atlas") + const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"] - if (!disabledAgents.includes("atlas")) { - const orchestratorOverride = agentOverrides["atlas"] - const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"] - - // Use resolver to determine model const { model: atlasModel, variant: atlasResolvedVariant } = resolveModelWithFallback({ - userModel: orchestratorOverride?.model, + userModel: extractPrimaryModel(orchestratorOverride?.model), fallbackChain: atlasRequirement?.fallbackChain, availableModels, systemDefaultModel, }) - + let orchestratorConfig = createAtlasAgent({ model: atlasModel, availableAgents, availableSkills, userCategories: categories, }) - - // Apply variant from override or resolved fallback chain + if (orchestratorOverride?.variant) { orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant } } else if (atlasResolvedVariant) { @@ -303,8 +327,8 @@ export async function createBuiltinAgents( orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride) } - result["atlas"] = orchestratorConfig - } + result["atlas"] = orchestratorConfig + } - return result - } + return result +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 48bdb9169b..83af0653b4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -85,6 +85,7 @@ export const HookNameSchema = z.enum([ "prometheus-md-only", "start-work", "atlas", + "smart-failover", ]) export const BuiltinCommandNameSchema = z.enum([ @@ -94,7 +95,7 @@ export const BuiltinCommandNameSchema = z.enum([ export const AgentOverrideConfigSchema = z.object({ /** @deprecated Use `category` instead. Model is inherited from category defaults. */ - model: z.string().optional(), + model: z.union([z.string(), z.array(z.string())]).optional(), variant: z.string().optional(), /** Category name to inherit model and other settings from CategoryConfig */ category: z.string().optional(), @@ -151,7 +152,7 @@ export const SisyphusAgentConfigSchema = z.object({ export const CategoryConfigSchema = z.object({ /** Human-readable description of the category's purpose. Shown in delegate_task prompt. */ description: z.string().optional(), - model: z.string().optional(), + model: z.union([z.string(), z.array(z.string())]).optional(), variant: z.string().optional(), temperature: z.number().min(0).max(2).optional(), top_p: z.number().min(0).max(1).optional(), @@ -299,6 +300,7 @@ export const GitMasterConfigSchema = z.object({ export const OhMyOpenCodeConfigSchema = z.object({ $schema: z.string().optional(), + model: z.union([z.string(), z.array(z.string())]).optional(), disabled_mcps: z.array(AnyMcpNameSchema).optional(), disabled_agents: z.array(BuiltinAgentNameSchema).optional(), disabled_skills: z.array(BuiltinSkillNameSchema).optional(), diff --git a/src/features/failover/diagnoser.test.ts b/src/features/failover/diagnoser.test.ts new file mode 100644 index 0000000000..84843f2de4 --- /dev/null +++ b/src/features/failover/diagnoser.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import { ErrorDiagnoser } from "./diagnoser" + +describe("ErrorDiagnoser header parsing", () => { + test("Retry-After seconds", () => { + const res = ErrorDiagnoser.diagnose("any", { "retry-after": "120" }) + expect(res.action).toBe("COOLING") + expect(res.cooldownMs).toBe(120_000) + }) + + test("Retry-After HTTP-date", () => { + const target = new Date(Date.now() + 60_000).toUTCString() + const res = ErrorDiagnoser.diagnose("any", { "retry-after": target }) + expect(res.action).toBe("COOLING") + expect(res.cooldownMs ?? 0).toBeGreaterThan(40_000) + expect(res.cooldownMs ?? 0).toBeLessThan(80_000) + }) + + test("x-ratelimit-reset epoch seconds", () => { + const epochSeconds = Math.floor((Date.now() + 90_000) / 1000) + const res = ErrorDiagnoser.diagnose("any", { "x-ratelimit-reset": String(epochSeconds) }) + expect(res.action).toBe("COOLING") + expect(res.cooldownMs ?? 0).toBeGreaterThan(70_000) + expect(res.cooldownMs ?? 0).toBeLessThan(120_000) + }) + + test("x-ratelimit-reset delta seconds", () => { + const res = ErrorDiagnoser.diagnose("any", { "x-ratelimit-reset": "45" }) + expect(res.action).toBe("COOLING") + expect(res.cooldownMs ?? 0).toBeGreaterThan(40_000) + expect(res.cooldownMs ?? 0).toBeLessThan(60_000) + }) + + test("x-ratelimit-reset epoch milliseconds", () => { + const epochMs = Date.now() + 80_000 + const res = ErrorDiagnoser.diagnose("any", { "x-ratelimit-reset": String(epochMs) }) + expect(res.action).toBe("COOLING") + expect(res.cooldownMs ?? 0).toBeGreaterThan(60_000) + expect(res.cooldownMs ?? 0).toBeLessThan(110_000) + }) +}) + diff --git a/src/features/failover/diagnoser.ts b/src/features/failover/diagnoser.ts new file mode 100644 index 0000000000..d10bdb32d1 --- /dev/null +++ b/src/features/failover/diagnoser.ts @@ -0,0 +1,106 @@ +import type { DiagnoseResult, RecoveryAction } from "./types" + +const PATTERNS: Array<{ regex: RegExp; action: RecoveryAction; type: string }> = [ + { regex: /insufficient balance/i, action: "LOCKED", type: "balance" }, + { regex: /usage limit reached/i, action: "LOCKED", type: "balance" }, + { regex: /quota exceeded/i, action: "COOLING", type: "quota" }, + { regex: /rate limit/i, action: "COOLING", type: "rate_limit" }, + { regex: /429/i, action: "COOLING", type: "rate_limit" }, + { regex: /overloaded/i, action: "COOLING", type: "overloaded" }, + { regex: /503/i, action: "COOLING", type: "server_error" }, + { regex: /502/i, action: "COOLING", type: "server_error" }, + { regex: /500/i, action: "COOLING", type: "server_error" }, + { regex: /\bservice unavailable\b/i, action: "COOLING", type: "server_error" }, + { + regex: /(?:model|deployment|endpoint|provider)\b[\s\S]{0,40}\bunavailable\b/i, + action: "COOLING", + type: "availability", + }, + { + regex: /(?:model|deployment|endpoint|provider)\b[\s\S]{0,40}\bnot found/i, + action: "COOLING", + type: "availability", + }, + { + regex: /(?:model|deployment|endpoint|provider)\b[\s\S]{0,40}\bdoes not exist/i, + action: "COOLING", + type: "availability", + }, + { + regex: /(?:model|deployment|endpoint|provider)\b[\s\S]{0,40}\bunsupported/i, + action: "COOLING", + type: "availability", + }, + { regex: /context length/i, action: "SKIP", type: "context_length" }, + { regex: /maximum context/i, action: "SKIP", type: "context_length" }, + { regex: /token limit/i, action: "SKIP", type: "context_length" }, +] + +function parseRetryAfterMs(value: string, headerName: "retry-after" | "x-ratelimit-reset"): number | null { + const trimmed = value.trim() + if (!trimmed) return null + + const now = Date.now() + + if (/^\d+(\.\d+)?$/.test(trimmed)) { + const num = Number(trimmed) + if (!Number.isFinite(num) || num < 0) return null + + if (headerName === "retry-after") { + return Math.round(num * 1000) + } + + const isLikelyEpochSeconds = num >= 1_000_000_000 + const isLikelyEpochMs = num >= 1_000_000_000_000 + + const targetMs = isLikelyEpochMs + ? Math.round(num) + : isLikelyEpochSeconds + ? Math.round(num * 1000) + : now + Math.round(num * 1000) + + const delta = targetMs - now + return delta > 0 ? delta : null + } + + const dateMs = Date.parse(trimmed) + if (Number.isNaN(dateMs)) return null + + const delta = dateMs - now + return delta > 0 ? delta : null +} + +export class ErrorDiagnoser { + static diagnose(error: unknown, headers?: Record): DiagnoseResult { + const errorStr = String(error) + + if (headers) { + const retryAfter = headers["retry-after"] + const rateLimitReset = headers["x-ratelimit-reset"] + + const retryAfterMs = retryAfter ? parseRetryAfterMs(retryAfter, "retry-after") : null + if (retryAfterMs !== null) { + return { action: "COOLING", reason: `Retry-After header`, cooldownMs: retryAfterMs } + } + + const resetMs = rateLimitReset ? parseRetryAfterMs(rateLimitReset, "x-ratelimit-reset") : null + if (resetMs !== null) { + return { action: "COOLING", reason: `x-ratelimit-reset header`, cooldownMs: resetMs } + } + } + + for (const pattern of PATTERNS) { + if (pattern.regex.test(errorStr)) { + return { + action: pattern.action, + reason: `Matched pattern: ${pattern.type}` + } + } + } + + return { + action: "RETRY", + reason: "Unknown error, default retry" + } + } +} diff --git a/src/features/failover/resolver.ts b/src/features/failover/resolver.ts new file mode 100644 index 0000000000..0565d7fca8 --- /dev/null +++ b/src/features/failover/resolver.ts @@ -0,0 +1,25 @@ +import type { ModelChain } from "./types" + +export function resolveModelChain(modelConfig?: string | string[]): ModelChain | null { + if (!modelConfig) return null + + let models: string[] = [] + + if (Array.isArray(modelConfig)) { + models = modelConfig.map(m => m.trim()).filter(m => m.length > 0) + } else if (typeof modelConfig === "string") { + if (modelConfig.includes("|")) { + models = modelConfig.split("|").map(m => m.trim()).filter(m => m.length > 0) + } else { + const trimmed = modelConfig.trim() + models = trimmed.length > 0 ? [trimmed] : [] + } + } + + if (models.length === 0) return null + + return { + primary: models[0], + fallbacks: models.slice(1) + } +} diff --git a/src/features/failover/status-manager.ts b/src/features/failover/status-manager.ts new file mode 100644 index 0000000000..3c2c643c3b --- /dev/null +++ b/src/features/failover/status-manager.ts @@ -0,0 +1,66 @@ +import type { ProviderState, ProviderStatus } from "./types" + +export class ProviderStatusManager { + private static instance: ProviderStatusManager + private states = new Map() + + private constructor() {} + + static getInstance(): ProviderStatusManager { + if (!ProviderStatusManager.instance) { + ProviderStatusManager.instance = new ProviderStatusManager() + } + return ProviderStatusManager.instance + } + + getState(model: string): ProviderState | undefined { + return this.states.get(model) + } + + getStatus(model: string): ProviderStatus { + const state = this.states.get(model) + if (!state) return "HEALTHY" + + if (state.status === "COOLING") { + if (Date.now() >= state.resumeAt) { + return "PROBATION" + } + return "COOLING" + } + + return state.status + } + + isAvailable(model: string): boolean { + const status = this.getStatus(model) + return status === "HEALTHY" || status === "PROBATION" + } + + markCooling(model: string, durationMs: number, reason: string) { + const current = this.states.get(model) + this.states.set(model, { + status: "COOLING", + resumeAt: Date.now() + durationMs, + reason, + retryCount: (current?.retryCount ?? 0) + 1 + }) + } + + markLocked(model: string, reason: string) { + const current = this.states.get(model) + this.states.set(model, { + status: "LOCKED", + resumeAt: Infinity, + reason, + retryCount: (current?.retryCount ?? 0) + 1 + }) + } + + markHealthy(model: string) { + this.states.delete(model) + } + + reset() { + this.states.clear() + } +} diff --git a/src/features/failover/types.ts b/src/features/failover/types.ts new file mode 100644 index 0000000000..38b5adce7c --- /dev/null +++ b/src/features/failover/types.ts @@ -0,0 +1,21 @@ +export type ProviderStatus = 'HEALTHY' | 'COOLING' | 'LOCKED' | 'PROBATION'; + +export interface ProviderState { + status: ProviderStatus; + resumeAt: number; + reason?: string; + retryCount: number; +} + +export type RecoveryAction = 'COOLING' | 'LOCKED' | 'RETRY' | 'SKIP'; + +export interface DiagnoseResult { + action: RecoveryAction; + reason: string; + cooldownMs?: number; +} + +export interface ModelChain { + primary: string; + fallbacks: string[]; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d781f0df3b..c04f819680 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -30,4 +30,5 @@ export { createTaskResumeInfoHook } from "./task-resume-info"; export { createStartWorkHook } from "./start-work"; export { createAtlasHook } from "./atlas"; export { createDelegateTaskRetryHook } from "./delegate-task-retry"; +export { createSmartFailoverHook } from "./smart-failover"; export { createQuestionLabelTruncatorHook } from "./question-label-truncator"; diff --git a/src/hooks/smart-failover/index.test.ts b/src/hooks/smart-failover/index.test.ts new file mode 100644 index 0000000000..84ae255357 --- /dev/null +++ b/src/hooks/smart-failover/index.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test" +import { createSmartFailoverHook } from "./index" +import { ProviderStatusManager } from "../../features/failover/status-manager" +import type { PluginInput } from "@opencode-ai/plugin" +import type { OhMyOpenCodeConfig } from "../../config" +import type { ModelCacheState } from "../../plugin-state" + +describe("smart-failover hook", () => { + let ctx: PluginInput + let config: OhMyOpenCodeConfig + let statusManager: ProviderStatusManager + let modelCacheState: ModelCacheState + + beforeEach(() => { + ProviderStatusManager.getInstance().reset() + statusManager = ProviderStatusManager.getInstance() + + ctx = { + client: { + tui: { + showToast: mock(() => Promise.resolve()) + }, + session: { + abort: mock(() => Promise.resolve()), + prompt: mock(() => Promise.resolve()) + } + } + } as unknown as PluginInput + + config = { + model: "primary/model", + agents: { + sisyphus: { + model: "primary/model | fallback/model" + } + } + } + + modelCacheState = { + modelContextLimitsCache: new Map(), + anthropicContext1MEnabled: false + } + }) + + test("chat.message should allow healthy primary", async () => { + const hook = createSmartFailoverHook(ctx, config, modelCacheState) + const output = { message: {} } as any + + await hook["chat.message"]( + { sessionID: "ses-1", agent: "Sisyphus", model: { providerID: "primary", modelID: "model" } }, + output + ) + + expect(output.message.model).toBeUndefined() + }) + + test("chat.message should swap cooling primary", async () => { + statusManager.markCooling("primary/model", 10000, "test") + + const hook = createSmartFailoverHook(ctx, config, modelCacheState) + const output = { message: {} } as any + + await hook["chat.message"]( + { sessionID: "ses-1", agent: "Sisyphus", model: { providerID: "primary", modelID: "model" } }, + output + ) + + expect(output.message.model).toEqual({ providerID: "fallback", modelID: "model" }) + await new Promise(resolve => setTimeout(resolve, 1600)) + expect(ctx.client.tui.showToast).toHaveBeenCalled() + }) + + test("session.error should mark model cooling with backoff", async () => { + const hook = createSmartFailoverHook(ctx, config, modelCacheState) + const output = { message: {} } as any + + await hook["chat.message"]( + { sessionID: "ses-1", agent: "Sisyphus", model: { providerID: "primary", modelID: "model" } }, + output + ) + + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID: "ses-1", + error: "Rate limit reached" + } + } + }) + + const state1 = statusManager.getState("primary/model") + expect(state1?.status).toBe("COOLING") + expect(state1?.retryCount).toBe(1) + + expect(ctx.client.session.abort).toHaveBeenCalled() + }) + + test("session.idle should recover probation model", async () => { + statusManager.markCooling("primary/model", -1000, "test") + + const hook = createSmartFailoverHook(ctx, config, modelCacheState) + const output = { message: {} } as any + + await hook["chat.message"]( + { sessionID: "ses-1", agent: "Sisyphus", model: { providerID: "primary", modelID: "model" } }, + output + ) + + expect(statusManager.getStatus("primary/model")).toBe("PROBATION") + + await hook.event({ + event: { + type: "session.idle", + properties: { + sessionID: "ses-1" + } + } + }) + + expect(statusManager.getStatus("primary/model")).toBe("HEALTHY") + }) +}) diff --git a/src/hooks/smart-failover/index.ts b/src/hooks/smart-failover/index.ts new file mode 100644 index 0000000000..b469240d97 --- /dev/null +++ b/src/hooks/smart-failover/index.ts @@ -0,0 +1,270 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { OhMyOpenCodeConfig } from "../../config" +import { ProviderStatusManager } from "../../features/failover/status-manager" +import { ErrorDiagnoser } from "../../features/failover/diagnoser" +import { resolveModelChain } from "../../features/failover/resolver" +import { log } from "../../shared" +import type { ModelCacheState } from "../../plugin-state" + +const TOAST_DELAY_MS = 1500 +const FAILOVER_TOAST_DURATION_MS = 5000 +const SWAP_TOAST_DURATION_MS = 10000 +const PROVIDER_LOCKED_TOAST_DURATION_MS = 6000 +const SESSION_PROMPT_INITIAL_DELAY_MS = 500 +const SESSION_PROMPT_BUSY_RETRY_DELAY_MS = 300 +const SESSION_PROMPT_MAX_RETRIES = 5 +const SESSION_PROMPT_BUSY_RETRY_BACKOFF_FACTOR = 1 +const RETRY_LOOP_COOLDOWN_MS = 300000 +const DEFAULT_COOLDOWN_MS = 300000 +const MAX_BACKOFF_EXPONENT = 14 +const MAX_COOLDOWN_MS = 21600000 +const CONTEXT_WINDOW_MIN_RATIO = 0.5 + +export function createSmartFailoverHook( + ctx: PluginInput, + config: OhMyOpenCodeConfig, + modelCacheState: ModelCacheState +) { + const statusManager = ProviderStatusManager.getInstance() + const sessionContext = new Map() + const toastedSessions = new Set() + const pendingFailovers = new Set() + + const findFallback = (agentName: string, currentModelKey: string, sessionID: string) => { + const getModelConfig = (agent: string) => { + const agents = config.agents as unknown as + | Record + | undefined + if (!agents) return undefined + return agents[agent]?.model ?? agents[agent.toLowerCase()]?.model + } + + let modelConfig = getModelConfig(agentName) ?? config.model + + const chain = resolveModelChain(modelConfig as string | string[]) + if (!chain) return undefined + + return chain.fallbacks.find(m => { + if (!statusManager.isAvailable(m)) return false + + const primaryLimit = modelCacheState.modelContextLimitsCache.get(currentModelKey) + const fallbackLimit = modelCacheState.modelContextLimitsCache.get(m) + + if ( + primaryLimit && + fallbackLimit && + fallbackLimit < primaryLimit * CONTEXT_WINDOW_MIN_RATIO + ) { + log(`[SmartFailover] Skipping fallback ${m} due to small context window (${fallbackLimit} < ${primaryLimit})`, { sessionID }) + return false + } + return true + }) + } + + const performFailover = async ( + sessionID: string, + currentModelKey: string, + agent: string, + reason: string + ) => { + if (pendingFailovers.has(sessionID)) return false + pendingFailovers.add(sessionID) + + try { + const fallback = findFallback(agent, currentModelKey, sessionID) + + if (fallback) { + const [providerID, modelID] = fallback.split("/") + if (providerID && modelID) { + log(`[SmartFailover] Failover triggered: ${currentModelKey} -> ${fallback}. Reason: ${reason}`, { sessionID }) + + if (!toastedSessions.has(sessionID)) { + setTimeout(() => { + ctx.client.tui.showToast({ + body: { + title: "Failover Active", + message: `⚠️ ${currentModelKey} unavailable. Switched to ${fallback}.`, + variant: "warning", + duration: FAILOVER_TOAST_DURATION_MS + } + }).catch(() => {}) + }, TOAST_DELAY_MS) + toastedSessions.add(sessionID) + } + + sessionContext.set(sessionID, { modelKey: fallback, agent }) + + await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {}) + + let retryAttempt = 0 + const checkAndPrompt = async () => { + try { + await ctx.client.session.prompt({ + path: { id: sessionID }, + body: { + model: { providerID, modelID }, + agent, + parts: [{ type: "text", text: "System: Previous model failed. Please continue from exactly where you left off." }] + } + }) + pendingFailovers.delete(sessionID) + } catch (e: any) { + if (e.message?.includes("busy") && retryAttempt < SESSION_PROMPT_MAX_RETRIES) { + retryAttempt++ + const backoffDelay = + SESSION_PROMPT_BUSY_RETRY_DELAY_MS + + retryAttempt * SESSION_PROMPT_BUSY_RETRY_BACKOFF_FACTOR * 100 + setTimeout(checkAndPrompt, backoffDelay) + } else { + log("[SmartFailover] Retry prompt failed", e) + pendingFailovers.delete(sessionID) + } + } + } + + setTimeout(checkAndPrompt, SESSION_PROMPT_INITIAL_DELAY_MS) + return true + } + } + pendingFailovers.delete(sessionID) + return false + } catch (e) { + log("[SmartFailover] Failover error", e) + pendingFailovers.delete(sessionID) + return false + } + } + + return { + "chat.message": async ( + input: { + sessionID: string + agent?: string + model?: { providerID: string; modelID: string } + }, + output: { + message: Record + } + ) => { + const currentModelKey = input.model + ? `${input.model.providerID}/${input.model.modelID}` + : undefined + + if (!currentModelKey) return + + const agentName = input.agent || "Sisyphus" + sessionContext.set(input.sessionID, { modelKey: currentModelKey, agent: agentName }) + + if (statusManager.isAvailable(currentModelKey)) { + return + } + + const fallback = findFallback(agentName, currentModelKey, input.sessionID) + + if (fallback) { + const [providerID, modelID] = fallback.split("/") + if (providerID && modelID && output.message) { + output.message.model = { providerID, modelID } + + sessionContext.set(input.sessionID, { modelKey: fallback, agent: agentName }) + + log(`[SmartFailover] Swapped ${currentModelKey} -> ${fallback}`, { sessionID: input.sessionID }) + + if (!toastedSessions.has(input.sessionID)) { + setTimeout(() => { + ctx.client.tui.showToast({ + body: { + title: "Failover Active", + message: `⚠️ ${currentModelKey} unavailable. Switched to ${fallback}.`, + variant: "warning", + duration: SWAP_TOAST_DURATION_MS + } + }).catch(() => {}) + }, TOAST_DELAY_MS) + toastedSessions.add(input.sessionID) + } + } + } + }, + + "event": async (input: { event: { type: string; properties?: unknown } }) => { + if (input.event.type === "session.deleted") { + const id = (input.event.properties as any)?.info?.id + if (id) { + sessionContext.delete(id) + toastedSessions.delete(id) + pendingFailovers.delete(id) + } + } + + if (input.event.type === "session.idle") { + const props = input.event.properties as { sessionID?: string } + const sessionID = props.sessionID + if (sessionID) { + const sessionCtx = sessionContext.get(sessionID) + if (sessionCtx && statusManager.getStatus(sessionCtx.modelKey) === "PROBATION") { + statusManager.markHealthy(sessionCtx.modelKey) + log(`[SmartFailover] ${sessionCtx.modelKey} recovered from PROBATION`, { sessionID }) + } + } + } + + if (input.event.type === "session.status") { + const props = input.event.properties as { status?: { type?: string; message?: string }, sessionID?: string } + if (props.status?.type === "retry") { + const sessionID = props.sessionID + if (!sessionID) return + const sessionCtx = sessionContext.get(sessionID) + + if (sessionCtx && statusManager.getStatus(sessionCtx.modelKey) !== "COOLING") { + const reason = props.status.message || "Retry loop detected" + statusManager.markCooling(sessionCtx.modelKey, RETRY_LOOP_COOLDOWN_MS, reason) + await performFailover(sessionID, sessionCtx.modelKey, sessionCtx.agent, reason) + } + } + } + + if (input.event.type === "session.error") { + const props = input.event.properties as { error?: unknown; sessionID?: string } + const sessionID = props.sessionID + if (!sessionID) return + + if (String(props.error).includes("AbortError") || String(props.error).includes("Aborted")) return + + const sessionCtx = sessionContext.get(sessionID) + if (!sessionCtx) return + + const result = ErrorDiagnoser.diagnose(props.error) + + if (result.action === "COOLING") { + + const currentState = statusManager.getState(sessionCtx.modelKey) + const retryCount = currentState?.retryCount ?? 0 + const backoffMultiplier = Math.pow(2, Math.min(retryCount, MAX_BACKOFF_EXPONENT)) + const duration = Math.min( + (result.cooldownMs ?? DEFAULT_COOLDOWN_MS) * backoffMultiplier, + MAX_COOLDOWN_MS + ) + + statusManager.markCooling(sessionCtx.modelKey, duration, result.reason) + + await performFailover(sessionID, sessionCtx.modelKey, sessionCtx.agent, result.reason) + + } else if (result.action === "LOCKED") { + statusManager.markLocked(sessionCtx.modelKey, result.reason) + await performFailover(sessionID, sessionCtx.modelKey, sessionCtx.agent, result.reason) + + ctx.client.tui.showToast({ + body: { + title: "Provider Locked", + message: `🛑 ${sessionCtx.modelKey} locked (Balance/Quota). Update config to reset.`, + variant: "error", + duration: PROVIDER_LOCKED_TOAST_DURATION_MS + } + }).catch(() => {}) + } + } + } + } +} diff --git a/src/index.ts b/src/index.ts index d3375c4210..51cdcbf8a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ import { createStartWorkHook, createAtlasHook, createPrometheusMdOnlyHook, + createSmartFailoverHook, createQuestionLabelTruncatorHook, } from "./hooks"; import { @@ -214,6 +215,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? createAtlasHook(ctx, { directory: ctx.directory, backgroundManager }) : null; + const smartFailover = isHookEnabled("smart-failover") + ? createSmartFailoverHook(ctx, pluginConfig, modelCacheState) + : null; + initTaskToastManager(ctx.client); const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") @@ -238,13 +243,21 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { "multimodal-looker" ); const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null; + const sisyphusJuniorModelConfig = pluginConfig.agents?.["sisyphus-junior"]?.model + const sisyphusJuniorModel = Array.isArray(sisyphusJuniorModelConfig) + ? sisyphusJuniorModelConfig.map((m) => m.trim()).find((m) => m.length > 0) + : typeof sisyphusJuniorModelConfig === "string" + ? sisyphusJuniorModelConfig.includes("|") + ? sisyphusJuniorModelConfig.split("|")[0].trim() || undefined + : sisyphusJuniorModelConfig.trim() || undefined + : undefined const delegateTask = createDelegateTask({ manager: backgroundManager, client: ctx.client, directory: ctx.directory, userCategories: pluginConfig.categories, gitMasterConfig: pluginConfig.git_master, - sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model, + sisyphusJuniorModel, }); const disabledSkills = new Set(pluginConfig.disabled_skills ?? []); const systemMcpNames = getSystemMcpServerNames(); @@ -335,6 +348,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await claudeCodeHooks["chat.message"]?.(input, output); await autoSlashCommand?.["chat.message"]?.(input, output); await startWork?.["chat.message"]?.(input, output); + await smartFailover?.["chat.message"]?.(input, output); if (ralphLoop) { const parts = ( @@ -421,6 +435,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await interactiveBashSession?.event(input); await ralphLoop?.event(input); await atlasHook?.handler(input); + await smartFailover?.event(input); const { event } = input; const props = event.properties as Record | undefined; diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index f571d05fb3..1c6b43f743 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -105,22 +105,37 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { log(`Plugin load errors`, { errors: pluginComponents.errors }); } - if (!(config.model as string | undefined)?.trim()) { + const extractPrimaryModel = (model?: string | string[]) => { + if (Array.isArray(model)) { + return model.map((m) => m.trim()).find((m) => m.length > 0) + } + if (typeof model === "string") { + if (model.includes("|")) return model.split("|")[0].trim() || undefined + return model.trim() || undefined + } + return undefined + } + + let systemDefaultModel = extractPrimaryModel(config.model as string | string[] | undefined) + + if (!systemDefaultModel) { let fallbackModel: string | undefined for (const agentConfig of Object.values(pluginConfig.agents ?? {})) { - const model = (agentConfig as { model?: string })?.model - if (model && typeof model === 'string' && model.trim()) { - fallbackModel = model.trim() + const model = (agentConfig as { model?: string | string[] })?.model + const primary = extractPrimaryModel(model) + if (primary) { + fallbackModel = primary break } } if (!fallbackModel) { for (const categoryConfig of Object.values(pluginConfig.categories ?? {})) { - const model = (categoryConfig as { model?: string })?.model - if (model && typeof model === 'string' && model.trim()) { - fallbackModel = model.trim() + const model = (categoryConfig as { model?: string | string[] })?.model + const primary = extractPrimaryModel(model) + if (primary) { + fallbackModel = primary break } } @@ -128,14 +143,15 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { if (fallbackModel) { config.model = fallbackModel + systemDefaultModel = fallbackModel log(`No default model specified, using fallback from config: ${fallbackModel}`) } else { const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) throw new Error( 'oh-my-opencode requires a default model.\n\n' + - `Add this to ${paths.configJsonc}:\n\n` + - ' "model": "anthropic/claude-sonnet-4-5"\n\n' + - '(Replace with your preferred provider/model)' + `Add this to ${paths.configJsonc}:\n\n` + + ' "model": "anthropic/claude-sonnet-4-5"\n\n' + + "(Replace with your preferred provider/model)" ) } } @@ -169,7 +185,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { migratedDisabledAgents, pluginConfig.agents, ctx.directory, - config.model as string | undefined, + systemDefaultModel, pluginConfig.categories, pluginConfig.git_master, allDiscoveredSkills, @@ -225,7 +241,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides( pluginConfig.agents?.["sisyphus-junior"], - config.model as string | undefined + systemDefaultModel ); if (builderEnabled) { @@ -256,7 +272,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { pluginConfig.agents?.["prometheus"] as | (Record & { category?: string; model?: string }) | undefined; - const defaultModel = config.model as string | undefined; + const defaultModel = systemDefaultModel; // Resolve full category config (model, temperature, top_p, tools, etc.) // Apply all category properties when category is specified, but explicit diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index 1528b973cb..f478fad18a 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -28,6 +28,17 @@ function parseModelString(model: string): { providerID: string; modelID: string return undefined } +function extractPrimaryModel(model?: string | string[]): string | undefined { + if (Array.isArray(model)) { + return model.map((m) => m.trim()).find((m) => m.length > 0) + } + if (typeof model === "string") { + if (model.includes("|")) return model.split("|")[0].trim() || undefined + return model.trim() || undefined + } + return undefined +} + function getMessageDir(sessionID: string): string | null { if (!existsSync(MESSAGE_STORAGE)) return null @@ -128,9 +139,12 @@ export function resolveCategoryConfig( // Model priority for categories: user override > category default > system default // Categories have explicit models - no inheritance from parent session + const userModel = userConfig?.model + const inheritedModelVal = defaultConfig?.model + const model = resolveModel({ - userModel: userConfig?.model, - inheritedModel: defaultConfig?.model, // Category's built-in model takes precedence over system default + userModel: Array.isArray(userModel) ? userModel[0] : userModel, + inheritedModel: Array.isArray(inheritedModelVal) ? inheritedModelVal[0] : inheritedModelVal, // Category's built-in model takes precedence over system default systemDefault: systemDefaultModel, }) const config: CategoryConfig = { @@ -513,8 +527,9 @@ To resume this session: resume="${args.resume}"` actualModel = resolved.model modelInfo = { model: actualModel, type: "system-default", source: "system-default" } } else { + const userModel = extractPrimaryModel(userCategories?.[args.category]?.model) ?? sisyphusJuniorModel const { model: resolvedModel, source, variant: resolvedVariant } = resolveModelWithFallback({ - userModel: userCategories?.[args.category]?.model ?? sisyphusJuniorModel, + userModel, fallbackChain: requirement.fallbackChain, availableModels, systemDefaultModel,