mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
fix(ui): match auto-router preset models against wildcard-expanded model groups (#36111)
This commit is contained in:
parent
811b402ba3
commit
7da891a42a
3 changed files with 237 additions and 3 deletions
|
|
@ -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(<Harness />);
|
||||
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(<Harness />);
|
||||
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),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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<string>,
|
||||
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<string, Set<string>>();
|
||||
for (const entry of entries) {
|
||||
const groupsForKey = grouped.get(entry.key) ?? new Set<string>();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue