diff --git a/src/cli/__snapshots__/model-fallback.test.ts.snap b/src/cli/__snapshots__/model-fallback.test.ts.snap index 036f8cc55..e6dee4dc8 100644 --- a/src/cli/__snapshots__/model-fallback.test.ts.snap +++ b/src/cli/__snapshots__/model-fallback.test.ts.snap @@ -915,6 +915,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on "model": "opencode/claude-sonnet-4-6", }, "explore": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "opencode/claude-haiku-4-5", }, "hephaestus": { @@ -1132,6 +1140,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is "model": "opencode/claude-sonnet-4-6", }, "explore": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "opencode/claude-haiku-4-5", }, "hephaestus": { @@ -1353,6 +1369,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when "model": "github-copilot/claude-sonnet-4.6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + ], "model": "github-copilot/gpt-5-mini", }, "hephaestus": { @@ -1534,6 +1555,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with "model": "github-copilot/claude-sonnet-4.6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + ], "model": "github-copilot/gpt-5-mini", }, "hephaestus": { @@ -1842,6 +1868,17 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "model": "anthropic/claude-sonnet-4-6", }, "explore": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "anthropic/claude-haiku-4-5", }, "hephaestus": { @@ -2114,6 +2151,11 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb "model": "github-copilot/claude-sonnet-4.6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + ], "model": "github-copilot/gpt-5-mini", }, "hephaestus": { @@ -2353,6 +2395,11 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat "model": "anthropic/claude-haiku-4-5", }, "librarian": { + "fallback_models": [ + { + "model": "anthropic/claude-haiku-4-5", + }, + ], "model": "zai-coding-plan/glm-4.7", }, "metis": { @@ -2573,6 +2620,17 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider "model": "github-copilot/claude-sonnet-4.6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "opencode/claude-haiku-4-5", }, "hephaestus": { @@ -2586,6 +2644,17 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider "variant": "medium", }, "librarian": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7-highspeed", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "zai-coding-plan/glm-4.7", }, "metis": { @@ -2949,6 +3018,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "model": "anthropic/claude-sonnet-4-6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "anthropic/claude-haiku-4-5", }, "hephaestus": { @@ -2966,6 +3049,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "variant": "medium", }, "librarian": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7-highspeed", + }, + { + "model": "anthropic/claude-haiku-4-5", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "zai-coding-plan/glm-4.7", }, "metis": { @@ -3472,6 +3569,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "model": "anthropic/claude-sonnet-4-6", }, "explore": { + "fallback_models": [ + { + "model": "github-copilot/grok-code-fast-1", + }, + { + "model": "opencode/minimax-m2.7", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "anthropic/claude-haiku-4-5", }, "hephaestus": { @@ -3489,6 +3600,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "variant": "medium", }, "librarian": { + "fallback_models": [ + { + "model": "opencode/minimax-m2.7-highspeed", + }, + { + "model": "anthropic/claude-haiku-4-5", + }, + { + "model": "opencode/claude-haiku-4-5", + }, + { + "model": "opencode/gpt-5-nano", + }, + ], "model": "zai-coding-plan/glm-4.7", }, "metis": { diff --git a/src/cli/model-fallback.test.ts b/src/cli/model-fallback.test.ts index 888f5336b..57ff2c16e 100644 --- a/src/cli/model-fallback.test.ts +++ b/src/cli/model-fallback.test.ts @@ -549,6 +549,58 @@ describe("generateModelConfig", () => { }) }) + describe("special-case agents include fallback_models", () => { + test("explore includes fallback_models when Copilot and Claude are both available", () => { + // #given both Copilot and Claude are available + const config = createConfig({ hasCopilot: true, hasClaude: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then explore should have fallback_models from the remaining chain entries + expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5") + expect(result.agents?.explore?.fallback_models).toBeDefined() + expect(result.agents?.explore?.fallback_models?.length).toBeGreaterThan(0) + }) + + test("explore omits fallback_models when only one provider matches chain entries", () => { + // #given only Claude is available + const config = createConfig({ hasClaude: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then explore should not have fallback_models (only one chain entry matches) + expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5") + expect(result.agents?.explore?.fallback_models).toBeUndefined() + }) + + test("librarian includes fallback_models when opencode-go and Claude are both available", () => { + // #given opencode-go and Claude are available + const config = createConfig({ hasOpencodeGo: true, hasClaude: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then librarian should have fallback_models + expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7") + expect(result.agents?.librarian?.fallback_models).toBeDefined() + expect(result.agents?.librarian?.fallback_models?.length).toBeGreaterThan(0) + }) + + test("librarian omits fallback_models when only one provider matches", () => { + // #given only opencode-go is available + const config = createConfig({ hasOpencodeGo: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then librarian should not have fallback_models + expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7") + expect(result.agents?.librarian?.fallback_models).toBeUndefined() + }) + }) + describe("schema URL", () => { test("always includes correct schema URL", () => { // #given any config diff --git a/src/cli/model-fallback.ts b/src/cli/model-fallback.ts index aa2ef0e74..5dabb9fc1 100644 --- a/src/cli/model-fallback.ts +++ b/src/cli/model-fallback.ts @@ -37,22 +37,29 @@ function toFallbackModelObject(entry: FallbackEntry, provider: string): Fallback } } -function attachFallbackModels( - config: T, +function collectAvailableFallbacks( fallbackChain: FallbackEntry[], availability: ReturnType, -): T { +): FallbackModelObject[] { const expandedFallbacks = fallbackChain.flatMap((entry) => entry.providers .filter((provider) => isProviderAvailable(provider, availability)) .map((provider) => toFallbackModelObject(entry, provider)) ) - const uniqueFallbacks = expandedFallbacks.filter((entry, index, allEntries) => + return expandedFallbacks.filter((entry, index, allEntries) => allEntries.findIndex((candidate) => candidate.model === entry.model && candidate.variant === entry.variant ) === index ) +} + +function attachFallbackModels( + config: T, + fallbackChain: FallbackEntry[], + availability: ReturnType, +): T { + const uniqueFallbacks = collectAvailableFallbacks(fallbackChain, availability) const primaryIndex = uniqueFallbacks.findIndex((entry) => entry.model === config.model) if (primaryIndex === -1) { return config @@ -69,6 +76,23 @@ function attachFallbackModels( } } +function attachAllFallbackModels( + config: T, + fallbackChain: FallbackEntry[], + availability: ReturnType, +): T { + const uniqueFallbacks = collectAvailableFallbacks(fallbackChain, availability) + const fallbackModels = uniqueFallbacks.filter((entry) => entry.model !== config.model) + if (fallbackModels.length === 0) { + return config + } + + return { + ...config, + fallback_models: fallbackModels, + } +} + export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig { @@ -101,26 +125,32 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig { for (const [role, req] of Object.entries(CLI_AGENT_MODEL_REQUIREMENTS)) { if (role === "librarian") { + let agentConfig: AgentConfig | undefined if (avail.opencodeGo) { - agents[role] = { model: "opencode-go/minimax-m2.7" } + agentConfig = { model: "opencode-go/minimax-m2.7" } } else if (avail.zai) { - agents[role] = { model: ZAI_MODEL } + agentConfig = { model: ZAI_MODEL } + } + if (agentConfig) { + agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail) } continue } if (role === "explore") { + let agentConfig: AgentConfig if (avail.native.claude) { - agents[role] = { model: "anthropic/claude-haiku-4-5" } + agentConfig = { model: "anthropic/claude-haiku-4-5" } } else if (avail.opencodeZen) { - agents[role] = { model: "opencode/claude-haiku-4-5" } + agentConfig = { model: "opencode/claude-haiku-4-5" } } else if (avail.opencodeGo) { - agents[role] = { model: "opencode-go/minimax-m2.7" } + agentConfig = { model: "opencode-go/minimax-m2.7" } } else if (avail.copilot) { - agents[role] = { model: "github-copilot/gpt-5-mini" } + agentConfig = { model: "github-copilot/gpt-5-mini" } } else { - agents[role] = { model: "opencode/gpt-5-nano" } + agentConfig = { model: "opencode/gpt-5-nano" } } + agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail) continue }