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:
7
.changeset/fn-7389-sonnet-5-model-list.md
Normal file
7
.changeset/fn-7389-sonnet-5-model-list.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user