mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
feat(ui): edit the auto-router tier set with custom classifier-defined tiers
This commit is contained in:
parent
f818a48ae5
commit
c62e7f8c8b
17 changed files with 1000 additions and 218 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -145,3 +145,4 @@ crash.*.log
|
|||
|
||||
ui/litellm-dashboard/out/
|
||||
litellm.log
|
||||
litellm-round.log
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
CLASSIFICATION_RUBRIC_KEYS,
|
||||
ClassificationRubric,
|
||||
effectiveTierLabel,
|
||||
effectiveClassifierType,
|
||||
} from "./ComplexityRouterConfig";
|
||||
|
||||
const DEFAULT_SCORING_EXPLANATION =
|
||||
|
|
@ -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 has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
|
||||
<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>
|
||||
|
|
@ -348,13 +359,20 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</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}
|
||||
/>
|
||||
{hasCustomTierSet ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Unavailable with an edited tier set: a replacement prompt would drop the tier definitions the classifier
|
||||
routes on, along with the injection guard. Your tier definitions are the rubric.
|
||||
</span>
|
||||
) : (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const baseProps = {
|
|||
modelInfo: mockModelInfo,
|
||||
value: defaultValue,
|
||||
onChange: vi.fn(),
|
||||
editingTiers: false,
|
||||
onEditingTiersChange: vi.fn(),
|
||||
keywordTierRules: [],
|
||||
onKeywordTierRulesChange: vi.fn(),
|
||||
semanticMatchingEnabled: false,
|
||||
|
|
@ -943,3 +945,110 @@ describe("ComplexityRouterConfig reasoning effort gating", () => {
|
|||
).toHaveTextContent("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit tiers", () => {
|
||||
const customValue: ComplexityRouterConfigValue = {
|
||||
...defaultValue,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
|
||||
custom_tier_set: {
|
||||
tiers: [
|
||||
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-3.5-turbo"] },
|
||||
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: ["gpt-4"] },
|
||||
{ id: "sec", name: "AUDIT", definition: "security audits", models: ["claude-3-opus"] },
|
||||
],
|
||||
fallback_tier_id: "COMPLEX",
|
||||
},
|
||||
};
|
||||
|
||||
// Remove snapshots in-editor models so Restore returns those; only "Use built-in tiers" exits.
|
||||
// One pass: row limits, rules following a rename (they point by NAME), and forced-off inputs.
|
||||
it("edits a custom tier set: rows, keyword rules, and the inputs it forces off", async () => {
|
||||
const onChange = vi.fn();
|
||||
const onKeywordTierRulesChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
editingTiers
|
||||
value={{
|
||||
...customValue,
|
||||
plan_mode_min_tier: "COMPLEX",
|
||||
tier_model_params: { AUDIT: { o: { reasoning_effort: "high" } } },
|
||||
}}
|
||||
onChange={onChange}
|
||||
keywordTierRules={[{ id: "rule-1", keywords: ["scan"], tier: "AUDIT" }]}
|
||||
onKeywordTierRulesChange={onKeywordTierRulesChange}
|
||||
/>,
|
||||
);
|
||||
const name = screen.getByRole("textbox", { name: "Name for tier 3" });
|
||||
const definition = screen.getByRole("textbox", { name: "Definition for tier 3" });
|
||||
expect(name).toHaveValue("AUDIT");
|
||||
expect(name).toHaveAttribute("maxLength", "64");
|
||||
expect(definition).toHaveAttribute("maxLength", "500");
|
||||
expect(screen.queryByLabelText("Display name for the Simple tier")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("combobox", { name: /Reasoning effort/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(definition, { target: { value: "audits\nand\r\nreviews" } });
|
||||
expect((onChange.mock.calls.at(-1)?.[0] as ComplexityRouterConfigValue).custom_tier_set?.tiers[2].definition).toBe(
|
||||
"audits and reviews",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
|
||||
expect(screen.getByRole("combobox", { name: "Route keyword rule 1 to tier" })).toHaveTextContent("AUDIT");
|
||||
fireEvent.change(name, { target: { value: "SECREV" } });
|
||||
expect(onKeywordTierRulesChange).toHaveBeenLastCalledWith([{ id: "rule-1", keywords: ["scan"], tier: "SECREV" }]);
|
||||
|
||||
await userEvent.click(screen.getByText("Advanced: Affinity"));
|
||||
expect(screen.getByLabelText("Pin a session to its first model")).toHaveAttribute("data-disabled");
|
||||
expect(screen.getByText(/escalation walks the built-in tier ladder/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.getByRole("radio", { name: /rule-based scoring/ })).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText(/Unavailable with an edited tier set: a replacement prompt/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced: Plan-Mode Override"));
|
||||
const floor = screen.getByRole("switch", { name: "Route plan-mode requests to a minimum tier" });
|
||||
expect(floor).toHaveAttribute("aria-disabled", "true");
|
||||
expect(floor).toBeChecked();
|
||||
expect(screen.queryByRole("combobox", { name: "Plan-mode minimum tier" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves keyword rules alone when the old tier name is shared by another row", () => {
|
||||
const onKeywordTierRulesChange = vi.fn();
|
||||
const tiers = customValue.custom_tier_set!.tiers.map((row) => ({ ...row, name: "AUDIT" }));
|
||||
const shared = { ...customValue, custom_tier_set: { tiers, fallback_tier_id: tiers[0].id } };
|
||||
const rules = [{ id: "rule-1", keywords: ["scan"], tier: "AUDIT" }];
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
editingTiers
|
||||
value={shared}
|
||||
keywordTierRules={rules}
|
||||
onKeywordTierRulesChange={onKeywordTierRulesChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Name for tier 3" }), { target: { value: "SECREV" } });
|
||||
expect(onKeywordTierRulesChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes, restores, and exits the tier set without losing in-editor models", async () => {
|
||||
const onChange = vi.fn();
|
||||
const step = (value: ComplexityRouterConfigValue, button: string) => {
|
||||
onChange.mockClear();
|
||||
renderWithProviders(<ComplexityRouterConfig {...baseProps} editingTiers value={value} onChange={onChange} />);
|
||||
return userEvent
|
||||
.click(screen.getAllByRole("button", { name: button })[0])
|
||||
.then(() => onChange.mock.calls.at(-1)?.[0] as ComplexityRouterConfigValue);
|
||||
};
|
||||
const removed = await step({ ...defaultValue, plan_mode_min_tier: "MEDIUM" }, "Remove the MEDIUM tier");
|
||||
expect(removed.plan_mode_min_tier).toBeUndefined();
|
||||
expect(removed.custom_tier_set?.tiers.map((r) => r.id)).toEqual(["SIMPLE", "COMPLEX", "REASONING"]);
|
||||
expect(removed.custom_tier_set?.fallback_tier_id).toBe("SIMPLE");
|
||||
|
||||
const restored = await step(removed, "Restore defaults");
|
||||
expect(restored.custom_tier_set?.tiers.map((r) => r.id)).toEqual(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
|
||||
expect(restored.custom_tier_set?.tiers[1].models).toEqual(defaultValue.tiers.MEDIUM);
|
||||
|
||||
expect(await step(restored, "Use built-in tiers")).toEqual(defaultValue);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,12 +2,25 @@ import { SimpleTooltip } from "@/components/ui/tooltip";
|
|||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ChevronRight, Info, X } from "lucide-react";
|
||||
import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
type CustomTierSet,
|
||||
type TierDraft,
|
||||
MAX_TIER_COUNT,
|
||||
MIN_TIER_COUNT,
|
||||
findTierByName,
|
||||
getCustomTierRowsError,
|
||||
isBuiltInTierName,
|
||||
tierNamesMatch,
|
||||
} from "./custom_tier_set";
|
||||
import React from "react";
|
||||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
|
||||
|
|
@ -15,6 +28,7 @@ import ClassificationMethodConfig from "./ClassificationMethodConfig";
|
|||
import {
|
||||
ReasoningEffort,
|
||||
TierModelParamsByTier,
|
||||
customTierDefaultModel,
|
||||
pruneTierModelParams,
|
||||
resolveComplexityDefaultModel,
|
||||
setTierModelReasoningEffort,
|
||||
|
|
@ -27,11 +41,15 @@ import SemanticKeywordMatching from "./SemanticKeywordMatching";
|
|||
import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from "./heuristic_scoring_knobs";
|
||||
|
||||
export type { DimensionWeights, TierBoundaries, TokenThresholds };
|
||||
export type { CustomTierSet, TierDraft } from "./custom_tier_set";
|
||||
export { MAX_TIER_COUNT, MIN_TIER_COUNT, getCustomTierRowsError, isBuiltInTierName } from "./custom_tier_set";
|
||||
|
||||
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000;
|
||||
export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5;
|
||||
export const DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE = 3;
|
||||
export const DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS = 200;
|
||||
export const MAX_TIER_NAME_CHARS = 64;
|
||||
export const MAX_TIER_DESCRIPTION_CHARS = 500;
|
||||
export const DEFAULT_SESSION_AFFINITY = false;
|
||||
export const DEFAULT_DEPLOYMENT_AFFINITY = true;
|
||||
|
||||
|
|
@ -123,15 +141,26 @@ 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>>;
|
||||
|
||||
const defaultModelPlaceholder = (derived: string | undefined, hasCustomTierSet: boolean): string => {
|
||||
if (derived) return `Derived from tiers: ${derived}`;
|
||||
return hasCustomTierSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier";
|
||||
};
|
||||
|
||||
/** Derived, never written into the value, so an undone tier edit reverts the form with nothing left behind. */
|
||||
export const effectiveClassifierType = (
|
||||
value: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type">,
|
||||
): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type);
|
||||
|
||||
export interface ComplexityRouterConfigValue {
|
||||
tiers: ComplexityTiers;
|
||||
tier_labels?: ComplexityTierLabels;
|
||||
custom_tier_set?: CustomTierSet;
|
||||
/** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */
|
||||
default_model?: string;
|
||||
classifier_type: ClassifierType;
|
||||
|
|
@ -142,7 +171,7 @@ export interface ComplexityRouterConfigValue {
|
|||
classifier_fallback?: ClassifierFallback;
|
||||
session_affinity?: boolean;
|
||||
deployment_affinity?: boolean;
|
||||
/** Tier floor for coding-agent plan-mode requests. Unset means detection is off, matching the backend. */
|
||||
/** Plan-mode floor as a tier ROW ID (unset = off); the wire carries the row's name. */
|
||||
plan_mode_min_tier?: string;
|
||||
adaptive?: boolean;
|
||||
adaptive_weights?: AdaptiveRouterWeights;
|
||||
|
|
@ -173,6 +202,9 @@ interface ComplexityRouterConfigProps {
|
|||
modelInfo: ModelGroup[];
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
/** Parent-owned: this component unmounts on section collapse. */
|
||||
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
|
||||
|
|
@ -229,6 +261,8 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
modelInfo,
|
||||
value,
|
||||
onChange,
|
||||
editingTiers,
|
||||
onEditingTiersChange,
|
||||
customTechnicalKeywords,
|
||||
onCustomTechnicalKeywordsChange,
|
||||
keywordTierRules = [],
|
||||
|
|
@ -243,12 +277,89 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
onEscalationKeywordsChange,
|
||||
showValidationErrors = false,
|
||||
}) => {
|
||||
const customTierSet = value.custom_tier_set;
|
||||
|
||||
const builtInRow = (tier: keyof ComplexityTiers): TierDraft => ({
|
||||
id: tier,
|
||||
name: tier,
|
||||
definition: "",
|
||||
models: value.tiers[tier],
|
||||
});
|
||||
const tierRows: TierDraft[] = customTierSet?.tiers ?? TIER_KEYS.map(builtInRow);
|
||||
|
||||
// Sole tier-set writer: reconciles both row-id pointers (fallback re-points, dead floor clears).
|
||||
const commitTierRows = (rows: TierDraft[], fallbackTierId: string, base: ComplexityRouterConfigValue = value) => {
|
||||
const fallback_tier_id = rows.some((row) => row.id === fallbackTierId)
|
||||
? fallbackTierId
|
||||
: (findTierByName(rows, "MEDIUM") ?? rows[0])?.id ?? "";
|
||||
const floorGone = base.plan_mode_min_tier !== undefined && !rows.some((r) => r.id === base.plan_mode_min_tier);
|
||||
const floor = floorGone ? { plan_mode_min_tier: undefined } : {};
|
||||
onChange({ ...base, ...floor, custom_tier_set: { tiers: rows, fallback_tier_id } });
|
||||
};
|
||||
|
||||
const currentFallbackId = customTierSet?.fallback_tier_id ?? "MEDIUM";
|
||||
const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null;
|
||||
|
||||
const removeTierRow = (id: string) => {
|
||||
const removed = tierRows.find((row) => row.id === id);
|
||||
const snapshotBase =
|
||||
removed && (TIER_KEYS as string[]).includes(removed.id)
|
||||
? { ...value, tiers: { ...value.tiers, [removed.id]: removed.models } }
|
||||
: value;
|
||||
commitTierRows(
|
||||
tierRows.filter((row) => row.id !== id),
|
||||
currentFallbackId,
|
||||
snapshotBase,
|
||||
);
|
||||
};
|
||||
|
||||
const restoreDefaultTiers = () => {
|
||||
const restoredInCanonicalOrder = [
|
||||
...TIER_KEYS.map((builtIn) => tierRows.find((row) => row.id === builtIn) ?? builtInRow(builtIn)),
|
||||
...tierRows.filter((row) => !(TIER_KEYS as string[]).includes(row.id)),
|
||||
];
|
||||
commitTierRows(restoredInCanonicalOrder, currentFallbackId);
|
||||
};
|
||||
|
||||
const exitToBuiltInTiers = () => {
|
||||
const { custom_tier_set: _cleared, ...rest } = value;
|
||||
const modelsFor = (tier: keyof ComplexityTiers) =>
|
||||
tierRows.find((row) => row.id === tier)?.models ?? value.tiers[tier];
|
||||
onChange({ ...rest, tiers: { ...value.tiers, ...Object.fromEntries(TIER_KEYS.map((t) => [t, modelsFor(t)])) } });
|
||||
};
|
||||
|
||||
const addCustomTier = () => {
|
||||
commitTierRows([...tierRows, { id: crypto.randomUUID(), name: "", definition: "", models: [] }], currentFallbackId);
|
||||
};
|
||||
|
||||
const updateTierRow = (id: string, patch: Partial<Omit<TierDraft, "id">>) => {
|
||||
const renamedTo = patch.name;
|
||||
const renamedFrom = tierRows.find((candidate) => candidate.id === id)?.name;
|
||||
// Skip when another row already answers to the old name: which rules were this row's is unknowable.
|
||||
const shared = tierRows.some(
|
||||
(r) => r.id !== id && renamedFrom !== undefined && tierNamesMatch(r.name, renamedFrom),
|
||||
);
|
||||
if (renamedTo !== undefined && renamedFrom !== undefined && !shared && onKeywordTierRulesChange) {
|
||||
onKeywordTierRulesChange(
|
||||
keywordTierRules.map((rule) => (tierNamesMatch(rule.tier, renamedFrom) ? { ...rule, tier: renamedTo } : rule)),
|
||||
);
|
||||
}
|
||||
commitTierRows(
|
||||
tierRows.map((candidate) => (candidate.id === id ? { ...candidate, ...patch } : candidate)),
|
||||
currentFallbackId,
|
||||
);
|
||||
};
|
||||
|
||||
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 derivedDefaultModel = customTierSet
|
||||
? customTierDefaultModel(customTierSet)
|
||||
: resolveComplexityDefaultModel(value.tiers);
|
||||
const defaultModel = customTierSet
|
||||
? customTierDefaultModel(customTierSet, value.default_model)
|
||||
: resolveComplexityDefaultModel(value.tiers, value.default_model);
|
||||
|
||||
// Embedding models can't serve a chat-completion role, so they're excluded here.
|
||||
const reasoningModels = new Set(
|
||||
|
|
@ -270,11 +381,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
const handleTierModelEffortChange = (
|
||||
tier: keyof ComplexityTiers,
|
||||
model: string,
|
||||
effort: ReasoningEffort | undefined,
|
||||
) => {
|
||||
const handleTierModelEffortChange = (tier: string, model: string, effort: ReasoningEffort | undefined) => {
|
||||
onChange({
|
||||
...value,
|
||||
tier_model_params: setTierModelReasoningEffort(value.tier_model_params, tier, model, effort),
|
||||
|
|
@ -309,77 +416,227 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
</span>
|
||||
|
||||
<span className="block mb-4 text-xs text-muted-foreground">
|
||||
Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how
|
||||
requests are classified, and callers never see these names.
|
||||
{value.classifier_type === "llm" &&
|
||||
{customTierSet
|
||||
? "Display names are unavailable with an edited tier set: your tier names appear as-is."
|
||||
: "Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how requests are classified, and callers never see these names."}
|
||||
{!customTierSet &&
|
||||
value.classifier_type === "llm" &&
|
||||
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
|
||||
</span>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
{TIER_KEYS.map((tier, index) => {
|
||||
const tierInfo = TIER_DESCRIPTIONS[tier];
|
||||
const label = effectiveTierLabel(tier, value.tier_labels);
|
||||
const tierMissing = showValidationErrors && value.tiers[tier].length === 0;
|
||||
{tierRows.map((row, index) => {
|
||||
const builtInKey = TIER_KEYS.find((tier) => tier === row.id);
|
||||
const tierInfo = builtInKey ? TIER_DESCRIPTIONS[builtInKey] : undefined;
|
||||
const label = customTierSet
|
||||
? row.name.trim() || "New"
|
||||
: effectiveTierLabel(row.id as keyof ComplexityTiers, value.tier_labels);
|
||||
const nameMissing = showValidationErrors && Boolean(customTierSet) && !row.name.trim();
|
||||
const definitionMissing =
|
||||
showValidationErrors && Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name);
|
||||
const modelsMissing = showValidationErrors && row.models.length === 0;
|
||||
return (
|
||||
<div key={tier}>
|
||||
<div key={row.id}>
|
||||
{index > 0 && <Separator className="my-4" />}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">{label} Tier</strong>
|
||||
<SimpleTooltip content={tierInfo.description}>
|
||||
<SimpleTooltip
|
||||
content={
|
||||
(customTierSet && row.definition.trim()) ||
|
||||
tierInfo?.description ||
|
||||
"A tier you defined. The classifier routes requests matching its definition here."
|
||||
}
|
||||
>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tier {index + 1} of {TIER_KEYS.length} · {tier}
|
||||
Tier {index + 1} of {tierRows.length} · {!customTierSet && row.id}
|
||||
{customTierSet && (isBuiltInTierName(row.name) ? "built-in" : "custom")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
|
||||
<InputGroup className="mb-2">
|
||||
<InputGroupInput
|
||||
value={value.tier_labels?.[tier] ?? ""}
|
||||
onChange={(event) => handleTierLabelChange(tier, event.target.value)}
|
||||
placeholder={`Display name (default: ${tierInfo.label})`}
|
||||
aria-label={`Display name for the ${tierInfo.label} tier`}
|
||||
/>
|
||||
{value.tier_labels?.[tier] && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Clear display name for the ${tierInfo.label} tier`}
|
||||
onClick={() => handleTierLabelChange(tier, "")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
{editingTiers && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
aria-label={`Remove the ${row.name.trim() || `tier ${index + 1}`} tier`}
|
||||
disabled={tierRows.length <= MIN_TIER_COUNT}
|
||||
onClick={() => removeTierRow(row.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</InputGroup>
|
||||
</div>
|
||||
{tierInfo && (
|
||||
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
|
||||
)}
|
||||
{customTierSet && editingTiers && (
|
||||
<>
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(event) => updateTierRow(row.id, { name: event.target.value })}
|
||||
placeholder="Tier name, e.g. SECURITY_REVIEW"
|
||||
aria-label={`Name for tier ${index + 1}`}
|
||||
maxLength={MAX_TIER_NAME_CHARS}
|
||||
className={nameMissing ? "mb-2 border-destructive" : "mb-2"}
|
||||
/>
|
||||
<Textarea
|
||||
value={row.definition}
|
||||
onChange={(event) =>
|
||||
updateTierRow(row.id, { definition: event.target.value.replace(/[\r\n]+/g, " ") })
|
||||
}
|
||||
maxLength={MAX_TIER_DESCRIPTION_CHARS}
|
||||
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 or vulnerability review"
|
||||
}
|
||||
aria-label={`Definition for tier ${index + 1}`}
|
||||
rows={2}
|
||||
className={definitionMissing ? "mb-2 border-destructive" : "mb-2"}
|
||||
/>
|
||||
{definitionMissing && (
|
||||
<span className="block mb-2 text-xs text-destructive">
|
||||
A definition is required: it is the rubric the classifier uses for this tier
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!customTierSet && (
|
||||
<InputGroup className="mb-2">
|
||||
<InputGroupInput
|
||||
value={value.tier_labels?.[row.id as keyof ComplexityTiers] ?? ""}
|
||||
onChange={(event) => handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)}
|
||||
placeholder={`Display name (default: ${tierInfo?.label})`}
|
||||
aria-label={`Display name for the ${tierInfo?.label} tier`}
|
||||
/>
|
||||
{value.tier_labels?.[row.id as keyof ComplexityTiers] && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Clear display name for the ${tierInfo?.label} tier`}
|
||||
onClick={() => handleTierLabelChange(row.id as keyof ComplexityTiers, "")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
)}
|
||||
<MultiSelect
|
||||
options={modelOptions}
|
||||
value={value.tiers[tier]}
|
||||
onValueChange={(models: string[]) => handleTierChange(tier, models)}
|
||||
value={row.models}
|
||||
onValueChange={(models: string[]) =>
|
||||
customTierSet
|
||||
? updateTierRow(row.id, { models })
|
||||
: handleTierChange(row.id as keyof ComplexityTiers, models)
|
||||
}
|
||||
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
|
||||
emptyText="No models found"
|
||||
className={tierMissing ? "w-full border-destructive" : "w-full"}
|
||||
className={modelsMissing ? "w-full border-destructive" : "w-full"}
|
||||
/>
|
||||
<TierModelEffortRows
|
||||
tierLabel={label}
|
||||
models={value.tiers[tier]}
|
||||
reasoningModels={reasoningModels}
|
||||
paramsByModel={value.tier_model_params?.[tier]}
|
||||
onEffortChange={(model, effort) => handleTierModelEffortChange(tier, model, effort)}
|
||||
/>
|
||||
{value.tiers[tier].length > 1 && (
|
||||
{!customTierSet && (
|
||||
<TierModelEffortRows
|
||||
tierLabel={label}
|
||||
models={row.models}
|
||||
reasoningModels={reasoningModels}
|
||||
paramsByModel={value.tier_model_params?.[row.name.trim()]}
|
||||
onEffortChange={(model, effort) => handleTierModelEffortChange(row.name.trim(), model, effort)}
|
||||
/>
|
||||
)}
|
||||
{row.models.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Multiple models selected — the router randomly picks among them per request (or Thompson-samples
|
||||
within the pool when adaptive routing is on).
|
||||
</span>
|
||||
)}
|
||||
{tierMissing && <span className="text-xs text-destructive">The {label} tier is required</span>}
|
||||
{modelsMissing && <span className="text-xs text-destructive">The {label} tier is required</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{editingTiers ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={addCustomTier} disabled={tierRows.length >= MAX_TIER_COUNT}>
|
||||
<Plus />
|
||||
Add tier
|
||||
</Button>
|
||||
<SimpleTooltip content={tierRowsError || undefined}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={Boolean(tierRowsError)}
|
||||
onClick={() => onEditingTiersChange?.(false)}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
{customTierSet && (
|
||||
<SimpleTooltip content="Return to the built-in tier ladder; settings that need tier definitions are dropped.">
|
||||
<Button variant="outline" size="sm" onClick={exitToBuiltInTiers}>
|
||||
Use built-in tiers
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
{customTierSet && TIER_KEYS.some((tier) => !tierRows.some((row) => row.id === tier)) && (
|
||||
<Button variant="outline" size="sm" onClick={restoreDefaultTiers}>
|
||||
Restore defaults
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
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 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>
|
||||
|
||||
{customTierSet && (
|
||||
<div className="mb-2">
|
||||
<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={customTierSet.fallback_tier_id || null}
|
||||
onValueChange={(fallbackTierId: string | null) =>
|
||||
fallbackTierId && commitTierRows(customTierSet.tiers, fallbackTierId)
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label="Fallback tier" className="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>
|
||||
)}
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="mb-2">
|
||||
|
|
@ -393,11 +650,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(derivedDefaultModel, Boolean(customTierSet))}
|
||||
emptyText="No models found"
|
||||
aria-label="Default model"
|
||||
/>
|
||||
|
|
@ -431,7 +684,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",
|
||||
|
|
@ -454,15 +714,17 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
</span>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
|
||||
checked={customTierSet ? false : value.session_affinity ?? DEFAULT_SESSION_AFFINITY}
|
||||
disabled={Boolean(customTierSet)}
|
||||
onCheckedChange={(sessionAffinity) => onChange({ ...value, session_affinity: sessionAffinity })}
|
||||
aria-label="Pin a session to its first model"
|
||||
/>
|
||||
<strong className="font-semibold">Pin a session to its first model</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the
|
||||
deployment.
|
||||
{customTierSet
|
||||
? "Unavailable with an edited tier set: escalation walks the built-in tier ladder."
|
||||
: "Keeps a session on its first turn's model instead of re-classifying each turn. Also pins the deployment."}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
|
|
@ -475,7 +737,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.plan_mode_min_tier !== undefined}
|
||||
disabled={planModeTiers.length === 0}
|
||||
disabled={Boolean(customTierSet) || planModeTiers.length === 0}
|
||||
onCheckedChange={(enabled) =>
|
||||
onChange({ ...value, plan_mode_min_tier: enabled ? planModeTiers.at(-1) : undefined })
|
||||
}
|
||||
|
|
@ -484,11 +746,18 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
<strong className="font-semibold">Route plan-mode requests to a minimum tier</strong>
|
||||
</div>
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier.
|
||||
The classifier still wins when it picks higher, and the override only lasts while plan mode is active.
|
||||
{planModeTiers.length === 0 && " Add models to a tier to enable this."}
|
||||
{customTierSet ? (
|
||||
"Locked while a tier set is edited; a saved floor still applies while its tier row exists."
|
||||
) : (
|
||||
<>
|
||||
Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this
|
||||
tier. The classifier still wins when it picks higher, and the override only lasts while plan mode
|
||||
is active.
|
||||
{planModeTiers.length === 0 && " Add models to a tier to enable this."}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{value.plan_mode_min_tier !== undefined && (
|
||||
{!customTierSet && value.plan_mode_min_tier !== undefined && (
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Select
|
||||
items={planModeTierOptions}
|
||||
|
|
@ -537,7 +806,14 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
{
|
||||
key: "escalation",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Escalation Keywords</strong>,
|
||||
children: <EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />,
|
||||
children: customTierSet ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Escalation keywords are unavailable with an edited tier set: they bump requests along the built-in
|
||||
tier ladder, which your tier set replaces.
|
||||
</span>
|
||||
) : (
|
||||
<EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
|
@ -553,6 +829,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
rules={keywordTierRules}
|
||||
onChange={onKeywordTierRulesChange}
|
||||
tierLabels={value.tier_labels}
|
||||
tierNames={customTierSet?.tiers.map((row) => row.name.trim()).filter(Boolean)}
|
||||
/>
|
||||
)}
|
||||
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,13 @@ 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";
|
||||
|
||||
export interface KeywordTierRule {
|
||||
id: string;
|
||||
keywords: string[];
|
||||
/** A built-in tier name, or with a custom tier set, one of the defined tier names. */
|
||||
tier: string;
|
||||
}
|
||||
|
||||
|
|
@ -22,12 +21,10 @@ 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 +32,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 +95,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 +103,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>
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@ import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
|
|||
import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import ComplexityRouterConfig, {
|
||||
ComplexityRouterConfigValue,
|
||||
ComplexityTiers,
|
||||
DEFAULT_ADAPTIVE_WEIGHTS,
|
||||
DEFAULT_SESSION_AFFINITY,
|
||||
DEFAULT_DEPLOYMENT_AFFINITY,
|
||||
DEFAULT_TIER_DISTANCE_PENALTY,
|
||||
effectiveClassifierType,
|
||||
} from "./ComplexityRouterConfig";
|
||||
import { KeywordTierRule } from "./KeywordTierRules";
|
||||
import { DEFAULT_ESCALATION_KEYWORDS } from "./EscalationKeywords";
|
||||
|
|
@ -39,8 +39,14 @@ import {
|
|||
getPlanModeTierError,
|
||||
getSemanticConfigError,
|
||||
getTierLabelsError,
|
||||
getCustomTierRowsError,
|
||||
} from "./build_complexity_router_config";
|
||||
import { resolveComplexityDefaultModel } from "./complexity_router_tiers";
|
||||
import {
|
||||
customTierDefaultModel,
|
||||
DEFAULT_TIER_LABELS,
|
||||
TIER_ORDER,
|
||||
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";
|
||||
|
|
@ -102,19 +108,12 @@ const isPresetHintAlarming = (availability: PresetAvailability): boolean => avai
|
|||
// this is resolved once at import time rather than re-called from inside the component every render.
|
||||
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 TIER_SUMMARY_LABELS: Record<string, string> = DEFAULT_TIER_LABELS;
|
||||
|
||||
const tierConfigSummary = (rows: [string, string[]][]): string => {
|
||||
const parts = rows
|
||||
.filter(([, models]) => models.length > 0)
|
||||
.map(([label, models]) => `${label}: ${models.join(", ")}`);
|
||||
.map(([tier, models]) => `${TIER_SUMMARY_LABELS[tier] ?? tier}: ${models.join(", ")}`);
|
||||
return parts.length > 0 ? parts.join(" · ") : "No tiers configured yet";
|
||||
};
|
||||
|
||||
|
|
@ -128,10 +127,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) ??
|
||||
getKeywordTierRulesError(keywordTierRules) ??
|
||||
(config.custom_tier_set
|
||||
? getCustomTierRowsError(config.custom_tier_set)
|
||||
: getMissingTiersError(config.tiers) ??
|
||||
getTierLabelsError(config.tier_labels) ??
|
||||
getPlanModeTierError(config.plan_mode_min_tier, config.tiers)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, config.custom_tier_set?.tiers.map((r) => r.name) ?? TIER_ORDER) ??
|
||||
getReferencedModelsError(referencedModelsParams, availability);
|
||||
|
||||
const autoRouterSchema = (requiresTeamScope: boolean) =>
|
||||
|
|
@ -200,6 +201,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
const [matchThreshold, setMatchThreshold] = useState<number>(DEFAULT_MATCH_THRESHOLD);
|
||||
const [escalationKeywords, setEscalationKeywords] = useState<string[]>(DEFAULT_ESCALATION_KEYWORDS);
|
||||
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
|
||||
const [editingTiers, setEditingTiers] = useState(false);
|
||||
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(undefined);
|
||||
// Closed by default: a caller opens it deliberately, either by clicking it or by choosing Custom
|
||||
|
|
@ -299,6 +301,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
);
|
||||
|
||||
const applyPrefill = (prefill: PresetPrefill) => {
|
||||
setEditingTiers(false);
|
||||
setComplexityRouterConfig(prefill.complexityRouterConfig);
|
||||
setCustomTechnicalKeywords(prefill.customTechnicalKeywords);
|
||||
setKeywordTierRules(prefill.keywordTierRules);
|
||||
|
|
@ -328,9 +331,17 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
setDetailsExpanded(presetState.viaDeployments);
|
||||
};
|
||||
|
||||
const customTierSet = complexityRouterConfig.custom_tier_set;
|
||||
// `tiers` is a stale built-in shadow while a set is edited, so read the rows the payload will carry.
|
||||
const tierEntries: [string, string[]][] = customTierSet
|
||||
? customTierSet.tiers.map((row) => [row.name.trim() || "New tier", row.models])
|
||||
: TIER_ORDER.map((tier) => [tier, complexityRouterConfig.tiers[tier]]);
|
||||
const activeDefaultModel = customTierSet
|
||||
? customTierDefaultModel(customTierSet, complexityRouterConfig.default_model)
|
||||
: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model);
|
||||
const referencedModelsParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
classifierType: complexityRouterConfig.classifier_type,
|
||||
tiers: Object.fromEntries(tierEntries),
|
||||
classifierType: effectiveClassifierType(complexityRouterConfig),
|
||||
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
|
|
@ -346,6 +357,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
|
||||
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
customTierSet,
|
||||
defaultModel: complexityRouterConfig.default_model,
|
||||
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
|
||||
tierLabels: complexityRouterConfig.tier_labels,
|
||||
|
|
@ -376,29 +388,32 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
};
|
||||
|
||||
const submitRecommendedRouter = async (name: string) => {
|
||||
const { tiers, tierLabels, classifierType, classifierLlmConfig } = complexityRouterConfigParams;
|
||||
const { tiers, tierLabels, classifierLlmConfig } = complexityRouterConfigParams;
|
||||
const classifierType = effectiveClassifierType(complexityRouterConfig);
|
||||
|
||||
const missingTiersError = getMissingTiersError(tiers);
|
||||
if (missingTiersError) {
|
||||
const tierSetError = customTierSet
|
||||
? getCustomTierRowsError(customTierSet)
|
||||
: getMissingTiersError(tiers) ?? getTierLabelsError(tierLabels);
|
||||
if (tierSetError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(missingTiersError);
|
||||
return;
|
||||
}
|
||||
|
||||
const tierLabelsError = getTierLabelsError(tierLabels);
|
||||
if (tierLabelsError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(tierLabelsError);
|
||||
toast.fromError(tierSetError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (classifierType === "llm" && !classifierLlmConfig?.model) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError("Please select a classifier model, or switch back to Heuristic");
|
||||
toast.fromError(
|
||||
customTierSet
|
||||
? "Select a classifier model: an edited tier set routes with the LLM classifier"
|
||||
: "Please select a classifier model, or switch back to Heuristic",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const keywordRulesError = getKeywordTierRulesError(keywordTierRules);
|
||||
const keywordRulesError = getKeywordTierRulesError(
|
||||
keywordTierRules,
|
||||
tierEntries.map(([name]) => name),
|
||||
);
|
||||
if (keywordRulesError) {
|
||||
setShowValidationErrors(true);
|
||||
toast.fromError(keywordRulesError);
|
||||
|
|
@ -423,7 +438,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model);
|
||||
const defaultModel = activeDefaultModel;
|
||||
const validatedFields = requiresTeamScope
|
||||
? (["auto_router_name", "team_id"] as const)
|
||||
: (["auto_router_name"] as const);
|
||||
|
|
@ -433,6 +448,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const builtConfig = buildComplexityRouterConfig(complexityRouterConfigParams);
|
||||
|
||||
// auto_router_default_model (-> litellm_params, read by the backend at init) and
|
||||
// complexity_router_config.default_model (-> the pin marker read back on edit, see
|
||||
// hydratePinnedDefaultModel in edit_auto_router_modal.tsx) must both come from the same
|
||||
|
|
@ -442,11 +459,11 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
...teamScopePayload(requiresTeamScope, form.getValues("team_id")),
|
||||
auto_router_default_model: defaultModel,
|
||||
model_type: "complexity_router",
|
||||
complexity_router_config: buildComplexityRouterConfig(complexityRouterConfigParams),
|
||||
complexity_router_config: builtConfig,
|
||||
model_access_group: form.getValues("model_access_group"),
|
||||
};
|
||||
|
||||
handleAddAutoRouterSubmit(submitValues, accessToken, () => form.reset(EMPTY_FORM_VALUES), handleOk);
|
||||
await handleAddAutoRouterSubmit(submitValues, accessToken, () => form.reset(EMPTY_FORM_VALUES), handleOk);
|
||||
};
|
||||
|
||||
const handleAutoRouterSubmit = async () => {
|
||||
|
|
@ -463,10 +480,10 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
|
||||
const handleTestConnection = () => {
|
||||
const testTargetParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
tiers: tierEntries,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
defaultModel: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model),
|
||||
defaultModel: activeDefaultModel,
|
||||
};
|
||||
const targets = buildAutoRouterTestTargets(testTargetParams);
|
||||
|
||||
|
|
@ -580,14 +597,14 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
Detailed Configuration
|
||||
</span>
|
||||
{!detailsExpanded && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tierConfigSummary(complexityRouterConfig.tiers)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">{tierConfigSummary(tierEntries)}</span>
|
||||
)}
|
||||
</button>
|
||||
{detailsExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
|
|
@ -694,10 +711,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}
|
||||
/>
|
||||
|
|
@ -707,7 +725,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
<Button variant="outline" onClick={() => setIsRoutingTestVisible(false)}>
|
||||
Close
|
||||
</Button>
|
||||
, ]
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -744,7 +761,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
>
|
||||
Close
|
||||
</Button>
|
||||
, ]
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { buildAutoRouterTestTargets } from "./build_auto_router_test_targets";
|
||||
|
||||
const tiers = {
|
||||
const entries = (tiers: Record<string, string[]>): [string, string[]][] => Object.entries(tiers);
|
||||
|
||||
const tiers = entries({
|
||||
SIMPLE: ["gpt-4o-mini"],
|
||||
MEDIUM: ["claude-sonnet-4"],
|
||||
COMPLEX: ["claude-sonnet-4"],
|
||||
REASONING: ["o3"],
|
||||
};
|
||||
});
|
||||
|
||||
describe("buildAutoRouterTestTargets", () => {
|
||||
it("dedups tiers that share a model group into one chat target carrying both labels", () => {
|
||||
|
|
@ -19,7 +21,12 @@ describe("buildAutoRouterTestTargets", () => {
|
|||
|
||||
it("emits a target per model when a tier has more than one, and dedups across tiers", () => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
tiers: { SIMPLE: ["gpt-4o-mini", "claude-sonnet-4"], MEDIUM: ["claude-sonnet-4"], COMPLEX: [], REASONING: [] },
|
||||
tiers: entries({
|
||||
SIMPLE: ["gpt-4o-mini", "claude-sonnet-4"],
|
||||
MEDIUM: ["claude-sonnet-4"],
|
||||
COMPLEX: [],
|
||||
REASONING: [],
|
||||
}),
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: undefined,
|
||||
});
|
||||
|
|
@ -29,9 +36,21 @@ describe("buildAutoRouterTestTargets", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("probes custom tier pools under their own labels, in entry order", () => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
tiers: entries({ CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["claude-opus-4", "gpt-4o-mini"] }),
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: undefined,
|
||||
});
|
||||
expect(targets).toEqual([
|
||||
{ labels: ["CASUAL", "SECURITY_REVIEW"], modelGroup: "gpt-4o-mini", mode: "chat" },
|
||||
{ labels: ["SECURITY_REVIEW"], modelGroup: "claude-opus-4", mode: "chat" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops empty/whitespace tiers", () => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [" "], REASONING: [] },
|
||||
tiers: entries({ SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [" "], REASONING: [] }),
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: undefined,
|
||||
});
|
||||
|
|
@ -41,7 +60,7 @@ describe("buildAutoRouterTestTargets", () => {
|
|||
it("returns [] when no tier is configured", () => {
|
||||
expect(
|
||||
buildAutoRouterTestTargets({
|
||||
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
tiers: entries({ SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }),
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: undefined,
|
||||
}),
|
||||
|
|
@ -50,7 +69,7 @@ describe("buildAutoRouterTestTargets", () => {
|
|||
|
||||
it("appends an embedding target only when semantic matching is on and a model is set", () => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
tiers: entries({ SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }),
|
||||
semanticMatchingEnabled: true,
|
||||
embeddingModel: "voyage-3-5",
|
||||
});
|
||||
|
|
@ -62,7 +81,7 @@ describe("buildAutoRouterTestTargets", () => {
|
|||
|
||||
it("omits the embedding target when semantic matching is on but no model is chosen", () => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
tiers: entries({ SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }),
|
||||
semanticMatchingEnabled: true,
|
||||
embeddingModel: undefined,
|
||||
});
|
||||
|
|
@ -71,7 +90,7 @@ describe("buildAutoRouterTestTargets", () => {
|
|||
|
||||
it("omits the embedding target when a model is set but semantic matching is off", () => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
tiers: entries({ SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }),
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: "voyage-3-5",
|
||||
});
|
||||
|
|
@ -112,7 +131,7 @@ describe("buildAutoRouterTestTargets", () => {
|
|||
|
||||
it.each([[undefined], [""], [" "]])("adds no default target for %o", (defaultModel) => {
|
||||
const targets = buildAutoRouterTestTargets({
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
tiers: entries({ SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }),
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: undefined,
|
||||
defaultModel,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
|
||||
export type AutoRouterTestMode = "chat" | "embedding";
|
||||
|
||||
export interface AutoRouterTestTarget {
|
||||
|
|
@ -9,40 +7,28 @@ export interface AutoRouterTestTarget {
|
|||
}
|
||||
|
||||
export interface BuildAutoRouterTestTargetsParams {
|
||||
tiers: ComplexityTiers;
|
||||
/** Ordered [tier name, model groups] entries of the active tier set. */
|
||||
tiers: [string, string[]][];
|
||||
semanticMatchingEnabled: boolean;
|
||||
embeddingModel: string | undefined;
|
||||
/** The resolved default model - see resolveComplexityDefaultModel. A live fallback destination,
|
||||
* so it is probed even when no tier lists it. */
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
// Keys drive iteration order; `satisfies Record<keyof ComplexityTiers, null>` makes it a
|
||||
// compile error to add a tier to ComplexityTiers without listing it here (and vice versa).
|
||||
const TIER_ORDER = Object.keys({
|
||||
SIMPLE: null,
|
||||
MEDIUM: null,
|
||||
COMPLEX: null,
|
||||
REASONING: null,
|
||||
} satisfies Record<keyof ComplexityTiers, null>) as (keyof ComplexityTiers)[];
|
||||
|
||||
export const buildAutoRouterTestTargets = ({
|
||||
tiers,
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
defaultModel,
|
||||
}: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => {
|
||||
const tieredByModel = TIER_ORDER.reduce<Record<string, string[]>>((acc, tier) => {
|
||||
return (tiers[tier] ?? []).reduce((tierAcc, rawModel) => {
|
||||
const tieredByModel = tiers.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] };
|
||||
}, acc);
|
||||
}, {});
|
||||
|
||||
// The default is a live destination whenever the chosen tier has no model, and when an LLM
|
||||
// classifier fails with "Route to the default model", so a green test that skipped it would be
|
||||
// reporting on a router it had not fully reached.
|
||||
// Classifier failures land on the default, so it is probed even when no tier lists it.
|
||||
const resolvedDefault = defaultModel?.trim();
|
||||
const groupedByModel =
|
||||
resolvedDefault && !(resolvedDefault in tieredByModel)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import {
|
|||
getTierLabelsError,
|
||||
hydrateTierLabels,
|
||||
BuildComplexityRouterConfigParams,
|
||||
getCustomTierRowsError,
|
||||
hydrateCustomTierSet,
|
||||
hydratePlanModeMinTier,
|
||||
} from "./build_complexity_router_config";
|
||||
|
||||
const tiers = {
|
||||
|
|
@ -673,3 +676,102 @@ describe("buildComplexityRouterConfig tier model params", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
const TIER_KEYS = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
|
||||
|
||||
describe("custom tier sets", () => {
|
||||
const customTierSet = {
|
||||
tiers: [
|
||||
{ id: "SIMPLE", name: "SIMPLE", definition: "", models: ["gpt-4o-mini"] },
|
||||
{ id: "COMPLEX", name: "COMPLEX", definition: "", models: ["claude-sonnet-4"] },
|
||||
{ id: "sec", name: "SECURITY_REVIEW", definition: "security audits", models: ["claude-sonnet-5"] },
|
||||
],
|
||||
fallback_tier_id: "COMPLEX",
|
||||
};
|
||||
const customParams: BuildComplexityRouterConfigParams = {
|
||||
...baseParams,
|
||||
customTierSet,
|
||||
classifierLlmConfig: {
|
||||
model: "haiku-classifier",
|
||||
timeout_ms: 400,
|
||||
classification_rubric: "agentic",
|
||||
system_prompt: "x",
|
||||
},
|
||||
tierLabels: { SIMPLE: "Cheap" },
|
||||
classifierFallback: "default_model",
|
||||
sessionAffinity: true,
|
||||
escalationKeywords: ["GO UP"],
|
||||
adaptive: true,
|
||||
planModeMinTier: "sec",
|
||||
tierBoundaries: { simple_medium: 0.2, medium_complex: 0.4, complex_reasoning: 0.6 },
|
||||
};
|
||||
|
||||
it("serializes rows in order and forces off everything the backend rejects beside tier_definitions", () => {
|
||||
const config = buildComplexityRouterConfig(customParams);
|
||||
expect(config).toEqual({
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], COMPLEX: ["claude-sonnet-4"], SECURITY_REVIEW: ["claude-sonnet-5"] },
|
||||
tier_definitions: [
|
||||
{ name: "SIMPLE" },
|
||||
{ name: "COMPLEX" },
|
||||
{ name: "SECURITY_REVIEW", description: "security audits" },
|
||||
],
|
||||
fallback_tier: "COMPLEX",
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "haiku-classifier", timeout_ms: 400 },
|
||||
session_affinity: false,
|
||||
deployment_affinity: true,
|
||||
escalation_keywords: [],
|
||||
plan_mode_min_tier: "SECURITY_REVIEW",
|
||||
});
|
||||
});
|
||||
|
||||
// A hand-written config keys tiers/fallback_tier in any case; the backend resolves with casefold.
|
||||
it("hydrates a hand-written config in stored order, matching names case-insensitively", () => {
|
||||
const hydrated = hydrateCustomTierSet({
|
||||
tier_definitions: [{ name: "audit", description: "a" }, { name: "simple" }],
|
||||
tiers: { Audit: "single-model-pin", simple: ["m1"] },
|
||||
fallback_tier: "AUDIT",
|
||||
});
|
||||
expect(hydrated?.tiers).toEqual([
|
||||
{ id: "stored-0", name: "audit", definition: "a", models: ["single-model-pin"] },
|
||||
{ id: "SIMPLE", name: "simple", definition: "", models: ["m1"] },
|
||||
]);
|
||||
expect(hydrated?.fallback_tier_id).toBe("stored-0");
|
||||
expect(hydrateCustomTierSet({ tiers: { SIMPLE: ["m"] } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getCustomTierRowsError reports the first per-row gap and passes a complete set", () => {
|
||||
const rows = customTierSet.tiers;
|
||||
const withRows = (tiers: typeof rows) => getCustomTierRowsError({ ...customTierSet, tiers });
|
||||
expect(getCustomTierRowsError(customTierSet)).toBeNull();
|
||||
expect(withRows(Array.from({ length: 9 }, (_, i) => ({ ...rows[2], id: `r${i}`, name: `T${i}` })))).toContain("8");
|
||||
expect(withRows([rows[2]])).toContain("2 to 8");
|
||||
expect(withRows([{ id: "a", name: " ", definition: "d", models: ["m"] }, rows[0]])).toBe("Name every tier");
|
||||
expect(withRows([{ id: "a", name: "A", definition: " ", models: ["m"] }, rows[0]])).toContain("definition");
|
||||
expect(withRows([...rows, { id: "new-1", name: "DRAFT", definition: "d", models: [] }])).toContain("model");
|
||||
expect(getCustomTierRowsError({ ...customTierSet, fallback_tier_id: "gone" })).toContain("Fallback");
|
||||
expect(withRows([{ ...rows[0], id: "dup", name: ` ${rows[0].name.toLowerCase()} ` }, ...rows])).toBe(
|
||||
"Tier names must be unique",
|
||||
);
|
||||
// A rule targets a tier by NAME, so an edit orphans it and the backend rejects the whole config.
|
||||
const names = rows.map((row) => row.name);
|
||||
const rules = [
|
||||
{ id: "a", keywords: ["audit"], tier: "security_review" },
|
||||
{ id: "b", keywords: ["gone"], tier: "REMOVED_TIER" },
|
||||
];
|
||||
expect(getKeywordTierRulesError(rules, names)).toContain("2");
|
||||
expect(getKeywordTierRulesError([rules[0]], names)).toBeNull();
|
||||
expect(getKeywordTierRulesError([rules[0]], TIER_KEYS)).toContain("1");
|
||||
});
|
||||
|
||||
// The floor is a row id on the form and a name on the wire; unresolvable means OFF at every layer.
|
||||
it("maps the plan-mode floor between row id and name, and drops it when the row is gone", () => {
|
||||
expect(hydratePlanModeMinTier("SECURITY_REVIEW", customTierSet)).toBe("sec");
|
||||
expect(hydratePlanModeMinTier("security_review", customTierSet)).toBe("sec");
|
||||
expect(hydratePlanModeMinTier("NOT_A_TIER", customTierSet)).toBeUndefined();
|
||||
expect(hydratePlanModeMinTier("COMPLEX", undefined)).toBe("COMPLEX");
|
||||
expect(buildComplexityRouterConfig({ ...customParams, planModeMinTier: "gone" })).not.toHaveProperty(
|
||||
"plan_mode_min_tier",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { KeywordTierRule } from "./KeywordTierRules";
|
||||
import { findTierByName, tierNamesMatch } from "./custom_tier_set";
|
||||
import { emptyKeywordTierRuleIndexes, serializeKeywordTierRules } from "./complexity_router_keywords";
|
||||
import { TierModelParams, TierModelParamsByTier, serializeTierModelConfigs } from "./complexity_router_tiers";
|
||||
import {
|
||||
TierModelParams,
|
||||
TierModelParamsByTier,
|
||||
normalizeTierModels,
|
||||
serializeTierModelConfigs,
|
||||
} from "./complexity_router_tiers";
|
||||
import {
|
||||
AdaptiveEligible,
|
||||
AdaptiveRouterWeights,
|
||||
|
|
@ -9,8 +15,10 @@ import {
|
|||
ClassifierType,
|
||||
ComplexityTierLabels,
|
||||
ComplexityTiers,
|
||||
CustomTierSet,
|
||||
DimensionWeights,
|
||||
TIER_DESCRIPTIONS,
|
||||
TIER_KEYS,
|
||||
TierBoundaries,
|
||||
TokenThresholds,
|
||||
effectiveTierLabel,
|
||||
|
|
@ -74,6 +82,7 @@ const scorerKnobPayload = ({
|
|||
|
||||
export interface BuildComplexityRouterConfigParams {
|
||||
tiers: ComplexityTiers;
|
||||
customTierSet?: CustomTierSet;
|
||||
defaultModel: string | undefined;
|
||||
planModeMinTier: string | undefined;
|
||||
tierLabels: ComplexityTierLabels | undefined;
|
||||
|
|
@ -103,8 +112,15 @@ export interface BuildComplexityRouterConfigParams {
|
|||
tierModelParams?: TierModelParamsByTier;
|
||||
}
|
||||
|
||||
export interface TierDefinitionPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ComplexityRouterConfigPayload {
|
||||
tiers: ComplexityTiers;
|
||||
tiers: ComplexityTiers | Record<string, string[]>;
|
||||
tier_definitions?: TierDefinitionPayload[];
|
||||
fallback_tier?: string;
|
||||
default_model?: string;
|
||||
plan_mode_min_tier?: string;
|
||||
tier_labels?: ComplexityTierLabels;
|
||||
|
|
@ -134,8 +150,6 @@ export interface ComplexityRouterConfigPayload {
|
|||
tier_model_configs?: Record<string, { model_name: string; litellm_params: TierModelParams }[]>;
|
||||
}
|
||||
|
||||
const TIER_KEYS: Array<keyof ComplexityTiers> = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
|
||||
|
||||
export const serializeTierLabels = (tierLabels: ComplexityTierLabels | undefined): ComplexityTierLabels | undefined => {
|
||||
const renamed = TIER_KEYS.map((tier) => [tier, tierLabels?.[tier]?.trim() ?? ""] as const).filter(
|
||||
([tier, label]) => label !== "" && label !== TIER_DESCRIPTIONS[tier].label,
|
||||
|
|
@ -192,10 +206,20 @@ export const getPlanModeTierError = (planModeMinTier: string | undefined, tiers:
|
|||
return `The plan-mode minimum tier (${planModeMinTier}) has no models. Add one or turn the override off.`;
|
||||
};
|
||||
|
||||
export const getKeywordTierRulesError = (keywordTierRules: KeywordTierRule[]): string | null => {
|
||||
// Rules point at a tier by NAME, so a tier edit can orphan one and the backend rejects the config.
|
||||
export const getKeywordTierRulesError = (
|
||||
keywordTierRules: KeywordTierRule[],
|
||||
activeTierNames?: readonly string[],
|
||||
): string | null => {
|
||||
const emptyRows = emptyKeywordTierRuleIndexes(keywordTierRules);
|
||||
if (emptyRows.length === 0) return null;
|
||||
return `Add at least one keyword to keyword rule(s): ${emptyRows.map((index) => index + 1).join(", ")}`;
|
||||
if (emptyRows.length > 0)
|
||||
return `Add at least one keyword to keyword rule(s): ${emptyRows.map((index) => index + 1).join(", ")}`;
|
||||
if (!activeTierNames) return null;
|
||||
const orphaned = keywordTierRules.flatMap((rule, index) =>
|
||||
activeTierNames.some((name) => tierNamesMatch(name, rule.tier)) ? [] : [index + 1],
|
||||
);
|
||||
if (orphaned.length === 0) return null;
|
||||
return `Keyword rule(s) ${orphaned.join(", ")} route to a tier this router no longer has`;
|
||||
};
|
||||
|
||||
export const getSemanticConfigError = ({
|
||||
|
|
@ -211,8 +235,100 @@ export const getSemanticConfigError = ({
|
|||
return null;
|
||||
};
|
||||
|
||||
// Mirrors validate_complexity_router_config_write (litellm/router_utils/auto_router_model_naming.py).
|
||||
export const KEYS_REJECTED_WITH_CUSTOM_TIERS: readonly string[] = [
|
||||
"plugins",
|
||||
"tier_labels",
|
||||
"classifier_fallback",
|
||||
"adaptive",
|
||||
"adaptive_weights",
|
||||
"tier_distance_penalty",
|
||||
"adaptive_eligible",
|
||||
"tier_boundaries",
|
||||
"token_thresholds",
|
||||
"dimension_weights",
|
||||
"reasoning_override_min_score",
|
||||
"escalation_keywords",
|
||||
"session_affinity",
|
||||
];
|
||||
|
||||
export const serializeCustomTierSet = (
|
||||
customTierSet: CustomTierSet,
|
||||
): Pick<ComplexityRouterConfigPayload, "tiers" | "tier_definitions" | "fallback_tier"> => {
|
||||
const fallbackName = customTierSet.tiers.find((row) => row.id === customTierSet.fallback_tier_id)?.name.trim();
|
||||
return {
|
||||
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() }),
|
||||
})),
|
||||
...(fallbackName && { fallback_tier: fallbackName }),
|
||||
};
|
||||
};
|
||||
|
||||
// A built-in name keeps its canonical key as the row id so Restore recognizes it.
|
||||
export const hydrateCustomTierSet = (parsedConfig: {
|
||||
tier_definitions?: unknown;
|
||||
fallback_tier?: unknown;
|
||||
tiers?: unknown;
|
||||
}): CustomTierSet | undefined => {
|
||||
if (!Array.isArray(parsedConfig.tier_definitions) || parsedConfig.tier_definitions.length === 0) return undefined;
|
||||
const storedTiers =
|
||||
typeof parsedConfig.tiers === "object" && parsedConfig.tiers !== null && !Array.isArray(parsedConfig.tiers)
|
||||
? (parsedConfig.tiers as Record<string, unknown>)
|
||||
: {};
|
||||
const rows = parsedConfig.tier_definitions.flatMap((entry, index): CustomTierSet["tiers"] => {
|
||||
if (typeof entry !== "object" || entry === null) return [];
|
||||
const { name, description } = entry as { name?: unknown; description?: unknown };
|
||||
if (typeof name !== "string" || !name.trim()) return [];
|
||||
return [
|
||||
{
|
||||
id: TIER_KEYS.find((tier) => tierNamesMatch(tier, name)) ?? `stored-${index}`,
|
||||
name: name.trim(),
|
||||
definition: typeof description === "string" ? description.trim() : "",
|
||||
models: normalizeTierModels(Object.entries(storedTiers).find(([tier]) => tierNamesMatch(tier, name))?.[1]),
|
||||
},
|
||||
];
|
||||
});
|
||||
if (rows.length === 0) return undefined;
|
||||
const storedFallback = typeof parsedConfig.fallback_tier === "string" ? parsedConfig.fallback_tier.trim() : "";
|
||||
return { tiers: rows, fallback_tier_id: findTierByName(rows, storedFallback)?.id ?? "" };
|
||||
};
|
||||
|
||||
// The floor is a ROW ID on the value; one rule at every layer: unresolvable means OFF.
|
||||
export const hydratePlanModeMinTier = (
|
||||
stored: unknown,
|
||||
customTierSet: CustomTierSet | undefined,
|
||||
): string | undefined => {
|
||||
if (typeof stored !== "string" || stored.trim() === "") return undefined;
|
||||
if (!customTierSet) return stored;
|
||||
return findTierByName(customTierSet.tiers, stored)?.id;
|
||||
};
|
||||
|
||||
// Everything an edited tier set forces onto the wire; shared by both builders.
|
||||
export const customTierSetWireFields = (
|
||||
customTierSet: CustomTierSet,
|
||||
classifierLlmConfig: ClassifierLLMConfig | undefined,
|
||||
planModeMinTierId: string | undefined,
|
||||
) => {
|
||||
const planModeName = customTierSet.tiers.find((row) => row.id === planModeMinTierId)?.name.trim();
|
||||
return {
|
||||
...serializeCustomTierSet(customTierSet),
|
||||
classifier_type: "llm" as const,
|
||||
...(classifierLlmConfig && {
|
||||
classifier_llm_config: { model: classifierLlmConfig.model, timeout_ms: classifierLlmConfig.timeout_ms },
|
||||
}),
|
||||
session_affinity: false,
|
||||
escalation_keywords: [] as string[],
|
||||
...(planModeName && { plan_mode_min_tier: planModeName }),
|
||||
};
|
||||
};
|
||||
|
||||
export { getCustomTierRowsError } from "./custom_tier_set";
|
||||
|
||||
export const buildComplexityRouterConfig = ({
|
||||
tiers,
|
||||
customTierSet,
|
||||
defaultModel,
|
||||
planModeMinTier,
|
||||
tierLabels,
|
||||
|
|
@ -241,7 +357,7 @@ export const buildComplexityRouterConfig = ({
|
|||
reasoningOverrideMinScore,
|
||||
tierModelParams,
|
||||
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
|
||||
const serializedTierModelConfigs = serializeTierModelConfigs(tiers, tierModelParams);
|
||||
const serializedTierModelConfigs = customTierSet ? undefined : serializeTierModelConfigs(tiers, tierModelParams);
|
||||
const cleanedEscalationKeywords = escalationKeywords.map((keyword) => keyword.trim()).filter(Boolean);
|
||||
const cleanedKeywordTierRules = serializeKeywordTierRules(keywordTierRules);
|
||||
const cleanedTierLabels = serializeTierLabels(tierLabels);
|
||||
|
|
@ -255,25 +371,27 @@ export const buildComplexityRouterConfig = ({
|
|||
};
|
||||
const scorerKnobs = scorerKnobPayload(scorerInputs);
|
||||
|
||||
return {
|
||||
// A custom set forces the LLM classifier, so llm-only inputs must survive a stale heuristic field.
|
||||
const effectiveType: ClassifierType = customTierSet ? "llm" : classifierType;
|
||||
const payload: ComplexityRouterConfigPayload = {
|
||||
tiers,
|
||||
...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }),
|
||||
...(defaultModel?.trim() && { default_model: defaultModel }),
|
||||
...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }),
|
||||
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
|
||||
classifier_type: classifierType,
|
||||
...(classifierType === "llm" &&
|
||||
...(effectiveType === "llm" &&
|
||||
classifierLlmConfig && { classifier_llm_config: normalizeClassifierLlmConfig(classifierLlmConfig) }),
|
||||
...(classifierType === "llm" && classifierFallback !== undefined && { classifier_fallback: classifierFallback }),
|
||||
...(classifierType === "llm" &&
|
||||
...(effectiveType === "llm" &&
|
||||
classifierContextWindowSize !== undefined && {
|
||||
classifier_context_window_size: classifierContextWindowSize,
|
||||
}),
|
||||
...(classifierType === "llm" &&
|
||||
...(effectiveType === "llm" &&
|
||||
classifierContextPerTurnChars !== undefined && {
|
||||
classifier_context_per_turn_chars: classifierContextPerTurnChars,
|
||||
}),
|
||||
...(classifierType === "llm" &&
|
||||
...(effectiveType === "llm" &&
|
||||
classifierContextIncludeAssistantTurns !== undefined && {
|
||||
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
|
||||
}),
|
||||
|
|
@ -296,4 +414,11 @@ export const buildComplexityRouterConfig = ({
|
|||
...(returnRawModelName && { return_raw_model_name: true }),
|
||||
...scorerKnobs,
|
||||
};
|
||||
if (!customTierSet) return payload;
|
||||
// Drop what the backend rejects beside tier_definitions; stale control state would fail the save.
|
||||
const { plan_mode_min_tier: planModeMinTierId, ...withFloorRemoved } = payload;
|
||||
const rest = Object.fromEntries(
|
||||
Object.entries(withFloorRemoved).filter(([key]) => !KEYS_REJECTED_WITH_CUSTOM_TIERS.includes(key)),
|
||||
) as typeof withFloorRemoved;
|
||||
return { ...rest, ...customTierSetWireFields(customTierSet, classifierLlmConfig, planModeMinTierId) };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
customTierDefaultModel,
|
||||
hydrateTierModelParams,
|
||||
normalizeTierModels,
|
||||
pruneTierModelParams,
|
||||
|
|
@ -234,3 +235,16 @@ describe("pruneTierModelParams", () => {
|
|||
expect(pruneTierModelParams(current, "MEDIUM", [])).toBe(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom tier derivations", () => {
|
||||
const set = (names: string[], fallbackId: string) => ({
|
||||
tiers: names.map((name, index) => ({ id: `r${index}`, name, definition: "d", models: [`${name}-model`] })),
|
||||
fallback_tier_id: fallbackId,
|
||||
});
|
||||
|
||||
// The backend resolves with casefold, so a set spelling its tier "medium" derives the same default.
|
||||
it("resolves the MEDIUM and SIMPLE derivation case-insensitively", () => {
|
||||
expect(customTierDefaultModel(set(["medium", "simple"], "gone"))).toBe("medium-model");
|
||||
expect(customTierDefaultModel(set(["simple"], "gone"))).toBe("simple-model");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
import type { ComplexityTiers, CustomTierSet } from "./ComplexityRouterConfig";
|
||||
import type { ComplexityTier } from "./KeywordTierRules";
|
||||
import { findTierByName } from "./custom_tier_set";
|
||||
|
||||
export type TierModelParams = Record<string, unknown>;
|
||||
|
||||
|
|
@ -133,7 +134,23 @@ 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,
|
||||
}));
|
||||
|
||||
/** Backend derivation for an edited set: pin, then the fallback tier's pool, then MEDIUM/SIMPLE. */
|
||||
export const customTierDefaultModel = (customTierSet: CustomTierSet, pinned?: string): string | undefined => {
|
||||
const rowNamed = (name: string) => findTierByName(customTierSet.tiers, name);
|
||||
const fallbackRow = customTierSet.tiers.find((row) => row.id === customTierSet.fallback_tier_id);
|
||||
return pinned?.trim() || fallbackRow?.models[0] || rowNamed("MEDIUM")?.models[0] || rowNamed("SIMPLE")?.models[0];
|
||||
};
|
||||
|
||||
export const defaultRuleTier = (tierNames?: string[]): string =>
|
||||
!tierNames || tierNames.includes("COMPLEX") ? "COMPLEX" : tierNames[0] ?? "COMPLEX";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import type { ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
|
||||
export interface TierDraft {
|
||||
/** React key and fallback pointer target; never serialized. */
|
||||
id: string;
|
||||
name: string;
|
||||
/** Blank on a built-in name inherits the built-in criteria. */
|
||||
definition: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
// Absent means the built-in four-tier router and a payload identical to before this field existed.
|
||||
export interface CustomTierSet {
|
||||
tiers: TierDraft[];
|
||||
fallback_tier_id: string;
|
||||
}
|
||||
|
||||
const BUILT_IN_TIER_NAMES = Object.keys({
|
||||
SIMPLE: null,
|
||||
MEDIUM: null,
|
||||
COMPLEX: null,
|
||||
REASONING: null,
|
||||
} satisfies Record<keyof ComplexityTiers, null>);
|
||||
|
||||
// One rule for comparing tier names, matching the backend's casefold
|
||||
// (litellm/router_strategy/complexity_router/config.py), so no two lookups can disagree.
|
||||
export const foldTierName = (name: string): string => name.trim().toLowerCase();
|
||||
|
||||
export const tierNamesMatch = (left: string, right: string): boolean => foldTierName(left) === foldTierName(right);
|
||||
|
||||
export const findTierByName = <T extends { name: string }>(rows: readonly T[], name: string): T | undefined =>
|
||||
rows.find((row) => tierNamesMatch(row.name, name));
|
||||
|
||||
export const isBuiltInTierName = (name: string): boolean =>
|
||||
BUILT_IN_TIER_NAMES.some((tier) => tierNamesMatch(tier, name));
|
||||
|
||||
// Mirrors the backend's 2..8 tier_definitions rule; Restore defaults can push a full set past it.
|
||||
export const MIN_TIER_COUNT = 2;
|
||||
export const MAX_TIER_COUNT = 8;
|
||||
|
||||
export const getCustomTierRowsError = (customTierSet: CustomTierSet): string | null => {
|
||||
const rows = customTierSet.tiers;
|
||||
if (rows.length < MIN_TIER_COUNT || rows.length > MAX_TIER_COUNT)
|
||||
return `A tier set needs ${MIN_TIER_COUNT} to ${MAX_TIER_COUNT} tiers`;
|
||||
if (rows.some((row) => !row.name.trim())) return "Name every tier";
|
||||
const names = rows.map((row) => foldTierName(row.name));
|
||||
if (new Set(names).size !== names.length) return "Tier names must be unique";
|
||||
if (rows.some((row) => !row.definition.trim() && !isBuiltInTierName(row.name)))
|
||||
return "Every custom tier needs a definition: it is the rubric the classifier routes on";
|
||||
if (rows.some((row) => row.models.length === 0)) return "Select at least one model for every tier";
|
||||
if (!rows.some((row) => row.id === customTierSet.fallback_tier_id))
|
||||
return "Pick a Fallback Tier for classifier failures";
|
||||
return null;
|
||||
};
|
||||
|
|
@ -47,13 +47,13 @@ const MODEL_DATA = {
|
|||
model_info: { id: "auto-1", access_groups: [] },
|
||||
};
|
||||
|
||||
const renderModal = () =>
|
||||
const renderModal = (modelData: typeof MODEL_DATA = MODEL_DATA) =>
|
||||
renderWithProviders(
|
||||
<EditAutoRouterModal
|
||||
isVisible
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
modelData={MODEL_DATA}
|
||||
modelData={modelData}
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
/>,
|
||||
|
|
@ -799,3 +799,29 @@ describe("EditAutoRouterModal plan-mode minimum tier", () => {
|
|||
expect(savedConfig()).not.toHaveProperty("plan_mode_min_tier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditAutoRouterModal stored custom tier set", () => {
|
||||
// Before the pass-through this rebuilt the built-in four over a custom pool set, and the
|
||||
// surviving tier_definitions then disagreed with `tiers`, failing the router at load.
|
||||
const CONFIG = {
|
||||
tiers: { CASUAL: ["gpt-4o-mini"], SECURITY_REVIEW: ["gpt-4o-mini"] },
|
||||
tier_definitions: [
|
||||
{ name: "CASUAL", description: "chat" },
|
||||
{ name: "SECURITY_REVIEW", description: "audits" },
|
||||
],
|
||||
fallback_tier: "CASUAL",
|
||||
classifier_type: "llm",
|
||||
};
|
||||
|
||||
it("says the editor is unavailable and saves the stored config byte-identical", async () => {
|
||||
modelPatchUpdateCall.mockClear();
|
||||
renderModal({ ...MODEL_DATA, litellm_params: { ...MODEL_DATA.litellm_params, complexity_router_config: CONFIG } });
|
||||
|
||||
await screen.findByText(/custom tier set, which this form cannot edit yet/i);
|
||||
expect(screen.queryByText("Complexity Tier Configuration")).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig()).toEqual(CONFIG);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -137,12 +137,20 @@ export interface KeywordMatchingState {
|
|||
matchThreshold: number;
|
||||
}
|
||||
|
||||
// This form hydrates only the four built-in tiers, so rebuilding from it would drop custom pools
|
||||
// while the preserved tier_definitions still name them: a save would corrupt or be rejected.
|
||||
export const hasStoredCustomTierSet = (storedConfig: unknown): boolean => {
|
||||
const definitions = toRecord(storedConfig).tier_definitions;
|
||||
return Array.isArray(definitions) && definitions.length > 0;
|
||||
};
|
||||
|
||||
export const buildUpdatedComplexityRouterConfig = (
|
||||
storedConfig: unknown,
|
||||
value: ComplexityRouterConfigValue,
|
||||
customTechnicalKeywords?: string[],
|
||||
keywordMatching?: KeywordMatchingState,
|
||||
): Record<string, unknown> => {
|
||||
if (hasStoredCustomTierSet(storedConfig)) return toRecord(storedConfig);
|
||||
const isManaged = (key: string): boolean => {
|
||||
if (MANAGED_COMPLEXITY_ROUTER_KEYS.has(key)) return true;
|
||||
if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
|
||||
|
|
@ -281,6 +289,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
classifier_type: "heuristic",
|
||||
});
|
||||
const isComplexityRouterModel = isComplexityRouter(modelData?.litellm_params);
|
||||
const storedCustomTierSet = hasStoredCustomTierSet(modelData?.litellm_params?.complexity_router_config);
|
||||
|
||||
const schema = useMemo(
|
||||
() => (isComplexityRouterModel ? complexityRouterSchema : semanticRouterSchema),
|
||||
|
|
@ -291,14 +300,15 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
// Mirrors the create form: the button says why it is unavailable and disables on the same
|
||||
// answer. Tiers use this modal's own rule, which allows a partly filled router, so an edit that
|
||||
// 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);
|
||||
const submitBlockedReason =
|
||||
!isComplexityRouterModel || storedCustomTierSet
|
||||
? 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);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && modelData) {
|
||||
|
|
@ -451,6 +461,22 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
};
|
||||
|
||||
const saveValues = async (values: EditAutoRouterFormValues) => {
|
||||
if (isComplexityRouterModel && storedCustomTierSet) {
|
||||
const updatedModelInfo = {
|
||||
...modelData.model_info,
|
||||
access_groups: values.model_access_group || [],
|
||||
};
|
||||
const renamed = {
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: modelData.litellm_params,
|
||||
model_info: updatedModelInfo,
|
||||
};
|
||||
await modelPatchUpdateCall(accessToken, renamed, modelData.model_info.id);
|
||||
toast.success("Auto router configuration updated successfully");
|
||||
onSuccess({ ...modelData, ...renamed });
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (isComplexityRouterModel) {
|
||||
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
|
||||
if (Object.values(tiers).every((models) => models.length === 0)) {
|
||||
|
|
@ -606,8 +632,13 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g., auto_router_1, smart_routing" />}
|
||||
</FormField>
|
||||
|
||||
{isComplexityRouterModel ? (
|
||||
/* Complexity Router Configuration */
|
||||
{isComplexityRouterModel && storedCustomTierSet && (
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
This router uses a custom tier set, which this form cannot edit yet. Its complexity configuration is
|
||||
preserved exactly as stored; the name and access settings above still save.
|
||||
</span>
|
||||
)}
|
||||
{isComplexityRouterModel && !storedCustomTierSet && (
|
||||
<div className="w-full">
|
||||
<ComplexityRouterConfig
|
||||
showValidationErrors={showValidationErrors}
|
||||
|
|
@ -630,7 +661,8 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{!isComplexityRouterModel && (
|
||||
<>
|
||||
{/* Router Configuration Builder */}
|
||||
<div className="w-full">
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ 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 { normalizeTierModels } from "./add_model/complexity_router_tiers";
|
||||
import {
|
||||
hasAutoRouterEditor,
|
||||
isAutoRouterDeployment,
|
||||
|
|
@ -91,24 +91,22 @@ const buildComplexityRouterTestTargets = (
|
|||
config = rawConfig;
|
||||
}
|
||||
|
||||
const tiers = {
|
||||
SIMPLE: normalizeTierModels(config.tiers?.SIMPLE),
|
||||
MEDIUM: normalizeTierModels(config.tiers?.MEDIUM),
|
||||
COMPLEX: normalizeTierModels(config.tiers?.COMPLEX),
|
||||
REASONING: normalizeTierModels(config.tiers?.REASONING),
|
||||
};
|
||||
const tiers: [string, string[]][] =
|
||||
config.tiers && typeof config.tiers === "object"
|
||||
? Object.entries(config.tiers).map(([tier, value]) => [tier, normalizeTierModels(value)])
|
||||
: [];
|
||||
|
||||
// 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).
|
||||
// calls (see PR #36615 discussion). Tier-derived defaults are already in a probed pool.
|
||||
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),
|
||||
defaultModel: effectiveDefaultModel,
|
||||
};
|
||||
return buildAutoRouterTestTargets(testTargetParams);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
} from "@/components/add_model/build_complexity_router_config";
|
||||
import {
|
||||
ComplexityRouterConfigValue,
|
||||
ComplexityTiers,
|
||||
ClassifierType,
|
||||
ClassifierLLMConfig,
|
||||
DEFAULT_SESSION_AFFINITY,
|
||||
|
|
@ -43,15 +42,7 @@ export const getRequiredModels = (
|
|||
config: Pick<ComplexityRouterConfigPayload, "tiers" | "classifier_llm_config" | "embedding_model" | "default_model">,
|
||||
): Set<string> => {
|
||||
const { tiers, classifier_llm_config: classifier, embedding_model: embedding, default_model: pinned } = config;
|
||||
const models = [
|
||||
...tiers.SIMPLE,
|
||||
...tiers.MEDIUM,
|
||||
...tiers.COMPLEX,
|
||||
...tiers.REASONING,
|
||||
classifier?.model,
|
||||
embedding,
|
||||
pinned,
|
||||
];
|
||||
const models = [...Object.values(tiers).flat(), classifier?.model, embedding, pinned];
|
||||
// Boolean(), not != null: an empty-string placeholder (e.g. classifier_llm_config seeded before a
|
||||
// model is chosen) is never a real model reference either.
|
||||
return new Set(models.filter((model): model is string => Boolean(model)));
|
||||
|
|
@ -191,7 +182,7 @@ export const getMissingModelsInPreset = (preset: AutoRouterPreset, availability:
|
|||
// effect would block submit for a model that was never going to be submitted.
|
||||
export const getReferencedModelsError = (
|
||||
params: {
|
||||
tiers: ComplexityTiers;
|
||||
tiers: ComplexityRouterConfigPayload["tiers"];
|
||||
classifierType: ClassifierType;
|
||||
classifierLlmConfig: ClassifierLLMConfig | undefined;
|
||||
semanticMatchingEnabled: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue