fix(ui): match auto-router preset models against the model groups the tier dropdown lists

The template picker only counted a model group as carrying a model when a
/v2/model/info row vouched for it, so a team-scoped caller offered
anthropic/claude-sonnet-5 in the tier dropdown was told the Anthropic Family
preset was missing claude-sonnet-5. A group now carries its own model name
wherever no deployment row speaks for it, which subsumes the wildcard glob
matcher and deletes it, and the submit gate takes the model group set directly
so it stays on literal group names.
This commit is contained in:
Tin Chi Lo 2026-08-10 11:27:43 -07:00
parent 9bca9dfbb1
commit ab6ed61397
4 changed files with 174 additions and 147 deletions

View file

@ -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(<Harness />);
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(<Harness />);
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),
},
},
});
});
});
});

View file

@ -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<typeof getReferencedModelsError>[0],
availability: ModelAvailability,
modelGroups: ReadonlySet<string>,
): string | null =>
getMissingTiersError(config.tiers) ??
getTierLabelsError(config.tier_labels) ??
getKeywordTierRulesError(keywordTierRules) ??
getReferencedModelsError(referencedModelsParams, availability);
getReferencedModelsError(referencedModelsParams, modelGroups);
const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
handleOk,
@ -150,7 +150,9 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
// 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<boolean>(false);
const [isRoutingTestVisible, setIsRoutingTestVisible] = useState<boolean>(false);
@ -199,15 +201,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
),
[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<AddAutoRouterTabProps> = ({
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<AddAutoRouterTabProps> = ({
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<AddAutoRouterTabProps> = ({
complexityRouterConfig,
keywordTierRules,
referencedModelsParams,
groupsOnlyAvailability,
availability.modelGroups,
);
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
@ -344,7 +340,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
// 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<AddAutoRouterTabProps> = ({
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 (
<AntdSelect.Option

View file

@ -6,6 +6,7 @@ import {
getMissingModelsInPreset,
getRequiredModels,
getMissingModels,
getMissingModelGroups,
getReferencedModelsError,
buildEmptyPrefill,
buildPresetPrefill,
@ -130,7 +131,8 @@ describe("autorouter_presets", () => {
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 = {

View file

@ -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<string>,
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<string, Set<string>>();
for (const entry of entries) {
const groupsForKey = grouped.get(entry.key) ?? new Set<string>();
@ -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>): 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<ComplexityRouterConfigPayload, "tiers" | "classifier_llm_config" | "embedding_model">,
resolve: (model: string) => string | undefined,
): string[] => [...getRequiredModels(config)].filter((model) => resolve(model) === undefined).sort();
export const getMissingModels = (
config: Pick<ComplexityRouterConfigPayload, "tiers" | "classifier_llm_config" | "embedding_model">,
availability: ModelAvailability,
): string[] =>
[...getRequiredModels(config)].filter((model) => resolveAvailableModel(model, availability) === undefined).sort();
): string[] => missingFrom(config, (model) => resolveAvailableModel(model, availability));
export const getMissingModelGroups = (
config: Pick<ComplexityRouterConfigPayload, "tiers" | "classifier_llm_config" | "embedding_model">,
modelGroups: ReadonlySet<string>,
): string[] => missingFrom(config, (model) => resolveModelGroup(model, modelGroups));
export const getRequiredModelsInPreset = (preset: AutoRouterPreset): Set<string> =>
getRequiredModels(preset.complexity_router_config);
@ -174,6 +170,9 @@ export const getRequiredModelsInPreset = (preset: AutoRouterPreset): Set<string>
export const getMissingModelsInPreset = (preset: AutoRouterPreset, availability: ModelAvailability): string[] =>
getMissingModels(preset.complexity_router_config, availability);
export const presetNeedsSubstitution = (preset: AutoRouterPreset, modelGroups: ReadonlySet<string>): 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>,
): 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;
};