feat(ui): edit the auto-router tier set with custom classifier-defined tiers

This commit is contained in:
Tin Chi Lo 2026-08-20 19:51:05 -07:00
parent a66a10b1b5
commit e6a711bf9c
17 changed files with 1506 additions and 256 deletions

View file

@ -26,6 +26,7 @@ import {
CLASSIFICATION_RUBRIC_KEYS,
ClassificationRubric,
effectiveTierLabel,
effectiveClassifierType,
} from "./ComplexityRouterConfig";
const DEFAULT_SCORING_EXPLANATION =
@ -84,6 +85,20 @@ const boundaryRanges = (
const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => {
// The shipped boundaries come from the proxy, so this card cannot state ranges the router stopped using.
const { data: scorerDefaults, isError } = useComplexityScorerDefaults();
if (value.custom_tier_set) {
return (
<Card className="bg-muted mt-4">
<CardContent>
<strong className="block mb-2 font-semibold">How Classification Works</strong>
<span className="text-[13px] text-muted-foreground">
The LLM classifier routes each request by matching it against your tier definitions, in the order listed
(earlier tiers win keyword ties). The heuristic scorer and its score bands never run. When the classifier
errors, times out, or returns an unparseable reply, requests route to the Fallback Tier chosen above.
</span>
</CardContent>
</Card>
);
}
const ranges = boundaryRanges(
scorerDefaults?.tier_boundaries,
value.tier_boundaries,
@ -129,6 +144,8 @@ interface ClassificationMethodConfigProps {
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
modelOptions: { value: string; label: string }[];
/** Present with an edited tier set: commits a fallback-tier re-point through the tier editor's owner. */
onFallbackTierChange?: (fallbackTierId: string) => void;
customTechnicalKeywords?: string[];
onCustomTechnicalKeywordsChange?: (keywords: string[]) => void;
showValidationErrors?: boolean;
@ -144,10 +161,13 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
onCustomTechnicalKeywordsChange,
showValidationErrors = false,
defaultModel,
onFallbackTierChange,
}) => {
const hasDefaultModel = Boolean(defaultModel);
const hasCustomTierSet = Boolean(value.custom_tier_set);
const classifierType = effectiveClassifierType(value);
const classifierModelMissing =
showValidationErrors && value.classifier_type === "llm" && !value.classifier_llm_config?.model;
showValidationErrors && classifierType === "llm" && !value.classifier_llm_config?.model;
const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim());
const classificationRubric = value.classifier_llm_config?.classification_rubric ?? DEFAULT_CLASSIFICATION_RUBRIC;
@ -252,20 +272,28 @@ const ClassificationMethodConfig: React.FC<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">
<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,8 +304,17 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</div>
</RadioGroup>
{value.classifier_type === "llm" && (
{classifierType === "llm" && (
<div className="mt-4 space-y-3">
{hasCustomTierSet &&
Boolean(
value.classifier_llm_config?.system_prompt?.trim() || value.classifier_llm_config?.classification_rubric,
) && (
<span className="block text-xs text-muted-foreground">
Your custom classifier prompt and rubric preset are not used with an edited tier set: the classifier
prompt is built from your tier definitions. Restore the built-in tiers to use them again.
</span>
)}
<div>
<strong className="block mb-1 font-semibold">Classifier Model</strong>
<SearchSelect
@ -306,91 +343,138 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
How long the classifier call has before it fails and the fallback below takes over.
</span>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<strong className="font-semibold">Classification Rubric</strong>
<SimpleTooltip content="Every rubric uses the same four tiers. They differ in the worked examples that show the classifier where the boundary between tiers sits, and the Business rubric also rewrites the tier definitions for business traffic.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
</div>
<SimpleTooltip
content={usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined}
className="w-full"
>
<Select
items={CLASSIFICATION_RUBRIC_KEYS.map((preset) => ({
value: preset,
label: CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label,
}))}
value={classificationRubric}
onValueChange={(preset: ClassificationRubric | null) =>
preset && handleClassificationRubricChange(preset)
}
disabled={usesCustomPrompt}
{!hasCustomTierSet && (
<div>
<div className="flex items-center gap-2 mb-1">
<strong className="font-semibold">Classification Rubric</strong>
<SimpleTooltip content="Every rubric uses the same four tiers. They differ in the worked examples that show the classifier where the boundary between tiers sits, and the Business rubric also rewrites the tier definitions for business traffic.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
</div>
<SimpleTooltip
content={usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined}
className="w-full"
>
<SelectTrigger aria-label="Classification Rubric" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLASSIFICATION_RUBRIC_KEYS.map((preset) => (
<SelectItem key={preset} value={preset}>
{CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label}
</SelectItem>
))}
</SelectContent>
</Select>
</SimpleTooltip>
<span className="block text-xs text-muted-foreground">
{usesCustomPrompt
? "Not in use: the custom prompt below is the classifier's entire rubric."
: CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description}
</span>
</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}
/>
</div>
<Select
items={CLASSIFICATION_RUBRIC_KEYS.map((preset) => ({
value: preset,
label: CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label,
}))}
value={classificationRubric}
onValueChange={(preset: ClassificationRubric | null) =>
preset && handleClassificationRubricChange(preset)
}
disabled={usesCustomPrompt}
>
<SelectTrigger aria-label="Classification Rubric" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLASSIFICATION_RUBRIC_KEYS.map((preset) => (
<SelectItem key={preset} value={preset}>
{CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label}
</SelectItem>
))}
</SelectContent>
</Select>
</SimpleTooltip>
<span className="block text-xs text-muted-foreground">
{usesCustomPrompt
? "Not in use: the custom prompt below is the classifier's entire rubric."
: CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description}
</span>
</div>
)}
{!hasCustomTierSet && (
<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}
/>
</div>
)}
<div>
<strong className="block mb-1 font-semibold">If the classifier fails</strong>
<RadioGroup
value={value.classifier_fallback ?? DEFAULT_CLASSIFIER_FALLBACK}
onValueChange={(fallback: unknown) => handleClassifierFallbackChange(fallback as ClassifierFallback)}
>
<div className="inline-flex flex-col gap-2">
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="heuristic" className="mt-0.5" />
<span>
<span>Score with the heuristic</span>{" "}
<span className="text-muted-foreground">— right when the classifier grades complexity too</span>
</span>
</Label>
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
<RadioGroupItem value="default_model" disabled={!hasDefaultModel} className="mt-0.5" />
<SimpleTooltip
content={
hasDefaultModel
? "Change it from the Default Model select."
: "Set a default model on this router to use this option"
}
>
<span>
<span>Route to the default model{defaultModel ? ` (${defaultModel})` : ""}</span>{" "}
<span className="text-muted-foreground">
— right when your prompt grades something other than complexity
</span>
</span>
{hasCustomTierSet ? (
<RadioGroup value="fallback_tier" onValueChange={() => undefined}>
<div className="inline-flex flex-col gap-2">
<SimpleTooltip content="An edited tier set requires the LLM classifier and its Fallback Tier: the heuristic scorer only produces the built-in tiers">
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
<RadioGroupItem value="heuristic" disabled className="mt-0.5" />
<span>Score with the heuristic</span>
</Label>
</SimpleTooltip>
</Label>
</div>
</RadioGroup>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="fallback_tier" className="mt-0.5" />
<span>Route to the Fallback Tier</span>
</Label>
{value.custom_tier_set && (
<Select
items={value.custom_tier_set.tiers
.filter((row) => row.name.trim())
.map((row) => ({ value: row.id, label: row.name.trim() }))}
value={value.custom_tier_set.fallback_tier_id || null}
onValueChange={(fallbackTierId: string | null) =>
fallbackTierId && onFallbackTierChange?.(fallbackTierId)
}
>
<SelectTrigger aria-label="Fallback tier" className="ml-6 w-64">
<SelectValue placeholder="Pick the tier classifier failures route to" />
</SelectTrigger>
<SelectContent>
{value.custom_tier_set.tiers
.filter((row) => row.name.trim())
.map((row) => (
<SelectItem key={row.id} value={row.id}>
{row.name.trim()}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</RadioGroup>
) : (
<RadioGroup
value={value.classifier_fallback ?? DEFAULT_CLASSIFIER_FALLBACK}
onValueChange={(fallback: unknown) => handleClassifierFallbackChange(fallback as ClassifierFallback)}
>
<div className="inline-flex flex-col gap-2">
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="heuristic" className="mt-0.5" />
<span>
<span>Score with the heuristic</span>{" "}
<span className="text-muted-foreground">— right when the classifier grades complexity too</span>
</span>
</Label>
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
<RadioGroupItem value="default_model" disabled={!hasDefaultModel} className="mt-0.5" />
<SimpleTooltip
content={
hasDefaultModel
? "Change it from the Default Model select."
: "Set a default model on this router to use this option"
}
>
<span>
<span>Route to the default model{defaultModel ? ` (${defaultModel})` : ""}</span>{" "}
<span className="text-muted-foreground">
— right when your prompt grades something other than complexity
</span>
</span>
</SimpleTooltip>
</Label>
</div>
</RadioGroup>
)}
<span className="block text-xs text-muted-foreground">
Applies when the classifier call errors, times out, or returns an unparseable response.
{hasCustomTierSet
? "Applies when the classifier call errors, times out, or returns an unparseable response. The classifier prompt is built from your tier definitions."
: "Applies when the classifier call errors, times out, or returns an unparseable response."}
</span>
</div>
<div>
@ -446,7 +530,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</div>
)}
{value.classifier_type === "heuristic" && (
{classifierType === "heuristic" && (
<div className="mt-4">
<div className="flex items-center gap-2 mb-1">
<strong className="font-semibold">Custom Technical Keywords</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,174 @@ describe("ComplexityRouterConfig reasoning effort gating", () => {
).toHaveTextContent("low");
});
});
describe("edit tiers", () => {
const customValue: ComplexityRouterConfigValue = {
...defaultValue,
classifier_type: "llm",
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
custom_tier_set: {
tiers: [
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-3.5-turbo"] },
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: ["gpt-4"] },
{ id: "sec", name: "AUDIT", definition: "security audits", models: ["claude-3-opus"] },
],
fallback_tier_id: "COMPLEX",
},
};
it("removing a built-in tier materializes the ordered row set and touches nothing else", async () => {
const onChange = vi.fn();
renderWithProviders(<ComplexityRouterConfig {...baseProps} editingTiers onChange={onChange} />);
await userEvent.click(screen.getByRole("button", { name: "Remove the MEDIUM tier" }));
expect(onChange).toHaveBeenCalledWith({
...defaultValue,
custom_tier_set: {
tiers: [
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: defaultValue.tiers.SIMPLE },
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: defaultValue.tiers.COMPLEX },
{ id: "REASONING", name: "REASONING", definition: "", models: defaultValue.tiers.REASONING },
],
fallback_tier_id: "SIMPLE",
},
});
});
it("Restore defaults refills missing built-ins but only Use built-in tiers exits, returning the pristine value", async () => {
const onChange = vi.fn();
const removed: ComplexityRouterConfigValue = {
...defaultValue,
custom_tier_set: {
tiers: (["SIMPLE", "COMPLEX", "REASONING"] as const).map((tier) => ({
id: tier,
name: tier,
definition: "",
models: defaultValue.tiers[tier],
})),
fallback_tier_id: "SIMPLE",
},
};
renderWithProviders(<ComplexityRouterConfig {...baseProps} editingTiers value={removed} onChange={onChange} />);
await userEvent.click(screen.getByRole("button", { name: "Restore defaults" }));
const restored = onChange.mock.calls.at(-1)?.[0] as ComplexityRouterConfigValue;
expect(restored.custom_tier_set?.tiers.map((row) => row.id)).toEqual(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
await userEvent.click(screen.getByRole("button", { name: "Use built-in tiers" }));
expect(onChange).toHaveBeenLastCalledWith(defaultValue);
});
it("removing a row snapshots its in-editor models so Restore returns them, not a stale pool", async () => {
const onChange = vi.fn();
const edited: ComplexityRouterConfigValue = {
...customValue,
custom_tier_set: {
...customValue.custom_tier_set!,
tiers: customValue.custom_tier_set!.tiers.map((row) =>
row.id === "SIMPLE" ? { ...row, models: ["edited-in-editor"] } : row,
),
},
};
renderWithProviders(<ComplexityRouterConfig {...baseProps} editingTiers value={edited} onChange={onChange} />);
await userEvent.click(screen.getByRole("button", { name: "Remove the SIMPLE tier" }));
const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue;
expect(next.tiers.SIMPLE).toEqual(["edited-in-editor"]);
expect(next.custom_tier_set?.tiers.some((row) => row.id === "SIMPLE")).toBe(false);
});
it("tells the custom-tier routing story instead of the heuristic score bands", async () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} editingTiers value={customValue} />);
await userEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByText(/matching it against your tier definitions/)).toBeInTheDocument();
expect(screen.queryByText(/Score </)).not.toBeInTheDocument();
});
it("warns that a configured classifier prompt is unused with an edited tier set", async () => {
const withPrompt: ComplexityRouterConfigValue = {
...customValue,
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000, system_prompt: "grade it my way" },
};
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={withPrompt} />);
await userEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByText(/prompt and rubric preset are not used with an edited tier set/)).toBeInTheDocument();
});
it("shows name and definition inputs for the rows and disables session pinning with a hint", async () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} editingTiers value={customValue} />);
expect(screen.getByLabelText("Name for tier 3")).toHaveValue("AUDIT");
expect(screen.getByLabelText("Definition for tier 3")).toHaveValue("security audits");
expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
await userEvent.click(screen.getByText("Advanced: Affinity"));
expect(screen.getByLabelText("Pin a session to its first model")).toHaveAttribute("data-disabled");
expect(screen.getByText(/Unavailable with an edited tier set: escalating a pinned session/)).toBeInTheDocument();
});
it("exiting scrubs params that no built-in tier owns", async () => {
const onChange = vi.fn();
const withStrayParams: ComplexityRouterConfigValue = {
...customValue,
tier_model_params: {
AUDIT: { "claude-3-opus": { reasoning_effort: "high" } },
COMPLEX: { "gpt-4": { reasoning_effort: "low" } },
},
};
renderWithProviders(
<ComplexityRouterConfig {...baseProps} editingTiers value={withStrayParams} onChange={onChange} />,
);
await userEvent.click(screen.getByRole("button", { name: "Use built-in tiers" }));
const exited = onChange.mock.calls.at(-1)?.[0] as ComplexityRouterConfigValue;
expect(exited.custom_tier_set).toBeUndefined();
expect(exited.tier_model_params).toEqual({ COMPLEX: { "gpt-4": { reasoning_effort: "low" } } });
});
it("clears the plan-mode floor when its row is removed, and keeps a built-in floor through mode exit", async () => {
const onChange = vi.fn();
const withFloor: ComplexityRouterConfigValue = { ...customValue, plan_mode_min_tier: "sec" };
renderWithProviders(<ComplexityRouterConfig {...baseProps} editingTiers value={withFloor} onChange={onChange} />);
await userEvent.click(screen.getByRole("button", { name: "Remove the AUDIT tier" }));
expect((onChange.mock.calls[0][0] as ComplexityRouterConfigValue).plan_mode_min_tier).toBeUndefined();
});
it("keeps a built-in plan-mode floor through the explicit exit, and clears a custom one", async () => {
const onChange = vi.fn();
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
editingTiers
value={{ ...customValue, plan_mode_min_tier: "COMPLEX" }}
onChange={onChange}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Use built-in tiers" }));
expect((onChange.mock.calls.at(-1)?.[0] as ComplexityRouterConfigValue).plan_mode_min_tier).toBe("COMPLEX");
});
it("view mode shows only the tier name and models; the definition moves into the info tooltip", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} />);
expect(screen.getByText("AUDIT Tier")).toBeInTheDocument();
expect(screen.queryByLabelText("Name for tier 3")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Definition for tier 3")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Remove the/ })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Edit tiers" })).toBeInTheDocument();
});
it("Done stays disabled until every row has a name, a definition, and models", () => {
const incomplete: ComplexityRouterConfigValue = {
...customValue,
custom_tier_set: {
...customValue.custom_tier_set!,
tiers: [...customValue.custom_tier_set!.tiers, { id: "new-1", name: "", definition: "", models: [] }],
},
};
const { rerender } = renderWithProviders(<ComplexityRouterConfig {...baseProps} editingTiers value={incomplete} />);
expect(screen.getByRole("button", { name: "Done" })).toHaveAttribute("data-disabled");
rerender(<ComplexityRouterConfig {...baseProps} editingTiers value={customValue} />);
expect(screen.getByRole("button", { name: "Done" })).not.toHaveAttribute("data-disabled");
});
it("shows the editor controls from the parent-owned editing flag, surviving a section remount", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} editingTiers />);
expect(screen.getByRole("button", { name: "Add tier" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
});
});

View file

@ -2,12 +2,23 @@ import { SimpleTooltip } from "@/components/ui/tooltip";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ChevronRight, Info, X } from "lucide-react";
import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Separator } from "@/components/ui/separator";
import {
type CustomTierSet,
type TierDraft,
activeTierNames,
getCustomTierRowsError,
isBuiltInTierName,
scopeTierParamsToRows,
} from "./custom_tier_set";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
@ -15,6 +26,7 @@ import ClassificationMethodConfig from "./ClassificationMethodConfig";
import {
ReasoningEffort,
TierModelParamsByTier,
customTierDefaultModel,
pruneTierModelParams,
resolveComplexityDefaultModel,
setTierModelReasoningEffort,
@ -27,11 +39,15 @@ import SemanticKeywordMatching from "./SemanticKeywordMatching";
import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from "./heuristic_scoring_knobs";
export type { DimensionWeights, TierBoundaries, TokenThresholds };
export type { CustomTierSet, TierDraft } from "./custom_tier_set";
export { activeTierNames, getCustomTierRowsError, isBuiltInTierName } from "./custom_tier_set";
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000;
export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5;
export const DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE = 3;
export const DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS = 200;
export const MIN_TIER_COUNT = 2;
export const MAX_TIER_COUNT = 8;
export const DEFAULT_SESSION_AFFINITY = false;
export const DEFAULT_DEPLOYMENT_AFFINITY = true;
@ -123,15 +139,25 @@ export const heuristicScoringRoleFor = (
};
export const heuristicScoringRole = (value: ComplexityRouterConfigValue): HeuristicScoringRole =>
heuristicScoringRoleFor(value.classifier_type, value.classifier_fallback);
value.custom_tier_set ? "never" : heuristicScoringRoleFor(value.classifier_type, value.classifier_fallback);
export type AdaptiveEligible = "all" | "classified_tier";
export type ComplexityTierLabels = Partial<Record<keyof ComplexityTiers, string>>;
/**
* 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<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 +168,7 @@ export interface ComplexityRouterConfigValue {
classifier_fallback?: ClassifierFallback;
session_affinity?: boolean;
deployment_affinity?: boolean;
/** Tier floor for coding-agent plan-mode requests. Unset means detection is off, matching the backend. */
/** Plan-mode floor as a tier ROW ID (unset = off); the wire carries the row's name. */
plan_mode_min_tier?: string;
adaptive?: boolean;
adaptive_weights?: AdaptiveRouterWeights;
@ -173,6 +199,9 @@ interface ComplexityRouterConfigProps {
modelInfo: ModelGroup[];
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
/** Parent-owned: this component unmounts on section collapse and editor mode must survive. */
editingTiers: boolean;
onEditingTiersChange: (editing: boolean) => void;
customTechnicalKeywords?: string[];
onCustomTechnicalKeywordsChange?: (keywords: string[]) => void;
// Optional: the edit-auto-router modal doesn't yet support editing keyword tier
@ -221,14 +250,18 @@ export const TIER_KEYS = Object.keys(TIER_DESCRIPTIONS) as Array<keyof Complexit
export const effectiveTierLabel = (tier: keyof ComplexityTiers, tierLabels: ComplexityTierLabels | undefined): string =>
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<keyof ComplexityTiers> =>
TIER_KEYS.filter((tier) => (tiers[tier] ?? []).length > 0);
// Row IDS the floor may point at: the backend rejects a floor whose tier has no models.
export const planModeEligibleTiers = (tiers: ComplexityTiers, customTierSet?: CustomTierSet): string[] =>
customTierSet
? customTierSet.tiers.filter((row) => row.name.trim() && row.models.length > 0).map((row) => row.id)
: TIER_KEYS.filter((tier) => (tiers[tier] ?? []).length > 0);
const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
modelInfo,
value,
onChange,
editingTiers,
onEditingTiersChange,
customTechnicalKeywords,
onCustomTechnicalKeywordsChange,
keywordTierRules = [],
@ -243,12 +276,99 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
onEscalationKeywordsChange,
showValidationErrors = false,
}) => {
const planModeTiers = planModeEligibleTiers(value.tiers);
const planModeTierOptions = tierOptions(value.tier_labels).filter((option) =>
(planModeTiers as string[]).includes(option.value),
);
const derivedDefaultModel = resolveComplexityDefaultModel(value.tiers);
const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model);
const customTierSet = value.custom_tier_set;
const builtInRow = (tier: keyof ComplexityTiers): TierDraft => ({
id: tier,
name: tier,
definition: "",
models: value.tiers[tier],
});
const tierRows: TierDraft[] = customTierSet?.tiers ?? TIER_KEYS.map(builtInRow);
// The single owner of every tier-set write: reconciles both row-id pointers (fallback
// re-points, floor clears) and issues the one onChange. Mode transitions are explicit.
const commitTierRows = (rows: TierDraft[], fallbackTierId: string, base: ComplexityRouterConfigValue = value) => {
const plan_mode_min_tier = rows.some((row) => row.id === base.plan_mode_min_tier)
? base.plan_mode_min_tier
: undefined;
const fallback_tier_id = rows.some((row) => row.id === fallbackTierId)
? fallbackTierId
: (rows.find((row) => row.name === "MEDIUM") ?? rows[0])?.id ?? "";
onChange({ ...base, plan_mode_min_tier, custom_tier_set: { tiers: rows, fallback_tier_id } });
};
const currentFallbackId = customTierSet?.fallback_tier_id ?? "MEDIUM";
// Removing a built-in row snapshots its models so Restore returns them, not a stale pool.
const removeTierRow = (id: string) => {
const removed = tierRows.find((row) => row.id === id);
const snapshotBase =
removed && (TIER_KEYS as string[]).includes(removed.id)
? { ...value, tiers: { ...value.tiers, [removed.id]: removed.models } }
: value;
commitTierRows(
tierRows.filter((row) => row.id !== id),
currentFallbackId,
snapshotBase,
);
};
const restoreDefaultTiers = () => {
const restoredInCanonicalOrder = [
...TIER_KEYS.map((builtIn) => tierRows.find((row) => row.id === builtIn) ?? builtInRow(builtIn)),
...tierRows.filter((row) => !(TIER_KEYS as string[]).includes(row.id)),
];
commitTierRows(restoredInCanonicalOrder, currentFallbackId);
};
// The one way back to built-in mode; models map back by row id.
const exitToBuiltInTiers = () => {
const { custom_tier_set: _cleared, ...rest } = value;
const modelsFor = (tier: keyof ComplexityTiers) =>
tierRows.find((row) => row.id === tier)?.models ?? value.tiers[tier];
onChange({
...rest,
plan_mode_min_tier: (TIER_KEYS as string[]).includes(value.plan_mode_min_tier ?? "")
? value.plan_mode_min_tier
: undefined,
tier_model_params: scopeTierParamsToRows(
value.tier_model_params,
TIER_KEYS.map((tier) => ({ name: tier })),
),
tiers: {
SIMPLE: modelsFor("SIMPLE"),
MEDIUM: modelsFor("MEDIUM"),
COMPLEX: modelsFor("COMPLEX"),
REASONING: modelsFor("REASONING"),
},
});
};
const addCustomTier = () => {
commitTierRows([...tierRows, { id: crypto.randomUUID(), name: "", definition: "", models: [] }], currentFallbackId);
};
const updateTierRow = (id: string, patch: Partial<Omit<TierDraft, "id">>) => {
commitTierRows(
tierRows.map((candidate) => (candidate.id === id ? { ...candidate, ...patch } : candidate)),
currentFallbackId,
);
};
const fallbackRow = customTierSet?.tiers.find((row) => row.id === customTierSet.fallback_tier_id);
const planModeTiers = planModeEligibleTiers(value.tiers, customTierSet);
const planModeTierOptions = customTierSet
? customTierSet.tiers
.filter((row) => planModeTiers.includes(row.id))
.map((row) => ({ value: row.id, label: row.name.trim() }))
: tierOptions(value.tier_labels).filter((option) => planModeTiers.includes(option.value));
const derivedDefaultModel = customTierSet
? customTierDefaultModel(customTierSet)
: resolveComplexityDefaultModel(value.tiers);
const defaultModel = customTierSet
? customTierDefaultModel(customTierSet, value.default_model)
: resolveComplexityDefaultModel(value.tiers, value.default_model);
// Embedding models can't serve a chat-completion role, so they're excluded here.
const reasoningModels = new Set(
@ -270,11 +390,7 @@ const ComplexityRouterConfig: React.FC<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 +425,191 @@ 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 themselves appear in the dashboard and spend logs."
: "Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how requests are classified, and callers never see these names."}
{!customTierSet &&
value.classifier_type === "llm" &&
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
</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}`}
className={nameMissing ? "mb-2 border-destructive" : "mb-2"}
/>
<Textarea
value={row.definition}
onChange={(event) => updateTierRow(row.id, { definition: event.target.value })}
placeholder={
isBuiltInTierName(row.name)
? "Leave blank to keep the built-in definition the classifier already uses for this tier"
: "What belongs in this tier. The LLM classifier reads this definition to decide when a request routes here, e.g. requests asking for a security audit, vulnerability review, or exploit analysis"
}
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 && (
{row.name.trim() !== "" && (
<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>
{editingTiers && (
<SimpleTooltip content={(customTierSet && getCustomTierRowsError(customTierSet)) || undefined}>
<Button
variant="outline"
disabled={Boolean(customTierSet && getCustomTierRowsError(customTierSet))}
onClick={() => onEditingTiersChange(false)}
>
Done
</Button>
</SimpleTooltip>
)}
{customTierSet && (
<SimpleTooltip content="Return to the built-in tier ladder. Stored settings that require tier definitions, such as a custom classification prompt, are dropped from the next save.">
<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>
)}
</>
) : (
<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>
<Separator className="my-4" />
<div className="mb-2">
@ -425,13 +655,23 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange}
showValidationErrors={showValidationErrors}
defaultModel={defaultModel}
onFallbackTierChange={
customTierSet ? (fallbackTierId) => commitTierRows(customTierSet.tiers, fallbackTierId) : undefined
}
/>
),
},
{
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 +694,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: escalating a pinned session walks the built-in tier ladder, which your tier set replaces."
: "Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment."}
</span>
</>
),
@ -537,7 +779,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 +802,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
rules={keywordTierRules}
onChange={onKeywordTierRulesChange}
tierLabels={value.tier_labels}
tierNames={customTierSet ? activeTierNames(customTierSet) : undefined}
/>
)}
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}

View file

@ -7,7 +7,7 @@ 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";
@ -22,12 +22,13 @@ 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 +36,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 +99,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,13 +107,18 @@ 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>
))}
</SelectContent>
</Select>
{tierNames && !tierNames.includes(rule.tier) && (
<span className="block mt-1 text-xs text-destructive">
{rule.tier} is not in the tier set. Re-point or remove this rule.
</span>
)}
</div>
<Button
variant="ghost"

View file

@ -71,6 +71,7 @@ const { mockFetchAvailableModels, mockFetchAllModelDeployments } = vi.hoisted(()
vi.mock("../networking", () => ({
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
testAutoRouterRouting: vi.fn(),
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true, error: null }),
}));
vi.mock("@/components/llm_calls/fetch_models", () => ({
@ -864,3 +865,39 @@ describe("AddAutoRouterTab", () => {
});
});
});
describe("AddAutoRouterTab submit guard", () => {
it("keeps the submission locked until the create itself resolves, so a double click cannot duplicate", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);
let releaseCreate: () => void = () => {};
vi.mocked(handleAddAutoRouterSubmit).mockClear();
vi.mocked(handleAddAutoRouterSubmit).mockImplementation(
() => new Promise<void>((resolve) => (releaseCreate = resolve)),
);
renderWithProviders(<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Admin" />);
await user.type(screen.getByPlaceholderText(/smart_router/i), "once-only-router");
const submit = screen.getByRole("button", { name: /add auto router/i });
await user.click(submit);
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledTimes(1));
await user.click(submit);
expect(handleAddAutoRouterSubmit).toHaveBeenCalledTimes(1);
releaseCreate();
});
});
describe("AddAutoRouterTab tier editor mode", () => {
it("a preset prefill closes the tier editor, so the fresh built-in set opens collapsed", async () => {
renderWithProviders(<Harness />);
expandDetailedConfiguration();
fireEvent.click(screen.getByRole("button", { name: "Edit tiers" }));
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
openTemplateDropdown();
fireEvent.click(optionByLabel("Anthropic Family")!);
expect(screen.queryByRole("button", { name: "Done" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Edit tiers" })).toBeInTheDocument();
});
});

View file

@ -13,7 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox";
import { modelAvailableCall } from "../networking";
import { modelAvailableCall, validateAutoRouterConfig } from "../networking";
import { all_admin_roles } from "@/utils/roles";
import { type ModelWriteScope } from "@/utils/modelPermissions";
import TeamDropdown from "../common_components/team_dropdown";
@ -27,6 +27,8 @@ import ComplexityRouterConfig, {
DEFAULT_SESSION_AFFINITY,
DEFAULT_DEPLOYMENT_AFFINITY,
DEFAULT_TIER_DISTANCE_PENALTY,
CustomTierSet,
effectiveClassifierType,
} from "./ComplexityRouterConfig";
import { KeywordTierRule } from "./KeywordTierRules";
import { DEFAULT_ESCALATION_KEYWORDS } from "./EscalationKeywords";
@ -39,8 +41,10 @@ import {
getPlanModeTierError,
getSemanticConfigError,
getTierLabelsError,
getCustomTierRowsError,
getKeywordRuleTierError,
} from "./build_complexity_router_config";
import { resolveComplexityDefaultModel } from "./complexity_router_tiers";
import { customTierDefaultModel, 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";
@ -104,15 +108,16 @@ 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 tierConfigSummary = (tiers: ComplexityTiers, customTierSet?: CustomTierSet): string => {
const rows: [string, string[]][] = customTierSet
? customTierSet.tiers.map((row): [string, string[]] => [row.name.trim() || "New tier", row.models])
: [
["Simple", tiers.SIMPLE],
["Medium", tiers.MEDIUM],
["Complex", tiers.COMPLEX],
["Reasoning", tiers.REASONING],
];
const parts = rows
.filter(([, models]) => models.length > 0)
.map(([label, models]) => `${label}: ${models.join(", ")}`);
return parts.length > 0 ? parts.join(" · ") : "No tiers configured yet";
@ -128,10 +133,13 @@ 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) ??
(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) ??
getKeywordRuleTierError(keywordTierRules, config.custom_tier_set) ??
getReferencedModelsError(referencedModelsParams, availability);
const autoRouterSchema = (requiresTeamScope: boolean) =>
@ -200,6 +208,8 @@ 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 [isSubmitting, setIsSubmitting] = 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 +309,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
);
const applyPrefill = (prefill: PresetPrefill) => {
setEditingTiers(false);
setComplexityRouterConfig(prefill.complexityRouterConfig);
setCustomTechnicalKeywords(prefill.customTechnicalKeywords);
setKeywordTierRules(prefill.keywordTierRules);
@ -328,9 +339,12 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
setDetailsExpanded(presetState.viaDeployments);
};
const customTierSet = complexityRouterConfig.custom_tier_set;
const referencedModelsParams = {
tiers: complexityRouterConfig.tiers,
classifierType: complexityRouterConfig.classifier_type,
tiers: customTierSet
? Object.fromEntries(customTierSet.tiers.map((row) => [row.name.trim() || row.id, row.models] as const))
: complexityRouterConfig.tiers,
classifierType: effectiveClassifierType(complexityRouterConfig),
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
semanticMatchingEnabled,
embeddingModel,
@ -346,6 +360,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,
@ -375,20 +390,27 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
reasoningOverrideMinScore: complexityRouterConfig.reasoning_override_min_score,
};
// One in-flight submission: a second click would race the first into a duplicate /model/new.
const submitRecommendedRouter = async (name: string) => {
const { tiers, tierLabels, classifierType, classifierLlmConfig } = complexityRouterConfigParams;
const missingTiersError = getMissingTiersError(tiers);
if (missingTiersError) {
setShowValidationErrors(true);
toast.fromError(missingTiersError);
return;
if (isSubmitting) return;
setIsSubmitting(true);
try {
await submitRecommendedRouterInner(name);
} finally {
setIsSubmitting(false);
}
};
const tierLabelsError = getTierLabelsError(tierLabels);
if (tierLabelsError) {
const submitRecommendedRouterInner = async (name: string) => {
const { tiers, tierLabels, classifierLlmConfig } = complexityRouterConfigParams;
const classifierType = effectiveClassifierType(complexityRouterConfig);
const tierSetError = customTierSet
? getCustomTierRowsError(customTierSet)
: getMissingTiersError(tiers) ?? getTierLabelsError(tierLabels);
if (tierSetError) {
setShowValidationErrors(true);
toast.fromError(tierLabelsError);
toast.fromError(tierSetError);
return;
}
@ -398,7 +420,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
return;
}
const keywordRulesError = getKeywordTierRulesError(keywordTierRules);
const keywordRulesError =
getKeywordTierRulesError(keywordTierRules) ?? getKeywordRuleTierError(keywordTierRules, customTierSet);
if (keywordRulesError) {
setShowValidationErrors(true);
toast.fromError(keywordRulesError);
@ -423,7 +446,9 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
return;
}
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
const defaultModel = customTierSet
? customTierDefaultModel(customTierSet, complexityRouterConfig.default_model)
: resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
const validatedFields = requiresTeamScope
? (["auto_router_name", "team_id"] as const)
: (["auto_router_name"] as const);
@ -433,6 +458,19 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
return;
}
// Dry-run the write gate against the exact config the save will send.
const builtConfig = buildComplexityRouterConfig(complexityRouterConfigParams);
const serverVerdict = await validateAutoRouterConfig(
accessToken,
builtConfig,
requiresTeamScope ? form.getValues("team_id") : undefined,
);
if (!serverVerdict.valid && serverVerdict.error) {
setShowValidationErrors(true);
toast.fromError(serverVerdict.error);
return;
}
// 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 +480,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 () => {
@ -464,9 +502,12 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const handleTestConnection = () => {
const testTargetParams = {
tiers: complexityRouterConfig.tiers,
customTiers: customTierSet?.tiers.map((row) => ({ name: row.name.trim() || row.id, models: row.models })),
semanticMatchingEnabled,
embeddingModel,
defaultModel: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model),
defaultModel: customTierSet
? customTierDefaultModel(customTierSet, complexityRouterConfig.default_model)
: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model),
};
const targets = buildAutoRouterTestTargets(testTargetParams);
@ -581,13 +622,15 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
</span>
{!detailsExpanded && (
<span className="text-xs text-muted-foreground line-clamp-2">
{tierConfigSummary(complexityRouterConfig.tiers)}
{tierConfigSummary(complexityRouterConfig.tiers, customTierSet)}
</span>
)}
</button>
{detailsExpanded && (
<div className="px-4 pb-4">
<ComplexityRouterConfig
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
modelInfo={modelInfo}
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
@ -670,7 +713,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
<BlockedReasonTooltip reason={submitBlockedReason}>
<Button
type="button"
disabled={submitBlockedReason !== null}
disabled={submitBlockedReason !== null || isSubmitting}
onClick={() => {
void handleAutoRouterSubmit();
}}
@ -694,10 +737,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}
/>

View file

@ -10,6 +10,8 @@ export interface AutoRouterTestTarget {
export interface BuildAutoRouterTestTargetsParams {
tiers: ComplexityTiers;
/** An edited tier set's rows. When present they replace the built-in record as the tier source. */
customTiers?: { name: string; models: string[] }[];
semanticMatchingEnabled: boolean;
embeddingModel: string | undefined;
/** The resolved default model - see resolveComplexityDefaultModel. A live fallback destination,
@ -28,12 +30,16 @@ const TIER_ORDER = Object.keys({
export const buildAutoRouterTestTargets = ({
tiers,
customTiers,
semanticMatchingEnabled,
embeddingModel,
defaultModel,
}: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => {
const tieredByModel = TIER_ORDER.reduce<Record<string, string[]>>((acc, tier) => {
return (tiers[tier] ?? []).reduce((tierAcc, rawModel) => {
const tierPools: [string, string[]][] = customTiers
? customTiers.map((row) => [row.name, row.models])
: TIER_ORDER.map((tier) => [tier, tiers[tier] ?? []]);
const tieredByModel = tierPools.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] };

View file

@ -8,6 +8,11 @@ import {
getTierLabelsError,
hydrateTierLabels,
BuildComplexityRouterConfigParams,
getCustomTierRowsError,
hydrateCustomTierSet,
hydratePlanModeMinTier,
serializeCustomTierSet,
getKeywordRuleTierError,
} from "./build_complexity_router_config";
const tiers = {
@ -673,3 +678,177 @@ describe("buildComplexityRouterConfig tier model params", () => {
});
});
});
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: "grade it",
},
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",
});
});
it("omits the plan-mode floor when its row left the set, rather than a stale name", () => {
expect(buildComplexityRouterConfig({ ...customParams, planModeMinTier: "gone" })).not.toHaveProperty(
"plan_mode_min_tier",
);
});
it("round-trips through hydrate with canonical ids for built-in names", () => {
const hydrated = hydrateCustomTierSet(serializeCustomTierSet(customTierSet));
expect(hydrated).toEqual({
tiers: [
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-4o-mini"] },
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: ["claude-sonnet-4"] },
{ id: "stored-2", name: "SECURITY_REVIEW", definition: "security audits", models: ["claude-sonnet-5"] },
],
fallback_tier_id: "COMPLEX",
});
expect(serializeCustomTierSet(hydrated!)).toEqual(serializeCustomTierSet(customTierSet));
});
it("hydrates a hand-written config this UI never produced, in stored severity order", () => {
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");
});
it("hydrates no set from a config without tier_definitions", () => {
expect(hydrateCustomTierSet({ tiers: { SIMPLE: ["m"] } })).toBeUndefined();
});
it("getCustomTierRowsError reports the first per-row gap and passes a complete set", () => {
expect(getCustomTierRowsError(customTierSet)).toBeNull();
expect(
getCustomTierRowsError({ ...customTierSet, tiers: [{ id: "a", name: " ", definition: "d", models: ["m"] }] }),
).toBe("Name every tier");
expect(
getCustomTierRowsError({ ...customTierSet, tiers: [{ id: "a", name: "A", definition: " ", models: ["m"] }] }),
).toContain("definition");
expect(
getCustomTierRowsError({
...customTierSet,
tiers: [...customTierSet.tiers, { id: "new-1", name: "DRAFT", definition: "d", models: [] }],
}),
).toContain("model");
expect(getCustomTierRowsError({ ...customTierSet, fallback_tier_id: "gone" })).toContain("Fallback");
});
it("hydratePlanModeMinTier maps the stored name to its row id; an unresolvable floor is off", () => {
expect(hydratePlanModeMinTier("SECURITY_REVIEW", customTierSet)).toBe("sec");
expect(hydratePlanModeMinTier("NOT_A_TIER", customTierSet)).toBeUndefined();
expect(hydratePlanModeMinTier("COMPLEX", undefined)).toBe("COMPLEX");
expect(hydratePlanModeMinTier(" ", customTierSet)).toBeUndefined();
});
});
describe("custom tier set keeps llm-only inputs the raw form field would drop", () => {
it("emits classifier context knobs while the raw classifier_type still says heuristic", () => {
const config = buildComplexityRouterConfig({
...baseParams,
customTierSet: {
tiers: [
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["m1"] },
{ id: "sec", name: "AUDIT", definition: "audits", models: ["m2"] },
],
fallback_tier_id: "sec",
},
classifierType: "heuristic",
classifierLlmConfig: { model: "clf", timeout_ms: 400 },
classifierContextWindowSize: 5,
classifierContextPerTurnChars: 300,
classifierContextIncludeAssistantTurns: true,
});
expect(config.classifier_type).toBe("llm");
expect(config.classifier_llm_config).toEqual({ model: "clf", timeout_ms: 400 });
expect(config.classifier_context_window_size).toBe(5);
expect(config.classifier_context_per_turn_chars).toBe(300);
expect(config.classifier_context_include_assistant_turns).toBe(true);
});
});
describe("getKeywordRuleTierError", () => {
const set = {
tiers: [
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["m1"] },
{ id: "sec", name: "AUDIT", definition: "audits", models: ["m2"] },
],
fallback_tier_id: "sec",
};
it("names a rule tier the edited set no longer defines", () => {
expect(getKeywordRuleTierError([{ id: "r1", keywords: ["k"], tier: "MEDIUM" }], set)).toContain("MEDIUM");
});
it("passes rules pointing at active custom or built-in tiers", () => {
expect(getKeywordRuleTierError([{ id: "r1", keywords: ["k"], tier: "AUDIT" }], set)).toBeNull();
expect(getKeywordRuleTierError([{ id: "r1", keywords: ["k"], tier: "MEDIUM" }], undefined)).toBeNull();
});
});
describe("custom wire params", () => {
it("scopes name-keyed params to the active tiers and their models", () => {
const config = buildComplexityRouterConfig({
...baseParams,
customTierSet: {
tiers: [
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["m1"] },
{ id: "row-a", name: "AUDIT", definition: "audits", models: ["opus"] },
],
fallback_tier_id: "SIMPLE",
},
classifierLlmConfig: { model: "clf", timeout_ms: 400 },
tierModelParams: {
AUDIT: { opus: { reasoning_effort: "high" } },
GONE: { opus: { reasoning_effort: "low" } },
},
});
expect(config.tier_model_configs).toEqual({
AUDIT: [{ model_name: "opus", litellm_params: { reasoning_effort: "high" } }],
});
});
});

View file

@ -1,6 +1,11 @@
import { KeywordTierRule } from "./KeywordTierRules";
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,12 +14,14 @@ import {
ClassifierType,
ComplexityTierLabels,
ComplexityTiers,
CustomTierSet,
DimensionWeights,
TIER_DESCRIPTIONS,
TierBoundaries,
TokenThresholds,
effectiveTierLabel,
heuristicScoringRoleFor,
activeTierNames,
} from "./ComplexityRouterConfig";
/**
@ -74,6 +81,7 @@ const scorerKnobPayload = ({
export interface BuildComplexityRouterConfigParams {
tiers: ComplexityTiers;
customTierSet?: CustomTierSet;
defaultModel: string | undefined;
planModeMinTier: string | undefined;
tierLabels: ComplexityTierLabels | undefined;
@ -103,8 +111,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;
@ -211,8 +226,122 @@ export const getSemanticConfigError = ({
return null;
};
// Keys the backend rejects alongside tier_definitions. Mirrors
// validate_complexity_router_config_write (litellm/router_utils/auto_router_model_naming.py);
// add new backend rejections here. Drift shows inline via the pre-save dry-run of that same
// validator, but only this list can auto-drop a stored key so the save succeeds.
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",
];
// A plain map: rows are the definition list; a blank definition on a built-in name inherits it.
export const serializeCustomTierSet = (
customTierSet: CustomTierSet,
): Pick<ComplexityRouterConfigPayload, "tiers" | "tier_definitions" | "fallback_tier"> => ({
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() }),
})),
...(() => {
const fallbackName = customTierSet.tiers.find((row) => row.id === customTierSet.fallback_tier_id)?.name.trim();
return fallbackName ? { fallback_tier: fallbackName } : {};
})(),
});
// The inverse, for the edit modal. A built-in name keeps its canonical key as the row id so
// Restore and the explicit exit recognize it; other rows get positional ids.
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) => tier.toLowerCase() === name.trim().toLowerCase()) ?? `stored-${index}`,
name: name.trim(),
definition: typeof description === "string" ? description.trim() : "",
models: normalizeTierModels(storedTiers[name.trim()]),
},
];
});
if (rows.length === 0) return undefined;
const storedFallback = typeof parsedConfig.fallback_tier === "string" ? parsedConfig.fallback_tier.trim() : "";
return { tiers: rows, fallback_tier_id: rows.find((row) => row.name === 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 customTierSet.tiers.find((row) => row.name.trim() === stored.trim())?.id;
};
// Everything an edited tier set forces onto the wire, shared by both builders so the invariant
// set has one owner; the floor resolves from row id to the row's current name.
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";
import { scopeTierParamsToRows } from "./custom_tier_set";
/**
* A keyword rule whose tier left the active set would pass row validation (which only checks for
* empty keywords) and then fail the save at the dry-run wall; this names the stranded rule inline
* while the operator is still editing the tier set.
*/
export const getKeywordRuleTierError = (
keywordTierRules: KeywordTierRule[],
customTierSet: CustomTierSet | undefined,
): string | null => {
const active = new Set(activeTierNames(customTierSet));
const stranded = [...new Set(keywordTierRules.map((rule) => rule.tier).filter((tier) => !active.has(tier)))];
if (stranded.length === 0) return null;
return `Keyword rule tier(s) not in the tier set: ${stranded.join(", ")}. Re-point or remove those rules.`;
};
export const buildComplexityRouterConfig = ({
tiers,
customTierSet,
defaultModel,
planModeMinTier,
tierLabels,
@ -241,7 +370,12 @@ export const buildComplexityRouterConfig = ({
reasoningOverrideMinScore,
tierModelParams,
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
const serializedTierModelConfigs = serializeTierModelConfigs(tiers, tierModelParams);
const serializedTierModelConfigs = serializeTierModelConfigs(
customTierSet
? Object.fromEntries(customTierSet.tiers.map((row) => [row.name.trim(), row.models] as const))
: tiers,
customTierSet ? scopeTierParamsToRows(tierModelParams, customTierSet.tiers) : tierModelParams,
);
const cleanedEscalationKeywords = escalationKeywords.map((keyword) => keyword.trim()).filter(Boolean);
const cleanedKeywordTierRules = serializeKeywordTierRules(keywordTierRules);
const cleanedTierLabels = serializeTierLabels(tierLabels);
@ -255,25 +389,29 @@ export const buildComplexityRouterConfig = ({
};
const scorerKnobs = scorerKnobPayload(scorerInputs);
return {
// A custom tier set forces the LLM classifier on the wire, so llm-only inputs (context knobs,
// the classifier config) must survive serialization even while the raw form field still says
// heuristic; reading the raw field here is what silently dropped them.
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 +434,23 @@ export const buildComplexityRouterConfig = ({
...(returnRawModelName && { return_raw_model_name: true }),
...scorerKnobs,
};
if (!customTierSet) return payload;
// An edited tier set: drop every input the backend rejects beside tier_definitions (any of
// which would turn a disabled control's stale state into a rejected save), then force the
// custom-mode wire fields through their single owner.
const {
tier_labels: _tierLabels,
classifier_fallback: _classifierFallback,
adaptive: _adaptive,
adaptive_weights: _adaptiveWeights,
tier_distance_penalty: _tierDistancePenalty,
adaptive_eligible: _adaptiveEligible,
tier_boundaries: _tierBoundaries,
token_thresholds: _tokenThresholds,
dimension_weights: _dimensionWeights,
reasoning_override_min_score: _reasoningOverrideMinScore,
plan_mode_min_tier: planModeMinTierId,
...rest
} = payload;
return { ...rest, ...customTierSetWireFields(customTierSet, classifierLlmConfig, planModeMinTierId) };
};

View file

@ -1,4 +1,4 @@
import type { ComplexityTiers } from "./ComplexityRouterConfig";
import type { ComplexityTiers, CustomTierSet } from "./ComplexityRouterConfig";
import type { ComplexityTier } from "./KeywordTierRules";
export type TierModelParams = Record<string, unknown>;
@ -70,7 +70,7 @@ export const hydrateTierModelParams = (
* tiers this editor does not render pass through rather than being dropped now the key is managed.
*/
export const serializeTierModelConfigs = (
tiers: ComplexityTiers,
tiers: ComplexityTiers | Record<string, string[]>,
tierModelParams: TierModelParamsByTier | undefined,
): Record<string, { model_name: string; litellm_params: TierModelParams }[]> | undefined => {
if (tierModelParams === undefined) return undefined;
@ -133,7 +133,26 @@ 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,
}));
/**
* The backend derivation for an edited tier set, over the rows the payload will carry: the pin
* wins, then the fallback tier's pool, then a row named MEDIUM or SIMPLE if the set kept one.
*/
export const customTierDefaultModel = (customTierSet: CustomTierSet, pinned?: string): string | undefined => {
const rowNamed = (name: string) => customTierSet.tiers.find((row) => row.name.trim() === 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 {
/** 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[];
}
// The draft IS the wire list (severity order = tier_definitions order); 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;
}
const BUILT_IN_TIER_NAMES = Object.keys({
SIMPLE: null,
MEDIUM: null,
COMPLEX: null,
REASONING: null,
} satisfies Record<keyof ComplexityTiers, null>);
export const isBuiltInTierName = (name: string): boolean =>
BUILT_IN_TIER_NAMES.some((tier) => tier.toLowerCase() === name.trim().toLowerCase());
export const activeTierNames = (customTierSet: CustomTierSet | undefined): string[] =>
customTierSet ? customTierSet.tiers.map((tier) => tier.name.trim()).filter(Boolean) : [...BUILT_IN_TIER_NAMES];
// Per-row errors the backend cannot phrase; everything payload-level is the write gate's job,
// dry-run before every save. The Done button and both submit gates read this one guard.
export const getCustomTierRowsError = (customTierSet: CustomTierSet): string | null => {
const rows = customTierSet.tiers;
if (rows.some((row) => !row.name.trim())) return "Name every tier";
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;
};
// Params are name-keyed; keys no live row owns are stale and drop.
export const scopeTierParamsToRows = <T>(
params: Record<string, T> | undefined,
rows: { name: string }[],
): Record<string, T> | undefined => {
if (!params) return params;
const names = new Set(rows.map((row) => row.name.trim()));
const scoped = Object.fromEntries(Object.entries(params).filter(([key]) => names.has(key)));
return Object.keys(scoped).length > 0 ? scoped : undefined;
};

View file

@ -429,3 +429,70 @@ describe("buildUpdatedComplexityRouterConfig tier model params", () => {
expect(result).not.toHaveProperty("tier_model_configs");
});
});
describe("buildUpdatedComplexityRouterConfig custom tier sets", () => {
const customValue = {
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
custom_tier_set: {
tiers: [
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-4o-mini"] },
{ id: "sec", name: "AUDIT", definition: "security audits", models: ["claude-sonnet-5"] },
],
fallback_tier_id: "sec",
},
classifier_type: "heuristic" as const,
classifier_llm_config: { model: "haiku-classifier", timeout_ms: 400, system_prompt: "grade it" },
session_affinity: true,
adaptive: true,
tier_labels: { SIMPLE: "Cheap" },
plan_mode_min_tier: "sec",
};
const storedWithRejected = {
...STORED,
session_affinity: true,
adaptive: true,
tier_labels: { SIMPLE: "Cheap" },
plan_mode_min_tier: "AUDIT",
};
it("emits the edited set through the shared wire owner and drops stored keys the backend rejects", () => {
const result = buildUpdatedComplexityRouterConfig(storedWithRejected, customValue, undefined, hydratedState);
expect(result).toEqual({
some_future_backend_key: { nested: true },
deployment_affinity: true,
keyword_tier_rules: [{ keywords: ["invoice", "refund"], tier: "MEDIUM" }],
semantic_keyword_matching: true,
embedding_model: "voyage-4-large",
match_threshold: 0.72,
tiers: { SIMPLE: ["gpt-4o-mini"], AUDIT: ["claude-sonnet-5"] },
tier_definitions: [{ name: "SIMPLE" }, { name: "AUDIT", description: "security audits" }],
fallback_tier: "AUDIT",
classifier_type: "llm",
classifier_llm_config: { model: "haiku-classifier", timeout_ms: 400 },
session_affinity: false,
escalation_keywords: [],
plan_mode_min_tier: "AUDIT",
});
});
it("follows a rename of the plan-mode floor's tier, because the floor points at the row id", () => {
const renamed = {
...customValue,
custom_tier_set: {
...customValue.custom_tier_set,
tiers: customValue.custom_tier_set.tiers.map((row) =>
row.id === "sec" ? { ...row, name: "SECURITY_REVIEW" } : row,
),
},
};
const result = buildUpdatedComplexityRouterConfig(storedWithRejected, renamed, undefined, hydratedState);
expect(result.plan_mode_min_tier).toBe("SECURITY_REVIEW");
expect(result.fallback_tier).toBe("SECURITY_REVIEW");
});
it("keeps a built-in save identical when no custom set exists", () => {
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, hydratedState);
expect(result).not.toHaveProperty("tier_definitions");
expect(result.tiers).toEqual(FORM_VALUE.tiers);
});
});

View file

@ -20,6 +20,7 @@ vi.mock("../networking", () => ({
modelPatchUpdateCall,
modelAvailableCall,
getAutoRouterClassifierDefaultPromptCall,
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true, error: null }),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) }));
@ -799,3 +800,85 @@ describe("EditAutoRouterModal plan-mode minimum tier", () => {
expect(savedConfig()).not.toHaveProperty("plan_mode_min_tier");
});
});
describe("EditAutoRouterModal custom tier sets", () => {
const CUSTOM_STORED = {
tiers: { CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["claude-sonnet-5"] },
tier_definitions: [
{ name: "CASUAL", description: "casual chat" },
{ name: "SECURITY_REVIEW", description: "security audits" },
],
fallback_tier: "SECURITY_REVIEW",
classifier_type: "llm",
classifier_llm_config: { model: "haiku-classifier", timeout_ms: 400 },
session_affinity: false,
escalation_keywords: [],
};
const renderCustomModal = (config: Record<string, unknown> = CUSTOM_STORED) =>
renderWithProviders(
<EditAutoRouterModal
isVisible
onCancel={vi.fn()}
onSuccess={vi.fn()}
modelData={{
model_name: "custom-router",
litellm_params: { model: "auto_router/complexity_router", complexity_router_config: config },
model_info: { id: "auto-2", access_groups: [] },
}}
accessToken="token"
userRole="Admin"
/>,
);
beforeEach(() => {
modelPatchUpdateCall.mockClear();
});
it("saves a stored custom-tier router whose built-in tier keys are empty, round-tripping the set", async () => {
const user = userEvent.setup();
renderCustomModal();
await screen.findByText(/Escalation Keywords/i);
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
const config = savedConfig();
expect(config.tier_definitions).toEqual(CUSTOM_STORED.tier_definitions);
expect(config.fallback_tier).toBe("SECURITY_REVIEW");
expect(config.tiers).toEqual(CUSTOM_STORED.tiers);
});
it("still requires a classifier model even when only the forced effective type is llm", async () => {
const user = userEvent.setup();
renderCustomModal({ ...CUSTOM_STORED, classifier_type: "heuristic", classifier_llm_config: undefined });
await screen.findByText(/Escalation Keywords/i);
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(toast.fromError).toHaveBeenCalledWith(expect.stringContaining("classifier model")));
expect(modelPatchUpdateCall).not.toHaveBeenCalled();
});
});
describe("EditAutoRouterModal tier editor mode", () => {
it("resets the tier editor when the modal re-initializes for another router", async () => {
const user = userEvent.setup();
const { rerender } = renderModal();
await screen.findByText(/Escalation Keywords/i);
await user.click(screen.getByRole("button", { name: "Edit tiers" }));
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
rerender(
<EditAutoRouterModal
isVisible
onCancel={vi.fn()}
onSuccess={vi.fn()}
modelData={{ ...MODEL_DATA, model_info: { id: "auto-3", access_groups: [] } }}
accessToken="token"
userRole="Admin"
/>,
);
expect(await screen.findByRole("button", { name: "Edit tiers" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Done" })).not.toBeInTheDocument();
});
});

View file

@ -11,10 +11,11 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
import AccessGroupTagsCombobox from "../add_model/AccessGroupTagsCombobox";
import ModelChoiceCombobox, { type ModelChoice } from "../add_model/ModelChoiceCombobox";
import { modelAvailableCall, modelPatchUpdateCall } from "../networking";
import { modelAvailableCall, modelPatchUpdateCall, validateAutoRouterConfig } from "../networking";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
import {
customTierDefaultModel,
hydrateTierModelParams,
normalizeTierModels,
resolveComplexityDefaultModel,
@ -29,7 +30,14 @@ import {
hydrateTierLabels,
normalizeClassifierLlmConfig,
serializeTierLabels,
KEYS_REJECTED_WITH_CUSTOM_TIERS,
customTierSetWireFields,
getCustomTierRowsError,
hydrateCustomTierSet,
hydratePlanModeMinTier,
getKeywordRuleTierError,
} from "../add_model/build_complexity_router_config";
import { scopeTierParamsToRows } from "../add_model/custom_tier_set";
import { KeywordTierRule } from "../add_model/KeywordTierRules";
import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching";
import { hydrateKeywordTierRules, serializeKeywordTierRules } from "../add_model/complexity_router_keywords";
@ -47,6 +55,8 @@ import ComplexityRouterConfig, {
DEFAULT_DEPLOYMENT_AFFINITY,
DEFAULT_TIER_DISTANCE_PENALTY,
heuristicScoringRole,
CustomTierSet,
effectiveClassifierType,
} from "../add_model/ComplexityRouterConfig";
import {
Dialog,
@ -72,6 +82,8 @@ interface EditAutoRouterModalProps {
const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"tiers",
"tier_model_configs",
"tier_definitions",
"fallback_tier",
"default_model",
"plan_mode_min_tier",
"tier_labels",
@ -120,11 +132,12 @@ export const hydratePinnedDefaultModel = (
storedConfigDefaultModel: unknown,
litellmParamsDefaultModel: string | null | undefined,
tiers: ComplexityTiers,
customTierSet?: CustomTierSet,
): string | undefined => {
if (typeof storedConfigDefaultModel === "string" && storedConfigDefaultModel.trim()) {
return storedConfigDefaultModel;
}
const tierDerived = resolveComplexityDefaultModel(tiers);
const tierDerived = customTierSet ? customTierDefaultModel(customTierSet) : resolveComplexityDefaultModel(tiers);
const externalOverride = litellmParamsDefaultModel?.trim();
return externalOverride && externalOverride !== tierDerived ? externalOverride : undefined;
};
@ -149,13 +162,57 @@ export const buildUpdatedComplexityRouterConfig = (
return customTechnicalKeywords !== undefined && key === "custom_technical_keywords";
};
const preservedConfig = Object.fromEntries(Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key)));
// A custom save drops stored keys the backend rejects beside tier_definitions; a built-in save
// drops a stored classification_prompt, which requires tier_definitions.
const preservedConfig = Object.fromEntries(
Object.entries(toRecord(storedConfig))
.filter(([key]) => !isManaged(key))
.filter(([key]) =>
value.custom_tier_set ? !KEYS_REJECTED_WITH_CUSTOM_TIERS.includes(key) : key !== "classification_prompt",
),
);
const adaptiveEligible = value.adaptive_eligible ?? "all";
const storedKeywordRules = keywordMatching ? serializeKeywordTierRules(keywordMatching.keywordTierRules) : [];
const serializedTierLabels = serializeTierLabels(value.tier_labels);
const scorerRuns = heuristicScoringRole(value) !== "never";
const serializedTierModelConfigs = serializeTierModelConfigs(value.tiers, value.tier_model_params);
const serializedTierModelConfigs = serializeTierModelConfigs(
value.custom_tier_set
? Object.fromEntries(value.custom_tier_set.tiers.map((row) => [row.name.trim(), row.models] as const))
: value.tiers,
value.custom_tier_set
? scopeTierParamsToRows(value.tier_model_params, value.custom_tier_set.tiers)
: value.tier_model_params,
);
if (value.custom_tier_set) {
return {
...preservedConfig,
...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }),
...(value.default_model?.trim() && { default_model: value.default_model }),
...(value.classifier_context_window_size !== undefined && {
classifier_context_window_size: value.classifier_context_window_size,
}),
...(value.classifier_context_per_turn_chars !== undefined && {
classifier_context_per_turn_chars: value.classifier_context_per_turn_chars,
}),
...(value.classifier_context_include_assistant_turns !== undefined && {
classifier_context_include_assistant_turns: value.classifier_context_include_assistant_turns,
}),
deployment_affinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
...(customTechnicalKeywords &&
customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),
...(value.return_raw_model_name && { return_raw_model_name: true }),
...(keywordMatching && {
...(storedKeywordRules.length > 0 && { keyword_tier_rules: storedKeywordRules }),
...(keywordMatching.semanticMatchingEnabled && {
semantic_keyword_matching: true,
embedding_model: keywordMatching.embeddingModel,
match_threshold: keywordMatching.matchThreshold,
}),
}),
...customTierSetWireFields(value.custom_tier_set, value.classifier_llm_config, value.plan_mode_min_tier),
};
}
return {
...preservedConfig,
@ -269,6 +326,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
const [editingTiers, setEditingTiers] = useState(false);
const [routerConfig, setRouterConfig] = useState<any>(null);
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState<string[]>([]);
const [keywordTierRules, setKeywordTierRules] = useState<KeywordTierRule[]>([]);
@ -293,12 +351,15 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
// 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);
: (complexityRouterConfig.custom_tier_set
? getCustomTierRowsError(complexityRouterConfig.custom_tier_set)
: (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) ??
getKeywordRuleTierError(keywordTierRules, complexityRouterConfig.custom_tier_set);
useEffect(() => {
if (isVisible && modelData) {
@ -334,6 +395,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
}, [isVisible, accessToken]);
const initializeForm = () => {
setEditingTiers(false);
try {
if (isComplexityRouterModel) {
// Parse the complexity_router_config if it exists and is a string
@ -349,18 +411,18 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING),
};
const hydratedCustomTierSet = hydrateCustomTierSet(parsedConfig);
const hydratedComplexityRouterConfig: ComplexityRouterConfigValue = {
tiers: hydratedTiers,
custom_tier_set: hydratedCustomTierSet,
tier_model_params: hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs),
default_model: hydratePinnedDefaultModel(
parsedConfig.default_model,
modelData.litellm_params?.complexity_router_default_model,
hydratedTiers,
hydratedCustomTierSet,
),
plan_mode_min_tier:
typeof parsedConfig.plan_mode_min_tier === "string" && parsedConfig.plan_mode_min_tier.trim() !== ""
? parsedConfig.plan_mode_min_tier
: undefined,
plan_mode_min_tier: hydratePlanModeMinTier(parsedConfig.plan_mode_min_tier, hydratedCustomTierSet),
tier_labels: hydrateTierLabels(parsedConfig.tier_labels),
classifier_type: parsedConfig.classifier_type || "heuristic",
classifier_llm_config: parsedConfig.classifier_llm_config,
@ -452,13 +514,17 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
const saveValues = async (values: EditAutoRouterFormValues) => {
if (isComplexityRouterModel) {
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
if (Object.values(tiers).every((models) => models.length === 0)) {
const { tiers, custom_tier_set, classifier_llm_config } = complexityRouterConfig;
const builtInError = Object.values(tiers).every((models) => models.length === 0)
? "Please select at least one model for a complexity tier"
: null;
const rowsError = custom_tier_set ? getCustomTierRowsError(custom_tier_set) : builtInError;
if (rowsError) {
setShowValidationErrors(true);
toast.fromError("Please select at least one model for a complexity tier");
toast.fromError(rowsError);
return;
}
if (classifier_type === "llm" && !classifier_llm_config?.model) {
if (effectiveClassifierType(complexityRouterConfig) === "llm" && !classifier_llm_config?.model) {
setShowValidationErrors(true);
toast.fromError("Please select a classifier model, or switch back to Heuristic");
return;
@ -467,7 +533,8 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
// keyword rule with no keyword, and semantic_keyword_matching without an embedding model
// or keyword rules (complexity_router/config.py), so without these a save fails as a raw
// 400 instead of an inline message.
const keywordRulesError = getKeywordTierRulesError(keywordTierRules);
const keywordRulesError =
getKeywordTierRulesError(keywordTierRules) ?? getKeywordRuleTierError(keywordTierRules, custom_tier_set);
if (keywordRulesError) {
setShowValidationErrors(true);
toast.fromError(keywordRulesError);
@ -486,7 +553,9 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
// build_complexity_router_config.ts for why create never can). init_complexity_router_deployment
// raises in that case (litellm/router.py), so block it rather than saving a router that
// fails at init.
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
const defaultModel = complexityRouterConfig.custom_tier_set
? customTierDefaultModel(complexityRouterConfig.custom_tier_set, complexityRouterConfig.default_model)
: resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
if (!defaultModel) {
setShowValidationErrors(true);
toast.fromError(
@ -498,20 +567,23 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
// Dual write: complexity_router_config.default_model (the pin marker hydratePinnedDefaultModel
// reads back) and complexity_router_default_model (what the backend routes on) must always be
// written together from the same value. Same pairing in add_auto_router_tab.tsx.
const updatedConfig = buildUpdatedComplexityRouterConfig(
modelData.litellm_params?.complexity_router_config,
complexityRouterConfig,
customTechnicalKeywords,
{ keywordTierRules, escalationKeywords, semanticMatchingEnabled, embeddingModel, matchThreshold },
);
// Dry-run the write gate against the exact config the save will send.
const serverVerdict = await validateAutoRouterConfig(accessToken, updatedConfig, modelData?.model_info?.team_id);
if (!serverVerdict.valid && serverVerdict.error) {
setShowValidationErrors(true);
toast.fromError(serverVerdict.error);
return;
}
const updatedLitellmParams = {
...modelData.litellm_params,
complexity_router_config: buildUpdatedComplexityRouterConfig(
modelData.litellm_params?.complexity_router_config,
complexityRouterConfig,
customTechnicalKeywords,
{
keywordTierRules,
escalationKeywords,
semanticMatchingEnabled,
embeddingModel,
matchThreshold,
},
),
complexity_router_config: updatedConfig,
complexity_router_default_model: defaultModel,
};
const updatedModelInfo = {
@ -610,6 +682,8 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
/* Complexity Router Configuration */
<div className="w-full">
<ComplexityRouterConfig
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
showValidationErrors={showValidationErrors}
modelInfo={modelInfo}
value={complexityRouterConfig}

View file

@ -2374,6 +2374,32 @@ export type AutoRouterRoutingTestResponse =
| { status: "success"; result: AutoRouterRoutingTestResult }
| { status: "error"; error: string };
export interface AutoRouterConfigValidationResult {
valid: boolean;
error: string | null;
}
/**
* Dry-run the backend's own config validator, so a form can surface the exact verdict the save
* would produce. A transport failure resolves valid so a flaky network cannot block a save the
* write gate would accept; the write path still runs the same validator authoritatively.
*/
export const validateAutoRouterConfig = async (
accessToken: string,
complexityRouterConfig: ComplexityRouterConfigPayload | Record<string, unknown>,
teamId?: string,
): Promise<AutoRouterConfigValidationResult> => {
try {
return await apiClient.post<AutoRouterConfigValidationResult>("/auto_router/validate_complexity_router_config", {
accessToken,
body: { complexity_router_config: complexityRouterConfig, ...(teamId && { team_id: teamId }) },
});
} catch (error) {
console.warn("auto_router/validate_complexity_router_config unreachable, deferring to the write gate", error);
return { valid: true, error: null };
}
};
export const testAutoRouterRouting = async (
accessToken: string,
request: AutoRouterRoutingTestRequest,

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;

File diff suppressed because one or more lines are too long