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();