feat(ui): plan-mode override tier in the auto-router create and edit forms (#37319)

* feat(ui): plan-mode override tier in the auto-router create and edit forms

The backend plan_mode_min_tier field (#37230) was API-only. Both forms now
carry an Advanced: Plan-Mode Override panel in the shared complexity config
component: a toggle derived from field presence, so on writes the highest
tier that has models and off deletes the key, and a tier select limited to
tiers with models because the backend rejects a floor at an empty tier. The
edit modal manages the key on every save, so clearing it actually clears the
stored config instead of the preserved copy resurrecting it, while unmanaged
keys like plan_mode_patterns still round-trip untouched

* refactor(ui): hoist the eligible plan-mode tier list out of the panel JSX

* refactor(ui): move tierOptions into complexity_router_tiers, the shared tier-utility module
This commit is contained in:
tin-berri 2026-08-18 13:13:21 -07:00 committed by GitHub
parent 00e1f25e9b
commit 340d30867e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 309 additions and 15 deletions

View file

@ -785,3 +785,61 @@ describe("ComplexityRouterConfig default model", () => {
expect(screen.getByRole("radio", { name: /Route to the default model \(claude-3-opus\)/ })).toBeInTheDocument();
});
});
describe("plan-mode override", () => {
const openPanel = () => fireEvent.click(screen.getByText("Advanced: Plan-Mode Override"));
const switchName = "Route plan-mode requests to a minimum tier";
it("toggling on floors at the highest tier that has models", async () => {
const onChange = vi.fn();
renderWithProviders(<ComplexityRouterConfig {...baseProps} onChange={onChange} />);
openPanel();
fireEvent.click(await screen.findByRole("switch", { name: switchName }));
expect(onChange.mock.calls.at(-1)?.[0].plan_mode_min_tier).toBe("REASONING");
});
it("toggling off drops the key entirely instead of storing an empty value", async () => {
const onChange = vi.fn();
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
value={{ ...defaultValue, plan_mode_min_tier: "COMPLEX" }}
onChange={onChange}
/>,
);
openPanel();
const control = await screen.findByRole("switch", { name: switchName });
expect(control).toBeChecked();
fireEvent.click(control);
const updated = onChange.mock.calls.at(-1)?.[0];
expect(updated.plan_mode_min_tier).toBeUndefined();
});
it("only offers tiers that have models, since the backend rejects a floor at an empty tier", async () => {
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
value={{
...defaultValue,
tiers: { ...defaultValue.tiers, REASONING: [] },
plan_mode_min_tier: "COMPLEX",
}}
/>,
);
openPanel();
fireEvent.mouseDown(await screen.findByRole("combobox", { name: "Plan-mode minimum tier" }));
expect(await screen.findByTitle("Medium")).toBeInTheDocument();
expect(screen.queryByTitle("Reasoning")).not.toBeInTheDocument();
});
it("disables the toggle until some tier has models", async () => {
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
value={{ ...defaultValue, tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] } }}
/>,
);
openPanel();
expect(await screen.findByRole("switch", { name: switchName })).toBeDisabled();
});
});

View file

@ -4,7 +4,7 @@ import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
import ClassificationMethodConfig from "./ClassificationMethodConfig";
import { resolveComplexityDefaultModel } from "./complexity_router_tiers";
import { resolveComplexityDefaultModel, tierOptions } from "./complexity_router_tiers";
import EscalationKeywords from "./EscalationKeywords";
import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules";
import SemanticKeywordMatching from "./SemanticKeywordMatching";
@ -121,6 +121,8 @@ export interface ComplexityRouterConfigValue {
classifier_fallback?: ClassifierFallback;
session_affinity?: boolean;
deployment_affinity?: boolean;
/** Tier floor for coding-agent plan-mode requests. Unset means detection is off, matching the backend. */
plan_mode_min_tier?: string;
adaptive?: boolean;
adaptive_weights?: AdaptiveRouterWeights;
tier_distance_penalty?: number;
@ -187,6 +189,10 @@ export const TIER_KEYS = Object.keys(TIER_DESCRIPTIONS) as Array<keyof Complexit
export const effectiveTierLabel = (tier: keyof ComplexityTiers, tierLabels: ComplexityTierLabels | undefined): string =>
tierLabels?.[tier]?.trim() || TIER_DESCRIPTIONS[tier].label;
/** Tiers the plan-mode floor may name: the backend rejects a floor whose tier has no models. */
export const planModeEligibleTiers = (tiers: ComplexityTiers): Array<keyof ComplexityTiers> =>
TIER_KEYS.filter((tier) => (tiers[tier] ?? []).length > 0);
const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
modelInfo,
value,
@ -205,6 +211,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
onEscalationKeywordsChange,
showValidationErrors = false,
}) => {
const planModeTiers = planModeEligibleTiers(value.tiers);
const derivedDefaultModel = resolveComplexityDefaultModel(value.tiers);
const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model);
@ -417,6 +424,47 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</>
),
},
{
key: "plan-mode",
label: (
<Text strong style={{ color: "#374151" }}>
Advanced: Plan-Mode Override
</Text>
),
children: (
<>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.plan_mode_min_tier !== undefined}
disabled={planModeTiers.length === 0}
onChange={(enabled) =>
onChange({ ...value, plan_mode_min_tier: enabled ? planModeTiers.at(-1) : undefined })
}
aria-label="Route plan-mode requests to a minimum tier"
/>
<Text strong>Route plan-mode requests to a minimum tier</Text>
</div>
<Text type="secondary" style={{ display: "block", fontSize: 12, marginBottom: 12 }}>
Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier.
The classifier still wins when it picks higher, and the override only lasts while plan mode is active.
{planModeTiers.length === 0 && " Add models to a tier to enable this."}
</Text>
{value.plan_mode_min_tier !== undefined && (
<div style={{ maxWidth: 320 }}>
<AntdSelect
aria-label="Plan-mode minimum tier"
style={{ width: "100%" }}
value={value.plan_mode_min_tier}
options={tierOptions(value.tier_labels).filter((option) =>
(planModeTiers as string[]).includes(option.value),
)}
onChange={(tier: string) => onChange({ ...value, plan_mode_min_tier: tier })}
/>
</div>
)}
</>
),
},
{
key: "response",
label: (

View file

@ -3,6 +3,7 @@ import { Button, Card, Empty, Select as AntdSelect, Tooltip, Typography } from "
import React from "react";
import { emptyKeywordTierRuleIndexes } from "./complexity_router_keywords";
import { tierOptions } from "./complexity_router_tiers";
const { Text } = Typography;
@ -20,20 +21,6 @@ interface KeywordTierRulesProps {
tierLabels?: Partial<Record<ComplexityTier, string>>;
}
const DEFAULT_TIER_LABELS: Record<ComplexityTier, string> = {
SIMPLE: "Simple",
MEDIUM: "Medium",
COMPLEX: "Complex",
REASONING: "Reasoning",
};
const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
export const tierOptions = (
tierLabels: Partial<Record<ComplexityTier, string>> | undefined,
): { value: ComplexityTier; label: string }[] =>
TIER_ORDER.map((tier) => ({ value: tier, label: tierLabels?.[tier]?.trim() || DEFAULT_TIER_LABELS[tier] }));
// A row exists only because the caller asked for it, so it reports its own gap straight away
// rather than waiting for a submit; the submit button is disabled while one is outstanding, so
// there is no failed attempt left to surface it.

View file

@ -665,6 +665,53 @@ describe("AddAutoRouterTab", () => {
});
});
describe("plan-mode override", () => {
beforeEach(() => {
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
});
const waitForPresetEnabled = async (label: string) => {
openTemplateDropdown();
await waitFor(() => {
expect(isOptionDisabled(optionByLabel(label)!)).toBe(false);
});
};
it("omits plan_mode_min_tier from the payload when never touched", async () => {
const user = userEvent.setup();
renderWithProviders(<Harness />);
await waitForPresetEnabled("Anthropic Family");
fireEvent.click(optionByLabel("Anthropic Family")!);
await user.type(screen.getByPlaceholderText(/smart_router/i), "no-plan-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).not.toHaveProperty(
"plan_mode_min_tier",
);
});
it("carries the enabled override through to the create payload", async () => {
const user = userEvent.setup();
renderWithProviders(<Harness />);
await waitForPresetEnabled("Anthropic Family");
fireEvent.click(optionByLabel("Anthropic Family")!);
expandDetailedConfiguration();
await user.click(screen.getByText("Advanced: Plan-Mode Override"));
await user.click(await screen.findByRole("switch", { name: "Route plan-mode requests to a minimum tier" }));
await user.type(screen.getByPlaceholderText(/smart_router/i), "plan-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).toMatchObject({
plan_mode_min_tier: "REASONING",
});
});
});
describe("deployment-matched presets", () => {
const renamedDeploymentsFor = (presetKey: string) =>
[...getRequiredModelsInPreset(getPresetByKey(presetKey)!)].map((model, index) => ({

View file

@ -35,6 +35,7 @@ import {
buildComplexityRouterConfig,
getKeywordTierRulesError,
getMissingTiersError,
getPlanModeTierError,
getSemanticConfigError,
getTierLabelsError,
} from "./build_complexity_router_config";
@ -127,6 +128,7 @@ const getSubmitBlockedReason = (
): string | null =>
getMissingTiersError(config.tiers) ??
getTierLabelsError(config.tier_labels) ??
getPlanModeTierError(config.plan_mode_min_tier, config.tiers) ??
getKeywordTierRulesError(keywordTierRules) ??
getReferencedModelsError(referencedModelsParams, availability);
@ -335,6 +337,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
tiers: complexityRouterConfig.tiers,
defaultModel: complexityRouterConfig.default_model,
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
tierLabels: complexityRouterConfig.tier_labels,
classifierType: complexityRouterConfig.classifier_type,
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,

View file

@ -1,5 +1,6 @@
import {
buildComplexityRouterConfig,
getPlanModeTierError,
normalizeClassifierLlmConfig,
getKeywordTierRulesError,
getMissingTiersError,
@ -601,3 +602,36 @@ describe("buildComplexityRouterConfig scorer knobs", () => {
expect(buildComplexityRouterConfig(llmWithDefaultFallback)).not.toHaveProperty("tier_boundaries");
});
});
describe("plan-mode minimum tier", () => {
it("omits plan_mode_min_tier when unset, so the backend default (off) is preserved", () => {
const config = buildComplexityRouterConfig({ ...baseParams, planModeMinTier: undefined });
expect(config).not.toHaveProperty("plan_mode_min_tier");
});
it("writes the selected tier", () => {
const config = buildComplexityRouterConfig({ ...baseParams, planModeMinTier: "COMPLEX" });
expect(config.plan_mode_min_tier).toBe("COMPLEX");
});
it("never writes an empty string, which the backend rejects instead of treating as off", () => {
const config = buildComplexityRouterConfig({ ...baseParams, planModeMinTier: " " });
expect(config).not.toHaveProperty("plan_mode_min_tier");
});
});
describe("getPlanModeTierError", () => {
const tiersWithEmptyComplex = { SIMPLE: ["m1"], MEDIUM: ["m1"], COMPLEX: [], REASONING: [] };
it("passes when the override is off", () => {
expect(getPlanModeTierError(undefined, tiersWithEmptyComplex)).toBeNull();
});
it("passes when the named tier has models", () => {
expect(getPlanModeTierError("MEDIUM", tiersWithEmptyComplex)).toBeNull();
});
it("blocks a tier whose models were removed, which the backend would reject with a 400", () => {
expect(getPlanModeTierError("COMPLEX", tiersWithEmptyComplex)).toContain("COMPLEX");
});
});

View file

@ -71,6 +71,7 @@ const scorerKnobPayload = ({
export interface BuildComplexityRouterConfigParams {
tiers: ComplexityTiers;
defaultModel: string | undefined;
planModeMinTier: string | undefined;
tierLabels: ComplexityTierLabels | undefined;
classifierType: ClassifierType;
classifierLlmConfig: ClassifierLLMConfig | undefined;
@ -99,6 +100,7 @@ export interface BuildComplexityRouterConfigParams {
export interface ComplexityRouterConfigPayload {
tiers: ComplexityTiers;
default_model?: string;
plan_mode_min_tier?: string;
tier_labels?: ComplexityTierLabels;
classifier_type: ClassifierType;
classifier_llm_config?: ClassifierLLMConfig;
@ -172,6 +174,16 @@ export const getMissingTiersError = (tiers: ComplexityTiers): string | null => {
return `Select a model for the following tier(s): ${missing.join(", ")}`;
};
// The backend rejects a plan-mode floor naming a tier with no models. The create form's
// getMissingTiersError makes this unreachable there; the edit modal allows partially filled
// tiers, so both gates call this to keep the two forms symmetric.
export const getPlanModeTierError = (planModeMinTier: string | undefined, tiers: ComplexityTiers): string | null => {
if (!planModeMinTier) return null;
const models = tiers[planModeMinTier as keyof ComplexityTiers] ?? [];
if (models.length > 0) return null;
return `The plan-mode minimum tier (${planModeMinTier}) has no models. Add one or turn the override off.`;
};
export const getKeywordTierRulesError = (keywordTierRules: KeywordTierRule[]): string | null => {
const emptyRows = emptyKeywordTierRuleIndexes(keywordTierRules);
if (emptyRows.length === 0) return null;
@ -194,6 +206,7 @@ export const getSemanticConfigError = ({
export const buildComplexityRouterConfig = ({
tiers,
defaultModel,
planModeMinTier,
tierLabels,
classifierType,
classifierLlmConfig,
@ -227,6 +240,7 @@ export const buildComplexityRouterConfig = ({
return {
tiers,
...(defaultModel?.trim() && { default_model: defaultModel }),
...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }),
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
classifier_type: classifierType,
...(classifierType === "llm" &&

View file

@ -1,4 +1,5 @@
import type { ComplexityTiers } from "./ComplexityRouterConfig";
import type { ComplexityTier } from "./KeywordTierRules";
/**
* A complexity tier maps to `str | list[str]` on the backend
@ -22,3 +23,17 @@ export const normalizeTierModels = (value: unknown): string[] => {
*/
export const resolveComplexityDefaultModel = (tiers: ComplexityTiers, pinned?: string): string | undefined =>
pinned?.trim() || tiers.MEDIUM[0] || tiers.SIMPLE[0];
export const DEFAULT_TIER_LABELS: Record<ComplexityTier, string> = {
SIMPLE: "Simple",
MEDIUM: "Medium",
COMPLEX: "Complex",
REASONING: "Reasoning",
};
export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
export const tierOptions = (
tierLabels: Partial<Record<ComplexityTier, string>> | undefined,
): { value: ComplexityTier; label: string }[] =>
TIER_ORDER.map((tier) => ({ value: tier, label: tierLabels?.[tier]?.trim() || DEFAULT_TIER_LABELS[tier] }));

View file

@ -314,3 +314,26 @@ describe("buildUpdatedComplexityRouterConfig scorer knobs", () => {
expect(buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE)).not.toHaveProperty("tier_boundaries");
});
});
describe("buildUpdatedComplexityRouterConfig plan-mode minimum tier", () => {
it("round-trips a stored tier through an untouched open-and-save", () => {
const result = buildUpdatedComplexityRouterConfig(
{ ...STORED, plan_mode_min_tier: "COMPLEX" },
{ ...FORM_VALUE, plan_mode_min_tier: "COMPLEX" },
);
expect(result.plan_mode_min_tier).toBe("COMPLEX");
});
it("stops a stored tier from surviving a save that turned the override off", () => {
const result = buildUpdatedComplexityRouterConfig({ ...STORED, plan_mode_min_tier: "COMPLEX" }, FORM_VALUE);
expect(result).not.toHaveProperty("plan_mode_min_tier");
});
it("writes a newly selected tier over the stored one", () => {
const result = buildUpdatedComplexityRouterConfig(
{ ...STORED, plan_mode_min_tier: "COMPLEX" },
{ ...FORM_VALUE, plan_mode_min_tier: "MEDIUM" },
);
expect(result.plan_mode_min_tier).toBe("MEDIUM");
});
});

View file

@ -748,3 +748,60 @@ describe("EditAutoRouterModal default model", () => {
expect(savedConfig()).not.toHaveProperty("default_model");
});
});
describe("EditAutoRouterModal plan-mode minimum tier", () => {
beforeEach(() => {
modelPatchUpdateCall.mockClear();
});
const renderWithStoredTier = (plan_mode_min_tier?: string) =>
renderWithProviders(
<EditAutoRouterModal
isVisible
onCancel={vi.fn()}
onSuccess={vi.fn()}
modelData={{
...MODEL_DATA,
litellm_params: {
...MODEL_DATA.litellm_params,
complexity_router_config: { ...STORED_CONFIG, ...(plan_mode_min_tier && { plan_mode_min_tier }) },
},
}}
accessToken="token"
userRole="Admin"
/>,
);
const openPlanModePanel = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(await screen.findByText("Advanced: Plan-Mode Override"));
};
it("shows a stored tier as an enabled override, so the saved value is not a hidden one", async () => {
const user = userEvent.setup();
renderWithStoredTier("MEDIUM");
await openPlanModePanel(user);
expect(await screen.findByRole("switch", { name: "Route plan-mode requests to a minimum tier" })).toBeChecked();
});
it("preserves a stored tier through an untouched open-and-save", async () => {
const user = userEvent.setup();
renderWithStoredTier("MEDIUM");
await user.click(await screen.findByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig()).toMatchObject({ plan_mode_min_tier: "MEDIUM" });
});
it("turning the override off removes the stored tier from the saved config", async () => {
const user = userEvent.setup();
renderWithStoredTier("MEDIUM");
await openPlanModePanel(user);
await user.click(await screen.findByRole("switch", { name: "Route plan-mode requests to a minimum tier" }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig()).not.toHaveProperty("plan_mode_min_tier");
});
});

View file

@ -19,6 +19,7 @@ import { isComplexityRouter } from "../add_model/auto_router_strategies";
import {
getKeywordTierRulesError,
getSemanticConfigError,
getPlanModeTierError,
getTierLabelsError,
hydrateTierLabels,
normalizeClassifierLlmConfig,
@ -65,6 +66,7 @@ interface EditAutoRouterModalProps {
const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"tiers",
"default_model",
"plan_mode_min_tier",
"tier_labels",
"classifier_type",
"classifier_llm_config",
@ -149,6 +151,7 @@ export const buildUpdatedComplexityRouterConfig = (
...preservedConfig,
tiers: value.tiers,
...(value.default_model?.trim() && { default_model: value.default_model }),
...(value.plan_mode_min_tier?.trim() && { plan_mode_min_tier: value.plan_mode_min_tier }),
...(serializedTierLabels && { tier_labels: serializedTierLabels }),
classifier_type: value.classifier_type,
...(value.classifier_type === "llm" && value.classifier_llm_config
@ -279,6 +282,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
? "Please select at least one model for a complexity tier"
: null) ??
getTierLabelsError(complexityRouterConfig.tier_labels) ??
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, complexityRouterConfig.tiers) ??
getKeywordTierRulesError(keywordTierRules);
useEffect(() => {
@ -337,6 +341,10 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
modelData.litellm_params?.complexity_router_default_model,
hydratedTiers,
),
plan_mode_min_tier:
typeof parsedConfig.plan_mode_min_tier === "string" && parsedConfig.plan_mode_min_tier.trim() !== ""
? parsedConfig.plan_mode_min_tier
: undefined,
tier_labels: hydrateTierLabels(parsedConfig.tier_labels),
classifier_type: parsedConfig.classifier_type || "heuristic",
classifier_llm_config: parsedConfig.classifier_llm_config,