mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(ui): edit the auto-router tier set with custom classifier-defined tiers
The editor over the model layer beneath it. An Edit tiers button turns the tier list into an editor: a tier takes a name, a classifier definition, and models, between two and eight rows. Restore defaults resets to the built-in four rather than stacking them on top. Keyword rules follow a rename, an orphaned rule blocks the save, and both forms dry-run the exact payload against the backend validator before writing. The edit modal hydrates a stored custom set into rows, and an untouched open-and-save round-trips byte-identically, per-model reasoning efforts included. A form that never opens the editor submits the same bytes as before. The cost-optimization tier chart renders arbitrary tier names: the guard that returned no models for a non-built-in name is gone, and the fixed four-color array gives way to the shared cycle.
This commit is contained in:
parent
3002994c0e
commit
dee48284b8
13 changed files with 788 additions and 236 deletions
|
|
@ -32,6 +32,8 @@ vi.mock("@/components/shared/charts", () => ({
|
|||
DonutChart: () => <div />,
|
||||
BarChart: () => <div />,
|
||||
CustomLegend: () => <div />,
|
||||
chartColorValue: (color: string) => color,
|
||||
DEFAULT_COLOR_CYCLE: ["blue", "cyan", "sky", "indigo", "violet", "purple", "fuchsia", "slate"],
|
||||
SEQUENTIAL_COLOR_RAMP: ["indigo"],
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useMod
|
|||
|
||||
vi.mock("@/components/shared/charts", () => ({
|
||||
DonutChart: ({ label }: { label: string }) => <div data-testid="donut">{label}</div>,
|
||||
DEFAULT_COLOR_CYCLE: ["blue", "cyan", "sky", "indigo", "violet", "purple", "fuchsia", "slate"],
|
||||
SEQUENTIAL_COLOR_RAMP: ["indigo", "blue"],
|
||||
chartColorValue: (color: string) => color,
|
||||
}));
|
||||
|
|
@ -111,6 +112,19 @@ describe("TierTurnsChart", () => {
|
|||
expect(screen.getByText("gpt-4o-mini")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists a custom tier's models, which the built-in name guard used to hide", () => {
|
||||
render(
|
||||
<TierTurnsChart
|
||||
view={groupView({ tier_turns: { CASUAL: 3, SECURITY_REVIEW: 1 } })}
|
||||
autoRouters={[deployment({ tiers: { CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["o1-preview"] } })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/SECURITY_REVIEW/)).toBeInTheDocument();
|
||||
expect(screen.getByText("o1-preview")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-4o-mini")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the model line for a tier with no configured models", () => {
|
||||
render(<TierTurnsChart view={groupView()} autoRouters={[deployment({ tiers: { SIMPLE: [] } })]} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
type ComplexityTiers,
|
||||
} from "@/components/add_model/ComplexityRouterConfig";
|
||||
import { normalizeTierModels } from "@/components/add_model/complexity_router_tiers";
|
||||
import { chartColorValue, DonutChart, type ChartColor } from "@/components/shared/charts";
|
||||
import { chartColorValue, DEFAULT_COLOR_CYCLE, DonutChart } from "@/components/shared/charts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
import { viewGroup, type BenchmarkView } from "./autoRouterBenchmarks";
|
||||
|
|
@ -71,7 +71,6 @@ const tierModelsFor = (
|
|||
routerType: string,
|
||||
autoRouters: readonly AutoRouterDeployment[],
|
||||
): string[] => {
|
||||
if (!isComplexityTier(tier)) return [];
|
||||
const deployment = deploymentFor(routerName, routerType, autoRouters);
|
||||
if (!deployment) return [];
|
||||
const config = asRecord(deployment.litellm_params?.complexity_router_config);
|
||||
|
|
@ -84,8 +83,6 @@ interface TierTurnsChartProps {
|
|||
autoRouters: readonly AutoRouterDeployment[];
|
||||
}
|
||||
|
||||
const TIER_DONUT_COLORS: readonly ChartColor[] = ["#c7d2fe", "#1e293b", "#d4b483", "#87a878"];
|
||||
|
||||
const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) => {
|
||||
const group = viewGroup(view);
|
||||
const entries = Object.entries(group?.tier_turns ?? {}).filter(([, turns]) => turns > 0);
|
||||
|
|
@ -98,7 +95,7 @@ const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters }) =>
|
|||
turns,
|
||||
models: tierModelsFor(tier, group.router_name, group.router_type, autoRouters),
|
||||
}));
|
||||
const colors = slices.map((_, idx) => TIER_DONUT_COLORS[idx % TIER_DONUT_COLORS.length]);
|
||||
const colors = slices.map((_, idx) => DEFAULT_COLOR_CYCLE[idx % DEFAULT_COLOR_CYCLE.length]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import React from "react";
|
||||
import ClassifierPromptEditor from "./ClassifierPromptEditor";
|
||||
import { Restricted, RestrictedSection, restrictedBy } from "./TierRestrictions";
|
||||
import HeuristicScoringConfig from "./HeuristicScoringConfig";
|
||||
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
|
||||
import {
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
usesLlmClassifier,
|
||||
DEFAULT_HEURISTIC_FIRST_MAX_TIER,
|
||||
HEURISTIC_FIRST_MAX_TIER_KEYS,
|
||||
effectiveClassifierType,
|
||||
} from "./ComplexityRouterConfig";
|
||||
|
||||
const DEFAULT_SCORING_EXPLANATION =
|
||||
|
|
@ -89,18 +91,22 @@ const boundaryRanges = (
|
|||
const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => {
|
||||
// The shipped boundaries come from the proxy, so this card cannot state ranges the router stopped using.
|
||||
const { data: scorerDefaults, isError } = useComplexityScorerDefaults();
|
||||
const scorerRuns = heuristicScoringRole(value) !== "never";
|
||||
const ranges = boundaryRanges(
|
||||
scorerDefaults?.tier_boundaries,
|
||||
value.tier_boundaries,
|
||||
value.reasoning_override_min_score,
|
||||
);
|
||||
|
||||
// The whole card describes the heuristic scorer, which an edited tier set replaces outright.
|
||||
if (value.custom_tier_set) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-muted mt-4">
|
||||
<CardContent>
|
||||
<strong className="block mb-2 font-semibold">How Classification Works</strong>
|
||||
<span className="text-[13px] text-muted-foreground">{scoringExplanation(value)}</span>
|
||||
{ranges && (
|
||||
{scorerRuns && ranges && (
|
||||
<ul style={{ marginTop: 8, marginBottom: 0, paddingLeft: 20, fontSize: 13, color: "rgba(0, 0, 0, 0.45)" }}>
|
||||
<li>
|
||||
<strong>{effectiveTierLabel("SIMPLE", value.tier_labels)}</strong>: Score < {ranges.simpleMedium}
|
||||
|
|
@ -151,8 +157,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
defaultModel,
|
||||
}) => {
|
||||
const hasDefaultModel = Boolean(defaultModel);
|
||||
const classifierType = effectiveClassifierType(value);
|
||||
const classifierModelMissing =
|
||||
showValidationErrors && usesLlmClassifier(value.classifier_type) && !value.classifier_llm_config?.model;
|
||||
showValidationErrors && usesLlmClassifier(classifierType) && !value.classifier_llm_config?.model;
|
||||
const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim());
|
||||
const contextBudget = value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS;
|
||||
const contextBudgetQuotesNothing = contextBudget > 0 && contextBudget < MIN_QUOTED_CONTEXT_TURN_CHARS;
|
||||
|
|
@ -265,20 +272,22 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
return (
|
||||
<>
|
||||
<RadioGroup
|
||||
value={value.classifier_type}
|
||||
value={classifierType}
|
||||
onValueChange={(classifierType: unknown) => handleClassifierTypeChange(classifierType as ClassifierType)}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="heuristic" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
(default), rule-based scoring with no API calls and <1ms latency
|
||||
<SimpleTooltip content={restrictedBy(value, "heuristicClassifier")?.reason}>
|
||||
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
|
||||
<RadioGroupItem value="heuristic" className="mt-0.5" disabled={Boolean(value.custom_tier_set)} />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
(default), rule-based scoring with no API calls and <1ms latency
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</Label>
|
||||
</SimpleTooltip>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="llm" className="mt-0.5" />
|
||||
<span>
|
||||
|
|
@ -286,19 +295,21 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
<span className="text-muted-foreground">calls a model to decide the tier (e.g. a small/fast model)</span>
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="heuristic_first" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic first</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
scores locally, and only pays for the classifier when the score does not confidently land a cheap tier
|
||||
<SimpleTooltip content={restrictedBy(value, "heuristicClassifier")?.reason}>
|
||||
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
|
||||
<RadioGroupItem value="heuristic_first" className="mt-0.5" disabled={Boolean(value.custom_tier_set)} />
|
||||
<span>
|
||||
<strong className="font-semibold">Heuristic first</strong>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
scores locally, and only pays for the classifier when the score does not confidently land a cheap tier
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
</Label>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{value.classifier_type === "heuristic_first" && (
|
||||
{classifierType === "heuristic_first" && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<strong className="block font-semibold">Decide locally up to</strong>
|
||||
<Select
|
||||
|
|
@ -323,7 +334,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{usesLlmClassifier(value.classifier_type) && (
|
||||
{usesLlmClassifier(classifierType) && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Classifier Model</strong>
|
||||
|
|
@ -361,7 +372,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</SimpleTooltip>
|
||||
</div>
|
||||
<SimpleTooltip
|
||||
content={usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined}
|
||||
content={
|
||||
restrictedBy(value, "classificationRubric")?.reason ??
|
||||
(usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<Select
|
||||
|
|
@ -373,7 +387,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
onValueChange={(preset: ClassificationRubric | null) =>
|
||||
preset && handleClassificationRubricChange(preset)
|
||||
}
|
||||
disabled={usesCustomPrompt}
|
||||
disabled={usesCustomPrompt || Boolean(value.custom_tier_set)}
|
||||
>
|
||||
<SelectTrigger aria-label="Classification Rubric" className="w-full">
|
||||
<SelectValue />
|
||||
|
|
@ -388,23 +402,25 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</Select>
|
||||
</SimpleTooltip>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{usesCustomPrompt
|
||||
? "Not in use: the custom prompt below is the classifier's entire rubric."
|
||||
: CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description}
|
||||
{restrictedBy(value, "classificationRubric")?.reason ??
|
||||
(usesCustomPrompt
|
||||
? "Not in use: the custom prompt below is the classifier's entire rubric."
|
||||
: CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Classifier Prompt</strong>
|
||||
<ClassifierPromptEditor
|
||||
systemPrompt={value.classifier_llm_config?.system_prompt}
|
||||
onChange={handleClassifierSystemPromptChange}
|
||||
contextWindowSize={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
|
||||
tierLabels={value.tier_labels}
|
||||
classificationRubric={classificationRubric}
|
||||
/>
|
||||
<Restricted by={restrictedBy(value, "classifierPrompt")}>
|
||||
<ClassifierPromptEditor
|
||||
systemPrompt={value.classifier_llm_config?.system_prompt}
|
||||
onChange={handleClassifierSystemPromptChange}
|
||||
contextWindowSize={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
|
||||
tierLabels={value.tier_labels}
|
||||
classificationRubric={classificationRubric}
|
||||
/>
|
||||
</Restricted>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">If the classifier fails</strong>
|
||||
<RestrictedSection heading="If the classifier fails" by={restrictedBy(value, "classifierFallback")}>
|
||||
<RadioGroup
|
||||
value={value.classifier_fallback ?? DEFAULT_CLASSIFIER_FALLBACK}
|
||||
onValueChange={(fallback: unknown) => handleClassifierFallbackChange(fallback as ClassifierFallback)}
|
||||
|
|
@ -439,7 +455,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
<span className="block text-xs text-muted-foreground">
|
||||
Applies when the classifier call errors, times out, or returns an unparseable response.
|
||||
</span>
|
||||
</div>
|
||||
</RestrictedSection>
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">Context Window Size</strong>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1056,3 +1056,216 @@ describe("ComplexityRouterConfig custom technical keywords", () => {
|
|||
expect(screen.queryByText("Custom Technical Keywords")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplexityRouterConfig tier editing", () => {
|
||||
const renderEditor = (
|
||||
value?: ComplexityRouterConfigValue,
|
||||
props: Partial<React.ComponentProps<typeof ComplexityRouterConfig>> = {},
|
||||
) => {
|
||||
const onChange = vi.fn();
|
||||
const view = renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
{...(value ? { value } : {})}
|
||||
onChange={onChange}
|
||||
editingTiers
|
||||
onEditingTiersChange={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { ...view, committed: () => onChange.mock.calls[0][0] as ComplexityRouterConfigValue, onChange };
|
||||
};
|
||||
|
||||
const customValue: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-4", timeout_ms: 3000 },
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
{ id: "CASUAL", name: "CASUAL", definition: "small talk", models: ["gpt-3.5-turbo"] },
|
||||
{ id: "sec", name: "SECURITY_REVIEW", definition: "audits", models: ["gpt-4"] },
|
||||
],
|
||||
fallback_tier_id: "CASUAL",
|
||||
},
|
||||
};
|
||||
|
||||
it("offers Edit tiers only when the parent owns the editor flag", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
|
||||
expect(screen.queryByRole("button", { name: "Edit tiers" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the four built-in tiers before any edit, unchanged", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Edit tiers" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Tier 1 of 4", { exact: false })).toHaveTextContent("SIMPLE");
|
||||
});
|
||||
|
||||
it("adds a row and moves the form into an edited tier set, which the built-in record never leaves", () => {
|
||||
const { committed } = renderEditor();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add tier" }));
|
||||
const next = committed();
|
||||
expect(next.custom_tier_set?.tiers).toHaveLength(5);
|
||||
expect(next.tiers).toEqual(defaultValue.tiers);
|
||||
});
|
||||
|
||||
it("renames a built-in tier straight from the editor, which is what makes the set custom", () => {
|
||||
const { committed } = renderEditor();
|
||||
fireEvent.change(screen.getByLabelText("Name for tier 3"), { target: { value: "SECURITY_REVIEW" } });
|
||||
const next = committed();
|
||||
expect(next.custom_tier_set?.tiers.map((row) => row.name)).toEqual([
|
||||
"SIMPLE",
|
||||
"MEDIUM",
|
||||
"SECURITY_REVIEW",
|
||||
"REASONING",
|
||||
]);
|
||||
expect(next.tiers).toEqual(defaultValue.tiers);
|
||||
});
|
||||
|
||||
it("opening the editor and changing nothing leaves the router on the built-in tiers", () => {
|
||||
const { onChange } = renderEditor();
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeEnabled();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swaps the display-name field for the tier-name field while the editor is open", () => {
|
||||
const { rerender } = renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Display name for the Simple tier")).toBeInTheDocument();
|
||||
rerender(<ComplexityRouterConfig {...baseProps} editingTiers onEditingTiersChange={vi.fn()} />);
|
||||
expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Name for tier 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces the prompt editor with the reason an edited tier set forbids it", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByText("A replacement prompt drops the tier bullets", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Change default prompt" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops the scorer card entirely once an edited tier set replaces the heuristic", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.queryByText("How Classification Works")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("scores each request across 7 dimensions", { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the scorer card on a built-in router, whose tiers the score still decides", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByText("How Classification Works")).toBeInTheDocument();
|
||||
expect(screen.getByText("scores each request across 7 dimensions", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says why a custom row is blocked instead of only reddening its border", () => {
|
||||
const missingDefinition: ComplexityRouterConfigValue = {
|
||||
...customValue,
|
||||
custom_tier_set: {
|
||||
tiers: [customValue.custom_tier_set!.tiers[0], { id: "b", name: "AUDIT", definition: "", models: ["gpt-4"] }],
|
||||
fallback_tier_id: "CASUAL",
|
||||
},
|
||||
};
|
||||
renderEditor(missingDefinition, { showValidationErrors: true });
|
||||
expect(screen.getByText("A definition is required", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Done disabled while a row is incomplete and says what is missing", async () => {
|
||||
const incomplete: ComplexityRouterConfigValue = {
|
||||
...customValue,
|
||||
custom_tier_set: {
|
||||
tiers: [customValue.custom_tier_set!.tiers[0], { id: "new", name: "", definition: "", models: [] }],
|
||||
fallback_tier_id: "CASUAL",
|
||||
},
|
||||
};
|
||||
renderEditor(incomplete);
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables Done once every row carries a name, a definition and a model", () => {
|
||||
renderEditor(customValue);
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("refuses to remove a row that would take the set below the backend's minimum", () => {
|
||||
renderEditor(customValue);
|
||||
expect(screen.getByRole("button", { name: "Remove the CASUAL tier" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps a definition on one line, because the backend rejects a newline in it", () => {
|
||||
const { committed } = renderEditor(customValue);
|
||||
fireEvent.change(screen.getByLabelText("Definition for tier 2"), { target: { value: "audits\nand reviews" } });
|
||||
const next = committed();
|
||||
expect(next.custom_tier_set?.tiers[1].definition).toBe("audits and reviews");
|
||||
});
|
||||
|
||||
it("moves a keyword rule with the tier it points at when that tier is renamed", () => {
|
||||
const onKeywordTierRulesChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
value={customValue}
|
||||
keywordTierRules={[{ id: "r1", keywords: ["audit"], tier: "SECURITY_REVIEW" }]}
|
||||
onKeywordTierRulesChange={onKeywordTierRulesChange}
|
||||
editingTiers
|
||||
onEditingTiersChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Name for tier 2"), { target: { value: "AUDIT" } });
|
||||
expect(onKeywordTierRulesChange).toHaveBeenCalledWith([{ id: "r1", keywords: ["audit"], tier: "AUDIT" }]);
|
||||
});
|
||||
|
||||
it("re-points the fallback tier when the row it named is removed, never leaving it dangling", () => {
|
||||
const threeRows: ComplexityRouterConfigValue = {
|
||||
...customValue,
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
...customValue.custom_tier_set!.tiers,
|
||||
{ id: "third", name: "MEDIUM", definition: "", models: ["gpt-4"] },
|
||||
],
|
||||
fallback_tier_id: "sec",
|
||||
},
|
||||
};
|
||||
const { committed } = renderEditor(threeRows);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove the SECURITY_REVIEW tier" }));
|
||||
const next = committed();
|
||||
expect(next.custom_tier_set?.tiers.some((row) => row.id === next.custom_tier_set?.fallback_tier_id)).toBe(true);
|
||||
});
|
||||
|
||||
it("turns off a plan-mode floor whose row was removed, rather than leaving it pointing at nothing", () => {
|
||||
const withFloor: ComplexityRouterConfigValue = {
|
||||
...customValue,
|
||||
plan_mode_min_tier: "sec",
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
...customValue.custom_tier_set!.tiers,
|
||||
{ id: "third", name: "BULK", definition: "d", models: ["gpt-4"] },
|
||||
],
|
||||
fallback_tier_id: "CASUAL",
|
||||
},
|
||||
};
|
||||
const { committed } = renderEditor(withFloor);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove the SECURITY_REVIEW tier" }));
|
||||
expect(committed().plan_mode_min_tier).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replaces the display-name inputs with the reason an edited tier set forbids them", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
|
||||
expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Display names rename the built-in tiers", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Fallback tier")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables session pinning and says why, rather than letting a stripped value look saved", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} value={customValue} onEditingTiersChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Advanced: Affinity"));
|
||||
expect(screen.getByLabelText("Pin a session to its first model")).toHaveAttribute("data-disabled");
|
||||
expect(
|
||||
screen.getByText("Session pinning escalates along the built-in tier ladder", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves built-in routers with their display-name inputs and no restriction copy", () => {
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} onEditingTiersChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Display name for the Simple tier")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Display names rename the built-in tiers", { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,32 +2,50 @@ import { SimpleTooltip } from "@/components/ui/tooltip";
|
|||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ChevronRight, Info, X } from "lucide-react";
|
||||
import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
type CustomTierSet,
|
||||
type TierRow,
|
||||
MAX_TIER_COUNT,
|
||||
MAX_TIER_DEFINITION_CHARS,
|
||||
MAX_TIER_NAME_CHARS,
|
||||
MIN_TIER_COUNT,
|
||||
TIER_ORDER,
|
||||
activeTierName,
|
||||
activeTierRows,
|
||||
getCustomTierRowsError,
|
||||
isBuiltInTierName,
|
||||
resolveComplexityDefaultModel,
|
||||
} from "./tier_rows";
|
||||
import React from "react";
|
||||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
|
||||
import ClassificationMethodConfig from "./ClassificationMethodConfig";
|
||||
import { Restricted, restrictedBy } from "./TierRestrictions";
|
||||
import { type TierSetAction, applyTierSetAction, setFallbackTier } from "./tier_set_actions";
|
||||
import {
|
||||
REASONING_EFFORT_OPTIONS,
|
||||
ReasoningEffort,
|
||||
TierModelParamsByTier,
|
||||
pruneTierModelParams,
|
||||
setTierModelReasoningEffort,
|
||||
tierRowLabel,
|
||||
} from "./complexity_router_tiers";
|
||||
import TierModelEffortRows from "./TierModelEffortRows";
|
||||
import EscalationKeywords from "./EscalationKeywords";
|
||||
import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules";
|
||||
import SemanticKeywordMatching from "./SemanticKeywordMatching";
|
||||
import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from "./heuristic_scoring_knobs";
|
||||
import { type CustomTierSet, type TierRow, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows";
|
||||
export type { CustomTierSet, TierRow } from "./tier_rows";
|
||||
|
||||
export type { DimensionWeights, TierBoundaries, TokenThresholds };
|
||||
export type { CustomTierSet, TierRow } from "./tier_rows";
|
||||
|
||||
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000;
|
||||
export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5;
|
||||
|
|
@ -141,12 +159,39 @@ export const effectiveClassifierType = (
|
|||
value: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type">,
|
||||
): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type);
|
||||
|
||||
const rowOrigin = (row: TierRow, editing: boolean): string => {
|
||||
if (!editing) return row.id;
|
||||
return isBuiltInTierName(row.name) ? "built-in" : "custom";
|
||||
};
|
||||
|
||||
const TierRowSelect: React.FC<{
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
value: string | null;
|
||||
onValueChange: (rowId: string) => void;
|
||||
placeholder?: string;
|
||||
}> = ({ label, options, value, onValueChange, placeholder }) => (
|
||||
<Select items={options} value={value} onValueChange={(rowId: string | null) => rowId && onValueChange(rowId)}>
|
||||
<SelectTrigger aria-label={label} className="w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
export type AdaptiveEligible = "all" | "classified_tier";
|
||||
|
||||
export type ComplexityTierLabels = Partial<Record<keyof ComplexityTiers, string>>;
|
||||
|
||||
export interface ComplexityRouterConfigValue {
|
||||
tiers: ComplexityTiers;
|
||||
custom_tier_set?: CustomTierSet;
|
||||
tier_labels?: ComplexityTierLabels;
|
||||
/** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */
|
||||
default_model?: string;
|
||||
|
|
@ -161,7 +206,7 @@ export interface ComplexityRouterConfigValue {
|
|||
heuristic_first_max_tier?: string;
|
||||
session_affinity?: boolean;
|
||||
deployment_affinity?: boolean;
|
||||
/** Tier floor for coding-agent plan-mode requests. Unset means detection is off, matching the backend. */
|
||||
/** Plan-mode floor as a tier ROW ID, unset meaning off. The wire carries the row's name. */
|
||||
plan_mode_min_tier?: string;
|
||||
adaptive?: boolean;
|
||||
adaptive_weights?: AdaptiveRouterWeights;
|
||||
|
|
@ -185,7 +230,6 @@ export interface ComplexityRouterConfigValue {
|
|||
* params object is held, not just reasoning_effort, so keys authored in config.yaml survive an
|
||||
* edit round-trip.
|
||||
*/
|
||||
custom_tier_set?: CustomTierSet;
|
||||
tier_model_params?: TierModelParamsByTier;
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +237,9 @@ interface ComplexityRouterConfigProps {
|
|||
modelInfo: ModelGroup[];
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
/** Parent-owned: this component unmounts when its section collapses. */
|
||||
editingTiers?: boolean;
|
||||
onEditingTiersChange?: (editing: boolean) => void;
|
||||
customTechnicalKeywords?: string[];
|
||||
onCustomTechnicalKeywordsChange?: (keywords: string[]) => void;
|
||||
// Optional: the edit-auto-router modal doesn't yet support editing keyword tier
|
||||
|
|
@ -268,14 +315,16 @@ const AffinityControls: React.FC<{
|
|||
</span>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
|
||||
checked={value.custom_tier_set ? false : value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
|
||||
disabled={Boolean(value.custom_tier_set)}
|
||||
onCheckedChange={(sessionAffinity) => onChange({ ...value, session_affinity: sessionAffinity })}
|
||||
aria-label="Pin a session to its first model"
|
||||
/>
|
||||
<strong className="font-semibold">Pin a session to its first model</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment.
|
||||
{restrictedBy(value, "sessionAffinity")?.reason ??
|
||||
"Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment."}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
|
@ -307,22 +356,12 @@ const PlanModeOverrideControls: React.FC<{
|
|||
</span>
|
||||
{value.plan_mode_min_tier !== undefined && (
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Select
|
||||
items={planModeTierOptions}
|
||||
value={value.plan_mode_min_tier}
|
||||
onValueChange={(tier: string | null) => tier && onChange({ ...value, plan_mode_min_tier: tier })}
|
||||
>
|
||||
<SelectTrigger aria-label="Plan-mode minimum tier" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{planModeTierOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TierRowSelect
|
||||
label="Plan-mode minimum tier"
|
||||
options={planModeTierOptions}
|
||||
value={value.plan_mode_min_tier ?? null}
|
||||
onValueChange={(tier) => onChange({ ...value, plan_mode_min_tier: tier })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -351,6 +390,8 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
modelInfo,
|
||||
value,
|
||||
onChange,
|
||||
editingTiers,
|
||||
onEditingTiersChange,
|
||||
customTechnicalKeywords,
|
||||
onCustomTechnicalKeywordsChange,
|
||||
keywordTierRules = [],
|
||||
|
|
@ -365,13 +406,34 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
onEscalationKeywordsChange,
|
||||
showValidationErrors = false,
|
||||
}) => {
|
||||
const customTierSet = value.custom_tier_set;
|
||||
const tierRows = activeTierRows(value);
|
||||
const planModeTierOptions = tierRows
|
||||
.filter((row) => row.models.length > 0)
|
||||
.map((row) => ({ value: row.id, label: effectiveTierLabel(row.id as keyof ComplexityTiers, value.tier_labels) }));
|
||||
const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null;
|
||||
|
||||
const planModeRows = tierRows.filter((row) => row.models.length > 0);
|
||||
const planModeTierOptions = planModeRows.map((row) => ({
|
||||
value: row.id,
|
||||
label: tierRowLabel(row, value.tier_labels),
|
||||
}));
|
||||
const derivedDefaultModel = resolveComplexityDefaultModel(value);
|
||||
const emptyTiersHint = customTierSet
|
||||
? "Add a model to your fallback tier"
|
||||
: "Add a model to the Simple or Medium tier";
|
||||
const defaultModelPlaceholder = derivedDefaultModel ? `Derived from tiers: ${derivedDefaultModel}` : emptyTiersHint;
|
||||
const defaultModel = resolveComplexityDefaultModel(value, value.default_model);
|
||||
|
||||
const dispatch = (action: TierSetAction) => {
|
||||
const next = applyTierSetAction(value, keywordTierRules, action);
|
||||
if (next.keywordTierRules !== keywordTierRules) onKeywordTierRulesChange?.([...next.keywordTierRules]);
|
||||
onChange(next.value);
|
||||
};
|
||||
|
||||
const setRowModels = (row: TierRow, models: string[]) => dispatch({ kind: "models", id: row.id, models });
|
||||
const updateTierRow = (id: string, patch: Partial<Omit<TierRow, "id">>) => dispatch({ kind: "patch", id, patch });
|
||||
const addCustomTier = () => dispatch({ kind: "add" });
|
||||
const removeTierRow = (id: string) => dispatch({ kind: "remove", id });
|
||||
const exitToBuiltInTiers = () => dispatch({ kind: "restore" });
|
||||
|
||||
// An absent list means the proxy does not send the field yet, so every level is offered as before.
|
||||
// An empty list is the group's own answer that its deployments share no level, and is left empty.
|
||||
const effortOptionsByModel: Record<string, string[]> = Object.fromEntries(
|
||||
|
|
@ -389,19 +451,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
label: model.model_group,
|
||||
}));
|
||||
|
||||
const handleTierChange = (tier: keyof ComplexityTiers, models: string[]) => {
|
||||
onChange({
|
||||
...value,
|
||||
tiers: { ...value.tiers, [tier]: models },
|
||||
tier_model_params: pruneTierModelParams(value.tier_model_params, tier, models),
|
||||
});
|
||||
};
|
||||
|
||||
const handleTierModelEffortChange = (
|
||||
tier: keyof ComplexityTiers,
|
||||
model: string,
|
||||
effort: ReasoningEffort | undefined,
|
||||
) => {
|
||||
const handleTierModelEffortChange = (tier: string, model: string, effort: ReasoningEffort | undefined) => {
|
||||
onChange({
|
||||
...value,
|
||||
tier_model_params: setTierModelReasoningEffort(value.tier_model_params, tier, model, effort),
|
||||
|
|
@ -431,61 +481,120 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
</div>
|
||||
|
||||
<span className="block mb-6 text-muted-foreground">
|
||||
The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls,
|
||||
<1ms latency). Configure which model(s) handle each tier.
|
||||
{heuristicScoringRole(value) === "never"
|
||||
? "The complexity router classifies each request with your classifier model and routes it to that tier. Configure which model(s) handle each tier."
|
||||
: "The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls, <1ms latency). Configure which model(s) handle each tier."}
|
||||
</span>
|
||||
|
||||
<span className="block mb-4 text-xs text-muted-foreground">
|
||||
Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how
|
||||
requests are classified, and callers never see these names.
|
||||
{usesLlmClassifier(value.classifier_type) &&
|
||||
{restrictedBy(value, "displayNames")?.reason ??
|
||||
"Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how requests are classified, and callers never see these names."}
|
||||
{!customTierSet &&
|
||||
usesLlmClassifier(value.classifier_type) &&
|
||||
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
|
||||
</span>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
{tierRows.map((row: TierRow, index) => {
|
||||
const tier = row.id as keyof ComplexityTiers;
|
||||
const tierInfo = TIER_DESCRIPTIONS[tier];
|
||||
const label = effectiveTierLabel(tier, value.tier_labels);
|
||||
{tierRows.map((row, index) => {
|
||||
const builtIn = TIER_ORDER.find((tier) => tier === row.id);
|
||||
const tierInfo = builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined;
|
||||
const label = tierRowLabel(row, value.tier_labels);
|
||||
const tierMissing = showValidationErrors && row.models.length === 0;
|
||||
const definitionMissing =
|
||||
showValidationErrors && Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name);
|
||||
return (
|
||||
<div key={row.id}>
|
||||
{index > 0 && <Separator className="my-4" />}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">{label} Tier</strong>
|
||||
<SimpleTooltip content={tierInfo.description}>
|
||||
<SimpleTooltip
|
||||
content={
|
||||
row.definition.trim() ||
|
||||
tierInfo?.description ||
|
||||
"A tier you defined. The classifier routes requests matching its definition here."
|
||||
}
|
||||
>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tier {index + 1} of {tierRows.length} · {row.id}
|
||||
Tier {index + 1} of {tierRows.length} · {rowOrigin(row, Boolean(customTierSet))}
|
||||
</span>
|
||||
</div>
|
||||
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
|
||||
<InputGroup className="mb-2">
|
||||
<InputGroupInput
|
||||
value={value.tier_labels?.[tier] ?? ""}
|
||||
onChange={(event) => handleTierLabelChange(tier, event.target.value)}
|
||||
placeholder={`Display name (default: ${tierInfo.label})`}
|
||||
aria-label={`Display name for the ${tierInfo.label} tier`}
|
||||
/>
|
||||
{value.tier_labels?.[tier] && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Clear display name for the ${tierInfo.label} tier`}
|
||||
onClick={() => handleTierLabelChange(tier, "")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
{editingTiers && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
aria-label={`Remove the ${activeTierName(row) || `tier ${index + 1}`} tier`}
|
||||
disabled={tierRows.length <= MIN_TIER_COUNT}
|
||||
onClick={() => removeTierRow(row.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</InputGroup>
|
||||
</div>
|
||||
{tierInfo && !customTierSet && (
|
||||
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
|
||||
)}
|
||||
{editingTiers && (
|
||||
<>
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) => updateTierRow(row.id, { name: event.target.value })}
|
||||
placeholder="Tier name, e.g. SECURITY_REVIEW"
|
||||
aria-label={`Name for tier ${index + 1}`}
|
||||
maxLength={MAX_TIER_NAME_CHARS}
|
||||
className="mb-2"
|
||||
/>
|
||||
<Textarea
|
||||
value={row.definition}
|
||||
onChange={(event) =>
|
||||
updateTierRow(row.id, { definition: event.target.value.replace(/[\r\n]+/g, " ") })
|
||||
}
|
||||
placeholder={
|
||||
isBuiltInTierName(row.name)
|
||||
? "Leave blank to keep the built-in definition"
|
||||
: "What belongs in this tier, e.g. requests asking for a security audit"
|
||||
}
|
||||
aria-label={`Definition for tier ${index + 1}`}
|
||||
maxLength={MAX_TIER_DEFINITION_CHARS}
|
||||
rows={2}
|
||||
className={definitionMissing ? "mb-2 border-destructive" : "mb-2"}
|
||||
/>
|
||||
{definitionMissing && (
|
||||
<span className="mb-2 block text-xs text-destructive">
|
||||
A definition is required: it is the rubric the classifier routes on for this tier
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!customTierSet && !editingTiers && tierInfo && (
|
||||
<InputGroup className="mb-2">
|
||||
<InputGroupInput
|
||||
value={value.tier_labels?.[row.id as keyof ComplexityTiers] ?? ""}
|
||||
onChange={(event) => handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)}
|
||||
placeholder={`Display name (default: ${tierInfo.label})`}
|
||||
aria-label={`Display name for the ${tierInfo.label} tier`}
|
||||
/>
|
||||
{value.tier_labels?.[row.id as keyof ComplexityTiers] && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Clear display name for the ${tierInfo.label} tier`}
|
||||
onClick={() => handleTierLabelChange(row.id as keyof ComplexityTiers, "")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
)}
|
||||
<MultiSelect
|
||||
options={modelOptions}
|
||||
value={row.models}
|
||||
onValueChange={(models: string[]) => handleTierChange(tier, models)}
|
||||
onValueChange={(models: string[]) => setRowModels(row, models)}
|
||||
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
|
||||
emptyText="No models found"
|
||||
className={tierMissing ? "w-full border-destructive" : "w-full"}
|
||||
|
|
@ -494,12 +603,12 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
tierLabel={label}
|
||||
models={row.models}
|
||||
effortOptionsByModel={effortOptionsByModel}
|
||||
paramsByModel={value.tier_model_params?.[tier]}
|
||||
onEffortChange={(model, effort) => handleTierModelEffortChange(tier, model, effort)}
|
||||
paramsByModel={row.params}
|
||||
onEffortChange={(model, effort) => handleTierModelEffortChange(row.id, model, effort)}
|
||||
/>
|
||||
{row.models.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Multiple models selected — the router randomly picks among them per request (or Thompson-samples
|
||||
Multiple models selected: the router randomly picks among them per request (or Thompson-samples
|
||||
within the pool when adaptive routing is on).
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -508,6 +617,64 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{editingTiers ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={addCustomTier} disabled={tierRows.length >= MAX_TIER_COUNT}>
|
||||
<Plus />
|
||||
Add tier
|
||||
</Button>
|
||||
<SimpleTooltip content={tierRowsError || undefined}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={Boolean(tierRowsError)}
|
||||
onClick={() => onEditingTiersChange?.(false)}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
{customTierSet && (
|
||||
<Button variant="outline" size="sm" onClick={exitToBuiltInTiers}>
|
||||
Restore defaults
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
onEditingTiersChange && (
|
||||
<Button variant="outline" onClick={() => onEditingTiersChange(true)}>
|
||||
Edit tiers
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{editingTiers && (
|
||||
<span className="block mt-1 text-xs text-muted-foreground">
|
||||
Add or remove tiers to define your own set. Every custom tier needs a definition the LLM classifier routes
|
||||
on, and an edited set requires the LLM classification method
|
||||
</span>
|
||||
)}
|
||||
|
||||
{customTierSet && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">Fallback Tier</strong>
|
||||
<SimpleTooltip content="Where requests route when the LLM classifier errors, times out, or returns an unparseable reply. Required for an edited tier set: the heuristic scorer cannot produce your tiers.">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<TierRowSelect
|
||||
label="Fallback tier"
|
||||
options={tierRows
|
||||
.filter((row) => activeTierName(row))
|
||||
.map((row) => ({ value: row.id, label: activeTierName(row) }))}
|
||||
value={customTierSet.fallback_tier_id || null}
|
||||
onValueChange={(fallbackTierId) => onChange(setFallbackTier(value, fallbackTierId))}
|
||||
placeholder="Pick the tier classifier failures route to"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="mb-2">
|
||||
|
|
@ -521,11 +688,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
options={modelOptions}
|
||||
value={value.default_model ?? ""}
|
||||
onValueChange={handleDefaultModelChange}
|
||||
placeholder={
|
||||
derivedDefaultModel
|
||||
? `Derived from tiers: ${derivedDefaultModel}`
|
||||
: "Add a model to the Simple or Medium tier"
|
||||
}
|
||||
placeholder={defaultModelPlaceholder}
|
||||
emptyText="No models found"
|
||||
aria-label="Default model"
|
||||
/>
|
||||
|
|
@ -559,7 +722,11 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
{
|
||||
key: "adaptive",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Adaptive Routing</strong>,
|
||||
children: <AdaptiveRoutingConfig value={value} onChange={onChange} />,
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "adaptive")}>
|
||||
<AdaptiveRoutingConfig value={value} onChange={onChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "affinity",
|
||||
|
|
@ -583,7 +750,11 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
{
|
||||
key: "escalation",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Escalation Keywords</strong>,
|
||||
children: <EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />,
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "escalation")}>
|
||||
<EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
|
@ -599,6 +770,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
rules={keywordTierRules}
|
||||
onChange={onKeywordTierRulesChange}
|
||||
tierLabels={value.tier_labels}
|
||||
tierNames={customTierSet && tierRows.map(activeTierName).filter(Boolean)}
|
||||
/>
|
||||
)}
|
||||
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,13 @@ interface KeywordTierRulesProps {
|
|||
rules: KeywordTierRule[];
|
||||
onChange: (rules: KeywordTierRule[]) => void;
|
||||
tierLabels?: Partial<Record<ComplexityTier, string>>;
|
||||
tierNames?: string[];
|
||||
}
|
||||
|
||||
// A row exists only because the caller asked for it, so it reports its own gap straight away
|
||||
// rather than waiting for a submit; the submit button is disabled while one is outstanding, so
|
||||
// there is no failed attempt left to surface it.
|
||||
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, tierLabels }) => {
|
||||
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, tierLabels, tierNames }) => {
|
||||
const emptyRuleIndexes = new Set(emptyKeywordTierRuleIndexes(rules));
|
||||
|
||||
const replaceKeywords = (rule: KeywordTierRule) => (keywords: string[]) => {
|
||||
|
|
@ -35,7 +36,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
};
|
||||
|
||||
const addRule = () => {
|
||||
onChange([...rules, { id: `${Date.now()}`, keywords: [], tier: "COMPLEX" }]);
|
||||
onChange([...rules, { id: `${Date.now()}`, keywords: [], tier: tierNames?.[0] ?? "COMPLEX" }]);
|
||||
};
|
||||
|
||||
const updateRule = (id: string, updates: Partial<Omit<KeywordTierRule, "id">>) => {
|
||||
|
|
@ -98,7 +99,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
<div style={{ width: 220 }}>
|
||||
<strong className="mb-2 block font-semibold">Route to tier</strong>
|
||||
<Select
|
||||
items={tierOptions(tierLabels)}
|
||||
items={tierOptions(tierLabels, tierNames)}
|
||||
value={rule.tier}
|
||||
onValueChange={(tier: string | null) => tier && updateRule(rule.id, { tier })}
|
||||
>
|
||||
|
|
@ -106,7 +107,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tierOptions(tierLabels).map((option) => (
|
||||
{tierOptions(tierLabels, tierNames).map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import React from "react";
|
||||
import { CUSTOM_TIER_RESTRICTIONS, CustomTierSet, TierRestriction } from "./tier_rows";
|
||||
|
||||
export const restrictedBy = (
|
||||
value: { custom_tier_set?: CustomTierSet },
|
||||
key: keyof typeof CUSTOM_TIER_RESTRICTIONS,
|
||||
): TierRestriction | undefined => (value.custom_tier_set ? CUSTOM_TIER_RESTRICTIONS[key] : undefined);
|
||||
|
||||
export const Restricted: React.FC<{ by: TierRestriction | undefined; children: React.ReactNode }> = ({
|
||||
by,
|
||||
children,
|
||||
}) => (by ? <span className="block text-sm text-muted-foreground">{by.reason}</span> : <>{children}</>);
|
||||
|
||||
/** A labelled section whose body is replaced by the reason an edited tier set forbids it. */
|
||||
export const RestrictedSection: React.FC<{
|
||||
heading: string;
|
||||
by: TierRestriction | undefined;
|
||||
children: React.ReactNode;
|
||||
}> = ({ heading, by, children }) => (
|
||||
<div>
|
||||
<strong className="block mb-1 font-semibold">{heading}</strong>
|
||||
{by ? <span className="block text-sm text-muted-foreground">{by.reason}</span> : children}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -194,6 +194,9 @@ describe("AddAutoRouterTab", () => {
|
|||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]).toMatchObject({ team_id: "team-1" });
|
||||
});
|
||||
|
||||
// The dry-run exists so a config the write gate would refuse shows the backend's own message
|
||||
// inline instead of coming back as a raw 400. Nothing asserted that its verdict actually stops
|
||||
// the submit, so the whole gate could be deleted with the suite still green.
|
||||
it("does not submit when the backend's dry-run rejects the config", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
|
@ -222,32 +225,6 @@ describe("AddAutoRouterTab", () => {
|
|||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
// A second submit while the dry-run round-trip is pending must not start another create: the
|
||||
// button disables, and the handler itself refuses re-entry since a form submit (Enter) fires it
|
||||
// regardless of the button's disabled state.
|
||||
it("creates the router once when the form is submitted again mid dry-run", async () => {
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
let resolveVerdict: (verdict: { valid: boolean }) => void = () => {};
|
||||
validateAutoRouterConfig.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveVerdict = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = renderWithProviders(<Harness />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/smart_router/i), { target: { value: "double-submit-router" } });
|
||||
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled());
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
|
||||
resolveVerdict({ valid: true });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /add auto router/i })).toBeEnabled());
|
||||
expect(validateAutoRouterConfig).toHaveBeenCalledTimes(1);
|
||||
expect(handleAddAutoRouterSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// LIT-5133: "Add keyword rule" seeds a row with no keywords, and the semantic toggle that used
|
||||
// to be the only thing checking them is off by default. The row was dropped on the way to the
|
||||
// payload, so the create succeeded and the caller's rule was gone with nothing said about it.
|
||||
|
|
@ -651,28 +628,6 @@ describe("AddAutoRouterTab", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Every step between the bundled JSON and the payload drops these params silently.
|
||||
it("carries a preset's per-tier reasoning effort through to the create payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
await waitForPresetEnabled("Anthropic Family");
|
||||
await selectTemplate("Anthropic Family");
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "anthropic-router");
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
complexity_router_config: {
|
||||
tier_model_configs: {
|
||||
REASONING: [{ model_name: "claude-opus-5", litellm_params: { reasoning_effort: "high" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Bugbot-found bug: submitBlockedReason disables the button for this, but Form's onFinish
|
||||
// (wired to the same handler as the button) fires whenever the form itself is submitted,
|
||||
// independent of the button's own disabled state. Without submitRecommendedRouter re-checking
|
||||
|
|
@ -975,6 +930,23 @@ describe("getSubmitBlockedReason", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("blocks an edited tier set with no classifier model, since the set forces the LLM classifier", () => {
|
||||
const config = {
|
||||
tiers,
|
||||
classifier_type: "heuristic" as const,
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
{ id: "a", name: "CASUAL", definition: "d", models: ["gpt-4o-mini"] },
|
||||
{ id: "b", name: "AUDIT", definition: "d", models: ["gpt-4o-mini"] },
|
||||
],
|
||||
fallback_tier_id: "a",
|
||||
},
|
||||
};
|
||||
expect(getSubmitBlockedReason(config, [], referenced, availability)).toContain(
|
||||
"an edited tier set routes with the LLM classifier",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks a keyword rule aimed at a tier this router does not have", () => {
|
||||
const rules = [{ id: "r1", keywords: ["audit"], tier: "AUDIT" }];
|
||||
expect(getSubmitBlockedReason({ tiers, classifier_type: "heuristic" }, rules, referenced, availability)).toContain(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { ChevronDown, ChevronRight, CircleHelp } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { z } from "zod/v4";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
|
|
@ -14,6 +14,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
|||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox";
|
||||
import { modelAvailableCall, validateAutoRouterConfig } from "../networking";
|
||||
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { type ModelWriteScope } from "@/utils/modelPermissions";
|
||||
import TeamDropdown from "../common_components/team_dropdown";
|
||||
|
|
@ -22,6 +23,7 @@ import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
|
|||
import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import ComplexityRouterConfig, {
|
||||
ComplexityRouterConfigValue,
|
||||
effectiveClassifierType,
|
||||
DEFAULT_ADAPTIVE_WEIGHTS,
|
||||
DEFAULT_SESSION_AFFINITY,
|
||||
DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
|
|
@ -33,17 +35,16 @@ import { DEFAULT_MATCH_THRESHOLD } from "./SemanticKeywordMatching";
|
|||
import {
|
||||
BuildComplexityRouterConfigParams,
|
||||
buildComplexityRouterConfig,
|
||||
dryRunRejection,
|
||||
getKeywordTierRulesError,
|
||||
getClassifierModelError,
|
||||
getMissingTiersError,
|
||||
getPlanModeTierError,
|
||||
getSemanticConfigError,
|
||||
getTierLabelsError,
|
||||
dryRunRejection,
|
||||
} from "./build_complexity_router_config";
|
||||
import { activeTierName, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows";
|
||||
import { DEFAULT_TIER_LABELS } from "./complexity_router_tiers";
|
||||
import type { ComplexityTier } from "./KeywordTierRules";
|
||||
import { activeTierName, activeTierRows, getCustomTierRowsError, resolveComplexityDefaultModel } from "./tier_rows";
|
||||
import { tierRowLabel } from "./complexity_router_tiers";
|
||||
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
|
||||
import AutoRouterConnectionTest from "./auto_router_connection_test";
|
||||
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
|
||||
|
|
@ -110,7 +111,7 @@ const presets = getAllPresets();
|
|||
const tierConfigSummary = (config: ComplexityRouterConfigValue): string => {
|
||||
const parts = activeTierRows(config)
|
||||
.filter((row) => row.models.length > 0)
|
||||
.map((row) => `${DEFAULT_TIER_LABELS[row.id as ComplexityTier] ?? activeTierName(row)}: ${row.models.join(", ")}`);
|
||||
.map((row) => `${tierRowLabel(row, config.tier_labels)}: ${row.models.join(", ")}`);
|
||||
return parts.length > 0 ? parts.join(" · ") : "No tiers configured yet";
|
||||
};
|
||||
|
||||
|
|
@ -124,8 +125,8 @@ export const getSubmitBlockedReason = (
|
|||
referencedModelsParams: Parameters<typeof getReferencedModelsError>[0],
|
||||
availability: ModelAvailability,
|
||||
): string | null =>
|
||||
(config.custom_tier_set ? getCustomTierRowsError(config.custom_tier_set) : getTierLabelsError(config.tier_labels)) ??
|
||||
getMissingTiersError(activeTierRows(config)) ??
|
||||
getTierLabelsError(config.tier_labels) ??
|
||||
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
|
||||
getClassifierModelError(config) ??
|
||||
|
|
@ -146,16 +147,6 @@ const EMPTY_FORM_VALUES: AddAutoRouterFormValues = {
|
|||
model_access_group: undefined,
|
||||
};
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const teamScopePayload = (requiresTeamScope: boolean, teamId: string): { team_id?: string } =>
|
||||
requiresTeamScope ? { team_id: teamId } : {};
|
||||
|
||||
|
|
@ -197,7 +188,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
const [matchThreshold, setMatchThreshold] = useState<number>(DEFAULT_MATCH_THRESHOLD);
|
||||
const [escalationKeywords, setEscalationKeywords] = useState<string[]>(DEFAULT_ESCALATION_KEYWORDS);
|
||||
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [editingTiers, setEditingTiers] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(undefined);
|
||||
// Closed by default: a caller opens it deliberately, either by clicking it or by choosing Custom
|
||||
|
|
@ -297,6 +289,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
);
|
||||
|
||||
const applyPrefill = (prefill: PresetPrefill) => {
|
||||
setEditingTiers(false);
|
||||
setComplexityRouterConfig(prefill.complexityRouterConfig);
|
||||
setCustomTechnicalKeywords(prefill.customTechnicalKeywords);
|
||||
setKeywordTierRules(prefill.keywordTierRules);
|
||||
|
|
@ -327,8 +320,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
};
|
||||
|
||||
const referencedModelsParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
classifierType: complexityRouterConfig.classifier_type,
|
||||
tiers: Object.fromEntries(activeTierRows(complexityRouterConfig).map((row) => [activeTierName(row), row.models])),
|
||||
classifierType: effectiveClassifierType(complexityRouterConfig),
|
||||
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
|
|
@ -344,6 +337,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
|
||||
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
customTierSet: complexityRouterConfig.custom_tier_set,
|
||||
defaultModel: complexityRouterConfig.default_model,
|
||||
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
|
||||
heuristicFirstMaxTier: complexityRouterConfig.heuristic_first_max_tier,
|
||||
|
|
@ -375,8 +369,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
};
|
||||
|
||||
const submitRecommendedRouter = async (name: string) => {
|
||||
const { tiers } = complexityRouterConfigParams;
|
||||
|
||||
// The one answer the submit button reads, so a disabled button and a refused submit cannot
|
||||
// disagree about why. The handler needs it in its own right: the form fires this on Enter
|
||||
// regardless of the button's disabled state.
|
||||
|
|
@ -403,6 +395,10 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// auto_router_default_model (-> litellm_params, read by the backend at init) and
|
||||
// complexity_router_config.default_model (-> the pin marker read back on edit, see
|
||||
// hydratePinnedDefaultModel in edit_auto_router_modal.tsx) must both come from the same
|
||||
// `defaultModel`, or the two fields diverge and hydration's divergence check misfires.
|
||||
const complexityRouterConfigPayload = buildComplexityRouterConfig(complexityRouterConfigParams);
|
||||
const serverVerdict = await validateAutoRouterConfig(
|
||||
accessToken,
|
||||
|
|
@ -416,10 +412,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// auto_router_default_model (-> litellm_params, read by the backend at init) and
|
||||
// complexity_router_config.default_model (-> the pin marker read back on edit, see
|
||||
// hydratePinnedDefaultModel in edit_auto_router_modal.tsx) must both come from the same
|
||||
// `defaultModel`, or the two fields diverge and hydration's divergence check misfires.
|
||||
const submitValues: AddAutoRouterValues = {
|
||||
auto_router_name: name,
|
||||
...teamScopePayload(requiresTeamScope, form.getValues("team_id")),
|
||||
|
|
@ -433,7 +425,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
};
|
||||
|
||||
const handleAutoRouterSubmit = async () => {
|
||||
if (isSubmitting) return;
|
||||
const name = form.getValues("auto_router_name");
|
||||
if (!name) {
|
||||
setShowValidationErrors(true);
|
||||
|
|
@ -579,6 +570,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
{detailsExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
|
|
@ -642,7 +635,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
type="button"
|
||||
variant="outline"
|
||||
data-testid="auto-router-test-routing-btn"
|
||||
disabled={submitBlockedReason !== null}
|
||||
disabled={submitBlockedReason !== null || isSubmitting}
|
||||
onClick={() => setIsRoutingTestVisible(true)}
|
||||
>
|
||||
Test Routing
|
||||
|
|
@ -666,7 +659,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
void handleAutoRouterSubmit();
|
||||
}}
|
||||
>
|
||||
{isSubmitting && <UiLoadingSpinner className="size-4" />}
|
||||
Add Auto Router
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,19 @@ import {
|
|||
type KeywordMatchingState,
|
||||
} from "./edit_auto_router_modal";
|
||||
|
||||
// The custom-tier router these cases round-trip, so a variant differs only by what it overrides.
|
||||
const storedCustomConfig = (overrides: Record<string, unknown> = {}) => ({
|
||||
tiers: { CASUAL: ["gpt-4o-mini"], AUDIT: ["o1"] },
|
||||
tier_definitions: [
|
||||
{ name: "CASUAL", description: "small talk" },
|
||||
{ name: "AUDIT", description: "security review" },
|
||||
],
|
||||
fallback_tier: "CASUAL",
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const STORED = {
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
|
|
@ -476,14 +489,49 @@ describe("managed keys survive an untouched open-and-save", () => {
|
|||
reasoning_override_min_score: 0.3,
|
||||
};
|
||||
|
||||
it("carries every managed key through hydrate then save", () => {
|
||||
// tier_definitions and fallback_tier cannot sit beside heuristic_first, which this fixture uses, so
|
||||
// no single stored config can hold every managed key. They get their own round trip below.
|
||||
const CUSTOM_TIER_ONLY_KEYS = new Set(["tier_definitions", "fallback_tier"]);
|
||||
|
||||
it("carries every managed key a built-in router can hold through hydrate then save", () => {
|
||||
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
|
||||
const saved = buildUpdatedComplexityRouterConfig(STORED_ALL_MANAGED, hydrated);
|
||||
|
||||
const dropped = [...MANAGED_COMPLEXITY_ROUTER_KEYS].filter((key) => saved[key] === undefined);
|
||||
const dropped = [...MANAGED_COMPLEXITY_ROUTER_KEYS]
|
||||
.filter((key) => !CUSTOM_TIER_ONLY_KEYS.has(key))
|
||||
.filter((key) => saved[key] === undefined);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a stored local-scorer threshold when the operator converts the router to custom tiers", () => {
|
||||
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
|
||||
const converted = {
|
||||
...hydrated,
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
{ id: "a", name: "CASUAL", definition: "small talk", models: ["gpt-4o-mini"] },
|
||||
{ id: "b", name: "AUDIT", definition: "security review", models: ["o1"] },
|
||||
],
|
||||
fallback_tier_id: "a",
|
||||
},
|
||||
};
|
||||
const saved = buildUpdatedComplexityRouterConfig(STORED_ALL_MANAGED, converted);
|
||||
|
||||
expect(saved.heuristic_first_max_tier).toBeUndefined();
|
||||
expect(saved.classifier_type).toBe("llm");
|
||||
expect(saved.tier_definitions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("carries the custom-tier keys through their own round trip", () => {
|
||||
const storedCustom = storedCustomConfig();
|
||||
const hydrated = hydrateComplexityRouterConfig(storedCustom, undefined);
|
||||
const saved = buildUpdatedComplexityRouterConfig(storedCustom, hydrated);
|
||||
|
||||
expect(saved.tier_definitions).toEqual(storedCustom.tier_definitions);
|
||||
expect(saved.fallback_tier).toBe("CASUAL");
|
||||
expect(saved.tiers).toEqual(storedCustom.tiers);
|
||||
});
|
||||
|
||||
it("round-trips the heuristic_first threshold, which save requires and the backend rejects without", () => {
|
||||
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
|
||||
expect(hydrated.heuristic_first_max_tier).toBe("SIMPLE");
|
||||
|
|
|
|||
|
|
@ -10,15 +10,13 @@ vi.mock(
|
|||
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
|
||||
);
|
||||
|
||||
const { modelPatchUpdateCall, modelAvailableCall, getAutoRouterClassifierDefaultPromptCall } = vi.hoisted(() => ({
|
||||
modelPatchUpdateCall: vi.fn().mockResolvedValue({}),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getAutoRouterClassifierDefaultPromptCall: vi.fn().mockResolvedValue("Classify the request into exactly one tier."),
|
||||
}));
|
||||
|
||||
const { validateAutoRouterConfig } = vi.hoisted(() => ({
|
||||
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }),
|
||||
}));
|
||||
const { modelPatchUpdateCall, modelAvailableCall, getAutoRouterClassifierDefaultPromptCall, validateAutoRouterConfig } =
|
||||
vi.hoisted(() => ({
|
||||
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }),
|
||||
modelPatchUpdateCall: vi.fn().mockResolvedValue({}),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getAutoRouterClassifierDefaultPromptCall: vi.fn().mockResolvedValue("Classify the request into exactly one tier."),
|
||||
}));
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
modelPatchUpdateCall,
|
||||
|
|
@ -101,6 +99,8 @@ describe("EditAutoRouterModal keyword matching", () => {
|
|||
expect(config.match_threshold).toBe(0.72);
|
||||
});
|
||||
|
||||
// Same gate as the create form: the dry-run's verdict has to stop the PATCH, or an operator sees
|
||||
// a raw 400 instead of the inline message the dry-run was added to give them.
|
||||
it("does not PATCH when the backend's dry-run rejects the config", async () => {
|
||||
const user = userEvent.setup();
|
||||
validateAutoRouterConfig.mockResolvedValueOnce({
|
||||
|
|
@ -819,3 +819,74 @@ describe("EditAutoRouterModal plan-mode minimum tier", () => {
|
|||
expect(savedConfig()).not.toHaveProperty("plan_mode_min_tier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditAutoRouterModal with a stored custom tier set", () => {
|
||||
const CUSTOM_STORED = {
|
||||
tiers: { CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["gpt-4o-mini"] },
|
||||
tier_definitions: [
|
||||
{ name: "CASUAL", description: "small talk" },
|
||||
{ name: "SECURITY_REVIEW", description: "audits and vulnerability review" },
|
||||
],
|
||||
fallback_tier: "CASUAL",
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000 },
|
||||
plan_mode_min_tier: "SECURITY_REVIEW",
|
||||
classification_prompt: "operator written preamble",
|
||||
tier_model_configs: {
|
||||
SECURITY_REVIEW: [{ model_name: "gpt-4o-mini", litellm_params: { reasoning_effort: "high" } }],
|
||||
},
|
||||
};
|
||||
|
||||
const renderCustomModal = () =>
|
||||
renderWithProviders(
|
||||
<EditAutoRouterModal
|
||||
isVisible
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
modelData={{
|
||||
...MODEL_DATA,
|
||||
litellm_params: { ...MODEL_DATA.litellm_params, complexity_router_config: CUSTOM_STORED },
|
||||
}}
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
modelPatchUpdateCall.mockClear();
|
||||
});
|
||||
|
||||
it("shows the stored tier names rather than the built-in four", async () => {
|
||||
renderCustomModal();
|
||||
expect(await screen.findByText("SECURITY_REVIEW Tier")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Simple Tier")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves an untouched custom-tier router back byte-identically, tier set and floor included", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCustomModal();
|
||||
|
||||
await screen.findByText("SECURITY_REVIEW Tier");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
|
||||
const config = savedConfig();
|
||||
expect(config.tier_definitions).toEqual(CUSTOM_STORED.tier_definitions);
|
||||
expect(config.tiers).toEqual(CUSTOM_STORED.tiers);
|
||||
expect(config.fallback_tier).toBe("CASUAL");
|
||||
expect(config.plan_mode_min_tier).toBe("SECURITY_REVIEW");
|
||||
expect(config.classification_prompt).toBe("operator written preamble");
|
||||
expect(config.classifier_type).toBe("llm");
|
||||
});
|
||||
|
||||
it("keeps the stored per-model reasoning effort, which hydrates by tier name and saves by row id", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCustomModal();
|
||||
|
||||
await screen.findByText("SECURITY_REVIEW Tier");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
|
||||
expect(savedConfig().tier_model_configs).toEqual(CUSTOM_STORED.tier_model_configs);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,16 +15,26 @@ import { modelAvailableCall, modelPatchUpdateCall, validateAutoRouterConfig } fr
|
|||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
|
||||
import { hydrateTierModelParams, normalizeTierModels } from "../add_model/complexity_router_tiers";
|
||||
import { type ActiveTierSet, activeTierRows, resolveComplexityDefaultModel } from "../add_model/tier_rows";
|
||||
import {
|
||||
type ActiveTierSet,
|
||||
CUSTOM_TIER_OMITTED_KEYS,
|
||||
activeTierRows,
|
||||
getCustomTierRowsError,
|
||||
tierParamsByRowId,
|
||||
resolveComplexityDefaultModel,
|
||||
} from "../add_model/tier_rows";
|
||||
import { isComplexityRouter } from "../add_model/auto_router_strategies";
|
||||
import {
|
||||
type BuildComplexityRouterConfigParams,
|
||||
buildComplexityRouterConfig,
|
||||
getClassifierModelError,
|
||||
getKeywordTierRulesError,
|
||||
getMissingTiersError,
|
||||
getSemanticConfigError,
|
||||
getPlanModeTierError,
|
||||
getTierLabelsError,
|
||||
hydrateCustomTierSet,
|
||||
hydratePlanModeMinTier,
|
||||
hydrateTierLabels,
|
||||
dryRunRejection,
|
||||
} from "../add_model/build_complexity_router_config";
|
||||
|
|
@ -113,16 +123,18 @@ export const hydrateComplexityRouterConfig = (
|
|||
REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING),
|
||||
};
|
||||
|
||||
const custom_tier_set = hydrateCustomTierSet(parsedConfig);
|
||||
const activeTiers = { tiers: hydratedTiers, custom_tier_set };
|
||||
|
||||
return {
|
||||
tiers: hydratedTiers,
|
||||
tier_model_params: hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs),
|
||||
default_model: hydratePinnedDefaultModel(parsedConfig.default_model, complexityRouterDefaultModel, {
|
||||
tiers: hydratedTiers,
|
||||
}),
|
||||
plan_mode_min_tier:
|
||||
typeof parsedConfig.plan_mode_min_tier === "string" && parsedConfig.plan_mode_min_tier.trim() !== ""
|
||||
? parsedConfig.plan_mode_min_tier
|
||||
: undefined,
|
||||
custom_tier_set,
|
||||
tier_model_params: tierParamsByRowId(
|
||||
hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs),
|
||||
activeTierRows(activeTiers),
|
||||
),
|
||||
default_model: hydratePinnedDefaultModel(parsedConfig.default_model, complexityRouterDefaultModel, activeTiers),
|
||||
plan_mode_min_tier: hydratePlanModeMinTier(parsedConfig.plan_mode_min_tier, custom_tier_set),
|
||||
tier_labels: hydrateTierLabels(parsedConfig.tier_labels),
|
||||
classifier_type: parsedConfig.classifier_type || "heuristic",
|
||||
classifier_llm_config: parsedConfig.classifier_llm_config,
|
||||
|
|
@ -166,6 +178,8 @@ export const hydrateComplexityRouterConfig = (
|
|||
|
||||
export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
||||
"tiers",
|
||||
"tier_definitions",
|
||||
"fallback_tier",
|
||||
"tier_model_configs",
|
||||
"default_model",
|
||||
"plan_mode_min_tier",
|
||||
|
|
@ -244,10 +258,15 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
|
||||
return customTechnicalKeywords !== undefined && key === "custom_technical_keywords";
|
||||
};
|
||||
const preservedConfig = Object.fromEntries(Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key)));
|
||||
// A custom save drops the stored keys an edited tier set forbids.
|
||||
const dropped: readonly string[] = value.custom_tier_set ? CUSTOM_TIER_OMITTED_KEYS : [];
|
||||
const preservedConfig = Object.fromEntries(
|
||||
Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key) && !dropped.includes(key)),
|
||||
);
|
||||
|
||||
const builderParams: BuildComplexityRouterConfigParams = {
|
||||
tiers: value.tiers,
|
||||
customTierSet: value.custom_tier_set,
|
||||
defaultModel: value.default_model,
|
||||
planModeMinTier: value.plan_mode_min_tier,
|
||||
heuristicFirstMaxTier: value.heuristic_first_max_tier,
|
||||
|
|
@ -341,6 +360,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
|
||||
const [editingTiers, setEditingTiers] = useState(false);
|
||||
const [routerConfig, setRouterConfig] = useState<any>(null);
|
||||
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState<string[]>([]);
|
||||
const [keywordTierRules, setKeywordTierRules] = useState<KeywordTierRule[]>([]);
|
||||
|
|
@ -365,10 +385,12 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
// is legal today stays legal.
|
||||
const submitBlockedReason = !isComplexityRouterModel
|
||||
? null
|
||||
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
|
||||
? "Please select at least one model for a complexity tier"
|
||||
: null) ??
|
||||
getTierLabelsError(complexityRouterConfig.tier_labels) ??
|
||||
: (complexityRouterConfig.custom_tier_set
|
||||
? getCustomTierRowsError(complexityRouterConfig.custom_tier_set) ??
|
||||
getMissingTiersError(activeTierRows(complexityRouterConfig))
|
||||
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
|
||||
? "Please select at least one model for a complexity tier"
|
||||
: null) ?? getTierLabelsError(complexityRouterConfig.tier_labels)) ??
|
||||
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig)) ??
|
||||
getClassifierModelError(complexityRouterConfig);
|
||||
|
|
@ -407,6 +429,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
}, [isVisible, accessToken]);
|
||||
|
||||
const initializeForm = () => {
|
||||
setEditingTiers(false);
|
||||
try {
|
||||
if (isComplexityRouterModel) {
|
||||
// Parse the complexity_router_config if it exists and is a string
|
||||
|
|
@ -473,10 +496,15 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
|
||||
const saveValues = async (values: EditAutoRouterFormValues) => {
|
||||
if (isComplexityRouterModel) {
|
||||
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
|
||||
if (Object.values(tiers).every((models) => models.length === 0)) {
|
||||
const { tiers, custom_tier_set, classifier_llm_config } = complexityRouterConfig;
|
||||
const rows = activeTierRows(complexityRouterConfig);
|
||||
const builtInTiersEmpty = Object.values(tiers).every((models) => models.length === 0);
|
||||
const tierSetError = custom_tier_set
|
||||
? getCustomTierRowsError(custom_tier_set) ?? getMissingTiersError(rows)
|
||||
: builtInTiersEmpty && "Please select at least one model for a complexity tier";
|
||||
if (tierSetError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError("Please select at least one model for a complexity tier");
|
||||
toast.fromError(tierSetError);
|
||||
return;
|
||||
}
|
||||
const classifierError = getClassifierModelError(complexityRouterConfig);
|
||||
|
|
@ -489,7 +517,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
// keyword rule with no keyword, and semantic_keyword_matching without an embedding model
|
||||
// or keyword rules (complexity_router/config.py), so without these a save fails as a raw
|
||||
// 400 instead of an inline message.
|
||||
const keywordRulesError = getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig));
|
||||
const keywordRulesError = getKeywordTierRulesError(keywordTierRules, rows);
|
||||
if (keywordRulesError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(keywordRulesError);
|
||||
|
|
@ -517,6 +545,9 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Dual write: complexity_router_config.default_model (the pin marker hydratePinnedDefaultModel
|
||||
// reads back) and complexity_router_default_model (what the backend routes on) must always be
|
||||
// written together from the same value. Same pairing in add_auto_router_tab.tsx.
|
||||
const updatedConfig = buildUpdatedComplexityRouterConfig(
|
||||
modelData.litellm_params?.complexity_router_config,
|
||||
complexityRouterConfig,
|
||||
|
|
@ -531,9 +562,6 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Dual write: complexity_router_config.default_model (the pin marker hydratePinnedDefaultModel
|
||||
// reads back) and complexity_router_default_model (what the backend routes on) must always be
|
||||
// written together from the same value. Same pairing in add_auto_router_tab.tsx.
|
||||
const updatedLitellmParams = {
|
||||
...modelData.litellm_params,
|
||||
complexity_router_config: updatedConfig,
|
||||
|
|
@ -635,6 +663,8 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
/* Complexity Router Configuration */
|
||||
<div className="w-full">
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
showValidationErrors={showValidationErrors}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue