From a1ff26cf13b9e704eba0a47bbdef33e26c2f2c9e Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Mon, 3 Aug 2026 16:09:32 -0700 Subject: [PATCH] fix(ui): validate the auto-router template selector, dedupe presets, memoize model set The Template selector carried a manual required asterisk with nothing behind it: submitting with no template chosen fell through to the unrelated missing-tiers error instead of naming the actual problem. Add an explicit check ahead of the existing tier/classifier/semantic validation and an inline hint under the selector, reusing the showValidationErrors flag the rest of the form already uses. sample_spec duplicated anthropic_family's exact model list under a different label, kept only as shape documentation; that duplication could drift silently if the real preset's models changed without a matching edit. Delete it and drop the now-unneeded filter in autorouter_presets.ts. availableModelSet was rebuilt on every render while presets right above it was already memoized; wrap it in useMemo for consistency and to stop recomputing it on unrelated re-renders. --- .../src/autorouter_presets.json | 14 ----------- .../add_model/add_auto_router_tab.test.tsx | 25 ++++++++++++++++++- .../add_model/add_auto_router_tab.tsx | 11 +++++++- .../src/lib/autorouter_presets.test.ts | 4 +-- .../src/lib/autorouter_presets.ts | 6 ++--- 5 files changed, 37 insertions(+), 23 deletions(-) diff --git a/ui/litellm-dashboard/src/autorouter_presets.json b/ui/litellm-dashboard/src/autorouter_presets.json index b1c0235eb13..ec9cfe1eac4 100644 --- a/ui/litellm-dashboard/src/autorouter_presets.json +++ b/ui/litellm-dashboard/src/autorouter_presets.json @@ -1,18 +1,4 @@ { - "sample_spec": { - "label": "Anthropic Family (Example)", - "description": "Complexity-tiered routing using only Anthropic models", - "complexity_router_config": { - "tiers": { - "SIMPLE": ["claude-haiku-4-5"], - "MEDIUM": ["claude-sonnet-4-5"], - "COMPLEX": ["claude-opus-5"], - "REASONING": ["claude-opus-5"] - }, - "classifier_type": "heuristic", - "escalation_keywords": ["LITELLM ESCALATE"] - } - }, "anthropic_family": { "label": "Anthropic Family", "description": "Routes across the Claude model family: Haiku for simple queries, Sonnet for medium, Opus for complex and reasoning-heavy requests.", diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx index 5fc39bbc45a..30153b6f87a 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx @@ -246,7 +246,26 @@ describe("AddAutoRouterTab", () => { await user.type(screen.getByPlaceholderText(/smart_router/i), "raced-router"); await user.click(screen.getByRole("button", { name: /add auto router/i })); - // No preset was applied, so the tiers are empty and tier validation blocks the submit. + // No preset was applied and none was picked, so the template check blocks the submit. + expect(mockHandleAddAutoRouterSubmit).not.toHaveBeenCalled(); + }); + + // The Template field carries a required marker but, until now, nothing actually validated it: + // submit fell through to the unrelated missing-tiers error instead. This pins a Template-specific + // block so a future regression (e.g. dropping this check) surfaces as a wrong error message, not + // silence. + it("blocks the submit and shows an inline error when no template is chosen", async () => { + const user = userEvent.setup(); + mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS); + + renderWithProviders(); + await user.type(screen.getByPlaceholderText(/smart_router/i), "no-template-router"); + await user.click(screen.getByRole("button", { name: /add auto router/i })); + + expect(await screen.findByText("Please select a template")).toBeInTheDocument(); + expect(NotificationManager.fromBackend).toHaveBeenCalledWith( + "Please select a template, or choose Custom Configuration", + ); expect(mockHandleAddAutoRouterSubmit).not.toHaveBeenCalled(); }); @@ -319,6 +338,8 @@ describe("AddAutoRouterTab", () => { , ); + openTemplateDropdown(); + fireEvent.click(optionByLabel("Custom Configuration")!); await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router"); await user.selectOptions(screen.getByTestId("team-dropdown"), "team-1"); await user.click(screen.getByRole("button", { name: /add auto router/i })); @@ -335,6 +356,8 @@ describe("AddAutoRouterTab", () => { , ); + openTemplateDropdown(); + fireEvent.click(optionByLabel("Custom Configuration")!); await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router"); await user.click(screen.getByRole("button", { name: /add auto router/i })); diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index 9abc1c17ca1..f73a06860c3 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -122,7 +122,7 @@ const AddAutoRouterTab: React.FC = ({ label: model_group, })); - const availableModelSet = new Set(modelInfo.map((m) => m.model_group)); + const availableModelSet = React.useMemo(() => new Set(modelInfo.map((m) => m.model_group)), [modelInfo]); const presets = React.useMemo(() => getAllPresets(), []); // A preset's models can only be trusted against a successfully loaded list. Selection and the @@ -182,6 +182,12 @@ const AddAutoRouterTab: React.FC = ({ }; const submitRecommendedRouter = (name: string) => { + if (!selectedPreset) { + setShowValidationErrors(true); + NotificationManager.fromBackend("Please select a template, or choose Custom Configuration"); + return; + } + const { tiers, classifier_type: classifierType, @@ -348,6 +354,9 @@ const AddAutoRouterTab: React.FC = ({ ); })} + {showValidationErrors && !selectedPreset && ( +
Please select a template
+ )} { - it("loads exactly the two model-family presets (sample_spec excluded)", () => { + it("loads exactly the two model-family presets", () => { const presets = getAllPresets(); expect(presets.map((p) => p.label).sort()).toEqual(["Anthropic Family", "OpenAI Family"]); // Every preset carries all four fields the UI relies on; a JSON typo dropping one fails here. @@ -20,8 +20,6 @@ describe("autorouter_presets", () => { it("resolves a preset by its stable JSON key, not its display label", () => { expect(getPresetByKey("anthropic_family")?.label).toBe("Anthropic Family"); expect(getPresetByKey("does_not_exist")).toBeUndefined(); - // sample_spec is filtered out, so it is not resolvable by key either. - expect(getPresetByKey("sample_spec")).toBeUndefined(); }); it("keeps every preset a plain heuristic complexity router (no adaptive/quality settings)", () => { diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts index 46aec8685df..4183f90bbd4 100644 --- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts +++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts @@ -13,12 +13,10 @@ export interface AutoRouterPreset { // The bundled JSON is a developer-authored, build-time asset, so it is trusted at the import // boundary rather than re-validated at runtime (resolveJsonModule widens its string literals, // hence this one cast). autorouter_presets.test.ts pins the parsed shape, so a JSON typo fails -// CI. "sample_spec" documents the shape and is not a selectable preset. +// CI. const RAW = presetsRaw as Record>; -const PRESETS: AutoRouterPreset[] = Object.entries(RAW) - .filter(([key]) => key !== "sample_spec") - .map(([key, preset]) => ({ key, ...preset })); +const PRESETS: AutoRouterPreset[] = Object.entries(RAW).map(([key, preset]) => ({ key, ...preset })); export const getAllPresets = (): AutoRouterPreset[] => PRESETS;