From e6a711bf9ca2a1fbfd2b822a8e90566345814edd Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 20 Aug 2026 19:51:05 -0700 Subject: [PATCH] feat(ui): edit the auto-router tier set with custom classifier-defined tiers --- .../add_model/ClassificationMethodConfig.tsx | 268 ++++++++----- .../add_model/ComplexityRouterConfig.test.tsx | 173 ++++++++ .../add_model/ComplexityRouterConfig.tsx | 378 +++++++++++++++--- .../components/add_model/KeywordTierRules.tsx | 16 +- .../add_model/add_auto_router_tab.test.tsx | 37 ++ .../add_model/add_auto_router_tab.tsx | 118 ++++-- .../build_auto_router_test_targets.ts | 10 +- .../build_complexity_router_config.test.ts | 179 +++++++++ .../build_complexity_router_config.ts | 173 +++++++- .../add_model/complexity_router_tiers.ts | 27 +- .../components/add_model/custom_tier_set.ts | 54 +++ ...d_updated_complexity_router_config.test.ts | 67 ++++ .../edit_auto_router_modal.test.tsx | 83 ++++ .../edit_auto_router_modal.tsx | 138 +++++-- .../src/components/networking.tsx | 26 ++ .../src/lib/autorouter_presets.ts | 13 +- ui/litellm-dashboard/tsconfig.tsbuildinfo | 2 +- 17 files changed, 1506 insertions(+), 256 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/custom_tier_set.ts diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index ef6e521de42..e46aee1381c 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -26,6 +26,7 @@ import { CLASSIFICATION_RUBRIC_KEYS, ClassificationRubric, effectiveTierLabel, + effectiveClassifierType, } from "./ComplexityRouterConfig"; const DEFAULT_SCORING_EXPLANATION = @@ -84,6 +85,20 @@ const boundaryRanges = ( const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => { // The shipped boundaries come from the proxy, so this card cannot state ranges the router stopped using. const { data: scorerDefaults, isError } = useComplexityScorerDefaults(); + if (value.custom_tier_set) { + return ( + + + How Classification Works + + The LLM classifier routes each request by matching it against your tier definitions, in the order listed + (earlier tiers win keyword ties). The heuristic scorer and its score bands never run. When the classifier + errors, times out, or returns an unparseable reply, requests route to the Fallback Tier chosen above. + + + + ); + } const ranges = boundaryRanges( scorerDefaults?.tier_boundaries, value.tier_boundaries, @@ -129,6 +144,8 @@ interface ClassificationMethodConfigProps { value: ComplexityRouterConfigValue; onChange: (value: ComplexityRouterConfigValue) => void; modelOptions: { value: string; label: string }[]; + /** Present with an edited tier set: commits a fallback-tier re-point through the tier editor's owner. */ + onFallbackTierChange?: (fallbackTierId: string) => void; customTechnicalKeywords?: string[]; onCustomTechnicalKeywordsChange?: (keywords: string[]) => void; showValidationErrors?: boolean; @@ -144,10 +161,13 @@ const ClassificationMethodConfig: React.FC = ({ onCustomTechnicalKeywordsChange, showValidationErrors = false, defaultModel, + onFallbackTierChange, }) => { const hasDefaultModel = Boolean(defaultModel); + const hasCustomTierSet = Boolean(value.custom_tier_set); + const classifierType = effectiveClassifierType(value); const classifierModelMissing = - showValidationErrors && value.classifier_type === "llm" && !value.classifier_llm_config?.model; + showValidationErrors && classifierType === "llm" && !value.classifier_llm_config?.model; const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim()); const classificationRubric = value.classifier_llm_config?.classification_rubric ?? DEFAULT_CLASSIFICATION_RUBRIC; @@ -252,20 +272,28 @@ const ClassificationMethodConfig: React.FC = ({ return ( <> handleClassifierTypeChange(classifierType as ClassifierType)} className="w-full" >
- + +
- {value.classifier_type === "llm" && ( + {classifierType === "llm" && (
+ {hasCustomTierSet && + Boolean( + value.classifier_llm_config?.system_prompt?.trim() || value.classifier_llm_config?.classification_rubric, + ) && ( + + Your custom classifier prompt and rubric preset are not used with an edited tier set: the classifier + prompt is built from your tier definitions. Restore the built-in tiers to use them again. + + )}
Classifier Model = ({ How long the classifier call has before it fails and the fallback below takes over.
-
-
- Classification Rubric - - - -
- - - - - {usesCustomPrompt - ? "Not in use: the custom prompt below is the classifier's entire rubric." - : CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description} - -
-
- Classifier Prompt - -
+ + + + {usesCustomPrompt + ? "Not in use: the custom prompt below is the classifier's entire rubric." + : CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description} + +
+ )} + {!hasCustomTierSet && ( +
+ Classifier Prompt + +
+ )}
If the classifier fails - handleClassifierFallbackChange(fallback as ClassifierFallback)} - > -
- -
+
+ ) : ( + handleClassifierFallbackChange(fallback as ClassifierFallback)} + > +
+ + +
+
+ )} - Applies when the classifier call errors, times out, or returns an unparseable response. + {hasCustomTierSet + ? "Applies when the classifier call errors, times out, or returns an unparseable response. The classifier prompt is built from your tier definitions." + : "Applies when the classifier call errors, times out, or returns an unparseable response."}
@@ -446,7 +530,7 @@ const ClassificationMethodConfig: React.FC = ({
)} - {value.classifier_type === "heuristic" && ( + {classifierType === "heuristic" && (
Custom Technical Keywords 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 0a848ed7deb..d24cc543727 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -28,6 +28,8 @@ const baseProps = { modelInfo: mockModelInfo, value: defaultValue, onChange: vi.fn(), + editingTiers: false, + onEditingTiersChange: vi.fn(), keywordTierRules: [], onKeywordTierRulesChange: vi.fn(), semanticMatchingEnabled: false, @@ -943,3 +945,174 @@ describe("ComplexityRouterConfig reasoning effort gating", () => { ).toHaveTextContent("low"); }); }); + +describe("edit tiers", () => { + const customValue: ComplexityRouterConfigValue = { + ...defaultValue, + classifier_type: "llm", + classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 }, + custom_tier_set: { + tiers: [ + { id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-3.5-turbo"] }, + { id: "COMPLEX", name: "COMPLEX", definition: "", models: ["gpt-4"] }, + { id: "sec", name: "AUDIT", definition: "security audits", models: ["claude-3-opus"] }, + ], + fallback_tier_id: "COMPLEX", + }, + }; + + it("removing a built-in tier materializes the ordered row set and touches nothing else", async () => { + const onChange = vi.fn(); + renderWithProviders(); + await userEvent.click(screen.getByRole("button", { name: "Remove the MEDIUM tier" })); + expect(onChange).toHaveBeenCalledWith({ + ...defaultValue, + custom_tier_set: { + tiers: [ + { id: "SIMPLE", name: "SIMPLE", definition: "", models: defaultValue.tiers.SIMPLE }, + { id: "COMPLEX", name: "COMPLEX", definition: "", models: defaultValue.tiers.COMPLEX }, + { id: "REASONING", name: "REASONING", definition: "", models: defaultValue.tiers.REASONING }, + ], + fallback_tier_id: "SIMPLE", + }, + }); + }); + + it("Restore defaults refills missing built-ins but only Use built-in tiers exits, returning the pristine value", async () => { + const onChange = vi.fn(); + const removed: ComplexityRouterConfigValue = { + ...defaultValue, + custom_tier_set: { + tiers: (["SIMPLE", "COMPLEX", "REASONING"] as const).map((tier) => ({ + id: tier, + name: tier, + definition: "", + models: defaultValue.tiers[tier], + })), + fallback_tier_id: "SIMPLE", + }, + }; + renderWithProviders(); + await userEvent.click(screen.getByRole("button", { name: "Restore defaults" })); + const restored = onChange.mock.calls.at(-1)?.[0] as ComplexityRouterConfigValue; + expect(restored.custom_tier_set?.tiers.map((row) => row.id)).toEqual(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]); + + await userEvent.click(screen.getByRole("button", { name: "Use built-in tiers" })); + expect(onChange).toHaveBeenLastCalledWith(defaultValue); + }); + + it("removing a row snapshots its in-editor models so Restore returns them, not a stale pool", async () => { + const onChange = vi.fn(); + const edited: ComplexityRouterConfigValue = { + ...customValue, + custom_tier_set: { + ...customValue.custom_tier_set!, + tiers: customValue.custom_tier_set!.tiers.map((row) => + row.id === "SIMPLE" ? { ...row, models: ["edited-in-editor"] } : row, + ), + }, + }; + renderWithProviders(); + await userEvent.click(screen.getByRole("button", { name: "Remove the SIMPLE tier" })); + const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue; + expect(next.tiers.SIMPLE).toEqual(["edited-in-editor"]); + expect(next.custom_tier_set?.tiers.some((row) => row.id === "SIMPLE")).toBe(false); + }); + + it("tells the custom-tier routing story instead of the heuristic score bands", async () => { + renderWithProviders(); + await userEvent.click(screen.getByText("Advanced: Classification Method")); + expect(screen.getByText(/matching it against your tier definitions/)).toBeInTheDocument(); + expect(screen.queryByText(/Score { + const withPrompt: ComplexityRouterConfigValue = { + ...customValue, + classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000, system_prompt: "grade it my way" }, + }; + renderWithProviders(); + await userEvent.click(screen.getByText("Advanced: Classification Method")); + expect(screen.getByText(/prompt and rubric preset are not used with an edited tier set/)).toBeInTheDocument(); + }); + + it("shows name and definition inputs for the rows and disables session pinning with a hint", async () => { + renderWithProviders(); + expect(screen.getByLabelText("Name for tier 3")).toHaveValue("AUDIT"); + expect(screen.getByLabelText("Definition for tier 3")).toHaveValue("security audits"); + expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument(); + await userEvent.click(screen.getByText("Advanced: Affinity")); + expect(screen.getByLabelText("Pin a session to its first model")).toHaveAttribute("data-disabled"); + expect(screen.getByText(/Unavailable with an edited tier set: escalating a pinned session/)).toBeInTheDocument(); + }); + + it("exiting scrubs params that no built-in tier owns", async () => { + const onChange = vi.fn(); + const withStrayParams: ComplexityRouterConfigValue = { + ...customValue, + tier_model_params: { + AUDIT: { "claude-3-opus": { reasoning_effort: "high" } }, + COMPLEX: { "gpt-4": { reasoning_effort: "low" } }, + }, + }; + renderWithProviders( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Use built-in tiers" })); + const exited = onChange.mock.calls.at(-1)?.[0] as ComplexityRouterConfigValue; + expect(exited.custom_tier_set).toBeUndefined(); + expect(exited.tier_model_params).toEqual({ COMPLEX: { "gpt-4": { reasoning_effort: "low" } } }); + }); + + it("clears the plan-mode floor when its row is removed, and keeps a built-in floor through mode exit", async () => { + const onChange = vi.fn(); + const withFloor: ComplexityRouterConfigValue = { ...customValue, plan_mode_min_tier: "sec" }; + renderWithProviders(); + await userEvent.click(screen.getByRole("button", { name: "Remove the AUDIT tier" })); + expect((onChange.mock.calls[0][0] as ComplexityRouterConfigValue).plan_mode_min_tier).toBeUndefined(); + }); + + it("keeps a built-in plan-mode floor through the explicit exit, and clears a custom one", async () => { + const onChange = vi.fn(); + renderWithProviders( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Use built-in tiers" })); + expect((onChange.mock.calls.at(-1)?.[0] as ComplexityRouterConfigValue).plan_mode_min_tier).toBe("COMPLEX"); + }); + + it("view mode shows only the tier name and models; the definition moves into the info tooltip", () => { + renderWithProviders(); + expect(screen.getByText("AUDIT Tier")).toBeInTheDocument(); + expect(screen.queryByLabelText("Name for tier 3")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Definition for tier 3")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Remove the/ })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Edit tiers" })).toBeInTheDocument(); + }); + + it("Done stays disabled until every row has a name, a definition, and models", () => { + const incomplete: ComplexityRouterConfigValue = { + ...customValue, + custom_tier_set: { + ...customValue.custom_tier_set!, + tiers: [...customValue.custom_tier_set!.tiers, { id: "new-1", name: "", definition: "", models: [] }], + }, + }; + const { rerender } = renderWithProviders(); + expect(screen.getByRole("button", { name: "Done" })).toHaveAttribute("data-disabled"); + + rerender(); + expect(screen.getByRole("button", { name: "Done" })).not.toHaveAttribute("data-disabled"); + }); + + it("shows the editor controls from the parent-owned editing flag, surviving a section remount", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: "Add tier" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index fc731e2c77f..db339c28cba 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -2,12 +2,23 @@ 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 { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; 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 { + type CustomTierSet, + type TierDraft, + activeTierNames, + getCustomTierRowsError, + isBuiltInTierName, + scopeTierParamsToRows, +} from "./custom_tier_set"; import React from "react"; import { ModelGroup } from "@/components/llm_calls/fetch_models"; import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig"; @@ -15,6 +26,7 @@ import ClassificationMethodConfig from "./ClassificationMethodConfig"; import { ReasoningEffort, TierModelParamsByTier, + customTierDefaultModel, pruneTierModelParams, resolveComplexityDefaultModel, setTierModelReasoningEffort, @@ -27,11 +39,15 @@ import SemanticKeywordMatching from "./SemanticKeywordMatching"; import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from "./heuristic_scoring_knobs"; export type { DimensionWeights, TierBoundaries, TokenThresholds }; +export type { CustomTierSet, TierDraft } from "./custom_tier_set"; +export { activeTierNames, getCustomTierRowsError, isBuiltInTierName } from "./custom_tier_set"; export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000; export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5; export const DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE = 3; export const DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS = 200; +export const MIN_TIER_COUNT = 2; +export const MAX_TIER_COUNT = 8; export const DEFAULT_SESSION_AFFINITY = false; export const DEFAULT_DEPLOYMENT_AFFINITY = true; @@ -123,15 +139,25 @@ export const heuristicScoringRoleFor = ( }; export const heuristicScoringRole = (value: ComplexityRouterConfigValue): HeuristicScoringRole => - heuristicScoringRoleFor(value.classifier_type, value.classifier_fallback); + value.custom_tier_set ? "never" : heuristicScoringRoleFor(value.classifier_type, value.classifier_fallback); export type AdaptiveEligible = "all" | "classified_tier"; export type ComplexityTierLabels = Partial>; +/** + * The classifier type the payload will carry, which a custom tier set pins to "llm" without + * writing into the value: deriving it wherever it is displayed or validated is what lets an + * undone tier edit revert the form with nothing left behind. + */ +export const effectiveClassifierType = ( + value: Pick, +): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type); + export interface ComplexityRouterConfigValue { tiers: ComplexityTiers; tier_labels?: ComplexityTierLabels; + custom_tier_set?: CustomTierSet; /** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */ default_model?: string; classifier_type: ClassifierType; @@ -142,7 +168,7 @@ 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 floor as a tier ROW ID (unset = off); the wire carries the row's name. */ plan_mode_min_tier?: string; adaptive?: boolean; adaptive_weights?: AdaptiveRouterWeights; @@ -173,6 +199,9 @@ interface ComplexityRouterConfigProps { modelInfo: ModelGroup[]; value: ComplexityRouterConfigValue; onChange: (value: ComplexityRouterConfigValue) => void; + /** Parent-owned: this component unmounts on section collapse and editor mode must survive. */ + 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 @@ -221,14 +250,18 @@ 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); +// Row IDS the floor may point at: the backend rejects a floor whose tier has no models. +export const planModeEligibleTiers = (tiers: ComplexityTiers, customTierSet?: CustomTierSet): string[] => + customTierSet + ? customTierSet.tiers.filter((row) => row.name.trim() && row.models.length > 0).map((row) => row.id) + : TIER_KEYS.filter((tier) => (tiers[tier] ?? []).length > 0); const ComplexityRouterConfig: React.FC = ({ modelInfo, value, onChange, + editingTiers, + onEditingTiersChange, customTechnicalKeywords, onCustomTechnicalKeywordsChange, keywordTierRules = [], @@ -243,12 +276,99 @@ const ComplexityRouterConfig: React.FC = ({ onEscalationKeywordsChange, showValidationErrors = false, }) => { - const planModeTiers = planModeEligibleTiers(value.tiers); - const planModeTierOptions = tierOptions(value.tier_labels).filter((option) => - (planModeTiers as string[]).includes(option.value), - ); - const derivedDefaultModel = resolveComplexityDefaultModel(value.tiers); - const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model); + const customTierSet = value.custom_tier_set; + + const builtInRow = (tier: keyof ComplexityTiers): TierDraft => ({ + id: tier, + name: tier, + definition: "", + models: value.tiers[tier], + }); + const tierRows: TierDraft[] = customTierSet?.tiers ?? TIER_KEYS.map(builtInRow); + + // The single owner of every tier-set write: reconciles both row-id pointers (fallback + // re-points, floor clears) and issues the one onChange. Mode transitions are explicit. + const commitTierRows = (rows: TierDraft[], fallbackTierId: string, base: ComplexityRouterConfigValue = value) => { + const plan_mode_min_tier = rows.some((row) => row.id === base.plan_mode_min_tier) + ? base.plan_mode_min_tier + : undefined; + const fallback_tier_id = rows.some((row) => row.id === fallbackTierId) + ? fallbackTierId + : (rows.find((row) => row.name === "MEDIUM") ?? rows[0])?.id ?? ""; + onChange({ ...base, plan_mode_min_tier, custom_tier_set: { tiers: rows, fallback_tier_id } }); + }; + + const currentFallbackId = customTierSet?.fallback_tier_id ?? "MEDIUM"; + + // Removing a built-in row snapshots its models so Restore returns them, not a stale pool. + const removeTierRow = (id: string) => { + const removed = tierRows.find((row) => row.id === id); + const snapshotBase = + removed && (TIER_KEYS as string[]).includes(removed.id) + ? { ...value, tiers: { ...value.tiers, [removed.id]: removed.models } } + : value; + commitTierRows( + tierRows.filter((row) => row.id !== id), + currentFallbackId, + snapshotBase, + ); + }; + + const restoreDefaultTiers = () => { + const restoredInCanonicalOrder = [ + ...TIER_KEYS.map((builtIn) => tierRows.find((row) => row.id === builtIn) ?? builtInRow(builtIn)), + ...tierRows.filter((row) => !(TIER_KEYS as string[]).includes(row.id)), + ]; + commitTierRows(restoredInCanonicalOrder, currentFallbackId); + }; + + // The one way back to built-in mode; models map back by row id. + const exitToBuiltInTiers = () => { + const { custom_tier_set: _cleared, ...rest } = value; + const modelsFor = (tier: keyof ComplexityTiers) => + tierRows.find((row) => row.id === tier)?.models ?? value.tiers[tier]; + onChange({ + ...rest, + plan_mode_min_tier: (TIER_KEYS as string[]).includes(value.plan_mode_min_tier ?? "") + ? value.plan_mode_min_tier + : undefined, + tier_model_params: scopeTierParamsToRows( + value.tier_model_params, + TIER_KEYS.map((tier) => ({ name: tier })), + ), + tiers: { + SIMPLE: modelsFor("SIMPLE"), + MEDIUM: modelsFor("MEDIUM"), + COMPLEX: modelsFor("COMPLEX"), + REASONING: modelsFor("REASONING"), + }, + }); + }; + + const addCustomTier = () => { + commitTierRows([...tierRows, { id: crypto.randomUUID(), name: "", definition: "", models: [] }], currentFallbackId); + }; + + const updateTierRow = (id: string, patch: Partial>) => { + commitTierRows( + tierRows.map((candidate) => (candidate.id === id ? { ...candidate, ...patch } : candidate)), + currentFallbackId, + ); + }; + + const fallbackRow = customTierSet?.tiers.find((row) => row.id === customTierSet.fallback_tier_id); + const planModeTiers = planModeEligibleTiers(value.tiers, customTierSet); + const planModeTierOptions = customTierSet + ? customTierSet.tiers + .filter((row) => planModeTiers.includes(row.id)) + .map((row) => ({ value: row.id, label: row.name.trim() })) + : tierOptions(value.tier_labels).filter((option) => planModeTiers.includes(option.value)); + const derivedDefaultModel = customTierSet + ? customTierDefaultModel(customTierSet) + : resolveComplexityDefaultModel(value.tiers); + const defaultModel = customTierSet + ? customTierDefaultModel(customTierSet, value.default_model) + : resolveComplexityDefaultModel(value.tiers, value.default_model); // Embedding models can't serve a chat-completion role, so they're excluded here. const reasoningModels = new Set( @@ -270,11 +390,7 @@ const ComplexityRouterConfig: React.FC = ({ }); }; - 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), @@ -309,77 +425,191 @@ const ComplexityRouterConfig: React.FC = ({ - 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.classifier_type === "llm" && + {customTierSet + ? "Display names are unavailable with an edited tier set: your tier names themselves appear in the dashboard and spend logs." + : "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 && + value.classifier_type === "llm" && " Your classifier model reads these names, so clearer ones can sharpen its choices."} - {TIER_KEYS.map((tier, index) => { - const tierInfo = TIER_DESCRIPTIONS[tier]; - const label = effectiveTierLabel(tier, value.tier_labels); - const tierMissing = showValidationErrors && value.tiers[tier].length === 0; + {tierRows.map((row, index) => { + const builtInKey = TIER_KEYS.find((tier) => tier === row.id); + const tierInfo = builtInKey ? TIER_DESCRIPTIONS[builtInKey] : undefined; + const label = customTierSet + ? row.name.trim() || "New" + : effectiveTierLabel(row.id as keyof ComplexityTiers, value.tier_labels); + const nameMissing = showValidationErrors && Boolean(customTierSet) && !row.name.trim(); + const definitionMissing = + showValidationErrors && Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name); + const modelsMissing = showValidationErrors && row.models.length === 0; return ( -
+
{index > 0 && }
{label} Tier - + - Tier {index + 1} of {TIER_KEYS.length} · {tier} + Tier {index + 1} of {tierRows.length} · {!customTierSet && row.id} + {customTierSet && (isBuiltInTierName(row.name) ? "built-in" : "custom")} -
- Examples: {tierInfo.examples} - - handleTierLabelChange(tier, event.target.value)} - placeholder={`Display name (default: ${tierInfo.label})`} - aria-label={`Display name for the ${tierInfo.label} tier`} - /> - {value.tier_labels?.[tier] && ( - - handleTierLabelChange(tier, "")} - > - - - + {editingTiers && ( + )} - +
+ {tierInfo && ( + Examples: {tierInfo.examples} + )} + {customTierSet && editingTiers && ( + <> + updateTierRow(row.id, { name: event.target.value })} + placeholder="Tier name, e.g. SECURITY_REVIEW" + aria-label={`Name for tier ${index + 1}`} + className={nameMissing ? "mb-2 border-destructive" : "mb-2"} + /> +