mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): carry a preset's per-tier litellm_params through the prefill (#38453)
* fix(ui): carry a preset's per-tier litellm_params through the prefill buildPresetPrefill rebuilt the complexity router config field by field and never emitted tier_model_params, so a bundled preset that declares per-model litellm_params (reasoning_effort, for instance) lost them before the create form ever saw them. Both halves of the round trip already existed: hydrateTierModelParams reads either storage shape, and serializeTierModelConfigs writes them back on submit. Hydrating alone is not enough. Tier entries get rewritten to the caller's registered model spelling, which can differ from the preset's literal string by version-separator punctuation, while the params stay keyed on what the preset spelled. serializeTierModelConfigs then drops any param whose key is not in the tier, silently. The param keys go through the same resolver as the tier entries. * test(ui): catch a preset spelling the same model two ways in one tier 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 means one model's litellm_params silently overwrites the other's - flagged by Greptile on #38453 (P2, confirmed real via a throwaway repro, not a regression: on the merge base both param sets were already dropped). Nothing else validates preset authoring, and these are trusted, checked-in JSON, so the fix is a static test over the bundled data rather than runtime code. Exports normalizeModelName so the test exercises the actual resolution rule instead of a hand-rolled copy of it. Verified the test fails when a preset is mutated to spell one model two ways, and passes clean on the real bundled presets.
This commit is contained in:
parent
8ebcb3e181
commit
81dc8dba1c
2 changed files with 118 additions and 1 deletions
|
|
@ -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<string, string[]>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Record<string, TierModelParams>>((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 && {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue