Restore Claude Sonnet 5 in the picker + address PR review feedback (#1862)

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-01 16:19:03 -07:00
parent a1af5de609
commit b7c6443a7b
8 changed files with 69 additions and 34 deletions

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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<Omit<AnthropicModelRegistration, "name" | "compat">> & {

View File

@@ -105,9 +105,16 @@ export const MODEL_PRICING: Readonly<Record<string, ModelPricing>> = {
// ── 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,

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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");

View File

@@ -1173,7 +1173,7 @@ function readJsonObject(path: string): Record<string, any> {
/*
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/<model>` 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/<model>` 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/<model>` 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 {