FN-7374: stop advertising Claude Sonnet 5 statically

Stop Fusion from statically offering Claude Sonnet 5 when account availability is unknown.

- Remove the supplemental direct Anthropic Claude Sonnet 5 model registration and static pricing row.
- Keep Claude CLI supplemental metadata from force-adding Sonnet 5 while preserving other extra Claude models.
- Update model registry, pricing, adapter, and provider tests to expect unavailable or upstream-only Sonnet 5 handling.
- Add a patch changeset for the operator-facing fix.

Files changed:
 .changeset/fn-7374-anthropic-sonnet-5-404.md       |  7 ++++++
 packages/core/src/__tests__/model-pricing.test.ts  | 20 +++++++---------
 packages/core/src/anthropic-models.ts              | 24 +++----------------
 packages/core/src/model-pricing.ts                 | 11 ++-------
 .../dashboard/src/__tests__/routes-auth.test.ts    | 13 ++++++----
 .../src/__tests__/pi-create-fn-agent.test.ts       | 28 ++++++----------------
 .../src/cli-agent/adapters/__tests__/pi.test.ts    |  6 ++---
 packages/pi-claude-cli/index.ts                    | 13 ++--------
 .../src/__tests__/process-manager.test.ts          |  4 ++--
 .../pi-claude-cli/src/__tests__/provider.test.ts   | 12 ++--------
 10 files changed, 44 insertions(+), 94 deletions(-)

Fusion-Task-Id: FN-7374

Fusion-Task-Lineage: 9c498bb4-c4c1-4dd8-ae80-869dfb1d4c2c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 10:23:39 -07:00
parent 919420e11a
commit 427ce04a6f
10 changed files with 44 additions and 94 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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