diff --git a/ui/litellm-dashboard/src/components/add_model/NonReasoningTierToggle.tsx b/ui/litellm-dashboard/src/components/add_model/NonReasoningTierToggle.tsx index caadd79039d..5ca0d5517af 100644 --- a/ui/litellm-dashboard/src/components/add_model/NonReasoningTierToggle.tsx +++ b/ui/litellm-dashboard/src/components/add_model/NonReasoningTierToggle.tsx @@ -12,11 +12,16 @@ const NonReasoningTierToggle: React.FC<{ }> = ({ value, onChange, available }) => { const handleToggle = (enabled: boolean): void => { const { NON_REASONING: existingPool, ...keptTiers } = value.tiers; - const next: ComplexityRouterConfigValue = { - ...value, - enable_non_reasoning_tier: enabled ? true : undefined, - tiers: enabled ? { ...keptTiers, NON_REASONING: existingPool ?? [] } : keptTiers, - }; + // Turning it off must also release the plan-mode floor, which the backend rejects while it + // names an inactive tier. An orphaned keyword rule is left for the save gate to name. + const next: ComplexityRouterConfigValue = enabled + ? { ...value, enable_non_reasoning_tier: true, tiers: { ...keptTiers, NON_REASONING: existingPool ?? [] } } + : { + ...value, + enable_non_reasoning_tier: undefined, + tiers: keptTiers, + plan_mode_min_tier: value.plan_mode_min_tier === "NON_REASONING" ? undefined : value.plan_mode_min_tier, + }; onChange(next); }; diff --git a/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.test.ts b/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.test.ts index 3f986d57dd9..c09c859ac27 100644 --- a/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.test.ts @@ -50,3 +50,22 @@ describe("nonReasoningTierFields", () => { }); }); }); + +describe("stale references to the cleared tier", () => { + const withFloorOnTierZero: ComplexityRouterConfigValue = { ...enabledValue, plan_mode_min_tier: "NON_REASONING" }; + + it("releases a plan-mode floor pointing at the tier it just cleared", () => { + // The backend rejects a floor naming an inactive tier, and the switch is disabled once the + // classifier changes, so a floor left behind is a config the operator cannot save or undo. + expect(nonReasoningTierFields("heuristic", withFloorOnTierZero).plan_mode_min_tier).toBeUndefined(); + }); + + it("leaves a floor on another tier alone", () => { + const floorOnComplex: ComplexityRouterConfigValue = { ...enabledValue, plan_mode_min_tier: "COMPLEX" }; + expect(nonReasoningTierFields("heuristic", floorOnComplex).plan_mode_min_tier).toBe("COMPLEX"); + }); + + it("keeps the floor while the classifier can still emit the tier", () => { + expect(nonReasoningTierFields("llm", withFloorOnTierZero).plan_mode_min_tier).toBe("NON_REASONING"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.ts b/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.ts index 2ea7a3f3b97..92a665a199c 100644 --- a/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.ts +++ b/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.ts @@ -1,14 +1,28 @@ import type { ClassifierType, ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; +const NON_REASONING = "NON_REASONING"; + /** The NON_REASONING keys a classifier switch carries forward, or clears for a classifier that - * cannot emit the tier. Leaving them set there is a config the backend refuses on save. */ + * cannot emit the tier. Leaving them set there is a config the backend refuses on save. The floor + * goes with them: it is rejected on save while it names an inactive tier, and the switch is + * disabled once the classifier changes, so the operator could not clear it themselves. + * An orphaned keyword rule is left for getKeywordTierRulesError to name, matching how a removed + * custom tier already behaves. */ export const nonReasoningTierFields = ( classifierType: ClassifierType, value: ComplexityRouterConfigValue, -): Pick => { +): Pick => { if (classifierType === "llm") { - return { enable_non_reasoning_tier: value.enable_non_reasoning_tier, tiers: value.tiers }; + return { + enable_non_reasoning_tier: value.enable_non_reasoning_tier, + tiers: value.tiers, + plan_mode_min_tier: value.plan_mode_min_tier, + }; } - const { NON_REASONING: _cleared, ...keptTiers } = value.tiers; - return { enable_non_reasoning_tier: undefined, tiers: keptTiers }; + const { [NON_REASONING]: _cleared, ...tiers } = value.tiers; + return { + enable_non_reasoning_tier: undefined, + tiers, + plan_mode_min_tier: value.plan_mode_min_tier === NON_REASONING ? undefined : value.plan_mode_min_tier, + }; };