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:
tin-berri 2026-08-26 23:48:04 -07:00 committed by GitHub
parent 8ebcb3e181
commit 81dc8dba1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 118 additions and 1 deletions

View file

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

View file

@ -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 && {