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

The auto-router form was fixed at the four built-in complexity tiers. The
backend has taken tier_definitions since #37226, so the only way to add a tier
was hand-written raw config plus a proxy restart.

Edit tiers turns the existing tier list into an editor: rename a built-in,
remove one, restore it, or add a tier with a name, the definition the LLM
classifier routes on, and its models. A Fallback Tier picks where classifier
failures land.

Every input an edited tier set forbids lives in one table beside the payload
keys it owns, so a control cannot grey out while its value still ships:
display names, escalation, adaptive routing, session pinning, the heuristic
classifier, the replacement classifier prompt, the rubric preset, and the
classifier-failure fallback. The scorer knobs go too, as inert rather than
rejected.

Per-model params live on the tier row that owns them, so a row's models and its
reasoning efforts cannot be keyed differently and travel together through a
rename, a removal, and a return to the built-in tiers.

Both forms dry-run POST /auto_router/validate_complexity_router_config against
the exact payload the save will send, and fail open on a transport error since
the write gate stays authoritative. A form that never opens the editor submits a
byte-identical payload.

Resolves LIT-5214
This commit is contained in:
Tin Chi Lo 2026-08-26 17:45:11 -07:00
parent 166694948f
commit bbd45bf37d
19 changed files with 1636 additions and 257 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

@ -31,6 +31,10 @@ import {
usesLlmClassifier,
DEFAULT_HEURISTIC_FIRST_MAX_TIER,
HEURISTIC_FIRST_MAX_TIER_KEYS,
effectiveClassifierType,
heuristicScoringRole,
restrictedBy,
Restricted,
} from "./ComplexityRouterConfig";
const DEFAULT_SCORING_EXPLANATION =
@ -51,7 +55,12 @@ const CUSTOM_PROMPT_WITH_DEFAULT_MODEL_FALLBACK =
* decides the tier, and pairing one with the default-model fallback means the heuristic never runs
* at all, so the panel must not keep implying a score is involved on either router.
*/
const CUSTOM_TIER_SET_EXPLANATION =
"The LLM classifier reads your tier definitions and picks the tier a request belongs to. The built-in " +
"seven-dimension scorer does not run, so its score ranges do not apply here.";
const scoringExplanation = (value: ComplexityRouterConfigValue): string => {
if (value.custom_tier_set) return CUSTOM_TIER_SET_EXPLANATION;
const usesCustomPrompt =
usesLlmClassifier(value.classifier_type) && Boolean(value.classifier_llm_config?.system_prompt?.trim());
if (!usesCustomPrompt) return DEFAULT_SCORING_EXPLANATION;
@ -89,6 +98,7 @@ 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,
@ -100,7 +110,7 @@ const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> =
<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}
@ -151,8 +161,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;
@ -265,20 +276,22 @@ 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 with no API calls and &lt;1ms latency
<SimpleTooltip content={restrictedBy(value, "heuristicClassifier")?.reason}>
<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={Boolean(value.custom_tier_set)} />
<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>
</span>
</Label>
</Label>
</SimpleTooltip>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="llm" className="mt-0.5" />
<span>
@ -286,19 +299,21 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
<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
<SimpleTooltip content={restrictedBy(value, "heuristicClassifier")?.reason}>
<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={Boolean(value.custom_tier_set)} />
<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>
</span>
</Label>
</Label>
</SimpleTooltip>
</div>
</RadioGroup>
{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 +338,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 +376,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 +391,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,57 +406,62 @@ 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>
<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"
}
>
<Restricted by={restrictedBy(value, "classifierFallback")}>
<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>Route to the default model{defaultModel ? ` (${defaultModel})` : ""}</span>{" "}
<span className="text-muted-foreground">
— right when your prompt grades something other than complexity
</span>
<span>Score with the heuristic</span>{" "}
<span className="text-muted-foreground">— right when the classifier grades complexity too</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.
</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.
</span>
</Restricted>
</div>
<div>
<strong className="block mb-1 font-semibold">Context Window Size</strong>

View file

@ -1056,3 +1056,251 @@ 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("leaves no custom row's effort settings behind when the operator returns to the built-in tiers", () => {
const onChange = vi.fn();
const withEfforts: ComplexityRouterConfigValue = {
...customValue,
tier_model_params: { sec: { "gpt-4": { reasoning_effort: "high" } }, SIMPLE: { "gpt-3.5-turbo": {} } },
};
const { committed } = renderEditor(withEfforts);
fireEvent.click(screen.getByRole("button", { name: "Use built-in tiers" }));
const next = committed();
expect(next.custom_tier_set).toBeUndefined();
expect(Object.keys(next.tier_model_params ?? {})).toEqual(["SIMPLE"]);
});
it("keeps a renamed built-in row's effort settings when the operator reverts to the built-in tiers", () => {
const renamed: ComplexityRouterConfigValue = {
...customValue,
tier_model_params: { COMPLEX: { "gpt-4": { reasoning_effort: "high" } } },
custom_tier_set: {
tiers: [
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-3.5-turbo"] },
{ id: "COMPLEX", name: "DEEP_WORK", definition: "renamed built-in", models: ["gpt-4"] },
],
fallback_tier_id: "SIMPLE",
},
};
const { committed } = renderEditor(renamed);
fireEvent.click(screen.getByRole("button", { name: "Use built-in tiers" }));
const next = committed();
expect(next.tiers.COMPLEX).toEqual(["gpt-4"]);
expect(next.tier_model_params).toEqual({ COMPLEX: { "gpt-4": { reasoning_effort: "high" } } });
});
it("refuses to restore the built-in tiers when doing so would pass the tier limit", () => {
const nearLimit: ComplexityRouterConfigValue = {
...customValue,
custom_tier_set: {
tiers: Array.from({ length: 6 }, (_, index) => ({
id: `row-${index}`,
name: `TIER_${index}`,
definition: "d",
models: ["gpt-4"],
})),
fallback_tier_id: "row-0",
},
};
renderEditor(nearLimit);
expect(screen.getByRole("button", { name: "Restore defaults" })).toBeDisabled();
});
it("stops describing the heuristic scorer once an edited tier set replaces it", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
expect(screen.getByText("The LLM classifier reads your tier definitions", { exact: false })).toBeInTheDocument();
expect(screen.queryByText("scores each request across 7 dimensions", { exact: false })).not.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,12 +2,37 @@ 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 TierRestriction,
type ActiveTierRow,
type TierRow,
rowParamsByTier,
CUSTOM_TIER_RESTRICTIONS,
MAX_TIER_COUNT,
MAX_TIER_DEFINITION_CHARS,
MAX_TIER_NAME_CHARS,
MIN_TIER_COUNT,
TIER_ORDER,
activeTierName,
activeTierRows,
getCustomTierRowsError,
isBuiltInTierName,
resolveComplexityDefaultModel,
restoredBuiltInRows,
sameTierIdentity,
tierRowById,
tierRowByName,
} from "./tier_rows";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
@ -18,15 +43,16 @@ import {
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 TierRow, activeTierRows, resolveComplexityDefaultModel } 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;
@ -133,7 +159,48 @@ 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);
// Derived, never written into the value, so undoing a tier edit reverts the form with nothing left behind.
export const effectiveClassifierType = (
value: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type">,
): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type);
export const restrictedBy = (
value: Pick<ComplexityRouterConfigValue, "custom_tier_set">,
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}</>);
const rowOrigin = (row: TierRow, editing: boolean): string => {
if (!editing) return row.id;
return isBuiltInTierName(row.name) ? "built-in" : "custom";
};
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";
@ -141,6 +208,7 @@ 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;
@ -155,7 +223,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;
@ -186,6 +254,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
@ -246,6 +317,8 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
modelInfo,
value,
onChange,
editingTiers,
onEditingTiersChange,
customTechnicalKeywords,
onCustomTechnicalKeywordsChange,
keywordTierRules = [],
@ -260,13 +333,113 @@ 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 currentFallbackId = customTierSet?.fallback_tier_id ?? "MEDIUM";
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 emptyTiersHint = customTierSet
? "Add a model to your fallback tier"
: "Add a model to the Simple or Medium tier";
const defaultModelPlaceholder = derivedDefaultModel ? `Derived from tiers: ${derivedDefaultModel}` : emptyTiersHint;
const defaultModel = resolveComplexityDefaultModel(value, value.default_model);
// The sole tier-set writer: it owns where rows live and reconciles both row-id pointers, so a
// fallback re-points and a floor whose row is gone turns off in the same write.
const commitTierRows = (rows: TierRow[], fallbackTierId: string, base: ComplexityRouterConfigValue = value) => {
const floorGone = base.plan_mode_min_tier !== undefined && !rows.some((row) => row.id === base.plan_mode_min_tier);
const next = floorGone ? { ...base, plan_mode_min_tier: undefined } : base;
if (!next.custom_tier_set) {
onChange({ ...next, tiers: { ...next.tiers, ...Object.fromEntries(rows.map((row) => [row.id, row.models])) } });
return;
}
const fallback_tier_id = rows.some((row) => row.id === fallbackTierId)
? fallbackTierId
: (tierRowByName(rows, "MEDIUM") ?? rows[0])?.id ?? "";
onChange({ ...next, custom_tier_set: { tiers: rows, fallback_tier_id } });
};
const asCustomBase = (base: ComplexityRouterConfigValue): ComplexityRouterConfigValue =>
base.custom_tier_set
? base
: { ...base, custom_tier_set: { tiers: activeTierRows(base), fallback_tier_id: "MEDIUM" } };
const setRowModels = (row: TierRow, models: string[]) =>
commitTierRows(
tierRows.map((candidate) => (candidate.id === row.id ? { ...candidate, models } : candidate)),
currentFallbackId,
{ ...value, tier_model_params: pruneTierModelParams(value.tier_model_params, row.id, models) },
);
const updateTierRow = (id: string, patch: Partial<Omit<TierRow, "id">>) => {
const renamedFrom = patch.name === undefined ? undefined : tierRowById(tierRows, id)?.name;
const sharedName =
renamedFrom !== undefined && tierRows.some((row) => row.id !== id && sameTierIdentity(row.name, renamedFrom));
if (renamedFrom !== undefined && !sharedName && onKeywordTierRulesChange) {
onKeywordTierRulesChange(
keywordTierRules.map((rule) =>
rule.tier === renamedFrom.trim() ? { ...rule, tier: (patch.name ?? "").trim() } : rule,
),
);
}
commitTierRows(
tierRows.map((row) => (row.id === id ? { ...row, ...patch } : row)),
currentFallbackId,
asCustomBase(value),
);
};
const addCustomTier = () =>
commitTierRows(
[...tierRows, { id: crypto.randomUUID(), name: "", definition: "", models: [] }],
currentFallbackId,
asCustomBase(value),
);
const removeTierRow = (id: string) => {
const removed = tierRowById(tierRows, id);
const snapshot =
removed && (TIER_ORDER as string[]).includes(id)
? { ...value, tiers: { ...value.tiers, [id]: removed.models } }
: value;
commitTierRows(
tierRows.filter((row) => row.id !== id),
currentFallbackId,
asCustomBase(snapshot),
);
};
const restorableRows = restoredBuiltInRows(tierRows, value.tiers);
const restoreDefaultTiers = () => commitTierRows(restorableRows, currentFallbackId);
// Models and params both come from these rows, so the two cannot be keyed differently.
const exitToBuiltInTiers = () => {
const { custom_tier_set: _dropped, ...rest } = value;
const builtInRows: ActiveTierRow[] = TIER_ORDER.map(
(tier) =>
tierRowById(tierRows, tier) ?? {
id: tier,
name: tier,
definition: "",
models: value.tiers[tier],
params: value.tier_model_params?.[tier] ?? {},
},
);
const restored: ComplexityRouterConfigValue = {
...rest,
tier_model_params: rowParamsByTier(builtInRows),
tiers: { ...value.tiers, ...Object.fromEntries(builtInRows.map((row) => [row.id, row.models])) },
};
commitTierRows(activeTierRows(restored), "", restored);
};
// 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(
@ -284,19 +457,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),
@ -326,61 +487,120 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</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.
{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">
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) &&
{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."}
{!customTierSet &&
usesLlmClassifier(value.classifier_type) &&
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
</span>
<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 builtIn = TIER_ORDER.find((tier) => tier === row.id);
const tierInfo = builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined;
const label = tierRowLabel(row, value.tier_labels);
const tierMissing = showValidationErrors && row.models.length === 0;
const definitionMissing =
showValidationErrors && Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name);
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}>
<SimpleTooltip
content={
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 {tierRows.length} &middot; {row.id}
Tier {index + 1} of {tierRows.length} &middot; {rowOrigin(row, Boolean(customTierSet))}
</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 ${activeTierName(row) || `tier ${index + 1}`} tier`}
disabled={tierRows.length <= MIN_TIER_COUNT}
onClick={() => removeTierRow(row.id)}
>
<Trash2 />
Remove
</Button>
)}
</InputGroup>
</div>
{tierInfo && !customTierSet && (
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
)}
{editingTiers && (
<>
<Input
value={row.name}
onChange={(event) => updateTierRow(row.id, { name: event.target.value })}
placeholder="Tier name, e.g. SECURITY_REVIEW"
aria-label={`Name for tier ${index + 1}`}
maxLength={MAX_TIER_NAME_CHARS}
className="mb-2"
/>
<Textarea
value={row.definition}
onChange={(event) =>
updateTierRow(row.id, { 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>
)}
</>
)}
{!customTierSet && !editingTiers && 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"}
@ -389,12 +609,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>
)}
@ -403,6 +623,82 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</div>
);
})}
<div className="mt-4 flex flex-wrap items-center gap-2">
{editingTiers ? (
<>
<Button variant="outline" onClick={addCustomTier} disabled={tierRows.length >= MAX_TIER_COUNT}>
<Plus />
Add tier
</Button>
<SimpleTooltip content={tierRowsError || undefined}>
<Button
variant="outline"
disabled={Boolean(tierRowsError)}
onClick={() => onEditingTiersChange?.(false)}
>
Done
</Button>
</SimpleTooltip>
{customTierSet && TIER_ORDER.some((tier) => !tierRows.some((row) => row.id === tier)) && (
<SimpleTooltip
content={
restorableRows.length > MAX_TIER_COUNT
? `Restoring the built-in tiers would make ${restorableRows.length} tiers, past the limit of ${MAX_TIER_COUNT}. Remove a tier first`
: undefined
}
>
<Button
variant="outline"
size="sm"
disabled={restorableRows.length > MAX_TIER_COUNT}
onClick={restoreDefaultTiers}
>
Restore defaults
</Button>
</SimpleTooltip>
)}
{customTierSet && (
<Button variant="outline" size="sm" onClick={exitToBuiltInTiers}>
Use built-in tiers
</Button>
)}
</>
) : (
onEditingTiersChange && (
<Button variant="outline" onClick={() => onEditingTiersChange(true)}>
Edit tiers
</Button>
)
)}
</div>
{editingTiers && (
<span className="block mt-1 text-xs text-muted-foreground">
Add or remove tiers to define your own set. Every custom tier needs a definition the LLM classifier routes
on, and an edited set requires the LLM classification method
</span>
)}
{customTierSet && (
<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={tierRows
.filter((row) => activeTierName(row))
.map((row) => ({ value: row.id, label: activeTierName(row) }))}
value={customTierSet.fallback_tier_id || null}
onValueChange={(fallbackTierId) => commitTierRows(tierRows, fallbackTierId)}
placeholder="Pick the tier classifier failures route to"
/>
</div>
)}
<Separator className="my-4" />
<div className="mb-2">
@ -416,11 +712,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"
/>
@ -454,7 +746,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",
@ -477,15 +773,16 @@ 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.
{restrictedBy(value, "sessionAffinity")?.reason ??
"Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment."}
</span>
</>
),
@ -516,22 +813,12 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</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>
)}
</>
@ -563,7 +850,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>
),
},
]
: []),
@ -579,6 +870,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

@ -73,6 +73,7 @@ const { mockFetchAvailableModels, mockFetchAllModelDeployments } = vi.hoisted(()
vi.mock("../networking", () => ({
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
testAutoRouterRouting: vi.fn(),
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }),
}));
vi.mock("@/components/llm_calls/fetch_models", () => ({
@ -894,6 +895,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";
@ -13,7 +13,8 @@ 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 { 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,
@ -40,9 +42,8 @@ import {
getSemanticConfigError,
getTierLabelsError,
} 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";
@ -109,7 +110,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";
};
@ -123,8 +124,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) ??
@ -145,16 +146,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 } : {};
@ -196,6 +187,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
@ -295,6 +288,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
);
const applyPrefill = (prefill: PresetPrefill) => {
setEditingTiers(false);
setComplexityRouterConfig(prefill.complexityRouterConfig);
setCustomTechnicalKeywords(prefill.customTechnicalKeywords);
setKeywordTierRules(prefill.keywordTierRules);
@ -325,8 +319,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,
@ -342,6 +336,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,
@ -373,8 +368,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.
@ -405,16 +398,28 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
// 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,
complexityRouterConfigPayload as unknown as Record<string, unknown>,
requiresTeamScope ? form.getValues("team_id") : undefined,
);
if (!serverVerdict.valid && serverVerdict.error) {
setShowValidationErrors(true);
toast.fromError(serverVerdict.error);
return;
}
const submitValues: AddAutoRouterValues = {
auto_router_name: name,
...teamScopePayload(requiresTeamScope, form.getValues("team_id")),
auto_router_default_model: defaultModel,
model_type: "complexity_router",
complexity_router_config: buildComplexityRouterConfig(complexityRouterConfigParams),
complexity_router_config: complexityRouterConfigPayload,
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 () => {
@ -426,7 +431,12 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
return;
}
await submitRecommendedRouter(name);
setIsSubmitting(true);
try {
await submitRecommendedRouter(name);
} finally {
setIsSubmitting(false);
}
};
const handleTestConnection = () => {
@ -558,6 +568,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
{detailsExpanded && (
<div className="px-4 pb-4">
<ComplexityRouterConfig
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
modelInfo={modelInfo}
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
@ -621,7 +633,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
@ -640,7 +652,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
<BlockedReasonTooltip reason={submitBlockedReason}>
<Button
type="button"
disabled={submitBlockedReason !== null}
disabled={submitBlockedReason !== null || isSubmitting}
onClick={() => {
void handleAutoRouterSubmit();
}}

View file

@ -5,12 +5,13 @@ import {
getKeywordTierRulesError,
getClassifierModelError,
getMissingTiersError,
hydrateCustomTierSet,
getSemanticConfigError,
getTierLabelsError,
hydrateTierLabels,
BuildComplexityRouterConfigParams,
} from "./build_complexity_router_config";
import { activeTierRows } from "./tier_rows";
import { CUSTOM_TIER_RESTRICTIONS, activeTierRows } from "./tier_rows";
const tiers = {
SIMPLE: ["gpt-4o-mini"],
@ -694,6 +695,19 @@ describe("getClassifierModelError", () => {
);
});
it("asks for a model under an edited tier set even while the stored type still reads heuristic", () => {
const customSet = {
tiers: [
{ id: "a", name: "CASUAL", definition: "d", models: ["m"] },
{ id: "b", name: "AUDIT", definition: "d", models: ["m"] },
],
fallback_tier_id: "a",
};
expect(getClassifierModelError({ classifier_type: "heuristic", custom_tier_set: customSet })).toContain(
"an edited tier set routes with the LLM classifier",
);
});
it("stays quiet once a model is chosen", () => {
expect(
getClassifierModelError({ classifier_type: "llm", classifier_llm_config: { model: "m", timeout_ms: 3000 } }),
@ -759,3 +773,168 @@ describe("heuristic_first", () => {
}
});
});
describe("buildComplexityRouterConfig with an edited tier set", () => {
const customTierSet = {
tiers: [
{ id: "CASUAL", name: "CASUAL", definition: "small talk", models: ["gpt-4o-mini"] },
{ id: "sec", name: " SECURITY_REVIEW ", definition: " audits and vulnerability review ", models: ["o1-preview"] },
],
fallback_tier_id: "CASUAL",
};
const build = (overrides: Partial<BuildComplexityRouterConfigParams> = {}) => {
const params: BuildComplexityRouterConfigParams = {
...baseParams,
customTierSet,
classifierType: "llm",
classifierLlmConfig: { model: "gpt-4o-mini", timeout_ms: 3000 },
...overrides,
};
return buildComplexityRouterConfig(params);
};
it("writes tiers, tier_definitions and fallback_tier from the rows, trimmed to what the backend matches", () => {
const payload = build();
expect(payload.tiers).toEqual({ CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["o1-preview"] });
expect(payload.tier_definitions).toEqual([
{ name: "CASUAL", description: "small talk" },
{ name: "SECURITY_REVIEW", description: "audits and vulnerability review" },
]);
expect(payload.fallback_tier).toBe("CASUAL");
});
it("forces the LLM classifier even when the form still holds heuristic, which the backend rejects", () => {
expect(build({ classifierType: "heuristic" }).classifier_type).toBe("llm");
});
it("turns session pinning off rather than leaving a stale true the backend rejects", () => {
expect(build({ sessionAffinity: true }).session_affinity).toBe(false);
});
it("omits a definition on a built-in name, letting the backend rubric supply it", () => {
const payload = build({
customTierSet: {
tiers: [{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-4o-mini"] }, customTierSet.tiers[1]],
fallback_tier_id: "SIMPLE",
},
});
expect(payload.tier_definitions?.[0]).toEqual({ name: "SIMPLE" });
});
it("drops the replacement prompt and the rubric preset, which live inside classifier_llm_config", () => {
const payload = build({
classifierLlmConfig: {
model: "gpt-4o-mini",
timeout_ms: 3000,
system_prompt: "replace the whole rubric",
classification_rubric: "agentic",
},
});
expect(payload.classifier_llm_config).toEqual({ model: "gpt-4o-mini", timeout_ms: 3000 });
});
it.each(
Object.entries(CUSTOM_TIER_RESTRICTIONS).flatMap(([name, restriction]) =>
restriction.omit.map((key) => [name, key] as const),
),
)("drops %s's %s key, which an edited tier set never uses", (_name, key) => {
const loaded: Partial<BuildComplexityRouterConfigParams> = {
tierLabels: { SIMPLE: "Cheap" },
escalationKeywords: ["LITELLM ESCALATE"],
adaptive: true,
classifierFallback: "heuristic",
tierBoundaries: { simple_medium: 0.1, medium_complex: 0.25, complex_reasoning: 0.5 },
tokenThresholds: { short: 1, long: 2 },
dimensionWeights: { length: 1 },
reasoningOverrideMinScore: 0.5,
heuristicFirstMaxTier: "SIMPLE",
customTechnicalKeywords: ["kubernetes"],
};
// The control arm must be a router that actually emits the key, or the assertion is vacuous.
const emittingType = key === "heuristic_first_max_tier" ? "heuristic_first" : "llm";
expect(buildComplexityRouterConfig({ ...baseParams, ...loaded, classifierType: emittingType })).toHaveProperty(key);
expect(build(loaded)).not.toHaveProperty(key);
});
it("drops the local-scorer threshold left behind by a heuristic_first router, which the backend rejects here", () => {
const payload = build({ classifierType: "heuristic_first", heuristicFirstMaxTier: "SIMPLE" });
expect(payload).not.toHaveProperty("heuristic_first_max_tier");
expect(payload.classifier_type).toBe("llm");
});
it("keeps the classifier context knobs the operator set, which the forced LLM classifier reads", () => {
const payload = build({ classifierType: "heuristic", classifierContextWindowSize: 5 });
expect(payload.classifier_context_window_size).toBe(5);
expect(payload.classifier_llm_config).toEqual({ model: "gpt-4o-mini", timeout_ms: 3000 });
});
it("carries the plan-mode floor as the row's name, not the row id the form holds", () => {
expect(build({ planModeMinTier: "sec" }).plan_mode_min_tier).toBe("SECURITY_REVIEW");
});
it("leaves the floor off when its row is gone, the same rule hydration and the editor apply", () => {
expect(build({ planModeMinTier: "removed-row" })).not.toHaveProperty("plan_mode_min_tier");
});
it("keys per-model params by tier name on the wire while the editor keys them by row id", () => {
const payload = build({ tierModelParams: { sec: { "o1-preview": { reasoning_effort: "high" } } } });
expect(payload.tier_model_configs).toEqual({
SECURITY_REVIEW: [{ model_name: "o1-preview", litellm_params: { reasoning_effort: "high" } }],
});
});
it("leaves behind no params for a row that is no longer in the set", () => {
const payload = build({ tierModelParams: { removed: { "o1-preview": { reasoning_effort: "high" } } } });
expect(payload).not.toHaveProperty("tier_model_configs");
});
});
describe("hydrateCustomTierSet", () => {
it("returns nothing when the stored config has no tier_definitions, keeping built-in routers built-in", () => {
expect(hydrateCustomTierSet({ tiers: { SIMPLE: ["a"] } })).toBeUndefined();
});
it("round-trips a payload this form built, so an edit save cannot lose a key", () => {
const roundTripParams: BuildComplexityRouterConfigParams = {
...baseParams,
classifierType: "llm",
classifierLlmConfig: { model: "gpt-4o-mini", timeout_ms: 3000 },
customTierSet: {
tiers: [
{ id: "CASUAL", name: "CASUAL", definition: "small talk", models: ["gpt-4o-mini"] },
{ id: "sec", name: "SECURITY_REVIEW", definition: "audits", models: ["o1-preview"] },
],
fallback_tier_id: "sec",
},
};
const payload = buildComplexityRouterConfig(roundTripParams);
const hydrated = hydrateCustomTierSet(payload);
expect(hydrated?.tiers).toEqual([
{ id: "stored-0", name: "CASUAL", definition: "small talk", models: ["gpt-4o-mini"] },
{ id: "stored-1", name: "SECURITY_REVIEW", definition: "audits", models: ["o1-preview"] },
]);
expect(hydrated?.tiers.find((row) => row.id === hydrated.fallback_tier_id)?.name).toBe("SECURITY_REVIEW");
});
it("mints the canonical key for a built-in name so Restore defaults recognises the row", () => {
const hydrated = hydrateCustomTierSet({
tier_definitions: [{ name: "SIMPLE" }, { name: "AUDIT", description: "audits" }],
tiers: { SIMPLE: ["a"], AUDIT: ["b"] },
fallback_tier: "AUDIT",
});
expect(hydrated?.tiers.map((row) => row.id)).toEqual(["SIMPLE", "stored-1"]);
});
it("matches a hand-written config's tier pool case-insensitively rather than losing its models", () => {
const hydrated = hydrateCustomTierSet({
tier_definitions: [
{ name: "audit", description: "x" },
{ name: "CASUAL", description: "y" },
],
tiers: { Audit: ["m1"], CASUAL: ["m2"] },
fallback_tier: " audit ",
});
expect(hydrated?.tiers[0].models).toEqual(["m1"]);
expect(hydrated?.fallback_tier_id).toBe(hydrated?.tiers[0].id);
});
});

View file

@ -1,7 +1,20 @@
import { KeywordTierRule } from "./KeywordTierRules";
import { type TierRow, activeTierName, tierRowById } from "./tier_rows";
import {
type CustomTierSet,
type TierRow,
CUSTOM_TIER_OMITTED_KEYS,
activeTierName,
sameTierIdentity,
tierRowById,
tierRowByName,
} from "./tier_rows";
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,
@ -13,6 +26,7 @@ import {
ComplexityTiers,
DimensionWeights,
TIER_KEYS,
effectiveClassifierType,
TIER_DESCRIPTIONS,
TierBoundaries,
TokenThresholds,
@ -78,6 +92,7 @@ const scorerKnobPayload = ({
export interface BuildComplexityRouterConfigParams {
tiers: ComplexityTiers;
customTierSet?: CustomTierSet;
defaultModel: string | undefined;
planModeMinTier: string | undefined;
tierLabels: ComplexityTierLabels | undefined;
@ -108,8 +123,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;
@ -177,7 +199,7 @@ export const getTierLabelsError = (tierLabels: ComplexityTierLabels | undefined)
// Requires every active tier non-empty, so the create form can never reach the
// resolveComplexityDefaultModel === undefined case. The edit modal allows a partially filled
// set, which is why it keeps its own !defaultModel guard after deriving.
// built-in set, which is why it keeps its own !defaultModel guard after deriving.
export const getMissingTiersError = (rows: readonly TierRow[]): string | null => {
const missing = rows.filter((row) => row.models.length === 0).map(activeTierName);
if (missing.length === 0) return null;
@ -191,8 +213,9 @@ export const getPlanModeTierError = (planModeMinTier: string | undefined, rows:
return `The plan-mode minimum tier (${floor ? activeTierName(floor) : planModeMinTier}) has no models. Add one or turn the override off.`;
};
// The tier is a free string since #37413, and _validate_keyword_rule_tiers matches it EXACTLY, so a
// rule naming a tier this router does not have is a raw 400 unless the gate catches it first.
// The orphan check compares exactly, not casefold: _validate_keyword_rule_tiers is exact
// membership, so a rule left pointing at a differently cased name would clear a gate the
// backend then rejects.
export const getKeywordTierRulesError = (
keywordTierRules: KeywordTierRule[],
rows: readonly TierRow[],
@ -206,14 +229,16 @@ export const getKeywordTierRulesError = (
return `Keyword rule(s) ${orphaned.join(", ")} route to a tier this router no longer has`;
};
// The submit gate and the submit handler both read this, so a disabled button and a refused submit
// cannot disagree about why.
// An edited tier set forces the LLM classifier, so the model requirement follows the EFFECTIVE type.
// Both forms' submit gates and their submit handlers read this one answer so they cannot drift.
export const getClassifierModelError = (
config: Pick<ComplexityRouterConfigValue, "classifier_type" | "classifier_llm_config">,
): string | null =>
usesLlmClassifier(config.classifier_type) && !config.classifier_llm_config?.model
? "Please select a classifier model, or switch back to Heuristic"
: null;
config: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type" | "classifier_llm_config">,
): string | null => {
if (!usesLlmClassifier(effectiveClassifierType(config)) || config.classifier_llm_config?.model) return null;
return config.custom_tier_set
? "Please select a classifier model: an edited tier set routes with the LLM classifier"
: "Please select a classifier model, or switch back to Heuristic";
};
export const getSemanticConfigError = ({
semanticMatchingEnabled,
@ -228,8 +253,79 @@ export const getSemanticConfigError = ({
return null;
};
export const customTierWireFields = (
customTierSet: CustomTierSet,
classifierLlmConfig: ClassifierLLMConfig | undefined,
planModeMinTierId: string | undefined,
): Partial<ComplexityRouterConfigPayload> => {
const rows = customTierSet.tiers;
const fallback = tierRowById(rows, customTierSet.fallback_tier_id);
const floor = tierRowById(rows, planModeMinTierId);
return {
tiers: Object.fromEntries(rows.map((row) => [activeTierName(row), row.models])),
tier_definitions: rows.map((row) => ({
name: activeTierName(row),
...(row.definition.trim() && { description: row.definition.trim() }),
})),
...(fallback && { fallback_tier: activeTierName(fallback) }),
classifier_type: "llm",
// Rebuilt from the two fields an edited tier set allows: system_prompt and
// classification_rubric are the classifierPrompt and classificationRubric restrictions, and
// both live inside this object rather than at the top level the omit list covers.
...(classifierLlmConfig && {
classifier_llm_config: { model: classifierLlmConfig.model, timeout_ms: classifierLlmConfig.timeout_ms },
}),
session_affinity: false,
...(floor && { plan_mode_min_tier: activeTierName(floor) }),
};
};
// plan_mode_min_tier rides the strip list because the base payload carries it as a row id;
// customTierWireFields re-emits it as the row's name, and an unresolvable floor stays off.
const CUSTOM_TIER_STRIPPED_KEYS: readonly string[] = [...CUSTOM_TIER_OMITTED_KEYS, "plan_mode_min_tier"];
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)
? Object.entries(parsedConfig.tiers as Record<string, unknown>)
: [];
const rows = parsedConfig.tier_definitions.flatMap((entry, index): TierRow[] => {
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) => sameTierIdentity(tier, name)) ?? `stored-${index}`,
name: name.trim(),
definition: typeof description === "string" ? description.trim() : "",
models: normalizeTierModels(storedTiers.find(([tier]) => sameTierIdentity(tier, name))?.[1]),
},
];
});
if (rows.length === 0) return undefined;
const storedFallback = typeof parsedConfig.fallback_tier === "string" ? parsedConfig.fallback_tier : "";
return { tiers: rows, fallback_tier_id: tierRowByName(rows, storedFallback)?.id ?? "" };
};
// Ids are session-ephemeral, so a stored floor hydrates by name; unresolvable means off, the same
// rule the editor and the wire apply.
export const hydratePlanModeMinTier = (
stored: unknown,
customTierSet: CustomTierSet | undefined,
): string | undefined => {
if (typeof stored !== "string" || !stored.trim()) return undefined;
if (!customTierSet) return stored;
return tierRowByName(customTierSet.tiers, stored)?.id;
};
export const buildComplexityRouterConfig = ({
tiers,
customTierSet,
defaultModel,
planModeMinTier,
tierLabels,
@ -259,7 +355,12 @@ export const buildComplexityRouterConfig = ({
reasoningOverrideMinScore,
tierModelParams,
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
const serializedTierModelConfigs = serializeTierModelConfigs(tiers, tierModelParams);
const serializedTierModelConfigs = customTierSet
? serializeTierModelConfigs(
Object.fromEntries(customTierSet.tiers.map((row) => [activeTierName(row), row.models])),
Object.fromEntries(customTierSet.tiers.map((row) => [activeTierName(row), tierModelParams?.[row.id] ?? {}])),
)
: serializeTierModelConfigs(tiers, tierModelParams);
const cleanedEscalationKeywords = escalationKeywords.map((keyword) => keyword.trim()).filter(Boolean);
const cleanedKeywordTierRules = serializeKeywordTierRules(keywordTierRules);
const cleanedTierLabels = serializeTierLabels(tierLabels);
@ -272,29 +373,32 @@ export const buildComplexityRouterConfig = ({
reasoningOverrideMinScore,
};
const scorerKnobs = scorerKnobPayload(scorerInputs);
// An edited tier set forces the LLM classifier, so llm-only inputs must survive a classifier_type
// the form never rewrote. The UI gates the same controls on this, not on the raw value.
const effectiveType: ClassifierType = customTierSet ? "llm" : classifierType;
return {
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,
...(usesLlmClassifier(classifierType) &&
...(usesLlmClassifier(effectiveType) &&
classifierLlmConfig && { classifier_llm_config: normalizeClassifierLlmConfig(classifierLlmConfig) }),
...(usesLlmClassifier(classifierType) &&
...(usesLlmClassifier(effectiveType) &&
classifierFallback !== undefined && { classifier_fallback: classifierFallback }),
...(classifierType === "heuristic_first" &&
...(effectiveType === "heuristic_first" &&
heuristicFirstMaxTier?.trim() && { heuristic_first_max_tier: heuristicFirstMaxTier }),
...(usesLlmClassifier(classifierType) &&
...(usesLlmClassifier(effectiveType) &&
classifierContextWindowSize !== undefined && {
classifier_context_window_size: classifierContextWindowSize,
}),
...(usesLlmClassifier(classifierType) &&
...(usesLlmClassifier(effectiveType) &&
classifierContextBudgetChars !== undefined && {
classifier_context_budget_chars: classifierContextBudgetChars,
}),
...(usesLlmClassifier(classifierType) &&
...(usesLlmClassifier(effectiveType) &&
classifierContextIncludeAssistantTurns !== undefined && {
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
}),
@ -317,4 +421,9 @@ export const buildComplexityRouterConfig = ({
...(returnRawModelName && { return_raw_model_name: true }),
...scorerKnobs,
};
if (!customTierSet) return payload;
const kept = Object.fromEntries(
Object.entries(payload).filter(([key]) => !CUSTOM_TIER_STRIPPED_KEYS.includes(key)),
) as ComplexityRouterConfigPayload;
return { ...kept, ...customTierWireFields(customTierSet, classifierLlmConfig, planModeMinTier) };
};

View file

@ -5,6 +5,7 @@ import {
normalizeTierModels,
pruneTierModelParams,
serializeTierModelConfigs,
tierRowLabel,
setTierModelReasoningEffort,
} from "./complexity_router_tiers";
import { resolveComplexityDefaultModel } from "./tier_rows";
@ -234,3 +235,22 @@ describe("pruneTierModelParams", () => {
expect(pruneTierModelParams(current, "MEDIUM", [])).toBe(current);
});
});
describe("tierRowLabel", () => {
it("shows a built-in row's display label while it is untouched", () => {
expect(tierRowLabel({ id: "COMPLEX", name: "COMPLEX" })).toBe("Complex");
expect(tierRowLabel({ id: "COMPLEX", name: "COMPLEX" }, { COMPLEX: "Deep" })).toBe("Deep");
});
it("shows the operator's name once a built-in row is renamed, since the id stays canonical", () => {
expect(tierRowLabel({ id: "COMPLEX", name: "SECURITY_REVIEW" })).toBe("SECURITY_REVIEW");
});
it("shows a custom row's name", () => {
expect(tierRowLabel({ id: "stored-1", name: "AUDIT" })).toBe("AUDIT");
});
it("calls an unnamed new row New rather than rendering an empty label", () => {
expect(tierRowLabel({ id: "uuid", name: " " })).toBe("New");
});
});

View file

@ -133,7 +133,25 @@ export const DEFAULT_TIER_LABELS: Record<ComplexityTier, string> = {
REASONING: "Reasoning",
};
const isBuiltInTier = (tier: string): tier is ComplexityTier => (TIER_ORDER as string[]).includes(tier);
// What a tier row is called on screen. A row the operator named shows that name; an untouched
// built-in row shows its display label. The one owner for every surface that renders a tier.
export const tierRowLabel = (
row: { id: string; name: string },
tierLabels?: Partial<Record<ComplexityTier, string>>,
): string => {
const builtIn = TIER_ORDER.find((tier) => tier === row.id);
const named = row.name.trim();
if (!builtIn || named !== builtIn) return named || "New";
return tierLabels?.[builtIn]?.trim() || DEFAULT_TIER_LABELS[builtIn];
};
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?: readonly string[],
): { value: string; label: string }[] =>
(tierNames ?? TIER_ORDER).map((tier) => ({
value: tier,
label: (isBuiltInTier(tier) && (tierLabels?.[tier]?.trim() || DEFAULT_TIER_LABELS[tier])) || tier,
}));

View file

@ -1,12 +1,19 @@
import { describe, expect, it } from "vitest";
import type { ActiveTierRow, CustomTierSet, TierRow } from "./tier_rows";
import {
CUSTOM_TIER_OMITTED_KEYS,
CUSTOM_TIER_RESTRICTIONS,
MAX_TIER_COUNT,
activeTierName,
activeTierRows,
isBuiltInTierName,
resolveComplexityDefaultModel,
sameTierIdentity,
tierRowById,
getCustomTierRowsError,
restoredBuiltInRows,
tierParamsByRowId,
tierRowByName,
} from "./tier_rows";
@ -15,19 +22,17 @@ const tiers = { SIMPLE: ["a"], MEDIUM: ["b"], COMPLEX: ["c"], REASONING: ["d"] }
describe("activeTierRows", () => {
it("reads the tier set as rows whose id is the canonical tier key, in severity order", () => {
expect(activeTierRows({ tiers })).toEqual([
{ id: "SIMPLE", name: "SIMPLE", models: ["a"] },
{ id: "MEDIUM", name: "MEDIUM", models: ["b"] },
{ id: "COMPLEX", name: "COMPLEX", models: ["c"] },
{ id: "REASONING", name: "REASONING", models: ["d"] },
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["a"], params: {} },
{ id: "MEDIUM", name: "MEDIUM", definition: "", models: ["b"], params: {} },
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: ["c"], params: {} },
{ id: "REASONING", name: "REASONING", definition: "", models: ["d"], params: {} },
]);
});
it("gives a tier with no models an empty pool rather than dropping the row", () => {
expect(activeTierRows({ tiers: { ...tiers, COMPLEX: [] } })[2]).toEqual({
id: "COMPLEX",
name: "COMPLEX",
models: [],
});
const withEmptyComplex = { tiers: { ...tiers, COMPLEX: [] } };
const emptyComplexRow: ActiveTierRow = { id: "COMPLEX", name: "COMPLEX", definition: "", models: [], params: {} };
expect(activeTierRows(withEmptyComplex)[2]).toEqual(emptyComplexRow);
});
it("finds a row by id and by name", () => {
@ -53,7 +58,8 @@ describe("sameTierIdentity", () => {
});
it("trims a row name, since the backend matches fallback_tier and keyword rules exactly", () => {
expect(activeTierName({ id: "1", name: " AUDIT ", models: [] })).toBe("AUDIT");
const padded: TierRow = { id: "1", name: " AUDIT ", definition: "", models: [] };
expect(activeTierName(padded)).toBe("AUDIT");
});
});
@ -68,3 +74,134 @@ describe("resolveComplexityDefaultModel", () => {
expect(resolveComplexityDefaultModel({ tiers: { ...tiers, SIMPLE: [], MEDIUM: [] } })).toBeUndefined();
});
});
const definedRow = (name: string, models: string[] = ["m"], definition = "what belongs here"): TierRow => ({
id: name.toLowerCase(),
name,
definition,
models,
});
const set = (rows: TierRow[], fallback?: string): CustomTierSet => ({
tiers: rows,
fallback_tier_id: fallback ?? rows[0]?.id ?? "",
});
describe("activeTierRows with an edited set", () => {
it("reads the edited rows instead of the built-in record once a set is present", () => {
const custom = set([definedRow("CASUAL"), definedRow("AUDIT")]);
expect(activeTierRows({ tiers, custom_tier_set: custom }).map((r) => r.name)).toEqual(["CASUAL", "AUDIT"]);
});
it("prefers the fallback tier's pool for the default model, mirroring init_complexity_router_deployment", () => {
const custom = set([definedRow("CASUAL", ["casual-model"]), definedRow("AUDIT", ["audit-model"])], "audit");
expect(resolveComplexityDefaultModel({ tiers, custom_tier_set: custom })).toBe("audit-model");
});
});
describe("CUSTOM_TIER_RESTRICTIONS", () => {
it("gives every restriction a reason, since each one replaces or explains a control", () => {
const reasons = Object.values(CUSTOM_TIER_RESTRICTIONS).map((restriction) => restriction.reason);
expect(reasons.every((reason) => reason.length > 0)).toBe(true);
expect(new Set(reasons).size).toBe(reasons.length);
});
it("collects every omitted key exactly once, so no key is dropped by two owners", () => {
expect(new Set(CUSTOM_TIER_OMITTED_KEYS).size).toBe(CUSTOM_TIER_OMITTED_KEYS.length);
});
it("omits the keys the backend rejects beside tier_definitions", () => {
expect(CUSTOM_TIER_OMITTED_KEYS).toEqual(
expect.arrayContaining(["tier_labels", "escalation_keywords", "adaptive", "classifier_fallback"]),
);
});
});
describe("restoredBuiltInRows", () => {
it("brings missing built-ins back in canonical order and leaves custom rows after them", () => {
const restored = restoredBuiltInRows([definedRow("AUDIT"), { ...definedRow("COMPLEX"), id: "COMPLEX" }], tiers);
expect(restored.map((r) => r.id)).toEqual(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING", "audit"]);
});
it("keeps the models an already-present built-in row carries rather than the stale record", () => {
const edited = { ...definedRow("SIMPLE", ["edited"]), id: "SIMPLE" };
expect(restoredBuiltInRows([edited], tiers).find((r) => r.id === "SIMPLE")?.models).toEqual(["edited"]);
});
});
describe("restoredBuiltInRows name collisions", () => {
it("does not restore a built-in slot whose name a custom row already answers to", () => {
const custom: TierRow[] = [
{ id: "uuid-1", name: "SIMPLE", definition: "operator took this name", models: ["a"] },
{ id: "MEDIUM", name: "MEDIUM", definition: "", models: ["b"] },
];
const restored = restoredBuiltInRows(custom, tiers);
const folded = restored.map((row) => row.name.toLowerCase());
expect(new Set(folded).size).toBe(folded.length);
expect(restored.filter((row) => row.name === "SIMPLE")).toHaveLength(1);
});
it("still restores the built-in slots nothing else claims", () => {
const custom: TierRow[] = [{ id: "uuid-1", name: "AUDIT", definition: "d", models: ["a"] }];
expect(restoredBuiltInRows(custom, tiers).map((row) => row.id)).toEqual([
"SIMPLE",
"MEDIUM",
"COMPLEX",
"REASONING",
"uuid-1",
]);
});
});
describe("getCustomTierRowsError", () => {
it("accepts a complete set", () => {
expect(getCustomTierRowsError(set([definedRow("CASUAL"), definedRow("AUDIT")]))).toBeNull();
});
it.each([
[set([definedRow("CASUAL")]), "A tier set needs 2 to 8 tiers"],
[
set(Array.from({ length: MAX_TIER_COUNT + 1 }, (_, index) => definedRow(`T${index}`))),
"A tier set needs 2 to 8 tiers",
],
[set([definedRow(""), definedRow("AUDIT")], "audit"), "Name every tier"],
[set([definedRow("AUDIT"), { ...definedRow("audit"), id: "second" }]), "Tier names must be unique, ignoring case"],
[
set([definedRow("CASUAL"), { ...definedRow("AUDIT"), definition: " " }]),
"Every custom tier needs a definition: it is the rubric the classifier routes on",
],
[set([definedRow("CASUAL"), definedRow("AUDIT")], "gone"), "Pick a Fallback Tier for classifier failures"],
])("reports the row problem the backend would reject", (customTierSet, expected) => {
expect(getCustomTierRowsError(customTierSet)).toBe(expected);
});
it("lets a built-in name inherit its definition, which is the one blank the backend allows", () => {
expect(getCustomTierRowsError(set([{ ...definedRow("SIMPLE"), definition: "" }, definedRow("AUDIT")]))).toBeNull();
});
});
describe("tierParamsByRowId", () => {
const rows = [
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["a"] },
{ id: "stored-1", name: "SECURITY_REVIEW", definition: "audits", models: ["b"] },
];
it("re-keys a stored tier name onto the ephemeral row id the editor reads", () => {
const stored = { SECURITY_REVIEW: { b: { reasoning_effort: "high" } } };
expect(tierParamsByRowId(stored, rows)).toEqual({ "stored-1": { b: { reasoning_effort: "high" } } });
});
it("leaves a built-in tier untouched, because its row id is already the tier name", () => {
const stored = { SIMPLE: { a: { reasoning_effort: "low" } } };
expect(tierParamsByRowId(stored, rows)).toEqual(stored);
});
it("passes a tier this editor does not render straight through instead of dropping its params", () => {
const stored = { DEEP_RESEARCH: { c: { reasoning_effort: "high" } } };
expect(tierParamsByRowId(stored, rows)).toEqual(stored);
});
it("returns nothing when there are no stored params, keeping the key out of the payload", () => {
expect(tierParamsByRowId(undefined, rows)).toBeUndefined();
});
});

View file

@ -1,40 +1,176 @@
import type { ComplexityTiers } from "./ComplexityRouterConfig";
import type { ComplexityTier } from "./KeywordTierRules";
import type { TierModelParams, TierModelParamsByTier } from "./complexity_router_tiers";
export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
export interface TierRow {
id: string;
name: string;
definition: string;
models: string[];
}
/** A row plus the per-model params it owns, so the two can never be keyed differently. */
export type ActiveTierRow = TierRow & { params: Record<string, TierModelParams> };
export interface CustomTierSet {
tiers: TierRow[];
fallback_tier_id: string;
}
// Mirrors TierDefinition in litellm/router_strategy/complexity_router/config.py
export const MIN_TIER_COUNT = 2;
export const MAX_TIER_COUNT = 8;
export const MAX_TIER_NAME_CHARS = 64;
export const MAX_TIER_DEFINITION_CHARS = 500;
export interface ActiveTierSet {
tiers: ComplexityTiers;
custom_tier_set?: CustomTierSet;
tier_model_params?: TierModelParamsByTier;
}
export const activeTierName = (row: TierRow): string => row.name.trim();
// Casefold, matching the backend's name-uniqueness rule. NOT the comparison the keyword-rule gate
// uses: _validate_keyword_rule_tiers is exact membership, so folding there clears a save the
// backend then rejects.
export const sameTierIdentity = (left: string, right: string): boolean =>
left.trim().toLowerCase() === right.trim().toLowerCase();
export const isBuiltInTierName = (name: string): boolean => TIER_ORDER.some((tier) => sameTierIdentity(tier, name));
// The only reader of the tier set. A row's id is the canonical tier key, so anything pointing into
// the set (the plan-mode floor, per-model params) points at a row rather than at a position.
export const activeTierRows = (value: ActiveTierSet): TierRow[] =>
TIER_ORDER.map((tier) => ({ id: tier, name: tier, models: value.tiers[tier] ?? [] }));
const builtInRow = (tier: keyof ComplexityTiers, tiers: ComplexityTiers): TierRow => ({
id: tier,
name: tier,
definition: "",
models: tiers[tier] ?? [],
});
export const tierRowById = (rows: readonly TierRow[], id: string | undefined): TierRow | undefined =>
// The only reader of the tier set. Built-in rows carry the canonical tier key as their id, so every
// pointer into the set is a row id in both modes and nothing downstream branches on the mode.
export const activeTierRows = (value: ActiveTierSet): ActiveTierRow[] => {
const rows = value.custom_tier_set?.tiers ?? TIER_ORDER.map((tier) => builtInRow(tier, value.tiers));
return rows.map((row) => ({ ...row, params: value.tier_model_params?.[row.id] ?? {} }));
};
// A custom row can answer to a built-in name without carrying its id, and restoring the slot then
// collides on the uniqueness rule, so a claimed name means that slot stays gone.
export const restoredBuiltInRows = (rows: readonly TierRow[], tiers: ComplexityTiers): TierRow[] => [
...TIER_ORDER.flatMap((tier) => {
const slot = rows.find((row) => row.id === tier);
if (slot) return [slot];
return rows.some((row) => sameTierIdentity(row.name, tier)) ? [] : [builtInRow(tier, tiers)];
}),
...rows.filter((row) => !(TIER_ORDER as string[]).includes(row.id)),
];
export const tierRowById = <T extends TierRow>(rows: readonly T[], id: string | undefined): T | undefined =>
id === undefined ? undefined : rows.find((row) => row.id === id);
export const tierRowByName = (rows: readonly TierRow[], name: string): TierRow | undefined =>
export const tierRowByName = <T extends TierRow>(rows: readonly T[], name: string): T | undefined =>
rows.find((row) => sameTierIdentity(row.name, name));
// Mirrors init_complexity_router_deployment (litellm/router.py): a pin wins, then MEDIUM or SIMPLE
// looked up by exact name.
// Mirrors init_complexity_router_deployment (litellm/router.py): a pin wins, then the fallback
// tier's pool, then MEDIUM or SIMPLE looked up by exact name, so a row named `medium` is no match.
export const resolveComplexityDefaultModel = (value: ActiveTierSet, pinned?: string): string | undefined => {
const rows = activeTierRows(value);
const named = (name: string) => rows.find((row) => activeTierName(row) === name)?.models[0];
return pinned?.trim() || named("MEDIUM") || named("SIMPLE");
return (
pinned?.trim() ||
tierRowById(rows, value.custom_tier_set?.fallback_tier_id)?.models[0] ||
named("MEDIUM") ||
named("SIMPLE")
);
};
// Stored params arrive keyed by the wire tier name while the editor keys them by row id, which is
// ephemeral for a custom row. A key matching no row passes through, so a tier this editor does not
// render keeps its params.
export const tierParamsByRowId = (
params: TierModelParamsByTier | undefined,
rows: readonly TierRow[],
): TierModelParamsByTier | undefined =>
params &&
Object.fromEntries(Object.entries(params).map(([tier, byModel]) => [tierRowByName(rows, tier)?.id ?? tier, byModel]));
// Params keyed by the rows that own them, dropping the rows with none so an untouched router keeps
// the key out of its payload.
export const rowParamsByTier = (rows: readonly ActiveTierRow[]): TierModelParamsByTier | undefined => {
const owned = rows.filter((row) => Object.keys(row.params).length > 0);
return owned.length > 0 ? Object.fromEntries(owned.map((row) => [row.id, row.params])) : undefined;
};
export interface TierRestriction {
omit: readonly string[];
reason: string;
}
// One source for both the disabled control and the wire, so a control cannot grey out while its
// value still ships. All but heuristicScoring are rejected outright by _validate_tier_definitions;
// heuristicScoring the backend accepts, and it is dropped because that scorer never runs here.
export const CUSTOM_TIER_RESTRICTIONS = {
displayNames: {
omit: ["tier_labels"],
reason: "Display names rename the built-in tiers, which your tier set replaces. Name each tier directly",
},
escalation: {
omit: ["escalation_keywords"],
reason: "Escalation bumps a request along the built-in tier ladder, which your tier set replaces",
},
adaptive: {
omit: ["adaptive", "adaptive_weights", "tier_distance_penalty", "adaptive_eligible"],
reason: "Adaptive routing scores models along the built-in tier ladder, which your tier set replaces",
},
sessionAffinity: {
omit: [],
reason: "Session pinning escalates along the built-in tier ladder, which your tier set replaces",
},
heuristicClassifier: {
omit: ["heuristic_first_max_tier"],
reason:
"The heuristic scorer only produces the built-in tiers, so an edited set needs the LLM classifier. " +
"Heuristic first is out for the same reason: its local scorer decides the cheap traffic",
},
heuristicScoring: {
omit: [
"tier_boundaries",
"token_thresholds",
"dimension_weights",
"reasoning_override_min_score",
"custom_technical_keywords",
],
reason: "The heuristic scorer never runs under an edited tier set, so its inputs have no effect",
},
classifierPrompt: {
omit: [],
reason: "A replacement prompt drops the tier bullets and the injection guard. Your definitions are the rubric",
},
classificationRubric: {
omit: [],
reason: "The preset calibration examples are written against the built-in tiers, which your tier set replaces",
},
classifierFallback: {
omit: ["classifier_fallback"],
reason: "Fallback Tier is where an edited tier set routes when the classifier fails",
},
} as const satisfies Record<string, TierRestriction>;
export const CUSTOM_TIER_OMITTED_KEYS: readonly string[] = Object.values(CUSTOM_TIER_RESTRICTIONS).flatMap(
(restriction) => restriction.omit,
);
// Row-shape errors the backend cannot phrase per row. Payload validity is the dry-run's job.
export const getCustomTierRowsError = (customTierSet: CustomTierSet): string | null => {
const rows = customTierSet.tiers;
if (rows.length < MIN_TIER_COUNT || rows.length > MAX_TIER_COUNT)
return `A tier set needs ${MIN_TIER_COUNT} to ${MAX_TIER_COUNT} tiers`;
if (rows.some((row) => !activeTierName(row))) return "Name every tier";
const folded = rows.map((row) => row.name.trim().toLowerCase());
if (new Set(folded).size !== folded.length) return "Tier names must be unique, ignoring case";
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 (!tierRowById(rows, customTierSet.fallback_tier_id)) return "Pick a Fallback Tier for classifier failures";
return null;
};

View file

@ -476,14 +476,58 @@ 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 = {
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 },
};
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

@ -20,6 +20,7 @@ vi.mock("../networking", () => ({
modelPatchUpdateCall,
modelAvailableCall,
getAutoRouterClassifierDefaultPromptCall,
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) }));
@ -799,3 +800,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

@ -11,20 +11,30 @@ 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 { 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,
} from "../add_model/build_complexity_router_config";
import { KeywordTierRule } from "../add_model/KeywordTierRules";
@ -112,16 +122,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,
@ -165,6 +177,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",
@ -243,10 +257,16 @@ 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)));
// A custom save drops the stored keys an edited tier set forbids; a built-in save drops a stored
// classification_prompt, which the backend accepts only beside tier_definitions.
const dropped: readonly string[] = value.custom_tier_set ? CUSTOM_TIER_OMITTED_KEYS : ["classification_prompt"];
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,
@ -340,6 +360,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[]>([]);
@ -364,10 +385,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);
@ -406,6 +429,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
@ -472,10 +496,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);
@ -488,7 +517,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);
@ -519,20 +548,22 @@ 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 },
);
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 = {
@ -631,6 +662,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

@ -2394,6 +2394,30 @@ export const testAutoRouterRouting = async (
}
};
export interface ComplexityRouterConfigValidation {
valid: boolean;
error?: string | null;
}
// Dry-runs the same write gate /model/new and /model/update apply, so a save that would come back
// as a raw 400 shows the backend's own message inline first. Transport failures fail open: the
// write gate stays authoritative.
export const validateAutoRouterConfig = async (
accessToken: string,
complexityRouterConfig: Record<string, unknown>,
teamId?: string,
): Promise<ComplexityRouterConfigValidation> => {
try {
return await apiClient.post<ComplexityRouterConfigValidation>("/auto_router/validate_complexity_router_config", {
accessToken,
body: { complexity_router_config: complexityRouterConfig, ...(teamId && { team_id: teamId }) },
});
} catch (error) {
console.warn("Could not dry-run the complexity router config; the save will be validated server side", error);
return { valid: true };
}
};
// ... existing code ...
export const keyInfoV1Call = async (accessToken: string, key: string) => {
try {