-
+
+
@@ -286,19 +295,21 @@ const ClassificationMethodConfig: React.FC = ({
calls a model to decide the tier (e.g. a small/fast model)
-
-
-
- Heuristic first{" "}
-
- scores locally, and only pays for the classifier when the score does not confidently land a cheap tier
+
+
+
+
+ Heuristic first{" "}
+
+ scores locally, and only pays for the classifier when the score does not confidently land a cheap tier
+
-
-
+
+
- {usesCustomPrompt
- ? "Not in use: the custom prompt below is the classifier's entire rubric."
- : CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description}
+ {restrictedBy(value, "classificationRubric")?.reason ??
+ (usesCustomPrompt
+ ? "Not in use: the custom prompt below is the classifier's entire rubric."
+ : CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description)}
Classifier Prompt
-
+
+
+
-
- If the classifier fails
+ handleClassifierFallbackChange(fallback as ClassifierFallback)}
@@ -439,7 +455,7 @@ const ClassificationMethodConfig: React.FC = ({
Applies when the classifier call errors, times out, or returns an unparseable response.
-
+
Context Window Size
{
expect(screen.queryByText("Custom Technical Keywords")).not.toBeInTheDocument();
});
});
+
+describe("ComplexityRouterConfig tier editing", () => {
+ const renderEditor = (
+ value?: ComplexityRouterConfigValue,
+ props: Partial> = {},
+ ) => {
+ const onChange = vi.fn();
+ const view = renderWithProviders(
+ ,
+ );
+ return { ...view, committed: () => onChange.mock.calls[0][0] as ComplexityRouterConfigValue, onChange };
+ };
+
+ const customValue: ComplexityRouterConfigValue = {
+ ...defaultValue,
+ classifier_type: "llm",
+ classifier_llm_config: { model: "gpt-4", timeout_ms: 3000 },
+ custom_tier_set: {
+ tiers: [
+ { id: "CASUAL", name: "CASUAL", definition: "small talk", models: ["gpt-3.5-turbo"] },
+ { id: "sec", name: "SECURITY_REVIEW", definition: "audits", models: ["gpt-4"] },
+ ],
+ fallback_tier_id: "CASUAL",
+ },
+ };
+
+ it("offers Edit tiers only when the parent owns the editor flag", () => {
+ renderWithProviders();
+ expect(screen.queryByRole("button", { name: "Edit tiers" })).not.toBeInTheDocument();
+ });
+
+ it("renders the four built-in tiers before any edit, unchanged", () => {
+ renderWithProviders();
+ expect(screen.getByRole("button", { name: "Edit tiers" })).toBeInTheDocument();
+ expect(screen.getByText("Tier 1 of 4", { exact: false })).toHaveTextContent("SIMPLE");
+ });
+
+ it("adds a row and moves the form into an edited tier set, which the built-in record never leaves", () => {
+ const { committed } = renderEditor();
+ fireEvent.click(screen.getByRole("button", { name: "Add tier" }));
+ const next = committed();
+ expect(next.custom_tier_set?.tiers).toHaveLength(5);
+ expect(next.tiers).toEqual(defaultValue.tiers);
+ });
+
+ it("renames a built-in tier straight from the editor, which is what makes the set custom", () => {
+ const { committed } = renderEditor();
+ fireEvent.change(screen.getByLabelText("Name for tier 3"), { target: { value: "SECURITY_REVIEW" } });
+ const next = committed();
+ expect(next.custom_tier_set?.tiers.map((row) => row.name)).toEqual([
+ "SIMPLE",
+ "MEDIUM",
+ "SECURITY_REVIEW",
+ "REASONING",
+ ]);
+ expect(next.tiers).toEqual(defaultValue.tiers);
+ });
+
+ it("opening the editor and changing nothing leaves the router on the built-in tiers", () => {
+ const { onChange } = renderEditor();
+ expect(screen.getByRole("button", { name: "Done" })).toBeEnabled();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("swaps the display-name field for the tier-name field while the editor is open", () => {
+ const { rerender } = renderWithProviders();
+ expect(screen.getByLabelText("Display name for the Simple tier")).toBeInTheDocument();
+ rerender();
+ expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
+ expect(screen.getByLabelText("Name for tier 1")).toBeInTheDocument();
+ });
+
+ it("replaces the prompt editor with the reason an edited tier set forbids it", () => {
+ renderWithProviders();
+ fireEvent.click(screen.getByText("Advanced: Classification Method"));
+ expect(screen.getByText("A replacement prompt drops the tier bullets", { exact: false })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Change default prompt" })).not.toBeInTheDocument();
+ });
+
+ it("drops the scorer card entirely once an edited tier set replaces the heuristic", () => {
+ renderWithProviders();
+ fireEvent.click(screen.getByText("Advanced: Classification Method"));
+ expect(screen.queryByText("How Classification Works")).not.toBeInTheDocument();
+ expect(screen.queryByText("scores each request across 7 dimensions", { exact: false })).not.toBeInTheDocument();
+ });
+
+ it("keeps the scorer card on a built-in router, whose tiers the score still decides", () => {
+ renderWithProviders();
+ fireEvent.click(screen.getByText("Advanced: Classification Method"));
+ expect(screen.getByText("How Classification Works")).toBeInTheDocument();
+ expect(screen.getByText("scores each request across 7 dimensions", { exact: false })).toBeInTheDocument();
+ });
+
+ it("says why a custom row is blocked instead of only reddening its border", () => {
+ const missingDefinition: ComplexityRouterConfigValue = {
+ ...customValue,
+ custom_tier_set: {
+ tiers: [customValue.custom_tier_set!.tiers[0], { id: "b", name: "AUDIT", definition: "", models: ["gpt-4"] }],
+ fallback_tier_id: "CASUAL",
+ },
+ };
+ renderEditor(missingDefinition, { showValidationErrors: true });
+ expect(screen.getByText("A definition is required", { exact: false })).toBeInTheDocument();
+ });
+
+ it("keeps Done disabled while a row is incomplete and says what is missing", async () => {
+ const incomplete: ComplexityRouterConfigValue = {
+ ...customValue,
+ custom_tier_set: {
+ tiers: [customValue.custom_tier_set!.tiers[0], { id: "new", name: "", definition: "", models: [] }],
+ fallback_tier_id: "CASUAL",
+ },
+ };
+ renderEditor(incomplete);
+ expect(screen.getByRole("button", { name: "Done" })).toBeDisabled();
+ });
+
+ it("enables Done once every row carries a name, a definition and a model", () => {
+ renderEditor(customValue);
+ expect(screen.getByRole("button", { name: "Done" })).toBeEnabled();
+ });
+
+ it("refuses to remove a row that would take the set below the backend's minimum", () => {
+ renderEditor(customValue);
+ expect(screen.getByRole("button", { name: "Remove the CASUAL tier" })).toBeDisabled();
+ });
+
+ it("keeps a definition on one line, because the backend rejects a newline in it", () => {
+ const { committed } = renderEditor(customValue);
+ fireEvent.change(screen.getByLabelText("Definition for tier 2"), { target: { value: "audits\nand reviews" } });
+ const next = committed();
+ expect(next.custom_tier_set?.tiers[1].definition).toBe("audits and reviews");
+ });
+
+ it("moves a keyword rule with the tier it points at when that tier is renamed", () => {
+ const onKeywordTierRulesChange = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ fireEvent.change(screen.getByLabelText("Name for tier 2"), { target: { value: "AUDIT" } });
+ expect(onKeywordTierRulesChange).toHaveBeenCalledWith([{ id: "r1", keywords: ["audit"], tier: "AUDIT" }]);
+ });
+
+ it("re-points the fallback tier when the row it named is removed, never leaving it dangling", () => {
+ const threeRows: ComplexityRouterConfigValue = {
+ ...customValue,
+ custom_tier_set: {
+ tiers: [
+ ...customValue.custom_tier_set!.tiers,
+ { id: "third", name: "MEDIUM", definition: "", models: ["gpt-4"] },
+ ],
+ fallback_tier_id: "sec",
+ },
+ };
+ const { committed } = renderEditor(threeRows);
+ fireEvent.click(screen.getByRole("button", { name: "Remove the SECURITY_REVIEW tier" }));
+ const next = committed();
+ expect(next.custom_tier_set?.tiers.some((row) => row.id === next.custom_tier_set?.fallback_tier_id)).toBe(true);
+ });
+
+ it("turns off a plan-mode floor whose row was removed, rather than leaving it pointing at nothing", () => {
+ const withFloor: ComplexityRouterConfigValue = {
+ ...customValue,
+ plan_mode_min_tier: "sec",
+ custom_tier_set: {
+ tiers: [
+ ...customValue.custom_tier_set!.tiers,
+ { id: "third", name: "BULK", definition: "d", models: ["gpt-4"] },
+ ],
+ fallback_tier_id: "CASUAL",
+ },
+ };
+ const { committed } = renderEditor(withFloor);
+ fireEvent.click(screen.getByRole("button", { name: "Remove the SECURITY_REVIEW tier" }));
+ expect(committed().plan_mode_min_tier).toBeUndefined();
+ });
+
+ it("replaces the display-name inputs with the reason an edited tier set forbids them", () => {
+ renderWithProviders();
+ expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
+ expect(screen.getByText("Display names rename the built-in tiers", { exact: false })).toBeInTheDocument();
+ expect(screen.getByLabelText("Fallback tier")).toBeInTheDocument();
+ });
+
+ it("disables session pinning and says why, rather than letting a stripped value look saved", () => {
+ renderWithProviders();
+ fireEvent.click(screen.getByText("Advanced: Affinity"));
+ expect(screen.getByLabelText("Pin a session to its first model")).toHaveAttribute("data-disabled");
+ expect(
+ screen.getByText("Session pinning escalates along the built-in tier ladder", { exact: false }),
+ ).toBeInTheDocument();
+ });
+
+ it("leaves built-in routers with their display-name inputs and no restriction copy", () => {
+ renderWithProviders();
+ expect(screen.getByLabelText("Display name for the Simple tier")).toBeInTheDocument();
+ expect(screen.queryByText("Display names rename the built-in tiers", { exact: false })).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx
index 9894e26d552..2a0b768385c 100644
--- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx
@@ -2,32 +2,50 @@ import { SimpleTooltip } from "@/components/ui/tooltip";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { ChevronRight, Info, X } from "lucide-react";
+import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Separator } from "@/components/ui/separator";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ type CustomTierSet,
+ type TierRow,
+ MAX_TIER_COUNT,
+ MAX_TIER_DEFINITION_CHARS,
+ MAX_TIER_NAME_CHARS,
+ MIN_TIER_COUNT,
+ TIER_ORDER,
+ activeTierName,
+ activeTierRows,
+ getCustomTierRowsError,
+ isBuiltInTierName,
+ resolveComplexityDefaultModel,
+} from "./tier_rows";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
import ClassificationMethodConfig from "./ClassificationMethodConfig";
+import { Restricted, restrictedBy } from "./TierRestrictions";
+import { type TierSetAction, applyTierSetAction, setFallbackTier } from "./tier_set_actions";
import {
REASONING_EFFORT_OPTIONS,
ReasoningEffort,
TierModelParamsByTier,
- pruneTierModelParams,
setTierModelReasoningEffort,
+ tierRowLabel,
} from "./complexity_router_tiers";
import TierModelEffortRows from "./TierModelEffortRows";
import EscalationKeywords from "./EscalationKeywords";
import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules";
import SemanticKeywordMatching from "./SemanticKeywordMatching";
import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from "./heuristic_scoring_knobs";
-import { type CustomTierSet, type TierRow, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows";
-export type { CustomTierSet, TierRow } from "./tier_rows";
export type { DimensionWeights, TierBoundaries, TokenThresholds };
+export type { CustomTierSet, TierRow } from "./tier_rows";
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000;
export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5;
@@ -141,12 +159,39 @@ export const effectiveClassifierType = (
value: Pick,
): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type);
+const rowOrigin = (row: TierRow, editing: boolean): string => {
+ if (!editing) return row.id;
+ return isBuiltInTierName(row.name) ? "built-in" : "custom";
+};
+
+const TierRowSelect: React.FC<{
+ label: string;
+ options: { value: string; label: string }[];
+ value: string | null;
+ onValueChange: (rowId: string) => void;
+ placeholder?: string;
+}> = ({ label, options, value, onValueChange, placeholder }) => (
+
+);
+
export type AdaptiveEligible = "all" | "classified_tier";
export type ComplexityTierLabels = Partial>;
export interface ComplexityRouterConfigValue {
tiers: ComplexityTiers;
+ custom_tier_set?: CustomTierSet;
tier_labels?: ComplexityTierLabels;
/** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */
default_model?: string;
@@ -161,7 +206,7 @@ export interface ComplexityRouterConfigValue {
heuristic_first_max_tier?: string;
session_affinity?: boolean;
deployment_affinity?: boolean;
- /** Tier floor for coding-agent plan-mode requests. Unset means detection is off, matching the backend. */
+ /** Plan-mode floor as a tier ROW ID, unset meaning off. The wire carries the row's name. */
plan_mode_min_tier?: string;
adaptive?: boolean;
adaptive_weights?: AdaptiveRouterWeights;
@@ -185,7 +230,6 @@ export interface ComplexityRouterConfigValue {
* params object is held, not just reasoning_effort, so keys authored in config.yaml survive an
* edit round-trip.
*/
- custom_tier_set?: CustomTierSet;
tier_model_params?: TierModelParamsByTier;
}
@@ -193,6 +237,9 @@ interface ComplexityRouterConfigProps {
modelInfo: ModelGroup[];
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
+ /** Parent-owned: this component unmounts when its section collapses. */
+ editingTiers?: boolean;
+ onEditingTiersChange?: (editing: boolean) => void;
customTechnicalKeywords?: string[];
onCustomTechnicalKeywordsChange?: (keywords: string[]) => void;
// Optional: the edit-auto-router modal doesn't yet support editing keyword tier
@@ -268,14 +315,16 @@ const AffinityControls: React.FC<{
onChange({ ...value, session_affinity: sessionAffinity })}
aria-label="Pin a session to its first model"
/>
Pin a session to its first model
- Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment.
+ {restrictedBy(value, "sessionAffinity")?.reason ??
+ "Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment."}
>
);
@@ -307,22 +356,12 @@ const PlanModeOverrideControls: React.FC<{
{value.plan_mode_min_tier !== undefined && (
)}
>
@@ -351,6 +390,8 @@ const ComplexityRouterConfig: React.FC = ({
modelInfo,
value,
onChange,
+ editingTiers,
+ onEditingTiersChange,
customTechnicalKeywords,
onCustomTechnicalKeywordsChange,
keywordTierRules = [],
@@ -365,13 +406,34 @@ const ComplexityRouterConfig: React.FC = ({
onEscalationKeywordsChange,
showValidationErrors = false,
}) => {
+ const customTierSet = value.custom_tier_set;
const tierRows = activeTierRows(value);
- const planModeTierOptions = tierRows
- .filter((row) => row.models.length > 0)
- .map((row) => ({ value: row.id, label: effectiveTierLabel(row.id as keyof ComplexityTiers, value.tier_labels) }));
+ const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null;
+
+ const planModeRows = tierRows.filter((row) => row.models.length > 0);
+ const planModeTierOptions = planModeRows.map((row) => ({
+ value: row.id,
+ label: tierRowLabel(row, value.tier_labels),
+ }));
const derivedDefaultModel = resolveComplexityDefaultModel(value);
+ const emptyTiersHint = customTierSet
+ ? "Add a model to your fallback tier"
+ : "Add a model to the Simple or Medium tier";
+ const defaultModelPlaceholder = derivedDefaultModel ? `Derived from tiers: ${derivedDefaultModel}` : emptyTiersHint;
const defaultModel = resolveComplexityDefaultModel(value, value.default_model);
+ const dispatch = (action: TierSetAction) => {
+ const next = applyTierSetAction(value, keywordTierRules, action);
+ if (next.keywordTierRules !== keywordTierRules) onKeywordTierRulesChange?.([...next.keywordTierRules]);
+ onChange(next.value);
+ };
+
+ const setRowModels = (row: TierRow, models: string[]) => dispatch({ kind: "models", id: row.id, models });
+ const updateTierRow = (id: string, patch: Partial>) => dispatch({ kind: "patch", id, patch });
+ const addCustomTier = () => dispatch({ kind: "add" });
+ const removeTierRow = (id: string) => dispatch({ kind: "remove", id });
+ const exitToBuiltInTiers = () => dispatch({ kind: "restore" });
+
// An absent list means the proxy does not send the field yet, so every level is offered as before.
// An empty list is the group's own answer that its deployments share no level, and is left empty.
const effortOptionsByModel: Record = Object.fromEntries(
@@ -389,19 +451,7 @@ const ComplexityRouterConfig: React.FC = ({
label: model.model_group,
}));
- const handleTierChange = (tier: keyof ComplexityTiers, models: string[]) => {
- onChange({
- ...value,
- tiers: { ...value.tiers, [tier]: models },
- tier_model_params: pruneTierModelParams(value.tier_model_params, tier, models),
- });
- };
-
- const handleTierModelEffortChange = (
- tier: keyof ComplexityTiers,
- model: string,
- effort: ReasoningEffort | undefined,
- ) => {
+ const handleTierModelEffortChange = (tier: string, model: string, effort: ReasoningEffort | undefined) => {
onChange({
...value,
tier_model_params: setTierModelReasoningEffort(value.tier_model_params, tier, model, effort),
@@ -431,61 +481,120 @@ const ComplexityRouterConfig: React.FC = ({
- The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls,
- <1ms latency). Configure which model(s) handle each tier.
+ {heuristicScoringRole(value) === "never"
+ ? "The complexity router classifies each request with your classifier model and routes it to that tier. Configure which model(s) handle each tier."
+ : "The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls, <1ms latency). Configure which model(s) handle each tier."}
- Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how
- requests are classified, and callers never see these names.
- {usesLlmClassifier(value.classifier_type) &&
+ {restrictedBy(value, "displayNames")?.reason ??
+ "Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how requests are classified, and callers never see these names."}
+ {!customTierSet &&
+ usesLlmClassifier(value.classifier_type) &&
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
- {tierRows.map((row: TierRow, index) => {
- const tier = row.id as keyof ComplexityTiers;
- const tierInfo = TIER_DESCRIPTIONS[tier];
- const label = effectiveTierLabel(tier, value.tier_labels);
+ {tierRows.map((row, index) => {
+ const builtIn = TIER_ORDER.find((tier) => tier === row.id);
+ const tierInfo = builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined;
+ const label = tierRowLabel(row, value.tier_labels);
const tierMissing = showValidationErrors && row.models.length === 0;
+ const definitionMissing =
+ showValidationErrors && Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name);
return (
+ {editingTiers && (
+
+ Add or remove tiers to define your own set. Every custom tier needs a definition the LLM classifier routes
+ on, and an edited set requires the LLM classification method
+
+ )}
+
+ {customTierSet && (
+