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; };