diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts index 01c61ba6130..a2d23473fb9 100644 --- a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts +++ b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts @@ -11,6 +11,7 @@ import { buildPresetPrefill, buildModelAvailability, deploymentRefsFromModelInfo, + normalizeModelName, } from "./autorouter_presets"; import { DEFAULT_MATCH_THRESHOLD } from "@/components/add_model/SemanticKeywordMatching"; import { DEFAULT_ESCALATION_KEYWORDS } from "@/components/add_model/EscalationKeywords"; @@ -28,6 +29,29 @@ describe("autorouter_presets", () => { } }); + // buildPresetPrefill resolves every model reference through normalizeModelName, so two spellings + // of the same model in one tier (e.g. "claude-sonnet-4-5" and "claude-sonnet-4.5") collapse to one + // key. For tier_model_configs that silently drops one model's litellm_params; catch it in the + // bundled data itself, since nothing else validates preset authoring. + it("never spells the same model two ways within a single tier", () => { + for (const preset of getAllPresets()) { + const { tiers, tier_model_configs: configs } = preset.complexity_router_config; + for (const tier of Object.keys(tiers) as (keyof typeof tiers)[]) { + const fromTierList = tiers[tier] ?? []; + const fromConfigs = (configs?.[tier] ?? []).map((entry) => entry.model_name); + const names = new Set([...fromTierList, ...fromConfigs]); + const byNormalized = new Map(); + for (const name of names) { + const key = normalizeModelName(name); + byNormalized.set(key, [...(byNormalized.get(key) ?? []), name]); + } + for (const spellings of byNormalized.values()) { + expect(new Set(spellings).size, `${preset.key}.${tier}: ${spellings.join(", ")}`).toBe(1); + } + } + } + }); + it("resolves a preset by its stable JSON key, not its display label", () => { expect(getPresetByKey("anthropic_family")?.label).toBe("Anthropic Family"); expect(getPresetByKey("does_not_exist")).toBeUndefined(); @@ -544,5 +568,73 @@ describe("autorouter_presets", () => { const prefill = buildPresetPrefill(config, groupsOnly(["claude-sonnet-4.5"])); expect(prefill.complexityRouterConfig.tiers.SIMPLE).toEqual(["claude-sonnet-4.5"]); }); + + it("prefills the per-model litellm_params a preset carries in tier_model_configs", () => { + const config = { + tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: ["o3"] }, + tier_model_configs: { + REASONING: [{ model_name: "o3", litellm_params: { reasoning_effort: "high" } }], + }, + classifier_type: "heuristic" as const, + session_affinity: false, + deployment_affinity: true, + }; + const prefill = buildPresetPrefill(config, groupsOnly(["gpt-5-nano", "o3"])); + expect(prefill.complexityRouterConfig.tier_model_params).toEqual({ + REASONING: { o3: { reasoning_effort: "high" } }, + }); + }); + + // The params key on the preset's own spelling while the tier entry gets rewritten to the + // caller's. Leaving the key alone names a model the tier no longer holds, and + // serializeTierModelConfigs then drops the params on submit without saying so. + it("rewrites a param key to the same registered spelling its tier entry was rewritten to", () => { + const config = { + tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: ["claude-sonnet-4-5"] }, + tier_model_configs: { + REASONING: [{ model_name: "claude-sonnet-4-5", litellm_params: { reasoning_effort: "high" } }], + }, + classifier_type: "heuristic" as const, + session_affinity: false, + deployment_affinity: true, + }; + const prefill = buildPresetPrefill(config, groupsOnly(["claude-sonnet-4.5"])); + expect(prefill.complexityRouterConfig.tier_model_params).toEqual({ + REASONING: { "claude-sonnet-4.5": { reasoning_effort: "high" } }, + }); + }); + + // Two spellings of one model in a tier collapse to a single registered key, and one model can + // only hold one param set downstream. Merging keeps whatever only one spelling set instead of + // dropping that spelling's params wholesale. + it("merges rather than drops params when two spellings resolve to the same registered model", () => { + const config = { + tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: ["claude-sonnet-4-5", "claude-sonnet-4.5"] }, + tier_model_configs: { + REASONING: [ + { model_name: "claude-sonnet-4-5", litellm_params: { reasoning_effort: "high", temperature: 0.2 } }, + { model_name: "claude-sonnet-4.5", litellm_params: { reasoning_effort: "low" } }, + ], + }, + classifier_type: "heuristic" as const, + }; + const prefill = buildPresetPrefill(config, groupsOnly(["claude-sonnet-4.5"])); + // temperature survives from the spelling that would otherwise have been overwritten; + // reasoning_effort, set by both, resolves last-wins. + expect(prefill.complexityRouterConfig.tier_model_params).toEqual({ + REASONING: { "claude-sonnet-4.5": { reasoning_effort: "low", temperature: 0.2 } }, + }); + }); + + it("leaves tier_model_params undefined for a preset that carries no per-model params", () => { + const config = { + tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + classifier_type: "heuristic" as const, + session_affinity: false, + deployment_affinity: true, + }; + const prefill = buildPresetPrefill(config, groupsOnly(["gpt-5-nano"])); + expect(prefill.complexityRouterConfig.tier_model_params).toBeUndefined(); + }); }); }); diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts index fd1ca7a13c2..a35b868db5e 100644 --- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts +++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts @@ -12,6 +12,11 @@ import { } from "@/components/add_model/ComplexityRouterConfig"; import { KeywordTierRule } from "@/components/add_model/KeywordTierRules"; import { hydrateKeywordTierRules } from "@/components/add_model/complexity_router_keywords"; +import { + TierModelParams, + TierModelParamsByTier, + hydrateTierModelParams, +} from "@/components/add_model/complexity_router_tiers"; import { DEFAULT_ESCALATION_KEYWORDS } from "@/components/add_model/EscalationKeywords"; import { DEFAULT_MATCH_THRESHOLD } from "@/components/add_model/SemanticKeywordMatching"; import presetsRaw from "@/autorouter_presets.json"; @@ -54,7 +59,7 @@ export const getRequiredModels = ( // differing only in that separator. Canonicalizing on "-" (the presets' own convention) lets both // spellings match without doing anything looser - two DIFFERENT model names never collide here, // only the punctuation within one version number does. -const normalizeModelName = (model: string): string => model.replace(/(\d)\.(\d)/g, "$1-$2"); +export const normalizeModelName = (model: string): string => model.replace(/(\d)\.(\d)/g, "$1-$2"); export interface DeploymentModelRef { modelGroup: string; @@ -244,6 +249,25 @@ export const buildPresetPrefill = ( ): PresetPrefill => { const resolve = (model: string): string => resolveAvailableModel(model, availability) ?? model; const resolveTier = (models: string[]): string[] => models.map(resolve); + // Params key on the model name the preset spells while every tier entry is rewritten to the + // caller's registered spelling, so the keys have to be rewritten the same way. Otherwise + // serializeTierModelConfigs drops them for naming a model the tier no longer holds. + // + // Two spellings in one tier can resolve to the same registered model, and one model holds one + // param set here and in the payload, so a collision has to collapse. Merge rather than replace: + // params only one spelling set still survive, and a key both set resolves last-wins, matching + // how hydrateTierModelParams already collapses two entries spelled identically. + const resolveParamKeys = (params: TierModelParamsByTier | undefined): TierModelParamsByTier | undefined => + params && + Object.fromEntries( + Object.entries(params).map(([tier, byModel]) => [ + tier, + Object.entries(byModel).reduce>((byResolved, [model, litellmParams]) => { + const resolved = resolve(model); + return { ...byResolved, [resolved]: { ...byResolved[resolved], ...litellmParams } }; + }, {}), + ]), + ); return { complexityRouterConfig: { @@ -253,6 +277,7 @@ export const buildPresetPrefill = ( COMPLEX: resolveTier(config.tiers.COMPLEX), REASONING: resolveTier(config.tiers.REASONING), }, + tier_model_params: resolveParamKeys(hydrateTierModelParams(config.tiers, config.tier_model_configs)), tier_labels: hydrateTierLabels(config.tier_labels), classifier_type: config.classifier_type, classifier_llm_config: config.classifier_llm_config && {