From 7da891a42a7604697f06ddcfe4e12d7e55a79d29 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Thu, 6 Aug 2026 17:47:05 -0700 Subject: [PATCH] fix(ui): match auto-router preset models against wildcard-expanded model groups (#36111) --- .../add_model/add_auto_router_tab.test.tsx | 62 +++++++- .../src/lib/autorouter_presets.test.ts | 142 ++++++++++++++++++ .../src/lib/autorouter_presets.ts | 36 ++++- 3 files changed, 237 insertions(+), 3 deletions(-) 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 57afdbbd28b..6a5a1e0f159 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 @@ -636,8 +636,11 @@ describe("AddAutoRouterTab", () => { expect(labels).toEqual(["Anthropic Family", "OpenAI Family", "Custom Configuration"]); }); - it("never lets a wildcard deployment satisfy a preset", async () => { - const wildcard = [{ model_name: "openai-wild", litellm_params: { model: "openai/*" } }]; + it.each([ + ["a wildcard group", "openai/*"], + ["a plain group over a wildcard underlying model", "openai-wild"], + ])("never lets %s satisfy a preset when the hub lists no expansions", async (_label, modelName) => { + const wildcard = [{ model_name: modelName, litellm_params: { model: "openai/*" } }]; mockFetchAvailableModels.mockResolvedValue(groupsFor(wildcard)); mockFetchAllModelDeployments.mockResolvedValue(wildcard); @@ -650,4 +653,59 @@ describe("AddAutoRouterTab", () => { expect(isOptionDisabled(optionByLabel("OpenAI Family")!)).toBe(true); }); }); + + describe("wildcard-matched presets", () => { + const WILDCARD_DEPLOYMENTS = [{ model_name: "someprovider/*", litellm_params: { model: "someprovider/*" } }]; + + const expandedGroupFor = (model: string): string => `someprovider/${model}`; + + const EXPANDED_HUB_GROUPS: ModelGroup[] = [ + { model_group: "someprovider/*", mode: "chat" }, + ...[...new Set(getAllPresets().flatMap((preset) => [...getRequiredModelsInPreset(preset)]))].map((model) => ({ + model_group: expandedGroupFor(model), + mode: "chat", + })), + ]; + + it("enables a preset whose models exist only as wildcard-expanded groups, labeling the match", async () => { + mockFetchAvailableModels.mockResolvedValue(EXPANDED_HUB_GROUPS); + mockFetchAllModelDeployments.mockResolvedValue(WILDCARD_DEPLOYMENTS); + + renderWithProviders(); + openTemplateDropdown(); + + await waitFor(() => { + expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false); + }); + expect(optionByLabel("Anthropic Family")!.textContent).toContain("Matches your deployments"); + }); + + it("prefills the expanded group names and submits them", async () => { + const user = userEvent.setup(); + mockFetchAvailableModels.mockResolvedValue(EXPANDED_HUB_GROUPS); + mockFetchAllModelDeployments.mockResolvedValue(WILDCARD_DEPLOYMENTS); + + renderWithProviders(); + openTemplateDropdown(); + await waitFor(() => { + expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false); + }); + fireEvent.click(optionByLabel("Anthropic Family")!); + + await user.type(screen.getByPlaceholderText(/smart_router/i), "wildcard-router"); + await user.click(screen.getByRole("button", { name: /add auto router/i })); + + await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled()); + expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]).toMatchObject({ + complexity_router_config: { + tiers: { + SIMPLE: ANTHROPIC_TIERS.SIMPLE.map(expandedGroupFor), + MEDIUM: ANTHROPIC_TIERS.MEDIUM.map(expandedGroupFor), + COMPLEX: ANTHROPIC_TIERS.COMPLEX.map(expandedGroupFor), + REASONING: ANTHROPIC_TIERS.REASONING.map(expandedGroupFor), + }, + }, + }); + }); + }); }); diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts index 0d965be054b..fca8420966f 100644 --- a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts +++ b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts @@ -191,6 +191,148 @@ describe("autorouter_presets", () => { ); }); + describe("wildcard deployment matching (expanded model groups)", () => { + const wildcardDeployment = (pattern: string) => ({ modelGroup: pattern, underlyingModels: [pattern] }); + + const simpleTierConfig = (presetModel: string) => ({ + tiers: { SIMPLE: [presetModel], MEDIUM: [], COMPLEX: [], REASONING: [] }, + classifier_type: "heuristic" as const, + session_affinity: false, + }); + + it("resolves a preset model to a group expanded from a wildcard deployment", () => { + const availability = buildModelAvailability( + ["anthropic/*", "anthropic/claude-opus-5", "bedrock/anthropic.claude-opus-5"], + [wildcardDeployment("anthropic/*")], + ); + const config = simpleTierConfig("claude-opus-5"); + expect(getMissingModels(config, availability)).toEqual([]); + expect(buildPresetPrefill(config, availability).complexityRouterConfig.tiers.SIMPLE).toEqual([ + "anthropic/claude-opus-5", + ]); + }); + + it("normalizes an expanded group's namespaced own name the same way as a deployment's", () => { + const availability = buildModelAvailability( + ["bedrock/*", "bedrock/us.anthropic.claude-sonnet-5"], + [wildcardDeployment("bedrock/*")], + ); + expect(getMissingModels(simpleTierConfig("claude-sonnet-5"), availability)).toEqual([]); + }); + + it("anchors a partial wildcard pattern and treats its dots literally", () => { + const availability = buildModelAvailability( + ["bedrock/us.anthropic.claude-opus-5", "bedrock/usXanthropic.claude-fable-5"], + [wildcardDeployment("bedrock/us.*")], + ); + expect(getMissingModels(simpleTierConfig("claude-opus-5"), availability)).toEqual([]); + expect(getMissingModels(simpleTierConfig("claude-fable-5"), availability)).toEqual(["claude-fable-5"]); + }); + + it.each([ + ["gpt-5.4", "openai/gpt-5.4-mini"], + ["gpt-5.4-mini", "openai/gpt-5.4"], + ["o3", "openai/o3-mini"], + ])("never lets %s be satisfied by the expanded group %s", (presetModel, expandedGroup) => { + const availability = buildModelAvailability(["openai/*", expandedGroup], [wildcardDeployment("openai/*")]); + expect(getMissingModels(simpleTierConfig(presetModel), availability)).toEqual([presetModel]); + }); + + it("anchors the pattern's suffix and keeps middle segments in order", () => { + const availability = buildModelAvailability( + ["bedrock/us.anthropic.claude-opus-5", "bedrock/anthropic.us.claude-sonnet-5"], + [wildcardDeployment("bedrock/*.anthropic.*")], + ); + expect(getMissingModels(simpleTierConfig("claude-opus-5"), availability)).toEqual([]); + expect(getMissingModels(simpleTierConfig("claude-sonnet-5"), availability)).toEqual(["claude-sonnet-5"]); + }); + + it("matches a pathological many-star pattern in linear time instead of backtracking", () => { + const hostile = `prov/a*${"a*".repeat(30)}b`; + const nonMatching = `prov/${"a".repeat(120)}`; + const availability = buildModelAvailability([nonMatching], [wildcardDeployment(hostile)]); + expect(availability.underlyingIndex.size).toBe(0); + }); + + it("expands a bare-star model_name through its underlying wildcard, not as match-all", () => { + const availability = buildModelAvailability( + ["openai/gpt-5.4", "team-a/claude-opus-5"], + [{ modelGroup: "*", underlyingModels: ["openai/*"] }], + ); + expect(getMissingModels(simpleTierConfig("gpt-5.4"), availability)).toEqual([]); + expect(getMissingModels(simpleTierConfig("claude-opus-5"), availability)).toEqual(["claude-opus-5"]); + }); + + it.each([ + ["a bare-star underlying", "*"], + ["a non-wildcard underlying", "openai/gpt-4o"], + ["a slashless wildcard underlying", "gpt*"], + ])("derives no pattern from a bare-star model_name with %s", (_label, underlying) => { + const availability = buildModelAvailability( + ["openai/gpt-5.4"], + [{ modelGroup: "*", underlyingModels: [underlying] }], + ); + expect(availability.underlyingIndex.size).toBe(0); + }); + + it("derives no pattern from a slashless wildcard model_name", () => { + const availability = buildModelAvailability(["gpt-5.4"], [wildcardDeployment("gpt*")]); + expect(availability.underlyingIndex.size).toBe(0); + }); + + it("does not trust a group's name when no wildcard deployment covers it", () => { + const availability = buildModelAvailability( + ["team-a/claude-opus-5", "openai/*"], + [wildcardDeployment("openai/*")], + ); + expect(getMissingModels(simpleTierConfig("claude-opus-5"), availability)).toEqual(["claude-opus-5"]); + }); + + it("never resolves to the wildcard group itself when the hub lists no expansions", () => { + const availability = buildModelAvailability(["openai/*"], [wildcardDeployment("openai/*")]); + expect(getMissingModels(simpleTierConfig("gpt-5.4"), availability)).toEqual(["gpt-5.4"]); + expect(availability.underlyingIndex.size).toBe(0); + }); + + it("applies a wildcard deployment's pattern even when the wildcard group is not itself listed", () => { + const availability = buildModelAvailability(["anthropic/claude-opus-5"], [wildcardDeployment("anthropic/*")]); + expect(getMissingModels(simpleTierConfig("claude-opus-5"), availability)).toEqual([]); + }); + + it("keeps the groups-only availability strict even when expanded groups are listed", () => { + const availability = groupsOnly(["anthropic/*", "anthropic/claude-opus-5"]); + expect(getMissingModels(simpleTierConfig("claude-opus-5"), availability)).toEqual(["claude-opus-5"]); + }); + + it("prefers the alphabetically first covered group when several expansions serve the model", () => { + const availability = buildModelAvailability( + ["bedrock/us.anthropic.claude-opus-5", "anthropic/claude-opus-5", "bedrock/anthropic.claude-opus-5"], + [wildcardDeployment("anthropic/*"), wildcardDeployment("bedrock/*")], + ); + const config = simpleTierConfig("claude-opus-5"); + expect(buildPresetPrefill(config, availability).complexityRouterConfig.tiers.SIMPLE).toEqual([ + "anthropic/claude-opus-5", + ]); + }); + + it.each(getAllPresets().map((preset) => [preset.key, preset] as const))( + "fully resolves the %s preset through wildcard-expanded groups only", + (_key, preset) => { + const required = [...getRequiredModelsInPreset(preset)]; + const expandedGroups = required.map((model) => `someprovider/${model}`); + const availability = buildModelAvailability( + ["someprovider/*", ...expandedGroups], + [wildcardDeployment("someprovider/*")], + ); + expect(getMissingModelsInPreset(preset, availability)).toEqual([]); + const prefilled = buildPresetPrefill(preset.complexity_router_config, availability); + const prefilledModels = Object.values(prefilled.complexityRouterConfig.tiers).flat(); + expect(prefilledModels.length).toBeGreaterThan(0); + for (const model of prefilledModels) expect(expandedGroups).toContain(model); + }, + ); + }); + describe("deploymentRefsFromModelInfo", () => { it("keeps litellm_params.model and model_info.base_model, drops rows with neither or no name", () => { const refs = deploymentRefsFromModelInfo([ diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts index 5b6e3dc6f20..ae3f30c90f5 100644 --- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts +++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts @@ -77,12 +77,30 @@ const normalizeUnderlyingModel = (model: string): string | null => { return stripped.toLowerCase() || null; }; +// A linear glob scan rather than a RegExp: patterns are admin-controlled model_name values, and a +// backtracking regex built from one ("a*a*a*...") can freeze another admin's dashboard. +const matchesWildcard = (pattern: string, name: string): boolean => { + const parts = pattern.split("*"); + if (parts.length === 1) return pattern === name; + const head = parts[0]; + const tail = parts[parts.length - 1]; + if (!name.startsWith(head) || !name.endsWith(tail)) return false; + if (name.length < head.length + tail.length) return false; + const scanEnd = name.length - tail.length; + const scanResult = parts.slice(1, -1).reduce((searchFrom: number, part: string) => { + if (searchFrom < 0) return -1; + const found = name.indexOf(part, searchFrom); + return found === -1 || found + part.length > scanEnd ? -1 : found + part.length; + }, head.length); + return scanResult >= 0; +}; + export const buildModelAvailability = ( modelGroups: Iterable, deployments: readonly DeploymentModelRef[], ): ModelAvailability => { const groups = new Set(modelGroups); - const entries = deployments + const literalEntries = deployments .filter((deployment) => groups.has(deployment.modelGroup)) .flatMap((deployment) => deployment.underlyingModels @@ -90,6 +108,22 @@ export const buildModelAvailability = ( .filter((key): key is string => key !== null) .map((key) => ({ key, modelGroup: deployment.modelGroup })), ); + // Mirrors get_known_models_from_wildcard: a bare "*" model_name expands via its underlying + // wildcard (or not at all), and a wildcard without a "/" expands to nothing. + const wildcardPatterns = Array.from( + new Set( + deployments + .flatMap((deployment) => + deployment.modelGroup === "*" ? deployment.underlyingModels : [deployment.modelGroup], + ) + .filter((pattern) => pattern !== "*" && pattern.includes("*") && pattern.includes("/")), + ), + ); + const wildcardEntries = Array.from(groups) + .filter((group) => !group.includes("*") && wildcardPatterns.some((pattern) => matchesWildcard(pattern, group))) + .map((group) => ({ key: normalizeUnderlyingModel(group), modelGroup: group })) + .filter((entry): entry is { key: string; modelGroup: string } => entry.key !== null); + const entries = [...literalEntries, ...wildcardEntries]; const grouped = new Map>(); for (const entry of entries) { const groupsForKey = grouped.get(entry.key) ?? new Set();