diff --git a/.changeset/fn-7374-anthropic-sonnet-5-404.md b/.changeset/fn-7374-anthropic-sonnet-5-404.md new file mode 100644 index 0000000000..4bbd6ed39b --- /dev/null +++ b/.changeset/fn-7374-anthropic-sonnet-5-404.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop force-advertising Anthropic Claude Sonnet 5 when account availability is unknown. +category: fix +dev: Removes static Sonnet 5 supplemental catalog/pricing metadata while preserving fallback handling for saved selections. diff --git a/packages/core/src/__tests__/model-pricing.test.ts b/packages/core/src/__tests__/model-pricing.test.ts index 02895052ac..4381bd7967 100644 --- a/packages/core/src/__tests__/model-pricing.test.ts +++ b/packages/core/src/__tests__/model-pricing.test.ts @@ -36,9 +36,7 @@ describe("model-pricing", () => { expect(result.usd).toBeCloseTo(10.0, 2); }); - it("prices Claude Sonnet 5 for Anthropic and bare-model fallback", () => { - // claude-sonnet-5 introductory pricing: input $2/1M, output $10/1M, - // cache read $0.20/1M, 5m cache write $2.50/1M. + it("reports direct Anthropic Claude Sonnet 5 pricing as unavailable without a static catalog row", () => { const usage = { inputTokens: 1_000_000, outputTokens: 200_000, @@ -47,12 +45,12 @@ describe("model-pricing", () => { }; const anthropic = costFor(usage, { provider: "anthropic", model: "claude-sonnet-5" }); - expect(anthropic.unavailable).toBe(false); - expect(anthropic.usd).toBeCloseTo(5.1, 3); + expect(anthropic.unavailable).toBe(true); + expect(anthropic.usd).toBeNull(); const bare = costFor(usage, { model: "claude-sonnet-5" }); - expect(bare.unavailable).toBe(false); - expect(bare.usd).toBeCloseTo(5.1, 3); + expect(bare.unavailable).toBe(true); + expect(bare.usd).toBeNull(); }); it("prices OpenAI Codex GPT-5 models instead of reporting unavailable", () => { @@ -206,13 +204,11 @@ describe("model-pricing", () => { ).toBe(MODEL_PRICING["openai-codex:gpt-5-codex"]); }); - it("resolves Claude Sonnet 5 by explicit provider and bare model", () => { + it("does not resolve static pricing for direct Anthropic Claude Sonnet 5", () => { expect( lookupPricing({ provider: " Anthropic ", model: " Claude-Sonnet-5 " }), - ).toBe(MODEL_PRICING["anthropic:claude-sonnet-5"]); - expect(lookupPricing({ model: "claude-sonnet-5" })).toBe( - MODEL_PRICING["anthropic:claude-sonnet-5"], - ); + ).toBeUndefined(); + expect(lookupPricing({ model: "claude-sonnet-5" })).toBeUndefined(); }); it("falls back to a bare model id when provider is unset", () => { diff --git a/packages/core/src/anthropic-models.ts b/packages/core/src/anthropic-models.ts index c46586736d..fa4e1b07d1 100644 --- a/packages/core/src/anthropic-models.ts +++ b/packages/core/src/anthropic-models.ts @@ -28,33 +28,15 @@ export interface AnthropicProviderRegistration { } /* - * FNXC:ModelCatalog 2026-06-30-12:22: - * Claude Sonnet 5 support must not depend on the installed pi-ai catalog version or on the Claude CLI provider. Keep this supplemental Anthropic registration shared by engine sessions and dashboard model routes, and dedupe by model id so upstream catalog catch-up does not create duplicate picker rows. + * FNXC:ModelCatalog 2026-07-01-18:05: + * Anthropic's official model overview lists `claude-sonnet-5` as a Claude API ID, but FN-7374 observed sparse provider `404 not_found_error` responses for direct Anthropic accounts after Fusion force-added that ID from static supplemental metadata. Fusion cannot encode per-account/model-surface availability from static docs, so direct Anthropic pickers must rely on the live/upstream registry for Sonnet 5 and only dedupe rows the registry already provides. Existing saved selections keep runtime fallback/actionable failure handling instead of being newly advertised here. */ export const SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION: AnthropicProviderRegistration = { name: "Anthropic", baseUrl: "https://api.anthropic.com/v1", apiKey: "$ANTHROPIC_API_KEY", api: "anthropic-messages", - models: [ - { - id: CLAUDE_SONNET_5_MODEL_ID, - name: "Claude Sonnet 5", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 0.2, - cacheWrite: 2.5, - }, - contextWindow: 1_000_000, - maxTokens: 128_000, - compat: { - supportsDeveloperRole: false, - }, - }, - ], + models: [], }; type AnthropicModelLike = Partial> & { diff --git a/packages/core/src/model-pricing.ts b/packages/core/src/model-pricing.ts index 785363088c..664d15eb72 100644 --- a/packages/core/src/model-pricing.ts +++ b/packages/core/src/model-pricing.ts @@ -105,16 +105,9 @@ export const MODEL_PRICING: Readonly> = { // ── Anthropic Claude ──────────────────────────────────────────────── // input / output / cacheRead(0.1×) / cacheWrite(1.25×, 5-min TTL) /* - * FNXC:ModelCatalog 2026-06-30-12:10: - * Anthropic's docs publish Claude Sonnet 5 as the dateless pinned API ID `claude-sonnet-5`. Keep it in Fusion's hand-maintained support data before upstream pi-ai catalogs necessarily refresh so direct Anthropic runtime and cost surfaces can resolve it consistently. + * FNXC:ModelCatalog 2026-07-01-18:10: + * Do not maintain static pricing for `anthropic:claude-sonnet-5` while Fusion cannot prove that a direct Anthropic account can call the model. Saved selections that hit Anthropic's sparse `not_found_error` should be treated as unavailable/fallback candidates rather than receiving a confident cost from a model row Fusion no longer force-advertises. */ - "anthropic:claude-sonnet-5": { - inputPer1M: 2, - outputPer1M: 10, - cacheReadPer1M: 0.2, - cacheWritePer1M: 2.5, - source: "platform.claude.com/docs/en/pricing#claude-sonnet-5-introductory-pricing", - }, "anthropic:claude-opus-4-8": { inputPer1M: 5, outputPer1M: 25, diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index e930cc0425..43c6372282 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -398,7 +398,7 @@ describe("GET /models", () => { expect(res.body.models).toEqual([]); }); - it("adds Claude Sonnet 5 for configured direct Anthropic users without relying on Claude CLI", async () => { + it("does not force-add Claude Sonnet 5 for configured direct Anthropic users", async () => { const modelRegistry = createMutableModelRegistry([ { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic", reasoning: true, contextWindow: 200000 }, { id: "gpt-4o", name: "GPT-4o", provider: "openai", reasoning: false, contextWindow: 128000 }, @@ -408,9 +408,12 @@ describe("GET /models", () => { expect(res.status).toBe(200); expect(res.body.models).toEqual(expect.arrayContaining([ - expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-5", name: "Claude Sonnet 5", reasoning: true, contextWindow: 1_000_000 }), + expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-4-5" }), ])); - expect(modelRegistry.registerProvider).toHaveBeenCalledWith("anthropic", expect.objectContaining({ + expect(res.body.models).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-5" }), + ])); + expect(modelRegistry.registerProvider).not.toHaveBeenCalledWith("anthropic", expect.objectContaining({ models: expect.arrayContaining([expect.objectContaining({ id: "claude-sonnet-5" })]), })); }); @@ -431,13 +434,13 @@ describe("GET /models", () => { expect(res.status).toBe(200); expect(res.body.models).toEqual([]); - expect(modelRegistry.models.some((model) => model.id === "claude-sonnet-5")).toBe(true); + expect(modelRegistry.models.some((model) => model.id === "claude-sonnet-5")).toBe(false); } finally { readFileSpy.mockRestore(); } }); - it("does not duplicate Claude Sonnet 5 when upstream registry already includes it", async () => { + it("dedupes Claude Sonnet 5 when upstream registry already includes it", async () => { const modelRegistry = createMutableModelRegistry([ { id: "claude-sonnet-5", name: "Claude Sonnet 5 Upstream", provider: "anthropic", reasoning: true, contextWindow: 1_000_000, maxTokens: 128_000 }, ]); diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index b19db2d048..42ce606ed3 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1625,42 +1625,28 @@ describe("createFnAgent", () => { }); }); - it("resolves direct Anthropic Claude Sonnet 5 when the mocked registry initially lacks it", async () => { + it("does not synthesize direct Anthropic Claude Sonnet 5 when the registry lacks it", async () => { getAllMock.mockReturnValueOnce([]); findMock.mockImplementation((provider: string, modelId: string) => { if (provider === "anthropic" && modelId === "claude-sonnet-5") { - const anthropicRegistration = registerProviderMock.mock.calls.find(([name]) => name === "anthropic")?.[1] as { models?: Array<{ id: string; name: string }> } | undefined; - const registeredModel = anthropicRegistration?.models?.find((model) => model.id === modelId); - return registeredModel ? { ...registeredModel, provider } : undefined; + return undefined; } return { provider, id: modelId }; }); const { createFnAgent } = await import("../pi.js"); - const result = await createFnAgent({ + await expect(createFnAgent({ cwd: "/tmp", systemPrompt: "test", tools: "readonly", defaultProvider: "anthropic", defaultModelId: "claude-sonnet-5", - }); + })).rejects.toThrow("Configured model anthropic/claude-sonnet-5 (primary selection) was not found in the pi model registry"); - expect(registerProviderMock).toHaveBeenCalledWith("anthropic", expect.objectContaining({ - api: "anthropic-messages", - models: expect.arrayContaining([expect.objectContaining({ - id: "claude-sonnet-5", - name: "Claude Sonnet 5", - contextWindow: 1_000_000, - maxTokens: 128_000, - })]), - })); - expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ - model: expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-5" }), - })); - expect((result.session as { model?: unknown }).model).toEqual(expect.objectContaining({ - provider: "anthropic", - id: "claude-sonnet-5", + expect(registerProviderMock).not.toHaveBeenCalledWith("anthropic", expect.objectContaining({ + models: expect.arrayContaining([expect.objectContaining({ id: "claude-sonnet-5" })]), })); + expect(createAgentSessionMock).not.toHaveBeenCalled(); }); it("does not duplicate Claude Sonnet 5 when the Anthropic registry already has it", async () => { diff --git a/packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts b/packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts index 6fa7b7551c..b9c7b8eeec 100644 --- a/packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts +++ b/packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts @@ -58,9 +58,9 @@ describe("piAdapter — buildLaunch", () => { ]); }); - it("forwards direct Anthropic Claude Sonnet 5 without Claude CLI routing", () => { + it("forwards arbitrary direct Anthropic user-entered model ids without Claude CLI routing", () => { const spec = piAdapter.buildLaunch({ - settings: { provider: "anthropic", model: "claude-sonnet-5", sessionDir: "/tmp/sess/pi" }, + settings: { provider: "anthropic", model: "custom-anthropic-model", sessionDir: "/tmp/sess/pi" }, posture: null, }); expect(spec.command).toBe("pi"); @@ -68,7 +68,7 @@ describe("piAdapter — buildLaunch", () => { "--provider", "anthropic", "--model", - "claude-sonnet-5", + "custom-anthropic-model", "--session-dir", "/tmp/sess/pi", ]); diff --git a/packages/pi-claude-cli/index.ts b/packages/pi-claude-cli/index.ts index 99e719f585..a792509737 100644 --- a/packages/pi-claude-cli/index.ts +++ b/packages/pi-claude-cli/index.ts @@ -191,18 +191,9 @@ export default function (pi: ExtensionAPI) { // https://platform.claude.com/docs/en/about-claude/models/overview const extraModels: typeof catalogModels = [ /* - * FNXC:ModelCatalog 2026-06-30-12:31: - * The vendored Claude CLI provider has its own model list because it exposes `pi-claude-cli` independently from direct `anthropic`. Add Claude Sonnet 5 here as supplemental metadata so Claude CLI users can select it before the upstream pi-ai catalog catches up, while the dedupe below prevents duplicate rows after it does. + * FNXC:ModelCatalog 2026-07-01-18:18: + * Keep Claude CLI supplemental metadata limited to models that Fusion can advertise without triggering the direct-Anthropic Sonnet 5 404 loop. `claude-sonnet-5` must come from the upstream/live registry before this provider shows it, because static metadata cannot prove the current account and CLI surface can call that model. */ - { - id: "claude-sonnet-5", - name: "Claude Sonnet 5", - reasoning: true, - input: ["text", "image"], - cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - contextWindow: 1_000_000, - maxTokens: 128_000, - }, { id: "claude-opus-4-7", name: "Claude Opus 4.7", diff --git a/packages/pi-claude-cli/src/__tests__/process-manager.test.ts b/packages/pi-claude-cli/src/__tests__/process-manager.test.ts index 101e1af846..f042b7c7c1 100644 --- a/packages/pi-claude-cli/src/__tests__/process-manager.test.ts +++ b/packages/pi-claude-cli/src/__tests__/process-manager.test.ts @@ -61,14 +61,14 @@ describe("buildClaudeSpawnArgs", () => { }); it("builds args including model and optional session/mcp flags", () => { - const args = buildClaudeSpawnArgs("claude-sonnet-5", undefined, { + const args = buildClaudeSpawnArgs("claude-sonnet-4-6", undefined, { resumeSessionId: "sess-1", effort: "high", mcpConfigPath: "/tmp/mcp.json", }); expect(args).toContain("--model"); - expect(args[args.indexOf("--model") + 1]).toBe("claude-sonnet-5"); + expect(args[args.indexOf("--model") + 1]).toBe("claude-sonnet-4-6"); expect(args).toContain("--resume"); expect(args).toContain("sess-1"); expect(args).toContain("--effort"); diff --git a/packages/pi-claude-cli/src/__tests__/provider.test.ts b/packages/pi-claude-cli/src/__tests__/provider.test.ts index 8916ccffbb..49d4a54ce4 100644 --- a/packages/pi-claude-cli/src/__tests__/provider.test.ts +++ b/packages/pi-claude-cli/src/__tests__/provider.test.ts @@ -116,7 +116,7 @@ describe("provider registration (default export)", () => { expect(firstModel.cost).toBeDefined(); }); - it("includes all extra Claude model entries", async () => { + it("includes supported extra Claude model entries without force-adding Sonnet 5", async () => { const registerProvider = vi.fn(); const mockPi = { registerProvider, on: vi.fn() } as any; @@ -127,7 +127,6 @@ describe("provider registration (default export)", () => { const modelIds = new Set(config.models.map((m: { id: string }) => m.id)); for (const id of [ - "claude-sonnet-5", "claude-opus-4-7", "claude-sonnet-4-6", "claude-sonnet-4-5", @@ -136,14 +135,7 @@ describe("provider registration (default export)", () => { expect(modelIds.has(id)).toBe(true); } - expect(config.models.find((m: { id: string }) => m.id === "claude-sonnet-5")).toMatchObject({ - name: "Claude Sonnet 5", - reasoning: true, - input: ["text", "image"], - cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - contextWindow: 1_000_000, - maxTokens: 128_000, - }); + expect(modelIds.has("claude-sonnet-5")).toBe(false); }); it("deduplicates extra models when catalog already includes them", async () => {