- {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 +466,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..e0371806ea7 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,210 @@ 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 defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => {
+ if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`;
+ return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier";
+};
+
+const builtInTierInfo = (rowId: string): { label: string; description: string; examples: string } | undefined => {
+ const builtIn = TIER_ORDER.find((tier) => tier === rowId);
+ return builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined;
+};
+
+const TierConfigIntro: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => (
+ <>
+
+ {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."}
+
+
+
+ {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."}
+ {!value.custom_tier_set &&
+ usesLlmClassifier(value.classifier_type) &&
+ " Your classifier model reads these names, so clearer ones can sharpen its choices."}
+
+ >
+);
+
+const TierSetToolbar: React.FC<{
+ editing: boolean;
+ isCustomSet: boolean;
+ rowCount: number;
+ rowsError: string | null;
+ onEditingChange: ((editing: boolean) => void) | undefined;
+ onAdd: () => void;
+ onRestore: () => void;
+}> = ({ editing, isCustomSet, rowCount, rowsError, onEditingChange, onAdd, onRestore }) => (
+ <>
+