From 90c40fdfee732b57fde03cad946835dd726770a1 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Wed, 26 Aug 2026 23:23:40 -0700 Subject: [PATCH] fix(ui): merge colliding param sets instead of overwriting one Two spellings of the same model in one tier resolve to a single registered key, and resolveParamKeys built the resolved map with Object.fromEntries, so the later spelling's litellm_params replaced the earlier one's wholesale. One model holds one param set both here and in the submitted payload, so a collision has to collapse; it does not have to lose anything. Merging keeps params only one spelling set, and leaves a key both set resolving last-wins, which is how hydrateTierModelParams already collapses two entries spelled identically. Closes the P2 Greptile held a point back for on #38453. The bundled-preset invariant added in 7174fa7a4c still guards shipped data; this covers a preset that has not been through that gate yet. --- .../src/lib/autorouter_presets.test.ts | 22 +++++++++++++++++++ .../src/lib/autorouter_presets.ts | 16 ++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts index 6324b7fd611..a2d23473fb9 100644 --- a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts +++ b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts @@ -604,6 +604,28 @@ describe("autorouter_presets", () => { }); }); + // 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: [] }, diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts index f6e7abee2f6..a35b868db5e 100644 --- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts +++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts @@ -12,7 +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 { TierModelParamsByTier, hydrateTierModelParams } from "@/components/add_model/complexity_router_tiers"; +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"; @@ -248,12 +252,20 @@ export const buildPresetPrefill = ( // 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.fromEntries(Object.entries(byModel).map(([model, litellmParams]) => [resolve(model), litellmParams])), + Object.entries(byModel).reduce>((byResolved, [model, litellmParams]) => { + const resolved = resolve(model); + return { ...byResolved, [resolved]: { ...byResolved[resolved], ...litellmParams } }; + }, {}), ]), );