From 9d7e09e4e8f6463516af1c0a5844d4a5dc7c2c55 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Tue, 8 Sep 2026 16:53:52 -0700 Subject: [PATCH] fix(ui): release the plan-mode floor when the non-reasoning tier is cleared Turning the tier off, or switching to a classifier that cannot emit it, dropped the flag and the pool but left plan_mode_min_tier naming a tier that is no longer active. The backend rejects that on save, and the switch is disabled after a classifier change, so the operator had no way to clear it. Both paths now release the floor when it points at the cleared tier. An orphaned keyword rule is left alone on purpose: getKeywordTierRulesError already names it at the save gate, which is how a removed custom tier behaves. --- .../add_model/NonReasoningTierToggle.tsx | 15 ++++++++---- .../add_model/nonReasoningTierFields.test.ts | 19 +++++++++++++++ .../add_model/nonReasoningTierFields.ts | 24 +++++++++++++++---- 3 files changed, 48 insertions(+), 10 deletions(-) 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, + }; };