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.
This commit is contained in:
moe-berri 2026-09-08 16:53:52 -07:00
parent a4f865b1be
commit 9d7e09e4e8
3 changed files with 48 additions and 10 deletions

View file

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

View file

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

View file

@ -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<ComplexityRouterConfigValue, "enable_non_reasoning_tier" | "tiers"> => {
): Pick<ComplexityRouterConfigValue, "enable_non_reasoning_tier" | "tiers" | "plan_mode_min_tier"> => {
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,
};
};