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:
Tin Chi Lo 2026-08-03 16:09:32 -07:00
parent 85cf656827
commit a1ff26cf13
5 changed files with 37 additions and 23 deletions

View file

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

View file

@ -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 }));

View file

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

View file

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

View file

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