Loading templates...
}
{presetsUnavailable && (
diff --git a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
index a26b39c2980..cfaa23fbeba 100644
--- a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { effectiveClassifierType, type ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { transitionClassifierType } from "./classifier_type_transition";
import { applyTierSetAction } from "./tier_set_actions";
+import { buildUpdatedComplexityRouterConfig } from "../edit_auto_router/edit_auto_router_modal";
const standard: ComplexityRouterConfigValue = {
classifier_type: "llm",
@@ -38,17 +39,17 @@ describe("transitionClassifierType", () => {
tiers: initial.tiers,
};
expect(jev).toMatchObject(expectedJevConfig);
- expect(jev.classifier_llm_config).toBeUndefined();
- expect(jev.classification_prompt).toBeUndefined();
- expect(jev.classification_examples).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, jev).classifier_llm_config).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, jev).classification_prompt).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, jev).classification_examples).toBeUndefined();
const custom = applyTierSetAction(jev, [], { kind: "patch", id: "SIMPLE", patch: { name: "QUICK" } }).value;
expect(effectiveClassifierType(custom)).toBe("jev");
const restored = applyTierSetAction(custom, [], { kind: "restore" }).value;
expect(effectiveClassifierType(restored)).toBe("jev");
expect(restored.jev_classifier_config).toEqual(jev.jev_classifier_config);
const llm = transitionClassifierType(custom, "llm");
- expect(llm.jev_classifier_config).toBeUndefined();
- expect(llm.classifier_llm_config).toMatchObject({ model: "" });
+ expect(buildUpdatedComplexityRouterConfig({}, llm).opensource_classifier_config).toBeUndefined();
+ expect(llm.classifier_llm_config).toEqual(standard.classifier_llm_config);
expect(llm.custom_tier_set).toEqual(custom.custom_tier_set);
expect(llm.classifier_context_window_size).toBe(8);
});
@@ -68,8 +69,11 @@ describe("transitionClassifierType", () => {
it.each(["capability", "llm_v2"] as const)("requires explicit policy input for a new %s classifier", (target) => {
const result = transitionClassifierType(standard, target);
- expect(result.classifier_llm_config).toEqual({ model: "judge", timeout_ms: 20000 });
- expect(result.classifier_fallback).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, result).classifier_llm_config).toEqual({
+ model: "judge",
+ timeout_ms: 20000,
+ });
+ expect(buildUpdatedComplexityRouterConfig({}, result).classifier_fallback).toBeUndefined();
if (target === "capability") {
expect(result.capability_classifier_config?.base_threshold).toBeNaN();
} else {
@@ -93,10 +97,10 @@ describe("transitionClassifierType", () => {
expect(result.classifier_llm_config).toEqual({
model: "judge",
timeout_ms: 20000,
- classification_rubric: "agentic",
+ classification_rubric: "business",
});
- expect(result.capability_classifier_config).toBeUndefined();
- expect(result.llm_v2_config).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, result).capability_classifier_config).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, result).llm_v2_config).toBeUndefined();
});
it("clears the inactive non-reasoning pool and plan floor when switching to local classification", () => {
@@ -107,14 +111,83 @@ describe("transitionClassifierType", () => {
plan_mode_min_tier: "NON_REASONING",
};
const result = transitionClassifierType(initial, "heuristic");
- expect(result.classifier_llm_config).toBeUndefined();
- expect(result.classifier_context_window_size).toBeUndefined();
- expect(result.classifier_context_budget_chars).toBeUndefined();
- expect(result.classifier_context_include_assistant_turns).toBeUndefined();
- expect(result.classifier_fallback).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, result).classifier_llm_config).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, result).classifier_context_window_size).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, result).classifier_context_budget_chars).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, result).classifier_context_include_assistant_turns).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, result).classifier_fallback).toBeUndefined();
expect(result.tiers.NON_REASONING).toBeUndefined();
expect(result.enable_non_reasoning_tier).toBeUndefined();
expect(result.plan_mode_min_tier).toBeUndefined();
expect(result.tiers.SIMPLE).toEqual(["efficient"]);
});
});
+
+describe("classifier draft preservation", () => {
+ it("keeps the decision deployment and LLM tuning while serializing only the active classifier", () => {
+ const initial = {
+ ...standard,
+ classifier_type: "jev" as const,
+ jev_classifier_config: { deployment_name: "decision", model: "jev-latest", timeout_ms: 3000 },
+ };
+ const judge = transitionClassifierType(initial, "llm");
+ const heuristic = transitionClassifierType(judge, "heuristic");
+ const decision = transitionClassifierType(heuristic, "jev");
+ expect(decision.jev_classifier_config).toEqual(initial.jev_classifier_config);
+ expect(transitionClassifierType(decision, "llm").classifier_llm_config).toEqual(standard.classifier_llm_config);
+ expect(decision.classifier_context_window_size).toBe(standard.classifier_context_window_size);
+ expect(buildUpdatedComplexityRouterConfig({}, decision).classifier_llm_config).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, heuristic).opensource_classifier_config).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, heuristic).classifier_llm_config).toBeUndefined();
+ expect(buildUpdatedComplexityRouterConfig({}, judge).opensource_classifier_config).toBeUndefined();
+ });
+});
+
+it.each(["heuristic", "heuristic_v2", "llm", "jev", "capability", "llm_v2"] as const)(
+ "preserves classifier tuning through %s and excludes inactive settings from save",
+ (target) => {
+ const initial: ComplexityRouterConfigValue = {
+ ...standard,
+ heuristic_v2_success_threshold: 0.65,
+ capability_classifier_config: { efficient_tier: "SIMPLE", capable_tier: "REASONING", base_threshold: 0.8 },
+ llm_v2_config: { efficient_profile: "fast", capable_profile: "thorough", harness: "tools", max_quality_gap: 0.1 },
+ };
+ const selected = transitionClassifierType(initial, target);
+ const restored = transitionClassifierType(selected, "llm");
+ expect(restored.classifier_llm_config).toEqual(initial.classifier_llm_config);
+ expect(restored.classifier_context_window_size).toBe(initial.classifier_context_window_size);
+ expect(restored.heuristic_v2_success_threshold).toBe(initial.heuristic_v2_success_threshold);
+ expect(restored.capability_classifier_config).toEqual(initial.capability_classifier_config);
+ expect(restored.llm_v2_config).toEqual(initial.llm_v2_config);
+ const saved = buildUpdatedComplexityRouterConfig({}, restored);
+ expect(saved.capability_classifier_config).toBeUndefined();
+ expect(saved.llm_v2_config).toBeUndefined();
+ expect(saved.opensource_classifier_config).toBeUndefined();
+ expect(saved.previous_classifier_types).toBeUndefined();
+ },
+);
+
+it.each([
+ ["capability", "llm_v2"],
+ ["llm_v2", "capability"],
+] as const)("keeps the solver pair and policy when switching from %s to %s and back", (source, target) => {
+ const initial: ComplexityRouterConfigValue = {
+ ...standard,
+ classifier_type: source,
+ tiers: { SIMPLE: [], MEDIUM: ["efficient"], COMPLEX: ["capable"], REASONING: [] },
+ capability_classifier_config: { efficient_tier: "MEDIUM", capable_tier: "COMPLEX", base_threshold: 0.8 },
+ llm_v2_config: {
+ efficient_tier: "MEDIUM",
+ capable_tier: "COMPLEX",
+ efficient_profile: "fast",
+ capable_profile: "thorough",
+ harness: "tools",
+ max_quality_gap: 0.1,
+ },
+ };
+ const selected = transitionClassifierType(initial, target);
+ const restored = transitionClassifierType(selected, source);
+ expect(restored.tiers).toEqual(initial.tiers);
+ expect(restored.capability_classifier_config).toEqual(initial.capability_classifier_config);
+ expect(restored.llm_v2_config).toEqual(initial.llm_v2_config);
+});
diff --git a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts
index ba758eac471..5798f9304b4 100644
--- a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts
+++ b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts
@@ -11,48 +11,45 @@ import {
usesClassifierContext,
} from "./ComplexityRouterConfig";
import { defaultJevClassifierConfig } from "./jev_classifier_config";
-import { isForecastClassifier, prepareForecastClassifier } from "./forecast_classifier_config";
+import { prepareForecastClassifier } from "./forecast_classifier_config";
import { nonReasoningTierFields } from "./nonReasoningTierFields";
export const transitionClassifierType = (
value: ComplexityRouterConfigValue,
classifierType: ClassifierType,
): ComplexityRouterConfigValue => {
- const startsLlmRubric =
- !value.classifier_llm_config ||
- (isForecastClassifier(value.classifier_type) && !isForecastClassifier(classifierType));
+ const startsLlmRubric = !value.classifier_llm_config;
const judgeConfig = value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS };
const nextValue: ComplexityRouterConfigValue = {
...value,
+ previous_classifier_types: [
+ value.classifier_type,
+ ...(value.previous_classifier_types ?? []).filter((type) => type !== value.classifier_type),
+ ],
jev_classifier_config:
- classifierType === "jev" ? value.jev_classifier_config ?? defaultJevClassifierConfig() : undefined,
- classification_prompt: classifierType === "jev" ? undefined : value.classification_prompt,
- classification_examples: classifierType === "jev" ? undefined : value.classification_examples,
+ classifierType === "jev"
+ ? value.jev_classifier_config ?? defaultJevClassifierConfig()
+ : value.jev_classifier_config,
classifier_llm_config: usesLlmClassifier(classifierType)
? {
...judgeConfig,
...(startsLlmRubric && { classification_rubric: NEW_CLASSIFIER_CLASSIFICATION_RUBRIC }),
}
- : undefined,
+ : value.classifier_llm_config,
classifier_context_window_size: usesClassifierContext(classifierType)
? value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE
- : undefined,
+ : value.classifier_context_window_size,
classifier_context_budget_chars: usesClassifierContext(classifierType)
? value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS
- : undefined,
- classifier_context_per_turn_chars: usesClassifierContext(classifierType)
- ? value.classifier_context_per_turn_chars
- : undefined,
- classifier_context_include_assistant_turns: usesClassifierContext(classifierType)
- ? value.classifier_context_include_assistant_turns
- : undefined,
- classifier_fallback: usesClassifierContext(classifierType) ? value.classifier_fallback : undefined,
+ : value.classifier_context_budget_chars,
heuristic_first_max_tier:
classifierType === "heuristic_first"
? value.heuristic_first_max_tier ?? DEFAULT_HEURISTIC_FIRST_MAX_TIER
- : undefined,
+ : value.heuristic_first_max_tier,
hybrid_boundary_margin:
- classifierType === "hybrid" ? value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN : undefined,
+ classifierType === "hybrid"
+ ? value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN
+ : value.hybrid_boundary_margin,
...nonReasoningTierFields(classifierType, value),
};
return prepareForecastClassifier(nextValue, classifierType);
diff --git a/ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.ts b/ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.ts
index 6b781e09ed9..d4bb5b71df0 100644
--- a/ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.ts
@@ -114,12 +114,7 @@ export const prepareForecastClassifier = (
classifierType: ClassifierType = previous.classifier_type,
): ComplexityRouterConfigValue => {
const value = { ...previous, classifier_type: classifierType };
- if (!isForecastClassifier(classifierType))
- return {
- ...value,
- capability_classifier_config: undefined,
- llm_v2_config: undefined,
- };
+ if (!isForecastClassifier(classifierType)) return value;
// Capture routing identity before replacing the source classifier's settings.
const [sourceEfficient, sourceCapable] = forecastTierNames(previous);
const transferredPair =
@@ -131,11 +126,11 @@ export const prepareForecastClassifier = (
capability_classifier_config:
value.classifier_type === "capability"
? { ...(value.capability_classifier_config ?? newCapabilitySettings()), ...transferredPair }
- : undefined,
+ : value.capability_classifier_config,
llm_v2_config:
value.classifier_type === "llm_v2"
? { ...(value.llm_v2_config ?? newFuseSettings()), ...transferredPair }
- : undefined,
+ : value.llm_v2_config,
};
const [efficient, capable] = forecastTierNames(configured);
const solverModels = (tier: string) => {
@@ -163,10 +158,6 @@ export const prepareForecastClassifier = (
: undefined,
custom_tier_set: undefined,
enable_non_reasoning_tier: false,
- classification_prompt: undefined,
- classification_examples: undefined,
- classifier_fallback: undefined,
- classifier_llm_config: value.classifier_llm_config && withoutForecastPromptOverrides(value.classifier_llm_config),
...(value.classifier_type === "llm_v2" && { adaptive: false }),
};
};
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
index b398780d14e..d1c05b05cc1 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
+import type { ComplexityRouterConfigValue } from "@/components/add_model/ComplexityRouterConfig";
import { BUNDLED_PRESETS_RESPONSE } from "../../tests/mocks/autoRouterPresets";
import {
hydratePresets,
@@ -9,7 +10,7 @@ import {
getMissingModels,
getReferencedModelsError,
buildEmptyPrefill,
- buildPresetPrefill,
+ applyPresetTiers,
buildModelAvailability,
deploymentRefsFromModelInfo,
normalizeModelName,
@@ -43,7 +44,7 @@ describe("autorouter_presets", () => {
}
});
- // buildPresetPrefill resolves every model reference through normalizeModelName, so two spellings
+ // applyPresetTiers 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.
@@ -94,7 +95,9 @@ describe("autorouter_presets", () => {
expect(config.custom_dimensions).toBeUndefined();
}
const config = getPresetByKey("anthropic_family")!.complexity_router_config;
- expect(buildPresetPrefill(config, groupsOnly([])).complexityRouterConfig.custom_dimensions).toBeUndefined();
+ expect(
+ applyPresetTiers(buildEmptyPrefill().complexityRouterConfig, config, groupsOnly([])).custom_dimensions,
+ ).toBeUndefined();
});
it("resets both scoring overrides when the form falls back to an empty prefill", () => {
@@ -102,28 +105,18 @@ describe("autorouter_presets", () => {
expect(buildEmptyPrefill().complexityRouterConfig.dimension_weights).toBeUndefined();
});
- it("carries a preset's session affinity idle window into the prefilled form state", () => {
- const config = getPresetByKey("anthropic_family")!.complexity_router_config;
- const prefill = buildPresetPrefill({ ...config, session_affinity_ttl_seconds: 300 }, groupsOnly([]));
- expect(prefill.complexityRouterConfig.session_affinity_ttl_seconds).toBe(300);
- expect(
- buildPresetPrefill(config, groupsOnly([])).complexityRouterConfig.session_affinity_ttl_seconds,
- ).toBeUndefined();
- });
-
- it("carries a preset's stored weights and custom dimensions into the prefill without rebalancing them", () => {
- const config = getPresetByKey("anthropic_family")!.complexity_router_config;
- const weights = { codePresence: 0.4 };
- const dimension = { name: "domain", weight: 0.9, keywords: ["orbitmesh"] };
- const prefill = buildPresetPrefill(
- { ...config, dimension_weights: weights, custom_dimensions: [dimension] },
- groupsOnly([]),
- ).complexityRouterConfig;
- expect(prefill.dimension_weights).toEqual(weights);
- expect(prefill.custom_dimensions).toEqual([{ ...dimension, id: "stored-0" }]);
- const plain = buildPresetPrefill(config, groupsOnly([])).complexityRouterConfig;
- expect(plain.dimension_weights).toBeUndefined();
- expect(plain.custom_dimensions).toBeUndefined();
+ it("preserves session affinity and custom scoring settings", () => {
+ const config = PRESETS[0].complexity_router_config;
+ const current = {
+ ...buildEmptyPrefill().complexityRouterConfig,
+ session_affinity_ttl_seconds: 300,
+ dimension_weights: { codePresence: 0.4 },
+ custom_dimensions: [{ id: "domain", name: "domain", weight: 0.9, keywords: ["orbitmesh"] }],
+ };
+ const selected = applyPresetTiers(current, config, groupsOnly([]));
+ expect(selected.session_affinity_ttl_seconds).toBe(300);
+ expect(selected.dimension_weights).toEqual(current.dimension_weights);
+ expect(selected.custom_dimensions).toEqual(current.custom_dimensions);
});
it("keeps the model-family presets on the heuristic classifier", () => {
@@ -180,28 +173,20 @@ describe("autorouter_presets", () => {
}
});
- it("carries a preset's modality_routing into the prefilled form state", () => {
- const preset = getPresetByKey("anthropic_family")!;
- const withFlag = { ...preset.complexity_router_config, modality_routing: true };
- const prefill = buildPresetPrefill(withFlag, groupsOnly(getRequiredModelsInPreset(preset)));
- expect(prefill.complexityRouterConfig.modality_routing).toBe(true);
- const withoutFlag = buildPresetPrefill(
+ it.each([false, true])("preserves the chosen modality settings %s", (enabled) => {
+ const preset = PRESETS[0];
+ const current = {
+ ...buildEmptyPrefill().complexityRouterConfig,
+ modality_routing: enabled,
+ modality_pin_override: enabled,
+ };
+ const selected = applyPresetTiers(
+ current,
preset.complexity_router_config,
groupsOnly(getRequiredModelsInPreset(preset)),
);
- expect(withoutFlag.complexityRouterConfig.modality_routing).toBe(false);
- });
-
- it("carries a preset's modality_pin_override into the prefilled form state", () => {
- const preset = getPresetByKey("anthropic_family")!;
- const withFlag = { ...preset.complexity_router_config, modality_routing: true, modality_pin_override: true };
- const prefill = buildPresetPrefill(withFlag, groupsOnly(getRequiredModelsInPreset(preset)));
- expect(prefill.complexityRouterConfig.modality_pin_override).toBe(true);
- const withoutFlag = buildPresetPrefill(
- preset.complexity_router_config,
- groupsOnly(getRequiredModelsInPreset(preset)),
- );
- expect(withoutFlag.complexityRouterConfig.modality_pin_override).toBe(false);
+ expect(selected.modality_routing).toBe(enabled);
+ expect(selected.modality_pin_override).toBe(enabled);
});
it("ships every bundled preset with both modality flags written out, since the payload type requires them", () => {
@@ -213,16 +198,24 @@ describe("autorouter_presets", () => {
it("prefills the anthropic preset's effort through to tier_model_params", () => {
const preset = getPresetByKey("anthropic_family")!;
- const prefill = buildPresetPrefill(preset.complexity_router_config, groupsOnly(getRequiredModelsInPreset(preset)));
- expect(prefill.complexityRouterConfig.tier_model_params).toEqual({
+ const prefill = applyPresetTiers(
+ buildEmptyPrefill().complexityRouterConfig,
+ preset.complexity_router_config,
+ groupsOnly(getRequiredModelsInPreset(preset)),
+ );
+ expect(prefill.tier_model_params).toEqual({
REASONING: { "claude-fable-5-1": { reasoning_effort: "high" } },
});
});
it("prefills the lite preset's efforts through to tier_model_params", () => {
const lite = getPresetByKey("lite")!;
- const prefill = buildPresetPrefill(lite.complexity_router_config, groupsOnly(getRequiredModelsInPreset(lite)));
- expect(prefill.complexityRouterConfig.tier_model_params).toEqual({
+ const prefill = applyPresetTiers(
+ buildEmptyPrefill().complexityRouterConfig,
+ lite.complexity_router_config,
+ groupsOnly(getRequiredModelsInPreset(lite)),
+ );
+ expect(prefill.tier_model_params).toEqual({
MEDIUM: { "muse-spark-1.3": { reasoning_effort: "xhigh" } },
COMPLEX: { "kimi-k3": { reasoning_effort: "max" } },
});
@@ -240,8 +233,12 @@ describe("autorouter_presets", () => {
expect(preset.complexity_router_config.tier_model_configs).toEqual({
REASONING: [{ model_name: "gpt-6-astra", litellm_params: { reasoning_effort: "xhigh" } }],
});
- const prefill = buildPresetPrefill(preset.complexity_router_config, groupsOnly(getRequiredModelsInPreset(preset)));
- expect(prefill.complexityRouterConfig.tier_model_params).toEqual({
+ const prefill = applyPresetTiers(
+ buildEmptyPrefill().complexityRouterConfig,
+ preset.complexity_router_config,
+ groupsOnly(getRequiredModelsInPreset(preset)),
+ );
+ expect(prefill.tier_model_params).toEqual({
REASONING: { "gpt-6-astra": { reasoning_effort: "xhigh" } },
});
});
@@ -259,8 +256,12 @@ describe("autorouter_presets", () => {
expect(preset.complexity_router_config.tier_model_configs).toEqual({
REASONING: [{ model_name: "claude-opus-5-5", litellm_params: { reasoning_effort: "high" } }],
});
- const prefill = buildPresetPrefill(preset.complexity_router_config, groupsOnly(getRequiredModelsInPreset(preset)));
- expect(prefill.complexityRouterConfig.tier_model_params).toEqual({
+ const prefill = applyPresetTiers(
+ buildEmptyPrefill().complexityRouterConfig,
+ preset.complexity_router_config,
+ groupsOnly(getRequiredModelsInPreset(preset)),
+ );
+ expect(prefill.tier_model_params).toEqual({
REASONING: { "claude-opus-5-5": { reasoning_effort: "high" } },
});
});
@@ -354,7 +355,9 @@ describe("autorouter_presets", () => {
deployment_affinity: true,
};
expect(getMissingModels(config, availability)).toEqual([]);
- expect(buildPresetPrefill(config, availability).complexityRouterConfig.tiers.SIMPLE).toEqual([group]);
+ expect(applyPresetTiers(buildEmptyPrefill().complexityRouterConfig, config, availability).tiers.SIMPLE).toEqual([
+ group,
+ ]);
});
it.each([
@@ -431,11 +434,11 @@ describe("autorouter_presets", () => {
[["a-cloud"], "a-cloud"],
] as const) {
const availability = buildModelAvailability(groups, deployments);
- const prefill = buildPresetPrefill(config, availability).complexityRouterConfig;
+ const prefill = applyPresetTiers(buildEmptyPrefill().complexityRouterConfig, config, availability);
expect(prefill.tiers.SIMPLE).toEqual([selected]);
expect(prefill.tier_model_params).toEqual({ SIMPLE: { [selected]: { reasoning_effort: "high" } } });
- expect(prefill.classifier_llm_config).toEqual({ model: selected, timeout_ms: 3000 });
+ expect(prefill.classifier_llm_config).toBeUndefined();
}
},
);
@@ -536,7 +539,9 @@ describe("autorouter_presets", () => {
session_affinity: false,
deployment_affinity: true,
};
- expect(buildPresetPrefill(config, availability).complexityRouterConfig.tiers.SIMPLE).toEqual(["claude-opus-5"]);
+ expect(applyPresetTiers(buildEmptyPrefill().complexityRouterConfig, config, availability).tiers.SIMPLE).toEqual([
+ "claude-opus-5",
+ ]);
});
it.each(getAllPresets().map((preset) => [preset.key, preset] as const))(
@@ -552,8 +557,12 @@ describe("autorouter_presets", () => {
})),
);
expect(getMissingModelsInPreset(preset, availability)).toEqual([]);
- const prefilled = buildPresetPrefill(preset.complexity_router_config, availability);
- const prefilledModels = Object.values(prefilled.complexityRouterConfig.tiers).flat();
+ const prefilled = applyPresetTiers(
+ buildEmptyPrefill().complexityRouterConfig,
+ preset.complexity_router_config,
+ availability,
+ );
+ const prefilledModels = Object.values(prefilled.tiers).flat();
expect(prefilledModels.length).toBeGreaterThan(0);
for (const model of prefilledModels) expect(groups).toContain(model);
},
@@ -578,7 +587,7 @@ describe("autorouter_presets", () => {
);
const config = simpleTierConfig("claude-opus-5");
expect(getMissingModels(config, availability)).toEqual([]);
- expect(buildPresetPrefill(config, availability).complexityRouterConfig.tiers.SIMPLE).toEqual([
+ expect(applyPresetTiers(buildEmptyPrefill().complexityRouterConfig, config, availability).tiers.SIMPLE).toEqual([
"anthropic/claude-opus-5",
]);
});
@@ -681,7 +690,7 @@ describe("autorouter_presets", () => {
[wildcardDeployment("anthropic/*"), wildcardDeployment("bedrock/*")],
);
const config = simpleTierConfig("claude-opus-5");
- expect(buildPresetPrefill(config, availability).complexityRouterConfig.tiers.SIMPLE).toEqual([
+ expect(applyPresetTiers(buildEmptyPrefill().complexityRouterConfig, config, availability).tiers.SIMPLE).toEqual([
"anthropic/claude-opus-5",
]);
});
@@ -760,8 +769,12 @@ describe("autorouter_presets", () => {
[wildcardDeployment("someprovider/*")],
);
expect(getMissingModelsInPreset(preset, availability)).toEqual([]);
- const prefilled = buildPresetPrefill(preset.complexity_router_config, availability);
- const prefilledModels = Object.values(prefilled.complexityRouterConfig.tiers).flat();
+ const prefilled = applyPresetTiers(
+ buildEmptyPrefill().complexityRouterConfig,
+ preset.complexity_router_config,
+ availability,
+ );
+ const prefilledModels = Object.values(prefilled.tiers).flat();
expect(prefilledModels.length).toBeGreaterThan(0);
for (const model of prefilledModels) expect(expandedGroups).toContain(model);
},
@@ -876,165 +889,66 @@ describe("autorouter_presets", () => {
});
});
- describe("buildPresetPrefill", () => {
- it("preserves Laya settings and drops inactive classifier settings when prefilling", () => {
- const config = {
- tiers: { SIMPLE: ["fast"], MEDIUM: [], COMPLEX: [], REASONING: [] },
- classifier_type: "oss_classifier" as const,
- classification_mode: "every_request" as const,
- session_affinity: false,
- deployment_affinity: true,
- modality_routing: false,
- modality_pin_override: false,
- opensource_classifier_config: {
- provider: "laya" as const,
- model: "english",
- timeout_ms: 4000,
- circuit_breaker_enabled: false,
+ describe("applyPresetTiers", () => {
+ const initial: ComplexityRouterConfigValue = {
+ classifier_type: "jev",
+ jev_classifier_config: { deployment_name: "decision", timeout_ms: 4200 },
+ classifier_llm_config: { model: "judge", classification_rubric: "business" },
+ classification_mode: "user_turn",
+ session_affinity: false,
+ classifier_context_window_size: 8,
+ heuristic_v2_success_threshold: 0.7,
+ tiers: { SIMPLE: ["old"], MEDIUM: [], COMPLEX: [], REASONING: [], NON_REASONING: ["relay"] },
+ tier_model_params: { SIMPLE: { old: { temperature: 0.3 } }, NON_REASONING: { relay: { max_tokens: 10 } } },
+ enable_non_reasoning_tier: true,
+ tier_labels: { SIMPLE: "Quick" },
+ default_model: "fallback",
+ plan_mode_min_tier: "REASONING",
+ adaptive: true,
+ deployment_affinity: false,
+ };
+
+ describe.each(["jev", "heuristic", "heuristic_v2", "llm"] as const)("%s classifier", (classifier_type) => {
+ it.each(PRESETS)("keeps non-template settings when selecting $label repeatedly", (preset) => {
+ const current = { ...initial, classifier_type };
+ const availability = groupsOnly(PRESETS.flatMap((item) => [...getRequiredModelsInPreset(item)]));
+ const selected = applyPresetTiers(current, preset.complexity_router_config, availability);
+ const { tiers: _oldTiers, tier_model_params: _oldParams, ...settings } = current;
+ const { tiers, tier_model_params, ...selectedSettings } = selected;
+ expect(selectedSettings).toEqual(settings);
+ expect(tiers).toEqual({ ...preset.complexity_router_config.tiers, NON_REASONING: ["relay"] });
+ expect(tier_model_params?.NON_REASONING).toEqual(initial.tier_model_params?.NON_REASONING);
+ expect(tier_model_params?.SIMPLE?.old).toBeUndefined();
+ const nextPreset = PRESETS.find((item) => item.key !== preset.key)!;
+ const next = applyPresetTiers(selected, nextPreset.complexity_router_config, availability);
+ expect(next).toEqual(applyPresetTiers(current, nextPreset.complexity_router_config, availability));
+ });
+ });
+
+ it("leaves a custom tier draft untouched", () => {
+ const current = {
+ ...initial,
+ custom_tier_set: {
+ tiers: [{ id: "custom", name: "CUSTOM", definition: "custom work", models: ["old"] }],
+ fallback_tier_id: "custom",
},
- classifier_llm_config: { model: "stale-judge", timeout_ms: 6000 },
- classifier_context_window_size: 6,
};
- const prefill = buildPresetPrefill(config, groupsOnly(["fast"]));
- const expectedJevConfig = {
- classifier_type: "jev",
- jev_classifier_config: config.opensource_classifier_config,
- classifier_context_window_size: 6,
- classifier_llm_config: undefined,
- };
- expect(prefill.complexityRouterConfig).toMatchObject(expectedJevConfig);
- const llmConfig = { ...config, classifier_type: "llm" as const };
- const llmPrefill = buildPresetPrefill(llmConfig, groupsOnly(["fast"]));
- expect(llmPrefill.complexityRouterConfig.jev_classifier_config).toBeUndefined();
- expect(llmPrefill.complexityRouterConfig.classifier_llm_config).toEqual(config.classifier_llm_config);
+ expect(applyPresetTiers(current, PRESETS[0].complexity_router_config, groupsOnly([]))).toBe(current);
});
- it.each([undefined, 0, 0.95])("carries a preset's success threshold %s into the form", (threshold) => {
- const preset = getPresetByKey("anthropic_family")!;
- const config = {
- ...preset.complexity_router_config,
- classifier_type: "heuristic_v2" as const,
- heuristic_v2_success_threshold: threshold,
- };
- const prefill = buildPresetPrefill(config, groupsOnly(getRequiredModelsInPreset(preset)));
- expect(prefill.complexityRouterConfig.heuristic_v2_success_threshold).toBe(threshold);
- });
-
- it("prefills a real bundled preset's tiers into the config", () => {
- const preset = getPresetByKey("anthropic_family")!;
- const prefill = buildPresetPrefill(
- preset.complexity_router_config,
- groupsOnly(getRequiredModelsInPreset(preset)),
- );
- expect(prefill.complexityRouterConfig.tiers).toEqual(preset.complexity_router_config.tiers);
- });
-
- // `??`, not `||`: match_threshold: 0 and an empty escalation_keywords array are deliberate,
- // falsy preset values. A prefill that used `||` would silently replace both with the default,
- // which is exactly the kind of bug this test would have caught before either bundled preset
- // happened to avoid the case.
- it("keeps a preset's falsy match_threshold and escalation_keywords instead of defaulting them", () => {
- const config = {
- tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
- classifier_type: "heuristic" as const,
- classification_mode: "every_request" as const,
- session_affinity: false,
- deployment_affinity: true,
- match_threshold: 0,
- escalation_keywords: [],
- };
- const prefill = buildPresetPrefill(config, groupsOnly(["gpt-5-nano"]));
- expect(prefill.matchThreshold).toBe(0);
- expect(prefill.escalationKeywords).toEqual([]);
- });
-
- it.each([undefined, false, true])("preserves a preset's context-window escalation setting: %s", (enabled) => {
- const prefill = buildPresetPrefill(
- {
- tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
- classifier_type: "heuristic",
- classification_mode: "every_request",
- session_affinity: false,
- deployment_affinity: true,
- enable_context_window_escalation: enabled,
- context_window_escalation_buffer: 0.9,
+ it("checks only tier models when determining template availability", () => {
+ const preset = {
+ ...PRESETS[0],
+ complexity_router_config: {
+ ...PRESETS[0].complexity_router_config,
+ classifier_llm_config: { model: "unavailable-judge" },
+ embedding_model: "unavailable-embedding",
+ default_model: "unavailable-default",
},
- groupsOnly(["gpt-5-nano"]),
- );
- expect(prefill.complexityRouterConfig.enable_context_window_escalation).toBe(enabled);
- expect(prefill.complexityRouterConfig.context_window_escalation_buffer).toBe(0.9);
- });
-
- it.each([undefined, false, true])(
- "preserves cache-routing settings and scalar tier models from a preset: %s",
- (enabled) => {
- const config = {
- ...getPresetByKey("anthropic_family")!.complexity_router_config,
- tiers: { SIMPLE: "small", MEDIUM: "large", COMPLEX: "large", REASONING: "large" },
- cache_aware_routing: enabled,
- cache_aware_routing_output_tokens: 0,
- cache_aware_routing_timeout_ms: 750,
- };
- const prefill = buildPresetPrefill(config, groupsOnly(["small", "large"]));
- const expected = {
- tiers: { SIMPLE: ["small"], MEDIUM: ["large"], COMPLEX: ["large"], REASONING: ["large"] },
- cache_aware_routing: enabled,
- cache_aware_routing_output_tokens: 0,
- cache_aware_routing_timeout_ms: 750,
- };
- expect(prefill.complexityRouterConfig).toMatchObject(expected);
- },
- );
-
- it("carries a preset's classification_mode and defaults it when the preset omits one", () => {
- const tiers = { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] };
- const base = {
- tiers,
- classifier_type: "heuristic" as const,
- classification_mode: "every_request" as const,
- session_affinity: false,
- deployment_affinity: true,
};
- const availability = groupsOnly(["gpt-5-nano"]);
- expect(
- buildPresetPrefill({ ...base, classification_mode: "user_turn" }, availability).complexityRouterConfig
- .classification_mode,
- ).toBe("user_turn");
- expect(buildPresetPrefill(base, availability).complexityRouterConfig.classification_mode).toBe("every_request");
- });
-
- it("falls back to the defaults when a preset omits match_threshold and escalation_keywords", () => {
- const prefill = buildPresetPrefill(
- {
- tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
- classifier_type: "heuristic",
- classification_mode: "every_request" as const,
- session_affinity: false,
- deployment_affinity: true,
- },
- groupsOnly(["gpt-5-nano"]),
- );
- expect(prefill.matchThreshold).toBe(DEFAULT_MATCH_THRESHOLD);
- expect(prefill.escalationKeywords).toEqual(DEFAULT_ESCALATION_KEYWORDS);
- });
-
- // The whole point of the separator normalization: a caller whose proxy only registered the
- // dotted form of a version number still gets that model written into the tier, not the
- // preset's own hyphenated spelling (which the caller never actually registered).
- it("prefills a preset's tier_labels and leaves them undefined when the preset has none", () => {
- const base = {
- tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
- classifier_type: "heuristic" as const,
- classification_mode: "every_request" as const,
- session_affinity: false,
- deployment_affinity: true,
- };
- const labeled = buildPresetPrefill(
- { ...base, tier_labels: { SIMPLE: "Cheap", REASONING: "Deep" } },
- groupsOnly(["gpt-5-nano"]),
- );
- expect(labeled.complexityRouterConfig.tier_labels).toEqual({ SIMPLE: "Cheap", REASONING: "Deep" });
- expect(buildPresetPrefill(base, groupsOnly(["gpt-5-nano"])).complexityRouterConfig.tier_labels).toBeUndefined();
+ const tierModels = Object.values(preset.complexity_router_config.tiers).flat();
+ expect(getRequiredModelsInPreset(preset)).toEqual(new Set(tierModels));
+ expect(getMissingModelsInPreset(preset, groupsOnly(tierModels))).toEqual([]);
});
it("rewrites a preset's model name to the caller's differently-punctuated registered spelling", () => {
@@ -1045,8 +959,12 @@ describe("autorouter_presets", () => {
session_affinity: false,
deployment_affinity: true,
};
- const prefill = buildPresetPrefill(config, groupsOnly(["claude-sonnet-4.5"]));
- expect(prefill.complexityRouterConfig.tiers.SIMPLE).toEqual(["claude-sonnet-4.5"]);
+ const prefill = applyPresetTiers(
+ buildEmptyPrefill().complexityRouterConfig,
+ config,
+ groupsOnly(["claude-sonnet-4.5"]),
+ );
+ expect(prefill.tiers.SIMPLE).toEqual(["claude-sonnet-4.5"]);
});
it("prefills the per-model litellm_params a preset carries in tier_model_configs", () => {
@@ -1060,8 +978,12 @@ describe("autorouter_presets", () => {
session_affinity: false,
deployment_affinity: true,
};
- const prefill = buildPresetPrefill(config, groupsOnly(["gpt-5-nano", "o3"]));
- expect(prefill.complexityRouterConfig.tier_model_params).toEqual({
+ const prefill = applyPresetTiers(
+ buildEmptyPrefill().complexityRouterConfig,
+ config,
+ groupsOnly(["gpt-5-nano", "o3"]),
+ );
+ expect(prefill.tier_model_params).toEqual({
REASONING: { o3: { reasoning_effort: "high" } },
});
});
@@ -1080,8 +1002,12 @@ describe("autorouter_presets", () => {
session_affinity: false,
deployment_affinity: true,
};
- const prefill = buildPresetPrefill(config, groupsOnly(["claude-sonnet-4.5"]));
- expect(prefill.complexityRouterConfig.tier_model_params).toEqual({
+ const prefill = applyPresetTiers(
+ buildEmptyPrefill().complexityRouterConfig,
+ config,
+ groupsOnly(["claude-sonnet-4.5"]),
+ );
+ expect(prefill.tier_model_params).toEqual({
REASONING: { "claude-sonnet-4.5": { reasoning_effort: "high" } },
});
});
@@ -1103,15 +1029,19 @@ describe("autorouter_presets", () => {
session_affinity: false,
deployment_affinity: true,
};
- const prefill = buildPresetPrefill(config, groupsOnly(["claude-sonnet-4.5"]));
+ const prefill = applyPresetTiers(
+ buildEmptyPrefill().complexityRouterConfig,
+ 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({
+ expect(prefill.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", () => {
+ it("clears old tier parameters for a preset that carries no per-model params", () => {
const config = {
tiers: { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
@@ -1119,8 +1049,8 @@ describe("autorouter_presets", () => {
session_affinity: false,
deployment_affinity: true,
};
- const prefill = buildPresetPrefill(config, groupsOnly(["gpt-5-nano"]));
- expect(prefill.complexityRouterConfig.tier_model_params).toBeUndefined();
+ const prefill = applyPresetTiers(buildEmptyPrefill().complexityRouterConfig, config, groupsOnly(["gpt-5-nano"]));
+ expect(prefill.tier_model_params).toEqual({});
});
});
});
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
index 0a83a06050b..be7feb28006 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
@@ -1,26 +1,11 @@
-import { hydrateOssClassifier } from "@/components/add_model/jev_classifier_config";
-import {
- ComplexityRouterConfigPayload,
- hydrateTierLabels,
-} from "@/components/add_model/build_complexity_router_config";
+import { ComplexityRouterConfigPayload } from "@/components/add_model/build_complexity_router_config";
import {
ComplexityRouterConfigValue,
ClassifierType,
ClassifierLLMConfig,
- DEFAULT_CLASSIFICATION_MODE,
- DEFAULT_SESSION_AFFINITY,
- DEFAULT_DEPLOYMENT_AFFINITY,
usesLlmClassifier,
} from "@/components/add_model/ComplexityRouterConfig";
import { KeywordTierRule } from "@/components/add_model/KeywordTierRules";
-import { hydrateKeywordTierRules } from "@/components/add_model/complexity_router_keywords";
-import { hydrateCustomDimensions } from "@/components/add_model/custom_dimensions";
-import {
- hydrateDimensionWeights,
- hydrateTierBoundaries,
- hydrateTokenThresholds,
- hydrateReasoningOverrideMinScore,
-} from "@/components/add_model/heuristic_scoring_knobs";
import {
TierModelParams,
TierModelParamsByTier,
@@ -232,10 +217,10 @@ export const getMissingModels = (
[...getRequiredModels(config)].filter((model) => resolveAvailableModel(model, availability) === undefined).sort();
export const getRequiredModelsInPreset = (preset: AutoRouterPreset): Set
=>
- getRequiredModels(preset.complexity_router_config);
+ getRequiredModels({ tiers: preset.complexity_router_config.tiers });
export const getMissingModelsInPreset = (preset: AutoRouterPreset, availability: ModelAvailability): string[] =>
- getMissingModels(preset.complexity_router_config, availability);
+ getMissingModels({ tiers: preset.complexity_router_config.tiers }, availability);
// 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
@@ -298,19 +283,12 @@ export const buildEmptyPrefill = (): PresetPrefill => ({
escalationKeywords: DEFAULT_ESCALATION_KEYWORDS,
});
-// `??`, never `||`: a preset's match_threshold: 0 or escalation_keywords: [] is a deliberate,
-// falsy value that must survive the prefill, not get silently replaced by the default.
-//
-// `availability` is required, not optional: every model reference gets rewritten to the
-// caller's actual registered spelling (resolveAvailableModel), which may differ from the preset's
-// literal string by version-separator punctuation alone. Called only after presetAvailability has
-// already confirmed every required model resolves, so falling back to the preset's own string
-// when a model somehow doesn't resolve is unreachable in practice, not a silent-failure path.
-export const buildPresetPrefill = (
+export const applyPresetTiers = (
+ current: ComplexityRouterConfigValue,
config: ComplexityRouterConfigPayload,
availability: ModelAvailability,
-): PresetPrefill => {
- const classifier = hydrateOssClassifier(config);
+): ComplexityRouterConfigValue => {
+ if (current.custom_tier_set) return current;
const resolve = (model: string): string => resolveAvailableModel(model, availability) ?? model;
const resolveTier = (models: string | string[]): string[] => normalizeTierModels(models).map(resolve);
// Params key on the model name the preset spells while every tier entry is rewritten to the
@@ -333,53 +311,19 @@ export const buildPresetPrefill = (
]),
);
+ const params = resolveParamKeys(hydrateTierModelParams(config.tiers, config.tier_model_configs));
return {
- complexityRouterConfig: {
- tiers: {
- SIMPLE: resolveTier(config.tiers.SIMPLE),
- MEDIUM: resolveTier(config.tiers.MEDIUM),
- 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,
- heuristic_v2_success_threshold: config.heuristic_v2_success_threshold,
- classifier_llm_config:
- classifier.classifier_type !== "jev" && config.classifier_llm_config
- ? { ...config.classifier_llm_config, model: resolve(config.classifier_llm_config.model) }
- : undefined,
- classifier_context_window_size: config.classifier_context_window_size,
- classifier_context_budget_chars: config.classifier_context_budget_chars,
- classifier_context_per_turn_chars: config.classifier_context_per_turn_chars,
- classifier_context_include_assistant_turns: config.classifier_context_include_assistant_turns,
- classification_mode: config.classification_mode ?? DEFAULT_CLASSIFICATION_MODE,
- session_affinity: config.session_affinity ?? DEFAULT_SESSION_AFFINITY,
- session_affinity_ttl_seconds: config.session_affinity_ttl_seconds,
- deployment_affinity: config.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
- modality_routing: config.modality_routing ?? false,
- modality_pin_override: config.modality_pin_override ?? false,
- adaptive: config.adaptive,
- adaptive_weights: config.adaptive_weights,
- tier_distance_penalty: config.tier_distance_penalty,
- adaptive_eligible: config.adaptive_eligible,
- return_raw_model_name: config.return_raw_model_name,
- dimension_weights: hydrateDimensionWeights(config.dimension_weights),
- custom_dimensions: hydrateCustomDimensions(config.custom_dimensions),
- tier_boundaries: hydrateTierBoundaries(config.tier_boundaries),
- token_thresholds: hydrateTokenThresholds(config.token_thresholds),
- reasoning_override_min_score: hydrateReasoningOverrideMinScore(config.reasoning_override_min_score),
- enable_context_window_escalation: config.enable_context_window_escalation,
- context_window_escalation_buffer: config.context_window_escalation_buffer,
- cache_aware_routing: config.cache_aware_routing,
- cache_aware_routing_output_tokens: config.cache_aware_routing_output_tokens,
- cache_aware_routing_timeout_ms: config.cache_aware_routing_timeout_ms,
+ ...current,
+ tiers: {
+ ...current.tiers,
+ SIMPLE: resolveTier(config.tiers.SIMPLE),
+ MEDIUM: resolveTier(config.tiers.MEDIUM),
+ COMPLEX: resolveTier(config.tiers.COMPLEX),
+ REASONING: resolveTier(config.tiers.REASONING),
+ },
+ tier_model_params: {
+ ...(current.tier_model_params?.NON_REASONING && { NON_REASONING: current.tier_model_params.NON_REASONING }),
+ ...params,
},
- customTechnicalKeywords: config.custom_technical_keywords ?? [],
- keywordTierRules: hydrateKeywordTierRules(config.keyword_tier_rules ?? []),
- semanticMatchingEnabled: config.semantic_keyword_matching ?? false,
- embeddingModel: config.embedding_model && resolve(config.embedding_model),
- matchThreshold: config.match_threshold ?? DEFAULT_MATCH_THRESHOLD,
- escalationKeywords: config.escalation_keywords ?? DEFAULT_ESCALATION_KEYWORDS,
};
};
From 68abe95c7b8d84c664187a43b8e7264b687dfd67 Mon Sep 17 00:00:00 2001
From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
Date: Mon, 12 Oct 2026 19:22:58 -0400
Subject: [PATCH 2/4] fix(ui): align Lite template description with tier-only
updates
---
litellm/proxy/public_endpoints/autorouter_presets.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/litellm/proxy/public_endpoints/autorouter_presets.json b/litellm/proxy/public_endpoints/autorouter_presets.json
index 1c2f96350c6..b485d27a071 100644
--- a/litellm/proxy/public_endpoints/autorouter_presets.json
+++ b/litellm/proxy/public_endpoints/autorouter_presets.json
@@ -74,7 +74,7 @@
},
"lite": {
"label": "Lite",
- "description": "Cost-optimized routing across providers: DeepSeek V4 Flash for simple queries, Muse Spark 1.3 at xhigh for medium, Kimi K3 at max for complex, Claude Opus 5.5 for reasoning. An LLM classifier with the agentic rubric assigns tiers.",
+ "description": "Cost-optimized routing across providers: DeepSeek V4 Flash for simple queries, Muse Spark 1.3 at xhigh for medium, Kimi K3 at max for complex, Claude Opus 5.5 for reasoning.",
"complexity_router_config": {
"tiers": {
"SIMPLE": ["deepseek-v4-flash"],
From 1392feb197f52254643b9a543bcdc4fe1e23cb28 Mon Sep 17 00:00:00 2001
From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
Date: Mon, 12 Oct 2026 20:00:04 -0400
Subject: [PATCH 3/4] fix(ui): validate restored classifier choices and
preserve rubric defaults
---
...oRouterClassifierTabs.integration.test.tsx | 40 +++++++++++++++++++
.../add_model/AutoRouterClassifierTabs.tsx | 6 ++-
...omplexityRouterConfig.integration.test.tsx | 11 +++--
...ecastClassifierConfig.integration.test.tsx | 1 +
.../classifier_type_transition.test.ts | 31 ++++++++++++++
.../add_model/classifier_type_transition.ts | 9 ++++-
6 files changed, 91 insertions(+), 7 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx
index 14593c7c95a..e429f0a1024 100644
--- a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx
@@ -49,6 +49,7 @@ function Form({
+
);
@@ -328,3 +329,42 @@ it("asks before disabling adaptive routing even when solver models do not change
fireEvent.click(screen.getByRole("button", { name: "Keep current settings" }));
expect(screen.getByLabelText("Classifier type")).toHaveTextContent("llm");
});
+
+it.each(["capability", "llm_v2", "hybrid", "heuristic_first"] as const)(
+ "keeps custom tiers when returning to an LLM family previously using %s",
+ (previous) => {
+ renderWithProviders(
+ ,
+ );
+ fireEvent.click(screen.getByRole("radio", { name: "LLM" }));
+ expect(screen.getByLabelText("Classifier type")).toHaveTextContent(/^llm$/);
+ expect(screen.getByLabelText("Custom tiers")).toHaveTextContent("REVIEW");
+ expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
+ },
+);
+
+it.each(["heuristic_v2", "capability", "llm_v2"] as const)(
+ "does not restore unavailable %s through a family switch",
+ (previous) => {
+ renderWithProviders(
+ ,
+ );
+ fireEvent.click(screen.getByRole("radio", { name: previous === "heuristic_v2" ? "Heuristics" : "LLM" }));
+ expect(screen.getByLabelText("Classifier type")).toHaveTextContent(
+ previous === "heuristic_v2" ? /^heuristic$/ : /^llm$/,
+ );
+ },
+);
diff --git a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx
index 8f2409d9a14..7ea1d367cf0 100644
--- a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx
@@ -185,7 +185,11 @@ const AutoRouterClassifierTabs: React.FC = ({
const changeFamily = (next: unknown) => {
if (next === family) return;
- const previous = value.previous_classifier_types?.find((type) => familyByType[type] === next);
+ const previous = value.previous_classifier_types?.find((type) => {
+ if (familyByType[type] !== next) return false;
+ if (hasCustomTiers && type !== "llm" && type !== "jev") return false;
+ return !isAllowanceExhausted(availability.data?.allowances.find((entry) => entry.key === type));
+ });
if (previous) return changeType(previous);
if (next === "heuristics") changeType("heuristic");
if (next === "llm") changeType("llm");
diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.integration.test.tsx
index acbece64904..19890b13db1 100644
--- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.integration.test.tsx
@@ -5,6 +5,7 @@ import React from "react";
import { describe, it, expect, vi, type Mock } from "vitest";
import ComplexityRouterConfigView, { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
+import { buildUpdatedComplexityRouterConfig } from "../edit_auto_router/edit_auto_router_modal";
const ComplexityRouterConfig = (props: React.ComponentProps) => (
@@ -234,7 +235,7 @@ describe("ComplexityRouterConfig", () => {
classifier_context_window_size: 3,
classifier_context_budget_chars: 8000,
};
- expect(onChange).toHaveBeenCalledWith(expectedValue);
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining(expectedValue));
});
it("selects heuristic v2 without requiring a classifier model or showing weighted scoring", async () => {
@@ -796,8 +797,8 @@ describe("ComplexityRouterConfig classifier fallback", () => {
expect(screen.queryByText("How Classification Works")).not.toBeInTheDocument();
});
- it("clears a stored fallback when switching back to the heuristic classifier", async () => {
- const onChange = vi.fn();
+ it("retains the fallback draft without saving it for the heuristic classifier", async () => {
+ const onChange = vi.fn<(next: ComplexityRouterConfigValue) => void>();
renderWithProviders(
{
);
openAutoRouterAdvanced("Classification Method");
fireEvent.click(screen.getByRole("radio", { name: /^Heuristics$/ }));
- expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ classifier_fallback: undefined }));
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ classifier_fallback: "default_model" }));
+ const updated = onChange.mock.calls[0][0];
+ expect(buildUpdatedComplexityRouterConfig({}, updated).classifier_fallback).toBeUndefined();
});
});
diff --git a/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx
index f243e63d387..666a424e4c4 100644
--- a/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx
@@ -331,6 +331,7 @@ describe("forecast classifier form", () => {
/>,
);
await selectAutoRouterApproach("Capability");
+ fireEvent.click(screen.getByRole("button", { name: "Change method" }));
fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.7" } });
expect(screen.getByRole("button", { name: "Save configuration" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Save configuration" }));
diff --git a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
index cfaa23fbeba..dda9c63ab09 100644
--- a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
@@ -103,6 +103,37 @@ describe("transitionClassifierType", () => {
expect(buildUpdatedComplexityRouterConfig({}, result).llm_v2_config).toBeUndefined();
});
+ it.each(["capability", "llm_v2"] as const)("preserves an existing implicit rubric after visiting %s", (target) => {
+ const initial: ComplexityRouterConfigValue = {
+ ...standard,
+ classifier_llm_config: { model: "judge", timeout_ms: 20000 },
+ };
+ const forecast = transitionClassifierType(initial, target);
+ const restored = transitionClassifierType(forecast, "llm");
+ expect(restored.classifier_llm_config).toEqual(initial.classifier_llm_config);
+ expect(buildUpdatedComplexityRouterConfig({}, restored).classifier_llm_config).toEqual(
+ initial.classifier_llm_config,
+ );
+ });
+
+ it.each(["heuristic", "jev"] as const)(
+ "initializes the first complexity rubric after visiting %s",
+ (intermediate) => {
+ const initial: ComplexityRouterConfigValue = {
+ ...standard,
+ classifier_type: "capability",
+ classifier_llm_config: { model: "judge", timeout_ms: 20000 },
+ capability_classifier_config: { efficient_tier: "SIMPLE", capable_tier: "REASONING", base_threshold: 0.7 },
+ };
+ const other = transitionClassifierType(initial, intermediate);
+ const complexity = transitionClassifierType(other, "llm");
+ expect(complexity.classifier_llm_config).toEqual({
+ ...initial.classifier_llm_config,
+ classification_rubric: "agentic",
+ });
+ },
+ );
+
it("clears the inactive non-reasoning pool and plan floor when switching to local classification", () => {
const initial: ComplexityRouterConfigValue = {
...standard,
diff --git a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts
index 5798f9304b4..31e2a5cf797 100644
--- a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts
+++ b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts
@@ -11,14 +11,19 @@ import {
usesClassifierContext,
} from "./ComplexityRouterConfig";
import { defaultJevClassifierConfig } from "./jev_classifier_config";
-import { prepareForecastClassifier } from "./forecast_classifier_config";
+import { isForecastClassifier, prepareForecastClassifier } from "./forecast_classifier_config";
import { nonReasoningTierFields } from "./nonReasoningTierFields";
export const transitionClassifierType = (
value: ComplexityRouterConfigValue,
classifierType: ClassifierType,
): ComplexityRouterConfigValue => {
- const startsLlmRubric = !value.classifier_llm_config;
+ const hasComplexityDraft =
+ value.classifier_llm_config?.classification_rubric !== undefined ||
+ [value.classifier_type, ...(value.previous_classifier_types ?? [])].some(
+ (type) => usesLlmClassifier(type) && !isForecastClassifier(type),
+ );
+ const startsLlmRubric = !value.classifier_llm_config || !hasComplexityDraft;
const judgeConfig = value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS };
const nextValue: ComplexityRouterConfigValue = {
...value,
From 59273de63fbd3502041141207da1a0e1c3d092b7 Mon Sep 17 00:00:00 2001
From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
Date: Tue, 13 Oct 2026 09:07:54 -0400
Subject: [PATCH 4/4] fix(ui): limit autorouter changes to tier template
selection
---
...oRouterClassifierTabs.integration.test.tsx | 72 ----------
.../add_model/AutoRouterClassifierTabs.tsx | 56 +-------
...omplexityRouterConfig.integration.test.tsx | 11 +-
.../add_model/ComplexityRouterConfig.tsx | 1 -
...ecastClassifierConfig.integration.test.tsx | 1 -
.../add_auto_router_tab.integration.test.tsx | 26 ----
.../classifier_type_transition.test.ts | 134 ++----------------
.../add_model/classifier_type_transition.ts | 38 +++--
.../add_model/forecast_classifier_config.ts | 15 +-
9 files changed, 51 insertions(+), 303 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx
index e429f0a1024..3c5d555cff8 100644
--- a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx
@@ -48,8 +48,6 @@ function Form({
>
-
-
);
@@ -298,73 +296,3 @@ describe("Gated routing contact action", () => {
expect(within(screen.getByRole("alert")).getByRole("link", { name: "Talk to our team" })).toBeVisible();
});
});
-
-it.each(["heuristic_v2", "hybrid"] as const)(
- "restores the selected %s method after a family round trip",
- (classifier_type) => {
- renderWithProviders();
- fireEvent.click(screen.getByRole("radio", { name: "Decisions Model" }));
- fireEvent.click(screen.getByRole("radio", { name: classifier_type === "heuristic_v2" ? "Heuristics" : "LLM" }));
- expect(screen.getByLabelText("Classifier type")).toHaveTextContent(classifier_type);
- },
-);
-
-it.each(["Capability", "Fuse v2"])("requires confirmation before %s removes configured tier models", async (method) => {
- renderWithProviders();
- await selectAutoRouterOption("Routing approach", method);
- expect(await screen.findByRole("alertdialog")).toHaveTextContent("remove models from incompatible tiers");
- fireEvent.click(screen.getByRole("button", { name: "Keep current settings" }));
- expect(screen.getByLabelText("Classifier type")).toHaveTextContent("llm");
- expect(screen.getByLabelText("Tier models")).toHaveTextContent("middle");
- await selectAutoRouterOption("Routing approach", method);
- fireEvent.click(screen.getByRole("button", { name: "Change method" }));
- expect(screen.getByLabelText("Classifier type")).toHaveTextContent(method === "Capability" ? "capability" : "llm_v2");
- expect(screen.getByLabelText("Tier models")).not.toHaveTextContent("middle");
-});
-
-it("asks before disabling adaptive routing even when solver models do not change", async () => {
- renderWithProviders();
- await selectAutoRouterOption("Routing approach", "Fuse v2");
- expect(await screen.findByRole("alertdialog")).toHaveTextContent("disable adaptive routing");
- fireEvent.click(screen.getByRole("button", { name: "Keep current settings" }));
- expect(screen.getByLabelText("Classifier type")).toHaveTextContent("llm");
-});
-
-it.each(["capability", "llm_v2", "hybrid", "heuristic_first"] as const)(
- "keeps custom tiers when returning to an LLM family previously using %s",
- (previous) => {
- renderWithProviders(
- ,
- );
- fireEvent.click(screen.getByRole("radio", { name: "LLM" }));
- expect(screen.getByLabelText("Classifier type")).toHaveTextContent(/^llm$/);
- expect(screen.getByLabelText("Custom tiers")).toHaveTextContent("REVIEW");
- expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
- },
-);
-
-it.each(["heuristic_v2", "capability", "llm_v2"] as const)(
- "does not restore unavailable %s through a family switch",
- (previous) => {
- renderWithProviders(
- ,
- );
- fireEvent.click(screen.getByRole("radio", { name: previous === "heuristic_v2" ? "Heuristics" : "LLM" }));
- expect(screen.getByLabelText("Classifier type")).toHaveTextContent(
- previous === "heuristic_v2" ? /^heuristic$/ : /^llm$/,
- );
- },
-);
diff --git a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx
index 7ea1d367cf0..509329bb948 100644
--- a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx
@@ -1,15 +1,5 @@
-import React, { useContext, useId, useState } from "react";
+import React, { useContext, useId } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import {
- AlertDialog,
- AlertDialogContent,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogCancel,
- AlertDialogAction,
-} from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { SimpleTooltip } from "@/components/ui/tooltip";
@@ -148,7 +138,6 @@ const AutoRouterClassifierTabs: React.FC = ({
decisionModel,
}) => {
const id = useId();
- const [pendingClassifier, setPendingClassifier] = useState(null);
const availability = useContext(AutoRouterAvailabilityContext);
const classifierType = effectiveClassifierType(value);
const familyByType: Record = {
@@ -165,32 +154,11 @@ const AutoRouterClassifierTabs: React.FC = ({
const family = familyByType[classifierType];
const hasCustomTiers = Boolean(value.custom_tier_set);
const changeType = (next: ClassifierType) => {
- if (next === classifierType) return;
- const updated = transitionClassifierType(value, next);
- const removesModels = Object.entries(value.tiers).some(([tier, models]) =>
- models.some((model) => !updated.tiers[tier as keyof typeof updated.tiers]?.includes(model)),
- );
- if (removesModels || (value.adaptive === true && updated.adaptive === false)) {
- setPendingClassifier(next);
- return;
- }
- onChange(updated);
- };
-
- const confirmChange = () => {
- if (pendingClassifier === null) return;
- onChange(transitionClassifierType(value, pendingClassifier));
- setPendingClassifier(null);
+ if (next !== classifierType) onChange(transitionClassifierType(value, next));
};
const changeFamily = (next: unknown) => {
if (next === family) return;
- const previous = value.previous_classifier_types?.find((type) => {
- if (familyByType[type] !== next) return false;
- if (hasCustomTiers && type !== "llm" && type !== "jev") return false;
- return !isAllowanceExhausted(availability.data?.allowances.find((entry) => entry.key === type));
- });
- if (previous) return changeType(previous);
if (next === "heuristics") changeType("heuristic");
if (next === "llm") changeType("llm");
if (next === "jev") changeType("jev");
@@ -381,26 +349,6 @@ const AutoRouterClassifierTabs: React.FC = ({
)}
- {
- if (!open) setPendingClassifier(null);
- }}
- >
-
-
- Change classification method?
-
- This change may remove models from incompatible tiers or disable adaptive routing. Your classifier
- settings will remain available when you switch back, but removed tier models will not.
-
-
-
- Keep current settings
- Change method
-
-
-
{children}
);
diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.integration.test.tsx
index 19890b13db1..acbece64904 100644
--- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.integration.test.tsx
@@ -5,7 +5,6 @@ import React from "react";
import { describe, it, expect, vi, type Mock } from "vitest";
import ComplexityRouterConfigView, { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
-import { buildUpdatedComplexityRouterConfig } from "../edit_auto_router/edit_auto_router_modal";
const ComplexityRouterConfig = (props: React.ComponentProps