diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx index 199acfe8769..217d3ca0989 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -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(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + openPanel(); + expect(await screen.findByRole("switch", { name: switchName })).toBeDisabled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 84d07facbf0..3d4845ecf39 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -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 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 => + TIER_KEYS.filter((tier) => (tiers[tier] ?? []).length > 0); + const ComplexityRouterConfig: React.FC = ({ modelInfo, value, @@ -205,6 +211,7 @@ const ComplexityRouterConfig: React.FC = ({ 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 = ({ ), }, + { + 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,