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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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">> & {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user