= ({
>
),
},
+ {
+ key: "plan-mode",
+ label: (
+
+ Advanced: Plan-Mode Override
+
+ ),
+ children: (
+ <>
+
+
+ onChange({ ...value, plan_mode_min_tier: enabled ? planModeTiers.at(-1) : undefined })
+ }
+ aria-label="Route plan-mode requests to a minimum tier"
+ />
+ Route plan-mode requests to a minimum tier
+
+
+ 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."}
+
+ {value.plan_mode_min_tier !== undefined && (
+
+
+ (planModeTiers as string[]).includes(option.value),
+ )}
+ onChange={(tier: string) => onChange({ ...value, plan_mode_min_tier: tier })}
+ />
+
+ )}
+ >
+ ),
+ },
{
key: "response",
label: (
diff --git a/ui/litellm-dashboard/src/components/add_model/KeywordTierRules.tsx b/ui/litellm-dashboard/src/components/add_model/KeywordTierRules.tsx
index fb89e67a2b3..72d6e9f4fef 100644
--- a/ui/litellm-dashboard/src/components/add_model/KeywordTierRules.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/KeywordTierRules.tsx
@@ -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>;
}
-const DEFAULT_TIER_LABELS: Record = {
- SIMPLE: "Simple",
- MEDIUM: "Medium",
- COMPLEX: "Complex",
- REASONING: "Reasoning",
-};
-
-const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
-
-export const tierOptions = (
- tierLabels: Partial> | 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.
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
index 988bae46bb4..5643dafc0f5 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
@@ -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();
+
+ 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();
+
+ 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) => ({
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
index 7537c91849e..e35b9581d9c 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
@@ -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 = ({
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,
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
index 703231ba5ee..81d54a7b773 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
@@ -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");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
index 3ee3b6f0180..0ab2db8c16f 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
@@ -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" &&
diff --git a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts
index 2e7859964bc..6f5eb7f877b 100644
--- a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts
+++ b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts
@@ -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 = {
+ SIMPLE: "Simple",
+ MEDIUM: "Medium",
+ COMPLEX: "Complex",
+ REASONING: "Reasoning",
+};
+
+export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
+
+export const tierOptions = (
+ tierLabels: Partial> | undefined,
+): { value: ComplexityTier; label: string }[] =>
+ TIER_ORDER.map((tier) => ({ value: tier, label: tierLabels?.[tier]?.trim() || DEFAULT_TIER_LABELS[tier] }));
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
index 8acae918c9b..c62955b6683 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
@@ -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");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx
index 019f60aecd9..9702fa85db0 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx
@@ -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(
+ ,
+ );
+
+ const openPlanModePanel = async (user: ReturnType) => {
+ 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");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
index d5e3aad4f8d..54f992b08f4 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
@@ -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 = ({
? "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 = ({
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,