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("");
|
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 () => {
|
it("closes the portaled dropdown when clicking outside the trigger and menu", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onChange = vi.fn();
|
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: "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: "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-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");
|
const res = await GET(buildAppWithSetting(false, registryWithCli()), "/api/models");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const providers = res.body.models.map((m: { provider: string }) => m.provider);
|
const providers = res.body.models.map((m: { provider: string }) => m.provider);
|
||||||
expect(providers).not.toContain("pi-claude-cli");
|
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"]));
|
expect(providers).toEqual(expect.arrayContaining(["anthropic", "openai"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,11 +497,17 @@ describe("GET /models", () => {
|
|||||||
expect(providers).not.toContain("pi-claude-cli");
|
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");
|
const res = await GET(buildAppWithSetting(true, registryWithCli()), "/api/models");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const providers = res.body.models.map((m: { provider: string }) => m.provider);
|
const providers = res.body.models.map((m: { provider: string }) => m.provider);
|
||||||
expect(providers).toEqual(expect.arrayContaining(["anthropic", "openai", "pi-claude-cli"]));
|
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 () => {
|
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,
|
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
|
// The vendored pi-claude-cli extension registers its provider as
|
||||||
// "pi-claude-cli" (distinct from "anthropic") whenever it loads.
|
// "pi-claude-cli" (distinct from "anthropic") whenever it loads.
|
||||||
// When the toggle is OFF, hide those entries from pickers so users
|
// When the toggle is OFF, hide those entries from pickers so users
|
||||||
|
|||||||
Reference in New Issue
Block a user