From b7c6443a7b765cd3c96fa3bf77c64d51cfa9d644 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 16:19:03 -0700 Subject: [PATCH] Restore Claude Sonnet 5 in the picker + address PR review feedback (#1862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonnet 5 had disappeared from every surface: pi-ai 0.79.9 (the installed version) lacks it, and FN-7374 removed the static row expecting the live registry to carry it. Live-verified that claude-sonnet-5 returns 200 on api.anthropic.com/v1 with a raw ANTHROPIC_API_KEY and runs via the Claude CLI (it 403s on subscription-OAuth /v1 — scope-gated; runtime fallback applies). Note: pi-ai 0.80.3 ships sonnet-5 natively, so this SUPPLEMENTAL row dedupes once the install catches up. - core: re-add claude-sonnet-5 to SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION and restore its static pricing (revert FN-7374); update pricing tests. - engine/dashboard tests: flip the FN-7374 "withheld" assertions to the restored "advertised" behavior. PR feedback: - Trim the two FNXC comments (auth-storage.ts, pi.ts) to concise requirement prose per coding guidelines (CodeRabbit). - Replace the now-inert getApiKey mock in two subscription routing tests with a clarifying note (Greptile). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/restore-claude-sonnet-5-catalog.md | 7 +++++ .../core/src/__tests__/model-pricing.test.ts | 16 +++++----- packages/core/src/anthropic-models.ts | 24 +++++++++++++-- packages/core/src/model-pricing.ts | 11 +++++-- .../src/__tests__/routes-auth.test.ts | 12 ++++---- .../src/__tests__/pi-create-fn-agent.test.ts | 29 ++++++++++--------- packages/engine/src/auth-storage.ts | 2 +- packages/engine/src/pi.ts | 2 +- 8 files changed, 69 insertions(+), 34 deletions(-) create mode 100644 .changeset/restore-claude-sonnet-5-catalog.md diff --git a/.changeset/restore-claude-sonnet-5-catalog.md b/.changeset/restore-claude-sonnet-5-catalog.md new file mode 100644 index 0000000000..79925b27d5 --- /dev/null +++ b/.changeset/restore-claude-sonnet-5-catalog.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore Claude Sonnet 5 in the model picker (it had disappeared from every surface). +category: fix +dev: Re-adds `claude-sonnet-5` to SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION and its static pricing (removed by FN-7374). Live-verified: Sonnet 5 returns 200 on api.anthropic.com/v1 with a raw ANTHROPIC_API_KEY and runs via the Claude CLI; it 403s (scope) on subscription-OAuth /v1, where the runtime actionable-failure/fallback path applies. diff --git a/packages/core/src/__tests__/model-pricing.test.ts b/packages/core/src/__tests__/model-pricing.test.ts index 4381bd7967..7ae646ce84 100644 --- a/packages/core/src/__tests__/model-pricing.test.ts +++ b/packages/core/src/__tests__/model-pricing.test.ts @@ -36,7 +36,7 @@ describe("model-pricing", () => { expect(result.usd).toBeCloseTo(10.0, 2); }); - it("reports direct Anthropic Claude Sonnet 5 pricing as unavailable without a static catalog row", () => { + it("prices direct Anthropic Claude Sonnet 5 from the restored static catalog row", () => { const usage = { inputTokens: 1_000_000, outputTokens: 200_000, @@ -45,12 +45,12 @@ describe("model-pricing", () => { }; const anthropic = costFor(usage, { provider: "anthropic", model: "claude-sonnet-5" }); - expect(anthropic.unavailable).toBe(true); - expect(anthropic.usd).toBeNull(); + expect(anthropic.unavailable).toBe(false); + expect(anthropic.usd).toBeGreaterThan(0); const bare = costFor(usage, { model: "claude-sonnet-5" }); - expect(bare.unavailable).toBe(true); - expect(bare.usd).toBeNull(); + expect(bare.unavailable).toBe(false); + expect(bare.usd).toBeGreaterThan(0); }); it("prices OpenAI Codex GPT-5 models instead of reporting unavailable", () => { @@ -204,11 +204,11 @@ describe("model-pricing", () => { ).toBe(MODEL_PRICING["openai-codex:gpt-5-codex"]); }); - it("does not resolve static pricing for direct Anthropic Claude Sonnet 5", () => { + it("resolves restored static pricing for direct Anthropic Claude Sonnet 5", () => { expect( lookupPricing({ provider: " Anthropic ", model: " Claude-Sonnet-5 " }), - ).toBeUndefined(); - expect(lookupPricing({ model: "claude-sonnet-5" })).toBeUndefined(); + ).toBe(MODEL_PRICING["anthropic:claude-sonnet-5"]); + expect(lookupPricing({ model: "claude-sonnet-5" })).toBe(MODEL_PRICING["anthropic:claude-sonnet-5"]); }); 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 fa4e1b07d1..22c4ab0d13 100644 --- a/packages/core/src/anthropic-models.ts +++ b/packages/core/src/anthropic-models.ts @@ -28,15 +28,33 @@ export interface AnthropicProviderRegistration { } /* - * 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. + * FNXC:ModelCatalog 2026-07-01-22:40: + * Re-advertise `claude-sonnet-5`: the pinned pi-ai builtin registry ships opus-4-8/sonnet-4-6/fable-5 but NOT sonnet-5, and FN-7374 removed the static row expecting the live registry to carry it — so Sonnet 5 was left visible on no surface at all. FN-7374's "404 for direct accounts" premise is disproven by a live probe: `claude-sonnet-5` returns 200 on `api.anthropic.com/v1` with a raw `ANTHROPIC_API_KEY`, and runs via the Claude CLI/`pi-claude-cli` (claude.ai backend). It DOES 403 (scope) on subscription-OAuth `/v1`, so OAuth-only users fall back to the runtime actionable-failure path; keep it advertised so API-key and CLI users can select it. */ export const SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION: AnthropicProviderRegistration = { name: "Anthropic", baseUrl: "https://api.anthropic.com/v1", apiKey: "$ANTHROPIC_API_KEY", api: "anthropic-messages", - models: [], + 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, + }, + }, + ], }; type AnthropicModelLike = Partial> & { diff --git a/packages/core/src/model-pricing.ts b/packages/core/src/model-pricing.ts index 664d15eb72..024bc3c9f2 100644 --- a/packages/core/src/model-pricing.ts +++ b/packages/core/src/model-pricing.ts @@ -105,9 +105,16 @@ export const MODEL_PRICING: Readonly> = { // ── Anthropic Claude ──────────────────────────────────────────────── // input / output / cacheRead(0.1×) / cacheWrite(1.25×, 5-min TTL) /* - * 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. + * FNXC:ModelCatalog 2026-07-01-22:40: + * `anthropic:claude-sonnet-5` is advertised again (works on raw API key + Claude CLI; live-verified), so restore its static pricing. Matches the cost in SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION. */ + "anthropic:claude-sonnet-5": { + inputPer1M: 2, + outputPer1M: 10, + cacheReadPer1M: 0.2, + cacheWritePer1M: 2.5, + source: "platform.claude.com/docs/en/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 29dd51cf25..b4950cd243 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("does not force-add Claude Sonnet 5 for configured direct Anthropic users", async () => { + it("advertises 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 }, @@ -407,13 +407,12 @@ describe("GET /models", () => { const res = await GET(buildApp(modelRegistry), "/api/models"); expect(res.status).toBe(200); + // Sonnet 5 is re-advertised via SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION (works on API key + CLI). expect(res.body.models).toEqual(expect.arrayContaining([ expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-4-5" }), - ])); - expect(res.body.models).not.toEqual(expect.arrayContaining([ expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-5" }), ])); - expect(modelRegistry.registerProvider).not.toHaveBeenCalledWith("anthropic", expect.objectContaining({ + expect(modelRegistry.registerProvider).toHaveBeenCalledWith("anthropic", expect.objectContaining({ models: expect.arrayContaining([expect.objectContaining({ id: "claude-sonnet-5" })]), })); }); @@ -434,7 +433,10 @@ describe("GET /models", () => { expect(res.status).toBe(200); expect(res.body.models).toEqual([]); - expect(modelRegistry.models.some((model) => model.id === "claude-sonnet-5")).toBe(false); + // Sonnet 5 is merged into the registry from supplemental metadata, but stays hidden + // from the response because no Anthropic auth is configured (provider-visibility filter). + expect(modelRegistry.models.some((model) => model.id === "claude-sonnet-5")).toBe(true); + expect(res.body.models.some((model: { id: string }) => model.id === "claude-sonnet-5")).toBe(false); } finally { readFileSpy.mockRestore(); } 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 563ef0c424..774cde8361 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1651,28 +1651,27 @@ describe("createFnAgent", () => { }); }); - 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") { - return undefined; - } - return { provider, id: modelId }; - }); + it("synthesizes direct Anthropic Claude Sonnet 5 from supplemental metadata when the live registry lacks it", async () => { + // Live registry has no anthropic models; mergeSupplementalAnthropicModels re-adds Sonnet 5. + getAllMock.mockReturnValue([]); + findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); const { createFnAgent } = await import("../pi.js"); - await expect(createFnAgent({ + await 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).not.toHaveBeenCalledWith("anthropic", expect.objectContaining({ + // SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION advertises claude-sonnet-5 on the direct provider again. + expect(registerProviderMock).toHaveBeenCalledWith("anthropic", expect.objectContaining({ models: expect.arrayContaining([expect.objectContaining({ id: "claude-sonnet-5" })]), })); - expect(createAgentSessionMock).not.toHaveBeenCalled(); + expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + model: { provider: "anthropic", id: "claude-sonnet-5" }, + })); }); it("does not duplicate Claude Sonnet 5 when the Anthropic registry already has it", async () => { @@ -1712,7 +1711,8 @@ describe("createFnAgent", () => { ? { type: "oauth", access: "subscription-access-token", refresh: "refresh", expires: Date.now() + 3_600_000 } : undefined); authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription"); - authStorageGetApiKeyMock.mockResolvedValue(undefined); + // Model selection no longer reads getApiKey (the reroute was removed); in production + // getApiKey("anthropic") returns the OAuth token, resolved later at session execution. getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8" }]); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); @@ -1779,7 +1779,8 @@ describe("createFnAgent", () => { ? { type: "oauth", access: "subscription-access-token", refresh: "refresh", expires: Date.now() + 3_600_000 } : undefined); authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription"); - authStorageGetApiKeyMock.mockResolvedValue(undefined); + // Model selection no longer reads getApiKey (the reroute was removed); production + // resolves the OAuth token later, at session execution. findMock.mockImplementation((provider: string, modelId: string) => { if (provider === "pi-claude-cli") { return undefined; diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 78e6340c87..ee67c9e42e 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -517,7 +517,7 @@ export function createFusionAuthStorage(): AuthStorage { /* FNXC:ProviderAuth 2026-07-01-14:55: - Restore the v0.51.0 direct-OAuth execution path (regressed by FN-7291 → FN-7391 → FN-7396, all built on issue #1857's incorrect "Anthropic blocks subscription OAuth on /v1, must use the CLI" conclusion). PROVEN in code: at v0.51.0 `getApiKey("anthropic")` returned the subscription OAuth access token, and pi-ai's built-in `anthropic` provider POSTs it to `api.anthropic.com/v1/messages` with full Claude Code impersonation (`Authorization: Bearer` + `anthropic-beta: claude-code-20250219,oauth-2025-04-20`), which Anthropic accepts. So `anthropic` runtime auth resolves, in precedence order: (1) raw API key, (2) legacy `anthropic` OAuth, (3) separated `anthropic-subscription` OAuth, (4) models.json / ModelRegistry fallback raw key. Raw key still wins so an explicit `ANTHROPIC_API_KEY` keeps using x-api-key. Direct OAuth, raw API key, and explicit `pi-claude-cli` remain three independent surfaces. + Anthropic runtime auth (`getApiKey("anthropic")`) resolves in precedence order: (1) raw API key, (2) legacy `anthropic` OAuth, (3) separated `anthropic-subscription` OAuth, (4) models.json / ModelRegistry fallback raw key. Raw key wins so an explicit `ANTHROPIC_API_KEY` keeps using x-api-key; subscription/OAuth tokens must resolve here so the built-in provider runs them on `/v1` with Claude Code impersonation. Do NOT gate OAuth behind the CLI or reroute it to an `/v1` `anthropic-subscription` provider — that reintroduced the #1857 regression (FN-7391/FN-7396). */ if (!rawProviderLoggedOut) { const anthropicApiKeyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key"); diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index f139b3e4e5..662253cb47 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1173,7 +1173,7 @@ function readJsonObject(path: string): Record { /* FNXC:ProviderAuth 2026-07-01-14:55: -Anthropic has three independent surfaces and NO runtime rerouting between them: (1) direct OAuth — a subscription/OAuth `anthropic/` selection executes on pi-ai's built-in `anthropic` provider, which detects the `sk-ant-oat` token and POSTs to `api.anthropic.com/v1/messages` with full Claude Code impersonation (the v0.51.0 working path; `authStorage.getApiKey("anthropic")` supplies the OAuth token); (2) raw API key — a configured `ANTHROPIC_API_KEY` takes precedence in `getApiKey("anthropic")` and uses x-api-key on the same built-in provider; (3) Claude CLI — an explicit `pi-claude-cli/` selection runs through the vendored CLI extension. FN-7291/FN-7391/FN-7396 added an `/v1`-based `anthropic-subscription` reroute on the incorrect premise that Anthropic blocks subscription OAuth on `/v1`; that reroute is intentionally absent so direct OAuth is not re-broken (issue #1857). +Anthropic has three independent execution surfaces with NO runtime rerouting: direct OAuth and raw API key both run on pi-ai's built-in `anthropic` provider (OAuth → `/v1` with Claude Code impersonation; raw key → x-api-key), and explicit `pi-claude-cli/` runs the vendored CLI. Do NOT register or route through an `/v1`-based `anthropic-subscription` provider — that reroute reintroduced the #1857 regression (FN-7391/FN-7396). */ function normalizeSessionHistoryEntries(sessionManager: SessionManagerLike): void {