From 687f2ad26dd73807dfbb667458b922a37296d6ae Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Mon, 17 Aug 2026 18:33:30 -0700 Subject: [PATCH] feat(ui): add and delete auto-router tiers with classifier definitions --- .../add_model/ClassificationMethodConfig.tsx | 214 ++++---- .../add_model/ComplexityRouterConfig.test.tsx | 201 ++++++- .../add_model/ComplexityRouterConfig.tsx | 507 ++++++++++++++---- .../components/add_model/KeywordTierRules.tsx | 11 +- .../add_model/add_auto_router_tab.test.tsx | 1 + .../add_model/add_auto_router_tab.tsx | 105 ++-- .../build_auto_router_test_targets.test.ts | 15 + .../build_auto_router_test_targets.ts | 76 ++- .../build_complexity_router_config.test.ts | 232 ++++++++ .../build_complexity_router_config.ts | 235 +++++++- .../add_model/classification_rubrics.ts | 44 ++ .../add_model/complexity_router_tiers.test.ts | 22 +- .../add_model/complexity_router_tiers.ts | 31 +- ...d_updated_complexity_router_config.test.ts | 143 +++++ .../edit_auto_router_modal.test.tsx | 1 + .../edit_auto_router_modal.tsx | 146 +++-- .../src/components/model_info_view.tsx | 59 +- .../src/components/networking.tsx | 27 + .../src/lib/autorouter_presets.ts | 8 +- 19 files changed, 1735 insertions(+), 343 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/classification_rubrics.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..7230495f756 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -25,6 +25,7 @@ import { CLASSIFICATION_RUBRIC_DESCRIPTIONS, CLASSIFICATION_RUBRIC_KEYS, ClassificationRubric, + effectiveClassifierType, effectiveTierLabel, } from "./ComplexityRouterConfig"; @@ -146,8 +147,10 @@ const ClassificationMethodConfig: React.FC = ({ defaultModel, }) => { 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 +255,28 @@ const ClassificationMethodConfig: React.FC = ({ return ( <> handleClassifierTypeChange(classifierType as ClassifierType)} className="w-full" >
- + +
- {value.classifier_type === "llm" && ( + {classifierType === "llm" && (
Classifier Model @@ -306,92 +317,105 @@ const ClassificationMethodConfig: React.FC = ({ 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)} - > -
- - + +
+
+ + Applies when the classifier call errors, times out, or returns an unparseable response. + + + )}
Context Window Size @@ -446,7 +470,7 @@ const ClassificationMethodConfig: React.FC = ({
)} - {value.classifier_type === "heuristic" && ( + {classifierType === "heuristic" && (
Custom Technical Keywords @@ -478,7 +502,7 @@ const ClassificationMethodConfig: React.FC = ({ - + {!hasCustomTierSet && } ); }; 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 640b10ad163..354ef922cdd 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, renderWithProviders, screen, within } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; -import ComplexityRouterConfig, { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; +import ComplexityRouterConfig, { ComplexityRouterConfigValue, heuristicScoringRole } from "./ComplexityRouterConfig"; vi.mock( "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults", async () => await import("../../../tests/mocks/complexityScorerDefaults"), @@ -874,3 +874,202 @@ describe("plan-mode override", () => { expect(await screen.findByRole("switch", { name: switchName })).toHaveAttribute("aria-disabled", "true"); }); }); + +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: "REASONING", name: "REASONING", definition: "", models: ["claude-3-opus"] }, + { 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 () => { + // The forced states (LLM classifier, affinity and adaptive off) are derived at display and + // submit time rather than written here, so undoing the edit reverts the form completely. + const onChange = vi.fn(); + renderWithProviders(); + await userEvent.click(screen.getByRole("button", { name: "Edit tiers" })); + await userEvent.click(screen.getByRole("button", { name: "Remove the MEDIUM tier" })); + const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue; + expect(next).toEqual({ + ...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("removing a built-in row snapshots its in-editor models so Restore returns them, not a stale pool", async () => { + const onChange = vi.fn(); + const edited = { + ...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("mints a fresh row id even when an earlier instance already used the counter", async () => { + // The section collapse unmounts this component while rows live in the parent, so ids must + // derive from the rows, not from instance state that resets on remount. + const onChange = vi.fn(); + renderWithProviders( + , + ); + await userEvent.click(screen.getByRole("button", { name: /Add tier/ })); + const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue; + const ids = next.custom_tier_set!.tiers.map((row) => row.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("adding a tier appends an empty row draft", async () => { + const onChange = vi.fn(); + renderWithProviders(); + await userEvent.click(screen.getByRole("button", { name: "Edit tiers" })); + await userEvent.click(screen.getByRole("button", { name: /Add tier/ })); + const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue; + expect(next.custom_tier_set?.tiers).toHaveLength(5); + expect(next.custom_tier_set?.tiers[4]).toEqual({ + id: expect.any(String), + name: "", + definition: "", + models: [], + }); + }); + + it("restoring the removed tier with no other edits clears the tier set entirely", async () => { + const onChange = vi.fn(); + renderWithProviders( + row.id !== "sec"), + fallback_tier_id: "COMPLEX", + }, + }} + onChange={onChange} + />, + ); + await userEvent.click(screen.getByRole("button", { name: "Restore MEDIUM" })); + const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue; + expect(next.custom_tier_set).toBeUndefined(); + expect(next.tiers).toEqual(defaultValue.tiers); + }); + + it("renaming the fallback tier keeps the fallback pointer on the same row", async () => { + const onChange = vi.fn(); + renderWithProviders( + , + ); + await userEvent.type(screen.getByLabelText("Name for tier 4"), "S"); + const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue; + expect(next.custom_tier_set?.tiers[3].name).toBe("AUDITS"); + expect(next.custom_tier_set?.fallback_tier_id).toBe("sec"); + }); + + it("renders every row with its definition and counts the active set", () => { + renderWithProviders(); + expect(screen.getByText("AUDIT Tier")).toBeInTheDocument(); + expect(screen.getByDisplayValue("security audits")).toBeInTheDocument(); + expect(screen.queryByText("Medium Tier")).not.toBeInTheDocument(); + expect(screen.getByText(/Tier 4 of 4/)).toHaveTextContent("Tier 4 of 4 · custom"); + expect(screen.getByText(/Tier 1 of 4/)).toHaveTextContent("Tier 1 of 4 · built-in"); + }); + + it("opens with the tier controls visible when the value already carries an edited tier set", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: /Add tier/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Edit tiers" })).not.toBeInTheDocument(); + }); + + it("flags a custom tier without a definition when validation errors show", () => { + renderWithProviders( + + row.id === "sec" ? { ...row, definition: "" } : row, + ), + }, + }} + showValidationErrors + />, + ); + expect(screen.getByText(/A definition is required/)).toBeInTheDocument(); + }); + + it("shows the fallback tier select carrying the chosen tier", () => { + renderWithProviders(); + const [fallbackSelect] = screen.getAllByLabelText("Fallback tier"); + expect(fallbackSelect).toHaveTextContent("COMPLEX"); + }); + + it("reports the scorer never runs on a custom tier set, hiding the scorer panels", async () => { + expect(heuristicScoringRole(customValue)).toBe("never"); + expect(heuristicScoringRole({ ...customValue, classifier_type: "heuristic" })).toBe("never"); + renderWithProviders( + , + ); + await userEvent.click(screen.getByText("Advanced: Classification Method")); + expect(screen.queryByText("How Classification Works")).not.toBeInTheDocument(); + }); + + it("shows the LLM classifier section for a custom tier set even when the stored type is heuristic", async () => { + renderWithProviders( + , + ); + await userEvent.click(screen.getByText("Advanced: Classification Method")); + expect(screen.getByText("Classifier Model")).toBeInTheDocument(); + }); + + it("hides display-name inputs and disables session pinning with an edited tier set", async () => { + renderWithProviders(); + expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument(); + await userEvent.click(screen.getByText("Advanced: Affinity")); + // Base UI switches carry disabled state as data-disabled, not the native attribute + expect(screen.getByLabelText("Pin a session to its first model")).toHaveAttribute("data-disabled"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 57028273d6d..1f7d176a230 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -2,8 +2,11 @@ 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"; @@ -12,7 +15,8 @@ import React from "react"; import { ModelGroup } from "@/components/llm_calls/fetch_models"; import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig"; import ClassificationMethodConfig from "./ClassificationMethodConfig"; -import { resolveComplexityDefaultModel, tierOptions } from "./complexity_router_tiers"; +import { type ClassificationRubric } from "./classification_rubrics"; +import { customTierDefaultModel, resolveComplexityDefaultModel, tierOptions } from "./complexity_router_tiers"; import EscalationKeywords from "./EscalationKeywords"; import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules"; import SemanticKeywordMatching from "./SemanticKeywordMatching"; @@ -34,50 +38,13 @@ export interface ComplexityTiers { REASONING: string[]; } -export type ClassificationRubric = "legacy" | "agentic" | "chat" | "business"; - -/** What an unset preset means, matching the backend: the rubric as it shipped before calibration. */ -export const DEFAULT_CLASSIFICATION_RUBRIC: ClassificationRubric = "legacy"; - -/** - * Stamped on a classifier being switched on for the first time. There is no prior tier behaviour to - * preserve at that moment, so a newly configured classifier gets the calibrated rubric while every - * router already running an LLM classifier keeps the one it has. - */ -export const NEW_CLASSIFIER_CLASSIFICATION_RUBRIC: ClassificationRubric = "agentic"; - -export const CLASSIFICATION_RUBRIC_DESCRIPTIONS: Record = - { - legacy: { - label: "Legacy (uncalibrated)", - description: - "The rubric as it shipped before calibration examples, with no worked examples at all. Routers created " + - "before this setting existed use it, so their tier decisions and spend are unchanged. It over-routes " + - "ordinary engineering to the most expensive tier.", - }, - agentic: { - label: "Agentic", - description: - "Anchors routine installs, builds, multi-file edits, and standard debugging at " + - "Medium, so ordinary engineering does not route to your most expensive tier. Suits agent, terminal, and " + - "coding-assistant traffic, and mixed traffic.", - }, - chat: { - label: "Chat", - description: - "Drops the engineering examples, for a router serving only conversational traffic that never sees those " + - "requests.", - }, - business: { - label: "Business", - description: - "Business and sales examples plus business-oriented tier definitions: routine drafting and summarizing " + - "stay at Medium, data-determined analysis is Complex, and only decisions under conflicting tradeoffs " + - "reach Reasoning. Suits sales, support, and go-to-market traffic.", - }, - }; - -export const CLASSIFICATION_RUBRIC_KEYS = Object.keys(CLASSIFICATION_RUBRIC_DESCRIPTIONS) as ClassificationRubric[]; +export { + CLASSIFICATION_RUBRIC_DESCRIPTIONS, + CLASSIFICATION_RUBRIC_KEYS, + DEFAULT_CLASSIFICATION_RUBRIC, + NEW_CLASSIFIER_CLASSIFICATION_RUBRIC, +} from "./classification_rubrics"; +export type { ClassificationRubric } from "./classification_rubrics"; export interface ClassifierLLMConfig { model: string; @@ -115,15 +82,52 @@ 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>; +export interface TierDraft { + /** List identity: the React key and the fallback and plan-mode pointers' target. Never serialized. */ + id: string; + name: string; + /** The tier's rubric bullet. Blank on a built-in name inherits the built-in criteria. */ + definition: string; + models: string[]; +} + +/** + * Present on the value when the operator edited the tier set itself. The draft IS the wire list: + * `tiers` holds every active tier in severity order, exactly as tier_definitions will carry them, + * so serialization and hydration are plain maps and no ordering, identity, or model placement can + * be lost in translation. Absence means the built-in four-tier router and a payload identical to + * before this field existed. + */ +export interface CustomTierSet { + tiers: TierDraft[]; + fallback_tier_id: string; +} + +export const isBuiltInTierName = (name: string): boolean => + TIER_KEYS.some((tier) => tier.toLowerCase() === name.trim().toLowerCase()); + +/** + * 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 const activeTierNames = (customTierSet: CustomTierSet | undefined): string[] => + customTierSet ? customTierSet.tiers.map((tier) => tier.name.trim()).filter(Boolean) : [...TIER_KEYS]; + 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; @@ -134,7 +138,12 @@ 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. */ + /** + * Tier floor for coding-agent plan-mode requests, held as a tier ROW ID so renames follow + * (built-in row ids are the four tier names, so built-in mode is id-stable by construction). + * Serialization resolves the id to the row's name; unset means detection is off, matching the + * backend. + */ plan_mode_min_tier?: string; adaptive?: boolean; adaptive_weights?: AdaptiveRouterWeights; @@ -207,9 +216,14 @@ 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 plan-mode floor may point at (the backend rejects a floor whose tier has no models). + * Ids, not names: a rename must not strand the floor, same rule as fallback_tier_id. + */ +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, @@ -229,12 +243,118 @@ 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 planModeTiers = planModeEligibleTiers(value.tiers, value.custom_tier_set); + const [editingTiers, setEditingTiers] = React.useState(false); + const customTierSet = value.custom_tier_set; + 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)); + // An edited tier set always shows its controls: a router hydrated with custom tiers would + // otherwise open looking read-only, with nothing hinting the set can be changed. + const showTierControls = editingTiers || Boolean(customTierSet); + const tierRows = customTierSet?.tiers; + const activeCount = tierRows?.length ?? TIER_KEYS.length; + const fallbackRow = tierRows?.find((tier) => tier.id === customTierSet?.fallback_tier_id); + const derivedDefaultModel = customTierSet + ? customTierDefaultModel(customTierSet) + : resolveComplexityDefaultModel(value.tiers); + const defaultModel = customTierSet + ? customTierDefaultModel(customTierSet, value.default_model) + : resolveComplexityDefaultModel(value.tiers, value.default_model); + + const builtInRow = (tier: keyof ComplexityTiers): TierDraft => ({ + id: tier, + name: tier, + definition: "", + models: value.tiers[tier], + }); + + // Compares names and definitions only, deliberately not models: restoring the built-in four + // clears the set and applyTierRows writes the rows' models back into value.tiers, so model + // edits made inside the editor survive the mode exit instead of silently reverting. + const isDefaultTierSet = (rows: TierDraft[]) => + rows.length === TIER_KEYS.length && + rows.every((row, index) => row.name === TIER_KEYS[index] && row.definition === ""); + + // Materializes or clears the edited tier set. A set equal to the built-in four clears itself, + // and no other value field is touched in either direction: the states a custom set forces + // (LLM classifier, affinity and adaptive off) are derived wherever they are displayed or + // submitted, so undoing every tier edit truly reverts the form instead of stranding forced + // classifier state behind a cleared flag. + const applyTierRows = (rows: TierDraft[], fallbackTierId: string) => { + if (isDefaultTierSet(rows)) { + const { custom_tier_set: _cleared, ...rest } = value; + onChange({ + ...rest, + tiers: { SIMPLE: rows[0].models, MEDIUM: rows[1].models, COMPLEX: rows[2].models, REASONING: rows[3].models }, + }); + return; + } + const fallback_tier_id = rows.some((row) => row.id === fallbackTierId) + ? fallbackTierId + : (rows.find((row) => row.name === "MEDIUM") ?? rows[0])?.id ?? ""; + onChange({ ...value, custom_tier_set: { tiers: rows, fallback_tier_id } }); + }; + + const currentRows = (): [TierDraft[], string] => + customTierSet + ? [customTierSet.tiers, customTierSet.fallback_tier_id] + : [TIER_KEYS.map(builtInRow), builtInRow("MEDIUM").id]; + + // Removing a built-in row snapshots its models into value.tiers (invisible on the wire while + // the set is custom) so Restore returns the models the row had at removal, not a stale pool. + const removeTierRow = (id: string) => { + const [rows, fallbackId] = currentRows(); + const removed = rows.find((row) => row.id === id); + const remaining = rows.filter((row) => row.id !== id); + if (removed && (TIER_KEYS as string[]).includes(removed.id)) { + const fallback_tier_id = remaining.some((row) => row.id === fallbackId) + ? fallbackId + : (remaining.find((row) => row.name === "MEDIUM") ?? remaining[0])?.id ?? ""; + onChange({ + ...value, + tiers: { ...value.tiers, [removed.id]: removed.models }, + custom_tier_set: { tiers: remaining, fallback_tier_id }, + }); + return; + } + applyTierRows(remaining, fallbackId); + }; + + const restoreBuiltInTier = (tier: keyof ComplexityTiers) => { + const [rows, fallbackId] = currentRows(); + const restoredInCanonicalOrder = [ + ...TIER_KEYS.flatMap((builtIn) => { + if (builtIn === tier) return [builtInRow(tier)]; + const existing = rows.find((row) => row.id === builtIn); + return existing ? [existing] : []; + }), + ...rows.filter((row) => !(TIER_KEYS as string[]).includes(row.id)), + ]; + applyTierRows(restoredInCanonicalOrder, fallbackId); + }; + + // The id is minted against the rows themselves rather than component state: this component + // unmounts when its section collapses while the rows live in the parent, so an instance + // counter would reset and re-mint an id a row already holds. + const addCustomTier = () => { + const [rows, fallbackId] = currentRows(); + const taken = new Set(rows.map((row) => row.id)); + const id = + Array.from({ length: rows.length + 1 }, (_, n) => `new-${n + 1}`).find((candidate) => !taken.has(candidate)) ?? + `new-${rows.length + 1}`; + applyTierRows([...rows, { id, name: "", definition: "", models: [] }], fallbackId); + }; + + const updateTierRow = (id: string, patch: Partial>) => { + const [rows, fallbackId] = currentRows(); + applyTierRows( + rows.map((row) => (row.id === id ? { ...row, ...patch } : row)), + fallbackId, + ); + }; // Embedding models can't serve a chat-completion role, so they're excluded here. const modelOptions = modelInfo @@ -287,64 +407,192 @@ const ComplexityRouterConfig: React.FC = ({ - {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; + {!customTierSet && + 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; + return ( +
+ {index > 0 && } +
+
+ {label} Tier + + + + + Tier {index + 1} of {activeCount} · {tier} + + {showTierControls && ( + + )} +
+ 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, "")} + > + + + + )} + + handleTierChange(tier, models)} + placeholder={`Select model(s) for ${label.toLowerCase()} queries`} + emptyText="No models found" + className={tierMissing ? "w-full border-destructive" : "w-full"} + /> + {value.tiers[tier].length > 1 && ( + + Multiple models selected — the router randomly picks among them per request (or Thompson-samples + within the pool when adaptive routing is on). + + )} + {tierMissing && The {label} tier is required} +
+
+ ); + })} + {tierRows?.map((row, index) => { + const rowName = row.name.trim(); + const builtIn = isBuiltInTierName(rowName); + const builtInInfo = builtIn ? TIER_DESCRIPTIONS[rowName.toUpperCase() as keyof ComplexityTiers] : undefined; + const nameMissing = showValidationErrors && !rowName; + const definitionMissing = showValidationErrors && !row.definition.trim() && !builtIn; + const modelsMissing = showValidationErrors && row.models.length === 0; return ( -
+
{index > 0 && }
- {label} Tier - + {rowName || "New"} Tier + - Tier {index + 1} of {TIER_KEYS.length} · {tier} + Tier {index + 1} of {activeCount} · {builtIn ? "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, "")} - > - - - - )} - - handleTierChange(tier, models)} - placeholder={`Select model(s) for ${label.toLowerCase()} queries`} - emptyText="No models found" - className={tierMissing ? "w-full border-destructive" : "w-full"} + {builtInInfo && ( + Examples: {builtInInfo.examples} + )} + 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"} /> - {value.tiers[tier].length > 1 && ( - - Multiple models selected — the router randomly picks among them per request (or Thompson-samples - within the pool when adaptive routing is on). +