diff --git a/src/cli/model-fallback.test.ts b/src/cli/model-fallback.test.ts new file mode 100644 index 000000000..61e1ad7d7 --- /dev/null +++ b/src/cli/model-fallback.test.ts @@ -0,0 +1,423 @@ +import { describe, expect, test } from "bun:test" + +import { generateModelConfig } from "./model-fallback" +import type { InstallConfig } from "./types" + +function createConfig(overrides: Partial = {}): InstallConfig { + return { + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + ...overrides, + } +} + +describe("generateModelConfig", () => { + describe("no providers available", () => { + test("returns ULTIMATE_FALLBACK for all agents and categories when no providers", () => { + // #given no providers are available + const config = createConfig() + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use ULTIMATE_FALLBACK for everything + expect(result).toMatchSnapshot() + }) + }) + + describe("single native provider", () => { + test("uses Claude models when only Claude is available", () => { + // #given only Claude is available + const config = createConfig({ hasClaude: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use Claude models per NATIVE_FALLBACK_CHAINS + expect(result).toMatchSnapshot() + }) + + test("uses Claude models with isMax20 flag", () => { + // #given Claude is available with Max 20 plan + const config = createConfig({ hasClaude: true, isMax20: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use higher capability models for Sisyphus + expect(result).toMatchSnapshot() + }) + + test("uses OpenAI models when only OpenAI is available", () => { + // #given only OpenAI is available + const config = createConfig({ hasOpenAI: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use OpenAI models + expect(result).toMatchSnapshot() + }) + + test("uses OpenAI models with isMax20 flag", () => { + // #given OpenAI is available with Max 20 plan + const config = createConfig({ hasOpenAI: true, isMax20: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use higher capability models + expect(result).toMatchSnapshot() + }) + + test("uses Gemini models when only Gemini is available", () => { + // #given only Gemini is available + const config = createConfig({ hasGemini: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use Gemini models + expect(result).toMatchSnapshot() + }) + + test("uses Gemini models with isMax20 flag", () => { + // #given Gemini is available with Max 20 plan + const config = createConfig({ hasGemini: true, isMax20: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use higher capability models + expect(result).toMatchSnapshot() + }) + }) + + describe("all native providers", () => { + test("uses preferred models from fallback chains when all natives available", () => { + // #given all native providers are available + const config = createConfig({ + hasClaude: true, + hasOpenAI: true, + hasGemini: true, + }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use first provider in each fallback chain + expect(result).toMatchSnapshot() + }) + + test("uses preferred models with isMax20 flag when all natives available", () => { + // #given all native providers are available with Max 20 plan + const config = createConfig({ + hasClaude: true, + hasOpenAI: true, + hasGemini: true, + isMax20: true, + }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use higher capability models + expect(result).toMatchSnapshot() + }) + }) + + describe("fallback providers", () => { + test("uses OpenCode Zen models when only OpenCode Zen is available", () => { + // #given only OpenCode Zen is available + const config = createConfig({ hasOpencodeZen: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use OPENCODE_ZEN_MODELS + expect(result).toMatchSnapshot() + }) + + test("uses OpenCode Zen models with isMax20 flag", () => { + // #given OpenCode Zen is available with Max 20 plan + const config = createConfig({ hasOpencodeZen: true, isMax20: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use higher capability models + expect(result).toMatchSnapshot() + }) + + test("uses GitHub Copilot models when only Copilot is available", () => { + // #given only GitHub Copilot is available + const config = createConfig({ hasCopilot: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use GITHUB_COPILOT_MODELS + expect(result).toMatchSnapshot() + }) + + test("uses GitHub Copilot models with isMax20 flag", () => { + // #given GitHub Copilot is available with Max 20 plan + const config = createConfig({ hasCopilot: true, isMax20: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use higher capability models + expect(result).toMatchSnapshot() + }) + + test("uses ZAI model for librarian when only ZAI is available", () => { + // #given only ZAI is available + const config = createConfig({ hasZaiCodingPlan: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use ZAI_MODEL for librarian + expect(result).toMatchSnapshot() + }) + + test("uses ZAI model for librarian with isMax20 flag", () => { + // #given ZAI is available with Max 20 plan + const config = createConfig({ hasZaiCodingPlan: true, isMax20: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use ZAI_MODEL for librarian + expect(result).toMatchSnapshot() + }) + }) + + describe("mixed provider scenarios", () => { + test("uses Claude + OpenCode Zen combination", () => { + // #given Claude and OpenCode Zen are available + const config = createConfig({ + hasClaude: true, + hasOpencodeZen: true, + }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should prefer Claude (native) over OpenCode Zen + expect(result).toMatchSnapshot() + }) + + test("uses OpenAI + Copilot combination", () => { + // #given OpenAI and Copilot are available + const config = createConfig({ + hasOpenAI: true, + hasCopilot: true, + }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should prefer OpenAI (native) over Copilot + expect(result).toMatchSnapshot() + }) + + test("uses Claude + ZAI combination (librarian uses ZAI)", () => { + // #given Claude and ZAI are available + const config = createConfig({ + hasClaude: true, + hasZaiCodingPlan: true, + }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then librarian should use ZAI, others use Claude + expect(result).toMatchSnapshot() + }) + + test("uses Gemini + Claude combination (explore uses Gemini)", () => { + // #given Gemini and Claude are available + const config = createConfig({ + hasGemini: true, + hasClaude: true, + }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then explore should use Gemini flash + expect(result).toMatchSnapshot() + }) + + test("uses all fallback providers together", () => { + // #given all fallback providers are available + const config = createConfig({ + hasOpencodeZen: true, + hasCopilot: true, + hasZaiCodingPlan: true, + }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should prefer OpenCode Zen, but librarian uses ZAI + expect(result).toMatchSnapshot() + }) + + test("uses all providers together", () => { + // #given all providers are available + const config = createConfig({ + hasClaude: true, + hasOpenAI: true, + hasGemini: true, + hasOpencodeZen: true, + hasCopilot: true, + hasZaiCodingPlan: true, + }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should prefer native providers, librarian uses ZAI + expect(result).toMatchSnapshot() + }) + + test("uses all providers with isMax20 flag", () => { + // #given all providers are available with Max 20 plan + const config = createConfig({ + hasClaude: true, + hasOpenAI: true, + hasGemini: true, + hasOpencodeZen: true, + hasCopilot: true, + hasZaiCodingPlan: true, + isMax20: true, + }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should use higher capability models + expect(result).toMatchSnapshot() + }) + }) + + describe("explore agent special cases", () => { + test("explore uses Gemini flash when Gemini available", () => { + // #given Gemini is available + const config = createConfig({ hasGemini: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then explore should use gemini-3-flash-preview + expect(result.agents?.explore?.model).toBe("google/gemini-3-flash-preview") + }) + + test("explore uses Claude haiku when Claude + isMax20 but no Gemini", () => { + // #given Claude is available with Max 20 plan but no Gemini + const config = createConfig({ hasClaude: true, isMax20: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then explore should use claude-haiku-4-5 + expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5") + }) + + test("explore uses grok-code when Claude without isMax20 and no Gemini", () => { + // #given Claude is available without Max 20 plan and no Gemini + const config = createConfig({ hasClaude: true, isMax20: false }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then explore should use grok-code + expect(result.agents?.explore?.model).toBe("opencode/grok-code") + }) + + test("explore uses grok-code when only OpenAI available", () => { + // #given only OpenAI is available + const config = createConfig({ hasOpenAI: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then explore should use grok-code (fallback) + expect(result.agents?.explore?.model).toBe("opencode/grok-code") + }) + }) + + describe("Sisyphus agent special cases", () => { + test("Sisyphus uses sisyphus-high capability when isMax20 is true", () => { + // #given Claude is available with Max 20 plan + const config = createConfig({ hasClaude: true, isMax20: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then Sisyphus should use opus (sisyphus-high) + expect(result.agents?.Sisyphus?.model).toBe("anthropic/claude-opus-4-5") + }) + + test("Sisyphus uses sisyphus-low capability when isMax20 is false", () => { + // #given Claude is available without Max 20 plan + const config = createConfig({ hasClaude: true, isMax20: false }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then Sisyphus should use sonnet (sisyphus-low) + expect(result.agents?.Sisyphus?.model).toBe("anthropic/claude-sonnet-4-5") + }) + }) + + describe("librarian agent special cases", () => { + test("librarian uses ZAI when ZAI is available regardless of other providers", () => { + // #given ZAI and Claude are available + const config = createConfig({ + hasClaude: true, + hasZaiCodingPlan: true, + }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then librarian should use ZAI_MODEL + expect(result.agents?.librarian?.model).toBe("zai-coding-plan/glm-4.7") + }) + + test("librarian uses claude-sonnet when ZAI not available but Claude is", () => { + // #given only Claude is available (no ZAI) + const config = createConfig({ hasClaude: true }) + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then librarian should use claude-sonnet-4-5 (third in fallback chain after ZAI and opencode/glm) + expect(result.agents?.librarian?.model).toBe("anthropic/claude-sonnet-4-5") + }) + }) + + describe("schema URL", () => { + test("always includes correct schema URL", () => { + // #given any config + const config = createConfig() + + // #when generateModelConfig is called + const result = generateModelConfig(config) + + // #then should include correct schema URL + expect(result.$schema).toBe( + "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json" + ) + }) + }) +}) diff --git a/src/cli/model-fallback.ts b/src/cli/model-fallback.ts index 206a72768..11dd409c1 100644 --- a/src/cli/model-fallback.ts +++ b/src/cli/model-fallback.ts @@ -1,17 +1,10 @@ +import { + AGENT_MODEL_REQUIREMENTS, + CATEGORY_MODEL_REQUIREMENTS, + type FallbackEntry, +} from "../shared/model-requirements" import type { InstallConfig } from "./types" -type NativeProvider = "claude" | "openai" | "gemini" - -type ModelCapability = - | "unspecified-high" - | "unspecified-low" - | "quick" - | "ultrabrain" - | "visual-engineering" - | "artistry" - | "writing" - | "glm" - interface ProviderAvailability { native: { claude: boolean @@ -41,106 +34,8 @@ export interface GeneratedOmoConfig { [key: string]: unknown } -interface NativeFallbackEntry { - provider: NativeProvider - model: string -} - -const NATIVE_FALLBACK_CHAINS: Record = { - "unspecified-high": [ - { provider: "claude", model: "anthropic/claude-opus-4-5" }, - { provider: "openai", model: "openai/gpt-5.2" }, - { provider: "gemini", model: "google/gemini-3-pro-preview" }, - ], - "unspecified-low": [ - { provider: "claude", model: "anthropic/claude-sonnet-4-5" }, - { provider: "openai", model: "openai/gpt-5.2" }, - { provider: "gemini", model: "google/gemini-3-flash-preview" }, - ], - quick: [ - { provider: "claude", model: "anthropic/claude-haiku-4-5" }, - { provider: "openai", model: "openai/gpt-5.1-codex-mini" }, - { provider: "gemini", model: "google/gemini-3-flash-preview" }, - ], - ultrabrain: [ - { provider: "openai", model: "openai/gpt-5.2-codex" }, - { provider: "claude", model: "anthropic/claude-opus-4-5" }, - { provider: "gemini", model: "google/gemini-3-pro-preview" }, - ], - "visual-engineering": [ - { provider: "gemini", model: "google/gemini-3-pro-preview" }, - { provider: "openai", model: "openai/gpt-5.2" }, - { provider: "claude", model: "anthropic/claude-sonnet-4-5" }, - ], - artistry: [ - { provider: "gemini", model: "google/gemini-3-pro-preview" }, - { provider: "openai", model: "openai/gpt-5.2" }, - { provider: "claude", model: "anthropic/claude-opus-4-5" }, - ], - writing: [ - { provider: "gemini", model: "google/gemini-3-flash-preview" }, - { provider: "openai", model: "openai/gpt-5.2" }, - { provider: "claude", model: "anthropic/claude-sonnet-4-5" }, - ], - glm: [], -} - -const OPENCODE_ZEN_MODELS: Record = { - "unspecified-high": "opencode/claude-opus-4-5", - "unspecified-low": "opencode/claude-sonnet-4-5", - quick: "opencode/claude-haiku-4-5", - ultrabrain: "opencode/gpt-5.2-codex", - "visual-engineering": "opencode/gemini-3-pro", - artistry: "opencode/gemini-3-pro", - writing: "opencode/gemini-3-flash", - glm: "opencode/glm-4.7-free", -} - -const GITHUB_COPILOT_MODELS: Record = { - "unspecified-high": "github-copilot/claude-opus-4.5", - "unspecified-low": "github-copilot/claude-sonnet-4.5", - quick: "github-copilot/claude-haiku-4.5", - ultrabrain: "github-copilot/gpt-5.2-codex", - "visual-engineering": "github-copilot/gemini-3-pro-preview", - artistry: "github-copilot/gemini-3-pro-preview", - writing: "github-copilot/gemini-3-flash-preview", - glm: "github-copilot/gpt-5.2", -} - const ZAI_MODEL = "zai-coding-plan/glm-4.7" -interface AgentRequirement { - capability: ModelCapability - variant?: string -} - -const AGENT_REQUIREMENTS: Record = { - Sisyphus: { capability: "unspecified-high" }, - oracle: { capability: "ultrabrain", variant: "high" }, - librarian: { capability: "glm" }, - explore: { capability: "quick" }, - "multimodal-looker": { capability: "visual-engineering" }, - "Prometheus (Planner)": { capability: "unspecified-high" }, - "Metis (Plan Consultant)": { capability: "unspecified-high" }, - "Momus (Plan Reviewer)": { capability: "ultrabrain", variant: "medium" }, - Atlas: { capability: "unspecified-high" }, -} - -interface CategoryRequirement { - capability: ModelCapability - variant?: string -} - -const CATEGORY_REQUIREMENTS: Record = { - "visual-engineering": { capability: "visual-engineering" }, - ultrabrain: { capability: "ultrabrain" }, - artistry: { capability: "artistry", variant: "max" }, - quick: { capability: "quick" }, - "unspecified-low": { capability: "unspecified-low" }, - "unspecified-high": { capability: "unspecified-high" }, - writing: { capability: "writing" }, -} - const ULTIMATE_FALLBACK = "opencode/glm-4.7-free" const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json" @@ -158,31 +53,46 @@ function toProviderAvailability(config: InstallConfig): ProviderAvailability { } } -function resolveModel(capability: ModelCapability, avail: ProviderAvailability): string { - const nativeChain = NATIVE_FALLBACK_CHAINS[capability] - for (const entry of nativeChain) { - if (avail.native[entry.provider]) { - return entry.model - } +function isProviderAvailable(provider: string, avail: ProviderAvailability): boolean { + const mapping: Record = { + anthropic: avail.native.claude, + openai: avail.native.openai, + google: avail.native.gemini, + "github-copilot": avail.copilot, + opencode: avail.opencodeZen, + "zai-coding-plan": avail.zai, } - - if (avail.opencodeZen) { - return OPENCODE_ZEN_MODELS[capability] - } - - if (avail.copilot) { - return GITHUB_COPILOT_MODELS[capability] - } - - if (avail.zai) { - return ZAI_MODEL - } - - return ULTIMATE_FALLBACK + return mapping[provider] ?? false } -function resolveClaudeCapability(avail: ProviderAvailability): ModelCapability { - return avail.isMaxPlan ? "unspecified-high" : "unspecified-low" +function resolveModelFromChain( + fallbackChain: FallbackEntry[], + avail: ProviderAvailability +): { model: string; variant?: string } | null { + for (const entry of fallbackChain) { + for (const provider of entry.providers) { + if (isProviderAvailable(provider, avail)) { + return { + model: `${provider}/${entry.model}`, + variant: entry.variant, + } + } + } + } + return null +} + +function getSisyphusFallbackChain(isMaxPlan: boolean): FallbackEntry[] { + // Sisyphus uses opus when isMaxPlan, sonnet otherwise + if (isMaxPlan) { + return AGENT_MODEL_REQUIREMENTS.Sisyphus.fallbackChain + } + // For non-max plan, use sonnet instead of opus + return [ + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" }, + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" }, + ] } export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig { @@ -199,10 +109,10 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig { return { $schema: SCHEMA_URL, agents: Object.fromEntries( - Object.keys(AGENT_REQUIREMENTS).map((role) => [role, { model: ULTIMATE_FALLBACK }]) + Object.keys(AGENT_MODEL_REQUIREMENTS).map((role) => [role, { model: ULTIMATE_FALLBACK }]) ), categories: Object.fromEntries( - Object.keys(CATEGORY_REQUIREMENTS).map((cat) => [cat, { model: ULTIMATE_FALLBACK }]) + Object.keys(CATEGORY_MODEL_REQUIREMENTS).map((cat) => [cat, { model: ULTIMATE_FALLBACK }]) ), } } @@ -210,28 +120,52 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig { const agents: Record = {} const categories: Record = {} - const claudeCapability = resolveClaudeCapability(avail) - - for (const [role, req] of Object.entries(AGENT_REQUIREMENTS)) { + for (const [role, req] of Object.entries(AGENT_MODEL_REQUIREMENTS)) { + // Special case: librarian always uses ZAI first if available if (role === "librarian" && avail.zai) { agents[role] = { model: ZAI_MODEL } - } else if (role === "explore") { - if (avail.native.claude && avail.isMaxPlan) { + continue + } + + // Special case: explore has custom Gemini → Claude → Grok logic + if (role === "explore") { + if (avail.native.gemini) { + agents[role] = { model: "google/gemini-3-flash-preview" } + } else if (avail.native.claude && avail.isMaxPlan) { agents[role] = { model: "anthropic/claude-haiku-4-5" } } else { agents[role] = { model: "opencode/grok-code" } } + continue + } + + // Special case: Sisyphus uses different fallbackChain based on isMaxPlan + const fallbackChain = + role === "Sisyphus" ? getSisyphusFallbackChain(avail.isMaxPlan) : req.fallbackChain + + const resolved = resolveModelFromChain(fallbackChain, avail) + if (resolved) { + const variant = resolved.variant ?? req.variant + agents[role] = variant ? { model: resolved.model, variant } : { model: resolved.model } } else { - const capability = req.capability === "unspecified-high" ? claudeCapability : req.capability - const model = resolveModel(capability, avail) - agents[role] = req.variant ? { model, variant: req.variant } : { model } + agents[role] = { model: ULTIMATE_FALLBACK } } } - for (const [cat, req] of Object.entries(CATEGORY_REQUIREMENTS)) { - const capability = req.capability === "unspecified-high" ? claudeCapability : req.capability - const model = resolveModel(capability, avail) - categories[cat] = req.variant ? { model, variant: req.variant } : { model } + for (const [cat, req] of Object.entries(CATEGORY_MODEL_REQUIREMENTS)) { + // Special case: unspecified-high downgrades to unspecified-low when not isMaxPlan + const fallbackChain = + cat === "unspecified-high" && !avail.isMaxPlan + ? CATEGORY_MODEL_REQUIREMENTS["unspecified-low"].fallbackChain + : req.fallbackChain + + const resolved = resolveModelFromChain(fallbackChain, avail) + if (resolved) { + const variant = resolved.variant ?? req.variant + categories[cat] = variant ? { model: resolved.model, variant } : { model: resolved.model } + } else { + categories[cat] = { model: ULTIMATE_FALLBACK } + } } return {