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 6a5a1e0f159..792372d1091 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
@@ -586,7 +586,7 @@ describe("AddAutoRouterTab", () => {
await waitFor(() => {
expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false);
});
- expect(optionByLabel("Anthropic Family")!.textContent).toContain("Matches your deployments");
+ expect(optionByLabel("Anthropic Family")!.textContent).toContain("Matches your models");
});
it("keeps detailed configuration open and prefills the admin's group names on apply", async () => {
@@ -677,7 +677,7 @@ describe("AddAutoRouterTab", () => {
await waitFor(() => {
expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false);
});
- expect(optionByLabel("Anthropic Family")!.textContent).toContain("Matches your deployments");
+ expect(optionByLabel("Anthropic Family")!.textContent).toContain("Matches your models");
});
it("prefills the expanded group names and submits them", async () => {
@@ -708,4 +708,58 @@ describe("AddAutoRouterTab", () => {
});
});
});
+
+ // The team-scoped shape from the live repro: /model_group/info lists every proxy group while
+ // /v2/model/info returns only the caller's team rows, so most preset models are backed by a hub
+ // group and nothing else.
+ describe("hub groups no deployment row covers", () => {
+ const prefixedGroupFor = (model: string): string => `anthropic/${model}`;
+
+ const PREFIXED_HUB_GROUPS: ModelGroup[] = [...getRequiredModelsInPreset(getPresetByKey("anthropic_family")!)].map(
+ (model) => ({ model_group: prefixedGroupFor(model), mode: "chat" }),
+ );
+
+ const UNRELATED_DEPLOYMENT = [{ model_name: "team-only-model", litellm_params: { model: "anthropic/some-other" } }];
+
+ it("enables a preset whose models the hub lists and the deployment rows omit", async () => {
+ mockFetchAvailableModels.mockResolvedValue(PREFIXED_HUB_GROUPS);
+ mockFetchAllModelDeployments.mockResolvedValue(UNRELATED_DEPLOYMENT);
+
+ renderWithProviders();
+ openTemplateDropdown();
+
+ await waitFor(() => {
+ expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false);
+ });
+ expect(optionByLabel("Anthropic Family")!.textContent).not.toContain("Missing:");
+ });
+
+ it("prefills and submits the hub's own group names", async () => {
+ const user = userEvent.setup();
+ mockFetchAvailableModels.mockResolvedValue(PREFIXED_HUB_GROUPS);
+ mockFetchAllModelDeployments.mockResolvedValue(UNRELATED_DEPLOYMENT);
+
+ renderWithProviders();
+ openTemplateDropdown();
+ await waitFor(() => {
+ expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false);
+ });
+ fireEvent.click(optionByLabel("Anthropic Family")!);
+
+ await user.type(screen.getByPlaceholderText(/smart_router/i), "hub-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(prefixedGroupFor),
+ MEDIUM: ANTHROPIC_TIERS.MEDIUM.map(prefixedGroupFor),
+ COMPLEX: ANTHROPIC_TIERS.COMPLEX.map(prefixedGroupFor),
+ REASONING: ANTHROPIC_TIERS.REASONING.map(prefixedGroupFor),
+ },
+ },
+ });
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
index 08041413d1f..79e94f2ef38 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
@@ -41,7 +41,7 @@ import {
buildPresetPrefill,
buildModelAvailability,
deploymentRefsFromModelInfo,
- ModelAvailability,
+ presetNeedsSubstitution,
PresetPrefill,
AutoRouterPreset,
} from "@/lib/autorouter_presets";
@@ -60,7 +60,7 @@ interface AddAutoRouterTabProps {
}
type PresetAvailability =
- | { kind: "available"; viaDeployments: boolean }
+ | { kind: "available"; viaSubstitution: boolean }
| { kind: "loading" }
| { kind: "unverifiable" }
| { kind: "missing_models"; models: readonly string[] };
@@ -115,12 +115,12 @@ const getSubmitBlockedReason = (
config: ComplexityRouterConfigValue,
keywordTierRules: KeywordTierRule[],
referencedModelsParams: Parameters[0],
- availability: ModelAvailability,
+ modelGroups: ReadonlySet,
): string | null =>
getMissingTiersError(config.tiers) ??
getTierLabelsError(config.tier_labels) ??
getKeywordTierRulesError(keywordTierRules) ??
- getReferencedModelsError(referencedModelsParams, availability);
+ getReferencedModelsError(referencedModelsParams, modelGroups);
const AddAutoRouterTab: React.FC = ({
handleOk,
@@ -150,7 +150,9 @@ const AddAutoRouterTab: React.FC = ({
// Closed by default: a caller opens it deliberately, either by clicking it or by choosing Custom
// (which expands it automatically, since there's nothing else to show them their config from). A
// preset re-collapses it after prefilling, offering the same "here's what got filled in, expand to
- // change it" affordance. A caller can always toggle it manually at any point.
+ // change it" affordance, EXCEPT where the prefill had to rewrite the preset's model names to the
+ // caller's own group names (viaSubstitution), which is worth showing unprompted. A caller can
+ // always toggle it manually at any point.
const [detailsExpanded, setDetailsExpanded] = useState(false);
const [isRoutingTestVisible, setIsRoutingTestVisible] = useState(false);
@@ -199,15 +201,6 @@ const AddAutoRouterTab: React.FC = ({
),
[modelInfo, deployments],
);
- const groupsOnlyAvailability = React.useMemo(
- () =>
- buildModelAvailability(
- modelInfo.map((m) => m.model_group),
- [],
- ),
- [modelInfo],
- );
-
// A preset's models can only be trusted against a successfully loaded list. Selection and the
// greyed-out state derive from this one function, so a preset that cannot be selected can never
// have been applied: while loading we withhold selection rather than let a caller pick a preset
@@ -219,12 +212,15 @@ const AddAutoRouterTab: React.FC = ({
if (modelsUnverifiable) return { kind: "unverifiable" };
const missing = getMissingModelsInPreset(preset, availability);
if (missing.length > 0) return { kind: "missing_models", models: missing };
+ // True when the preset's own model names are not themselves model groups, so applying it
+ // rewrites them to the caller's names. That one fact drives both the "Matches your models"
+ // hint and the auto-expand, since each is a way of saying "we picked these, take a look".
return {
kind: "available",
- viaDeployments: getMissingModelsInPreset(preset, groupsOnlyAvailability).length > 0,
+ viaSubstitution: presetNeedsSubstitution(preset, availability.modelGroups),
};
},
- [modelsLoading, modelsUnverifiable, availability, groupsOnlyAvailability],
+ [modelsLoading, modelsUnverifiable, availability],
);
const sortedPresetOptions = React.useMemo(
@@ -262,7 +258,7 @@ const AddAutoRouterTab: React.FC = ({
setSelectedPreset(presetKey);
applyPrefill(buildPresetPrefill(preset.complexity_router_config, availability));
- setDetailsExpanded(presetState.viaDeployments);
+ setDetailsExpanded(presetState.viaSubstitution);
};
const referencedModelsParams = {
@@ -277,7 +273,7 @@ const AddAutoRouterTab: React.FC = ({
complexityRouterConfig,
keywordTierRules,
referencedModelsParams,
- groupsOnlyAvailability,
+ availability.modelGroups,
);
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
@@ -344,7 +340,7 @@ const AddAutoRouterTab: React.FC = ({
// same handler) fires on Enter regardless of the button's disabled state - without this check,
// Enter in the name field could still create a router referencing a model that disappeared from
// availableModelSet after the tiers were filled in.
- const referencedModelsError = getReferencedModelsError(referencedModelsParams, groupsOnlyAvailability);
+ const referencedModelsError = getReferencedModelsError(referencedModelsParams, availability.modelGroups);
if (referencedModelsError) {
setShowValidationErrors(true);
NotificationManager.fromBackend(referencedModelsError);
@@ -446,7 +442,7 @@ const AddAutoRouterTab: React.FC = ({
const isDisabled = disabledHint !== null;
const hintClass = isPresetHintAlarming(presetState) ? "text-red-500" : "text-gray-400";
const matchedHint =
- presetState.kind === "available" && presetState.viaDeployments ? "Matches your deployments" : null;
+ presetState.kind === "available" && presetState.viaSubstitution ? "Matches your models" : null;
return (
{
it("never indexes a wildcard deployment", () => {
const availability = availabilityFor("openai-wild", "openai/*");
- expect(availability.underlyingIndex.size).toBe(0);
+ const config = { tiers: { SIMPLE: ["gpt-5.4"], MEDIUM: [], COMPLEX: [], REASONING: [] } };
+ expect(getMissingModels(config, availability)).toEqual(["gpt-5.4"]);
});
it("ignores a deployment whose group is not itself an available model group", () => {
@@ -138,7 +140,8 @@ describe("autorouter_presets", () => {
["some-other-group"],
[{ modelGroup: "orphan-group", underlyingModels: ["anthropic/claude-opus-5"] }],
);
- expect(availability.underlyingIndex.size).toBe(0);
+ const config = { tiers: { SIMPLE: ["claude-opus-5"], MEDIUM: [], COMPLEX: [], REASONING: [] } };
+ expect(getMissingModels(config, availability)).toEqual(["claude-opus-5"]);
});
it("breaks ties between groups serving the same model deterministically, alphabetically", () => {
@@ -191,7 +194,7 @@ describe("autorouter_presets", () => {
);
});
- describe("wildcard deployment matching (expanded model groups)", () => {
+ describe("model group name matching (what the tier dropdown lists)", () => {
const wildcardDeployment = (pattern: string) => ({ modelGroup: pattern, underlyingModels: [pattern] });
const simpleTierConfig = (presetModel: string) => ({
@@ -200,7 +203,34 @@ describe("autorouter_presets", () => {
session_affinity: false,
});
- it("resolves a preset model to a group expanded from a wildcard deployment", () => {
+ // The regression this block exists for: /model_group/info fills the tier dropdown while the
+ // deployments come from /v2/model/info, and a team-scoped caller sees every proxy group in the
+ // first and only their team's rows in the second. Before group names counted, the picker
+ // reported a model the caller could select one field below.
+ it("resolves a preset model to a prefixed group the hub lists with no deployment row of its own", () => {
+ const availability = buildModelAvailability(
+ ["claude-haiku-4-5", "anthropic/claude-sonnet-5"],
+ [{ modelGroup: "claude-haiku-4-5", underlyingModels: ["anthropic/claude-haiku-4-5"] }],
+ );
+ const config = simpleTierConfig("claude-sonnet-5");
+ expect(getMissingModels(config, availability)).toEqual([]);
+ expect(buildPresetPrefill(config, availability).complexityRouterConfig.tiers.SIMPLE).toEqual([
+ "anthropic/claude-sonnet-5",
+ ]);
+ });
+
+ // model_name is an admin's label, so a group can name one model while serving another. Where a
+ // deployment row exists it settles what the group carries, and the label gets no vote.
+ it("lets a deployment row override a group name that claims a different model", () => {
+ const availability = buildModelAvailability(
+ ["anthropic/claude-opus-5"],
+ [{ modelGroup: "anthropic/claude-opus-5", underlyingModels: ["anthropic/claude-haiku-4-5"] }],
+ );
+ expect(getMissingModels(simpleTierConfig("claude-opus-5"), availability)).toEqual(["claude-opus-5"]);
+ expect(getMissingModels(simpleTierConfig("claude-haiku-4-5"), availability)).toEqual([]);
+ });
+
+ it("resolves a preset model to a group the hub expanded from a wildcard deployment", () => {
const availability = buildModelAvailability(
["anthropic/*", "anthropic/claude-opus-5", "bedrock/anthropic.claude-opus-5"],
[wildcardDeployment("anthropic/*")],
@@ -212,7 +242,7 @@ describe("autorouter_presets", () => {
]);
});
- it("normalizes an expanded group's namespaced own name the same way as a deployment's", () => {
+ it("normalizes a group's namespaced own name the same way as a deployment's", () => {
const availability = buildModelAvailability(
["bedrock/*", "bedrock/us.anthropic.claude-sonnet-5"],
[wildcardDeployment("bedrock/*")],
@@ -220,94 +250,36 @@ describe("autorouter_presets", () => {
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/*")]);
+ ])("never lets %s be satisfied by the group %s", (presetModel, group) => {
+ const availability = buildModelAvailability(["openai/*", group], [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"]);
- });
-
+ // A group whose name is or contains a pattern is not a servable model name, so it can never
+ // stand in for one however the hub lists it.
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/*")]);
+ ["a bare star", "*"],
+ ["a provider wildcard", "openai/*"],
+ ["a slashless wildcard", "gpt*"],
+ ])("never satisfies a preset model from %s group", (_label, group) => {
+ const availability = buildModelAvailability([group], [wildcardDeployment(group)]);
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("prefers an exact group name over a group that merely carries the model", () => {
+ const availability = buildModelAvailability(["anthropic/claude-opus-5", "claude-opus-5"], []);
+ const config = simpleTierConfig("claude-opus-5");
+ expect(buildPresetPrefill(config, availability).complexityRouterConfig.tiers.SIMPLE).toEqual(["claude-opus-5"]);
});
- 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", () => {
+ it("prefers the alphabetically first group when several carry the same 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([
@@ -315,15 +287,21 @@ describe("autorouter_presets", () => {
]);
});
+ // The submit gate stays on literal model groups: what lands in the config has to be a name the
+ // router can resolve per request, and a preset's bare name is only ever submitted after
+ // buildPresetPrefill rewrote it to the group's real name.
+ it("keeps the submit gate on literal model group names", () => {
+ const groups = new Set(["anthropic/claude-opus-5"]);
+ expect(getMissingModelGroups(simpleTierConfig("claude-opus-5"), groups)).toEqual(["claude-opus-5"]);
+ expect(getMissingModelGroups(simpleTierConfig("anthropic/claude-opus-5"), groups)).toEqual([]);
+ });
+
it.each(getAllPresets().map((preset) => [preset.key, preset] as const))(
- "fully resolves the %s preset through wildcard-expanded groups only",
+ "fully resolves the %s preset through prefixed group names only",
(_key, preset) => {
const required = [...getRequiredModelsInPreset(preset)];
const expandedGroups = required.map((model) => `someprovider/${model}`);
- const availability = buildModelAvailability(
- ["someprovider/*", ...expandedGroups],
- [wildcardDeployment("someprovider/*")],
- );
+ const availability = buildModelAvailability(expandedGroups, []);
expect(getMissingModelsInPreset(preset, availability)).toEqual([]);
const prefilled = buildPresetPrefill(preset.complexity_router_config, availability);
const prefilledModels = Object.values(prefilled.complexityRouterConfig.tiers).flat();
@@ -379,7 +357,7 @@ describe("autorouter_presets", () => {
describe("getReferencedModelsError", () => {
const tiers = { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] };
- const available = groupsOnly(["gpt-5-nano"]);
+ const available = new Set(["gpt-5-nano"]);
// Both fields are always populated with a model missing from `available`; only the
// enabled/disabled toggles below decide whether that missing model gets reported.
const params = {
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
index ae3f30c90f5..ba0dfd8c011 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
@@ -77,24 +77,6 @@ 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[],
@@ -108,22 +90,18 @@ 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)))
+ // A group's own name is the last resort, used only where no deployment row speaks for it: the
+ // tier dropdown reads /model_group/info while these rows come from /v2/model/info, and a
+ // team-scoped caller gets every proxy group from the first and only their team's rows from the
+ // second, so without this a preset reports a model the caller can select one field below. Where a
+ // row does exist it wins outright, since model_name is an admin's label and can name one model
+ // while serving another.
+ const vouchedGroups = new Set(literalEntries.map((entry) => entry.modelGroup));
+ const groupNameEntries = Array.from(groups)
+ .filter((group) => !group.includes("*") && !vouchedGroups.has(group))
.map((group) => ({ key: normalizeUnderlyingModel(group), modelGroup: group }))
.filter((entry): entry is { key: string; modelGroup: string } => entry.key !== null);
- const entries = [...literalEntries, ...wildcardEntries];
+ const entries = [...literalEntries, ...groupNameEntries];
const grouped = new Map>();
for (const entry of entries) {
const groupsForKey = grouped.get(entry.key) ?? new Set();
@@ -152,21 +130,39 @@ export const deploymentRefsFromModelInfo = (
return row.model_name && underlyingModels.length > 0 ? [{ modelGroup: row.model_name, underlyingModels }] : [];
});
-const resolveAvailableModel = (requiredModel: string, availability: ModelAvailability): string | undefined => {
- const { modelGroups, underlyingIndex } = availability;
+// What may be SUBMITTED: only a literal model_group the router can resolve at request time. The two
+// spellings of one version number are the same group name (normalizeModelName), never a different
+// model.
+const resolveModelGroup = (requiredModel: string, modelGroups: ReadonlySet): string | undefined => {
if (modelGroups.has(requiredModel)) return requiredModel;
const normalized = normalizeModelName(requiredModel);
- const groupMatch = Array.from(modelGroups).find((available) => normalizeModelName(available) === normalized);
+ return Array.from(modelGroups).find((available) => normalizeModelName(available) === normalized);
+};
+
+// What may PREFILL a config: a preset's hardcoded name is a display convention, so it also matches a
+// group carrying that model under a provider prefix or a rename, and buildPresetPrefill rewrites it
+// to the group's real name before it can be submitted.
+const resolveAvailableModel = (requiredModel: string, availability: ModelAvailability): string | undefined => {
+ const groupMatch = resolveModelGroup(requiredModel, availability.modelGroups);
if (groupMatch !== undefined) return groupMatch;
const key = normalizeUnderlyingModel(requiredModel);
- return key === null ? undefined : underlyingIndex.get(key)?.[0];
+ return key === null ? undefined : availability.underlyingIndex.get(key)?.[0];
};
+const missingFrom = (
+ config: Pick,
+ resolve: (model: string) => string | undefined,
+): string[] => [...getRequiredModels(config)].filter((model) => resolve(model) === undefined).sort();
+
export const getMissingModels = (
config: Pick,
availability: ModelAvailability,
-): string[] =>
- [...getRequiredModels(config)].filter((model) => resolveAvailableModel(model, availability) === undefined).sort();
+): string[] => missingFrom(config, (model) => resolveAvailableModel(model, availability));
+
+export const getMissingModelGroups = (
+ config: Pick,
+ modelGroups: ReadonlySet,
+): string[] => missingFrom(config, (model) => resolveModelGroup(model, modelGroups));
export const getRequiredModelsInPreset = (preset: AutoRouterPreset): Set =>
getRequiredModels(preset.complexity_router_config);
@@ -174,6 +170,9 @@ export const getRequiredModelsInPreset = (preset: AutoRouterPreset): Set
export const getMissingModelsInPreset = (preset: AutoRouterPreset, availability: ModelAvailability): string[] =>
getMissingModels(preset.complexity_router_config, availability);
+export const presetNeedsSubstitution = (preset: AutoRouterPreset, modelGroups: ReadonlySet): boolean =>
+ getMissingModelGroups(preset.complexity_router_config, modelGroups).length > 0;
+
// Checks the config actually being built (whether it arrived via a preset prefill or was typed by
// hand - the two are indistinguishable once the caller has started editing), not a preset's
// original bundled model list. Only counts classifier_llm_config/embedding_model as referenced
@@ -188,15 +187,15 @@ export const getReferencedModelsError = (
semanticMatchingEnabled: boolean;
embeddingModel: string | undefined;
},
- availability: ModelAvailability,
+ modelGroups: ReadonlySet,
): string | null => {
- const missing = getMissingModels(
+ const missing = getMissingModelGroups(
{
tiers: params.tiers,
classifier_llm_config: params.classifierType === "llm" ? params.classifierLlmConfig : undefined,
embedding_model: params.semanticMatchingEnabled ? params.embeddingModel : undefined,
},
- availability,
+ modelGroups,
);
return missing.length > 0 ? `Model(s) no longer available: ${missing.join(", ")}` : null;
};