feat(ui): edit the auto-router tier set with custom classifier-defined tiers
Some checks failed
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

This commit is contained in:
Tin Chi Lo 2026-08-21 14:50:23 -07:00
parent f818a48ae5
commit c62e7f8c8b
17 changed files with 1000 additions and 218 deletions

1
.gitignore vendored
View file

@ -145,3 +145,4 @@ crash.*.log
ui/litellm-dashboard/out/
litellm.log
litellm-round.log

View file

@ -26,6 +26,7 @@ import {
CLASSIFICATION_RUBRIC_KEYS,
ClassificationRubric,
effectiveTierLabel,
effectiveClassifierType,
} from "./ComplexityRouterConfig";
const DEFAULT_SCORING_EXPLANATION =
@ -146,8 +147,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
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<ClassificationMethodConfigProps> = ({
return (
<>
<RadioGroup
value={value.classifier_type}
value={classifierType}
onValueChange={(classifierType: unknown) => handleClassifierTypeChange(classifierType as ClassifierType)}
className="w-full"
>
<div className="flex w-full flex-col items-start gap-2">
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="heuristic" className="mt-0.5" />
<span>
<strong className="font-semibold">Heuristic</strong>{" "}
<span className="text-muted-foreground">
(default) — rule-based scoring, no API calls, &lt;1ms latency
<SimpleTooltip
content={
hasCustomTierSet
? "An edited tier set requires the LLM classifier: the heuristic scorer only produces the built-in tiers"
: undefined
}
>
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
<RadioGroupItem value="heuristic" className="mt-0.5" disabled={hasCustomTierSet} />
<span>
<strong className="font-semibold">Heuristic</strong>{" "}
<span className="text-muted-foreground">
(default) — rule-based scoring, no API calls, &lt;1ms latency
</span>
</span>
</span>
</Label>
</Label>
</SimpleTooltip>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="llm" className="mt-0.5" />
<span>
@ -276,7 +287,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</div>
</RadioGroup>
{value.classifier_type === "llm" && (
{classifierType === "llm" && (
<div className="mt-4 space-y-3">
<div>
<strong className="block mb-1 font-semibold">Classifier Model</strong>
@ -348,13 +359,20 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</div>
<div>
<strong className="block mb-1 font-semibold">Classifier Prompt</strong>
<ClassifierPromptEditor
systemPrompt={value.classifier_llm_config?.system_prompt}
onChange={handleClassifierSystemPromptChange}
contextWindowSize={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
tierLabels={value.tier_labels}
classificationRubric={classificationRubric}
/>
{hasCustomTierSet ? (
<span className="block text-xs text-muted-foreground">
Unavailable with an edited tier set: a replacement prompt would drop the tier definitions the classifier
routes on, along with the injection guard. Your tier definitions are the rubric.
</span>
) : (
<ClassifierPromptEditor
systemPrompt={value.classifier_llm_config?.system_prompt}
onChange={handleClassifierSystemPromptChange}
contextWindowSize={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
tierLabels={value.tier_labels}
classificationRubric={classificationRubric}
/>
)}
</div>
<div>
<strong className="block mb-1 font-semibold">If the classifier fails</strong>

View file

@ -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,110 @@ 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",
},
};
// Remove snapshots in-editor models so Restore returns those; only "Use built-in tiers" exits.
// One pass: row limits, rules following a rename (they point by NAME), and forced-off inputs.
it("edits a custom tier set: rows, keyword rules, and the inputs it forces off", async () => {
const onChange = vi.fn();
const onKeywordTierRulesChange = vi.fn();
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
editingTiers
value={{
...customValue,
plan_mode_min_tier: "COMPLEX",
tier_model_params: { AUDIT: { o: { reasoning_effort: "high" } } },
}}
onChange={onChange}
keywordTierRules={[{ id: "rule-1", keywords: ["scan"], tier: "AUDIT" }]}
onKeywordTierRulesChange={onKeywordTierRulesChange}
/>,
);
const name = screen.getByRole("textbox", { name: "Name for tier 3" });
const definition = screen.getByRole("textbox", { name: "Definition for tier 3" });
expect(name).toHaveValue("AUDIT");
expect(name).toHaveAttribute("maxLength", "64");
expect(definition).toHaveAttribute("maxLength", "500");
expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: /Reasoning effort/ })).not.toBeInTheDocument();
fireEvent.change(definition, { target: { value: "audits\nand\r\nreviews" } });
expect((onChange.mock.calls.at(-1)?.[0] as ComplexityRouterConfigValue).custom_tier_set?.tiers[2].definition).toBe(
"audits and reviews",
);
fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
expect(screen.getByRole("combobox", { name: "Route keyword rule 1 to tier" })).toHaveTextContent("AUDIT");
fireEvent.change(name, { target: { value: "SECREV" } });
expect(onKeywordTierRulesChange).toHaveBeenLastCalledWith([{ id: "rule-1", keywords: ["scan"], tier: "SECREV" }]);
await userEvent.click(screen.getByText("Advanced: Affinity"));
expect(screen.getByLabelText("Pin a session to its first model")).toHaveAttribute("data-disabled");
expect(screen.getByText(/escalation walks the built-in tier ladder/)).toBeInTheDocument();
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByRole("radio", { name: /rule-based scoring/ })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText(/Unavailable with an edited tier set: a replacement prompt/)).toBeInTheDocument();
fireEvent.click(screen.getByText("Advanced: Plan-Mode Override"));
const floor = screen.getByRole("switch", { name: "Route plan-mode requests to a minimum tier" });
expect(floor).toHaveAttribute("aria-disabled", "true");
expect(floor).toBeChecked();
expect(screen.queryByRole("combobox", { name: "Plan-mode minimum tier" })).not.toBeInTheDocument();
});
it("leaves keyword rules alone when the old tier name is shared by another row", () => {
const onKeywordTierRulesChange = vi.fn();
const tiers = customValue.custom_tier_set!.tiers.map((row) => ({ ...row, name: "AUDIT" }));
const shared = { ...customValue, custom_tier_set: { tiers, fallback_tier_id: tiers[0].id } };
const rules = [{ id: "rule-1", keywords: ["scan"], tier: "AUDIT" }];
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
editingTiers
value={shared}
keywordTierRules={rules}
onKeywordTierRulesChange={onKeywordTierRulesChange}
/>,
);
fireEvent.change(screen.getByRole("textbox", { name: "Name for tier 3" }), { target: { value: "SECREV" } });
expect(onKeywordTierRulesChange).not.toHaveBeenCalled();
});
it("removes, restores, and exits the tier set without losing in-editor models", async () => {
const onChange = vi.fn();
const step = (value: ComplexityRouterConfigValue, button: string) => {
onChange.mockClear();
renderWithProviders(<ComplexityRouterConfig {...baseProps} editingTiers value={value} onChange={onChange} />);
return userEvent
.click(screen.getAllByRole("button", { name: button })[0])
.then(() => onChange.mock.calls.at(-1)?.[0] as ComplexityRouterConfigValue);
};
const removed = await step({ ...defaultValue, plan_mode_min_tier: "MEDIUM" }, "Remove the MEDIUM tier");
expect(removed.plan_mode_min_tier).toBeUndefined();
expect(removed.custom_tier_set?.tiers.map((r) => r.id)).toEqual(["SIMPLE", "COMPLEX", "REASONING"]);
expect(removed.custom_tier_set?.fallback_tier_id).toBe("SIMPLE");
const restored = await step(removed, "Restore defaults");
expect(restored.custom_tier_set?.tiers.map((r) => r.id)).toEqual(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
expect(restored.custom_tier_set?.tiers[1].models).toEqual(defaultValue.tiers.MEDIUM);
expect(await step(restored, "Use built-in tiers")).toEqual(defaultValue);
});
});

View file

@ -2,12 +2,25 @@ 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,
MAX_TIER_COUNT,
MIN_TIER_COUNT,
findTierByName,
getCustomTierRowsError,
isBuiltInTierName,
tierNamesMatch,
} from "./custom_tier_set";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
@ -15,6 +28,7 @@ import ClassificationMethodConfig from "./ClassificationMethodConfig";
import {
ReasoningEffort,
TierModelParamsByTier,
customTierDefaultModel,
pruneTierModelParams,
resolveComplexityDefaultModel,
setTierModelReasoningEffort,
@ -27,11 +41,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 { MAX_TIER_COUNT, MIN_TIER_COUNT, 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 MAX_TIER_NAME_CHARS = 64;
export const MAX_TIER_DESCRIPTION_CHARS = 500;
export const DEFAULT_SESSION_AFFINITY = false;
export const DEFAULT_DEPLOYMENT_AFFINITY = true;
@ -123,15 +141,26 @@ 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<Record<keyof ComplexityTiers, string>>;
const defaultModelPlaceholder = (derived: string | undefined, hasCustomTierSet: boolean): string => {
if (derived) return `Derived from tiers: ${derived}`;
return hasCustomTierSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier";
};
/** Derived, never written into the value, so an undone tier edit reverts the form with nothing left behind. */
export const effectiveClassifierType = (
value: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type">,
): 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 +171,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 +202,9 @@ interface ComplexityRouterConfigProps {
modelInfo: ModelGroup[];
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
/** Parent-owned: this component unmounts on section collapse. */
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
@ -229,6 +261,8 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
modelInfo,
value,
onChange,
editingTiers,
onEditingTiersChange,
customTechnicalKeywords,
onCustomTechnicalKeywordsChange,
keywordTierRules = [],
@ -243,12 +277,89 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
onEscalationKeywordsChange,
showValidationErrors = false,
}) => {
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);
// Sole tier-set writer: reconciles both row-id pointers (fallback re-points, dead floor clears).
const commitTierRows = (rows: TierDraft[], fallbackTierId: string, base: ComplexityRouterConfigValue = value) => {
const fallback_tier_id = rows.some((row) => row.id === fallbackTierId)
? fallbackTierId
: (findTierByName(rows, "MEDIUM") ?? rows[0])?.id ?? "";
const floorGone = base.plan_mode_min_tier !== undefined && !rows.some((r) => r.id === base.plan_mode_min_tier);
const floor = floorGone ? { plan_mode_min_tier: undefined } : {};
onChange({ ...base, ...floor, custom_tier_set: { tiers: rows, fallback_tier_id } });
};
const currentFallbackId = customTierSet?.fallback_tier_id ?? "MEDIUM";
const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null;
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);
};
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, tiers: { ...value.tiers, ...Object.fromEntries(TIER_KEYS.map((t) => [t, modelsFor(t)])) } });
};
const addCustomTier = () => {
commitTierRows([...tierRows, { id: crypto.randomUUID(), name: "", definition: "", models: [] }], currentFallbackId);
};
const updateTierRow = (id: string, patch: Partial<Omit<TierDraft, "id">>) => {
const renamedTo = patch.name;
const renamedFrom = tierRows.find((candidate) => candidate.id === id)?.name;
// Skip when another row already answers to the old name: which rules were this row's is unknowable.
const shared = tierRows.some(
(r) => r.id !== id && renamedFrom !== undefined && tierNamesMatch(r.name, renamedFrom),
);
if (renamedTo !== undefined && renamedFrom !== undefined && !shared && onKeywordTierRulesChange) {
onKeywordTierRulesChange(
keywordTierRules.map((rule) => (tierNamesMatch(rule.tier, renamedFrom) ? { ...rule, tier: renamedTo } : rule)),
);
}
commitTierRows(
tierRows.map((candidate) => (candidate.id === id ? { ...candidate, ...patch } : candidate)),
currentFallbackId,
);
};
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 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 +381,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
});
};
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 +416,227 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</span>
<span className="block mb-4 text-xs text-muted-foreground">
Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn&apos;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 appear as-is."
: "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."}
</span>
<Card>
<CardContent>
{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 (
<div key={tier}>
<div key={row.id}>
{index > 0 && <Separator className="my-4" />}
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<strong className="text-base font-semibold">{label} Tier</strong>
<SimpleTooltip content={tierInfo.description}>
<SimpleTooltip
content={
(customTierSet && row.definition.trim()) ||
tierInfo?.description ||
"A tier you defined. The classifier routes requests matching its definition here."
}
>
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
<span className="text-xs text-muted-foreground">
Tier {index + 1} of {TIER_KEYS.length} &middot; {tier}
Tier {index + 1} of {tierRows.length} &middot; {!customTierSet && row.id}
{customTierSet && (isBuiltInTierName(row.name) ? "built-in" : "custom")}
</span>
</div>
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
<InputGroup className="mb-2">
<InputGroupInput
value={value.tier_labels?.[tier] ?? ""}
onChange={(event) => handleTierLabelChange(tier, event.target.value)}
placeholder={`Display name (default: ${tierInfo.label})`}
aria-label={`Display name for the ${tierInfo.label} tier`}
/>
{value.tier_labels?.[tier] && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={`Clear display name for the ${tierInfo.label} tier`}
onClick={() => handleTierLabelChange(tier, "")}
>
<X />
</InputGroupButton>
</InputGroupAddon>
{editingTiers && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive/80"
aria-label={`Remove the ${row.name.trim() || `tier ${index + 1}`} tier`}
disabled={tierRows.length <= MIN_TIER_COUNT}
onClick={() => removeTierRow(row.id)}
>
<Trash2 />
Remove
</Button>
)}
</InputGroup>
</div>
{tierInfo && (
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
)}
{customTierSet && editingTiers && (
<>
<Input
value={row.name}
onChange={(event) => updateTierRow(row.id, { name: event.target.value })}
placeholder="Tier name, e.g. SECURITY_REVIEW"
aria-label={`Name for tier ${index + 1}`}
maxLength={MAX_TIER_NAME_CHARS}
className={nameMissing ? "mb-2 border-destructive" : "mb-2"}
/>
<Textarea
value={row.definition}
onChange={(event) =>
updateTierRow(row.id, { definition: event.target.value.replace(/[\r\n]+/g, " ") })
}
maxLength={MAX_TIER_DESCRIPTION_CHARS}
placeholder={
isBuiltInTierName(row.name)
? "Leave blank to keep the built-in definition"
: "What belongs in this tier, e.g. requests asking for a security audit or vulnerability review"
}
aria-label={`Definition for tier ${index + 1}`}
rows={2}
className={definitionMissing ? "mb-2 border-destructive" : "mb-2"}
/>
{definitionMissing && (
<span className="block mb-2 text-xs text-destructive">
A definition is required: it is the rubric the classifier uses for this tier
</span>
)}
</>
)}
{!customTierSet && (
<InputGroup className="mb-2">
<InputGroupInput
value={value.tier_labels?.[row.id as keyof ComplexityTiers] ?? ""}
onChange={(event) => handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)}
placeholder={`Display name (default: ${tierInfo?.label})`}
aria-label={`Display name for the ${tierInfo?.label} tier`}
/>
{value.tier_labels?.[row.id as keyof ComplexityTiers] && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={`Clear display name for the ${tierInfo?.label} tier`}
onClick={() => handleTierLabelChange(row.id as keyof ComplexityTiers, "")}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
)}
<MultiSelect
options={modelOptions}
value={value.tiers[tier]}
onValueChange={(models: string[]) => handleTierChange(tier, models)}
value={row.models}
onValueChange={(models: string[]) =>
customTierSet
? updateTierRow(row.id, { models })
: handleTierChange(row.id as keyof ComplexityTiers, models)
}
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
emptyText="No models found"
className={tierMissing ? "w-full border-destructive" : "w-full"}
className={modelsMissing ? "w-full border-destructive" : "w-full"}
/>
<TierModelEffortRows
tierLabel={label}
models={value.tiers[tier]}
reasoningModels={reasoningModels}
paramsByModel={value.tier_model_params?.[tier]}
onEffortChange={(model, effort) => handleTierModelEffortChange(tier, model, effort)}
/>
{value.tiers[tier].length > 1 && (
{!customTierSet && (
<TierModelEffortRows
tierLabel={label}
models={row.models}
reasoningModels={reasoningModels}
paramsByModel={value.tier_model_params?.[row.name.trim()]}
onEffortChange={(model, effort) => handleTierModelEffortChange(row.name.trim(), model, effort)}
/>
)}
{row.models.length > 1 && (
<span className="text-xs text-muted-foreground">
Multiple models selected — the router randomly picks among them per request (or Thompson-samples
within the pool when adaptive routing is on).
</span>
)}
{tierMissing && <span className="text-xs text-destructive">The {label} tier is required</span>}
{modelsMissing && <span className="text-xs text-destructive">The {label} tier is required</span>}
</div>
</div>
);
})}
<div className="mb-4">
<div className="flex flex-wrap items-center gap-2">
{editingTiers ? (
<>
<Button variant="outline" onClick={addCustomTier} disabled={tierRows.length >= MAX_TIER_COUNT}>
<Plus />
Add tier
</Button>
<SimpleTooltip content={tierRowsError || undefined}>
<Button
variant="outline"
disabled={Boolean(tierRowsError)}
onClick={() => onEditingTiersChange?.(false)}
>
Done
</Button>
</SimpleTooltip>
{customTierSet && (
<SimpleTooltip content="Return to the built-in tier ladder; settings that need tier definitions are dropped.">
<Button variant="outline" size="sm" onClick={exitToBuiltInTiers}>
Use built-in tiers
</Button>
</SimpleTooltip>
)}
{customTierSet && TIER_KEYS.some((tier) => !tierRows.some((row) => row.id === tier)) && (
<Button variant="outline" size="sm" onClick={restoreDefaultTiers}>
Restore defaults
</Button>
)}
</>
) : (
onEditingTiersChange && (
<Button variant="outline" onClick={() => onEditingTiersChange(true)}>
Edit tiers
</Button>
)
)}
</div>
{editingTiers && (
<span className="block mt-1 text-xs text-muted-foreground">
Add or remove tiers to define your own tier set. Every new tier needs a definition the LLM classifier
uses to route to it; editing the set requires the LLM classification method and disables escalation,
adaptive selection, session pinning, and display names.
</span>
)}
</div>
{customTierSet && (
<div className="mb-2">
<div className="flex items-center gap-2 mb-2">
<strong className="text-base font-semibold">Fallback Tier</strong>
<SimpleTooltip content="Where requests route when the LLM classifier errors, times out, or returns an unparseable reply. Required for an edited tier set: the heuristic scorer cannot produce your tiers.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
</div>
<Select
items={customTierSet.tiers
.filter((row) => row.name.trim())
.map((row) => ({ value: row.id, label: row.name.trim() }))}
value={customTierSet.fallback_tier_id || null}
onValueChange={(fallbackTierId: string | null) =>
fallbackTierId && commitTierRows(customTierSet.tiers, fallbackTierId)
}
>
<SelectTrigger aria-label="Fallback tier" className="w-full">
<SelectValue placeholder="Pick the tier classifier failures route to" />
</SelectTrigger>
<SelectContent>
{customTierSet.tiers
.filter((row) => row.name.trim())
.map((row) => (
<SelectItem key={row.id} value={row.id}>
{row.name.trim()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Separator className="my-4" />
<div className="mb-2">
@ -393,11 +650,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
options={modelOptions}
value={value.default_model ?? ""}
onValueChange={handleDefaultModelChange}
placeholder={
derivedDefaultModel
? `Derived from tiers: ${derivedDefaultModel}`
: "Add a model to the Simple or Medium tier"
}
placeholder={defaultModelPlaceholder(derivedDefaultModel, Boolean(customTierSet))}
emptyText="No models found"
aria-label="Default model"
/>
@ -431,7 +684,14 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
{
key: "adaptive",
label: <strong className="text-foreground font-semibold">Advanced: Adaptive Routing</strong>,
children: <AdaptiveRoutingConfig value={value} onChange={onChange} />,
children: customTierSet ? (
<span className="text-sm text-muted-foreground">
Adaptive routing is unavailable with an edited tier set: it scores models along the built-in tier
ladder, which your tier set replaces.
</span>
) : (
<AdaptiveRoutingConfig value={value} onChange={onChange} />
),
},
{
key: "affinity",
@ -454,15 +714,17 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</span>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
checked={customTierSet ? false : value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
disabled={Boolean(customTierSet)}
onCheckedChange={(sessionAffinity) => onChange({ ...value, session_affinity: sessionAffinity })}
aria-label="Pin a session to its first model"
/>
<strong className="font-semibold">Pin a session to its first model</strong>
</div>
<span className="block text-xs text-muted-foreground">
Keeps a session on its first turn&apos;s model instead of re-classifying each turn. Also pins the
deployment.
{customTierSet
? "Unavailable with an edited tier set: escalation walks the built-in tier ladder."
: "Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment."}
</span>
</>
),
@ -475,7 +737,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.plan_mode_min_tier !== undefined}
disabled={planModeTiers.length === 0}
disabled={Boolean(customTierSet) || planModeTiers.length === 0}
onCheckedChange={(enabled) =>
onChange({ ...value, plan_mode_min_tier: enabled ? planModeTiers.at(-1) : undefined })
}
@ -484,11 +746,18 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<strong className="font-semibold">Route plan-mode requests to a minimum tier</strong>
</div>
<span className="block text-xs mb-3 text-muted-foreground">
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."}
{customTierSet ? (
"Locked while a tier set is edited; a saved floor still applies while its tier row exists."
) : (
<>
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."}
</>
)}
</span>
{value.plan_mode_min_tier !== undefined && (
{!customTierSet && value.plan_mode_min_tier !== undefined && (
<div style={{ maxWidth: 320 }}>
<Select
items={planModeTierOptions}
@ -537,7 +806,14 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
{
key: "escalation",
label: <strong className="text-foreground font-semibold">Advanced: Escalation Keywords</strong>,
children: <EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />,
children: customTierSet ? (
<span className="text-sm text-muted-foreground">
Escalation keywords are unavailable with an edited tier set: they bump requests along the built-in
tier ladder, which your tier set replaces.
</span>
) : (
<EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />
),
},
]
: []),
@ -553,6 +829,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
rules={keywordTierRules}
onChange={onKeywordTierRulesChange}
tierLabels={value.tier_labels}
tierNames={customTierSet?.tiers.map((row) => row.name.trim()).filter(Boolean)}
/>
)}
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}

View file

@ -7,14 +7,13 @@ import { Button } from "@/components/ui/button";
import React from "react";
import { emptyKeywordTierRuleIndexes } from "./complexity_router_keywords";
import { tierOptions } from "./complexity_router_tiers";
import { defaultRuleTier, tierOptions } from "./complexity_router_tiers";
export type ComplexityTier = "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
export interface KeywordTierRule {
id: string;
keywords: string[];
/** A built-in tier name, or with a custom tier set, one of the defined tier names. */
tier: string;
}
@ -22,12 +21,10 @@ interface KeywordTierRulesProps {
rules: KeywordTierRule[];
onChange: (rules: KeywordTierRule[]) => void;
tierLabels?: Partial<Record<ComplexityTier, string>>;
tierNames?: string[];
}
// 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.
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, tierLabels }) => {
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, tierLabels, tierNames }) => {
const emptyRuleIndexes = new Set(emptyKeywordTierRuleIndexes(rules));
const replaceKeywords = (rule: KeywordTierRule) => (keywords: string[]) => {
@ -35,7 +32,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
};
const addRule = () => {
onChange([...rules, { id: `${Date.now()}`, keywords: [], tier: "COMPLEX" }]);
onChange([...rules, { id: `${Date.now()}`, keywords: [], tier: defaultRuleTier(tierNames) }]);
};
const updateRule = (id: string, updates: Partial<Omit<KeywordTierRule, "id">>) => {
@ -98,7 +95,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
<div style={{ width: 220 }}>
<strong className="mb-2 block font-semibold">Route to tier</strong>
<Select
items={tierOptions(tierLabels)}
items={tierOptions(tierLabels, tierNames)}
value={rule.tier}
onValueChange={(tier: string | null) => tier && updateRule(rule.id, { tier })}
>
@ -106,7 +103,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
<SelectValue />
</SelectTrigger>
<SelectContent>
{tierOptions(tierLabels).map((option) => (
{tierOptions(tierLabels, tierNames).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>

View file

@ -22,11 +22,11 @@ import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels";
import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
ComplexityTiers,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
DEFAULT_DEPLOYMENT_AFFINITY,
DEFAULT_TIER_DISTANCE_PENALTY,
effectiveClassifierType,
} from "./ComplexityRouterConfig";
import { KeywordTierRule } from "./KeywordTierRules";
import { DEFAULT_ESCALATION_KEYWORDS } from "./EscalationKeywords";
@ -39,8 +39,14 @@ import {
getPlanModeTierError,
getSemanticConfigError,
getTierLabelsError,
getCustomTierRowsError,
} from "./build_complexity_router_config";
import { resolveComplexityDefaultModel } from "./complexity_router_tiers";
import {
customTierDefaultModel,
DEFAULT_TIER_LABELS,
TIER_ORDER,
resolveComplexityDefaultModel,
} from "./complexity_router_tiers";
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
import AutoRouterConnectionTest from "./auto_router_connection_test";
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
@ -102,19 +108,12 @@ const isPresetHintAlarming = (availability: PresetAvailability): boolean => avai
// this is resolved once at import time rather than re-called from inside the component every render.
const presets = getAllPresets();
// A one-line summary of what's configured, shown when the detailed section is collapsed so a
// caller can see the shape of the config without opening it.
const tierConfigSummary = (tiers: ComplexityTiers): string => {
const parts = (
[
["Simple", tiers.SIMPLE],
["Medium", tiers.MEDIUM],
["Complex", tiers.COMPLEX],
["Reasoning", tiers.REASONING],
] as const
)
const TIER_SUMMARY_LABELS: Record<string, string> = DEFAULT_TIER_LABELS;
const tierConfigSummary = (rows: [string, string[]][]): string => {
const parts = rows
.filter(([, models]) => models.length > 0)
.map(([label, models]) => `${label}: ${models.join(", ")}`);
.map(([tier, models]) => `${TIER_SUMMARY_LABELS[tier] ?? tier}: ${models.join(", ")}`);
return parts.length > 0 ? parts.join(" · ") : "No tiers configured yet";
};
@ -128,10 +127,12 @@ const getSubmitBlockedReason = (
referencedModelsParams: Parameters<typeof getReferencedModelsError>[0],
availability: ModelAvailability,
): string | null =>
getMissingTiersError(config.tiers) ??
getTierLabelsError(config.tier_labels) ??
getPlanModeTierError(config.plan_mode_min_tier, config.tiers) ??
getKeywordTierRulesError(keywordTierRules) ??
(config.custom_tier_set
? getCustomTierRowsError(config.custom_tier_set)
: getMissingTiersError(config.tiers) ??
getTierLabelsError(config.tier_labels) ??
getPlanModeTierError(config.plan_mode_min_tier, config.tiers)) ??
getKeywordTierRulesError(keywordTierRules, config.custom_tier_set?.tiers.map((r) => r.name) ?? TIER_ORDER) ??
getReferencedModelsError(referencedModelsParams, availability);
const autoRouterSchema = (requiresTeamScope: boolean) =>
@ -200,6 +201,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const [matchThreshold, setMatchThreshold] = useState<number>(DEFAULT_MATCH_THRESHOLD);
const [escalationKeywords, setEscalationKeywords] = useState<string[]>(DEFAULT_ESCALATION_KEYWORDS);
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
const [editingTiers, setEditingTiers] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(undefined);
// Closed by default: a caller opens it deliberately, either by clicking it or by choosing Custom
@ -299,6 +301,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
);
const applyPrefill = (prefill: PresetPrefill) => {
setEditingTiers(false);
setComplexityRouterConfig(prefill.complexityRouterConfig);
setCustomTechnicalKeywords(prefill.customTechnicalKeywords);
setKeywordTierRules(prefill.keywordTierRules);
@ -328,9 +331,17 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
setDetailsExpanded(presetState.viaDeployments);
};
const customTierSet = complexityRouterConfig.custom_tier_set;
// `tiers` is a stale built-in shadow while a set is edited, so read the rows the payload will carry.
const tierEntries: [string, string[]][] = customTierSet
? customTierSet.tiers.map((row) => [row.name.trim() || "New tier", row.models])
: TIER_ORDER.map((tier) => [tier, complexityRouterConfig.tiers[tier]]);
const activeDefaultModel = customTierSet
? customTierDefaultModel(customTierSet, complexityRouterConfig.default_model)
: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model);
const referencedModelsParams = {
tiers: complexityRouterConfig.tiers,
classifierType: complexityRouterConfig.classifier_type,
tiers: Object.fromEntries(tierEntries),
classifierType: effectiveClassifierType(complexityRouterConfig),
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
semanticMatchingEnabled,
embeddingModel,
@ -346,6 +357,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
tiers: complexityRouterConfig.tiers,
customTierSet,
defaultModel: complexityRouterConfig.default_model,
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
tierLabels: complexityRouterConfig.tier_labels,
@ -376,29 +388,32 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
};
const submitRecommendedRouter = async (name: string) => {
const { tiers, tierLabels, classifierType, classifierLlmConfig } = complexityRouterConfigParams;
const { tiers, tierLabels, classifierLlmConfig } = complexityRouterConfigParams;
const classifierType = effectiveClassifierType(complexityRouterConfig);
const missingTiersError = getMissingTiersError(tiers);
if (missingTiersError) {
const tierSetError = customTierSet
? getCustomTierRowsError(customTierSet)
: getMissingTiersError(tiers) ?? getTierLabelsError(tierLabels);
if (tierSetError) {
setShowValidationErrors(true);
toast.fromError(missingTiersError);
return;
}
const tierLabelsError = getTierLabelsError(tierLabels);
if (tierLabelsError) {
setShowValidationErrors(true);
toast.fromError(tierLabelsError);
toast.fromError(tierSetError);
return;
}
if (classifierType === "llm" && !classifierLlmConfig?.model) {
setShowValidationErrors(true);
toast.fromError("Please select a classifier model, or switch back to Heuristic");
toast.fromError(
customTierSet
? "Select a classifier model: an edited tier set routes with the LLM classifier"
: "Please select a classifier model, or switch back to Heuristic",
);
return;
}
const keywordRulesError = getKeywordTierRulesError(keywordTierRules);
const keywordRulesError = getKeywordTierRulesError(
keywordTierRules,
tierEntries.map(([name]) => name),
);
if (keywordRulesError) {
setShowValidationErrors(true);
toast.fromError(keywordRulesError);
@ -423,7 +438,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
return;
}
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
const defaultModel = activeDefaultModel;
const validatedFields = requiresTeamScope
? (["auto_router_name", "team_id"] as const)
: (["auto_router_name"] as const);
@ -433,6 +448,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
return;
}
const builtConfig = buildComplexityRouterConfig(complexityRouterConfigParams);
// auto_router_default_model (-> litellm_params, read by the backend at init) and
// complexity_router_config.default_model (-> the pin marker read back on edit, see
// hydratePinnedDefaultModel in edit_auto_router_modal.tsx) must both come from the same
@ -442,11 +459,11 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
...teamScopePayload(requiresTeamScope, form.getValues("team_id")),
auto_router_default_model: defaultModel,
model_type: "complexity_router",
complexity_router_config: buildComplexityRouterConfig(complexityRouterConfigParams),
complexity_router_config: builtConfig,
model_access_group: form.getValues("model_access_group"),
};
handleAddAutoRouterSubmit(submitValues, accessToken, () => form.reset(EMPTY_FORM_VALUES), handleOk);
await handleAddAutoRouterSubmit(submitValues, accessToken, () => form.reset(EMPTY_FORM_VALUES), handleOk);
};
const handleAutoRouterSubmit = async () => {
@ -463,10 +480,10 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const handleTestConnection = () => {
const testTargetParams = {
tiers: complexityRouterConfig.tiers,
tiers: tierEntries,
semanticMatchingEnabled,
embeddingModel,
defaultModel: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model),
defaultModel: activeDefaultModel,
};
const targets = buildAutoRouterTestTargets(testTargetParams);
@ -580,14 +597,14 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
Detailed Configuration
</span>
{!detailsExpanded && (
<span className="text-xs text-muted-foreground line-clamp-2">
{tierConfigSummary(complexityRouterConfig.tiers)}
</span>
<span className="text-xs text-muted-foreground line-clamp-2">{tierConfigSummary(tierEntries)}</span>
)}
</button>
{detailsExpanded && (
<div className="px-4 pb-4">
<ComplexityRouterConfig
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
modelInfo={modelInfo}
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
@ -694,10 +711,11 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
<AutoRouterRoutingTest
accessToken={accessToken}
config={buildComplexityRouterConfig(complexityRouterConfigParams)}
defaultModel={resolveComplexityDefaultModel(
complexityRouterConfig.tiers,
complexityRouterConfig.default_model,
)}
defaultModel={
customTierSet
? customTierDefaultModel(customTierSet, complexityRouterConfig.default_model)
: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model)
}
routerName={watchedName}
teamId={requiresTeamScope ? watchedTeamId : undefined}
/>
@ -707,7 +725,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
<Button variant="outline" onClick={() => setIsRoutingTestVisible(false)}>
Close
</Button>
, ]
</DialogFooter>
</DialogContent>
</Dialog>
@ -744,7 +761,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
>
Close
</Button>
, ]
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -1,11 +1,13 @@
import { buildAutoRouterTestTargets } from "./build_auto_router_test_targets";
const tiers = {
const entries = (tiers: Record<string, string[]>): [string, string[]][] => Object.entries(tiers);
const tiers = entries({
SIMPLE: ["gpt-4o-mini"],
MEDIUM: ["claude-sonnet-4"],
COMPLEX: ["claude-sonnet-4"],
REASONING: ["o3"],
};
});
describe("buildAutoRouterTestTargets", () => {
it("dedups tiers that share a model group into one chat target carrying both labels", () => {
@ -19,7 +21,12 @@ describe("buildAutoRouterTestTargets", () => {
it("emits a target per model when a tier has more than one, and dedups across tiers", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini", "claude-sonnet-4"], MEDIUM: ["claude-sonnet-4"], COMPLEX: [], REASONING: [] },
tiers: entries({
SIMPLE: ["gpt-4o-mini", "claude-sonnet-4"],
MEDIUM: ["claude-sonnet-4"],
COMPLEX: [],
REASONING: [],
}),
semanticMatchingEnabled: false,
embeddingModel: undefined,
});
@ -29,9 +36,21 @@ describe("buildAutoRouterTestTargets", () => {
]);
});
it("probes custom tier pools under their own labels, in entry order", () => {
const targets = buildAutoRouterTestTargets({
tiers: entries({ CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["claude-opus-4", "gpt-4o-mini"] }),
semanticMatchingEnabled: false,
embeddingModel: undefined,
});
expect(targets).toEqual([
{ labels: ["CASUAL", "SECURITY_REVIEW"], modelGroup: "gpt-4o-mini", mode: "chat" },
{ labels: ["SECURITY_REVIEW"], modelGroup: "claude-opus-4", mode: "chat" },
]);
});
it("drops empty/whitespace tiers", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [" "], REASONING: [] },
tiers: entries({ SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [" "], REASONING: [] }),
semanticMatchingEnabled: false,
embeddingModel: undefined,
});
@ -41,7 +60,7 @@ describe("buildAutoRouterTestTargets", () => {
it("returns [] when no tier is configured", () => {
expect(
buildAutoRouterTestTargets({
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
tiers: entries({ SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }),
semanticMatchingEnabled: false,
embeddingModel: undefined,
}),
@ -50,7 +69,7 @@ describe("buildAutoRouterTestTargets", () => {
it("appends an embedding target only when semantic matching is on and a model is set", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
tiers: entries({ SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }),
semanticMatchingEnabled: true,
embeddingModel: "voyage-3-5",
});
@ -62,7 +81,7 @@ describe("buildAutoRouterTestTargets", () => {
it("omits the embedding target when semantic matching is on but no model is chosen", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
tiers: entries({ SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }),
semanticMatchingEnabled: true,
embeddingModel: undefined,
});
@ -71,7 +90,7 @@ describe("buildAutoRouterTestTargets", () => {
it("omits the embedding target when a model is set but semantic matching is off", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
tiers: entries({ SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }),
semanticMatchingEnabled: false,
embeddingModel: "voyage-3-5",
});
@ -112,7 +131,7 @@ describe("buildAutoRouterTestTargets", () => {
it.each([[undefined], [""], [" "]])("adds no default target for %o", (defaultModel) => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
tiers: entries({ SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }),
semanticMatchingEnabled: false,
embeddingModel: undefined,
defaultModel,

View file

@ -1,5 +1,3 @@
import { ComplexityTiers } from "./ComplexityRouterConfig";
export type AutoRouterTestMode = "chat" | "embedding";
export interface AutoRouterTestTarget {
@ -9,40 +7,28 @@ export interface AutoRouterTestTarget {
}
export interface BuildAutoRouterTestTargetsParams {
tiers: ComplexityTiers;
/** Ordered [tier name, model groups] entries of the active tier set. */
tiers: [string, string[]][];
semanticMatchingEnabled: boolean;
embeddingModel: string | undefined;
/** The resolved default model - see resolveComplexityDefaultModel. A live fallback destination,
* so it is probed even when no tier lists it. */
defaultModel?: string;
}
// Keys drive iteration order; `satisfies Record<keyof ComplexityTiers, null>` makes it a
// compile error to add a tier to ComplexityTiers without listing it here (and vice versa).
const TIER_ORDER = Object.keys({
SIMPLE: null,
MEDIUM: null,
COMPLEX: null,
REASONING: null,
} satisfies Record<keyof ComplexityTiers, null>) as (keyof ComplexityTiers)[];
export const buildAutoRouterTestTargets = ({
tiers,
semanticMatchingEnabled,
embeddingModel,
defaultModel,
}: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => {
const tieredByModel = TIER_ORDER.reduce<Record<string, string[]>>((acc, tier) => {
return (tiers[tier] ?? []).reduce((tierAcc, rawModel) => {
const tieredByModel = tiers.reduce<Record<string, string[]>>((acc, [tier, models]) => {
return models.reduce((tierAcc, rawModel) => {
const modelGroup = rawModel?.trim();
if (!modelGroup) return tierAcc;
return { ...tierAcc, [modelGroup]: [...(tierAcc[modelGroup] ?? []), tier] };
}, acc);
}, {});
// The default is a live destination whenever the chosen tier has no model, and when an LLM
// classifier fails with "Route to the default model", so a green test that skipped it would be
// reporting on a router it had not fully reached.
// Classifier failures land on the default, so it is probed even when no tier lists it.
const resolvedDefault = defaultModel?.trim();
const groupedByModel =
resolvedDefault && !(resolvedDefault in tieredByModel)

View file

@ -8,6 +8,9 @@ import {
getTierLabelsError,
hydrateTierLabels,
BuildComplexityRouterConfigParams,
getCustomTierRowsError,
hydrateCustomTierSet,
hydratePlanModeMinTier,
} from "./build_complexity_router_config";
const tiers = {
@ -673,3 +676,102 @@ describe("buildComplexityRouterConfig tier model params", () => {
});
});
});
const TIER_KEYS = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
describe("custom tier sets", () => {
const customTierSet = {
tiers: [
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-4o-mini"] },
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: ["claude-sonnet-4"] },
{ id: "sec", name: "SECURITY_REVIEW", definition: "security audits", models: ["claude-sonnet-5"] },
],
fallback_tier_id: "COMPLEX",
};
const customParams: BuildComplexityRouterConfigParams = {
...baseParams,
customTierSet,
classifierLlmConfig: {
model: "haiku-classifier",
timeout_ms: 400,
classification_rubric: "agentic",
system_prompt: "x",
},
tierLabels: { SIMPLE: "Cheap" },
classifierFallback: "default_model",
sessionAffinity: true,
escalationKeywords: ["GO UP"],
adaptive: true,
planModeMinTier: "sec",
tierBoundaries: { simple_medium: 0.2, medium_complex: 0.4, complex_reasoning: 0.6 },
};
it("serializes rows in order and forces off everything the backend rejects beside tier_definitions", () => {
const config = buildComplexityRouterConfig(customParams);
expect(config).toEqual({
tiers: { SIMPLE: ["gpt-4o-mini"], COMPLEX: ["claude-sonnet-4"], SECURITY_REVIEW: ["claude-sonnet-5"] },
tier_definitions: [
{ name: "SIMPLE" },
{ name: "COMPLEX" },
{ name: "SECURITY_REVIEW", description: "security audits" },
],
fallback_tier: "COMPLEX",
classifier_type: "llm",
classifier_llm_config: { model: "haiku-classifier", timeout_ms: 400 },
session_affinity: false,
deployment_affinity: true,
escalation_keywords: [],
plan_mode_min_tier: "SECURITY_REVIEW",
});
});
// A hand-written config keys tiers/fallback_tier in any case; the backend resolves with casefold.
it("hydrates a hand-written config in stored order, matching names case-insensitively", () => {
const hydrated = hydrateCustomTierSet({
tier_definitions: [{ name: "audit", description: "a" }, { name: "simple" }],
tiers: { Audit: "single-model-pin", simple: ["m1"] },
fallback_tier: "AUDIT",
});
expect(hydrated?.tiers).toEqual([
{ id: "stored-0", name: "audit", definition: "a", models: ["single-model-pin"] },
{ id: "SIMPLE", name: "simple", definition: "", models: ["m1"] },
]);
expect(hydrated?.fallback_tier_id).toBe("stored-0");
expect(hydrateCustomTierSet({ tiers: { SIMPLE: ["m"] } })).toBeUndefined();
});
it("getCustomTierRowsError reports the first per-row gap and passes a complete set", () => {
const rows = customTierSet.tiers;
const withRows = (tiers: typeof rows) => getCustomTierRowsError({ ...customTierSet, tiers });
expect(getCustomTierRowsError(customTierSet)).toBeNull();
expect(withRows(Array.from({ length: 9 }, (_, i) => ({ ...rows[2], id: `r${i}`, name: `T${i}` })))).toContain("8");
expect(withRows([rows[2]])).toContain("2 to 8");
expect(withRows([{ id: "a", name: " ", definition: "d", models: ["m"] }, rows[0]])).toBe("Name every tier");
expect(withRows([{ id: "a", name: "A", definition: " ", models: ["m"] }, rows[0]])).toContain("definition");
expect(withRows([...rows, { id: "new-1", name: "DRAFT", definition: "d", models: [] }])).toContain("model");
expect(getCustomTierRowsError({ ...customTierSet, fallback_tier_id: "gone" })).toContain("Fallback");
expect(withRows([{ ...rows[0], id: "dup", name: ` ${rows[0].name.toLowerCase()} ` }, ...rows])).toBe(
"Tier names must be unique",
);
// A rule targets a tier by NAME, so an edit orphans it and the backend rejects the whole config.
const names = rows.map((row) => row.name);
const rules = [
{ id: "a", keywords: ["audit"], tier: "security_review" },
{ id: "b", keywords: ["gone"], tier: "REMOVED_TIER" },
];
expect(getKeywordTierRulesError(rules, names)).toContain("2");
expect(getKeywordTierRulesError([rules[0]], names)).toBeNull();
expect(getKeywordTierRulesError([rules[0]], TIER_KEYS)).toContain("1");
});
// The floor is a row id on the form and a name on the wire; unresolvable means OFF at every layer.
it("maps the plan-mode floor between row id and name, and drops it when the row is gone", () => {
expect(hydratePlanModeMinTier("SECURITY_REVIEW", customTierSet)).toBe("sec");
expect(hydratePlanModeMinTier("security_review", customTierSet)).toBe("sec");
expect(hydratePlanModeMinTier("NOT_A_TIER", customTierSet)).toBeUndefined();
expect(hydratePlanModeMinTier("COMPLEX", undefined)).toBe("COMPLEX");
expect(buildComplexityRouterConfig({ ...customParams, planModeMinTier: "gone" })).not.toHaveProperty(
"plan_mode_min_tier",
);
});
});

View file

@ -1,6 +1,12 @@
import { KeywordTierRule } from "./KeywordTierRules";
import { findTierByName, tierNamesMatch } from "./custom_tier_set";
import { emptyKeywordTierRuleIndexes, serializeKeywordTierRules } from "./complexity_router_keywords";
import { TierModelParams, TierModelParamsByTier, serializeTierModelConfigs } from "./complexity_router_tiers";
import {
TierModelParams,
TierModelParamsByTier,
normalizeTierModels,
serializeTierModelConfigs,
} from "./complexity_router_tiers";
import {
AdaptiveEligible,
AdaptiveRouterWeights,
@ -9,8 +15,10 @@ import {
ClassifierType,
ComplexityTierLabels,
ComplexityTiers,
CustomTierSet,
DimensionWeights,
TIER_DESCRIPTIONS,
TIER_KEYS,
TierBoundaries,
TokenThresholds,
effectiveTierLabel,
@ -74,6 +82,7 @@ const scorerKnobPayload = ({
export interface BuildComplexityRouterConfigParams {
tiers: ComplexityTiers;
customTierSet?: CustomTierSet;
defaultModel: string | undefined;
planModeMinTier: string | undefined;
tierLabels: ComplexityTierLabels | undefined;
@ -103,8 +112,15 @@ export interface BuildComplexityRouterConfigParams {
tierModelParams?: TierModelParamsByTier;
}
export interface TierDefinitionPayload {
name: string;
description?: string;
}
export interface ComplexityRouterConfigPayload {
tiers: ComplexityTiers;
tiers: ComplexityTiers | Record<string, string[]>;
tier_definitions?: TierDefinitionPayload[];
fallback_tier?: string;
default_model?: string;
plan_mode_min_tier?: string;
tier_labels?: ComplexityTierLabels;
@ -134,8 +150,6 @@ export interface ComplexityRouterConfigPayload {
tier_model_configs?: Record<string, { model_name: string; litellm_params: TierModelParams }[]>;
}
const TIER_KEYS: Array<keyof ComplexityTiers> = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
export const serializeTierLabels = (tierLabels: ComplexityTierLabels | undefined): ComplexityTierLabels | undefined => {
const renamed = TIER_KEYS.map((tier) => [tier, tierLabels?.[tier]?.trim() ?? ""] as const).filter(
([tier, label]) => label !== "" && label !== TIER_DESCRIPTIONS[tier].label,
@ -192,10 +206,20 @@ export const getPlanModeTierError = (planModeMinTier: string | undefined, tiers:
return `The plan-mode minimum tier (${planModeMinTier}) has no models. Add one or turn the override off.`;
};
export const getKeywordTierRulesError = (keywordTierRules: KeywordTierRule[]): string | null => {
// Rules point at a tier by NAME, so a tier edit can orphan one and the backend rejects the config.
export const getKeywordTierRulesError = (
keywordTierRules: KeywordTierRule[],
activeTierNames?: readonly string[],
): string | null => {
const emptyRows = emptyKeywordTierRuleIndexes(keywordTierRules);
if (emptyRows.length === 0) return null;
return `Add at least one keyword to keyword rule(s): ${emptyRows.map((index) => index + 1).join(", ")}`;
if (emptyRows.length > 0)
return `Add at least one keyword to keyword rule(s): ${emptyRows.map((index) => index + 1).join(", ")}`;
if (!activeTierNames) return null;
const orphaned = keywordTierRules.flatMap((rule, index) =>
activeTierNames.some((name) => tierNamesMatch(name, rule.tier)) ? [] : [index + 1],
);
if (orphaned.length === 0) return null;
return `Keyword rule(s) ${orphaned.join(", ")} route to a tier this router no longer has`;
};
export const getSemanticConfigError = ({
@ -211,8 +235,100 @@ export const getSemanticConfigError = ({
return null;
};
// Mirrors validate_complexity_router_config_write (litellm/router_utils/auto_router_model_naming.py).
export const KEYS_REJECTED_WITH_CUSTOM_TIERS: readonly string[] = [
"plugins",
"tier_labels",
"classifier_fallback",
"adaptive",
"adaptive_weights",
"tier_distance_penalty",
"adaptive_eligible",
"tier_boundaries",
"token_thresholds",
"dimension_weights",
"reasoning_override_min_score",
"escalation_keywords",
"session_affinity",
];
export const serializeCustomTierSet = (
customTierSet: CustomTierSet,
): Pick<ComplexityRouterConfigPayload, "tiers" | "tier_definitions" | "fallback_tier"> => {
const fallbackName = customTierSet.tiers.find((row) => row.id === customTierSet.fallback_tier_id)?.name.trim();
return {
tiers: Object.fromEntries(customTierSet.tiers.map((row) => [row.name.trim(), row.models] as const)),
tier_definitions: customTierSet.tiers.map((row) => ({
name: row.name.trim(),
...(row.definition.trim() && { description: row.definition.trim() }),
})),
...(fallbackName && { fallback_tier: fallbackName }),
};
};
// A built-in name keeps its canonical key as the row id so Restore recognizes it.
export const hydrateCustomTierSet = (parsedConfig: {
tier_definitions?: unknown;
fallback_tier?: unknown;
tiers?: unknown;
}): CustomTierSet | undefined => {
if (!Array.isArray(parsedConfig.tier_definitions) || parsedConfig.tier_definitions.length === 0) return undefined;
const storedTiers =
typeof parsedConfig.tiers === "object" && parsedConfig.tiers !== null && !Array.isArray(parsedConfig.tiers)
? (parsedConfig.tiers as Record<string, unknown>)
: {};
const rows = parsedConfig.tier_definitions.flatMap((entry, index): CustomTierSet["tiers"] => {
if (typeof entry !== "object" || entry === null) return [];
const { name, description } = entry as { name?: unknown; description?: unknown };
if (typeof name !== "string" || !name.trim()) return [];
return [
{
id: TIER_KEYS.find((tier) => tierNamesMatch(tier, name)) ?? `stored-${index}`,
name: name.trim(),
definition: typeof description === "string" ? description.trim() : "",
models: normalizeTierModels(Object.entries(storedTiers).find(([tier]) => tierNamesMatch(tier, name))?.[1]),
},
];
});
if (rows.length === 0) return undefined;
const storedFallback = typeof parsedConfig.fallback_tier === "string" ? parsedConfig.fallback_tier.trim() : "";
return { tiers: rows, fallback_tier_id: findTierByName(rows, storedFallback)?.id ?? "" };
};
// The floor is a ROW ID on the value; one rule at every layer: unresolvable means OFF.
export const hydratePlanModeMinTier = (
stored: unknown,
customTierSet: CustomTierSet | undefined,
): string | undefined => {
if (typeof stored !== "string" || stored.trim() === "") return undefined;
if (!customTierSet) return stored;
return findTierByName(customTierSet.tiers, stored)?.id;
};
// Everything an edited tier set forces onto the wire; shared by both builders.
export const customTierSetWireFields = (
customTierSet: CustomTierSet,
classifierLlmConfig: ClassifierLLMConfig | undefined,
planModeMinTierId: string | undefined,
) => {
const planModeName = customTierSet.tiers.find((row) => row.id === planModeMinTierId)?.name.trim();
return {
...serializeCustomTierSet(customTierSet),
classifier_type: "llm" as const,
...(classifierLlmConfig && {
classifier_llm_config: { model: classifierLlmConfig.model, timeout_ms: classifierLlmConfig.timeout_ms },
}),
session_affinity: false,
escalation_keywords: [] as string[],
...(planModeName && { plan_mode_min_tier: planModeName }),
};
};
export { getCustomTierRowsError } from "./custom_tier_set";
export const buildComplexityRouterConfig = ({
tiers,
customTierSet,
defaultModel,
planModeMinTier,
tierLabels,
@ -241,7 +357,7 @@ export const buildComplexityRouterConfig = ({
reasoningOverrideMinScore,
tierModelParams,
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
const serializedTierModelConfigs = serializeTierModelConfigs(tiers, tierModelParams);
const serializedTierModelConfigs = customTierSet ? undefined : serializeTierModelConfigs(tiers, tierModelParams);
const cleanedEscalationKeywords = escalationKeywords.map((keyword) => keyword.trim()).filter(Boolean);
const cleanedKeywordTierRules = serializeKeywordTierRules(keywordTierRules);
const cleanedTierLabels = serializeTierLabels(tierLabels);
@ -255,25 +371,27 @@ export const buildComplexityRouterConfig = ({
};
const scorerKnobs = scorerKnobPayload(scorerInputs);
return {
// A custom set forces the LLM classifier, so llm-only inputs must survive a stale heuristic field.
const effectiveType: ClassifierType = customTierSet ? "llm" : classifierType;
const payload: ComplexityRouterConfigPayload = {
tiers,
...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }),
...(defaultModel?.trim() && { default_model: defaultModel }),
...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }),
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
classifier_type: classifierType,
...(classifierType === "llm" &&
...(effectiveType === "llm" &&
classifierLlmConfig && { classifier_llm_config: normalizeClassifierLlmConfig(classifierLlmConfig) }),
...(classifierType === "llm" && classifierFallback !== undefined && { classifier_fallback: classifierFallback }),
...(classifierType === "llm" &&
...(effectiveType === "llm" &&
classifierContextWindowSize !== undefined && {
classifier_context_window_size: classifierContextWindowSize,
}),
...(classifierType === "llm" &&
...(effectiveType === "llm" &&
classifierContextPerTurnChars !== undefined && {
classifier_context_per_turn_chars: classifierContextPerTurnChars,
}),
...(classifierType === "llm" &&
...(effectiveType === "llm" &&
classifierContextIncludeAssistantTurns !== undefined && {
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
}),
@ -296,4 +414,11 @@ export const buildComplexityRouterConfig = ({
...(returnRawModelName && { return_raw_model_name: true }),
...scorerKnobs,
};
if (!customTierSet) return payload;
// Drop what the backend rejects beside tier_definitions; stale control state would fail the save.
const { plan_mode_min_tier: planModeMinTierId, ...withFloorRemoved } = payload;
const rest = Object.fromEntries(
Object.entries(withFloorRemoved).filter(([key]) => !KEYS_REJECTED_WITH_CUSTOM_TIERS.includes(key)),
) as typeof withFloorRemoved;
return { ...rest, ...customTierSetWireFields(customTierSet, classifierLlmConfig, planModeMinTierId) };
};

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
customTierDefaultModel,
hydrateTierModelParams,
normalizeTierModels,
pruneTierModelParams,
@ -234,3 +235,16 @@ describe("pruneTierModelParams", () => {
expect(pruneTierModelParams(current, "MEDIUM", [])).toBe(current);
});
});
describe("custom tier derivations", () => {
const set = (names: string[], fallbackId: string) => ({
tiers: names.map((name, index) => ({ id: `r${index}`, name, definition: "d", models: [`${name}-model`] })),
fallback_tier_id: fallbackId,
});
// The backend resolves with casefold, so a set spelling its tier "medium" derives the same default.
it("resolves the MEDIUM and SIMPLE derivation case-insensitively", () => {
expect(customTierDefaultModel(set(["medium", "simple"], "gone"))).toBe("medium-model");
expect(customTierDefaultModel(set(["simple"], "gone"))).toBe("simple-model");
});
});

View file

@ -1,5 +1,6 @@
import type { ComplexityTiers } from "./ComplexityRouterConfig";
import type { ComplexityTiers, CustomTierSet } from "./ComplexityRouterConfig";
import type { ComplexityTier } from "./KeywordTierRules";
import { findTierByName } from "./custom_tier_set";
export type TierModelParams = Record<string, unknown>;
@ -133,7 +134,23 @@ export const DEFAULT_TIER_LABELS: Record<ComplexityTier, string> = {
export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
const isBuiltInOption = (tier: string): tier is ComplexityTier => (TIER_ORDER as string[]).includes(tier);
export const tierOptions = (
tierLabels: Partial<Record<ComplexityTier, string>> | undefined,
): { value: ComplexityTier; label: string }[] =>
TIER_ORDER.map((tier) => ({ value: tier, label: tierLabels?.[tier]?.trim() || DEFAULT_TIER_LABELS[tier] }));
tierNames?: string[],
): { value: string; label: string }[] =>
(tierNames ?? TIER_ORDER).map((tier) => ({
value: tier,
label: (isBuiltInOption(tier) && (tierLabels?.[tier]?.trim() || DEFAULT_TIER_LABELS[tier])) || tier,
}));
/** Backend derivation for an edited set: pin, then the fallback tier's pool, then MEDIUM/SIMPLE. */
export const customTierDefaultModel = (customTierSet: CustomTierSet, pinned?: string): string | undefined => {
const rowNamed = (name: string) => findTierByName(customTierSet.tiers, name);
const fallbackRow = customTierSet.tiers.find((row) => row.id === customTierSet.fallback_tier_id);
return pinned?.trim() || fallbackRow?.models[0] || rowNamed("MEDIUM")?.models[0] || rowNamed("SIMPLE")?.models[0];
};
export const defaultRuleTier = (tierNames?: string[]): string =>
!tierNames || tierNames.includes("COMPLEX") ? "COMPLEX" : tierNames[0] ?? "COMPLEX";

View file

@ -0,0 +1,54 @@
import type { ComplexityTiers } from "./ComplexityRouterConfig";
export interface TierDraft {
/** React key and fallback pointer target; never serialized. */
id: string;
name: string;
/** Blank on a built-in name inherits the built-in criteria. */
definition: string;
models: string[];
}
// Absent 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;
}
const BUILT_IN_TIER_NAMES = Object.keys({
SIMPLE: null,
MEDIUM: null,
COMPLEX: null,
REASONING: null,
} satisfies Record<keyof ComplexityTiers, null>);
// One rule for comparing tier names, matching the backend's casefold
// (litellm/router_strategy/complexity_router/config.py), so no two lookups can disagree.
export const foldTierName = (name: string): string => name.trim().toLowerCase();
export const tierNamesMatch = (left: string, right: string): boolean => foldTierName(left) === foldTierName(right);
export const findTierByName = <T extends { name: string }>(rows: readonly T[], name: string): T | undefined =>
rows.find((row) => tierNamesMatch(row.name, name));
export const isBuiltInTierName = (name: string): boolean =>
BUILT_IN_TIER_NAMES.some((tier) => tierNamesMatch(tier, name));
// Mirrors the backend's 2..8 tier_definitions rule; Restore defaults can push a full set past it.
export const MIN_TIER_COUNT = 2;
export const MAX_TIER_COUNT = 8;
export const getCustomTierRowsError = (customTierSet: CustomTierSet): string | null => {
const rows = customTierSet.tiers;
if (rows.length < MIN_TIER_COUNT || rows.length > MAX_TIER_COUNT)
return `A tier set needs ${MIN_TIER_COUNT} to ${MAX_TIER_COUNT} tiers`;
if (rows.some((row) => !row.name.trim())) return "Name every tier";
const names = rows.map((row) => foldTierName(row.name));
if (new Set(names).size !== names.length) return "Tier names must be unique";
if (rows.some((row) => !row.definition.trim() && !isBuiltInTierName(row.name)))
return "Every custom tier needs a definition: it is the rubric the classifier routes on";
if (rows.some((row) => row.models.length === 0)) return "Select at least one model for every tier";
if (!rows.some((row) => row.id === customTierSet.fallback_tier_id))
return "Pick a Fallback Tier for classifier failures";
return null;
};

View file

@ -47,13 +47,13 @@ const MODEL_DATA = {
model_info: { id: "auto-1", access_groups: [] },
};
const renderModal = () =>
const renderModal = (modelData: typeof MODEL_DATA = MODEL_DATA) =>
renderWithProviders(
<EditAutoRouterModal
isVisible
onCancel={vi.fn()}
onSuccess={vi.fn()}
modelData={MODEL_DATA}
modelData={modelData}
accessToken="token"
userRole="Admin"
/>,
@ -799,3 +799,29 @@ describe("EditAutoRouterModal plan-mode minimum tier", () => {
expect(savedConfig()).not.toHaveProperty("plan_mode_min_tier");
});
});
describe("EditAutoRouterModal stored custom tier set", () => {
// Before the pass-through this rebuilt the built-in four over a custom pool set, and the
// surviving tier_definitions then disagreed with `tiers`, failing the router at load.
const CONFIG = {
tiers: { CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["gpt-4o-mini"] },
tier_definitions: [
{ name: "CASUAL", description: "chat" },
{ name: "SECURITY_REVIEW", description: "audits" },
],
fallback_tier: "CASUAL",
classifier_type: "llm",
};
it("says the editor is unavailable and saves the stored config byte-identical", async () => {
modelPatchUpdateCall.mockClear();
renderModal({ ...MODEL_DATA, litellm_params: { ...MODEL_DATA.litellm_params, complexity_router_config: CONFIG } });
await screen.findByText(/custom tier set, which this form cannot edit yet/i);
expect(screen.queryByText("Complexity Tier Configuration")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig()).toEqual(CONFIG);
});
});

View file

@ -137,12 +137,20 @@ export interface KeywordMatchingState {
matchThreshold: number;
}
// This form hydrates only the four built-in tiers, so rebuilding from it would drop custom pools
// while the preserved tier_definitions still name them: a save would corrupt or be rejected.
export const hasStoredCustomTierSet = (storedConfig: unknown): boolean => {
const definitions = toRecord(storedConfig).tier_definitions;
return Array.isArray(definitions) && definitions.length > 0;
};
export const buildUpdatedComplexityRouterConfig = (
storedConfig: unknown,
value: ComplexityRouterConfigValue,
customTechnicalKeywords?: string[],
keywordMatching?: KeywordMatchingState,
): Record<string, unknown> => {
if (hasStoredCustomTierSet(storedConfig)) return toRecord(storedConfig);
const isManaged = (key: string): boolean => {
if (MANAGED_COMPLEXITY_ROUTER_KEYS.has(key)) return true;
if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
@ -281,6 +289,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
classifier_type: "heuristic",
});
const isComplexityRouterModel = isComplexityRouter(modelData?.litellm_params);
const storedCustomTierSet = hasStoredCustomTierSet(modelData?.litellm_params?.complexity_router_config);
const schema = useMemo(
() => (isComplexityRouterModel ? complexityRouterSchema : semanticRouterSchema),
@ -291,14 +300,15 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
// Mirrors the create form: the button says why it is unavailable and disables on the same
// answer. Tiers use this modal's own rule, which allows a partly filled router, so an edit that
// is legal today stays legal.
const submitBlockedReason = !isComplexityRouterModel
? null
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
? "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);
const submitBlockedReason =
!isComplexityRouterModel || storedCustomTierSet
? null
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
? "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(() => {
if (isVisible && modelData) {
@ -451,6 +461,22 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
};
const saveValues = async (values: EditAutoRouterFormValues) => {
if (isComplexityRouterModel && storedCustomTierSet) {
const updatedModelInfo = {
...modelData.model_info,
access_groups: values.model_access_group || [],
};
const renamed = {
model_name: values.auto_router_name,
litellm_params: modelData.litellm_params,
model_info: updatedModelInfo,
};
await modelPatchUpdateCall(accessToken, renamed, modelData.model_info.id);
toast.success("Auto router configuration updated successfully");
onSuccess({ ...modelData, ...renamed });
onCancel();
return;
}
if (isComplexityRouterModel) {
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
if (Object.values(tiers).every((models) => models.length === 0)) {
@ -606,8 +632,13 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g., auto_router_1, smart_routing" />}
</FormField>
{isComplexityRouterModel ? (
/* Complexity Router Configuration */
{isComplexityRouterModel && storedCustomTierSet && (
<span className="block text-sm text-muted-foreground">
This router uses a custom tier set, which this form cannot edit yet. Its complexity configuration is
preserved exactly as stored; the name and access settings above still save.
</span>
)}
{isComplexityRouterModel && !storedCustomTierSet && (
<div className="w-full">
<ComplexityRouterConfig
showValidationErrors={showValidationErrors}
@ -630,7 +661,8 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
onEscalationKeywordsChange={setEscalationKeywords}
/>
</div>
) : (
)}
{!isComplexityRouterModel && (
<>
{/* Router Configuration Builder */}
<div className="w-full">

View file

@ -16,7 +16,7 @@ import { stripMaskedSecrets } from "../utils/maskedSecretUtils";
import { truncateString } from "../utils/textUtils";
import AutoRouterConnectionTest from "./add_model/auto_router_connection_test";
import { AutoRouterTestTarget, buildAutoRouterTestTargets } from "./add_model/build_auto_router_test_targets";
import { normalizeTierModels, resolveComplexityDefaultModel } from "./add_model/complexity_router_tiers";
import { normalizeTierModels } from "./add_model/complexity_router_tiers";
import {
hasAutoRouterEditor,
isAutoRouterDeployment,
@ -91,24 +91,22 @@ const buildComplexityRouterTestTargets = (
config = rawConfig;
}
const tiers = {
SIMPLE: normalizeTierModels(config.tiers?.SIMPLE),
MEDIUM: normalizeTierModels(config.tiers?.MEDIUM),
COMPLEX: normalizeTierModels(config.tiers?.COMPLEX),
REASONING: normalizeTierModels(config.tiers?.REASONING),
};
const tiers: [string, string[]][] =
config.tiers && typeof config.tiers === "object"
? Object.entries(config.tiers).map(([tier, value]) => [tier, normalizeTierModels(value)])
: [];
// Mirrors init_complexity_router_deployment (litellm/router.py): litellm_params wins, otherwise
// pure tier-derivation. complexity_router_config.default_model is a UI-only marker the backend
// never reads — folding it in here could point Test Connection at a model the router never
// calls (see PR #36615 discussion).
// calls (see PR #36615 discussion). Tier-derived defaults are already in a probed pool.
const effectiveDefaultModel = modelData?.litellm_params?.complexity_router_default_model || undefined;
const testTargetParams = {
tiers,
semanticMatchingEnabled: Boolean(config.semantic_keyword_matching),
embeddingModel: config.embedding_model,
defaultModel: resolveComplexityDefaultModel(tiers, effectiveDefaultModel),
defaultModel: effectiveDefaultModel,
};
return buildAutoRouterTestTargets(testTargetParams);
};

View file

@ -4,7 +4,6 @@ import {
} from "@/components/add_model/build_complexity_router_config";
import {
ComplexityRouterConfigValue,
ComplexityTiers,
ClassifierType,
ClassifierLLMConfig,
DEFAULT_SESSION_AFFINITY,
@ -43,15 +42,7 @@ export const getRequiredModels = (
config: Pick<ComplexityRouterConfigPayload, "tiers" | "classifier_llm_config" | "embedding_model" | "default_model">,
): Set<string> => {
const { tiers, classifier_llm_config: classifier, embedding_model: embedding, default_model: pinned } = config;
const models = [
...tiers.SIMPLE,
...tiers.MEDIUM,
...tiers.COMPLEX,
...tiers.REASONING,
classifier?.model,
embedding,
pinned,
];
const models = [...Object.values(tiers).flat(), classifier?.model, embedding, pinned];
// Boolean(), not != null: an empty-string placeholder (e.g. classifier_llm_config seeded before a
// model is chosen) is never a real model reference either.
return new Set(models.filter((model): model is string => Boolean(model)));
@ -191,7 +182,7 @@ export const getMissingModelsInPreset = (preset: AutoRouterPreset, availability:
// effect would block submit for a model that was never going to be submitted.
export const getReferencedModelsError = (
params: {
tiers: ComplexityTiers;
tiers: ComplexityRouterConfigPayload["tiers"];
classifierType: ClassifierType;
classifierLlmConfig: ClassifierLLMConfig | undefined;
semanticMatchingEnabled: boolean;