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