FN-7389: dedupe Claude CLI Sonnet 5 models

Keep Claude Sonnet 5 visible only through eligible provider catalogs without duplicate picker rows.

- Dedupe API model rows by provider/model after registry and supplemental catalog merges.
- Cover Claude CLI Sonnet 5 visibility, disabled-toggle filtering, and duplicate suppression in API tests.
- Add dropdown coverage for stale direct-Anthropic favorite shells when Claude CLI Sonnet 5 is selected.
- Add a patch changeset for the model picker fix.

Files changed:
 .changeset/fn-7389-sonnet-5-model-list.md          |  7 +++++
 .../__tests__/CustomModelDropdown.test.tsx         | 33 ++++++++++++++++++++++
 .../dashboard/src/__tests__/routes-auth.test.ts    | 15 ++++++++--
 .../dashboard/src/routes/register-model-routes.ts  | 12 ++++++++
 4 files changed, 65 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7389

Fusion-Task-Lineage: d41b422e-60f9-46c3-9c6f-d115a86e9e2a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 12:20:58 -07:00
parent 775ff5fb9c
commit bfe5ceddc8
4 changed files with 65 additions and 2 deletions

View File

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

View File

@@ -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(
<CustomModelDropdown
label="Executor Model"
value="anthropic/claude-sonnet-5"
onChange={onChange}
models={[
...MOCK_MODELS,
{ provider: "pi-claude-cli", id: "claude-sonnet-5", name: "Claude Sonnet 5 (CLI)", reasoning: true, contextWindow: 1_000_000 },
]}
favoriteModels={["anthropic/claude-sonnet-5", "pi-claude-cli/claude-sonnet-5"]}
onToggleModelFavorite={vi.fn()}
/>,
);
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();

View File

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

View File

@@ -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<string>();
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