diff --git a/.changeset/fn-7389-sonnet-5-model-list.md b/.changeset/fn-7389-sonnet-5-model-list.md new file mode 100644 index 0000000000..6c872899fd --- /dev/null +++ b/.changeset/fn-7389-sonnet-5-model-list.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show eligible Claude Sonnet 5 model rows once in model pickers. +category: fix +dev: Dedupes /api/models rows by provider/model while preserving direct Anthropic Sonnet 5 guardrails. diff --git a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx index 4b43ceaff3..527ea89a48 100644 --- a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx +++ b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx @@ -132,6 +132,39 @@ describe("CustomModelDropdown", () => { expect(onChange).toHaveBeenCalledWith(""); }); + it("renders eligible Claude CLI Sonnet 5 without stale direct-Anthropic favorite shells", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + expect(screen.getByRole("button", { name: "Executor Model" })).toHaveTextContent("anthropic/claude-sonnet-5"); + + await user.click(screen.getByRole("button", { name: "Executor Model" })); + const portal = await screen.findByTestId("model-combobox-portal"); + const options = within(portal).getAllByRole("option"); + const cliSonnetOptions = options.filter((option) => option.textContent?.includes("Claude Sonnet 5 (CLI)")); + + expect(cliSonnetOptions).toHaveLength(1); + expect(within(portal).queryByLabelText("Remove Claude Sonnet 5 from favorites")).toBeNull(); + expect(within(portal).getByLabelText("Remove Claude Sonnet 5 (CLI) from favorites")).toBeTruthy(); + + await user.click(cliSonnetOptions[0]!); + expect(onChange).toHaveBeenCalledWith("pi-claude-cli/claude-sonnet-5"); + }); + it("closes the portaled dropdown when clicking outside the trigger and menu", async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 198bfb4b82..4aacae35e7 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -473,15 +473,20 @@ describe("GET /models", () => { { 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: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (CLI)", provider: "pi-claude-cli", reasoning: true, contextWindow: 200000 }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5 (CLI)", provider: "pi-claude-cli", reasoning: true, contextWindow: 1_000_000 }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5 Duplicate (CLI)", provider: "pi-claude-cli", reasoning: true, contextWindow: 1_000_000 }, ]), }); } - it("hides pi-claude-cli entries when useClaudeCli is false", async () => { + it("hides pi-claude-cli entries, including Claude Sonnet 5, when useClaudeCli is false", async () => { const res = await GET(buildAppWithSetting(false, registryWithCli()), "/api/models"); expect(res.status).toBe(200); const providers = res.body.models.map((m: { provider: string }) => m.provider); expect(providers).not.toContain("pi-claude-cli"); + expect(res.body.models).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ provider: "pi-claude-cli", id: "claude-sonnet-5" }), + ])); expect(providers).toEqual(expect.arrayContaining(["anthropic", "openai"])); }); @@ -492,11 +497,17 @@ describe("GET /models", () => { expect(providers).not.toContain("pi-claude-cli"); }); - it("includes pi-claude-cli entries alongside other providers when useClaudeCli is true", async () => { + it("includes pi-claude-cli Claude Sonnet 5 exactly once alongside other providers when useClaudeCli is true", async () => { const res = await GET(buildAppWithSetting(true, registryWithCli()), "/api/models"); expect(res.status).toBe(200); const providers = res.body.models.map((m: { provider: string }) => m.provider); expect(providers).toEqual(expect.arrayContaining(["anthropic", "openai", "pi-claude-cli"])); + const cliSonnetFiveRows = res.body.models.filter((m: { provider: string; id: string }) => m.provider === "pi-claude-cli" && m.id === "claude-sonnet-5"); + expect(cliSonnetFiveRows).toHaveLength(1); + expect(cliSonnetFiveRows[0]).toEqual(expect.objectContaining({ + name: "Claude Sonnet 5 (CLI)", + contextWindow: 1_000_000, + })); }); it("hides direct Anthropic rows for OAuth-only subscription auth while showing distinct Claude CLI rows", async () => { diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index 1ba3c547f6..b4e368b45b 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -163,6 +163,18 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { contextWindow: m.contextWindow, })); + /* + * FNXC:ModelCatalog 2026-07-01-12:02: + * Model visibility is provider-surface-specific: Claude CLI can advertise its own `pi-claude-cli/claude-sonnet-5` row while direct Anthropic must only show Sonnet 5 when the upstream registry returns it. Dedupe after refresh/supplemental merges so overlapping live and supplemental catalogs expose one selectable row without reintroducing static direct-Anthropic advertisement. + */ + const seenModelKeys = new Set(); + models = models.filter((model) => { + const key = `${model.provider}/${model.id}`; + if (seenModelKeys.has(key)) return false; + seenModelKeys.add(key); + return true; + }); + // The vendored pi-claude-cli extension registers its provider as // "pi-claude-cli" (distinct from "anthropic") whenever it loads. // When the toggle is OFF, hide those entries from pickers so users