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:
7
.changeset/restore-claude-sonnet-5-catalog.md
Normal file
7
.changeset/restore-claude-sonnet-5-catalog.md
Normal 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.
|
||||||
@@ -36,7 +36,7 @@ describe("model-pricing", () => {
|
|||||||
expect(result.usd).toBeCloseTo(10.0, 2);
|
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 = {
|
const usage = {
|
||||||
inputTokens: 1_000_000,
|
inputTokens: 1_000_000,
|
||||||
outputTokens: 200_000,
|
outputTokens: 200_000,
|
||||||
@@ -45,12 +45,12 @@ describe("model-pricing", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const anthropic = costFor(usage, { provider: "anthropic", model: "claude-sonnet-5" });
|
const anthropic = costFor(usage, { provider: "anthropic", model: "claude-sonnet-5" });
|
||||||
expect(anthropic.unavailable).toBe(true);
|
expect(anthropic.unavailable).toBe(false);
|
||||||
expect(anthropic.usd).toBeNull();
|
expect(anthropic.usd).toBeGreaterThan(0);
|
||||||
|
|
||||||
const bare = costFor(usage, { model: "claude-sonnet-5" });
|
const bare = costFor(usage, { model: "claude-sonnet-5" });
|
||||||
expect(bare.unavailable).toBe(true);
|
expect(bare.unavailable).toBe(false);
|
||||||
expect(bare.usd).toBeNull();
|
expect(bare.usd).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prices OpenAI Codex GPT-5 models instead of reporting unavailable", () => {
|
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"]);
|
).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(
|
expect(
|
||||||
lookupPricing({ provider: " Anthropic ", model: " Claude-Sonnet-5 " }),
|
lookupPricing({ provider: " Anthropic ", model: " Claude-Sonnet-5 " }),
|
||||||
).toBeUndefined();
|
).toBe(MODEL_PRICING["anthropic:claude-sonnet-5"]);
|
||||||
expect(lookupPricing({ model: "claude-sonnet-5" })).toBeUndefined();
|
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", () => {
|
it("falls back to a bare model id when provider is unset", () => {
|
||||||
|
|||||||
@@ -28,15 +28,33 @@ export interface AnthropicProviderRegistration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* FNXC:ModelCatalog 2026-07-01-18:05:
|
* FNXC:ModelCatalog 2026-07-01-22:40:
|
||||||
* 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.
|
* 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 = {
|
export const SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION: AnthropicProviderRegistration = {
|
||||||
name: "Anthropic",
|
name: "Anthropic",
|
||||||
baseUrl: "https://api.anthropic.com/v1",
|
baseUrl: "https://api.anthropic.com/v1",
|
||||||
apiKey: "$ANTHROPIC_API_KEY",
|
apiKey: "$ANTHROPIC_API_KEY",
|
||||||
api: "anthropic-messages",
|
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">> & {
|
type AnthropicModelLike = Partial<Omit<AnthropicModelRegistration, "name" | "compat">> & {
|
||||||
|
|||||||
@@ -105,9 +105,16 @@ export const MODEL_PRICING: Readonly<Record<string, ModelPricing>> = {
|
|||||||
// ── Anthropic Claude ────────────────────────────────────────────────
|
// ── Anthropic Claude ────────────────────────────────────────────────
|
||||||
// input / output / cacheRead(0.1×) / cacheWrite(1.25×, 5-min TTL)
|
// input / output / cacheRead(0.1×) / cacheWrite(1.25×, 5-min TTL)
|
||||||
/*
|
/*
|
||||||
* FNXC:ModelCatalog 2026-07-01-18:10:
|
* FNXC:ModelCatalog 2026-07-01-22:40:
|
||||||
* 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` 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": {
|
"anthropic:claude-opus-4-8": {
|
||||||
inputPer1M: 5,
|
inputPer1M: 5,
|
||||||
outputPer1M: 25,
|
outputPer1M: 25,
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ describe("GET /models", () => {
|
|||||||
expect(res.body.models).toEqual([]);
|
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([
|
const modelRegistry = createMutableModelRegistry([
|
||||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic", reasoning: true, contextWindow: 200000 },
|
{ 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 },
|
{ 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");
|
const res = await GET(buildApp(modelRegistry), "/api/models");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
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(res.body.models).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-4-5" }),
|
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.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" })]),
|
models: expect.arrayContaining([expect.objectContaining({ id: "claude-sonnet-5" })]),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -434,7 +433,10 @@ describe("GET /models", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.models).toEqual([]);
|
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 {
|
} finally {
|
||||||
readFileSpy.mockRestore();
|
readFileSpy.mockRestore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1651,28 +1651,27 @@ describe("createFnAgent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not synthesize direct Anthropic Claude Sonnet 5 when the registry lacks it", async () => {
|
it("synthesizes direct Anthropic Claude Sonnet 5 from supplemental metadata when the live registry lacks it", async () => {
|
||||||
getAllMock.mockReturnValueOnce([]);
|
// Live registry has no anthropic models; mergeSupplementalAnthropicModels re-adds Sonnet 5.
|
||||||
findMock.mockImplementation((provider: string, modelId: string) => {
|
getAllMock.mockReturnValue([]);
|
||||||
if (provider === "anthropic" && modelId === "claude-sonnet-5") {
|
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return { provider, id: modelId };
|
|
||||||
});
|
|
||||||
|
|
||||||
const { createFnAgent } = await import("../pi.js");
|
const { createFnAgent } = await import("../pi.js");
|
||||||
await expect(createFnAgent({
|
await createFnAgent({
|
||||||
cwd: "/tmp",
|
cwd: "/tmp",
|
||||||
systemPrompt: "test",
|
systemPrompt: "test",
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
defaultProvider: "anthropic",
|
defaultProvider: "anthropic",
|
||||||
defaultModelId: "claude-sonnet-5",
|
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" })]),
|
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 () => {
|
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 }
|
? { type: "oauth", access: "subscription-access-token", refresh: "refresh", expires: Date.now() + 3_600_000 }
|
||||||
: undefined);
|
: undefined);
|
||||||
authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription");
|
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" }]);
|
getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8" }]);
|
||||||
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
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 }
|
? { type: "oauth", access: "subscription-access-token", refresh: "refresh", expires: Date.now() + 3_600_000 }
|
||||||
: undefined);
|
: undefined);
|
||||||
authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription");
|
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) => {
|
findMock.mockImplementation((provider: string, modelId: string) => {
|
||||||
if (provider === "pi-claude-cli") {
|
if (provider === "pi-claude-cli") {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ export function createFusionAuthStorage(): AuthStorage {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:ProviderAuth 2026-07-01-14:55:
|
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) {
|
if (!rawProviderLoggedOut) {
|
||||||
const anthropicApiKeyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key");
|
const anthropicApiKeyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key");
|
||||||
|
|||||||
@@ -1173,7 +1173,7 @@ function readJsonObject(path: string): Record<string, any> {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:ProviderAuth 2026-07-01-14:55:
|
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 {
|
function normalizeSessionHistoryEntries(sessionManager: SessionManagerLike): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user