mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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.
This commit is contained in:
parent
85cf656827
commit
a1ff26cf13
5 changed files with 37 additions and 23 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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(<Harness />);
|
||||
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", () => {
|
|||
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
|
||||
);
|
||||
|
||||
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", () => {
|
|||
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
|
||||
);
|
||||
|
||||
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 }));
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
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<AddAutoRouterTabProps> = ({
|
|||
};
|
||||
|
||||
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<AddAutoRouterTabProps> = ({
|
|||
);
|
||||
})}
|
||||
</AntdSelect>
|
||||
{showValidationErrors && !selectedPreset && (
|
||||
<div className="text-xs mt-1 text-red-500">Please select a template</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from "./autorouter_presets";
|
||||
|
||||
describe("autorouter_presets", () => {
|
||||
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)", () => {
|
||||
|
|
|
|||
|
|
@ -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<string, Omit<AutoRouterPreset, "key">>;
|
||||
|
||||
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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue