This commit is contained in:
tin-berri 2026-08-28 04:01:46 +00:00 committed by GitHub
commit 4add39120e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 935 additions and 252 deletions

View file

@ -32,6 +32,8 @@ vi.mock("@/components/shared/charts", () => ({
DonutChart: () => <div />,
BarChart: () => <div />,
CustomLegend: () => <div />,
chartColorValue: (color: string) => color,
DEFAULT_COLOR_CYCLE: ["blue", "cyan", "sky", "indigo", "violet", "purple", "fuchsia", "slate"],
SEQUENTIAL_COLOR_RAMP: ["indigo"],
}));

View file

@ -6,6 +6,7 @@ import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useMod
vi.mock("@/components/shared/charts", () => ({
DonutChart: ({ label }: { label: string }) => <div data-testid="donut">{label}</div>,
DEFAULT_COLOR_CYCLE: ["blue", "cyan", "sky", "indigo", "violet", "purple", "fuchsia", "slate"],
SEQUENTIAL_COLOR_RAMP: ["indigo", "blue"],
chartColorValue: (color: string) => color,
}));
@ -111,6 +112,19 @@ describe("TierTurnsChart", () => {
expect(screen.getByText("gpt-4o-mini")).toBeInTheDocument();
});
it("lists a custom tier's models, which the built-in name guard used to hide", () => {
render(
<TierTurnsChart
view={groupView({ tier_turns: { CASUAL: 3, SECURITY_REVIEW: 1 } })}
autoRouters={[deployment({ tiers: { CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["o1-preview"] } })]}
/>,
);
expect(screen.getByText(/SECURITY_REVIEW/)).toBeInTheDocument();
expect(screen.getByText("o1-preview")).toBeInTheDocument();
expect(screen.getByText("gpt-4o-mini")).toBeInTheDocument();
});
it("omits the model line for a tier with no configured models", () => {
render(<TierTurnsChart view={groupView()} autoRouters={[deployment({ tiers: { SIMPLE: [] } })]} />);

View file

@ -11,7 +11,7 @@ import {
type ComplexityTiers,
} from "@/components/add_model/ComplexityRouterConfig";
import { normalizeTierModels } from "@/components/add_model/complexity_router_tiers";
import { chartColorValue, DonutChart, type ChartColor } from "@/components/shared/charts";
import { chartColorValue, DEFAULT_COLOR_CYCLE, DonutChart } from "@/components/shared/charts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { viewGroup, type BenchmarkView } from "./autoRouterBenchmarks";
@ -71,7 +71,6 @@ const tierModelsFor = (
routerType: string,
autoRouters: readonly AutoRouterDeployment[],
): string[] => {
if (!isComplexityTier(tier)) return [];
const deployment = deploymentFor(routerName, routerType, autoRouters);
if (!deployment) return [];
const config = asRecord(deployment.litellm_params?.complexity_router_config);
@ -84,8 +83,6 @@ interface TierTurnsChartProps {
autoRouters: readonly AutoRouterDeployment[];
}
const TIER_DONUT_COLORS: readonly ChartColor[] = ["#c7d2fe", "#1e293b", "#d4b483", "#87a878"];
const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) => {
const group = viewGroup(view);
const entries = Object.entries(group?.tier_turns ?? {}).filter(([, turns]) => turns > 0);
@ -98,7 +95,7 @@ const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) =>
turns,
models: tierModelsFor(tier, group.router_name, group.router_type, autoRouters),
}));
const colors = slices.map((_, idx) => TIER_DONUT_COLORS[idx % TIER_DONUT_COLORS.length]);
const colors = slices.map((_, idx) => DEFAULT_COLOR_CYCLE[idx % DEFAULT_COLOR_CYCLE.length]);
return (
<Card>

View file

@ -10,6 +10,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import React from "react";
import ClassifierPromptEditor from "./ClassifierPromptEditor";
import { Restricted, RestrictedSection, restrictedBy } from "./TierRestrictions";
import HeuristicScoringConfig from "./HeuristicScoringConfig";
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
import {
@ -31,6 +32,7 @@ import {
usesLlmClassifier,
DEFAULT_HEURISTIC_FIRST_MAX_TIER,
HEURISTIC_FIRST_MAX_TIER_KEYS,
effectiveClassifierType,
} from "./ComplexityRouterConfig";
const DEFAULT_SCORING_EXPLANATION =
@ -89,18 +91,21 @@ 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();
const scorerRuns = heuristicScoringRole(value) !== "never";
const ranges = boundaryRanges(
scorerDefaults?.tier_boundaries,
value.tier_boundaries,
value.reasoning_override_min_score,
);
if (value.custom_tier_set) return null;
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">{scoringExplanation(value)}</span>
{ranges && (
{scorerRuns && ranges && (
<ul style={{ marginTop: 8, marginBottom: 0, paddingLeft: 20, fontSize: 13, color: "rgba(0, 0, 0, 0.45)" }}>
<li>
<strong>{effectiveTierLabel("SIMPLE", value.tier_labels)}</strong>: Score &lt; {ranges.simpleMedium}
@ -141,6 +146,54 @@ interface ClassificationMethodConfigProps {
defaultModel?: string;
}
const ClassifierTypeRadios: React.FC<{
value: ComplexityRouterConfigValue;
classifierType: ClassifierType;
onTypeChange: (classifierType: ClassifierType) => void;
}> = ({ value, classifierType, onTypeChange }) => {
const scorerLocked = Boolean(value.custom_tier_set);
const scorerLockedReason = restrictedBy(value, "heuristicClassifier")?.reason;
return (
<RadioGroup
value={classifierType}
onValueChange={(classifierType: unknown) => onTypeChange(classifierType as ClassifierType)}
className="w-full"
>
<div className="flex w-full flex-col items-start gap-2">
<SimpleTooltip content={scorerLockedReason}>
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
<RadioGroupItem value="heuristic" className="mt-0.5" disabled={scorerLocked} />
<span>
<strong className="font-semibold">Heuristic</strong>{" "}
<span className="text-muted-foreground">
(default), rule-based scoring with no API calls and &lt;1ms latency
</span>
</span>
</Label>
</SimpleTooltip>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="llm" className="mt-0.5" />
<span>
<strong className="font-semibold">LLM Classifier</strong>{" "}
<span className="text-muted-foreground">calls a model to decide the tier (e.g. a small/fast model)</span>
</span>
</Label>
<SimpleTooltip content={scorerLockedReason}>
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
<RadioGroupItem value="heuristic_first" className="mt-0.5" disabled={scorerLocked} />
<span>
<strong className="font-semibold">Heuristic first</strong>{" "}
<span className="text-muted-foreground">
scores locally, and only pays for the classifier when the score does not confidently land a cheap tier
</span>
</span>
</Label>
</SimpleTooltip>
</div>
</RadioGroup>
);
};
const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
value,
onChange,
@ -151,8 +204,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
defaultModel,
}) => {
const hasDefaultModel = Boolean(defaultModel);
const classifierType = effectiveClassifierType(value);
const classifierModelMissing =
showValidationErrors && usesLlmClassifier(value.classifier_type) && !value.classifier_llm_config?.model;
showValidationErrors && usesLlmClassifier(classifierType) && !value.classifier_llm_config?.model;
const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim());
const contextBudget = value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS;
const contextBudgetQuotesNothing = contextBudget > 0 && contextBudget < MIN_QUOTED_CONTEXT_TURN_CHARS;
@ -264,41 +318,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
return (
<>
<RadioGroup
value={value.classifier_type}
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 with no API calls and &lt;1ms latency
</span>
</span>
</Label>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="llm" className="mt-0.5" />
<span>
<strong className="font-semibold">LLM Classifier</strong>{" "}
<span className="text-muted-foreground">calls a model to decide the tier (e.g. a small/fast model)</span>
</span>
</Label>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="heuristic_first" className="mt-0.5" />
<span>
<strong className="font-semibold">Heuristic first</strong>{" "}
<span className="text-muted-foreground">
scores locally, and only pays for the classifier when the score does not confidently land a cheap tier
</span>
</span>
</Label>
</div>
</RadioGroup>
<ClassifierTypeRadios value={value} classifierType={classifierType} onTypeChange={handleClassifierTypeChange} />
{value.classifier_type === "heuristic_first" && (
{classifierType === "heuristic_first" && (
<div className="mt-4 space-y-2">
<strong className="block font-semibold">Decide locally up to</strong>
<Select
@ -323,7 +345,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</div>
)}
{usesLlmClassifier(value.classifier_type) && (
{usesLlmClassifier(classifierType) && (
<div className="mt-4 space-y-3">
<div>
<strong className="block mb-1 font-semibold">Classifier Model</strong>
@ -361,7 +383,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</SimpleTooltip>
</div>
<SimpleTooltip
content={usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined}
content={
restrictedBy(value, "classificationRubric")?.reason ??
(usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined)
}
className="w-full"
>
<Select
@ -373,7 +398,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
onValueChange={(preset: ClassificationRubric | null) =>
preset && handleClassificationRubricChange(preset)
}
disabled={usesCustomPrompt}
disabled={usesCustomPrompt || Boolean(value.custom_tier_set)}
>
<SelectTrigger aria-label="Classification Rubric" className="w-full">
<SelectValue />
@ -388,23 +413,25 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</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}
{restrictedBy(value, "classificationRubric")?.reason ??
(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}
/>
<Restricted by={restrictedBy(value, "classifierPrompt")}>
<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}
/>
</Restricted>
</div>
<div>
<strong className="block mb-1 font-semibold">If the classifier fails</strong>
<RestrictedSection heading="If the classifier fails" by={restrictedBy(value, "classifierFallback")}>
<RadioGroup
value={value.classifier_fallback ?? DEFAULT_CLASSIFIER_FALLBACK}
onValueChange={(fallback: unknown) => handleClassifierFallbackChange(fallback as ClassifierFallback)}
@ -439,7 +466,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
<span className="block text-xs text-muted-foreground">
Applies when the classifier call errors, times out, or returns an unparseable response.
</span>
</div>
</RestrictedSection>
<div>
<strong className="block mb-1 font-semibold">Context Window Size</strong>
<Input

View file

@ -1056,3 +1056,216 @@ describe("ComplexityRouterConfig custom technical keywords", () => {
expect(screen.queryByText("Custom Technical Keywords")).not.toBeInTheDocument();
});
});
describe("ComplexityRouterConfig tier editing", () => {
const renderEditor = (
value?: ComplexityRouterConfigValue,
props: Partial<React.ComponentProps<typeof ComplexityRouterConfig>> = {},
) => {
const onChange = vi.fn();
const view = renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
{...(value ? { value } : {})}
onChange={onChange}
editingTiers
onEditingTiersChange={vi.fn()}
{...props}
/>,
);
return { ...view, committed: () => onChange.mock.calls[0][0] as ComplexityRouterConfigValue, onChange };
};
const customValue: ComplexityRouterConfigValue = {
...defaultValue,
classifier_type: "llm",
classifier_llm_config: { model: "gpt-4", timeout_ms: 3000 },
custom_tier_set: {
tiers: [
{ id: "CASUAL", name: "CASUAL", definition: "small talk", models: ["gpt-3.5-turbo"] },
{ id: "sec", name: "SECURITY_REVIEW", definition: "audits", models: ["gpt-4"] },
],
fallback_tier_id: "CASUAL",
},
};
it("offers Edit tiers only when the parent owns the editor flag", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
expect(screen.queryByRole("button", { name: "Edit tiers" })).not.toBeInTheDocument();
});
it("renders the four built-in tiers before any edit, unchanged", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
expect(screen.getByRole("button", { name: "Edit tiers" })).toBeInTheDocument();
expect(screen.getByText("Tier 1 of 4", { exact: false })).toHaveTextContent("SIMPLE");
});
it("adds a row and moves the form into an edited tier set, which the built-in record never leaves", () => {
const { committed } = renderEditor();
fireEvent.click(screen.getByRole("button", { name: "Add tier" }));
const next = committed();
expect(next.custom_tier_set?.tiers).toHaveLength(5);
expect(next.tiers).toEqual(defaultValue.tiers);
});
it("renames a built-in tier straight from the editor, which is what makes the set custom", () => {
const { committed } = renderEditor();
fireEvent.change(screen.getByLabelText("Name for tier 3"), { target: { value: "SECURITY_REVIEW" } });
const next = committed();
expect(next.custom_tier_set?.tiers.map((row) => row.name)).toEqual([
"SIMPLE",
"MEDIUM",
"SECURITY_REVIEW",
"REASONING",
]);
expect(next.tiers).toEqual(defaultValue.tiers);
});
it("opening the editor and changing nothing leaves the router on the built-in tiers", () => {
const { onChange } = renderEditor();
expect(screen.getByRole("button", { name: "Done" })).toBeEnabled();
expect(onChange).not.toHaveBeenCalled();
});
it("swaps the display-name field for the tier-name field while the editor is open", () => {
const { rerender } = renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
expect(screen.getByLabelText("Display name for the Simple tier")).toBeInTheDocument();
rerender(<ComplexityRouterConfig {...baseProps} editingTiers onEditingTiersChange={vi.fn()} />);
expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
expect(screen.getByLabelText("Name for tier 1")).toBeInTheDocument();
});
it("replaces the prompt editor with the reason an edited tier set forbids it", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByText("A replacement prompt drops the tier bullets", { exact: false })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Change default prompt" })).not.toBeInTheDocument();
});
it("drops the scorer card entirely once an edited tier set replaces the heuristic", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.queryByText("How Classification Works")).not.toBeInTheDocument();
expect(screen.queryByText("scores each request across 7 dimensions", { exact: false })).not.toBeInTheDocument();
});
it("keeps the scorer card on a built-in router, whose tiers the score still decides", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByText("How Classification Works")).toBeInTheDocument();
expect(screen.getByText("scores each request across 7 dimensions", { exact: false })).toBeInTheDocument();
});
it("says why a custom row is blocked instead of only reddening its border", () => {
const missingDefinition: ComplexityRouterConfigValue = {
...customValue,
custom_tier_set: {
tiers: [customValue.custom_tier_set!.tiers[0], { id: "b", name: "AUDIT", definition: "", models: ["gpt-4"] }],
fallback_tier_id: "CASUAL",
},
};
renderEditor(missingDefinition, { showValidationErrors: true });
expect(screen.getByText("A definition is required", { exact: false })).toBeInTheDocument();
});
it("keeps Done disabled while a row is incomplete and says what is missing", async () => {
const incomplete: ComplexityRouterConfigValue = {
...customValue,
custom_tier_set: {
tiers: [customValue.custom_tier_set!.tiers[0], { id: "new", name: "", definition: "", models: [] }],
fallback_tier_id: "CASUAL",
},
};
renderEditor(incomplete);
expect(screen.getByRole("button", { name: "Done" })).toBeDisabled();
});
it("enables Done once every row carries a name, a definition and a model", () => {
renderEditor(customValue);
expect(screen.getByRole("button", { name: "Done" })).toBeEnabled();
});
it("refuses to remove a row that would take the set below the backend's minimum", () => {
renderEditor(customValue);
expect(screen.getByRole("button", { name: "Remove the CASUAL tier" })).toBeDisabled();
});
it("keeps a definition on one line, because the backend rejects a newline in it", () => {
const { committed } = renderEditor(customValue);
fireEvent.change(screen.getByLabelText("Definition for tier 2"), { target: { value: "audits\nand reviews" } });
const next = committed();
expect(next.custom_tier_set?.tiers[1].definition).toBe("audits and reviews");
});
it("moves a keyword rule with the tier it points at when that tier is renamed", () => {
const onKeywordTierRulesChange = vi.fn();
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
value={customValue}
keywordTierRules={[{ id: "r1", keywords: ["audit"], tier: "SECURITY_REVIEW" }]}
onKeywordTierRulesChange={onKeywordTierRulesChange}
editingTiers
onEditingTiersChange={vi.fn()}
/>,
);
fireEvent.change(screen.getByLabelText("Name for tier 2"), { target: { value: "AUDIT" } });
expect(onKeywordTierRulesChange).toHaveBeenCalledWith([{ id: "r1", keywords: ["audit"], tier: "AUDIT" }]);
});
it("re-points the fallback tier when the row it named is removed, never leaving it dangling", () => {
const threeRows: ComplexityRouterConfigValue = {
...customValue,
custom_tier_set: {
tiers: [
...customValue.custom_tier_set!.tiers,
{ id: "third", name: "MEDIUM", definition: "", models: ["gpt-4"] },
],
fallback_tier_id: "sec",
},
};
const { committed } = renderEditor(threeRows);
fireEvent.click(screen.getByRole("button", { name: "Remove the SECURITY_REVIEW tier" }));
const next = committed();
expect(next.custom_tier_set?.tiers.some((row) => row.id === next.custom_tier_set?.fallback_tier_id)).toBe(true);
});
it("turns off a plan-mode floor whose row was removed, rather than leaving it pointing at nothing", () => {
const withFloor: ComplexityRouterConfigValue = {
...customValue,
plan_mode_min_tier: "sec",
custom_tier_set: {
tiers: [
...customValue.custom_tier_set!.tiers,
{ id: "third", name: "BULK", definition: "d", models: ["gpt-4"] },
],
fallback_tier_id: "CASUAL",
},
};
const { committed } = renderEditor(withFloor);
fireEvent.click(screen.getByRole("button", { name: "Remove the SECURITY_REVIEW tier" }));
expect(committed().plan_mode_min_tier).toBeUndefined();
});
it("replaces the display-name inputs with the reason an edited tier set forbids them", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
expect(screen.getByText("Display names rename the built-in tiers", { exact: false })).toBeInTheDocument();
expect(screen.getByLabelText("Fallback tier")).toBeInTheDocument();
});
it("disables session pinning and says why, rather than letting a stripped value look saved", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
fireEvent.click(screen.getByText("Advanced: Affinity"));
expect(screen.getByLabelText("Pin a session to its first model")).toHaveAttribute("data-disabled");
expect(
screen.getByText("Session pinning escalates along the built-in tier ladder", { exact: false }),
).toBeInTheDocument();
});
it("leaves built-in routers with their display-name inputs and no restriction copy", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
expect(screen.getByLabelText("Display name for the Simple tier")).toBeInTheDocument();
expect(screen.queryByText("Display names rename the built-in tiers", { exact: false })).not.toBeInTheDocument();
});
});

View file

@ -2,32 +2,50 @@ import { SimpleTooltip } from "@/components/ui/tooltip";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ChevronRight, Info, X } from "lucide-react";
import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
type CustomTierSet,
type TierRow,
MAX_TIER_COUNT,
MAX_TIER_DEFINITION_CHARS,
MAX_TIER_NAME_CHARS,
MIN_TIER_COUNT,
TIER_ORDER,
activeTierName,
activeTierRows,
getCustomTierRowsError,
isBuiltInTierName,
resolveComplexityDefaultModel,
} from "./tier_rows";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
import ClassificationMethodConfig from "./ClassificationMethodConfig";
import { Restricted, restrictedBy } from "./TierRestrictions";
import { type TierSetAction, applyTierSetAction, setFallbackTier } from "./tier_set_actions";
import {
REASONING_EFFORT_OPTIONS,
ReasoningEffort,
TierModelParamsByTier,
pruneTierModelParams,
setTierModelReasoningEffort,
tierRowLabel,
} from "./complexity_router_tiers";
import TierModelEffortRows from "./TierModelEffortRows";
import EscalationKeywords from "./EscalationKeywords";
import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules";
import SemanticKeywordMatching from "./SemanticKeywordMatching";
import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from "./heuristic_scoring_knobs";
import { type CustomTierSet, type TierRow, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows";
export type { CustomTierSet, TierRow } from "./tier_rows";
export type { DimensionWeights, TierBoundaries, TokenThresholds };
export type { CustomTierSet, TierRow } from "./tier_rows";
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000;
export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5;
@ -141,12 +159,210 @@ export const effectiveClassifierType = (
value: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type">,
): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type);
const rowOrigin = (row: TierRow, editing: boolean): string => {
if (!editing) return row.id;
return isBuiltInTierName(row.name) ? "built-in" : "custom";
};
const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => {
if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`;
return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier";
};
const builtInTierInfo = (rowId: string): { label: string; description: string; examples: string } | undefined => {
const builtIn = TIER_ORDER.find((tier) => tier === rowId);
return builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined;
};
const TierConfigIntro: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => (
<>
<span className="block mb-6 text-muted-foreground">
{heuristicScoringRole(value) === "never"
? "The complexity router classifies each request with your classifier model and routes it to that tier. Configure which model(s) handle each tier."
: "The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls, <1ms latency). Configure which model(s) handle each tier."}
</span>
<span className="block mb-4 text-xs text-muted-foreground">
{restrictedBy(value, "displayNames")?.reason ??
"Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how requests are classified, and callers never see these names."}
{!value.custom_tier_set &&
usesLlmClassifier(value.classifier_type) &&
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
</span>
</>
);
const TierSetToolbar: React.FC<{
editing: boolean;
isCustomSet: boolean;
rowCount: number;
rowsError: string | null;
onEditingChange: ((editing: boolean) => void) | undefined;
onAdd: () => void;
onRestore: () => void;
}> = ({ editing, isCustomSet, rowCount, rowsError, onEditingChange, onAdd, onRestore }) => (
<>
<div className="mt-4 flex flex-wrap items-center gap-2">
{editing ? (
<>
<Button variant="outline" onClick={onAdd} disabled={rowCount >= MAX_TIER_COUNT}>
<Plus />
Add tier
</Button>
<SimpleTooltip content={rowsError || undefined}>
<Button variant="outline" disabled={Boolean(rowsError)} onClick={() => onEditingChange?.(false)}>
Done
</Button>
</SimpleTooltip>
{isCustomSet && (
<Button variant="outline" size="sm" onClick={onRestore}>
Restore defaults
</Button>
)}
</>
) : (
onEditingChange && (
<Button variant="outline" onClick={() => onEditingChange(true)}>
Edit tiers
</Button>
)
)}
</div>
{editing && (
<span className="block mt-1 text-xs text-muted-foreground">
Add or remove tiers to define your own set. Every custom tier needs a definition the LLM classifier routes on,
and an edited set requires the LLM classification method
</span>
)}
</>
);
const FallbackTierField: React.FC<{
rows: readonly TierRow[];
fallbackTierId: string;
onValueChange: (rowId: string) => void;
}> = ({ rows, fallbackTierId, onValueChange }) => (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<strong className="text-base font-semibold">Fallback Tier</strong>
<SimpleTooltip content="Where requests route when the LLM classifier errors, times out, or returns an unparseable reply. Required for an edited tier set: the heuristic scorer cannot produce your tiers.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
</div>
<TierRowSelect
label="Fallback tier"
options={rows.filter((row) => activeTierName(row)).map((row) => ({ value: row.id, label: activeTierName(row) }))}
value={fallbackTierId || null}
onValueChange={onValueChange}
placeholder="Pick the tier classifier failures route to"
/>
</div>
);
const TierRowHeader: React.FC<{
row: TierRow;
index: number;
rowCount: number;
label: string;
description: string | undefined;
editing: boolean;
isCustomSet: boolean;
onRemove: () => void;
}> = ({ row, index, rowCount, label, description, editing, isCustomSet, onRemove }) => (
<div className="flex items-center gap-2 mb-2">
<strong className="text-base font-semibold">{label} Tier</strong>
<SimpleTooltip
content={
row.definition.trim() ||
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 {rowCount} &middot; {rowOrigin(row, isCustomSet)}
</span>
{editing && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive/80"
aria-label={`Remove the ${activeTierName(row) || `tier ${index + 1}`} tier`}
disabled={rowCount <= MIN_TIER_COUNT}
onClick={onRemove}
>
<Trash2 />
Remove
</Button>
)}
</div>
);
const TierRowEditFields: React.FC<{
row: TierRow;
index: number;
definitionMissing: boolean;
onPatch: (patch: Partial<Omit<TierRow, "id">>) => void;
}> = ({ row, index, definitionMissing, onPatch }) => (
<>
<Input
value={row.name}
onChange={(event) => onPatch({ name: event.target.value })}
placeholder="Tier name, e.g. SECURITY_REVIEW"
aria-label={`Name for tier ${index + 1}`}
maxLength={MAX_TIER_NAME_CHARS}
className="mb-2"
/>
<Textarea
value={row.definition}
onChange={(event) => onPatch({ definition: event.target.value.replace(/[\r\n]+/g, " ") })}
placeholder={
isBuiltInTierName(row.name)
? "Leave blank to keep the built-in definition"
: "What belongs in this tier, e.g. requests asking for a security audit"
}
aria-label={`Definition for tier ${index + 1}`}
maxLength={MAX_TIER_DEFINITION_CHARS}
rows={2}
className={definitionMissing ? "mb-2 border-destructive" : "mb-2"}
/>
{definitionMissing && (
<span className="mb-2 block text-xs text-destructive">
A definition is required: it is the rubric the classifier routes on for this tier
</span>
)}
</>
);
const TierRowSelect: React.FC<{
label: string;
options: { value: string; label: string }[];
value: string | null;
onValueChange: (rowId: string) => void;
placeholder?: string;
}> = ({ label, options, value, onValueChange, placeholder }) => (
<Select items={options} value={value} onValueChange={(rowId: string | null) => rowId && onValueChange(rowId)}>
<SelectTrigger aria-label={label} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
export type AdaptiveEligible = "all" | "classified_tier";
export type ComplexityTierLabels = Partial<Record<keyof ComplexityTiers, string>>;
export interface ComplexityRouterConfigValue {
tiers: ComplexityTiers;
custom_tier_set?: CustomTierSet;
tier_labels?: ComplexityTierLabels;
/** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */
default_model?: string;
@ -161,7 +377,7 @@ export interface ComplexityRouterConfigValue {
heuristic_first_max_tier?: string;
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 meaning off. The wire carries the row's name. */
plan_mode_min_tier?: string;
adaptive?: boolean;
adaptive_weights?: AdaptiveRouterWeights;
@ -185,7 +401,6 @@ export interface ComplexityRouterConfigValue {
* params object is held, not just reasoning_effort, so keys authored in config.yaml survive an
* edit round-trip.
*/
custom_tier_set?: CustomTierSet;
tier_model_params?: TierModelParamsByTier;
}
@ -193,6 +408,9 @@ interface ComplexityRouterConfigProps {
modelInfo: ModelGroup[];
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
/** Parent-owned: this component unmounts when its section collapses. */
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
@ -268,14 +486,16 @@ const AffinityControls: React.FC<{
</span>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
checked={value.custom_tier_set ? false : value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
disabled={Boolean(value.custom_tier_set)}
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.
{restrictedBy(value, "sessionAffinity")?.reason ??
"Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment."}
</span>
</>
);
@ -307,22 +527,12 @@ const PlanModeOverrideControls: React.FC<{
</span>
{value.plan_mode_min_tier !== undefined && (
<div style={{ maxWidth: 320 }}>
<Select
items={planModeTierOptions}
value={value.plan_mode_min_tier}
onValueChange={(tier: string | null) => tier && onChange({ ...value, plan_mode_min_tier: tier })}
>
<SelectTrigger aria-label="Plan-mode minimum tier" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{planModeTierOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<TierRowSelect
label="Plan-mode minimum tier"
options={planModeTierOptions}
value={value.plan_mode_min_tier ?? null}
onValueChange={(tier) => onChange({ ...value, plan_mode_min_tier: tier })}
/>
</div>
)}
</>
@ -351,6 +561,8 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
modelInfo,
value,
onChange,
editingTiers = false,
onEditingTiersChange,
customTechnicalKeywords,
onCustomTechnicalKeywordsChange,
keywordTierRules = [],
@ -365,13 +577,31 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
onEscalationKeywordsChange,
showValidationErrors = false,
}) => {
const customTierSet = value.custom_tier_set;
const tierRows = activeTierRows(value);
const planModeTierOptions = tierRows
.filter((row) => row.models.length > 0)
.map((row) => ({ value: row.id, label: effectiveTierLabel(row.id as keyof ComplexityTiers, value.tier_labels) }));
const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null;
const planModeRows = tierRows.filter((row) => row.models.length > 0);
const planModeTierOptions = planModeRows.map((row) => ({
value: row.id,
label: tierRowLabel(row, value.tier_labels),
}));
const derivedDefaultModel = resolveComplexityDefaultModel(value);
const defaultModelPlaceholder = defaultModelPlaceholderFor(derivedDefaultModel, Boolean(customTierSet));
const defaultModel = resolveComplexityDefaultModel(value, value.default_model);
const dispatch = (action: TierSetAction) => {
const next = applyTierSetAction(value, keywordTierRules, action);
if (next.keywordTierRules !== keywordTierRules) onKeywordTierRulesChange?.([...next.keywordTierRules]);
onChange(next.value);
};
const setRowModels = (row: TierRow, models: string[]) => dispatch({ kind: "models", id: row.id, models });
const updateTierRow = (id: string, patch: Partial<Omit<TierRow, "id">>) => dispatch({ kind: "patch", id, patch });
const addCustomTier = () => dispatch({ kind: "add" });
const removeTierRow = (id: string) => dispatch({ kind: "remove", id });
const exitToBuiltInTiers = () => dispatch({ kind: "restore" });
// An absent list means the proxy does not send the field yet, so every level is offered as before.
// An empty list is the group's own answer that its deployments share no level, and is left empty.
const effortOptionsByModel: Record<string, string[]> = Object.fromEntries(
@ -389,19 +619,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
label: model.model_group,
}));
const handleTierChange = (tier: keyof ComplexityTiers, models: string[]) => {
onChange({
...value,
tiers: { ...value.tiers, [tier]: models },
tier_model_params: pruneTierModelParams(value.tier_model_params, tier, models),
});
};
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),
@ -430,62 +648,67 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</SimpleTooltip>
</div>
<span className="block mb-6 text-muted-foreground">
The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls,
&lt;1ms latency). Configure which model(s) handle each tier.
</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.
{usesLlmClassifier(value.classifier_type) &&
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
</span>
<TierConfigIntro value={value} />
<Card>
<CardContent>
{tierRows.map((row: TierRow, index) => {
const tier = row.id as keyof ComplexityTiers;
const tierInfo = TIER_DESCRIPTIONS[tier];
const label = effectiveTierLabel(tier, value.tier_labels);
{tierRows.map((row, index) => {
const tierInfo = builtInTierInfo(row.id);
const label = tierRowLabel(row, value.tier_labels);
const tierMissing = showValidationErrors && row.models.length === 0;
const needsDefinition = Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name);
const definitionMissing = showValidationErrors && needsDefinition;
const showsDisplayName = !customTierSet && !editingTiers;
return (
<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}>
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
<span className="text-xs text-muted-foreground">
Tier {index + 1} of {tierRows.length} &middot; {row.id}
</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`}
<TierRowHeader
row={row}
index={index}
rowCount={tierRows.length}
label={label}
description={tierInfo?.description}
editing={editingTiers}
isCustomSet={Boolean(customTierSet)}
onRemove={() => removeTierRow(row.id)}
/>
{tierInfo && !customTierSet && (
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
)}
{editingTiers && (
<TierRowEditFields
row={row}
index={index}
definitionMissing={definitionMissing}
onPatch={(patch) => updateTierRow(row.id, patch)}
/>
{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>
)}
</InputGroup>
)}
{showsDisplayName && tierInfo && (
<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={row.models}
onValueChange={(models: string[]) => handleTierChange(tier, models)}
onValueChange={(models: string[]) => setRowModels(row, models)}
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
emptyText="No models found"
className={tierMissing ? "w-full border-destructive" : "w-full"}
@ -494,12 +717,12 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
tierLabel={label}
models={row.models}
effortOptionsByModel={effortOptionsByModel}
paramsByModel={value.tier_model_params?.[tier]}
onEffortChange={(model, effort) => handleTierModelEffortChange(tier, model, effort)}
paramsByModel={row.params}
onEffortChange={(model, effort) => handleTierModelEffortChange(row.id, 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
Multiple models selected: the router randomly picks among them per request (or Thompson-samples
within the pool when adaptive routing is on).
</span>
)}
@ -508,6 +731,25 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</div>
);
})}
<TierSetToolbar
editing={editingTiers}
isCustomSet={Boolean(customTierSet)}
rowCount={tierRows.length}
rowsError={tierRowsError}
onEditingChange={onEditingTiersChange}
onAdd={addCustomTier}
onRestore={exitToBuiltInTiers}
/>
{customTierSet && (
<FallbackTierField
rows={tierRows}
fallbackTierId={customTierSet.fallback_tier_id}
onValueChange={(fallbackTierId) => onChange(setFallbackTier(value, fallbackTierId))}
/>
)}
<Separator className="my-4" />
<div className="mb-2">
@ -521,11 +763,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
options={modelOptions}
value={value.default_model ?? ""}
onValueChange={handleDefaultModelChange}
placeholder={
derivedDefaultModel
? `Derived from tiers: ${derivedDefaultModel}`
: "Add a model to the Simple or Medium tier"
}
placeholder={defaultModelPlaceholder}
emptyText="No models found"
aria-label="Default model"
/>
@ -559,7 +797,11 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
{
key: "adaptive",
label: <strong className="text-foreground font-semibold">Advanced: Adaptive Routing</strong>,
children: <AdaptiveRoutingConfig value={value} onChange={onChange} />,
children: (
<Restricted by={restrictedBy(value, "adaptive")}>
<AdaptiveRoutingConfig value={value} onChange={onChange} />
</Restricted>
),
},
{
key: "affinity",
@ -583,7 +825,11 @@ 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: (
<Restricted by={restrictedBy(value, "escalation")}>
<EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />
</Restricted>
),
},
]
: []),
@ -599,6 +845,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
rules={keywordTierRules}
onChange={onKeywordTierRulesChange}
tierLabels={value.tier_labels}
tierNames={customTierSet && tierRows.map(activeTierName).filter(Boolean)}
/>
)}
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}

View file

@ -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: tierNames?.[0] ?? "COMPLEX" }]);
};
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,7 +107,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
<SelectValue />
</SelectTrigger>
<SelectContent>
{tierOptions(tierLabels).map((option) => (
{tierOptions(tierLabels, tierNames).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>

View file

@ -0,0 +1,24 @@
import React from "react";
import { CUSTOM_TIER_RESTRICTIONS, CustomTierSet, TierRestriction } from "./tier_rows";
export const restrictedBy = (
value: { custom_tier_set?: CustomTierSet },
key: keyof typeof CUSTOM_TIER_RESTRICTIONS,
): TierRestriction | undefined => (value.custom_tier_set ? CUSTOM_TIER_RESTRICTIONS[key] : undefined);
export const Restricted: React.FC<{ by: TierRestriction | undefined; children: React.ReactNode }> = ({
by,
children,
}) => (by ? <span className="block text-sm text-muted-foreground">{by.reason}</span> : <>{children}</>);
/** A labelled section whose body is replaced by the reason an edited tier set forbids it. */
export const RestrictedSection: React.FC<{
heading: string;
by: TierRestriction | undefined;
children: React.ReactNode;
}> = ({ heading, by, children }) => (
<div>
<strong className="block mb-1 font-semibold">{heading}</strong>
{by ? <span className="block text-sm text-muted-foreground">{by.reason}</span> : children}
</div>
);

View file

@ -210,21 +210,6 @@ describe("AddAutoRouterTab", () => {
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
});
it("submits when the dry-run passes, so the gate is not simply blocking everything", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);
validateAutoRouterConfig.mockResolvedValueOnce({ valid: true });
renderWithProviders(<Harness />);
await user.type(screen.getByPlaceholderText(/smart_router/i), "accepted-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
});
// A second submit while the dry-run round-trip is pending must not start another create: the
// button disables, and the handler itself refuses re-entry since a form submit (Enter) fires it
// regardless of the button's disabled state.
it("creates the router once when the form is submitted again mid dry-run", async () => {
vi.mocked(getMissingTiersError).mockReturnValue(null);
let resolveVerdict: (verdict: { valid: boolean }) => void = () => {};
@ -248,6 +233,18 @@ describe("AddAutoRouterTab", () => {
expect(handleAddAutoRouterSubmit).toHaveBeenCalledTimes(1);
});
it("submits when the dry-run passes, so the gate is not simply blocking everything", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);
validateAutoRouterConfig.mockResolvedValueOnce({ valid: true });
renderWithProviders(<Harness />);
await user.type(screen.getByPlaceholderText(/smart_router/i), "accepted-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
});
// LIT-5133: "Add keyword rule" seeds a row with no keywords, and the semantic toggle that used
// to be the only thing checking them is off by default. The row was dropped on the way to the
// payload, so the create succeeded and the caller's rule was gone with nothing said about it.
@ -651,28 +648,6 @@ describe("AddAutoRouterTab", () => {
});
});
// Every step between the bundled JSON and the payload drops these params silently.
it("carries a preset's per-tier reasoning effort through to the create payload", async () => {
const user = userEvent.setup();
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
await waitForPresetEnabled("Anthropic Family");
await selectTemplate("Anthropic Family");
await user.type(screen.getByPlaceholderText(/smart_router/i), "anthropic-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]).toMatchObject({
complexity_router_config: {
tier_model_configs: {
REASONING: [{ model_name: "claude-opus-5", litellm_params: { reasoning_effort: "high" } }],
},
},
});
});
// Bugbot-found bug: submitBlockedReason disables the button for this, but Form's onFinish
// (wired to the same handler as the button) fires whenever the form itself is submitted,
// independent of the button's own disabled state. Without submitRecommendedRouter re-checking
@ -700,6 +675,27 @@ describe("AddAutoRouterTab", () => {
await waitFor(() => expect(toast.fromError).toHaveBeenCalledWith(expect.stringContaining("no longer available")));
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
});
it("carries a preset's per-tier reasoning effort through to the create payload", async () => {
const user = userEvent.setup();
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
await waitForPresetEnabled("Anthropic Family");
await selectTemplate("Anthropic Family");
await user.type(screen.getByPlaceholderText(/smart_router/i), "anthropic-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]).toMatchObject({
complexity_router_config: {
tier_model_configs: {
REASONING: [{ model_name: "claude-opus-5", litellm_params: { reasoning_effort: "high" } }],
},
},
});
});
});
describe("default model pin", () => {
@ -975,6 +971,23 @@ describe("getSubmitBlockedReason", () => {
);
});
it("blocks an edited tier set with no classifier model, since the set forces the LLM classifier", () => {
const config = {
tiers,
classifier_type: "heuristic" as const,
custom_tier_set: {
tiers: [
{ id: "a", name: "CASUAL", definition: "d", models: ["gpt-4o-mini"] },
{ id: "b", name: "AUDIT", definition: "d", models: ["gpt-4o-mini"] },
],
fallback_tier_id: "a",
},
};
expect(getSubmitBlockedReason(config, [], referenced, availability)).toContain(
"an edited tier set routes with the LLM classifier",
);
});
it("blocks a keyword rule aimed at a tier this router does not have", () => {
const rules = [{ id: "r1", keywords: ["audit"], tier: "AUDIT" }];
expect(getSubmitBlockedReason({ tiers, classifier_type: "heuristic" }, rules, referenced, availability)).toContain(

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useWatch } from "react-hook-form";
import { ChevronDown, ChevronRight, CircleHelp } from "lucide-react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { z } from "zod/v4";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
@ -14,6 +14,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox";
import { modelAvailableCall, validateAutoRouterConfig } from "../networking";
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
import { all_admin_roles } from "@/utils/roles";
import { type ModelWriteScope } from "@/utils/modelPermissions";
import TeamDropdown from "../common_components/team_dropdown";
@ -22,6 +23,7 @@ import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels";
import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
effectiveClassifierType,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
DEFAULT_DEPLOYMENT_AFFINITY,
@ -33,17 +35,16 @@ import { DEFAULT_MATCH_THRESHOLD } from "./SemanticKeywordMatching";
import {
BuildComplexityRouterConfigParams,
buildComplexityRouterConfig,
dryRunRejection,
getKeywordTierRulesError,
getClassifierModelError,
getMissingTiersError,
getPlanModeTierError,
getSemanticConfigError,
getTierLabelsError,
dryRunRejection,
} from "./build_complexity_router_config";
import { activeTierName, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows";
import { DEFAULT_TIER_LABELS } from "./complexity_router_tiers";
import type { ComplexityTier } from "./KeywordTierRules";
import { activeTierName, activeTierRows, getCustomTierRowsError, resolveComplexityDefaultModel } from "./tier_rows";
import { tierRowLabel } from "./complexity_router_tiers";
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
import AutoRouterConnectionTest from "./auto_router_connection_test";
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
@ -110,7 +111,7 @@ const presets = getAllPresets();
const tierConfigSummary = (config: ComplexityRouterConfigValue): string => {
const parts = activeTierRows(config)
.filter((row) => row.models.length > 0)
.map((row) => `${DEFAULT_TIER_LABELS[row.id as ComplexityTier] ?? activeTierName(row)}: ${row.models.join(", ")}`);
.map((row) => `${tierRowLabel(row, config.tier_labels)}: ${row.models.join(", ")}`);
return parts.length > 0 ? parts.join(" · ") : "No tiers configured yet";
};
@ -124,8 +125,8 @@ export const getSubmitBlockedReason = (
referencedModelsParams: Parameters<typeof getReferencedModelsError>[0],
availability: ModelAvailability,
): string | null =>
(config.custom_tier_set ? getCustomTierRowsError(config.custom_tier_set) : getTierLabelsError(config.tier_labels)) ??
getMissingTiersError(activeTierRows(config)) ??
getTierLabelsError(config.tier_labels) ??
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
getClassifierModelError(config) ??
@ -146,16 +147,6 @@ const EMPTY_FORM_VALUES: AddAutoRouterFormValues = {
model_access_group: undefined,
};
const labelWithHint = (label: string, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
const teamScopePayload = (requiresTeamScope: boolean, teamId: string): { team_id?: string } =>
requiresTeamScope ? { team_id: teamId } : {};
@ -197,7 +188,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 [isSubmitting, setIsSubmitting] = 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
@ -297,6 +289,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
);
const applyPrefill = (prefill: PresetPrefill) => {
setEditingTiers(false);
setComplexityRouterConfig(prefill.complexityRouterConfig);
setCustomTechnicalKeywords(prefill.customTechnicalKeywords);
setKeywordTierRules(prefill.keywordTierRules);
@ -327,8 +320,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
};
const referencedModelsParams = {
tiers: complexityRouterConfig.tiers,
classifierType: complexityRouterConfig.classifier_type,
tiers: Object.fromEntries(activeTierRows(complexityRouterConfig).map((row) => [activeTierName(row), row.models])),
classifierType: effectiveClassifierType(complexityRouterConfig),
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
semanticMatchingEnabled,
embeddingModel,
@ -344,6 +337,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
tiers: complexityRouterConfig.tiers,
customTierSet: complexityRouterConfig.custom_tier_set,
defaultModel: complexityRouterConfig.default_model,
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
heuristicFirstMaxTier: complexityRouterConfig.heuristic_first_max_tier,
@ -375,8 +369,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
};
const submitRecommendedRouter = async (name: string) => {
const { tiers } = complexityRouterConfigParams;
// The one answer the submit button reads, so a disabled button and a refused submit cannot
// disagree about why. The handler needs it in its own right: the form fires this on Enter
// regardless of the button's disabled state.
@ -403,6 +395,10 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
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
// `defaultModel`, or the two fields diverge and hydration's divergence check misfires.
const complexityRouterConfigPayload = buildComplexityRouterConfig(complexityRouterConfigParams);
const serverVerdict = await validateAutoRouterConfig(
accessToken,
@ -416,10 +412,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
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
// `defaultModel`, or the two fields diverge and hydration's divergence check misfires.
const submitValues: AddAutoRouterValues = {
auto_router_name: name,
...teamScopePayload(requiresTeamScope, form.getValues("team_id")),
@ -579,6 +571,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
{detailsExpanded && (
<div className="px-4 pb-4">
<ComplexityRouterConfig
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
modelInfo={modelInfo}
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
@ -642,7 +636,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
type="button"
variant="outline"
data-testid="auto-router-test-routing-btn"
disabled={submitBlockedReason !== null}
disabled={submitBlockedReason !== null || isSubmitting}
onClick={() => setIsRoutingTestVisible(true)}
>
Test Routing
@ -666,7 +660,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
void handleAutoRouterSubmit();
}}
>
{isSubmitting && <UiLoadingSpinner className="size-4" />}
Add Auto Router
</Button>
</BlockedReasonTooltip>

View file

@ -7,6 +7,19 @@ import {
type KeywordMatchingState,
} from "./edit_auto_router_modal";
// The custom-tier router these cases round-trip, so a variant differs only by what it overrides.
const storedCustomConfig = (overrides: Record<string, unknown> = {}) => ({
tiers: { CASUAL: ["gpt-4o-mini"], AUDIT: ["o1"] },
tier_definitions: [
{ name: "CASUAL", description: "small talk" },
{ name: "AUDIT", description: "security review" },
],
fallback_tier: "CASUAL",
classifier_type: "llm",
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000 },
...overrides,
});
const STORED = {
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
@ -476,14 +489,49 @@ describe("managed keys survive an untouched open-and-save", () => {
reasoning_override_min_score: 0.3,
};
it("carries every managed key through hydrate then save", () => {
// tier_definitions and fallback_tier cannot sit beside heuristic_first, which this fixture uses, so
// no single stored config can hold every managed key. They get their own round trip below.
const CUSTOM_TIER_ONLY_KEYS = new Set(["tier_definitions", "fallback_tier"]);
it("carries every managed key a built-in router can hold through hydrate then save", () => {
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
const saved = buildUpdatedComplexityRouterConfig(STORED_ALL_MANAGED, hydrated);
const dropped = [...MANAGED_COMPLEXITY_ROUTER_KEYS].filter((key) => saved[key] === undefined);
const dropped = [...MANAGED_COMPLEXITY_ROUTER_KEYS]
.filter((key) => !CUSTOM_TIER_ONLY_KEYS.has(key))
.filter((key) => saved[key] === undefined);
expect(dropped).toEqual([]);
});
it("drops a stored local-scorer threshold when the operator converts the router to custom tiers", () => {
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
const converted = {
...hydrated,
custom_tier_set: {
tiers: [
{ id: "a", name: "CASUAL", definition: "small talk", models: ["gpt-4o-mini"] },
{ id: "b", name: "AUDIT", definition: "security review", models: ["o1"] },
],
fallback_tier_id: "a",
},
};
const saved = buildUpdatedComplexityRouterConfig(STORED_ALL_MANAGED, converted);
expect(saved.heuristic_first_max_tier).toBeUndefined();
expect(saved.classifier_type).toBe("llm");
expect(saved.tier_definitions).toHaveLength(2);
});
it("carries the custom-tier keys through their own round trip", () => {
const storedCustom = storedCustomConfig();
const hydrated = hydrateComplexityRouterConfig(storedCustom, undefined);
const saved = buildUpdatedComplexityRouterConfig(storedCustom, hydrated);
expect(saved.tier_definitions).toEqual(storedCustom.tier_definitions);
expect(saved.fallback_tier).toBe("CASUAL");
expect(saved.tiers).toEqual(storedCustom.tiers);
});
it("round-trips the heuristic_first threshold, which save requires and the backend rejects without", () => {
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
expect(hydrated.heuristic_first_max_tier).toBe("SIMPLE");

View file

@ -10,15 +10,13 @@ vi.mock(
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
);
const { modelPatchUpdateCall, modelAvailableCall, getAutoRouterClassifierDefaultPromptCall } = vi.hoisted(() => ({
modelPatchUpdateCall: vi.fn().mockResolvedValue({}),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
getAutoRouterClassifierDefaultPromptCall: vi.fn().mockResolvedValue("Classify the request into exactly one tier."),
}));
const { validateAutoRouterConfig } = vi.hoisted(() => ({
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }),
}));
const { modelPatchUpdateCall, modelAvailableCall, getAutoRouterClassifierDefaultPromptCall, validateAutoRouterConfig } =
vi.hoisted(() => ({
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }),
modelPatchUpdateCall: vi.fn().mockResolvedValue({}),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
getAutoRouterClassifierDefaultPromptCall: vi.fn().mockResolvedValue("Classify the request into exactly one tier."),
}));
vi.mock("../networking", () => ({
modelPatchUpdateCall,
@ -101,6 +99,8 @@ describe("EditAutoRouterModal keyword matching", () => {
expect(config.match_threshold).toBe(0.72);
});
// Same gate as the create form: the dry-run's verdict has to stop the PATCH, or an operator sees
// a raw 400 instead of the inline message the dry-run was added to give them.
it("does not PATCH when the backend's dry-run rejects the config", async () => {
const user = userEvent.setup();
validateAutoRouterConfig.mockResolvedValueOnce({
@ -819,3 +819,74 @@ describe("EditAutoRouterModal plan-mode minimum tier", () => {
expect(savedConfig()).not.toHaveProperty("plan_mode_min_tier");
});
});
describe("EditAutoRouterModal with a stored custom tier set", () => {
const CUSTOM_STORED = {
tiers: { CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["gpt-4o-mini"] },
tier_definitions: [
{ name: "CASUAL", description: "small talk" },
{ name: "SECURITY_REVIEW", description: "audits and vulnerability review" },
],
fallback_tier: "CASUAL",
classifier_type: "llm",
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000 },
plan_mode_min_tier: "SECURITY_REVIEW",
classification_prompt: "operator written preamble",
tier_model_configs: {
SECURITY_REVIEW: [{ model_name: "gpt-4o-mini", litellm_params: { reasoning_effort: "high" } }],
},
};
const renderCustomModal = () =>
renderWithProviders(
<EditAutoRouterModal
isVisible
onCancel={vi.fn()}
onSuccess={vi.fn()}
modelData={{
...MODEL_DATA,
litellm_params: { ...MODEL_DATA.litellm_params, complexity_router_config: CUSTOM_STORED },
}}
accessToken="token"
userRole="Admin"
/>,
);
beforeEach(() => {
modelPatchUpdateCall.mockClear();
});
it("shows the stored tier names rather than the built-in four", async () => {
renderCustomModal();
expect(await screen.findByText("SECURITY_REVIEW Tier")).toBeInTheDocument();
expect(screen.queryByText("Simple Tier")).not.toBeInTheDocument();
});
it("saves an untouched custom-tier router back byte-identically, tier set and floor included", async () => {
const user = userEvent.setup();
renderCustomModal();
await screen.findByText("SECURITY_REVIEW Tier");
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.tiers).toEqual(CUSTOM_STORED.tiers);
expect(config.fallback_tier).toBe("CASUAL");
expect(config.plan_mode_min_tier).toBe("SECURITY_REVIEW");
expect(config.classification_prompt).toBe("operator written preamble");
expect(config.classifier_type).toBe("llm");
});
it("keeps the stored per-model reasoning effort, which hydrates by tier name and saves by row id", async () => {
const user = userEvent.setup();
renderCustomModal();
await screen.findByText("SECURITY_REVIEW Tier");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig().tier_model_configs).toEqual(CUSTOM_STORED.tier_model_configs);
});
});

View file

@ -15,16 +15,26 @@ import { modelAvailableCall, modelPatchUpdateCall, validateAutoRouterConfig } fr
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
import { hydrateTierModelParams, normalizeTierModels } from "../add_model/complexity_router_tiers";
import { type ActiveTierSet, activeTierRows, resolveComplexityDefaultModel } from "../add_model/tier_rows";
import {
type ActiveTierSet,
CUSTOM_TIER_OMITTED_KEYS,
activeTierRows,
getCustomTierRowsError,
tierParamsByRowId,
resolveComplexityDefaultModel,
} from "../add_model/tier_rows";
import { isComplexityRouter } from "../add_model/auto_router_strategies";
import {
type BuildComplexityRouterConfigParams,
buildComplexityRouterConfig,
getClassifierModelError,
getKeywordTierRulesError,
getMissingTiersError,
getSemanticConfigError,
getPlanModeTierError,
getTierLabelsError,
hydrateCustomTierSet,
hydratePlanModeMinTier,
hydrateTierLabels,
dryRunRejection,
} from "../add_model/build_complexity_router_config";
@ -113,16 +123,18 @@ export const hydrateComplexityRouterConfig = (
REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING),
};
const custom_tier_set = hydrateCustomTierSet(parsedConfig);
const activeTiers = { tiers: hydratedTiers, custom_tier_set };
return {
tiers: hydratedTiers,
tier_model_params: hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs),
default_model: hydratePinnedDefaultModel(parsedConfig.default_model, complexityRouterDefaultModel, {
tiers: hydratedTiers,
}),
plan_mode_min_tier:
typeof parsedConfig.plan_mode_min_tier === "string" && parsedConfig.plan_mode_min_tier.trim() !== ""
? parsedConfig.plan_mode_min_tier
: undefined,
custom_tier_set,
tier_model_params: tierParamsByRowId(
hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs),
activeTierRows(activeTiers),
),
default_model: hydratePinnedDefaultModel(parsedConfig.default_model, complexityRouterDefaultModel, activeTiers),
plan_mode_min_tier: hydratePlanModeMinTier(parsedConfig.plan_mode_min_tier, custom_tier_set),
tier_labels: hydrateTierLabels(parsedConfig.tier_labels),
classifier_type: parsedConfig.classifier_type || "heuristic",
classifier_llm_config: parsedConfig.classifier_llm_config,
@ -166,6 +178,8 @@ export const hydrateComplexityRouterConfig = (
export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"tiers",
"tier_definitions",
"fallback_tier",
"tier_model_configs",
"default_model",
"plan_mode_min_tier",
@ -233,6 +247,10 @@ export interface KeywordMatchingState {
matchThreshold: number;
}
// A custom save drops the stored keys an edited tier set forbids.
const customTierDroppedKeys = (value: ComplexityRouterConfigValue): readonly string[] =>
value.custom_tier_set ? CUSTOM_TIER_OMITTED_KEYS : [];
export const buildUpdatedComplexityRouterConfig = (
storedConfig: unknown,
value: ComplexityRouterConfigValue,
@ -244,10 +262,14 @@ export const buildUpdatedComplexityRouterConfig = (
if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
return customTechnicalKeywords !== undefined && key === "custom_technical_keywords";
};
const preservedConfig = Object.fromEntries(Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key)));
const dropped = customTierDroppedKeys(value);
const preservedConfig = Object.fromEntries(
Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key) && !dropped.includes(key)),
);
const builderParams: BuildComplexityRouterConfigParams = {
tiers: value.tiers,
customTierSet: value.custom_tier_set,
defaultModel: value.default_model,
planModeMinTier: value.plan_mode_min_tier,
heuristicFirstMaxTier: value.heuristic_first_max_tier,
@ -341,6 +363,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[]>([]);
@ -365,10 +388,12 @@ 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) ??
: (complexityRouterConfig.custom_tier_set
? getCustomTierRowsError(complexityRouterConfig.custom_tier_set) ??
getMissingTiersError(activeTierRows(complexityRouterConfig))
: (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, activeTierRows(complexityRouterConfig)) ??
getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig)) ??
getClassifierModelError(complexityRouterConfig);
@ -407,6 +432,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
@ -473,10 +499,15 @@ 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 rows = activeTierRows(complexityRouterConfig);
const builtInTiersEmpty = Object.values(tiers).every((models) => models.length === 0);
const tierSetError = custom_tier_set
? getCustomTierRowsError(custom_tier_set) ?? getMissingTiersError(rows)
: builtInTiersEmpty && "Please select at least one model for a complexity tier";
if (tierSetError) {
setShowValidationErrors(true);
toast.fromError("Please select at least one model for a complexity tier");
toast.fromError(tierSetError);
return;
}
const classifierError = getClassifierModelError(complexityRouterConfig);
@ -489,7 +520,7 @@ 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, activeTierRows(complexityRouterConfig));
const keywordRulesError = getKeywordTierRulesError(keywordTierRules, rows);
if (keywordRulesError) {
setShowValidationErrors(true);
toast.fromError(keywordRulesError);
@ -517,6 +548,9 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
return;
}
// 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,
@ -531,9 +565,6 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
return;
}
// 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 updatedLitellmParams = {
...modelData.litellm_params,
complexity_router_config: updatedConfig,
@ -635,6 +666,8 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
/* Complexity Router Configuration */
<div className="w-full">
<ComplexityRouterConfig
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
showValidationErrors={showValidationErrors}
modelInfo={modelInfo}
value={complexityRouterConfig}