mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
feat(ui): add and delete auto-router tiers with classifier definitions
This commit is contained in:
parent
3a31331435
commit
687f2ad26d
19 changed files with 1735 additions and 343 deletions
|
|
@ -25,6 +25,7 @@ import {
|
|||
CLASSIFICATION_RUBRIC_DESCRIPTIONS,
|
||||
CLASSIFICATION_RUBRIC_KEYS,
|
||||
ClassificationRubric,
|
||||
effectiveClassifierType,
|
||||
effectiveTierLabel,
|
||||
} from "./ComplexityRouterConfig";
|
||||
|
||||
|
|
@ -146,8 +147,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
defaultModel,
|
||||
}) => {
|
||||
const hasDefaultModel = Boolean(defaultModel);
|
||||
const hasCustomTierSet = Boolean(value.custom_tier_set);
|
||||
const classifierType = effectiveClassifierType(value);
|
||||
const classifierModelMissing =
|
||||
showValidationErrors && value.classifier_type === "llm" && !value.classifier_llm_config?.model;
|
||||
showValidationErrors && classifierType === "llm" && !value.classifier_llm_config?.model;
|
||||
const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim());
|
||||
const classificationRubric = value.classifier_llm_config?.classification_rubric ?? DEFAULT_CLASSIFICATION_RUBRIC;
|
||||
|
||||
|
|
@ -252,20 +255,28 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
return (
|
||||
<>
|
||||
<RadioGroup
|
||||
value={value.classifier_type}
|
||||
value={classifierType}
|
||||
onValueChange={(classifierType: unknown) => handleClassifierTypeChange(classifierType as ClassifierType)}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="heuristic" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
(default) — rule-based scoring, no API calls, <1ms latency
|
||||
<SimpleTooltip
|
||||
content={
|
||||
hasCustomTierSet
|
||||
? "An edited tier set requires the LLM classifier: the heuristic scorer only produces the built-in tiers"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="heuristic" className="mt-0.5" disabled={hasCustomTierSet} />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
(default) — rule-based scoring, no API calls, <1ms latency
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</Label>
|
||||
</SimpleTooltip>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="llm" className="mt-0.5" />
|
||||
<span>
|
||||
|
|
@ -276,7 +287,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{value.classifier_type === "llm" && (
|
||||
{classifierType === "llm" && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Classifier Model</strong>
|
||||
|
|
@ -306,92 +317,105 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
How long the classifier call has before it fails and the fallback below takes over.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<strong className="font-semibold">Classification Rubric</strong>
|
||||
<SimpleTooltip content="Every rubric uses the same four tiers. They differ in the worked examples that show the classifier where the boundary between tiers sits, and the Business rubric also rewrites the tier definitions for business traffic.">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<SimpleTooltip
|
||||
content={usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined}
|
||||
className="w-full"
|
||||
>
|
||||
<Select
|
||||
items={CLASSIFICATION_RUBRIC_KEYS.map((preset) => ({
|
||||
value: preset,
|
||||
label: CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label,
|
||||
}))}
|
||||
value={classificationRubric}
|
||||
onValueChange={(preset: ClassificationRubric | null) =>
|
||||
preset && handleClassificationRubricChange(preset)
|
||||
}
|
||||
disabled={usesCustomPrompt}
|
||||
{!hasCustomTierSet && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<strong className="font-semibold">Classification Rubric</strong>
|
||||
<SimpleTooltip content="Every rubric uses the same four tiers. They differ in the worked examples that show the classifier where the boundary between tiers sits, and the Business rubric also rewrites the tier definitions for business traffic.">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<SimpleTooltip
|
||||
content={usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectTrigger aria-label="Classification Rubric" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CLASSIFICATION_RUBRIC_KEYS.map((preset) => (
|
||||
<SelectItem key={preset} value={preset}>
|
||||
{CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SimpleTooltip>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{usesCustomPrompt
|
||||
? "Not in use: the custom prompt below is the classifier's entire rubric."
|
||||
: CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Classifier Prompt</strong>
|
||||
<ClassifierPromptEditor
|
||||
systemPrompt={value.classifier_llm_config?.system_prompt}
|
||||
onChange={handleClassifierSystemPromptChange}
|
||||
contextWindowSize={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
|
||||
tierLabels={value.tier_labels}
|
||||
classificationRubric={classificationRubric}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
items={CLASSIFICATION_RUBRIC_KEYS.map((preset) => ({
|
||||
value: preset,
|
||||
label: CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label,
|
||||
}))}
|
||||
value={classificationRubric}
|
||||
onValueChange={(preset: ClassificationRubric | null) =>
|
||||
preset && handleClassificationRubricChange(preset)
|
||||
}
|
||||
disabled={usesCustomPrompt}
|
||||
>
|
||||
<SelectTrigger aria-label="Classification Rubric" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CLASSIFICATION_RUBRIC_KEYS.map((preset) => (
|
||||
<SelectItem key={preset} value={preset}>
|
||||
{CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SimpleTooltip>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{usesCustomPrompt
|
||||
? "Not in use: the custom prompt below is the classifier's entire rubric."
|
||||
: CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasCustomTierSet && (
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Classifier Prompt</strong>
|
||||
<ClassifierPromptEditor
|
||||
systemPrompt={value.classifier_llm_config?.system_prompt}
|
||||
onChange={handleClassifierSystemPromptChange}
|
||||
contextWindowSize={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
|
||||
tierLabels={value.tier_labels}
|
||||
classificationRubric={classificationRubric}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">If the classifier fails</strong>
|
||||
<RadioGroup
|
||||
value={value.classifier_fallback ?? DEFAULT_CLASSIFIER_FALLBACK}
|
||||
onValueChange={(fallback: unknown) => handleClassifierFallbackChange(fallback as ClassifierFallback)}
|
||||
>
|
||||
<div className="inline-flex flex-col gap-2">
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="heuristic" className="mt-0.5" />
|
||||
<span>
|
||||
<span>Score with the heuristic</span>{" "}
|
||||
<span className="text-muted-foreground">— right when the classifier grades complexity too</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
|
||||
<RadioGroupItem value="default_model" disabled={!hasDefaultModel} className="mt-0.5" />
|
||||
<SimpleTooltip
|
||||
content={
|
||||
hasDefaultModel
|
||||
? "Change it from the Default Model select."
|
||||
: "Set a default model on this router to use this option"
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Route to the default model{defaultModel ? ` (${defaultModel})` : ""}</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
— right when your prompt grades something other than complexity
|
||||
{hasCustomTierSet ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Failures route to the Fallback Tier chosen in the tier configuration above. The classifier prompt is
|
||||
built from your tier definitions.
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Context Window Size</strong>
|
||||
|
|
@ -446,7 +470,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{value.classifier_type === "heuristic" && (
|
||||
{classifierType === "heuristic" && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<strong className="font-semibold">Custom Technical Keywords</strong>
|
||||
|
|
@ -478,7 +502,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
|
||||
<HeuristicScoringConfig value={value} onChange={onChange} />
|
||||
|
||||
<HowClassificationWorks value={value} />
|
||||
{!hasCustomTierSet && <HowClassificationWorks value={value} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { fireEvent, renderWithProviders, screen, within } from "../../../tests/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
import ComplexityRouterConfig, { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
import ComplexityRouterConfig, { ComplexityRouterConfigValue, heuristicScoringRole } from "./ComplexityRouterConfig";
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
|
||||
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
|
||||
|
|
@ -874,3 +874,202 @@ describe("plan-mode override", () => {
|
|||
expect(await screen.findByRole("switch", { name: switchName })).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit tiers", () => {
|
||||
const customValue: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-3.5-turbo"] },
|
||||
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: ["gpt-4"] },
|
||||
{ id: "REASONING", name: "REASONING", definition: "", models: ["claude-3-opus"] },
|
||||
{ id: "sec", name: "AUDIT", definition: "security audits", models: ["claude-3-opus"] },
|
||||
],
|
||||
fallback_tier_id: "COMPLEX",
|
||||
},
|
||||
};
|
||||
|
||||
it("removing a built-in tier materializes the ordered row set and touches nothing else", async () => {
|
||||
// The forced states (LLM classifier, affinity and adaptive off) are derived at display and
|
||||
// submit time rather than written here, so undoing the edit reverts the form completely.
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit tiers" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Remove the MEDIUM tier" }));
|
||||
const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue;
|
||||
expect(next).toEqual({
|
||||
...defaultValue,
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: defaultValue.tiers.SIMPLE },
|
||||
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: defaultValue.tiers.COMPLEX },
|
||||
{ id: "REASONING", name: "REASONING", definition: "", models: defaultValue.tiers.REASONING },
|
||||
],
|
||||
fallback_tier_id: "SIMPLE",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removing a built-in row snapshots its in-editor models so Restore returns them, not a stale pool", async () => {
|
||||
const onChange = vi.fn();
|
||||
const edited = {
|
||||
...customValue,
|
||||
custom_tier_set: {
|
||||
...customValue.custom_tier_set!,
|
||||
tiers: customValue.custom_tier_set!.tiers.map((row) =>
|
||||
row.id === "SIMPLE" ? { ...row, models: ["edited-in-editor"] } : row,
|
||||
),
|
||||
},
|
||||
};
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={edited} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Remove the SIMPLE tier" }));
|
||||
const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue;
|
||||
expect(next.tiers.SIMPLE).toEqual(["edited-in-editor"]);
|
||||
expect(next.custom_tier_set?.tiers.some((row) => row.id === "SIMPLE")).toBe(false);
|
||||
});
|
||||
|
||||
it("mints a fresh row id even when an earlier instance already used the counter", async () => {
|
||||
// The section collapse unmounts this component while rows live in the parent, so ids must
|
||||
// derive from the rows, not from instance state that resets on remount.
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
value={{
|
||||
...customValue,
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
...customValue.custom_tier_set!.tiers.slice(0, 3),
|
||||
{ id: "new-1", name: "AUDIT", definition: "security audits", models: ["claude-3-opus"] },
|
||||
],
|
||||
fallback_tier_id: "COMPLEX",
|
||||
},
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: /Add tier/ }));
|
||||
const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue;
|
||||
const ids = next.custom_tier_set!.tiers.map((row) => row.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("adding a tier appends an empty row draft", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit tiers" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Add tier/ }));
|
||||
const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue;
|
||||
expect(next.custom_tier_set?.tiers).toHaveLength(5);
|
||||
expect(next.custom_tier_set?.tiers[4]).toEqual({
|
||||
id: expect.any(String),
|
||||
name: "",
|
||||
definition: "",
|
||||
models: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("restoring the removed tier with no other edits clears the tier set entirely", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
value={{
|
||||
...customValue,
|
||||
custom_tier_set: {
|
||||
tiers: customValue.custom_tier_set!.tiers.filter((row) => row.id !== "sec"),
|
||||
fallback_tier_id: "COMPLEX",
|
||||
},
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Restore MEDIUM" }));
|
||||
const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue;
|
||||
expect(next.custom_tier_set).toBeUndefined();
|
||||
expect(next.tiers).toEqual(defaultValue.tiers);
|
||||
});
|
||||
|
||||
it("renaming the fallback tier keeps the fallback pointer on the same row", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
value={{ ...customValue, custom_tier_set: { ...customValue.custom_tier_set!, fallback_tier_id: "sec" } }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
await userEvent.type(screen.getByLabelText("Name for tier 4"), "S");
|
||||
const next = onChange.mock.calls[0][0] as ComplexityRouterConfigValue;
|
||||
expect(next.custom_tier_set?.tiers[3].name).toBe("AUDITS");
|
||||
expect(next.custom_tier_set?.fallback_tier_id).toBe("sec");
|
||||
});
|
||||
|
||||
it("renders every row with its definition and counts the active set", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} />);
|
||||
expect(screen.getByText("AUDIT Tier")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("security audits")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Medium Tier")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Tier 4 of 4/)).toHaveTextContent("Tier 4 of 4 · custom");
|
||||
expect(screen.getByText(/Tier 1 of 4/)).toHaveTextContent("Tier 1 of 4 · built-in");
|
||||
});
|
||||
|
||||
it("opens with the tier controls visible when the value already carries an edited tier set", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} />);
|
||||
expect(screen.getByRole("button", { name: /Add tier/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit tiers" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flags a custom tier without a definition when validation errors show", () => {
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
value={{
|
||||
...customValue,
|
||||
custom_tier_set: {
|
||||
...customValue.custom_tier_set!,
|
||||
tiers: customValue.custom_tier_set!.tiers.map((row) =>
|
||||
row.id === "sec" ? { ...row, definition: "" } : row,
|
||||
),
|
||||
},
|
||||
}}
|
||||
showValidationErrors
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/A definition is required/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the fallback tier select carrying the chosen tier", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} />);
|
||||
const [fallbackSelect] = screen.getAllByLabelText("Fallback tier");
|
||||
expect(fallbackSelect).toHaveTextContent("COMPLEX");
|
||||
});
|
||||
|
||||
it("reports the scorer never runs on a custom tier set, hiding the scorer panels", async () => {
|
||||
expect(heuristicScoringRole(customValue)).toBe("never");
|
||||
expect(heuristicScoringRole({ ...customValue, classifier_type: "heuristic" })).toBe("never");
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig {...baseProps} value={{ ...customValue, classifier_type: "heuristic" }} />,
|
||||
);
|
||||
await userEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.queryByText("How Classification Works")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the LLM classifier section for a custom tier set even when the stored type is heuristic", async () => {
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig {...baseProps} value={{ ...customValue, classifier_type: "heuristic" }} />,
|
||||
);
|
||||
await userEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByText("Classifier Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides display-name inputs and disables session pinning with an edited tier set", async () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} />);
|
||||
expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("Advanced: Affinity"));
|
||||
// Base UI switches carry disabled state as data-disabled, not the native attribute
|
||||
expect(screen.getByLabelText("Pin a session to its first model")).toHaveAttribute("data-disabled");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ import { SimpleTooltip } from "@/components/ui/tooltip";
|
|||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ChevronRight, Info, X } from "lucide-react";
|
||||
import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
|
@ -12,7 +15,8 @@ import React from "react";
|
|||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
|
||||
import ClassificationMethodConfig from "./ClassificationMethodConfig";
|
||||
import { resolveComplexityDefaultModel, tierOptions } from "./complexity_router_tiers";
|
||||
import { type ClassificationRubric } from "./classification_rubrics";
|
||||
import { customTierDefaultModel, resolveComplexityDefaultModel, tierOptions } from "./complexity_router_tiers";
|
||||
import EscalationKeywords from "./EscalationKeywords";
|
||||
import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules";
|
||||
import SemanticKeywordMatching from "./SemanticKeywordMatching";
|
||||
|
|
@ -34,50 +38,13 @@ export interface ComplexityTiers {
|
|||
REASONING: string[];
|
||||
}
|
||||
|
||||
export type ClassificationRubric = "legacy" | "agentic" | "chat" | "business";
|
||||
|
||||
/** What an unset preset means, matching the backend: the rubric as it shipped before calibration. */
|
||||
export const DEFAULT_CLASSIFICATION_RUBRIC: ClassificationRubric = "legacy";
|
||||
|
||||
/**
|
||||
* Stamped on a classifier being switched on for the first time. There is no prior tier behaviour to
|
||||
* preserve at that moment, so a newly configured classifier gets the calibrated rubric while every
|
||||
* router already running an LLM classifier keeps the one it has.
|
||||
*/
|
||||
export const NEW_CLASSIFIER_CLASSIFICATION_RUBRIC: ClassificationRubric = "agentic";
|
||||
|
||||
export const CLASSIFICATION_RUBRIC_DESCRIPTIONS: Record<ClassificationRubric, { label: string; description: string }> =
|
||||
{
|
||||
legacy: {
|
||||
label: "Legacy (uncalibrated)",
|
||||
description:
|
||||
"The rubric as it shipped before calibration examples, with no worked examples at all. Routers created " +
|
||||
"before this setting existed use it, so their tier decisions and spend are unchanged. It over-routes " +
|
||||
"ordinary engineering to the most expensive tier.",
|
||||
},
|
||||
agentic: {
|
||||
label: "Agentic",
|
||||
description:
|
||||
"Anchors routine installs, builds, multi-file edits, and standard debugging at " +
|
||||
"Medium, so ordinary engineering does not route to your most expensive tier. Suits agent, terminal, and " +
|
||||
"coding-assistant traffic, and mixed traffic.",
|
||||
},
|
||||
chat: {
|
||||
label: "Chat",
|
||||
description:
|
||||
"Drops the engineering examples, for a router serving only conversational traffic that never sees those " +
|
||||
"requests.",
|
||||
},
|
||||
business: {
|
||||
label: "Business",
|
||||
description:
|
||||
"Business and sales examples plus business-oriented tier definitions: routine drafting and summarizing " +
|
||||
"stay at Medium, data-determined analysis is Complex, and only decisions under conflicting tradeoffs " +
|
||||
"reach Reasoning. Suits sales, support, and go-to-market traffic.",
|
||||
},
|
||||
};
|
||||
|
||||
export const CLASSIFICATION_RUBRIC_KEYS = Object.keys(CLASSIFICATION_RUBRIC_DESCRIPTIONS) as ClassificationRubric[];
|
||||
export {
|
||||
CLASSIFICATION_RUBRIC_DESCRIPTIONS,
|
||||
CLASSIFICATION_RUBRIC_KEYS,
|
||||
DEFAULT_CLASSIFICATION_RUBRIC,
|
||||
NEW_CLASSIFIER_CLASSIFICATION_RUBRIC,
|
||||
} from "./classification_rubrics";
|
||||
export type { ClassificationRubric } from "./classification_rubrics";
|
||||
|
||||
export interface ClassifierLLMConfig {
|
||||
model: string;
|
||||
|
|
@ -115,15 +82,52 @@ export const heuristicScoringRoleFor = (
|
|||
};
|
||||
|
||||
export const heuristicScoringRole = (value: ComplexityRouterConfigValue): HeuristicScoringRole =>
|
||||
heuristicScoringRoleFor(value.classifier_type, value.classifier_fallback);
|
||||
value.custom_tier_set ? "never" : heuristicScoringRoleFor(value.classifier_type, value.classifier_fallback);
|
||||
|
||||
export type AdaptiveEligible = "all" | "classified_tier";
|
||||
|
||||
export type ComplexityTierLabels = Partial<Record<keyof ComplexityTiers, string>>;
|
||||
|
||||
export interface TierDraft {
|
||||
/** List identity: the React key and the fallback and plan-mode pointers' target. Never serialized. */
|
||||
id: string;
|
||||
name: string;
|
||||
/** The tier's rubric bullet. Blank on a built-in name inherits the built-in criteria. */
|
||||
definition: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Present on the value when the operator edited the tier set itself. The draft IS the wire list:
|
||||
* `tiers` holds every active tier in severity order, exactly as tier_definitions will carry them,
|
||||
* so serialization and hydration are plain maps and no ordering, identity, or model placement can
|
||||
* be lost in translation. Absence means the built-in four-tier router and a payload identical to
|
||||
* before this field existed.
|
||||
*/
|
||||
export interface CustomTierSet {
|
||||
tiers: TierDraft[];
|
||||
fallback_tier_id: string;
|
||||
}
|
||||
|
||||
export const isBuiltInTierName = (name: string): boolean =>
|
||||
TIER_KEYS.some((tier) => tier.toLowerCase() === name.trim().toLowerCase());
|
||||
|
||||
/**
|
||||
* The classifier type the payload will carry, which a custom tier set pins to "llm" without
|
||||
* writing into the value: deriving it wherever it is displayed or validated is what lets an
|
||||
* undone tier edit revert the form with nothing left behind.
|
||||
*/
|
||||
export const effectiveClassifierType = (
|
||||
value: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type">,
|
||||
): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type);
|
||||
|
||||
export const activeTierNames = (customTierSet: CustomTierSet | undefined): string[] =>
|
||||
customTierSet ? customTierSet.tiers.map((tier) => tier.name.trim()).filter(Boolean) : [...TIER_KEYS];
|
||||
|
||||
export interface ComplexityRouterConfigValue {
|
||||
tiers: ComplexityTiers;
|
||||
tier_labels?: ComplexityTierLabels;
|
||||
custom_tier_set?: CustomTierSet;
|
||||
/** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */
|
||||
default_model?: string;
|
||||
classifier_type: ClassifierType;
|
||||
|
|
@ -134,7 +138,12 @@ export interface ComplexityRouterConfigValue {
|
|||
classifier_fallback?: ClassifierFallback;
|
||||
session_affinity?: boolean;
|
||||
deployment_affinity?: boolean;
|
||||
/** Tier floor for coding-agent plan-mode requests. Unset means detection is off, matching the backend. */
|
||||
/**
|
||||
* Tier floor for coding-agent plan-mode requests, held as a tier ROW ID so renames follow
|
||||
* (built-in row ids are the four tier names, so built-in mode is id-stable by construction).
|
||||
* Serialization resolves the id to the row's name; unset means detection is off, matching the
|
||||
* backend.
|
||||
*/
|
||||
plan_mode_min_tier?: string;
|
||||
adaptive?: boolean;
|
||||
adaptive_weights?: AdaptiveRouterWeights;
|
||||
|
|
@ -207,9 +216,14 @@ export const TIER_KEYS = Object.keys(TIER_DESCRIPTIONS) as Array<keyof Complexit
|
|||
export const effectiveTierLabel = (tier: keyof ComplexityTiers, tierLabels: ComplexityTierLabels | undefined): string =>
|
||||
tierLabels?.[tier]?.trim() || TIER_DESCRIPTIONS[tier].label;
|
||||
|
||||
/** Tiers the plan-mode floor may name: the backend rejects a floor whose tier has no models. */
|
||||
export const planModeEligibleTiers = (tiers: ComplexityTiers): Array<keyof ComplexityTiers> =>
|
||||
TIER_KEYS.filter((tier) => (tiers[tier] ?? []).length > 0);
|
||||
/**
|
||||
* Row IDS the plan-mode floor may point at (the backend rejects a floor whose tier has no models).
|
||||
* Ids, not names: a rename must not strand the floor, same rule as fallback_tier_id.
|
||||
*/
|
||||
export const planModeEligibleTiers = (tiers: ComplexityTiers, customTierSet?: CustomTierSet): string[] =>
|
||||
customTierSet
|
||||
? customTierSet.tiers.filter((row) => row.name.trim() && row.models.length > 0).map((row) => row.id)
|
||||
: TIER_KEYS.filter((tier) => (tiers[tier] ?? []).length > 0);
|
||||
|
||||
const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
||||
modelInfo,
|
||||
|
|
@ -229,12 +243,118 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
onEscalationKeywordsChange,
|
||||
showValidationErrors = false,
|
||||
}) => {
|
||||
const planModeTiers = planModeEligibleTiers(value.tiers);
|
||||
const planModeTierOptions = tierOptions(value.tier_labels).filter((option) =>
|
||||
(planModeTiers as string[]).includes(option.value),
|
||||
);
|
||||
const derivedDefaultModel = resolveComplexityDefaultModel(value.tiers);
|
||||
const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model);
|
||||
const planModeTiers = planModeEligibleTiers(value.tiers, value.custom_tier_set);
|
||||
const [editingTiers, setEditingTiers] = React.useState(false);
|
||||
const customTierSet = value.custom_tier_set;
|
||||
const planModeTierOptions = customTierSet
|
||||
? customTierSet.tiers
|
||||
.filter((row) => planModeTiers.includes(row.id))
|
||||
.map((row) => ({ value: row.id, label: row.name.trim() }))
|
||||
: tierOptions(value.tier_labels).filter((option) => planModeTiers.includes(option.value));
|
||||
// An edited tier set always shows its controls: a router hydrated with custom tiers would
|
||||
// otherwise open looking read-only, with nothing hinting the set can be changed.
|
||||
const showTierControls = editingTiers || Boolean(customTierSet);
|
||||
const tierRows = customTierSet?.tiers;
|
||||
const activeCount = tierRows?.length ?? TIER_KEYS.length;
|
||||
const fallbackRow = tierRows?.find((tier) => tier.id === customTierSet?.fallback_tier_id);
|
||||
const derivedDefaultModel = customTierSet
|
||||
? customTierDefaultModel(customTierSet)
|
||||
: resolveComplexityDefaultModel(value.tiers);
|
||||
const defaultModel = customTierSet
|
||||
? customTierDefaultModel(customTierSet, value.default_model)
|
||||
: resolveComplexityDefaultModel(value.tiers, value.default_model);
|
||||
|
||||
const builtInRow = (tier: keyof ComplexityTiers): TierDraft => ({
|
||||
id: tier,
|
||||
name: tier,
|
||||
definition: "",
|
||||
models: value.tiers[tier],
|
||||
});
|
||||
|
||||
// Compares names and definitions only, deliberately not models: restoring the built-in four
|
||||
// clears the set and applyTierRows writes the rows' models back into value.tiers, so model
|
||||
// edits made inside the editor survive the mode exit instead of silently reverting.
|
||||
const isDefaultTierSet = (rows: TierDraft[]) =>
|
||||
rows.length === TIER_KEYS.length &&
|
||||
rows.every((row, index) => row.name === TIER_KEYS[index] && row.definition === "");
|
||||
|
||||
// Materializes or clears the edited tier set. A set equal to the built-in four clears itself,
|
||||
// and no other value field is touched in either direction: the states a custom set forces
|
||||
// (LLM classifier, affinity and adaptive off) are derived wherever they are displayed or
|
||||
// submitted, so undoing every tier edit truly reverts the form instead of stranding forced
|
||||
// classifier state behind a cleared flag.
|
||||
const applyTierRows = (rows: TierDraft[], fallbackTierId: string) => {
|
||||
if (isDefaultTierSet(rows)) {
|
||||
const { custom_tier_set: _cleared, ...rest } = value;
|
||||
onChange({
|
||||
...rest,
|
||||
tiers: { SIMPLE: rows[0].models, MEDIUM: rows[1].models, COMPLEX: rows[2].models, REASONING: rows[3].models },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const fallback_tier_id = rows.some((row) => row.id === fallbackTierId)
|
||||
? fallbackTierId
|
||||
: (rows.find((row) => row.name === "MEDIUM") ?? rows[0])?.id ?? "";
|
||||
onChange({ ...value, custom_tier_set: { tiers: rows, fallback_tier_id } });
|
||||
};
|
||||
|
||||
const currentRows = (): [TierDraft[], string] =>
|
||||
customTierSet
|
||||
? [customTierSet.tiers, customTierSet.fallback_tier_id]
|
||||
: [TIER_KEYS.map(builtInRow), builtInRow("MEDIUM").id];
|
||||
|
||||
// Removing a built-in row snapshots its models into value.tiers (invisible on the wire while
|
||||
// the set is custom) so Restore returns the models the row had at removal, not a stale pool.
|
||||
const removeTierRow = (id: string) => {
|
||||
const [rows, fallbackId] = currentRows();
|
||||
const removed = rows.find((row) => row.id === id);
|
||||
const remaining = rows.filter((row) => row.id !== id);
|
||||
if (removed && (TIER_KEYS as string[]).includes(removed.id)) {
|
||||
const fallback_tier_id = remaining.some((row) => row.id === fallbackId)
|
||||
? fallbackId
|
||||
: (remaining.find((row) => row.name === "MEDIUM") ?? remaining[0])?.id ?? "";
|
||||
onChange({
|
||||
...value,
|
||||
tiers: { ...value.tiers, [removed.id]: removed.models },
|
||||
custom_tier_set: { tiers: remaining, fallback_tier_id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
applyTierRows(remaining, fallbackId);
|
||||
};
|
||||
|
||||
const restoreBuiltInTier = (tier: keyof ComplexityTiers) => {
|
||||
const [rows, fallbackId] = currentRows();
|
||||
const restoredInCanonicalOrder = [
|
||||
...TIER_KEYS.flatMap((builtIn) => {
|
||||
if (builtIn === tier) return [builtInRow(tier)];
|
||||
const existing = rows.find((row) => row.id === builtIn);
|
||||
return existing ? [existing] : [];
|
||||
}),
|
||||
...rows.filter((row) => !(TIER_KEYS as string[]).includes(row.id)),
|
||||
];
|
||||
applyTierRows(restoredInCanonicalOrder, fallbackId);
|
||||
};
|
||||
|
||||
// The id is minted against the rows themselves rather than component state: this component
|
||||
// unmounts when its section collapses while the rows live in the parent, so an instance
|
||||
// counter would reset and re-mint an id a row already holds.
|
||||
const addCustomTier = () => {
|
||||
const [rows, fallbackId] = currentRows();
|
||||
const taken = new Set(rows.map((row) => row.id));
|
||||
const id =
|
||||
Array.from({ length: rows.length + 1 }, (_, n) => `new-${n + 1}`).find((candidate) => !taken.has(candidate)) ??
|
||||
`new-${rows.length + 1}`;
|
||||
applyTierRows([...rows, { id, name: "", definition: "", models: [] }], fallbackId);
|
||||
};
|
||||
|
||||
const updateTierRow = (id: string, patch: Partial<Omit<TierDraft, "id">>) => {
|
||||
const [rows, fallbackId] = currentRows();
|
||||
applyTierRows(
|
||||
rows.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
fallbackId,
|
||||
);
|
||||
};
|
||||
|
||||
// Embedding models can't serve a chat-completion role, so they're excluded here.
|
||||
const modelOptions = modelInfo
|
||||
|
|
@ -287,64 +407,192 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
|
||||
<Card>
|
||||
<CardContent>
|
||||
{TIER_KEYS.map((tier, index) => {
|
||||
const tierInfo = TIER_DESCRIPTIONS[tier];
|
||||
const label = effectiveTierLabel(tier, value.tier_labels);
|
||||
const tierMissing = showValidationErrors && value.tiers[tier].length === 0;
|
||||
{!customTierSet &&
|
||||
TIER_KEYS.map((tier, index) => {
|
||||
const tierInfo = TIER_DESCRIPTIONS[tier];
|
||||
const label = effectiveTierLabel(tier, value.tier_labels);
|
||||
const tierMissing = showValidationErrors && value.tiers[tier].length === 0;
|
||||
return (
|
||||
<div key={tier}>
|
||||
{index > 0 && <Separator className="my-4" />}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">{label} Tier</strong>
|
||||
<SimpleTooltip content={tierInfo.description}>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tier {index + 1} of {activeCount} · {tier}
|
||||
</span>
|
||||
{showTierControls && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
aria-label={`Remove the ${tier} tier`}
|
||||
disabled={activeCount <= 2}
|
||||
onClick={() => removeTierRow(tier)}
|
||||
>
|
||||
<Trash2 />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</InputGroup>
|
||||
<MultiSelect
|
||||
options={modelOptions}
|
||||
value={value.tiers[tier]}
|
||||
onValueChange={(models: string[]) => handleTierChange(tier, models)}
|
||||
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
|
||||
emptyText="No models found"
|
||||
className={tierMissing ? "w-full border-destructive" : "w-full"}
|
||||
/>
|
||||
{value.tiers[tier].length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Multiple models selected — the router randomly picks among them per request (or Thompson-samples
|
||||
within the pool when adaptive routing is on).
|
||||
</span>
|
||||
)}
|
||||
{tierMissing && <span className="text-xs text-destructive">The {label} tier is required</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tierRows?.map((row, index) => {
|
||||
const rowName = row.name.trim();
|
||||
const builtIn = isBuiltInTierName(rowName);
|
||||
const builtInInfo = builtIn ? TIER_DESCRIPTIONS[rowName.toUpperCase() as keyof ComplexityTiers] : undefined;
|
||||
const nameMissing = showValidationErrors && !rowName;
|
||||
const definitionMissing = showValidationErrors && !row.definition.trim() && !builtIn;
|
||||
const modelsMissing = showValidationErrors && row.models.length === 0;
|
||||
return (
|
||||
<div key={tier}>
|
||||
<div key={row.id}>
|
||||
{index > 0 && <Separator className="my-4" />}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">{label} Tier</strong>
|
||||
<SimpleTooltip content={tierInfo.description}>
|
||||
<strong className="text-base font-semibold">{rowName || "New"} Tier</strong>
|
||||
<SimpleTooltip
|
||||
content={
|
||||
builtInInfo?.description ??
|
||||
"A tier you defined. The classifier routes here when a request matches the definition below."
|
||||
}
|
||||
>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tier {index + 1} of {TIER_KEYS.length} · {tier}
|
||||
Tier {index + 1} of {activeCount} · {builtIn ? "built-in" : "custom"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
aria-label={`Remove the ${rowName || `tier ${index + 1}`} tier`}
|
||||
disabled={activeCount <= 2}
|
||||
onClick={() => removeTierRow(row.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
Remove
|
||||
</Button>
|
||||
</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>
|
||||
)}
|
||||
</InputGroup>
|
||||
<MultiSelect
|
||||
options={modelOptions}
|
||||
value={value.tiers[tier]}
|
||||
onValueChange={(models: string[]) => handleTierChange(tier, models)}
|
||||
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
|
||||
emptyText="No models found"
|
||||
className={tierMissing ? "w-full border-destructive" : "w-full"}
|
||||
{builtInInfo && (
|
||||
<span className="block mb-2 text-xs text-muted-foreground">Examples: {builtInInfo.examples}</span>
|
||||
)}
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) => updateTierRow(row.id, { name: event.target.value })}
|
||||
placeholder="Tier name, e.g. SECURITY_REVIEW"
|
||||
aria-label={`Name for tier ${index + 1}`}
|
||||
className={nameMissing ? "mb-2 border-destructive" : "mb-2"}
|
||||
/>
|
||||
{value.tiers[tier].length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Multiple models selected — the router randomly picks among them per request (or Thompson-samples
|
||||
within the pool when adaptive routing is on).
|
||||
<Textarea
|
||||
value={row.definition}
|
||||
onChange={(event) => updateTierRow(row.id, { definition: event.target.value })}
|
||||
placeholder={
|
||||
builtIn
|
||||
? "Leave blank to keep the built-in definition the classifier already uses for this tier"
|
||||
: "What belongs in this tier. The LLM classifier reads this definition to decide when a request routes here, e.g. requests asking for a security audit, vulnerability review, or exploit analysis"
|
||||
}
|
||||
aria-label={`Definition for tier ${index + 1}`}
|
||||
rows={2}
|
||||
className={definitionMissing ? "mb-2 border-destructive" : "mb-2"}
|
||||
/>
|
||||
{definitionMissing && (
|
||||
<span className="block mb-2 text-xs text-destructive">
|
||||
A definition is required: it is the rubric the classifier uses for this tier
|
||||
</span>
|
||||
)}
|
||||
{tierMissing && <span className="text-xs text-destructive">The {label} tier is required</span>}
|
||||
<MultiSelect
|
||||
options={modelOptions}
|
||||
value={row.models}
|
||||
onValueChange={(models: string[]) => updateTierRow(row.id, { models })}
|
||||
placeholder={`Select model(s) for the ${rowName || "new"} tier`}
|
||||
aria-label={`Models for tier ${index + 1}`}
|
||||
emptyText="No models found"
|
||||
className={modelsMissing ? "w-full border-destructive" : "w-full"}
|
||||
/>
|
||||
{modelsMissing && (
|
||||
<span className="text-xs text-destructive">Select at least one model for this tier</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{showTierControls ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={addCustomTier} disabled={activeCount >= 8}>
|
||||
<Plus />
|
||||
Add tier
|
||||
</Button>
|
||||
{editingTiers && (
|
||||
<Button variant="outline" onClick={() => setEditingTiers(false)}>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
{TIER_KEYS.filter((tier) => customTierSet && !tierRows?.some((row) => row.id === tier)).map(
|
||||
(tier) => (
|
||||
<Button key={tier} variant="outline" size="sm" onClick={() => restoreBuiltInTier(tier)}>
|
||||
Restore {tier}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setEditingTiers(true)}>
|
||||
Edit tiers
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showTierControls && (
|
||||
<span className="block mt-1 text-xs text-muted-foreground">
|
||||
Add or remove tiers to define your own tier set. Every new tier needs a definition the LLM classifier
|
||||
uses to route to it; editing the set requires the LLM classification method and disables escalation,
|
||||
adaptive selection, session pinning, and display names.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">Default Model</strong>
|
||||
|
|
@ -369,6 +617,42 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
the default model" selected.
|
||||
</span>
|
||||
</div>
|
||||
{customTierSet && (
|
||||
<div className="mb-2">
|
||||
<Separator className="my-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>
|
||||
<Select
|
||||
items={customTierSet.tiers
|
||||
.filter((row) => row.name.trim())
|
||||
.map((row) => ({ value: row.id, label: row.name.trim() }))}
|
||||
value={fallbackRow?.id ?? null}
|
||||
onValueChange={(fallbackTierId: string | null) =>
|
||||
fallbackTierId && applyTierRows(customTierSet.tiers, fallbackTierId)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Fallback tier"
|
||||
className={showValidationErrors && !fallbackRow ? "w-full border-destructive" : "w-full"}
|
||||
>
|
||||
<SelectValue placeholder="Pick the tier classifier failures route to" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customTierSet.tiers
|
||||
.filter((row) => row.name.trim())
|
||||
.map((row) => (
|
||||
<SelectItem key={row.id} value={row.id}>
|
||||
{row.name.trim()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -394,7 +678,14 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
{
|
||||
key: "adaptive",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Adaptive Routing</strong>,
|
||||
children: <AdaptiveRoutingConfig value={value} onChange={onChange} />,
|
||||
children: customTierSet ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Adaptive routing is unavailable with an edited tier set: it scores models along the built-in tier
|
||||
ladder, which your tier set replaces.
|
||||
</span>
|
||||
) : (
|
||||
<AdaptiveRoutingConfig value={value} onChange={onChange} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "affinity",
|
||||
|
|
@ -417,15 +708,17 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
</span>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
|
||||
checked={customTierSet ? false : value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
|
||||
onCheckedChange={(sessionAffinity) => onChange({ ...value, session_affinity: sessionAffinity })}
|
||||
aria-label="Pin a session to its first model"
|
||||
disabled={Boolean(customTierSet)}
|
||||
/>
|
||||
<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's model instead of re-classifying each turn. Also pins the
|
||||
deployment.
|
||||
{customTierSet
|
||||
? "Unavailable with an edited tier set: escalating a pinned session walks the built-in tier ladder, which your tier set replaces."
|
||||
: "Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment."}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
|
|
@ -500,7 +793,14 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
{
|
||||
key: "escalation",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Escalation Keywords</strong>,
|
||||
children: <EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />,
|
||||
children: customTierSet ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Escalation keywords are unavailable with an edited tier set: they bump requests along the built-in
|
||||
tier ladder, which your tier set replaces.
|
||||
</span>
|
||||
) : (
|
||||
<EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
|
@ -515,7 +815,8 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
<KeywordTierRules
|
||||
rules={keywordTierRules}
|
||||
onChange={onKeywordTierRulesChange}
|
||||
tierLabels={value.tier_labels}
|
||||
tierLabels={customTierSet ? undefined : value.tier_labels}
|
||||
tierNames={customTierSet ? activeTierNames(customTierSet) : undefined}
|
||||
/>
|
||||
)}
|
||||
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||
import React from "react";
|
||||
|
||||
import { emptyKeywordTierRuleIndexes } from "./complexity_router_keywords";
|
||||
import { tierOptions } from "./complexity_router_tiers";
|
||||
import { defaultRuleTier, tierOptions } from "./complexity_router_tiers";
|
||||
|
||||
export type ComplexityTier = "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
|
||||
|
||||
|
|
@ -22,12 +22,13 @@ interface KeywordTierRulesProps {
|
|||
rules: KeywordTierRule[];
|
||||
onChange: (rules: KeywordTierRule[]) => void;
|
||||
tierLabels?: Partial<Record<ComplexityTier, string>>;
|
||||
tierNames?: string[];
|
||||
}
|
||||
|
||||
// A row exists only because the caller asked for it, so it reports its own gap straight away
|
||||
// rather than waiting for a submit; the submit button is disabled while one is outstanding, so
|
||||
// there is no failed attempt left to surface it.
|
||||
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, tierLabels }) => {
|
||||
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, tierLabels, tierNames }) => {
|
||||
const emptyRuleIndexes = new Set(emptyKeywordTierRuleIndexes(rules));
|
||||
|
||||
const replaceKeywords = (rule: KeywordTierRule) => (keywords: string[]) => {
|
||||
|
|
@ -35,7 +36,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
};
|
||||
|
||||
const addRule = () => {
|
||||
onChange([...rules, { id: `${Date.now()}`, keywords: [], tier: "COMPLEX" }]);
|
||||
onChange([...rules, { id: `${Date.now()}`, keywords: [], tier: defaultRuleTier(tierNames) }]);
|
||||
};
|
||||
|
||||
const updateRule = (id: string, updates: Partial<Omit<KeywordTierRule, "id">>) => {
|
||||
|
|
@ -98,7 +99,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
<div style={{ width: 220 }}>
|
||||
<strong className="mb-2 block font-semibold">Route to tier</strong>
|
||||
<Select
|
||||
items={tierOptions(tierLabels)}
|
||||
items={tierOptions(tierLabels, tierNames)}
|
||||
value={rule.tier}
|
||||
onValueChange={(tier: string | null) => tier && updateRule(rule.id, { tier })}
|
||||
>
|
||||
|
|
@ -106,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>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ const { mockFetchAvailableModels, mockFetchAllModelDeployments } = vi.hoisted(()
|
|||
vi.mock("../networking", () => ({
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
testAutoRouterRouting: vi.fn(),
|
||||
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox";
|
||||
import { modelAvailableCall } from "../networking";
|
||||
import { modelAvailableCall, validateAutoRouterConfig } from "../networking";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { type ModelWriteScope } from "@/utils/modelPermissions";
|
||||
import TeamDropdown from "../common_components/team_dropdown";
|
||||
|
|
@ -23,6 +23,8 @@ import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/h
|
|||
import ComplexityRouterConfig, {
|
||||
ComplexityRouterConfigValue,
|
||||
ComplexityTiers,
|
||||
CustomTierSet,
|
||||
effectiveClassifierType,
|
||||
DEFAULT_ADAPTIVE_WEIGHTS,
|
||||
DEFAULT_SESSION_AFFINITY,
|
||||
DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
|
|
@ -34,13 +36,15 @@ import { DEFAULT_MATCH_THRESHOLD } from "./SemanticKeywordMatching";
|
|||
import {
|
||||
BuildComplexityRouterConfigParams,
|
||||
buildComplexityRouterConfig,
|
||||
getCustomTierSetError,
|
||||
getKeywordRuleTierError,
|
||||
getKeywordTierRulesError,
|
||||
getMissingTiersError,
|
||||
getPlanModeTierError,
|
||||
getSemanticConfigError,
|
||||
getTierLabelsError,
|
||||
} from "./build_complexity_router_config";
|
||||
import { resolveComplexityDefaultModel } from "./complexity_router_tiers";
|
||||
import { customTierDefaultModel, resolveComplexityDefaultModel } from "./complexity_router_tiers";
|
||||
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
|
||||
import AutoRouterConnectionTest from "./auto_router_connection_test";
|
||||
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
|
||||
|
|
@ -104,15 +108,17 @@ const presets = getAllPresets();
|
|||
|
||||
// A one-line summary of what's configured, shown when the detailed section is collapsed so a
|
||||
// caller can see the shape of the config without opening it.
|
||||
const tierConfigSummary = (tiers: ComplexityTiers): string => {
|
||||
const parts = (
|
||||
[
|
||||
["Simple", tiers.SIMPLE],
|
||||
["Medium", tiers.MEDIUM],
|
||||
["Complex", tiers.COMPLEX],
|
||||
["Reasoning", tiers.REASONING],
|
||||
] as const
|
||||
)
|
||||
const tierConfigSummary = (tiers: ComplexityTiers, customTierSet?: CustomTierSet): string => {
|
||||
const builtInRows: [string, string[]][] = [
|
||||
["Simple", tiers.SIMPLE],
|
||||
["Medium", tiers.MEDIUM],
|
||||
["Complex", tiers.COMPLEX],
|
||||
["Reasoning", tiers.REASONING],
|
||||
];
|
||||
const rows: [string, string[]][] = customTierSet
|
||||
? customTierSet.tiers.map((row): [string, string[]] => [row.name.trim() || "New tier", row.models])
|
||||
: builtInRows;
|
||||
const parts = rows
|
||||
.filter(([, models]) => models.length > 0)
|
||||
.map(([label, models]) => `${label}: ${models.join(", ")}`);
|
||||
return parts.length > 0 ? parts.join(" · ") : "No tiers configured yet";
|
||||
|
|
@ -128,10 +134,12 @@ const getSubmitBlockedReason = (
|
|||
referencedModelsParams: Parameters<typeof getReferencedModelsError>[0],
|
||||
availability: ModelAvailability,
|
||||
): string | null =>
|
||||
getMissingTiersError(config.tiers) ??
|
||||
getTierLabelsError(config.tier_labels) ??
|
||||
getPlanModeTierError(config.plan_mode_min_tier, config.tiers) ??
|
||||
(config.custom_tier_set
|
||||
? getCustomTierSetError(config.custom_tier_set)
|
||||
: getMissingTiersError(config.tiers) ?? getTierLabelsError(config.tier_labels)) ??
|
||||
getPlanModeTierError(config.plan_mode_min_tier, config.tiers, config.custom_tier_set) ??
|
||||
getKeywordTierRulesError(keywordTierRules) ??
|
||||
getKeywordRuleTierError(keywordTierRules, config.custom_tier_set) ??
|
||||
getReferencedModelsError(referencedModelsParams, availability);
|
||||
|
||||
const autoRouterSchema = (requiresTeamScope: boolean) =>
|
||||
|
|
@ -328,9 +336,13 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
setDetailsExpanded(presetState.viaDeployments);
|
||||
};
|
||||
|
||||
const customTierSet = complexityRouterConfig.custom_tier_set;
|
||||
const emptyTiers: ComplexityTiers = { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] };
|
||||
|
||||
const referencedModelsParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
classifierType: complexityRouterConfig.classifier_type,
|
||||
tiers: customTierSet ? emptyTiers : complexityRouterConfig.tiers,
|
||||
additionalModels: customTierSet?.tiers.flatMap((row) => row.models),
|
||||
classifierType: effectiveClassifierType(complexityRouterConfig),
|
||||
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
|
|
@ -346,10 +358,11 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
|
||||
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
customTierSet,
|
||||
defaultModel: complexityRouterConfig.default_model,
|
||||
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
|
||||
tierLabels: complexityRouterConfig.tier_labels,
|
||||
classifierType: complexityRouterConfig.classifier_type,
|
||||
classifierType: effectiveClassifierType(complexityRouterConfig),
|
||||
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
|
||||
classifierContextWindowSize: complexityRouterConfig.classifier_context_window_size,
|
||||
classifierContextPerTurnChars: complexityRouterConfig.classifier_context_per_turn_chars,
|
||||
|
|
@ -377,17 +390,12 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
const submitRecommendedRouter = async (name: string) => {
|
||||
const { tiers, tierLabels, classifierType, classifierLlmConfig } = complexityRouterConfigParams;
|
||||
|
||||
const missingTiersError = getMissingTiersError(tiers);
|
||||
if (missingTiersError) {
|
||||
const tierSetupError = customTierSet
|
||||
? getCustomTierSetError(customTierSet)
|
||||
: getMissingTiersError(tiers) ?? getTierLabelsError(tierLabels);
|
||||
if (tierSetupError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(missingTiersError);
|
||||
return;
|
||||
}
|
||||
|
||||
const tierLabelsError = getTierLabelsError(tierLabels);
|
||||
if (tierLabelsError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(tierLabelsError);
|
||||
toast.fromError(tierSetupError);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -397,7 +405,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const keywordRulesError = getKeywordTierRulesError(keywordTierRules);
|
||||
const keywordRulesError =
|
||||
getKeywordTierRulesError(keywordTierRules) ?? getKeywordRuleTierError(keywordTierRules, customTierSet);
|
||||
if (keywordRulesError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(keywordRulesError);
|
||||
|
|
@ -422,7 +431,9 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
|
||||
const defaultModel = customTierSet
|
||||
? customTierDefaultModel(customTierSet, complexityRouterConfig.default_model)
|
||||
: resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
|
||||
const validatedFields = requiresTeamScope
|
||||
? (["auto_router_name", "team_id"] as const)
|
||||
: (["auto_router_name"] as const);
|
||||
|
|
@ -432,6 +443,22 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// The backend's own validator gets the final word before the write: the local guards above
|
||||
// give instant feedback, but any rule they do not mirror (or mirror stale) would otherwise
|
||||
// surface as a raw 400 after submit. A transport failure falls through to the save, whose
|
||||
// write gate runs the same validator authoritatively.
|
||||
const builtConfig = buildComplexityRouterConfig(complexityRouterConfigParams);
|
||||
const serverVerdict = await validateAutoRouterConfig(
|
||||
accessToken,
|
||||
builtConfig,
|
||||
requiresTeamScope ? form.getValues("team_id") : undefined,
|
||||
);
|
||||
if (!serverVerdict.valid && serverVerdict.error) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(serverVerdict.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// auto_router_default_model (-> litellm_params, read by the backend at init) and
|
||||
// complexity_router_config.default_model (-> the pin marker read back on edit, see
|
||||
// hydratePinnedDefaultModel in edit_auto_router_modal.tsx) must both come from the same
|
||||
|
|
@ -441,7 +468,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
...teamScopePayload(requiresTeamScope, form.getValues("team_id")),
|
||||
auto_router_default_model: defaultModel,
|
||||
model_type: "complexity_router",
|
||||
complexity_router_config: buildComplexityRouterConfig(complexityRouterConfigParams),
|
||||
complexity_router_config: builtConfig,
|
||||
model_access_group: form.getValues("model_access_group"),
|
||||
};
|
||||
|
||||
|
|
@ -462,10 +489,13 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
|
||||
const handleTestConnection = () => {
|
||||
const testTargetParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
tiers: customTierSet ? emptyTiers : complexityRouterConfig.tiers,
|
||||
additionalTiers: customTierSet?.tiers.map((row) => ({ name: row.name.trim(), models: row.models })),
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
defaultModel: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model),
|
||||
defaultModel: customTierSet
|
||||
? customTierDefaultModel(customTierSet, complexityRouterConfig.default_model)
|
||||
: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model),
|
||||
};
|
||||
const targets = buildAutoRouterTestTargets(testTargetParams);
|
||||
|
||||
|
|
@ -580,7 +610,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
</span>
|
||||
{!detailsExpanded && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tierConfigSummary(complexityRouterConfig.tiers)}
|
||||
{tierConfigSummary(complexityRouterConfig.tiers, customTierSet)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -693,10 +723,11 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
<AutoRouterRoutingTest
|
||||
accessToken={accessToken}
|
||||
config={buildComplexityRouterConfig(complexityRouterConfigParams)}
|
||||
defaultModel={resolveComplexityDefaultModel(
|
||||
complexityRouterConfig.tiers,
|
||||
complexityRouterConfig.default_model,
|
||||
)}
|
||||
defaultModel={
|
||||
customTierSet
|
||||
? customTierDefaultModel(customTierSet, complexityRouterConfig.default_model)
|
||||
: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model)
|
||||
}
|
||||
routerName={watchedName}
|
||||
teamId={requiresTeamScope ? watchedTeamId : undefined}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -120,3 +120,18 @@ describe("buildAutoRouterTestTargets", () => {
|
|||
expect(targets).toEqual([{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("additionalTiers", () => {
|
||||
it("probes custom tier models after the built-ins, labeled by tier name", () => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
tiers: { SIMPLE: ["cheap"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
additionalTiers: [{ name: "AUDIT", models: ["sonnet", "cheap"] }],
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: undefined,
|
||||
});
|
||||
expect(targets).toEqual([
|
||||
{ labels: ["SIMPLE", "AUDIT"], modelGroup: "cheap", mode: "chat" },
|
||||
{ labels: ["AUDIT"], modelGroup: "sonnet", mode: "chat" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { customTierDefaultModel, normalizeTierModels, resolveComplexityDefaultModel } from "./complexity_router_tiers";
|
||||
import { hydrateCustomTierSet } from "./build_complexity_router_config";
|
||||
import { ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
|
||||
export type AutoRouterTestMode = "chat" | "embedding";
|
||||
|
|
@ -9,7 +11,9 @@ export interface AutoRouterTestTarget {
|
|||
}
|
||||
|
||||
export interface BuildAutoRouterTestTargetsParams {
|
||||
/** With a custom tier set, pass the EFFECTIVE record (removed built-ins emptied) - see effectiveComplexityTiers. */
|
||||
tiers: ComplexityTiers;
|
||||
additionalTiers?: { name: string; models: string[] }[];
|
||||
semanticMatchingEnabled: boolean;
|
||||
embeddingModel: string | undefined;
|
||||
/** The resolved default model - see resolveComplexityDefaultModel. A live fallback destination,
|
||||
|
|
@ -28,12 +32,17 @@ const TIER_ORDER = Object.keys({
|
|||
|
||||
export const buildAutoRouterTestTargets = ({
|
||||
tiers,
|
||||
additionalTiers = [],
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
defaultModel,
|
||||
}: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => {
|
||||
const tieredByModel = TIER_ORDER.reduce<Record<string, string[]>>((acc, tier) => {
|
||||
return (tiers[tier] ?? []).reduce((tierAcc, rawModel) => {
|
||||
const tierPools: [string, string[]][] = [
|
||||
...TIER_ORDER.map((tier): [string, string[]] => [tier, tiers[tier] ?? []]),
|
||||
...additionalTiers.map((tier): [string, string[]] => [tier.name, tier.models]),
|
||||
];
|
||||
const tieredByModel = tierPools.reduce<Record<string, string[]>>((acc, [tier, models]) => {
|
||||
return models.reduce((tierAcc, rawModel) => {
|
||||
const modelGroup = rawModel?.trim();
|
||||
if (!modelGroup) return tierAcc;
|
||||
return { ...tierAcc, [modelGroup]: [...(tierAcc[modelGroup] ?? []), tier] };
|
||||
|
|
@ -62,3 +71,66 @@ export const buildAutoRouterTestTargets = ({
|
|||
|
||||
return [...tierTargets, ...embeddingTarget];
|
||||
};
|
||||
|
||||
interface ComplexityRouterTierConfig {
|
||||
tiers?: {
|
||||
SIMPLE?: unknown;
|
||||
MEDIUM?: unknown;
|
||||
COMPLEX?: unknown;
|
||||
REASONING?: unknown;
|
||||
};
|
||||
tier_definitions?: unknown;
|
||||
fallback_tier?: unknown;
|
||||
semantic_keyword_matching?: boolean;
|
||||
embedding_model?: string;
|
||||
default_model?: string;
|
||||
}
|
||||
|
||||
interface ComplexityRouterModelData {
|
||||
litellm_params?: {
|
||||
complexity_router_config?: ComplexityRouterTierConfig | string;
|
||||
complexity_router_default_model?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const buildComplexityRouterTestTargets = (
|
||||
modelData: ComplexityRouterModelData | null | undefined,
|
||||
): AutoRouterTestTarget[] => {
|
||||
const rawConfig = modelData?.litellm_params?.complexity_router_config;
|
||||
let config: ComplexityRouterTierConfig = {};
|
||||
if (typeof rawConfig === "string") {
|
||||
try {
|
||||
config = JSON.parse(rawConfig);
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
} else if (rawConfig) {
|
||||
config = rawConfig;
|
||||
}
|
||||
|
||||
const rawTiers = {
|
||||
SIMPLE: normalizeTierModels(config.tiers?.SIMPLE),
|
||||
MEDIUM: normalizeTierModels(config.tiers?.MEDIUM),
|
||||
COMPLEX: normalizeTierModels(config.tiers?.COMPLEX),
|
||||
REASONING: normalizeTierModels(config.tiers?.REASONING),
|
||||
};
|
||||
const customTierSet = hydrateCustomTierSet(config);
|
||||
const tiers = customTierSet ? { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] } : rawTiers;
|
||||
|
||||
// Mirrors init_complexity_router_deployment (litellm/router.py): litellm_params wins, otherwise
|
||||
// pure tier-derivation. complexity_router_config.default_model is a UI-only marker the backend
|
||||
// never reads — folding it in here could point Test Connection at a model the router never
|
||||
// calls (see PR #36615 discussion).
|
||||
const effectiveDefaultModel = modelData?.litellm_params?.complexity_router_default_model || undefined;
|
||||
|
||||
const testTargetParams = {
|
||||
tiers,
|
||||
additionalTiers: customTierSet?.tiers.map((row) => ({ name: row.name, models: row.models })),
|
||||
semanticMatchingEnabled: Boolean(config.semantic_keyword_matching),
|
||||
embeddingModel: config.embedding_model,
|
||||
defaultModel: customTierSet
|
||||
? customTierDefaultModel(customTierSet, effectiveDefaultModel)
|
||||
: resolveComplexityDefaultModel(tiers, effectiveDefaultModel),
|
||||
};
|
||||
return buildAutoRouterTestTargets(testTargetParams);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import {
|
||||
buildComplexityRouterConfig,
|
||||
getPlanModeTierError,
|
||||
getCustomTierSetError,
|
||||
getKeywordRuleTierError,
|
||||
hydrateCustomTierSet,
|
||||
hydratePlanModeMinTier,
|
||||
normalizeClassifierLlmConfig,
|
||||
serializeCustomTierSet,
|
||||
getKeywordTierRulesError,
|
||||
getMissingTiersError,
|
||||
getSemanticConfigError,
|
||||
|
|
@ -9,6 +14,7 @@ import {
|
|||
hydrateTierLabels,
|
||||
BuildComplexityRouterConfigParams,
|
||||
} from "./build_complexity_router_config";
|
||||
import { isBuiltInTierName } from "./ComplexityRouterConfig";
|
||||
|
||||
const tiers = {
|
||||
SIMPLE: ["gpt-4o-mini"],
|
||||
|
|
@ -19,6 +25,7 @@ const tiers = {
|
|||
|
||||
const baseParams: BuildComplexityRouterConfigParams = {
|
||||
tiers,
|
||||
customTierSet: undefined,
|
||||
tierLabels: undefined,
|
||||
classifierType: "heuristic",
|
||||
classifierLlmConfig: undefined,
|
||||
|
|
@ -656,3 +663,228 @@ describe("getPlanModeTierError", () => {
|
|||
expect(getPlanModeTierError("COMPLEX", tiersWithEmptyComplex)).toContain("COMPLEX");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom tier sets", () => {
|
||||
const customTierSet = {
|
||||
tiers: [
|
||||
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-4o-mini"] },
|
||||
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: ["claude-sonnet-4"] },
|
||||
{ id: "REASONING", name: "REASONING", definition: "", models: ["o1-preview"] },
|
||||
{
|
||||
id: "sec",
|
||||
name: "SECURITY_REVIEW",
|
||||
definition: "requests asking for a security audit or vulnerability review",
|
||||
models: ["claude-sonnet-5"],
|
||||
},
|
||||
],
|
||||
fallback_tier_id: "COMPLEX",
|
||||
};
|
||||
|
||||
const customParams = {
|
||||
...baseParams,
|
||||
customTierSet,
|
||||
};
|
||||
|
||||
it("serializes rows in order: bare names inherit criteria, written definitions become descriptions", () => {
|
||||
const config = buildComplexityRouterConfig(customParams);
|
||||
expect(config.tier_definitions).toEqual([
|
||||
{ name: "SIMPLE" },
|
||||
{ name: "COMPLEX" },
|
||||
{ name: "REASONING" },
|
||||
{ name: "SECURITY_REVIEW", description: "requests asking for a security audit or vulnerability review" },
|
||||
]);
|
||||
expect(config.fallback_tier).toBe("COMPLEX");
|
||||
expect(config.tiers).toEqual({
|
||||
SIMPLE: ["gpt-4o-mini"],
|
||||
COMPLEX: ["claude-sonnet-4"],
|
||||
REASONING: ["o1-preview"],
|
||||
SECURITY_REVIEW: ["claude-sonnet-5"],
|
||||
});
|
||||
});
|
||||
|
||||
it("forces off everything the backend rejects alongside tier_definitions", () => {
|
||||
const config = buildComplexityRouterConfig({
|
||||
...customParams,
|
||||
tierLabels: { SIMPLE: "Cheap" },
|
||||
classifierType: "heuristic",
|
||||
classifierLlmConfig: {
|
||||
model: "haiku-classifier",
|
||||
timeout_ms: 400,
|
||||
classification_rubric: "agentic",
|
||||
system_prompt: "grade it",
|
||||
},
|
||||
classifierFallback: "default_model",
|
||||
sessionAffinity: true,
|
||||
escalationKeywords: ["GO UP"],
|
||||
adaptive: true,
|
||||
});
|
||||
expect(config.classifier_type).toBe("llm");
|
||||
expect(config.classifier_llm_config).toEqual({ model: "haiku-classifier", timeout_ms: 400 });
|
||||
expect(config.session_affinity).toBe(false);
|
||||
expect(config.escalation_keywords).toEqual([]);
|
||||
expect(config).not.toHaveProperty("tier_labels");
|
||||
expect(config).not.toHaveProperty("classifier_fallback");
|
||||
expect(config).not.toHaveProperty("adaptive");
|
||||
expect(config).not.toHaveProperty("adaptive_weights");
|
||||
});
|
||||
|
||||
it("drops scorer knobs, which cannot run on a custom tier set", () => {
|
||||
const config = buildComplexityRouterConfig({
|
||||
...customParams,
|
||||
tierBoundaries: { simple_medium: 0.22, medium_complex: 0.44, complex_reasoning: 0.66 },
|
||||
});
|
||||
expect(config).not.toHaveProperty("tier_boundaries");
|
||||
});
|
||||
|
||||
it("emits no custom tier keys when the tier set was never edited", () => {
|
||||
const config = buildComplexityRouterConfig({ ...baseParams, customTierSet: undefined });
|
||||
expect(config).not.toHaveProperty("tier_definitions");
|
||||
expect(config).not.toHaveProperty("fallback_tier");
|
||||
});
|
||||
|
||||
describe("getCustomTierSetError", () => {
|
||||
const editRow = (id: string, patch: object) => ({
|
||||
...customTierSet,
|
||||
tiers: customTierSet.tiers.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
});
|
||||
|
||||
it("returns null for a complete tier set", () => {
|
||||
expect(getCustomTierSetError(customTierSet)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a bare built-in name, which inherits the built-in criteria", () => {
|
||||
expect(getCustomTierSetError(editRow("SIMPLE", { definition: "" }))).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unnamed tier", editRow("sec", { name: " " }), "Name every tier"],
|
||||
[
|
||||
"a custom tier without a definition",
|
||||
editRow("sec", { definition: "" }),
|
||||
"Write a definition for the classifier to use for: SECURITY_REVIEW",
|
||||
],
|
||||
[
|
||||
"a tier without models",
|
||||
editRow("sec", { models: [] }),
|
||||
"Select a model for the following tier(s): SECURITY_REVIEW",
|
||||
],
|
||||
["a duplicate name", editRow("sec", { name: "complex" }), "Tier names must be unique"],
|
||||
[
|
||||
"a definition with a newline, which the backend rejects",
|
||||
editRow("sec", { definition: "security audits\nand exploits" }),
|
||||
"to one line",
|
||||
],
|
||||
["a fallback pointing at no row", { ...customTierSet, fallback_tier_id: "gone" }, "Pick a fallback tier"],
|
||||
[
|
||||
"fewer than two tiers",
|
||||
{ tiers: [customTierSet.tiers[0]], fallback_tier_id: "SIMPLE" },
|
||||
"Keep at least 2 tiers",
|
||||
],
|
||||
])("names %s", (_case, tierSet, expected) => {
|
||||
expect(getCustomTierSetError(tierSet)).toContain(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKeywordRuleTierError", () => {
|
||||
it("passes rules that target active tiers and flags ones stranded by a removed tier", () => {
|
||||
const rules = [
|
||||
{ id: "r1", keywords: ["audit"], tier: "SECURITY_REVIEW" },
|
||||
{ id: "r2", keywords: ["billing"], tier: "MEDIUM" },
|
||||
];
|
||||
expect(getKeywordRuleTierError([rules[1]], undefined)).toBeNull();
|
||||
expect(getKeywordRuleTierError([rules[0]], customTierSet)).toBeNull();
|
||||
expect(getKeywordRuleTierError(rules, customTierSet)).toContain("MEDIUM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateCustomTierSet", () => {
|
||||
it("round-trips what serializeCustomTierSet wrote, in order, built-in ids canonical", () => {
|
||||
const serialized = serializeCustomTierSet(customTierSet);
|
||||
const hydrated = hydrateCustomTierSet(serialized);
|
||||
expect(hydrated).toEqual({
|
||||
tiers: customTierSet.tiers.map((row, index) => ({
|
||||
...row,
|
||||
id: isBuiltInTierName(row.name) ? row.name : `stored-${index}`,
|
||||
})),
|
||||
fallback_tier_id: "COMPLEX",
|
||||
});
|
||||
expect(serializeCustomTierSet(hydrated!)).toEqual(serialized);
|
||||
});
|
||||
|
||||
it("round-trips a backend-valid shape this UI never writes, preserving severity order", () => {
|
||||
const stored = {
|
||||
tier_definitions: [{ name: "SIMPLE", description: "only greetings" }, { name: "COMPLEX" }],
|
||||
tiers: { SIMPLE: ["cheap"], COMPLEX: ["big"] },
|
||||
fallback_tier: "COMPLEX",
|
||||
};
|
||||
const hydrated = hydrateCustomTierSet(stored);
|
||||
expect(hydrated?.tiers).toEqual([
|
||||
{ id: "SIMPLE", name: "SIMPLE", definition: "only greetings", models: ["cheap"] },
|
||||
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: ["big"] },
|
||||
]);
|
||||
const reserialized = serializeCustomTierSet(hydrated!);
|
||||
expect(reserialized.tier_definitions).toEqual(stored.tier_definitions);
|
||||
expect(reserialized.fallback_tier).toBe("COMPLEX");
|
||||
});
|
||||
|
||||
it("omits fallback_tier when the pointer resolves to no row", () => {
|
||||
const serialized = serializeCustomTierSet({ ...customTierSet, fallback_tier_id: "gone" });
|
||||
expect(serialized).not.toHaveProperty("fallback_tier");
|
||||
});
|
||||
|
||||
it("returns undefined for a built-in router config", () => {
|
||||
expect(hydrateCustomTierSet({ tiers: { SIMPLE: ["cheap"] } })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan-mode floor on a custom tier set", () => {
|
||||
const rows = [
|
||||
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-4o-mini"] },
|
||||
{ id: "sec", name: "SECURITY_REVIEW", definition: "security audits", models: ["claude-sonnet-5"] },
|
||||
{ id: "new-1", name: "DRAFTING", definition: "long-form writing", models: [] },
|
||||
];
|
||||
const set = { tiers: rows, fallback_tier_id: "SIMPLE" };
|
||||
const params = { ...baseParams, customTierSet: set, classifierType: "llm" as const };
|
||||
|
||||
it("resolves the floor's row id to the tier NAME on the wire", () => {
|
||||
const config = buildComplexityRouterConfig({ ...params, planModeMinTier: "sec" });
|
||||
expect(config.plan_mode_min_tier).toBe("SECURITY_REVIEW");
|
||||
});
|
||||
|
||||
it("emits no floor when the id matches no row", () => {
|
||||
const config = buildComplexityRouterConfig({ ...params, planModeMinTier: "gone" });
|
||||
expect(config).not.toHaveProperty("plan_mode_min_tier");
|
||||
});
|
||||
|
||||
it("getPlanModeTierError looks the floor up by row id and reports the row's NAME", () => {
|
||||
const builtInTiers = { SIMPLE: ["m"], MEDIUM: [], COMPLEX: [], REASONING: [] };
|
||||
expect(getPlanModeTierError("sec", builtInTiers, set)).toBeNull();
|
||||
expect(getPlanModeTierError("new-1", builtInTiers, set)).toContain("DRAFTING");
|
||||
expect(getPlanModeTierError("gone", builtInTiers, set)).toContain("not in the tier set");
|
||||
});
|
||||
|
||||
it("hydration maps the stored NAME back to the row id, and keeps an unmatched name raw", () => {
|
||||
expect(hydratePlanModeMinTier("SECURITY_REVIEW", set)).toBe("sec");
|
||||
expect(hydratePlanModeMinTier("NOT_A_TIER", set)).toBe("NOT_A_TIER");
|
||||
expect(hydratePlanModeMinTier("COMPLEX", undefined)).toBe("COMPLEX");
|
||||
expect(hydratePlanModeMinTier(" ", set)).toBeUndefined();
|
||||
expect(hydratePlanModeMinTier(7, set)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrated tier row identity", () => {
|
||||
it("gives a built-in name its canonical key as the row id, so Restore and mode-exit recognize it", () => {
|
||||
const hydrated = hydrateCustomTierSet({
|
||||
tier_definitions: [
|
||||
{ name: "simple", description: "cheap chat" },
|
||||
{ name: "SECURITY_REVIEW", description: "audits" },
|
||||
{ name: "REASONING" },
|
||||
],
|
||||
tiers: { simple: ["m1"], SECURITY_REVIEW: ["m2"], REASONING: ["m3"] },
|
||||
fallback_tier: "REASONING",
|
||||
});
|
||||
expect(hydrated?.tiers.map((row) => row.id)).toEqual(["SIMPLE", "stored-1", "REASONING"]);
|
||||
expect(hydrated?.fallback_tier_id).toBe("REASONING");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { KeywordTierRule } from "./KeywordTierRules";
|
||||
import { emptyKeywordTierRuleIndexes, serializeKeywordTierRules } from "./complexity_router_keywords";
|
||||
import { normalizeTierModels } from "./complexity_router_tiers";
|
||||
import {
|
||||
AdaptiveEligible,
|
||||
AdaptiveRouterWeights,
|
||||
|
|
@ -8,11 +9,14 @@ import {
|
|||
ClassifierType,
|
||||
ComplexityTierLabels,
|
||||
ComplexityTiers,
|
||||
CustomTierSet,
|
||||
DimensionWeights,
|
||||
TIER_DESCRIPTIONS,
|
||||
TierBoundaries,
|
||||
TokenThresholds,
|
||||
activeTierNames,
|
||||
effectiveTierLabel,
|
||||
isBuiltInTierName,
|
||||
heuristicScoringRoleFor,
|
||||
} from "./ComplexityRouterConfig";
|
||||
|
||||
|
|
@ -73,6 +77,7 @@ const scorerKnobPayload = ({
|
|||
|
||||
export interface BuildComplexityRouterConfigParams {
|
||||
tiers: ComplexityTiers;
|
||||
customTierSet: CustomTierSet | undefined;
|
||||
defaultModel: string | undefined;
|
||||
planModeMinTier: string | undefined;
|
||||
tierLabels: ComplexityTierLabels | undefined;
|
||||
|
|
@ -101,8 +106,15 @@ export interface BuildComplexityRouterConfigParams {
|
|||
reasoningOverrideMinScore?: number;
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -182,8 +194,20 @@ export const getMissingTiersError = (tiers: ComplexityTiers): string | null => {
|
|||
// The backend rejects a plan-mode floor naming a tier with no models. The create form's
|
||||
// getMissingTiersError makes this unreachable there; the edit modal allows partially filled
|
||||
// tiers, so both gates call this to keep the two forms symmetric.
|
||||
export const getPlanModeTierError = (planModeMinTier: string | undefined, tiers: ComplexityTiers): string | null => {
|
||||
export const getPlanModeTierError = (
|
||||
planModeMinTier: string | undefined,
|
||||
tiers: ComplexityTiers,
|
||||
customTierSet?: CustomTierSet,
|
||||
): string | null => {
|
||||
if (!planModeMinTier) return null;
|
||||
if (customTierSet) {
|
||||
const row = customTierSet.tiers.find((tier) => tier.id === planModeMinTier);
|
||||
const shownName = row?.name.trim() || planModeMinTier;
|
||||
if (!row)
|
||||
return `The plan-mode minimum tier (${shownName}) is not in the tier set. Re-pick or turn the override off.`;
|
||||
if (row.models.length > 0) return null;
|
||||
return `The plan-mode minimum tier (${shownName}) has no models. Add one or turn the override off.`;
|
||||
}
|
||||
const models = tiers[planModeMinTier as keyof ComplexityTiers] ?? [];
|
||||
if (models.length > 0) return null;
|
||||
return `The plan-mode minimum tier (${planModeMinTier}) has no models. Add one or turn the override off.`;
|
||||
|
|
@ -208,8 +232,212 @@ export const getSemanticConfigError = ({
|
|||
return null;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* The wire shape of an edited tier set. The draft rows already ARE the definition list in
|
||||
* severity order, so this is a plain map: names become tier_definitions entries (a blank
|
||||
* definition is omitted, which on a built-in name inherits the built-in criteria) and `tiers`
|
||||
* maps exactly those names, mirroring the backend's bijective-keys validation.
|
||||
*/
|
||||
export const serializeCustomTierSet = (
|
||||
customTierSet: CustomTierSet,
|
||||
): Pick<ComplexityRouterConfigPayload, "tiers" | "tier_definitions" | "fallback_tier"> => ({
|
||||
tiers: Object.fromEntries(customTierSet.tiers.map((row) => [row.name.trim(), row.models] as const)),
|
||||
tier_definitions: customTierSet.tiers.map((row) => ({
|
||||
name: row.name.trim(),
|
||||
...(row.definition.trim() && { description: row.definition.trim() }),
|
||||
})),
|
||||
...(() => {
|
||||
const fallbackName = customTierSet.tiers.find((row) => row.id === customTierSet.fallback_tier_id)?.name.trim();
|
||||
return fallbackName ? { fallback_tier: fallbackName } : {};
|
||||
})(),
|
||||
});
|
||||
|
||||
/**
|
||||
* The inverse of serializeCustomTierSet, for the edit modal: one draft row per stored definition,
|
||||
* in stored order, so any backend-valid shape round-trips byte-identically. Definition list order
|
||||
* is severity order on the backend (the keyword tie-break), which is why the draft is the ordered
|
||||
* list itself rather than a diff against the built-in four.
|
||||
*/
|
||||
export const hydrateCustomTierSet = (parsedConfig: {
|
||||
tier_definitions?: unknown;
|
||||
fallback_tier?: unknown;
|
||||
tiers?: unknown;
|
||||
}): CustomTierSet | undefined => {
|
||||
if (!Array.isArray(parsedConfig.tier_definitions) || parsedConfig.tier_definitions.length === 0) return undefined;
|
||||
const storedTiers =
|
||||
typeof parsedConfig.tiers === "object" && parsedConfig.tiers !== null && !Array.isArray(parsedConfig.tiers)
|
||||
? (parsedConfig.tiers as Record<string, unknown>)
|
||||
: {};
|
||||
const rows = parsedConfig.tier_definitions.flatMap((entry, index) => {
|
||||
if (typeof entry !== "object" || entry === null) return [];
|
||||
const { name, description } = entry as { name?: unknown; description?: unknown };
|
||||
if (typeof name !== "string" || !name.trim()) return [];
|
||||
return [
|
||||
{
|
||||
// A built-in name keeps its canonical key as the id: the editor's Restore chip and
|
||||
// mode-exit checks compare row ids against TIER_KEYS, so a hydrated SIMPLE must be
|
||||
// the same identity as an in-editor SIMPLE or restore would duplicate it.
|
||||
id: TIER_KEYS.find((tier) => tier.toLowerCase() === name.trim().toLowerCase()) ?? `stored-${index}`,
|
||||
name: name.trim(),
|
||||
definition: typeof description === "string" ? description.trim() : "",
|
||||
models: normalizeTierModels(storedTiers[name.trim()]),
|
||||
},
|
||||
];
|
||||
});
|
||||
if (rows.length === 0) return undefined;
|
||||
const storedFallback = typeof parsedConfig.fallback_tier === "string" ? parsedConfig.fallback_tier.trim() : "";
|
||||
return {
|
||||
tiers: rows,
|
||||
fallback_tier_id: rows.find((row) => row.name === storedFallback)?.id ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Stored config keys the backend rejects alongside tier_definitions. The create form cannot emit
|
||||
* them in custom mode (its builder strips its own inputs), but the edit modal preserves unmanaged
|
||||
* stored keys, so a router that already carried one of these would fail an otherwise valid custom
|
||||
* tier save with a raw 400 unless the modal drops them through this one list.
|
||||
*
|
||||
* Mirrors the rejections in ComplexityRouterConfig's model validators, surfaced by
|
||||
* validate_complexity_router_config_write (litellm/router_utils/auto_router_model_naming.py):
|
||||
* when the backend adds a rejection, add its key here. Drift is not fatal (the pre-save dry-run
|
||||
* of that same validator reports the conflict inline) but only this list can auto-drop the key.
|
||||
*/
|
||||
export const KEYS_REJECTED_WITH_CUSTOM_TIERS: readonly string[] = [
|
||||
"plugins",
|
||||
"tier_labels",
|
||||
"classifier_fallback",
|
||||
"adaptive",
|
||||
"adaptive_weights",
|
||||
"tier_distance_penalty",
|
||||
"adaptive_eligible",
|
||||
"tier_boundaries",
|
||||
"token_thresholds",
|
||||
"dimension_weights",
|
||||
"escalation_keywords",
|
||||
"session_affinity",
|
||||
];
|
||||
|
||||
/**
|
||||
* Everything a custom tier set forces onto the wire, shared by the create form's builder and the
|
||||
* edit modal's so the invariant set has one owner: forcing the LLM classifier, stripping the
|
||||
* rubric preset and wholesale prompt, and disabling session affinity and escalation, any of which
|
||||
* would turn a disabled control's stale state into a 400.
|
||||
*/
|
||||
export const customTierSetWireFields = (
|
||||
customTierSet: CustomTierSet,
|
||||
classifierLlmConfig: ClassifierLLMConfig | undefined,
|
||||
planModeMinTierId: string | undefined,
|
||||
) => {
|
||||
const planModeName = customTierSet.tiers.find((row) => row.id === planModeMinTierId)?.name.trim();
|
||||
return {
|
||||
...serializeCustomTierSet(customTierSet),
|
||||
classifier_type: "llm" as const,
|
||||
...(classifierLlmConfig && {
|
||||
classifier_llm_config: {
|
||||
model: classifierLlmConfig.model,
|
||||
timeout_ms: classifierLlmConfig.timeout_ms,
|
||||
},
|
||||
}),
|
||||
session_affinity: false,
|
||||
escalation_keywords: [] as string[],
|
||||
...(planModeName && { plan_mode_min_tier: planModeName }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* On the form value, plan_mode_min_tier holds a tier ROW ID (built-in row ids are the four tier
|
||||
* names, so built-in configs are id-stable by construction). The wire carries the NAME, so
|
||||
* hydration maps it back to the row minted for that name; an unmatched name is kept raw so
|
||||
* getPlanModeTierError blocks the save with the stored name in the message.
|
||||
*/
|
||||
export const hydratePlanModeMinTier = (
|
||||
stored: unknown,
|
||||
customTierSet: CustomTierSet | undefined,
|
||||
): string | undefined => {
|
||||
if (typeof stored !== "string" || stored.trim() === "") return undefined;
|
||||
if (!customTierSet) return stored;
|
||||
return customTierSet.tiers.find((row) => row.name.trim() === stored.trim())?.id ?? stored;
|
||||
};
|
||||
|
||||
const applyCustomTierSetInvariants = (
|
||||
payload: ComplexityRouterConfigPayload,
|
||||
customTierSet: CustomTierSet,
|
||||
classifierLlmConfig: ClassifierLLMConfig | undefined,
|
||||
): ComplexityRouterConfigPayload => {
|
||||
const {
|
||||
tier_labels: _tierLabels,
|
||||
classifier_fallback: _classifierFallback,
|
||||
adaptive: _adaptive,
|
||||
adaptive_weights: _adaptiveWeights,
|
||||
tier_distance_penalty: _tierDistancePenalty,
|
||||
adaptive_eligible: _adaptiveEligible,
|
||||
tier_boundaries: _tierBoundaries,
|
||||
token_thresholds: _tokenThresholds,
|
||||
dimension_weights: _dimensionWeights,
|
||||
plan_mode_min_tier: planModeMinTierId,
|
||||
...rest
|
||||
} = payload;
|
||||
return {
|
||||
...rest,
|
||||
...customTierSetWireFields(customTierSet, classifierLlmConfig, planModeMinTierId),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A keyword rule whose tier left the active set would pass the create form (rule validation only
|
||||
* checks for empty keywords) and then fail the save as a raw backend 400: the backend validates
|
||||
* rule tiers against the active tier names, so the form must fail the same way, inline.
|
||||
*/
|
||||
export const getKeywordRuleTierError = (
|
||||
keywordTierRules: KeywordTierRule[],
|
||||
customTierSet: CustomTierSet | undefined,
|
||||
): string | null => {
|
||||
const active = new Set(activeTierNames(customTierSet));
|
||||
const stranded = [...new Set(keywordTierRules.map((rule) => rule.tier).filter((tier) => !active.has(tier)))];
|
||||
if (stranded.length === 0) return null;
|
||||
return `Keyword rule(s) target tier(s) not in the tier set: ${stranded.join(", ")}. Retarget or delete those rules`;
|
||||
};
|
||||
|
||||
export const getCustomTierSetError = (customTierSet: CustomTierSet): string | null => {
|
||||
const names = customTierSet.tiers.map((row) => row.name.trim());
|
||||
if (names.some((name) => name === "")) return "Name every tier";
|
||||
if (names.some((name) => name.length > MAX_TIER_NAME_CHARS))
|
||||
return `Tier names must be at most ${MAX_TIER_NAME_CHARS} characters`;
|
||||
const folded = names.map((name) => name.toLowerCase());
|
||||
const duplicated = Array.from(new Set(names.filter((_, index) => folded.indexOf(folded[index]) !== index)));
|
||||
if (duplicated.length > 0) return `Tier names must be unique: ${duplicated.join(", ")}`;
|
||||
const multiline = customTierSet.tiers
|
||||
.filter((row) => /[\n\r]/.test(row.name) || /[\n\r]/.test(row.definition))
|
||||
.map((row) => row.name.trim() || "the new tier");
|
||||
if (multiline.length > 0)
|
||||
return `Keep the name and definition of ${multiline.join(", ")} to one line: the classifier rubric renders one bullet per tier`;
|
||||
const undefinedTiers = customTierSet.tiers
|
||||
.filter((row) => !row.definition.trim() && !isBuiltInTierName(row.name))
|
||||
.map((row) => row.name.trim());
|
||||
if (undefinedTiers.length > 0)
|
||||
return `Write a definition for the classifier to use for: ${undefinedTiers.join(", ")}`;
|
||||
const overlongDefinitions = customTierSet.tiers.filter(
|
||||
(row) => row.definition.trim().length > MAX_TIER_DEFINITION_CHARS,
|
||||
);
|
||||
if (overlongDefinitions.length > 0) return `Tier definitions must be at most ${MAX_TIER_DEFINITION_CHARS} characters`;
|
||||
const modelless = customTierSet.tiers.filter((row) => row.models.length === 0).map((row) => row.name.trim());
|
||||
if (modelless.length > 0) return `Select a model for the following tier(s): ${modelless.join(", ")}`;
|
||||
if (names.length < MIN_TIER_COUNT) return `Keep at least ${MIN_TIER_COUNT} tiers`;
|
||||
if (names.length > MAX_TIER_COUNT) return `Keep at most ${MAX_TIER_COUNT} tiers`;
|
||||
if (!customTierSet.tiers.some((row) => row.id === customTierSet.fallback_tier_id))
|
||||
return "Pick a fallback tier, so requests have somewhere to route when the classifier fails";
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildComplexityRouterConfig = ({
|
||||
tiers,
|
||||
customTierSet,
|
||||
defaultModel,
|
||||
planModeMinTier,
|
||||
tierLabels,
|
||||
|
|
@ -250,7 +478,7 @@ export const buildComplexityRouterConfig = ({
|
|||
};
|
||||
const scorerKnobs = scorerKnobPayload(scorerInputs);
|
||||
|
||||
return {
|
||||
const payload: ComplexityRouterConfigPayload = {
|
||||
tiers,
|
||||
...(defaultModel?.trim() && { default_model: defaultModel }),
|
||||
...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }),
|
||||
|
|
@ -290,4 +518,5 @@ export const buildComplexityRouterConfig = ({
|
|||
...(returnRawModelName && { return_raw_model_name: true }),
|
||||
...scorerKnobs,
|
||||
};
|
||||
return customTierSet ? applyCustomTierSetInvariants(payload, customTierSet, classifierLlmConfig) : payload;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
export type ClassificationRubric = "legacy" | "agentic" | "chat" | "business";
|
||||
|
||||
/** What an unset preset means, matching the backend: the rubric as it shipped before calibration. */
|
||||
export const DEFAULT_CLASSIFICATION_RUBRIC: ClassificationRubric = "legacy";
|
||||
|
||||
/**
|
||||
* Stamped on a classifier being switched on for the first time. There is no prior tier behaviour to
|
||||
* preserve at that moment, so a newly configured classifier gets the calibrated rubric while every
|
||||
* router already running an LLM classifier keeps the one it has.
|
||||
*/
|
||||
export const NEW_CLASSIFIER_CLASSIFICATION_RUBRIC: ClassificationRubric = "agentic";
|
||||
|
||||
export const CLASSIFICATION_RUBRIC_DESCRIPTIONS: Record<ClassificationRubric, { label: string; description: string }> =
|
||||
{
|
||||
legacy: {
|
||||
label: "Legacy (uncalibrated)",
|
||||
description:
|
||||
"The rubric as it shipped before calibration examples, with no worked examples at all. Routers created " +
|
||||
"before this setting existed use it, so their tier decisions and spend are unchanged. It over-routes " +
|
||||
"ordinary engineering to the most expensive tier.",
|
||||
},
|
||||
agentic: {
|
||||
label: "Agentic",
|
||||
description:
|
||||
"Anchors routine installs, builds, multi-file edits, and standard debugging at " +
|
||||
"Medium, so ordinary engineering does not route to your most expensive tier. Suits agent, terminal, and " +
|
||||
"coding-assistant traffic, and mixed traffic.",
|
||||
},
|
||||
chat: {
|
||||
label: "Chat",
|
||||
description:
|
||||
"Drops the engineering examples, for a router serving only conversational traffic that never sees those " +
|
||||
"requests.",
|
||||
},
|
||||
business: {
|
||||
label: "Business",
|
||||
description:
|
||||
"Business and sales examples plus business-oriented tier definitions: routine drafting and summarizing " +
|
||||
"stay at Medium, data-determined analysis is Complex, and only decisions under conflicting tradeoffs " +
|
||||
"reach Reasoning. Suits sales, support, and go-to-market traffic.",
|
||||
},
|
||||
};
|
||||
|
||||
export const CLASSIFICATION_RUBRIC_KEYS = Object.keys(CLASSIFICATION_RUBRIC_DESCRIPTIONS) as ClassificationRubric[];
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeTierModels, resolveComplexityDefaultModel } from "./complexity_router_tiers";
|
||||
import { customTierDefaultModel, normalizeTierModels, resolveComplexityDefaultModel } from "./complexity_router_tiers";
|
||||
|
||||
import type { ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
|
||||
|
|
@ -70,3 +70,23 @@ describe("resolveComplexityDefaultModel", () => {
|
|||
expect(resolveComplexityDefaultModel(noTiers)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("customTierDefaultModel", () => {
|
||||
const rows = (fallbackId: string) => ({
|
||||
tiers: [
|
||||
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["cheap"] },
|
||||
{ id: "MEDIUM", name: "MEDIUM", definition: "", models: ["mid"] },
|
||||
{ id: "sec", name: "AUDIT", definition: "security audits", models: ["sonnet"] },
|
||||
],
|
||||
fallback_tier_id: fallbackId,
|
||||
});
|
||||
|
||||
it("prefers the pin, then the fallback row, then a MEDIUM or SIMPLE row", () => {
|
||||
expect(customTierDefaultModel(rows("sec"), "pinned")).toBe("pinned");
|
||||
expect(customTierDefaultModel(rows("sec"))).toBe("sonnet");
|
||||
expect(customTierDefaultModel(rows("gone"))).toBe("mid");
|
||||
expect(
|
||||
customTierDefaultModel({ tiers: rows("gone").tiers.filter((r) => r.id !== "MEDIUM"), fallback_tier_id: "gone" }),
|
||||
).toBe("cheap");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
import type { ComplexityTiers, CustomTierSet } from "./ComplexityRouterConfig";
|
||||
import type { ComplexityTier } from "./KeywordTierRules";
|
||||
|
||||
/**
|
||||
|
|
@ -17,9 +17,9 @@ export const normalizeTierModels = (value: unknown): string[] => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Mirrors `init_complexity_router_deployment` (litellm/router.py): an explicit pin wins, otherwise
|
||||
* the default is `MEDIUM or SIMPLE`. Deriving past SIMPLE would name a model the backend never
|
||||
* picks, and it raises rather than falling through to COMPLEX/REASONING.
|
||||
* Mirrors `init_complexity_router_deployment` (litellm/router.py) for a built-in tier set: an
|
||||
* explicit pin wins, otherwise the default is `MEDIUM or SIMPLE`. Deriving past SIMPLE would name
|
||||
* a model the backend never picks, and it raises rather than falling through to COMPLEX/REASONING.
|
||||
*/
|
||||
export const resolveComplexityDefaultModel = (tiers: ComplexityTiers, pinned?: string): string | undefined =>
|
||||
pinned?.trim() || tiers.MEDIUM[0] || tiers.SIMPLE[0];
|
||||
|
|
@ -33,7 +33,26 @@ export const DEFAULT_TIER_LABELS: Record<ComplexityTier, string> = {
|
|||
|
||||
export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
|
||||
|
||||
const isBuiltInOption = (tier: string): tier is ComplexityTier => (TIER_ORDER as string[]).includes(tier);
|
||||
|
||||
export const tierOptions = (
|
||||
tierLabels: Partial<Record<ComplexityTier, string>> | undefined,
|
||||
): { value: ComplexityTier; label: string }[] =>
|
||||
TIER_ORDER.map((tier) => ({ value: tier, label: tierLabels?.[tier]?.trim() || DEFAULT_TIER_LABELS[tier] }));
|
||||
tierNames?: string[],
|
||||
): { value: string; label: string }[] =>
|
||||
(tierNames ?? TIER_ORDER).map((tier) => ({
|
||||
value: tier,
|
||||
label: (isBuiltInOption(tier) && (tierLabels?.[tier]?.trim() || DEFAULT_TIER_LABELS[tier])) || tier,
|
||||
}));
|
||||
|
||||
export const defaultRuleTier = (tierNames?: string[]): string =>
|
||||
!tierNames || tierNames.includes("COMPLEX") ? "COMPLEX" : tierNames[0] ?? "COMPLEX";
|
||||
|
||||
/**
|
||||
* The same backend derivation for an edited tier set, over the rows the payload will carry: the
|
||||
* pin wins, then the fallback tier's pool, then a row named MEDIUM or SIMPLE if the set kept one.
|
||||
*/
|
||||
export const customTierDefaultModel = (customTierSet: CustomTierSet, pinned?: string): string | undefined => {
|
||||
const rowNamed = (name: string) => customTierSet.tiers.find((row) => row.name.trim() === name);
|
||||
const fallbackRow = customTierSet.tiers.find((row) => row.id === customTierSet.fallback_tier_id);
|
||||
return pinned?.trim() || fallbackRow?.models[0] || rowNamed("MEDIUM")?.models[0] || rowNamed("SIMPLE")?.models[0];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -373,3 +373,146 @@ describe("buildUpdatedComplexityRouterConfig plan-mode minimum tier", () => {
|
|||
expect(result.plan_mode_min_tier).toBe("MEDIUM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUpdatedComplexityRouterConfig custom tier sets", () => {
|
||||
const customValue = {
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: ["gpt-4o"], COMPLEX: ["claude-sonnet-4"], REASONING: ["o1-preview"] },
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-4o-mini"] },
|
||||
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: ["claude-sonnet-4"] },
|
||||
{ id: "REASONING", name: "REASONING", definition: "", models: ["o1-preview"] },
|
||||
{ id: "sec", name: "AUDIT", definition: "security audits", models: ["claude-sonnet-5"] },
|
||||
],
|
||||
fallback_tier_id: "COMPLEX",
|
||||
},
|
||||
classifier_type: "llm" as const,
|
||||
classifier_llm_config: {
|
||||
model: "haiku-classifier",
|
||||
timeout_ms: 400,
|
||||
classification_rubric: "agentic" as const,
|
||||
system_prompt: "grade it",
|
||||
},
|
||||
classifier_fallback: "default_model" as const,
|
||||
session_affinity: true,
|
||||
adaptive: true,
|
||||
tier_labels: { SIMPLE: "Cheap" },
|
||||
};
|
||||
|
||||
it("writes the edited tier set and forces off everything the backend rejects beside it", () => {
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED, customValue, undefined, hydratedState);
|
||||
expect(result.tier_definitions).toEqual([
|
||||
{ name: "SIMPLE" },
|
||||
{ name: "COMPLEX" },
|
||||
{ name: "REASONING" },
|
||||
{ name: "AUDIT", description: "security audits" },
|
||||
]);
|
||||
expect(result.fallback_tier).toBe("COMPLEX");
|
||||
expect(result.tiers).toEqual({
|
||||
SIMPLE: ["gpt-4o-mini"],
|
||||
COMPLEX: ["claude-sonnet-4"],
|
||||
REASONING: ["o1-preview"],
|
||||
AUDIT: ["claude-sonnet-5"],
|
||||
});
|
||||
expect(result.classifier_type).toBe("llm");
|
||||
expect(result.classifier_llm_config).toEqual({ model: "haiku-classifier", timeout_ms: 400 });
|
||||
expect(result.session_affinity).toBe(false);
|
||||
expect(result.escalation_keywords).toEqual([]);
|
||||
expect(result).not.toHaveProperty("tier_labels");
|
||||
expect(result).not.toHaveProperty("classifier_fallback");
|
||||
expect(result).not.toHaveProperty("adaptive");
|
||||
expect(result.some_future_backend_key).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it("drops stored keys the backend rejects beside tier_definitions, like plugins", () => {
|
||||
const storedWithPlugins = { ...STORED, plugins: ["my.plugin.path"] };
|
||||
const result = buildUpdatedComplexityRouterConfig(storedWithPlugins, customValue, undefined, hydratedState);
|
||||
expect(result).not.toHaveProperty("plugins");
|
||||
expect(result.some_future_backend_key).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it("drops a stored classification_prompt when the built-in four are restored", () => {
|
||||
const storedCustomWithPrompt = {
|
||||
...STORED,
|
||||
tier_definitions: [{ name: "SIMPLE" }, { name: "AUDIT", description: "security audits" }],
|
||||
fallback_tier: "AUDIT",
|
||||
classification_prompt: "Grade the security relevance.",
|
||||
};
|
||||
const restored = buildUpdatedComplexityRouterConfig(storedCustomWithPrompt, FORM_VALUE, undefined, hydratedState);
|
||||
expect(restored).not.toHaveProperty("classification_prompt");
|
||||
const stillCustom = buildUpdatedComplexityRouterConfig(
|
||||
storedCustomWithPrompt,
|
||||
customValue,
|
||||
undefined,
|
||||
hydratedState,
|
||||
);
|
||||
expect(stillCustom.classification_prompt).toBe("Grade the security relevance.");
|
||||
});
|
||||
|
||||
it("drops a stored tier set when the operator restores the built-in four", () => {
|
||||
const storedCustom = {
|
||||
...STORED,
|
||||
tier_definitions: [{ name: "SIMPLE" }, { name: "AUDIT", description: "security audits" }],
|
||||
fallback_tier: "AUDIT",
|
||||
};
|
||||
const result = buildUpdatedComplexityRouterConfig(storedCustom, FORM_VALUE, undefined, hydratedState);
|
||||
expect(result).not.toHaveProperty("tier_definitions");
|
||||
expect(result).not.toHaveProperty("fallback_tier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUpdatedComplexityRouterConfig custom tier set plan-mode floor", () => {
|
||||
const customValue = {
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: ["gpt-4o"], COMPLEX: ["claude-sonnet-4"], REASONING: ["o1-preview"] },
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-4o-mini"] },
|
||||
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: ["claude-sonnet-4"] },
|
||||
{ id: "sec", name: "AUDIT", definition: "security audits", models: ["claude-sonnet-5"] },
|
||||
],
|
||||
fallback_tier_id: "COMPLEX",
|
||||
},
|
||||
classifier_type: "llm" as const,
|
||||
classifier_llm_config: { model: "haiku-classifier", timeout_ms: 400 },
|
||||
};
|
||||
|
||||
it("round-trips a stored floor on a custom tier save instead of silently clearing it", () => {
|
||||
const result = buildUpdatedComplexityRouterConfig(
|
||||
{ ...STORED, plan_mode_min_tier: "AUDIT" },
|
||||
{ ...customValue, plan_mode_min_tier: "sec" },
|
||||
undefined,
|
||||
hydratedState,
|
||||
);
|
||||
expect(result.plan_mode_min_tier).toBe("AUDIT");
|
||||
});
|
||||
|
||||
it("follows a rename of the floor's tier, because the floor points at the row id", () => {
|
||||
const renamed = {
|
||||
...customValue,
|
||||
custom_tier_set: {
|
||||
...customValue.custom_tier_set,
|
||||
tiers: customValue.custom_tier_set.tiers.map((row) =>
|
||||
row.id === "sec" ? { ...row, name: "SECURITY_REVIEW" } : row,
|
||||
),
|
||||
},
|
||||
plan_mode_min_tier: "sec",
|
||||
};
|
||||
const result = buildUpdatedComplexityRouterConfig(
|
||||
{ ...STORED, plan_mode_min_tier: "AUDIT" },
|
||||
renamed,
|
||||
undefined,
|
||||
hydratedState,
|
||||
);
|
||||
expect(result.plan_mode_min_tier).toBe("SECURITY_REVIEW");
|
||||
});
|
||||
|
||||
it("emits no floor when its row left the tier set, rather than a stale name", () => {
|
||||
const result = buildUpdatedComplexityRouterConfig(
|
||||
{ ...STORED, plan_mode_min_tier: "AUDIT" },
|
||||
{ ...customValue, plan_mode_min_tier: "gone" },
|
||||
undefined,
|
||||
hydratedState,
|
||||
);
|
||||
expect(result).not.toHaveProperty("plan_mode_min_tier");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ vi.mock("../networking", () => ({
|
|||
modelPatchUpdateCall,
|
||||
modelAvailableCall,
|
||||
getAutoRouterClassifierDefaultPromptCall,
|
||||
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) }));
|
||||
|
|
|
|||
|
|
@ -11,16 +11,26 @@ 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 { normalizeTierModels, resolveComplexityDefaultModel } from "../add_model/complexity_router_tiers";
|
||||
import {
|
||||
customTierDefaultModel,
|
||||
normalizeTierModels,
|
||||
resolveComplexityDefaultModel,
|
||||
} from "../add_model/complexity_router_tiers";
|
||||
import { isComplexityRouter } from "../add_model/auto_router_strategies";
|
||||
import {
|
||||
getCustomTierSetError,
|
||||
getKeywordRuleTierError,
|
||||
getKeywordTierRulesError,
|
||||
getSemanticConfigError,
|
||||
getPlanModeTierError,
|
||||
getTierLabelsError,
|
||||
customTierSetWireFields,
|
||||
KEYS_REJECTED_WITH_CUSTOM_TIERS,
|
||||
hydrateCustomTierSet,
|
||||
hydratePlanModeMinTier,
|
||||
hydrateTierLabels,
|
||||
normalizeClassifierLlmConfig,
|
||||
serializeTierLabels,
|
||||
|
|
@ -37,10 +47,12 @@ import {
|
|||
import ComplexityRouterConfig, {
|
||||
ComplexityRouterConfigValue,
|
||||
ComplexityTiers,
|
||||
CustomTierSet,
|
||||
DEFAULT_ADAPTIVE_WEIGHTS,
|
||||
DEFAULT_SESSION_AFFINITY,
|
||||
DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
DEFAULT_TIER_DISTANCE_PENALTY,
|
||||
effectiveClassifierType,
|
||||
heuristicScoringRole,
|
||||
} from "../add_model/ComplexityRouterConfig";
|
||||
import {
|
||||
|
|
@ -66,6 +78,8 @@ interface EditAutoRouterModalProps {
|
|||
// actually renders a control that can set it.
|
||||
const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
||||
"tiers",
|
||||
"tier_definitions",
|
||||
"fallback_tier",
|
||||
"default_model",
|
||||
"plan_mode_min_tier",
|
||||
"tier_labels",
|
||||
|
|
@ -114,11 +128,12 @@ export const hydratePinnedDefaultModel = (
|
|||
storedConfigDefaultModel: unknown,
|
||||
litellmParamsDefaultModel: string | null | undefined,
|
||||
tiers: ComplexityTiers,
|
||||
customTierSet?: CustomTierSet,
|
||||
): string | undefined => {
|
||||
if (typeof storedConfigDefaultModel === "string" && storedConfigDefaultModel.trim()) {
|
||||
return storedConfigDefaultModel;
|
||||
}
|
||||
const tierDerived = resolveComplexityDefaultModel(tiers);
|
||||
const tierDerived = customTierSet ? customTierDefaultModel(customTierSet) : resolveComplexityDefaultModel(tiers);
|
||||
const externalOverride = litellmParamsDefaultModel?.trim();
|
||||
return externalOverride && externalOverride !== tierDerived ? externalOverride : undefined;
|
||||
};
|
||||
|
|
@ -143,12 +158,52 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
return customTechnicalKeywords !== undefined && key === "custom_technical_keywords";
|
||||
};
|
||||
|
||||
const preservedConfig = Object.fromEntries(Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key)));
|
||||
// Beyond the managed keys, the preserved set is trimmed per mode: a custom tier save drops
|
||||
// stored keys the backend rejects beside tier_definitions, and a built-in save drops a stored
|
||||
// classification_prompt, which requires tier_definitions and would orphan-400 after a restore.
|
||||
const preservedConfig = Object.fromEntries(
|
||||
Object.entries(toRecord(storedConfig))
|
||||
.filter(([key]) => !isManaged(key))
|
||||
.filter(([key]) =>
|
||||
value.custom_tier_set ? !KEYS_REJECTED_WITH_CUSTOM_TIERS.includes(key) : key !== "classification_prompt",
|
||||
),
|
||||
);
|
||||
const adaptiveEligible = value.adaptive_eligible ?? "all";
|
||||
const storedKeywordRules = keywordMatching ? serializeKeywordTierRules(keywordMatching.keywordTierRules) : [];
|
||||
const serializedTierLabels = serializeTierLabels(value.tier_labels);
|
||||
const scorerRuns = heuristicScoringRole(value) !== "never";
|
||||
|
||||
if (value.custom_tier_set) {
|
||||
return {
|
||||
...preservedConfig,
|
||||
...(value.default_model?.trim() && { default_model: value.default_model }),
|
||||
...(value.classifier_context_window_size !== undefined && {
|
||||
classifier_context_window_size: value.classifier_context_window_size,
|
||||
}),
|
||||
...(value.classifier_context_per_turn_chars !== undefined && {
|
||||
classifier_context_per_turn_chars: value.classifier_context_per_turn_chars,
|
||||
}),
|
||||
...(value.classifier_context_include_assistant_turns !== undefined && {
|
||||
classifier_context_include_assistant_turns: value.classifier_context_include_assistant_turns,
|
||||
}),
|
||||
deployment_affinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
...(customTechnicalKeywords &&
|
||||
customTechnicalKeywords.length > 0 && {
|
||||
custom_technical_keywords: customTechnicalKeywords,
|
||||
}),
|
||||
...(value.return_raw_model_name && { return_raw_model_name: true }),
|
||||
...(keywordMatching && {
|
||||
...(storedKeywordRules.length > 0 && { keyword_tier_rules: storedKeywordRules }),
|
||||
...(keywordMatching.semanticMatchingEnabled && {
|
||||
semantic_keyword_matching: true,
|
||||
embedding_model: keywordMatching.embeddingModel,
|
||||
match_threshold: keywordMatching.matchThreshold,
|
||||
}),
|
||||
}),
|
||||
...customTierSetWireFields(value.custom_tier_set, value.classifier_llm_config, value.plan_mode_min_tier),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...preservedConfig,
|
||||
tiers: value.tiers,
|
||||
|
|
@ -284,12 +339,18 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
// is legal today stays legal.
|
||||
const submitBlockedReason = !isComplexityRouterModel
|
||||
? null
|
||||
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
|
||||
? "Please select at least one model for a complexity tier"
|
||||
: null) ??
|
||||
getTierLabelsError(complexityRouterConfig.tier_labels) ??
|
||||
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, complexityRouterConfig.tiers) ??
|
||||
getKeywordTierRulesError(keywordTierRules);
|
||||
: (complexityRouterConfig.custom_tier_set
|
||||
? getCustomTierSetError(complexityRouterConfig.custom_tier_set)
|
||||
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
|
||||
? "Please select at least one model for a complexity tier"
|
||||
: null) ?? getTierLabelsError(complexityRouterConfig.tier_labels)) ??
|
||||
getPlanModeTierError(
|
||||
complexityRouterConfig.plan_mode_min_tier,
|
||||
complexityRouterConfig.tiers,
|
||||
complexityRouterConfig.custom_tier_set,
|
||||
) ??
|
||||
getKeywordTierRulesError(keywordTierRules) ??
|
||||
getKeywordRuleTierError(keywordTierRules, complexityRouterConfig.custom_tier_set);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && modelData) {
|
||||
|
|
@ -339,18 +400,18 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
COMPLEX: normalizeTierModels(parsedConfig.tiers?.COMPLEX),
|
||||
REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING),
|
||||
};
|
||||
const hydratedCustomTierSet = hydrateCustomTierSet(parsedConfig);
|
||||
|
||||
const hydratedComplexityRouterConfig: ComplexityRouterConfigValue = {
|
||||
tiers: hydratedTiers,
|
||||
custom_tier_set: hydratedCustomTierSet,
|
||||
default_model: hydratePinnedDefaultModel(
|
||||
parsedConfig.default_model,
|
||||
modelData.litellm_params?.complexity_router_default_model,
|
||||
hydratedTiers,
|
||||
hydratedCustomTierSet,
|
||||
),
|
||||
plan_mode_min_tier:
|
||||
typeof parsedConfig.plan_mode_min_tier === "string" && parsedConfig.plan_mode_min_tier.trim() !== ""
|
||||
? parsedConfig.plan_mode_min_tier
|
||||
: undefined,
|
||||
plan_mode_min_tier: hydratePlanModeMinTier(parsedConfig.plan_mode_min_tier, hydratedCustomTierSet),
|
||||
tier_labels: hydrateTierLabels(parsedConfig.tier_labels),
|
||||
classifier_type: parsedConfig.classifier_type || "heuristic",
|
||||
classifier_llm_config: parsedConfig.classifier_llm_config,
|
||||
|
|
@ -442,13 +503,21 @@ 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, classifier_llm_config } = complexityRouterConfig;
|
||||
const customTierSet = complexityRouterConfig.custom_tier_set;
|
||||
if (customTierSet) {
|
||||
const customTierSetError = getCustomTierSetError(customTierSet);
|
||||
if (customTierSetError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(customTierSetError);
|
||||
return;
|
||||
}
|
||||
} else if (Object.values(tiers).every((models) => models.length === 0)) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError("Please select at least one model for a complexity tier");
|
||||
return;
|
||||
}
|
||||
if (classifier_type === "llm" && !classifier_llm_config?.model) {
|
||||
if (effectiveClassifierType(complexityRouterConfig) === "llm" && !classifier_llm_config?.model) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError("Please select a classifier model, or switch back to Heuristic");
|
||||
return;
|
||||
|
|
@ -457,7 +526,9 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
// keyword rule with no keyword, and semantic_keyword_matching without an embedding model
|
||||
// or keyword rules (complexity_router/config.py), so without these a save fails as a raw
|
||||
// 400 instead of an inline message.
|
||||
const keywordRulesError = getKeywordTierRulesError(keywordTierRules);
|
||||
const keywordRulesError =
|
||||
getKeywordTierRulesError(keywordTierRules) ??
|
||||
getKeywordRuleTierError(keywordTierRules, complexityRouterConfig.custom_tier_set);
|
||||
if (keywordRulesError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(keywordRulesError);
|
||||
|
|
@ -476,7 +547,9 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
// build_complexity_router_config.ts for why create never can). init_complexity_router_deployment
|
||||
// raises in that case (litellm/router.py), so block it rather than saving a router that
|
||||
// fails at init.
|
||||
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
|
||||
const defaultModel = customTierSet
|
||||
? customTierDefaultModel(customTierSet, complexityRouterConfig.default_model)
|
||||
: resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
|
||||
if (!defaultModel) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(
|
||||
|
|
@ -485,23 +558,34 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const updatedConfig = buildUpdatedComplexityRouterConfig(
|
||||
modelData.litellm_params?.complexity_router_config,
|
||||
complexityRouterConfig,
|
||||
customTechnicalKeywords,
|
||||
{
|
||||
keywordTierRules,
|
||||
escalationKeywords,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
matchThreshold,
|
||||
},
|
||||
);
|
||||
|
||||
// The backend's own validator gets the final word before the write, mirroring the create
|
||||
// form: local guards give instant feedback, and this catches anything they do not mirror.
|
||||
const serverVerdict = await validateAutoRouterConfig(accessToken, updatedConfig, modelData?.model_info?.team_id);
|
||||
if (!serverVerdict.valid && serverVerdict.error) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(serverVerdict.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dual write: complexity_router_config.default_model (the pin marker hydratePinnedDefaultModel
|
||||
// reads back) and complexity_router_default_model (what the backend routes on) must always be
|
||||
// written together from the same value. Same pairing in add_auto_router_tab.tsx.
|
||||
const updatedLitellmParams = {
|
||||
...modelData.litellm_params,
|
||||
complexity_router_config: 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 = {
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ import { copyToClipboard as utilCopyToClipboard } from "../utils/dataUtils";
|
|||
import { stripMaskedSecrets } from "../utils/maskedSecretUtils";
|
||||
import { truncateString } from "../utils/textUtils";
|
||||
import AutoRouterConnectionTest from "./add_model/auto_router_connection_test";
|
||||
import { AutoRouterTestTarget, buildAutoRouterTestTargets } from "./add_model/build_auto_router_test_targets";
|
||||
import { normalizeTierModels, resolveComplexityDefaultModel } from "./add_model/complexity_router_tiers";
|
||||
import { AutoRouterTestTarget, buildComplexityRouterTestTargets } from "./add_model/build_auto_router_test_targets";
|
||||
import {
|
||||
hasAutoRouterEditor,
|
||||
isAutoRouterDeployment,
|
||||
|
|
@ -57,62 +56,6 @@ interface ModelInfoViewProps {
|
|||
modelAccessGroups: string[] | null;
|
||||
}
|
||||
|
||||
interface ComplexityRouterTierConfig {
|
||||
tiers?: {
|
||||
SIMPLE?: unknown;
|
||||
MEDIUM?: unknown;
|
||||
COMPLEX?: unknown;
|
||||
REASONING?: unknown;
|
||||
};
|
||||
semantic_keyword_matching?: boolean;
|
||||
embedding_model?: string;
|
||||
default_model?: string;
|
||||
}
|
||||
|
||||
interface ComplexityRouterModelData {
|
||||
litellm_params?: {
|
||||
complexity_router_config?: ComplexityRouterTierConfig | string;
|
||||
complexity_router_default_model?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const buildComplexityRouterTestTargets = (
|
||||
modelData: ComplexityRouterModelData | null | undefined,
|
||||
): AutoRouterTestTarget[] => {
|
||||
const rawConfig = modelData?.litellm_params?.complexity_router_config;
|
||||
let config: ComplexityRouterTierConfig = {};
|
||||
if (typeof rawConfig === "string") {
|
||||
try {
|
||||
config = JSON.parse(rawConfig);
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
} else if (rawConfig) {
|
||||
config = rawConfig;
|
||||
}
|
||||
|
||||
const tiers = {
|
||||
SIMPLE: normalizeTierModels(config.tiers?.SIMPLE),
|
||||
MEDIUM: normalizeTierModels(config.tiers?.MEDIUM),
|
||||
COMPLEX: normalizeTierModels(config.tiers?.COMPLEX),
|
||||
REASONING: normalizeTierModels(config.tiers?.REASONING),
|
||||
};
|
||||
|
||||
// Mirrors init_complexity_router_deployment (litellm/router.py): litellm_params wins, otherwise
|
||||
// pure tier-derivation. complexity_router_config.default_model is a UI-only marker the backend
|
||||
// never reads — folding it in here could point Test Connection at a model the router never
|
||||
// calls (see PR #36615 discussion).
|
||||
const effectiveDefaultModel = modelData?.litellm_params?.complexity_router_default_model || undefined;
|
||||
|
||||
const testTargetParams = {
|
||||
tiers,
|
||||
semanticMatchingEnabled: Boolean(config.semantic_keyword_matching),
|
||||
embeddingModel: config.embedding_model,
|
||||
defaultModel: resolveComplexityDefaultModel(tiers, effectiveDefaultModel),
|
||||
};
|
||||
return buildAutoRouterTestTargets(testTargetParams);
|
||||
};
|
||||
|
||||
export default function ModelInfoView({
|
||||
modelId,
|
||||
onClose,
|
||||
|
|
|
|||
|
|
@ -2374,6 +2374,33 @@ export type AutoRouterRoutingTestResponse =
|
|||
| { status: "success"; result: AutoRouterRoutingTestResult }
|
||||
| { status: "error"; error: string };
|
||||
|
||||
export interface AutoRouterConfigValidationResult {
|
||||
valid: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run the backend's own config validator, so a form can surface the exact verdict the save
|
||||
* would produce. A transport failure resolves valid so a flaky network cannot block a save the
|
||||
* write gate would accept; the write path still runs the same validator authoritatively.
|
||||
*/
|
||||
export const validateAutoRouterConfig = async (
|
||||
accessToken: string,
|
||||
complexityRouterConfig: ComplexityRouterConfigPayload | Record<string, unknown>,
|
||||
teamId?: string,
|
||||
): Promise<AutoRouterConfigValidationResult> => {
|
||||
try {
|
||||
return await apiClient.post<AutoRouterConfigValidationResult>("/auto_router/validate_complexity_router_config", {
|
||||
accessToken,
|
||||
body: { complexity_router_config: complexityRouterConfig, ...(teamId && { team_id: teamId }) },
|
||||
});
|
||||
} catch (error) {
|
||||
// Fail open: a transport failure must not block a save the write gate would accept.
|
||||
console.warn("auto_router/validate_complexity_router_config unreachable, deferring to the write gate", error);
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
};
|
||||
|
||||
export const testAutoRouterRouting = async (
|
||||
accessToken: string,
|
||||
request: AutoRouterRoutingTestRequest,
|
||||
|
|
|
|||
|
|
@ -192,6 +192,8 @@ export const getMissingModelsInPreset = (preset: AutoRouterPreset, availability:
|
|||
export const getReferencedModelsError = (
|
||||
params: {
|
||||
tiers: ComplexityTiers;
|
||||
/** Models referenced by custom tiers from an edited tier set. */
|
||||
additionalModels?: string[];
|
||||
classifierType: ClassifierType;
|
||||
classifierLlmConfig: ClassifierLLMConfig | undefined;
|
||||
semanticMatchingEnabled: boolean;
|
||||
|
|
@ -209,7 +211,11 @@ export const getReferencedModelsError = (
|
|||
},
|
||||
availability,
|
||||
);
|
||||
return missing.length > 0 ? `Model(s) no longer available: ${missing.join(", ")}` : null;
|
||||
const missingCustom = (params.additionalModels ?? []).filter(
|
||||
(model) => model.trim() && resolveAvailableModel(model, availability) === undefined,
|
||||
);
|
||||
const allMissing = [...new Set([...missing, ...missingCustom])].sort();
|
||||
return allMissing.length > 0 ? `Model(s) no longer available: ${allMissing.join(", ")}` : null;
|
||||
};
|
||||
|
||||
// Every piece of AddAutoRouterTab's config state that a preset (or a reset to Custom) prefills in
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue