feat(ui): configure capability and Fuse v2 classifiers

This commit is contained in:
Tin Chi Lo 2026-09-15 18:28:21 -07:00
parent 878716f806
commit 39b7812810
26 changed files with 2593 additions and 742 deletions

View file

@ -104,8 +104,8 @@ export function AutoRoutersPanel({
<DialogHeader>
<DialogTitle>Add Auto Router</DialogTitle>
<DialogDescription>
Routes each request to a model by classifying its complexity. Called like any other model, so clients keep
using a single model name.
Choose a classifier to route each request to a model. Called like any other model, so clients keep using a
single model name.
</DialogDescription>
</DialogHeader>
<AddAutoRouterTab

View file

@ -57,6 +57,8 @@ const dedupe = (models: string[]): string[] => Array.from(new Set(models));
const COMPLEXITY_TYPE_LABELS: Record<string, string> = {
llm: "LLM Classifier",
capability: "Capability",
llm_v2: "Fuse v2",
heuristic_first: "Heuristic first",
hybrid: "Hybrid",
custom: "Custom classifier",

View file

@ -0,0 +1,75 @@
import React, { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils";
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
const initial: ComplexityRouterConfigValue = {
classifier_type: "llm",
tiers: { SIMPLE: ["efficient"], MEDIUM: [], COMPLEX: [], REASONING: ["capable"] },
};
function Form({ initialValue = initial }: { initialValue?: ComplexityRouterConfigValue }) {
const [value, setValue] = useState(initialValue);
return (
<AutoRouterClassifierTabs value={value} onChange={setValue}>
<output aria-label="Classifier type">{value.classifier_type}</output>
</AutoRouterClassifierTabs>
);
}
describe("AutoRouterClassifierTabs", () => {
it.each(["heuristic", "heuristic_v2", "llm", "heuristic_first", "hybrid"] as const)(
"groups %s under Complexity without resetting its configuration",
(classifier_type) => {
const onChange = vi.fn();
renderWithProviders(
<AutoRouterClassifierTabs value={{ ...initial, classifier_type }} onChange={onChange}>
Existing classifier settings
</AutoRouterClassifierTabs>,
);
expect(screen.getByRole("tab", { name: "Complexity" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel", { name: "Complexity" })).toHaveTextContent("Existing classifier settings");
fireEvent.click(screen.getByRole("tab", { name: "Complexity" }));
expect(onChange).not.toHaveBeenCalled();
},
);
it.each([
["capability", "Capability"],
["llm_v2", "Fuse v2"],
] as const)("opens saved %s settings and switches back to local Complexity", (classifier_type, label) => {
renderWithProviders(<Form initialValue={{ ...initial, classifier_type }} />);
expect(screen.getByRole("tab", { name: label })).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByRole("tab", { name: "Complexity" }));
expect(screen.getByRole("tab", { name: "Complexity" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("status", { name: "Classifier type" })).toHaveTextContent("heuristic");
});
it("keeps custom tiers editable under Complexity and explains why forecast tabs are disabled", () => {
const onChange = vi.fn();
renderWithProviders(
<AutoRouterClassifierTabs
value={{
...initial,
custom_tier_set: {
tiers: [{ id: "review", name: "REVIEW", definition: "Code reviews", models: ["capable"] }],
fallback_tier_id: "review",
},
}}
onChange={onChange}
>
Custom tiers
</AutoRouterClassifierTabs>,
);
expect(screen.getByRole("tabpanel", { name: "Complexity" })).toHaveTextContent("Custom tiers");
for (const name of ["Capability", "Fuse v2"]) {
const tab = screen.getByRole("tab", { name });
expect(tab).toHaveAttribute("aria-disabled", "true");
expect(tab).toHaveAccessibleDescription("Restore standard tiers to use Capability or Fuse v2.");
fireEvent.click(tab);
}
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByText("Restore standard tiers to use Capability or Fuse v2.")).toBeVisible();
});
});

View file

@ -0,0 +1,58 @@
import React, { useId } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { effectiveClassifierType, type ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { transitionClassifierType } from "./classifier_type_transition";
import { isForecastClassifier } from "./forecast_classifier_config";
interface AutoRouterClassifierTabsProps {
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
children: React.ReactNode;
}
const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ value, onChange, children }) => {
const restrictionId = useId();
const classifierType = effectiveClassifierType(value);
const selected = isForecastClassifier(classifierType) ? classifierType : "complexity";
const hasCustomTiers = Boolean(value.custom_tier_set);
const handleChange = (tab: unknown) => {
if (tab === selected) return;
if (tab === "complexity") {
onChange(transitionClassifierType(value, isForecastClassifier(classifierType) ? "heuristic" : classifierType));
} else if (!hasCustomTiers && (tab === "capability" || tab === "llm_v2")) {
onChange(transitionClassifierType(value, tab));
}
};
return (
<Tabs value={selected} onValueChange={handleChange}>
<p className="text-sm font-medium">Classifier type</p>
<TabsList aria-label="Classifier type" className="w-full">
<TabsTrigger value="complexity">Complexity</TabsTrigger>
<TabsTrigger
value="capability"
disabled={hasCustomTiers}
aria-describedby={hasCustomTiers ? restrictionId : undefined}
>
Capability
</TabsTrigger>
<TabsTrigger
value="llm_v2"
disabled={hasCustomTiers}
aria-describedby={hasCustomTiers ? restrictionId : undefined}
>
Fuse v2
</TabsTrigger>
</TabsList>
{hasCustomTiers && (
<p id={restrictionId} className="text-sm text-muted-foreground">
Restore standard tiers to use Capability or Fuse v2.
</p>
)}
<TabsContent value={selected}>{children}</TabsContent>
</Tabs>
);
};
export default AutoRouterClassifierTabs;

View file

@ -1,3 +1,4 @@
import { transitionClassifierType } from "./classifier_type_transition";
import { Info } from "lucide-react";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { MultiSelect } from "@/components/shared/MultiSelect";
@ -17,7 +18,6 @@ import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect";
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
import ClassifierVisionConfig from "./ClassifierVisionConfig";
import type { ReasoningEffort } from "./complexity_router_tiers";
import { nonReasoningTierFields } from "./nonReasoningTierFields";
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
import {
ClassificationFrequency,
@ -33,12 +33,10 @@ import {
DEFAULT_CLASSIFIER_FALLBACK,
DEFAULT_CLASSIFIER_TIMEOUT_MS,
DEFAULT_CLASSIFICATION_RUBRIC,
NEW_CLASSIFIER_CLASSIFICATION_RUBRIC,
ClassificationRubric,
effectiveTierLabel,
heuristicScoringRole,
usesLlmClassifier,
DEFAULT_HEURISTIC_FIRST_MAX_TIER,
DEFAULT_HYBRID_BOUNDARY_MARGIN,
HEURISTIC_FIRST_MAX_TIER_KEYS,
effectiveClassifierType,
@ -263,35 +261,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
const explicitlySupportedClassifierEfforts = effortOptionsByModel[classifierModel];
const handleClassifierTypeChange = (classifierType: ClassifierType) => {
const nextValue: ComplexityRouterConfigValue = {
...value,
classifier_type: classifierType,
classifier_llm_config: usesLlmClassifier(classifierType)
? value.classifier_llm_config ?? {
model: "",
timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS,
classification_rubric: NEW_CLASSIFIER_CLASSIFICATION_RUBRIC,
}
: undefined,
classifier_context_window_size: usesLlmClassifier(classifierType)
? value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE
: undefined,
classifier_context_budget_chars: usesLlmClassifier(classifierType)
? value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS
: undefined,
classifier_context_include_assistant_turns: usesLlmClassifier(classifierType)
? value.classifier_context_include_assistant_turns
: undefined,
classifier_fallback: usesLlmClassifier(classifierType) ? value.classifier_fallback : undefined,
heuristic_first_max_tier:
classifierType === "heuristic_first"
? value.heuristic_first_max_tier ?? DEFAULT_HEURISTIC_FIRST_MAX_TIER
: undefined,
hybrid_boundary_margin:
classifierType === "hybrid" ? value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN : undefined,
...nonReasoningTierFields(classifierType, value),
};
onChange(nextValue);
onChange(transitionClassifierType(value, classifierType));
};
const handleHeuristicFirstMaxTierChange = (tier: string) => {
@ -433,27 +403,6 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
});
};
if (classifierType === "capability") {
return (
<p className="text-sm text-muted-foreground">
This router uses capability forecasting. Configure its classifier, threshold, and calibration through YAML or
the API. Saving preserves those settings
</p>
);
}
if (classifierType === "llm_v2") {
return (
<div className="rounded-md border p-4 text-sm">
<strong>LLM V2 classifier (experimental)</strong>
<p className="mt-2 text-muted-foreground">
Combines task demands and model capability in one forecast. Its solver profiles and quality allowance are
configured through the API. Saving this router preserves those settings
</p>
</div>
);
}
return (
<>
<ClassifierTypeRadios value={value} classifierType={classifierType} onTypeChange={handleClassifierTypeChange} />

View file

@ -1,8 +1,11 @@
import RoutingOptions from "./RoutingOptions";
import PlanModeOverrideControls from "./PlanModeOverrideControls";
import ForecastClassifierConfig, { ForecastSolverModels } from "./ForecastClassifierConfig";
import { isForecastClassifier, type CapabilitySettings, type FuseSettings } from "./forecast_classifier_config";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import DefaultModelField from "./DefaultModelField";
import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { AffinityControls } from "./AffinityControls";
import NonReasoningTierToggle from "./NonReasoningTierToggle";
@ -204,11 +207,6 @@ const rowOrigin = (row: TierRow, editing: boolean): string => {
return isBuiltInTierName(row.name) ? "built-in" : "custom";
};
const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => {
if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`;
return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier";
};
const builtInTierInfo = (rowId: string): { label: string; description: string; examples: string } | undefined => {
const builtIn = ALL_BUILT_IN_TIERS.find((tier) => tier === rowId);
return builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined;
@ -376,6 +374,8 @@ export interface ComplexityRouterConfigValue {
/** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */
default_model?: string;
classifier_type: ClassifierType;
capability_classifier_config?: CapabilitySettings;
llm_v2_config?: FuseSettings;
classifier_llm_config?: ClassifierLLMConfig;
classifier_context_window_size?: number;
classifier_context_budget_chars?: number;
@ -535,44 +535,6 @@ export const DEFAULT_HYBRID_BOUNDARY_MARGIN = 0.03;
*/
export const HEURISTIC_FIRST_MAX_TIER_KEYS = TIER_ORDER.slice(0, -1);
const PlanModeOverrideControls: React.FC<{
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
planModeTierOptions: { value: string; label: string }[];
}> = ({ value, onChange, planModeTierOptions }) => (
<>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.plan_mode_min_tier !== undefined}
disabled={planModeTierOptions.length === 0}
onCheckedChange={(enabled) =>
onChange({
...value,
plan_mode_min_tier: enabled ? planModeTierOptions.at(-1)?.value : undefined,
})
}
aria-label="Route plan-mode requests to a minimum tier"
/>
<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.
{planModeTierOptions.length === 0 && " Add models to a tier to enable this."}
</span>
{value.plan_mode_min_tier !== undefined && (
<div style={{ maxWidth: 320 }}>
<TierRowSelect
label="Plan-mode minimum tier"
options={planModeTierOptions}
value={value.plan_mode_min_tier ?? null}
onValueChange={(tier) => onChange({ ...value, plan_mode_min_tier: tier })}
/>
</div>
)}
</>
);
const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
modelInfo,
value,
@ -596,6 +558,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
onAutoRouterCompressionChange,
showValidationErrors = false,
}) => {
const forecast = isForecastClassifier(value.classifier_type);
const customTierSet = value.custom_tier_set;
const tierRows = activeTierRows(value);
const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null;
@ -605,8 +568,6 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
value: row.id,
label: tierRowLabel(row, value.tier_labels),
}));
const derivedDefaultModel = resolveComplexityDefaultModel(value);
const defaultModelPlaceholder = defaultModelPlaceholderFor(derivedDefaultModel, Boolean(customTierSet));
const defaultModel = resolveComplexityDefaultModel(value, value.default_model);
const dispatch = (action: TierSetAction) => {
@ -641,298 +602,321 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
tier_model_params: setTierModelParam(value.tier_model_params, tier, model, change),
});
// Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as
// "track the tiers" everywhere downstream instead of as a blank model name.
const handleDefaultModelChange = (model: string | null | undefined) => {
onChange({ ...value, default_model: model || undefined });
};
const handleTierLabelChange = (tier: keyof ComplexityTiers, label: string) => {
onChange({
...value,
tier_labels: { ...value.tier_labels, [tier]: label },
});
};
const handleTierLabelChange = (tier: keyof ComplexityTiers, label: string) =>
onChange({ ...value, tier_labels: { ...value.tier_labels, [tier]: label } });
return (
<div className="w-full max-w-none">
<div className="inline-flex items-center gap-2 mb-4">
<h4 className="m-0 text-xl font-semibold text-foreground">Complexity Tier Configuration</h4>
<SimpleTooltip content="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
<h4 className="m-0 text-xl font-semibold text-foreground">
{forecast ? "Solver models" : "Complexity Tier Configuration"}
</h4>
{!forecast && (
<SimpleTooltip content="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
)}
</div>
<TierConfigIntro value={value} />
<Card>
<CardContent>
{!customTierSet && (
<NonReasoningTierToggle value={value} onChange={onChange} available={value.classifier_type === "llm"} />
)}
{tierRows.map((row, index) => {
const tierInfo = builtInTierInfo(row.id);
const label = tierRowLabel(row, value.tier_labels);
const tierMissing = showValidationErrors && row.models.length === 0;
const needsDefinition = Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name);
const definitionMissing = showValidationErrors && needsDefinition;
const showsDisplayName = !customTierSet && !editingTiers;
return (
<div key={row.id}>
{index > 0 && <Separator className="my-4" />}
<div className="mb-4">
<TierRowHeader
row={row}
index={index}
rowCount={tierRows.length}
label={label}
description={tierInfo?.description}
editing={editingTiers}
isCustomSet={Boolean(customTierSet)}
onRemove={() => removeTierRow(row.id)}
/>
{tierInfo && !customTierSet && (
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
)}
{editingTiers && (
<TierRowEditFields
row={row}
index={index}
definitionMissing={definitionMissing}
onPatch={(patch) => updateTierRow(row.id, patch)}
/>
)}
{showsDisplayName && tierInfo && (
<InputGroup className="mb-2">
<InputGroupInput
value={value.tier_labels?.[row.id as keyof ComplexityTiers] ?? ""}
onChange={(event) => handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)}
placeholder={`Display name (default: ${tierInfo.label})`}
aria-label={`Display name for the ${tierInfo.label} tier`}
/>
{value.tier_labels?.[row.id as keyof ComplexityTiers] && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={`Clear display name for the ${tierInfo.label} tier`}
onClick={() => handleTierLabelChange(row.id as keyof ComplexityTiers, "")}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
)}
<MultiSelect
options={modelOptions}
value={row.models}
onValueChange={(models: string[]) => setRowModels(row, models)}
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
emptyText="No models found"
className={tierMissing ? "w-full border-destructive" : "w-full"}
/>
<TierModelEffortRows
tierLabel={label}
models={row.models}
effortOptionsByModel={tierEffortOptionsByModel}
paramsByModel={row.params}
fastModeByModel={fastModeByModel}
onEffortChange={(model, effort) =>
handleTierModelParamChange(row.id, model, ["reasoning_effort", effort])
}
onFastModeChange={(model, enabled) =>
handleTierModelParamChange(row.id, model, ["speed", enabled ? "fast" : undefined])
}
/>
{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>}
</div>
</div>
);
})}
<TierSetToolbar
editing={editingTiers}
isCustomSet={Boolean(customTierSet)}
rowCount={tierRows.length}
rowsError={tierRowsError}
keywordRulesError={keywordRulesError}
onEditingChange={onEditingTiersChange}
onAdd={addCustomTier}
onRestore={exitToBuiltInTiers}
{forecast ? (
<>
<ForecastSolverModels
value={value}
onChange={onChange}
modelOptions={modelOptions}
effortOptionsByModel={tierEffortOptionsByModel}
fastModeByModel={fastModeByModel}
/>
<ForecastClassifierConfig
value={value}
onChange={onChange}
modelOptions={modelOptions}
effortOptionsByModel={classifierEffortOptionsByModel}
/>
</>
) : (
<>
<TierConfigIntro value={value} />
{customTierSet && (
<FallbackTierField
rows={tierRows}
fallbackTierId={customTierSet.fallback_tier_id}
onValueChange={(fallbackTierId) => onChange(setFallbackTier(value, fallbackTierId))}
/>
)}
<Card>
<CardContent>
{!customTierSet && (
<NonReasoningTierToggle value={value} onChange={onChange} available={value.classifier_type === "llm"} />
)}
<Separator className="my-4" />
{tierRows.map((row, index) => {
const tierInfo = builtInTierInfo(row.id);
const label = tierRowLabel(row, value.tier_labels);
const tierMissing = showValidationErrors && row.models.length === 0;
const needsDefinition =
Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name);
const definitionMissing = showValidationErrors && needsDefinition;
const showsDisplayName = !customTierSet && !editingTiers;
return (
<div key={row.id}>
{index > 0 && <Separator className="my-4" />}
<div className="mb-4">
<TierRowHeader
row={row}
index={index}
rowCount={tierRows.length}
label={label}
description={tierInfo?.description}
editing={editingTiers}
isCustomSet={Boolean(customTierSet)}
onRemove={() => removeTierRow(row.id)}
/>
{tierInfo && !customTierSet && (
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
)}
{editingTiers && (
<TierRowEditFields
row={row}
index={index}
definitionMissing={definitionMissing}
onPatch={(patch) => updateTierRow(row.id, patch)}
/>
)}
{showsDisplayName && tierInfo && (
<InputGroup className="mb-2">
<InputGroupInput
value={value.tier_labels?.[row.id as keyof ComplexityTiers] ?? ""}
onChange={(event) =>
handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)
}
placeholder={`Display name (default: ${tierInfo.label})`}
aria-label={`Display name for the ${tierInfo.label} tier`}
/>
{value.tier_labels?.[row.id as keyof ComplexityTiers] && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={`Clear display name for the ${tierInfo.label} tier`}
onClick={() => handleTierLabelChange(row.id as keyof ComplexityTiers, "")}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
)}
<MultiSelect
options={modelOptions}
value={row.models}
onValueChange={(models: string[]) => setRowModels(row, models)}
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
emptyText="No models found"
className={tierMissing ? "w-full border-destructive" : "w-full"}
/>
<TierModelEffortRows
tierLabel={label}
models={row.models}
effortOptionsByModel={tierEffortOptionsByModel}
paramsByModel={row.params}
fastModeByModel={fastModeByModel}
onEffortChange={(model, effort) =>
handleTierModelParamChange(row.id, model, ["reasoning_effort", effort])
}
onFastModeChange={(model, enabled) =>
handleTierModelParamChange(row.id, model, ["speed", enabled ? "fast" : undefined])
}
/>
{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>}
</div>
</div>
);
})}
<div className="mb-2">
<div className="flex items-center gap-2 mb-2">
<strong className="text-base font-semibold">Default Model</strong>
<SimpleTooltip content="Leave empty to follow the tiers. A model chosen here is pinned: it stays the default however the tiers change.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
</div>
<SearchSelect
options={modelOptions}
value={value.default_model ?? ""}
onValueChange={handleDefaultModelChange}
placeholder={defaultModelPlaceholder}
emptyText="No models found"
aria-label="Default model"
/>
<span className="block mt-1 text-xs text-muted-foreground">
Used when the tier the request lands in has no model, and when the classifier fails with &quot;Route to
the default model&quot; selected.
</span>
</div>
</CardContent>
</Card>
<TierSetToolbar
editing={editingTiers}
isCustomSet={Boolean(customTierSet)}
rowCount={tierRows.length}
rowsError={tierRowsError}
keywordRulesError={keywordRulesError}
onEditingChange={onEditingTiersChange}
onAdd={addCustomTier}
onRestore={exitToBuiltInTiers}
/>
{customTierSet && (
<FallbackTierField
rows={tierRows}
fallbackTierId={customTierSet.fallback_tier_id}
onValueChange={(fallbackTierId) => onChange(setFallbackTier(value, fallbackTierId))}
/>
)}
</CardContent>
</Card>
</>
)}
{!forecast && <DefaultModelField value={value} onChange={onChange} modelOptions={modelOptions} />}
<Separator className="my-6" />
<div className="rounded-lg border border-border bg-muted">
{[
{
key: "classifier",
label: <strong className="text-foreground font-semibold">Advanced: Classification Method</strong>,
children: (
<ClassificationMethodConfig
value={value}
onChange={onChange}
modelOptions={modelOptions}
effortOptionsByModel={classifierEffortOptionsByModel}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange}
showValidationErrors={showValidationErrors}
defaultModel={defaultModel}
/>
),
},
{
key: "adaptive",
label: <strong className="text-foreground font-semibold">Advanced: Adaptive Routing</strong>,
children: (
<Restricted by={restrictedBy(value, "adaptive")}>
<AdaptiveRoutingConfig value={value} onChange={onChange} />
</Restricted>
),
},
{
key: "affinity",
label: <strong className="text-foreground font-semibold">Advanced: Affinity</strong>,
children: <AffinityControls value={value} onChange={onChange} />,
},
{
key: "modality",
label: <strong className="text-foreground font-semibold">Advanced: Modality Routing</strong>,
children: <ModalityRoutingControls value={value} onChange={onChange} />,
},
{
key: "plan-mode",
label: <strong className="text-foreground font-semibold">Advanced: Plan-Mode Override</strong>,
children: (
<PlanModeOverrideControls value={value} onChange={onChange} planModeTierOptions={planModeTierOptions} />
),
},
{
key: "context-window",
label: <strong className="text-foreground font-semibold">Advanced: Context Window Escalation</strong>,
children: <ContextWindowEscalationConfig value={value} onChange={onChange} />,
},
{
key: "stall-escalation",
label: <strong className="text-foreground font-semibold">Advanced: Stalled Task Escalation</strong>,
children: (
<Restricted by={restrictedBy(value, "stallEscalation")}>
<StallEscalationConfig value={value} onChange={onChange} />
</Restricted>
),
},
{
key: "response",
label: <strong className="text-foreground font-semibold">Advanced: Response Format</strong>,
children: <ResponseFormatControls value={value} onChange={onChange} />,
},
...(onEscalationKeywordsChange
? [
{
key: "escalation",
label: <strong className="text-foreground font-semibold">Advanced: Escalation Keywords</strong>,
children: (
<Restricted by={restrictedBy(value, "escalation")}>
<EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />
</Restricted>
),
},
]
: []),
...(onAutoRouterCompressionChange
? [
{
key: "compression",
label: <strong className="text-foreground font-semibold">Advanced: Compression</strong>,
children: (
<CompressionControls value={autoRouterCompression} onChange={onAutoRouterCompressionChange} />
),
},
]
: []),
...(onKeywordTierRulesChange || onSemanticMatchingEnabledChange
? [
{
key: "keyword-semantic",
label: <strong className="text-foreground font-semibold">Advanced: Keyword/Semantic Matching</strong>,
children: (
<>
{onKeywordTierRulesChange && (
<KeywordTierRules
rules={keywordTierRules}
onChange={onKeywordTierRulesChange}
tierLabels={value.tier_labels}
tierNames={customTierSet && tierRows.map(activeTierName).filter(Boolean)}
/>
)}
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}
{onSemanticMatchingEnabledChange && (
<SemanticKeywordMatching
enabled={semanticMatchingEnabled}
onEnabledChange={onSemanticMatchingEnabledChange}
embeddingModel={embeddingModel}
onEmbeddingModelChange={onEmbeddingModelChange}
matchThreshold={matchThreshold}
onMatchThresholdChange={onMatchThresholdChange}
modelInfo={modelInfo}
showValidationErrors={showValidationErrors}
/>
)}
</>
),
},
]
: []),
].map(({ key, label, children }) => (
<Collapsible key={key} className="border-b border-border last:border-b-0">
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left">
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
{label}
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4">{children}</CollapsibleContent>
</Collapsible>
))}
</div>
<RoutingOptions forecast={forecast}>
{forecast && (
<>
<DefaultModelField value={value} onChange={onChange} modelOptions={modelOptions} />
<ForecastSolverModels
additionalPoolsOnly
value={value}
onChange={onChange}
modelOptions={modelOptions}
effortOptionsByModel={tierEffortOptionsByModel}
fastModeByModel={fastModeByModel}
/>
</>
)}
<div className="rounded-lg border border-border bg-muted">
{[
...(!forecast
? [
{
key: "classifier",
label: <strong className="text-foreground font-semibold">Advanced: Classification Method</strong>,
children: (
<ClassificationMethodConfig
value={value}
onChange={onChange}
modelOptions={modelOptions}
effortOptionsByModel={classifierEffortOptionsByModel}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange}
showValidationErrors={showValidationErrors}
defaultModel={defaultModel}
/>
),
},
]
: []),
...(value.classifier_type !== "llm_v2"
? [
{
key: "adaptive",
label: <strong className="text-foreground font-semibold">Advanced: Adaptive Routing</strong>,
children: (
<Restricted by={restrictedBy(value, "adaptive")}>
<AdaptiveRoutingConfig value={value} onChange={onChange} />
</Restricted>
),
},
]
: []),
{
key: "affinity",
label: <strong className="text-foreground font-semibold">Advanced: Affinity</strong>,
children: <AffinityControls value={value} onChange={onChange} />,
},
{
key: "modality",
label: <strong className="text-foreground font-semibold">Advanced: Modality Routing</strong>,
children: <ModalityRoutingControls value={value} onChange={onChange} />,
},
{
key: "plan-mode",
label: <strong className="text-foreground font-semibold">Advanced: Plan-Mode Override</strong>,
children: (
<PlanModeOverrideControls value={value} onChange={onChange} planModeTierOptions={planModeTierOptions} />
),
},
{
key: "context-window",
label: <strong className="text-foreground font-semibold">Advanced: Context Window Escalation</strong>,
children: <ContextWindowEscalationConfig value={value} onChange={onChange} />,
},
{
key: "stall-escalation",
label: <strong className="text-foreground font-semibold">Advanced: Stalled Task Escalation</strong>,
children: (
<Restricted by={restrictedBy(value, "stallEscalation")}>
<StallEscalationConfig value={value} onChange={onChange} />
</Restricted>
),
},
{
key: "response",
label: <strong className="text-foreground font-semibold">Advanced: Response Format</strong>,
children: <ResponseFormatControls value={value} onChange={onChange} />,
},
...(onEscalationKeywordsChange
? [
{
key: "escalation",
label: <strong className="text-foreground font-semibold">Advanced: Escalation Keywords</strong>,
children: (
<Restricted by={restrictedBy(value, "escalation")}>
<EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />
</Restricted>
),
},
]
: []),
...(onAutoRouterCompressionChange
? [
{
key: "compression",
label: <strong className="text-foreground font-semibold">Advanced: Compression</strong>,
children: (
<CompressionControls value={autoRouterCompression} onChange={onAutoRouterCompressionChange} />
),
},
]
: []),
...(onKeywordTierRulesChange || onSemanticMatchingEnabledChange
? [
{
key: "keyword-semantic",
label: (
<strong className="text-foreground font-semibold">Advanced: Keyword/Semantic Matching</strong>
),
children: (
<>
{onKeywordTierRulesChange && (
<KeywordTierRules
rules={keywordTierRules}
onChange={onKeywordTierRulesChange}
tierLabels={value.tier_labels}
tierNames={
customTierSet || isForecastClassifier(value.classifier_type)
? tierRows.map(activeTierName).filter(Boolean)
: undefined
}
/>
)}
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}
{onSemanticMatchingEnabledChange && (
<SemanticKeywordMatching
enabled={semanticMatchingEnabled}
onEnabledChange={onSemanticMatchingEnabledChange}
embeddingModel={embeddingModel}
onEmbeddingModelChange={onEmbeddingModelChange}
matchThreshold={matchThreshold}
onMatchThresholdChange={onMatchThresholdChange}
modelInfo={modelInfo}
showValidationErrors={showValidationErrors}
/>
)}
</>
),
},
]
: []),
].map(({ key, label, children }) => (
<Collapsible key={key} className="border-b border-border last:border-b-0">
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left">
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
{label}
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4">{children}</CollapsibleContent>
</Collapsible>
))}
</div>
</RoutingOptions>
</div>
);
};

View file

@ -1,11 +1,12 @@
import userEvent from "@testing-library/user-event";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { renderWithProviders, screen, within } from "../../../tests/test-utils";
import {
buildUpdatedComplexityRouterConfig,
hydrateComplexityRouterConfig,
} from "../edit_auto_router/edit_auto_router_modal";
import type { KeywordTierRule } from "./KeywordTierRules";
import type { ModelGroup } from "../llm_calls/fetch_models";
import ComplexityRouterConfig, { type ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
@ -50,8 +51,8 @@ it.each([false, true])("edits and round-trips independent model settings with cu
const view = renderWithProviders(editor(initial));
const fast = () => screen.getByRole("switch", { name: `Fast mode for primary in the ${label} tier` });
expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(3);
expect(screen.queryByRole("switch", { name: /^Fast mode for (blocked|missing)/ })).not.toBeInTheDocument();
expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(4);
expect(screen.queryByRole("switch", { name: /^Fast mode for missing/ })).not.toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: /^Reasoning effort for secondary/ })).not.toBeInTheDocument();
expect(screen.getByRole("switch", { name: `Fast mode for secondary in the ${label} tier` })).toBeChecked();
expect(fast()).not.toBeChecked();
@ -125,19 +126,170 @@ it.each([false, true])("edits and round-trips independent model settings with cu
);
});
describe("Fast mode metadata", () => {
it("offers nothing before model capabilities load and leaves stored speed untouched", () => {
const value: ComplexityRouterConfigValue = {
tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
tier_model_params: { SIMPLE: { primary: { speed: "fast" } } },
};
const onChange = vi.fn();
renderWithProviders(<ComplexityRouterConfig modelInfo={[]} value={value} onChange={onChange} />);
expect(screen.queryByRole("switch", { name: /^Fast mode for/ })).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
expect(buildUpdatedComplexityRouterConfig({}, value).tier_model_configs).toEqual({
SIMPLE: [{ model_name: "primary", litellm_params: { speed: "fast" } }],
});
it.each(["capability", "llm_v2"] as const)("preserves Fast mode controls for %s solvers", async (classifierType) => {
const user = userEvent.setup();
const initial: ComplexityRouterConfigValue = {
classifier_type: classifierType,
classifier_llm_config: { model: "primary", timeout_ms: 3000 },
tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["blocked"] },
capability_classifier_config: { efficient_tier: "SIMPLE", capable_tier: "REASONING", base_threshold: 0.7 },
llm_v2_config: {
efficient_profile: "Small solver",
capable_profile: "Large solver",
harness: "One attempt",
max_quality_gap: 0.05,
},
tier_model_params: { SIMPLE: { primary: { reasoning_effort: "high", max_tokens: 1024 } } },
};
const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>();
const editor = (value: ComplexityRouterConfigValue) => (
<ComplexityRouterConfig modelInfo={modelInfo} value={value} onChange={onChange} />
);
const view = renderWithProviders(editor(initial));
const fast = () => screen.getByRole("switch", { name: "Fast mode for primary in the Efficient solver tier" });
expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(1);
expect(fast()).not.toBeChecked();
await user.click(fast());
const enabled = onChange.mock.lastCall![0];
expect(enabled.tier_model_params?.SIMPLE.primary).toEqual({
reasoning_effort: "high",
max_tokens: 1024,
speed: "fast",
});
const saved = buildUpdatedComplexityRouterConfig({}, enabled);
expect(saved.tier_model_configs).toEqual({
SIMPLE: [{ model_name: "primary", litellm_params: { reasoning_effort: "high", max_tokens: 1024, speed: "fast" } }],
});
view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined)));
expect(fast()).toBeChecked();
await user.click(fast());
expect(onChange.mock.lastCall![0].tier_model_params?.SIMPLE.primary).toEqual({
reasoning_effort: "high",
max_tokens: 1024,
});
});
describe("Fast mode metadata", () => {
it.each(["heuristic", "capability", "llm_v2"] as const)(
"can clear stored Fast mode without current capability metadata for %s",
async (classifier_type) => {
const user = userEvent.setup();
const value: ComplexityRouterConfigValue = {
tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["secondary"] },
classifier_type,
tier_model_params: { SIMPLE: { primary: { speed: "fast", max_tokens: 512 } } },
};
const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>();
const editor = (current: ComplexityRouterConfigValue, info: ModelGroup[]) => (
<ComplexityRouterConfig modelInfo={info} value={current} onChange={onChange} />
);
const view = renderWithProviders(editor(value, []));
const fast = () => screen.getByRole("switch", { name: /^Fast mode for primary/ });
expect(fast()).toBeChecked();
expect(onChange).not.toHaveBeenCalled();
view.rerender(editor(value, [{ model_group: "primary", supports_fast_mode: false }]));
expect(fast()).toBeChecked();
await user.click(fast());
const cleared = onChange.mock.lastCall![0];
expect(cleared.tier_model_params?.SIMPLE.primary).toEqual({ max_tokens: 512 });
const saved = buildUpdatedComplexityRouterConfig({}, cleared);
expect(saved.tier_model_configs).toEqual({
SIMPLE: [{ model_name: "primary", litellm_params: { max_tokens: 512 } }],
});
view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined), []));
expect(screen.queryByRole("switch", { name: /^Fast mode for primary/ })).not.toBeInTheDocument();
view.rerender(editor(cleared, modelInfo));
expect(fast()).not.toBeChecked();
},
);
});
it.each(["MEDIUM", "REASONING"])("clears a legacy Capability pool while reconciling plan floor %s", async (floor) => {
const user = userEvent.setup();
const stored = {
classifier_type: "capability" as const,
plan_mode_min_tier: floor,
tiers: { SIMPLE: ["primary"], MEDIUM: ["secondary"], COMPLEX: [], REASONING: ["blocked"] },
tier_model_configs: { MEDIUM: [{ model_name: "secondary", litellm_params: { speed: "fast" } }] },
};
const value = hydrateComplexityRouterConfig(stored, undefined);
const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>();
renderWithProviders(<ComplexityRouterConfig modelInfo={modelInfo} value={value} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: "Advanced routing options" }));
expect(screen.getByRole("switch", { name: "Fast mode for secondary in the Medium routing pool tier" })).toBeChecked();
expect(onChange).not.toHaveBeenCalled();
await user.click(screen.getByRole("combobox", { name: "Select medium routing pool models" }));
await user.click(await screen.findByRole("option", { name: "secondary" }));
await user.keyboard("{Escape}");
const cleared = onChange.mock.lastCall![0];
expect(cleared.tiers.MEDIUM).toEqual([]);
expect(cleared.plan_mode_min_tier).toBe(floor === "MEDIUM" ? undefined : floor);
expect(cleared.tier_model_params).toBeUndefined();
expect(buildUpdatedComplexityRouterConfig(stored, cleared).tiers).toEqual({
SIMPLE: ["primary"],
REASONING: ["blocked"],
});
});
it.each(["capability", "llm_v2"] as const)(
"shows and clears a persisted default model in %s",
async (classifier_type) => {
const user = userEvent.setup();
const stored = {
classifier_type,
default_model: "legacy-default",
tiers: { SIMPLE: ["primary"], REASONING: ["secondary"] },
};
const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>();
const editor = (value: ComplexityRouterConfigValue) => (
<ComplexityRouterConfig modelInfo={modelInfo} value={value} onChange={onChange} />
);
const view = renderWithProviders(editor(hydrateComplexityRouterConfig(stored, undefined)));
await user.click(screen.getByRole("button", { name: "Advanced routing options" }));
const select = () => screen.getByRole("combobox", { name: "Default model" });
expect(select()).toHaveValue("legacy-default");
expect(onChange).not.toHaveBeenCalled();
await user.click(select());
await user.click(await screen.findByRole("option", { name: "blocked" }));
const changed = onChange.mock.lastCall![0];
expect(buildUpdatedComplexityRouterConfig(stored, changed).default_model).toBe("blocked");
view.rerender(editor(changed));
await user.click(
within(screen.getByRole("group", { name: "Default model configuration" })).getByRole("button", { name: "Clear" }),
);
const cleared = onChange.mock.lastCall![0];
expect(cleared.default_model).toBeUndefined();
const saved = buildUpdatedComplexityRouterConfig(stored, cleared);
expect(saved).not.toHaveProperty("default_model");
view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined)));
expect(select()).toHaveValue("");
expect(select()).toHaveAttribute("placeholder", expect.stringContaining("primary"));
},
);
it.each(["capability", "llm_v2"] as const)("offers only populated keyword targets for %s", async (classifier_type) => {
const user = userEvent.setup();
const value: ComplexityRouterConfigValue = {
classifier_type,
tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["secondary"] },
};
const onRulesChange = vi.fn<(rules: KeywordTierRule[]) => void>();
const editor = (rules: KeywordTierRule[]) => (
<ComplexityRouterConfig
value={value}
onChange={vi.fn()}
modelInfo={modelInfo}
keywordTierRules={rules}
onKeywordTierRulesChange={onRulesChange}
/>
);
const view = renderWithProviders(editor([]));
await user.click(screen.getByRole("button", { name: "Advanced routing options" }));
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
await user.click(screen.getByRole("button", { name: "Add keyword rule" }));
const rules = onRulesChange.mock.lastCall![0];
expect(rules[0].tier).toBe("SIMPLE");
view.rerender(editor(rules));
await user.click(screen.getByRole("combobox", { name: "Route keyword rule 1 to tier" }));
expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual(["Simple", "Reasoning"]);
});

View file

@ -0,0 +1,56 @@
import React from "react";
import { Info } from "lucide-react";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { SimpleTooltip } from "@/components/ui/tooltip";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { isForecastClassifier } from "./forecast_classifier_config";
import { resolveComplexityDefaultModel } from "./tier_rows";
interface DefaultModelFieldProps {
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
modelOptions: { value: string; label: string }[];
}
const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => {
if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`;
return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier";
};
const DefaultModelField = ({ value, onChange, modelOptions }: DefaultModelFieldProps) => {
const defaultModelPlaceholder = defaultModelPlaceholderFor(
resolveComplexityDefaultModel(value),
Boolean(value.custom_tier_set),
);
// Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as
// "track the tiers" everywhere downstream instead of as a blank model name.
const handleDefaultModelChange = (model: string | null | undefined) => {
onChange({ ...value, default_model: model || undefined });
};
return (
<div className="mt-4 mb-2" role="group" aria-label="Default model configuration">
<div className="flex items-center gap-2 mb-2">
<strong className="text-base font-semibold">Default Model</strong>
<SimpleTooltip content="Leave empty to follow the tiers. A model chosen here is pinned: it stays the default however the tiers change.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
</div>
<SearchSelect
options={modelOptions}
value={value.default_model ?? ""}
onValueChange={handleDefaultModelChange}
placeholder={defaultModelPlaceholder}
emptyText="No models found"
aria-label="Default model"
/>
<span className="block mt-1 text-xs text-muted-foreground">
{isForecastClassifier(value.classifier_type)
? "Used when routing cannot find a suitable model. Classifier failures route to the capable solver."
: 'Used when the tier the request lands in has no model, and when the classifier fails with "Route to the default model" selected.'}
</span>
</div>
);
};
export default DefaultModelField;

View file

@ -0,0 +1,224 @@
import React, { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils";
import ClassificationMethodConfig from "./ClassificationMethodConfig";
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
import ForecastClassifierConfig from "./ForecastClassifierConfig";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { getForecastConfigError, isForecastClassifier } from "./forecast_classifier_config";
import { buildUpdatedComplexityRouterConfig } from "../edit_auto_router/edit_auto_router_modal";
vi.mock("@/components/networking", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/networking")>()),
getComplexityScorerDefaults: vi.fn(async () => ({
tier_boundaries: {},
token_thresholds: {},
dimension_weights: {},
})),
}));
const initial: ComplexityRouterConfigValue = {
classifier_type: "capability",
classifier_llm_config: { model: "judge", timeout_ms: 20000 },
tiers: { SIMPLE: ["efficient"], MEDIUM: [], COMPLEX: [], REASONING: ["capable"] },
capability_classifier_config: { efficient_tier: "SIMPLE", capable_tier: "REASONING", base_threshold: 0.7 },
};
const fuseInitial: ComplexityRouterConfigValue = {
...initial,
classifier_type: "llm_v2",
capability_classifier_config: undefined,
adaptive: false,
llm_v2_config: {
efficient_profile: "Small solver",
capable_profile: "Larger solver",
harness: "One attempt",
max_quality_gap: 0.05,
},
};
const options = ["judge", "efficient", "capable"].map((model) => ({ value: model, label: model }));
function Form({ initialValue = initial }: { initialValue?: ComplexityRouterConfigValue }) {
const [value, setValue] = useState(initialValue);
const [saved, setSaved] = useState("");
return (
<>
<AutoRouterClassifierTabs value={value} onChange={setValue}>
{isForecastClassifier(value.classifier_type) ? (
<ForecastClassifierConfig
value={value}
onChange={setValue}
modelOptions={options}
effortOptionsByModel={{}}
/>
) : (
<ClassificationMethodConfig
value={value}
onChange={setValue}
modelOptions={options}
effortOptionsByModel={{}}
/>
)}
</AutoRouterClassifierTabs>
<button
disabled={Boolean(getForecastConfigError(value))}
onClick={() => setSaved(JSON.stringify(buildUpdatedComplexityRouterConfig({}, value)))}
>
Save configuration
</button>
<output aria-label="Saved configuration">{saved}</output>
</>
);
}
describe("forecast classifier form", () => {
it("switches a populated standard router to Capability without saving hidden pools or their overrides", () => {
renderWithProviders(
<Form
initialValue={{
classifier_type: "llm",
classifier_llm_config: { model: "judge", timeout_ms: 20000, classification_rubric: "agentic" },
adaptive: true,
plan_mode_min_tier: "MEDIUM",
tiers: {
SIMPLE: ["efficient", "second-efficient"],
MEDIUM: ["leftover-medium"],
COMPLEX: ["leftover-complex"],
REASONING: ["capable"],
},
tier_model_params: {
SIMPLE: { efficient: { reasoning_effort: "low", speed: "fast", max_tokens: 1024 } },
MEDIUM: { "leftover-medium": { speed: "fast" } },
COMPLEX: { "leftover-complex": { max_tokens: 4096 } },
REASONING: { capable: { reasoning_effort: "high" } },
},
}}
/>,
);
fireEvent.click(screen.getByRole("tab", { name: "Capability" }));
fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.7" } });
expect(screen.getByRole("button", { name: "Save configuration" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Save configuration" }));
const output = screen.getByRole("status", { name: "Saved configuration" });
expect(output).toHaveTextContent('"classifier_type":"capability"');
expect(output).toHaveTextContent('"SIMPLE":["efficient","second-efficient"]');
expect(output).toHaveTextContent('"REASONING":["capable"]');
expect(output).toHaveTextContent('"reasoning_effort":"low","speed":"fast","max_tokens":1024');
expect(output).toHaveTextContent('"reasoning_effort":"high"');
expect(output).toHaveTextContent('"adaptive":true');
expect(output).not.toHaveTextContent("leftover-medium");
expect(output).not.toHaveTextContent("leftover-complex");
expect(output).not.toHaveTextContent('"plan_mode_min_tier"');
});
it.each(["capability", "llm_v2"] as const)(
"carries non-default solver assignments when switching away from %s",
(source) => {
const pair = { efficient_tier: "MEDIUM", capable_tier: "COMPLEX" };
const previous: ComplexityRouterConfigValue = {
...(source === "capability" ? initial : fuseInitial),
tiers: { SIMPLE: [], MEDIUM: ["efficient"], COMPLEX: ["capable"], REASONING: [] },
capability_classifier_config:
source === "capability" ? { ...initial.capability_classifier_config!, ...pair } : undefined,
llm_v2_config: source === "llm_v2" ? { ...fuseInitial.llm_v2_config!, ...pair } : undefined,
plan_mode_min_tier: "COMPLEX",
tier_model_params: { MEDIUM: { efficient: { max_tokens: 128 } }, COMPLEX: { capable: { speed: "fast" } } },
};
renderWithProviders(<Form initialValue={previous} />);
fireEvent.click(screen.getByRole("tab", { name: source === "capability" ? "Fuse v2" : "Capability" }));
if (source === "capability") {
fireEvent.change(screen.getByLabelText("Efficient solver profile"), { target: { value: "Small solver" } });
fireEvent.change(screen.getByLabelText("Capable solver profile"), { target: { value: "Large solver" } });
fireEvent.change(screen.getByLabelText("Harness and budget"), { target: { value: "One attempt" } });
fireEvent.change(screen.getByLabelText("Maximum quality gap"), { target: { value: "0.05" } });
} else {
fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.7" } });
}
expect(screen.getByRole("button", { name: "Save configuration" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Save configuration" }));
const output = screen.getByRole("status", { name: "Saved configuration" });
expect(output).toHaveTextContent('"efficient_tier":"MEDIUM","capable_tier":"COMPLEX"');
expect(output).toHaveTextContent('"tiers":{"MEDIUM":["efficient"],"COMPLEX":["capable"]}');
expect(output).toHaveTextContent('"plan_mode_min_tier":"COMPLEX"');
expect(output).toHaveTextContent('"max_tokens":128');
expect(output).toHaveTextContent('"speed":"fast"');
},
);
it("keeps decimal and negative numbers when entered one character at a time", async () => {
const user = userEvent.setup();
renderWithProviders(<Form />);
const threshold = screen.getByLabelText("Solve probability threshold");
await user.clear(threshold);
await user.type(threshold, "0.65");
expect(threshold).toHaveValue(0.65);
await user.click(screen.getByRole("button", { name: "Classifier options" }));
await user.click(screen.getByRole("switch", { name: "Use fitted calibration" }));
await user.type(screen.getByLabelText("Efficient intercept"), "-0.3");
expect(screen.getByLabelText("Efficient intercept")).toHaveValue(-0.3);
});
it.each([
["capability", "LLM Classifier"],
["capability", "Heuristic first"],
["capability", "Hybrid"],
["llm_v2", "LLM Classifier"],
["llm_v2", "Heuristic first"],
["llm_v2", "Hybrid"],
] as const)("restores the current rubric when switching %s through Complexity to %s", async (source, target) => {
const user = userEvent.setup();
renderWithProviders(<Form initialValue={source === "capability" ? initial : fuseInitial} />);
fireEvent.click(screen.getByRole("tab", { name: "Complexity" }));
fireEvent.click(screen.getByRole("radio", { name: new RegExp(`^${target}`) }));
await user.click(screen.getByRole("combobox", { name: "Classifier Model" }));
await user.click(screen.getByRole("option", { name: "judge", exact: true }));
fireEvent.click(screen.getByRole("button", { name: "Save configuration" }));
const output = screen.getByRole("status", { name: "Saved configuration" });
expect(output).toHaveTextContent('"classification_rubric":"agentic"');
expect(output).toHaveTextContent('"model":"judge"');
expect(output).toHaveTextContent('"timeout_ms":3000');
expect(output).not.toHaveTextContent('"capability_classifier_config"');
expect(output).not.toHaveTextContent('"llm_v2_config"');
});
it("saves capability threshold edits together with fitted calibration", () => {
renderWithProviders(<Form />);
fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.6" } });
fireEvent.click(screen.getByRole("button", { name: "Classifier options" }));
fireEvent.click(screen.getByRole("switch", { name: "Use fitted calibration" }));
expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Calibration version"), { target: { value: "eval-a" } });
fireEvent.change(screen.getByLabelText("Efficient slope"), { target: { value: "1.2" } });
fireEvent.change(screen.getByLabelText("Efficient intercept"), { target: { value: "-0.3" } });
fireEvent.click(screen.getByRole("button", { name: "Save configuration" }));
const output = screen.getByRole("status", { name: "Saved configuration" });
expect(output).toHaveTextContent('"base_threshold":0.6');
expect(output).toHaveTextContent('"calibration":{"version":"eval-a","slope":1.2,"intercept":-0.3}');
fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "" } });
expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled();
});
it("switches to Fuse, requires solver context, and saves the filled fields", () => {
renderWithProviders(<Form />);
fireEvent.click(screen.getByRole("tab", { name: "Fuse v2" }));
expect(screen.queryByLabelText("Solve probability threshold")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Efficient solver profile"), {
target: { value: "Short reasoning budget" },
});
fireEvent.change(screen.getByLabelText("Capable solver profile"), { target: { value: "Larger reasoning budget" } });
fireEvent.change(screen.getByLabelText("Harness and budget"), {
target: { value: "Shell and test runner, one attempt" },
});
fireEvent.change(screen.getByLabelText("Maximum quality gap"), { target: { value: "0.05" } });
fireEvent.click(screen.getByRole("button", { name: "Save configuration" }));
const output = screen.getByRole("status", { name: "Saved configuration" });
expect(output).toHaveTextContent('"classifier_type":"llm_v2"');
expect(output).toHaveTextContent('"efficient_profile":"Short reasoning budget"');
expect(output).toHaveTextContent('"capable_profile":"Larger reasoning budget"');
expect(output).toHaveTextContent('"harness":"Shell and test runner, one attempt"');
expect(output).toHaveTextContent('"max_quality_gap":0.05');
expect(output).toHaveTextContent('"adaptive":false');
expect(output).not.toHaveTextContent('"capability_classifier_config"');
});
});

View file

@ -0,0 +1,433 @@
import React from "react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronRight } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { MultiSelect } from "@/components/shared/MultiSelect";
import {
type ComplexityRouterConfigValue,
type ClassificationFrequency,
classificationFrequency,
withClassificationFrequency,
DEFAULT_CLASSIFIER_TIMEOUT_MS,
} from "./ComplexityRouterConfig";
import {
forecastTierNames,
forecastModels,
getForecastConfigError,
newCapabilitySettings,
newFuseSettings,
type CapabilitySettings,
type FuseSettings,
} from "./forecast_classifier_config";
import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect";
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
import ClassifierVisionConfig from "./ClassifierVisionConfig";
import TierModelEffortRows from "./TierModelEffortRows";
import { activeTierRows } from "./tier_rows";
import { setTierModels } from "./tier_set_actions";
import { tierRowLabel, setTierModelParam, setTierModelReasoningEffort } from "./complexity_router_tiers";
interface Props {
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
modelOptions: { value: string; label: string }[];
effortOptionsByModel: Record<string, string[] | null | undefined>;
}
const NumberField = ({
label,
value,
onChange,
min,
max,
step = "any",
help,
}: {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number | "any";
help?: string;
}) => {
const id = React.useId();
return (
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type="number"
min={min}
max={max}
step={step}
value={Number.isFinite(value) ? value : ""}
onChange={(event) => onChange(event.target.value === "" ? Number.NaN : Number(event.target.value))}
/>
{help && <p className="text-xs text-muted-foreground">{help}</p>}
</div>
);
};
export const ForecastSolverModels = ({
value,
onChange,
modelOptions,
effortOptionsByModel,
fastModeByModel,
additionalPoolsOnly = false,
}: Props & { fastModeByModel: Record<string, boolean>; additionalPoolsOnly?: boolean }) => {
const id = React.useId();
const names = forecastTierNames(value);
const additionalRows =
value.classifier_type === "capability"
? activeTierRows(value)
.filter((row) => !names.includes(row.id) && row.models.length > 0)
.map((row) => ({ tier: row.id, label: `${tierRowLabel(row, value.tier_labels)} routing pool` }))
: [];
const rows = additionalPoolsOnly
? additionalRows
: names.map((tier, index) => ({ tier, label: index === 0 ? "Efficient solver" : "Capable solver" }));
if (rows.length === 0) return null;
return (
<div className="rounded-lg border p-4 space-y-4">
{rows.map(({ tier, label }) => {
const models = forecastModels(value.tiers, tier);
const setModels = (next: string[]) => onChange(setTierModels(value, tier, next));
return (
<div key={tier} className="space-y-2">
<Label htmlFor={`${id}-${tier}`} className="block text-sm font-semibold">
{label}
</Label>
{value.classifier_type === "llm_v2" ? (
<SearchSelect
options={modelOptions}
inputId={`${id}-${tier}`}
value={models[0] ?? ""}
aria-label={label}
placeholder={`Select ${label.toLowerCase()}`}
onValueChange={(model) => setModels(model ? [model] : [])}
/>
) : (
<MultiSelect
options={modelOptions}
id={`${id}-${tier}`}
value={models}
onValueChange={setModels}
placeholder={`Select ${label.toLowerCase()} models`}
/>
)}
<TierModelEffortRows
tierLabel={label}
models={models}
effortOptionsByModel={Object.fromEntries(
Object.entries(effortOptionsByModel).map(([model, efforts]) => [model, efforts ?? []]),
)}
paramsByModel={value.tier_model_params?.[tier] ?? {}}
fastModeByModel={fastModeByModel}
onFastModeChange={(model, enabled) =>
onChange({
...value,
tier_model_params: setTierModelParam(value.tier_model_params, tier, model, [
"speed",
enabled ? "fast" : undefined,
]),
})
}
onEffortChange={(model, effort) =>
onChange({
...value,
tier_model_params: setTierModelReasoningEffort(value.tier_model_params, tier, model, effort),
})
}
/>
</div>
);
})}
{!additionalPoolsOnly && (
<p className="text-sm text-muted-foreground">
Invalid forecasts and classifier failures route to the capable solver
</p>
)}
</div>
);
};
const CalibrationFields = ({
label,
value,
onChange,
bounded = false,
}: {
label: string;
bounded?: boolean;
value: { slope: number; intercept: number };
onChange: (value: { slope: number; intercept: number }) => void;
}) => (
<div className="grid gap-3 sm:grid-cols-2">
<NumberField
label={`${label} slope`}
value={value.slope}
min={0}
max={bounded ? 20 : undefined}
onChange={(slope) => onChange({ ...value, slope })}
/>
<NumberField
label={`${label} intercept`}
value={value.intercept}
min={bounded ? -20 : undefined}
max={bounded ? 20 : undefined}
onChange={(intercept) => onChange({ ...value, intercept })}
/>
</div>
);
const emptyCoefficients = () => ({ slope: Number.NaN, intercept: Number.NaN });
const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptionsByModel }: Props) => {
const id = React.useId();
const isCapability = value.classifier_type === "capability";
const capability = value.capability_classifier_config ?? newCapabilitySettings();
const fuse = value.llm_v2_config ?? newFuseSettings();
const config = isCapability ? capability : fuse;
const llm = value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS };
const updateCapability = (next: CapabilitySettings) => onChange({ ...value, capability_classifier_config: next });
const updateFuse = (next: FuseSettings) => onChange({ ...value, llm_v2_config: next });
const updateTransport = (patch: { max_output_tokens?: number; response_format?: "json_schema" | "json_object" }) =>
isCapability ? updateCapability({ ...capability, ...patch }) : updateFuse({ ...fuse, ...patch });
const setCalibrationVersion = (version: string) => {
if (isCapability && capability.calibration)
updateCapability({ ...capability, calibration: { ...capability.calibration, version } });
if (!isCapability && fuse.calibration) updateFuse({ ...fuse, calibration: { ...fuse.calibration, version } });
};
const error = getForecastConfigError(value);
return (
<div className="mt-4 space-y-4">
<p className="text-sm text-muted-foreground">
{isCapability
? "Forecasts whether the efficient solver can complete the task using the bundled capability card"
: "Forecasts success for both solvers and selects efficient when the estimated quality gap is within your allowance"}
</p>
<div className="space-y-1">
<Label htmlFor={`${id}-judge`}>Judge model</Label>
<SearchSelect
inputId={`${id}-judge`}
aria-label="Judge model"
options={modelOptions}
value={llm.model}
placeholder="Select the judge model"
onValueChange={(model) => {
if (model === llm.model) return;
onChange({ ...value, classifier_llm_config: { ...llm, model: model ?? "", reasoning_effort: undefined } });
}}
/>
</div>
{isCapability ? (
<>
<NumberField
label="Solve probability threshold"
value={capability.base_threshold}
min={0}
max={1}
help="Minimum estimated chance of whole-task success required to use the efficient solver"
onChange={(base_threshold) => updateCapability({ ...capability, base_threshold })}
/>
</>
) : (
<>
{(["efficient_profile", "capable_profile", "harness"] as const).map((field) => {
const label = {
efficient_profile: "Efficient solver profile",
capable_profile: "Capable solver profile",
harness: "Harness and budget",
}[field];
return (
<div key={field} className="space-y-1">
<Label htmlFor={`${id}-${field}`}>{label}</Label>
<Textarea
id={`${id}-${field}`}
value={fuse[field]}
maxLength={4000}
placeholder={
field === "harness"
? "Tools, execution environment, verification, and budget available to each solver"
: "Describe this solver's strengths, limitations, and settings"
}
onChange={(event) => updateFuse({ ...fuse, [field]: event.target.value })}
/>
</div>
);
})}
<NumberField
label="Maximum quality gap"
value={fuse.max_quality_gap}
min={0}
max={1}
help="Allowed difference between capable and efficient success probabilities, from 0 to 1. This is an estimate, not a measured quality guarantee"
onChange={(max_quality_gap) => updateFuse({ ...fuse, max_quality_gap })}
/>
</>
)}
<Collapsible className="rounded-lg border">
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left font-medium">
<ChevronRight className="size-4 transition-transform group-data-panel-open:rotate-90" />
Classifier options
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 px-4 pb-4">
<ClassifierReasoningEffortSelect
model={llm.model}
value={llm.reasoning_effort}
explicitlySupported={effortOptionsByModel[llm.model]}
onChange={(reasoning_effort) => onChange({ ...value, classifier_llm_config: { ...llm, reasoning_effort } })}
/>
<NumberField
label="Timeout (ms)"
min={1}
step={1}
value={llm.timeout_ms}
help="Allow enough time for the judge to produce its forecast"
onChange={(timeout_ms) => onChange({ ...value, classifier_llm_config: { ...llm, timeout_ms } })}
/>
<ClassifierCircuitBreakerConfig
value={llm}
onChange={(classifier_llm_config) => onChange({ ...value, classifier_llm_config })}
/>
<ClassifierVisionConfig
value={llm}
onChange={(classifier_llm_config) => onChange({ ...value, classifier_llm_config })}
/>
<div className="space-y-1">
<Label htmlFor={`${id}-frequency`}>How often to classify</Label>
<SearchSelect
inputId={`${id}-frequency`}
aria-label="How often to classify"
value={classificationFrequency(value)}
allowClear={false}
options={[
{ value: "every_request", label: "Every request" },
{ value: "user_turn", label: "Every new user message" },
{ value: "session", label: "Once per session" },
]}
onValueChange={(frequency) => {
if (frequency) onChange(withClassificationFrequency(value, frequency as ClassificationFrequency));
}}
/>
</div>
{isCapability && (
<NumberField
label="Capability boundary step"
value={capability.threshold_step ?? 0}
min={0}
max={0.5}
help="Added once for uncertain or unmatched tasks and twice for unsupported tasks; the final threshold cannot exceed 1"
onChange={(threshold_step) => updateCapability({ ...capability, threshold_step })}
/>
)}
<NumberField
label="Classifier output token limit"
min={1}
step={1}
value={config.max_output_tokens ?? (isCapability ? 4096 : 1024)}
onChange={(max_output_tokens) => updateTransport({ max_output_tokens })}
/>
<div className="space-y-1">
<Label htmlFor={`${id}-format`}>Forecast response format</Label>
<SearchSelect
inputId={`${id}-format`}
aria-label="Forecast response format"
value={config.response_format ?? "json_schema"}
allowClear={false}
options={[
{ value: "json_schema", label: "Strict JSON schema" },
{ value: "json_object", label: "JSON object (for judges without strict schema support)" },
]}
onValueChange={(response_format) => {
if (response_format === "json_schema" || response_format === "json_object")
updateTransport({ response_format });
}}
/>
</div>
<div className="space-y-3 rounded-md border p-3">
<Label>
<Switch
checked={Boolean(config.calibration)}
onCheckedChange={(enabled) =>
isCapability
? updateCapability({
...capability,
calibration: enabled ? { version: "", ...emptyCoefficients() } : undefined,
})
: updateFuse({
...fuse,
calibration: enabled
? {
version: "",
prompt_version: "llm-v2-1",
efficient: emptyCoefficients(),
capable: emptyCoefficients(),
}
: undefined,
})
}
/>
Use fitted calibration
</Label>
<p className="text-xs text-muted-foreground">
Optional coefficients fitted for your judge, solvers, and harness. Leave off to use raw forecasts
</p>
{config.calibration && (
<div className="space-y-1">
<Label htmlFor={`${id}-version`}>Calibration version</Label>
<Input
id={`${id}-version`}
value={config.calibration.version}
maxLength={isCapability ? 128 : 512}
onChange={(event) => setCalibrationVersion(event.target.value)}
/>
</div>
)}
{isCapability && capability.calibration && (
<CalibrationFields
label="Efficient"
bounded
value={capability.calibration}
onChange={(next) =>
updateCapability({
...capability,
calibration: { version: capability.calibration?.version ?? "", ...next },
})
}
/>
)}
{!isCapability &&
fuse.calibration &&
(["efficient", "capable"] as const).map((role) => (
<CalibrationFields
key={role}
label={role === "efficient" ? "Efficient" : "Capable"}
value={fuse.calibration![role]}
onChange={(next) => {
if (fuse.calibration) updateFuse({ ...fuse, calibration: { ...fuse.calibration, [role]: next } });
}}
/>
))}
</div>
</CollapsibleContent>
</Collapsible>
<p className="text-xs text-muted-foreground">
The classifier uses its bundled prompt and always falls back to the capable solver
</p>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</div>
);
};
export default ForecastClassifierConfig;

View file

@ -0,0 +1,44 @@
import React from "react";
import { Switch } from "@/components/ui/switch";
import TierRowSelect from "./TierRowSelect";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
const PlanModeOverrideControls: React.FC<{
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
planModeTierOptions: { value: string; label: string }[];
}> = ({ value, onChange, planModeTierOptions }) => (
<>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.plan_mode_min_tier !== undefined}
disabled={planModeTierOptions.length === 0}
onCheckedChange={(enabled) =>
onChange({
...value,
plan_mode_min_tier: enabled ? planModeTierOptions.at(-1)?.value : undefined,
})
}
aria-label="Route plan-mode requests to a minimum tier"
/>
<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.
{planModeTierOptions.length === 0 && " Add models to a tier to enable this."}
</span>
{value.plan_mode_min_tier !== undefined && (
<div style={{ maxWidth: 320 }}>
<TierRowSelect
label="Plan-mode minimum tier"
options={planModeTierOptions}
value={value.plan_mode_min_tier ?? null}
onValueChange={(tier) => onChange({ ...value, plan_mode_min_tier: tier })}
/>
</div>
)}
</>
);
export default PlanModeOverrideControls;

View file

@ -0,0 +1,23 @@
import React from "react";
import { ChevronRight } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
interface RoutingOptionsProps {
forecast: boolean;
children: React.ReactNode;
}
const RoutingOptions = ({ forecast, children }: RoutingOptionsProps) =>
forecast ? (
<Collapsible className="rounded-lg border">
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left font-medium">
<ChevronRight className="size-4 transition-transform group-data-panel-open:rotate-90" />
Advanced routing options
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 px-4 pb-4">{children}</CollapsibleContent>
</Collapsible>
) : (
<>{children}</>
);
export default RoutingOptions;

View file

@ -23,6 +23,12 @@ interface TierModelEffortRowsProps {
onFastModeChange: (model: string, enabled: boolean) => void;
}
const canEditFastMode = (
model: string,
fastModeByModel: TierModelEffortRowsProps["fastModeByModel"],
paramsByModel: TierModelEffortRowsProps["paramsByModel"],
): boolean => fastModeByModel?.[model] === true || paramsByModel?.[model]?.speed === "fast";
export interface TierEffortRow {
model: string;
effort: ReasoningEffort | undefined;
@ -49,7 +55,7 @@ export const tierEffortRows = ({
const listed = effort !== undefined && !supported.includes(effort) ? [...supported, effort] : supported;
return { model, effort, options: Array.from(new Set(listed)) };
})
.filter(({ model, options }) => options.length > 0 || fastModeByModel?.[model] === true);
.filter(({ model, options }) => options.length > 0 || canEditFastMode(model, fastModeByModel, paramsByModel));
const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = (props) => {
const { tierLabel, paramsByModel, onEffortChange, fastModeByModel, onFastModeChange } = props;
@ -101,7 +107,7 @@ const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = (props) => {
</SelectContent>
</Select>
)}
{fastModeByModel?.[model] === true && (
{canEditFastMode(model, fastModeByModel, paramsByModel) && (
<SimpleTooltip content="Fast mode has higher pricing and requires an eligible provider account. Off removes this tier's speed override and inherits the request or provider default">
<label
className="flex items-center gap-2 text-xs"

View file

@ -2,9 +2,17 @@ import React from "react";
import { CUSTOM_TIER_RESTRICTIONS, CustomTierSet, TierRestriction } from "./tier_rows";
export const restrictedBy = (
value: { custom_tier_set?: CustomTierSet },
value: { custom_tier_set?: CustomTierSet; classifier_type?: string },
key: keyof typeof CUSTOM_TIER_RESTRICTIONS,
): TierRestriction | undefined => (value.custom_tier_set ? CUSTOM_TIER_RESTRICTIONS[key] : undefined);
): TierRestriction | undefined => {
if (value.custom_tier_set) return CUSTOM_TIER_RESTRICTIONS[key];
if (value.classifier_type === "llm_v2" && key === "adaptive")
return {
omit: ["adaptive", "adaptive_weights", "adaptive_eligible", "tier_distance_penalty"],
reason: "Fuse v2 uses its quality-gap decision directly; adaptive routing is unavailable",
};
return undefined;
};
export const Restricted: React.FC<{ by: TierRestriction | undefined; children: React.ReactNode }> = ({
by,

View file

@ -167,6 +167,114 @@ describe("AddAutoRouterTab", () => {
mockFetchAllModelDeployments.mockResolvedValue([]);
});
it.each(["Capability", "Fuse v2"])(
"creates %s from its dedicated tab without complexity templates",
async (label) => {
const user = userEvent.setup();
mockFetchAvailableModels.mockResolvedValue([
{ model_group: "efficient", mode: "chat" },
{ model_group: "capable", mode: "chat" },
{ model_group: "judge", mode: "chat" },
]);
renderWithProviders(<Harness />);
await user.type(screen.getByLabelText("Auto Router Name"), "forecast-router");
await user.click(screen.getByRole("tab", { name: label, exact: true }));
expect(screen.getByLabelText("Auto Router Name")).toHaveValue("forecast-router");
expect(screen.queryByTestId("template-selector")).not.toBeInTheDocument();
expect(screen.queryByTestId("configure-automatically-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("detailed-configuration-toggle")).not.toBeInTheDocument();
expect(screen.queryByText("Complexity Tier Configuration")).not.toBeInTheDocument();
expect(screen.queryByText("Advanced: Classification Method")).not.toBeInTheDocument();
expect(screen.queryByText("Advanced: Adaptive Routing")).not.toBeInTheDocument();
const capability = label === "Capability";
for (const [role, model] of [
["Efficient", "efficient"],
["Capable", "capable"],
]) {
await user.click(
screen.getByRole("combobox", {
name: capability ? `Select ${role.toLowerCase()} solver models` : `${role} solver`,
}),
);
await user.click(await screen.findByRole("option", { name: model, exact: true }));
if (capability) await user.keyboard("{Escape}");
}
await user.click(screen.getByRole("combobox", { name: "Judge model" }));
await user.click(await screen.findByRole("option", { name: "judge", exact: true }));
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeDisabled();
if (capability) {
fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.7" } });
} else {
fireEvent.change(screen.getByLabelText("Efficient solver profile"), { target: { value: "Small solver" } });
fireEvent.change(screen.getByLabelText("Capable solver profile"), { target: { value: "Large solver" } });
fireEvent.change(screen.getByLabelText("Harness and budget"), { target: { value: "One attempt" } });
fireEvent.change(screen.getByLabelText("Maximum quality gap"), { target: { value: "0.05" } });
}
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Advanced routing options" }));
if (capability) expect(screen.getByText("Advanced: Adaptive Routing")).toBeInTheDocument();
else expect(screen.queryByText("Advanced: Adaptive Routing")).not.toBeInTheDocument();
expect(screen.queryByText("Advanced: Classification Method")).not.toBeInTheDocument();
expect(screen.getByText("Advanced: Affinity")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledTimes(1));
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject({
classifier_type: capability ? "capability" : "llm_v2",
tiers: { SIMPLE: ["efficient"], REASONING: ["capable"] },
classifier_llm_config: { model: "judge" },
});
},
);
it("restores the automatic/template/detail flow on the Complexity tab", async () => {
const user = userEvent.setup();
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
await screen.findByTestId("configure-automatically-button");
await user.click(screen.getByRole("tab", { name: "Capability", exact: true }));
expect(screen.queryByTestId("configure-automatically-button")).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Complexity", exact: true }));
expect(screen.getByTestId("configure-automatically-button")).toBeInTheDocument();
expect(screen.getByTestId("template-selector")).toBeInTheDocument();
expandDetailedConfiguration();
expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument();
await user.click(screen.getByText("Advanced: Classification Method"));
expect(screen.queryByRole("radio", { name: /^Capability/ })).not.toBeInTheDocument();
expect(screen.queryByRole("radio", { name: /^Fuse v2/ })).not.toBeInTheDocument();
expect(screen.getByRole("radio", { name: /^Heuristic \(default/ })).toBeChecked();
});
it.each(["Capability", "Fuse v2"])(
"retries failed model loading on %s without losing entered settings",
async (label) => {
const user = userEvent.setup();
mockFetchAvailableModels.mockRejectedValueOnce(new Error("Model list unavailable")).mockResolvedValue([
{ model_group: "efficient", mode: "chat" },
{ model_group: "capable", mode: "chat" },
{ model_group: "judge", mode: "chat" },
]);
renderWithProviders(<Harness />);
await user.click(screen.getByRole("tab", { name: label, exact: true }));
await user.type(screen.getByLabelText("Auto Router Name"), "forecast-retry");
const capability = label === "Capability";
const policyField = capability ? "Solve probability threshold" : "Efficient solver profile";
fireEvent.change(screen.getByLabelText(policyField), {
target: { value: capability ? "0.7" : "Small solver" },
});
expect(await screen.findByText("Could not load available models.")).toBeVisible();
expect(screen.queryByTestId("template-selector")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry", exact: true }));
await waitFor(() => expect(screen.queryByText("Could not load available models.")).not.toBeInTheDocument());
expect(screen.getByRole("tab", { name: label, exact: true })).toHaveAttribute("aria-selected", "true");
expect(screen.getByLabelText("Auto Router Name")).toHaveValue("forecast-retry");
expect(screen.getByLabelText(policyField)).toHaveValue(capability ? 0.7 : "Small solver");
await user.click(screen.getByRole("combobox", { name: "Judge model" }));
expect(await screen.findByRole("option", { name: "judge", exact: true })).toBeVisible();
expect(mockFetchAvailableModels).toHaveBeenCalledTimes(2);
},
);
// Detailed Configuration starts collapsed so the modal opens onto just Name + Template; a caller
// opts into the full tier/classifier form rather than always seeing it up front.
it("keeps Detailed Configuration collapsed until a caller opens it", () => {

View file

@ -1,3 +1,5 @@
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
import { getForecastConfigError, isForecastClassifier } from "../add_model/forecast_classifier_config";
import React, { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useWatch } from "react-hook-form";
@ -138,7 +140,9 @@ export const getSubmitBlockedReason = (
(config.custom_tier_set
? getCustomTierRowsError(config.custom_tier_set)
: getTierLabelsError(config.tier_labels)) ??
getMissingTiersError(activeTierRows(config)) ??
(isForecastClassifier(config.classifier_type)
? getForecastConfigError(config)
: getMissingTiersError(activeTierRows(config))) ??
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
getClassifierModelError(config) ??
@ -401,6 +405,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
classificationMode: complexityRouterConfig.classification_mode,
tierLabels: complexityRouterConfig.tier_labels,
classifierType: complexityRouterConfig.classifier_type,
capabilityClassifierConfig: complexityRouterConfig.capability_classifier_config,
llmV2Config: complexityRouterConfig.llm_v2_config,
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
classifierContextWindowSize: complexityRouterConfig.classifier_context_window_size,
classifierContextBudgetChars: complexityRouterConfig.classifier_context_budget_chars,
@ -543,81 +549,138 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
setIsTestModalVisible(true);
};
const configurationForm = (
<ComplexityRouterConfig
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
modelInfo={modelInfo}
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
keywordTierRules={keywordTierRules}
onKeywordTierRulesChange={setKeywordTierRules}
keywordRulesError={getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig))}
semanticMatchingEnabled={semanticMatchingEnabled}
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
embeddingModel={embeddingModel}
onEmbeddingModelChange={setEmbeddingModel}
matchThreshold={matchThreshold}
onMatchThresholdChange={setMatchThreshold}
escalationKeywords={escalationKeywords}
onEscalationKeywordsChange={setEscalationKeywords}
autoRouterCompression={autoRouterCompression}
onAutoRouterCompressionChange={isMemberManaged ? undefined : setAutoRouterCompression}
showValidationErrors={showValidationErrors}
/>
);
const forecast = isForecastClassifier(complexityRouterConfig.classifier_type);
return (
<TooltipProvider>
<Card>
<CardContent>
<form onSubmit={form.handleSubmit(() => handleAutoRouterSubmit())} noValidate>
<FieldGroup>
<div>
<FormField
control={form.control}
name="auto_router_name"
label={labelWithHint("Auto Router Name", "Unique name for this auto router configuration")}
>
{({ ref, ...field }) => (
<Input {...field} ref={ref} placeholder="e.g., smart_router, auto_router_1" />
)}
</FormField>
{!automaticSetupLoading && automaticRouterConfig && (
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted px-4 py-3">
<div>
<p className="text-sm font-medium text-foreground">Not sure where to start?</p>
<p className="text-sm text-muted-foreground">Let us pick models for each complexity tier.</p>
</div>
<Button type="button" data-testid="configure-automatically-button" onClick={handleAutomaticSetup}>
Configure automatically
</Button>
</div>
)}
<div className="mt-5">
<label className="block text-sm font-medium text-foreground mb-2">Template</label>
<Select
items={templateItems}
value={selectedPreset ?? null}
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
>
<SelectTrigger data-testid="template-selector" className="w-full">
<SelectValue placeholder="Choose a template or select Custom to define your own" />
</SelectTrigger>
<SelectContent>
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
const disabledHint = presetDisabledHint(presetState);
const hintClass = isPresetHintAlarming(presetState)
? "text-destructive"
: "text-muted-foreground";
const matchedHint =
presetState.kind === "available" && presetState.viaDeployments
? "Matches your deployments"
: null;
return (
<SelectItem
key={preset.key}
value={preset.key}
label={preset.label}
disabled={disabledHint !== null}
title={disabledHint ?? preset.description}
<div className="mb-6">
<FormField
control={form.control}
name="auto_router_name"
label={labelWithHint("Auto Router Name", "Unique name for this auto router configuration")}
>
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g., smart_router, auto_router_1" />}
</FormField>
</div>
<AutoRouterClassifierTabs
value={complexityRouterConfig}
onChange={(config) => {
setSelectedPreset(undefined);
setComplexityRouterConfig(config);
}}
>
<FieldGroup>
<div>
{!forecast && (
<>
{!automaticSetupLoading && automaticRouterConfig && (
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted px-4 py-3">
<div>
<p className="text-sm font-medium text-foreground">Not sure where to start?</p>
<p className="text-sm text-muted-foreground">
Let us pick models for each complexity tier.
</p>
</div>
<Button
type="button"
data-testid="configure-automatically-button"
onClick={handleAutomaticSetup}
>
<div>
<div className="font-medium">{preset.label}</div>
<div className="text-xs text-muted-foreground">{preset.description}</div>
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
</div>
</SelectItem>
);
})}
<SelectItem value="custom" label="Custom Configuration">
<div>
<div className="font-medium">Custom Configuration</div>
<div className="text-xs text-muted-foreground">Define your auto router from scratch</div>
Configure automatically
</Button>
</div>
</SelectItem>
</SelectContent>
</Select>
)}
<div className="mt-5">
<label className="block text-sm font-medium text-foreground mb-2">Template</label>
<Select
items={templateItems}
value={selectedPreset ?? null}
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
>
<SelectTrigger data-testid="template-selector" className="w-full">
<SelectValue placeholder="Choose a template or select Custom to define your own" />
</SelectTrigger>
<SelectContent>
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
const disabledHint = presetDisabledHint(presetState);
const hintClass = isPresetHintAlarming(presetState)
? "text-destructive"
: "text-muted-foreground";
const matchedHint =
presetState.kind === "available" && presetState.viaDeployments
? "Matches your deployments"
: null;
return (
<SelectItem
key={preset.key}
value={preset.key}
label={preset.label}
disabled={disabledHint !== null}
title={disabledHint ?? preset.description}
>
<div>
<div className="font-medium">{preset.label}</div>
<div className="text-xs text-muted-foreground">{preset.description}</div>
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
</div>
</SelectItem>
);
})}
<SelectItem value="custom" label="Custom Configuration">
<div>
<div className="font-medium">Custom Configuration</div>
<div className="text-xs text-muted-foreground">
Define your auto router from scratch
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
{presetsPending && (
<div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>
)}
{presetsUnavailable && (
<div className="text-xs mt-1 text-destructive">
Could not load templates, so only Custom Configuration is shown.{" "}
<button type="button" className="underline" onClick={() => void refetchPresets()}>
Retry
</button>
</div>
)}
</div>
</>
)}
{modelsUnverifiable && (
<div className="text-xs mt-1 text-destructive">
Could not load available models.{" "}
@ -626,163 +689,129 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
</button>
</div>
)}
{presetsPending && <div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>}
{presetsUnavailable && (
<div className="text-xs mt-1 text-destructive">
Could not load templates, so only Custom Configuration is shown.{" "}
<button type="button" className="underline" onClick={() => void refetchPresets()}>
Retry
</button>
</div>
)}
</div>
</div>
{requiresTeamScope && (
<FormField
control={form.control}
name="team_id"
label={labelWithHint(
"Select Team",
"Select the team this auto router belongs to. Only keys for this team will be able to call it.",
)}
>
{({ id, value, onChange }) => (
<TeamDropdown
id={id}
value={value}
onChange={onChange}
filterTeam={(team) => canCreateAutoRouterForTeam(actor, team)}
/>
)}
</FormField>
)}
<div className="border border-border rounded-lg">
<button
type="button"
onClick={() => setDetailsExpanded((expanded) => !expanded)}
className="w-full flex flex-col gap-1 px-4 py-3 text-left hover:bg-muted"
data-testid="detailed-configuration-toggle"
>
<span className="flex items-center gap-2 font-medium text-foreground">
{detailsExpanded ? (
<ChevronDown className="size-3 text-muted-foreground" />
) : (
<ChevronRight className="size-3 text-muted-foreground" />
{requiresTeamScope && (
<FormField
control={form.control}
name="team_id"
label={labelWithHint(
"Select Team",
"Select the team this auto router belongs to. Only keys for this team will be able to call it.",
)}
Detailed Configuration
</span>
{!detailsExpanded && (
<span className="text-xs text-muted-foreground line-clamp-2">
{tierConfigSummary(complexityRouterConfig)}
</span>
)}
</button>
{detailsExpanded && (
<div className="px-4 pb-4">
<ComplexityRouterConfig
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
modelInfo={modelInfo}
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
keywordTierRules={keywordTierRules}
onKeywordTierRulesChange={setKeywordTierRules}
keywordRulesError={getKeywordTierRulesError(
keywordTierRules,
activeTierRows(complexityRouterConfig),
>
{({ id, value, onChange }) => (
<TeamDropdown
id={id}
value={value}
onChange={onChange}
filterTeam={(team) => canCreateAutoRouterForTeam(actor, team)}
/>
)}
</FormField>
)}
{forecast ? (
configurationForm
) : (
<div className="border border-border rounded-lg">
<button
type="button"
onClick={() => setDetailsExpanded((expanded) => !expanded)}
className="w-full flex flex-col gap-1 px-4 py-3 text-left hover:bg-muted"
data-testid="detailed-configuration-toggle"
>
<span className="flex items-center gap-2 font-medium text-foreground">
{detailsExpanded ? (
<ChevronDown className="size-3 text-muted-foreground" />
) : (
<ChevronRight className="size-3 text-muted-foreground" />
)}
Detailed Configuration
</span>
{!detailsExpanded && (
<span className="text-xs text-muted-foreground line-clamp-2">
{tierConfigSummary(complexityRouterConfig)}
</span>
)}
semanticMatchingEnabled={semanticMatchingEnabled}
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
embeddingModel={embeddingModel}
onEmbeddingModelChange={setEmbeddingModel}
matchThreshold={matchThreshold}
onMatchThresholdChange={setMatchThreshold}
escalationKeywords={escalationKeywords}
onEscalationKeywordsChange={setEscalationKeywords}
autoRouterCompression={autoRouterCompression}
onAutoRouterCompressionChange={isMemberManaged ? undefined : setAutoRouterCompression}
showValidationErrors={showValidationErrors}
/>
</button>
{detailsExpanded && <div className="px-4 pb-4">{configurationForm}</div>}
</div>
)}
</div>
{isAdmin && (
<FormField
control={form.control}
name="model_access_group"
label={labelWithHint(
"Model Access Group",
"Use model access groups to control who can access this auto router",
)}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<AccessGroupTagsCombobox
id={id}
value={value}
onChange={onChange}
options={modelAccessGroups}
ariaInvalid={ariaInvalid}
ariaDescribedBy={ariaDescribedBy}
{isAdmin && (
<FormField
control={form.control}
name="model_access_group"
label={labelWithHint(
"Model Access Group",
"Use model access groups to control who can access this auto router",
)}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<AccessGroupTagsCombobox
id={id}
value={value}
onChange={onChange}
options={modelAccessGroups}
ariaInvalid={ariaInvalid}
ariaDescribedBy={ariaDescribedBy}
/>
)}
</FormField>
)}
<div className="flex justify-between items-center">
<Tooltip>
<TooltipTrigger
render={
<a
href="https://github.com/BerriAI/litellm/issues"
className="text-sm text-primary underline-offset-4 hover:underline"
>
Need Help?
</a>
}
/>
)}
</FormField>
)}
<div className="flex justify-between items-center">
<Tooltip>
<TooltipTrigger
render={
<a
href="https://github.com/BerriAI/litellm/issues"
className="text-sm text-primary underline-offset-4 hover:underline"
<TooltipContent>Get help on our github</TooltipContent>
</Tooltip>
<div className="flex gap-2">
<BlockedReasonTooltip reason={submitBlockedReason}>
<Button
type="button"
variant="outline"
data-testid="auto-router-test-routing-btn"
disabled={submitBlockedReason !== null || isSubmitting}
onClick={() => setIsRoutingTestVisible(true)}
>
Need Help?
</a>
}
/>
<TooltipContent>Get help on our github</TooltipContent>
</Tooltip>
<div className="flex gap-2">
<BlockedReasonTooltip reason={submitBlockedReason}>
Test Routing
</Button>
</BlockedReasonTooltip>
<Button
type="button"
variant="outline"
data-testid="auto-router-test-routing-btn"
disabled={submitBlockedReason !== null || isSubmitting}
onClick={() => setIsRoutingTestVisible(true)}
data-testid="auto-router-test-connect-btn"
onClick={handleTestConnection}
disabled={isTestingConnection}
>
Test Routing
{isTestingConnection && <UiLoadingSpinner className="size-4" />}
Test Connection
</Button>
</BlockedReasonTooltip>
<Button
type="button"
variant="outline"
data-testid="auto-router-test-connect-btn"
onClick={handleTestConnection}
disabled={isTestingConnection}
>
{isTestingConnection && <UiLoadingSpinner className="size-4" />}
Test Connection
</Button>
<BlockedReasonTooltip reason={submitBlockedReason}>
<Button
type="button"
disabled={submitBlockedReason !== null || isSubmitting}
onClick={() => {
void handleAutoRouterSubmit();
}}
>
Add Auto Router
</Button>
</BlockedReasonTooltip>
<BlockedReasonTooltip reason={submitBlockedReason}>
<Button
type="button"
disabled={submitBlockedReason !== null || isSubmitting}
onClick={() => {
void handleAutoRouterSubmit();
}}
>
Add Auto Router
</Button>
</BlockedReasonTooltip>
</div>
</div>
</div>
</FieldGroup>
</FieldGroup>
</AutoRouterClassifierTabs>
</form>
</CardContent>
</Card>

View file

@ -1,3 +1,9 @@
import {
isForecastClassifier,
withoutForecastPromptOverrides,
type CapabilitySettings,
type FuseSettings,
} from "./forecast_classifier_config";
import type { ModelGroup } from "../llm_calls/fetch_models";
import { KeywordTierRule } from "./KeywordTierRules";
import {
@ -126,6 +132,48 @@ const scorerKnobPayload = ({
};
};
export interface StoredComplexityRouterConfig {
tiers?: Partial<Record<keyof ComplexityTiers, unknown>>;
enable_non_reasoning_tier?: boolean;
tier_model_configs?: unknown;
default_model?: string | null;
plan_mode_min_tier?: unknown;
classification_prompt?: unknown;
classification_examples?: unknown;
heuristic_first_max_tier?: unknown;
hybrid_boundary_margin?: unknown;
tier_labels?: unknown;
classifier_type?: ClassifierType;
capability_classifier_config?: unknown;
llm_v2_config?: unknown;
classifier_llm_config?: ClassifierLLMConfig;
classifier_context_window_size?: unknown;
classifier_context_budget_chars?: unknown;
classifier_context_include_assistant_turns?: unknown;
classifier_fallback?: unknown;
classification_mode?: unknown;
tier_boundaries?: unknown;
token_thresholds?: unknown;
dimension_weights?: unknown;
custom_dimensions?: unknown;
reasoning_override_min_score?: unknown;
session_affinity?: unknown;
session_affinity_ttl_seconds?: unknown;
modality_routing?: unknown;
modality_pin_override?: unknown;
deployment_affinity?: unknown;
adaptive?: boolean;
adaptive_weights?: AdaptiveRouterWeights;
tier_distance_penalty?: number;
adaptive_eligible?: AdaptiveEligible;
return_raw_model_name?: boolean;
enable_context_window_escalation?: unknown;
context_window_escalation_buffer?: unknown;
stall_escalation_enabled?: unknown;
stall_escalation_window?: unknown;
stall_escalation_repeat_threshold?: unknown;
}
export interface BuildComplexityRouterConfigParams {
tiers: ComplexityTiers;
enableNonReasoningTier?: boolean;
@ -134,6 +182,8 @@ export interface BuildComplexityRouterConfigParams {
planModeMinTier: string | undefined;
tierLabels: ComplexityTierLabels | undefined;
classifierType: ClassifierType;
capabilityClassifierConfig?: CapabilitySettings;
llmV2Config?: FuseSettings;
classifierLlmConfig: ClassifierLLMConfigWire | undefined;
classifierContextWindowSize: number | undefined;
classifierContextBudgetChars: number | undefined;
@ -198,6 +248,8 @@ export interface ComplexityRouterConfigPayload {
plan_mode_min_tier?: string;
tier_labels?: ComplexityTierLabels;
classifier_type: ClassifierType;
capability_classifier_config?: CapabilitySettings;
llm_v2_config?: FuseSettings;
classifier_llm_config?: ClassifierLLMConfig;
classifier_context_window_size?: number;
classifier_context_budget_chars?: number;
@ -468,31 +520,34 @@ const classifierWireFields = (
| "classifierContextBudgetChars"
| "classifierContextIncludeAssistantTurns"
>,
): Partial<ComplexityRouterConfigPayload> => ({
...(usesLlmClassifier(effectiveType) &&
classifierLlmConfig && {
classifier_llm_config:
effectiveType === "capability" ? classifierLlmConfig : normalizeClassifierLlmConfig(classifierLlmConfig),
}),
...(usesLlmClassifier(effectiveType) &&
classifierFallback !== undefined && { classifier_fallback: classifierFallback }),
...(effectiveType === "heuristic_first" &&
heuristicFirstMaxTier?.trim() && { heuristic_first_max_tier: heuristicFirstMaxTier }),
...(effectiveType === "hybrid" &&
hybridBoundaryMargin !== undefined && { hybrid_boundary_margin: hybridBoundaryMargin }),
...(usesLlmClassifier(effectiveType) &&
classifierContextWindowSize !== undefined && {
classifier_context_window_size: classifierContextWindowSize,
}),
...(usesLlmClassifier(effectiveType) &&
classifierContextBudgetChars !== undefined && {
classifier_context_budget_chars: classifierContextBudgetChars,
}),
...(usesLlmClassifier(effectiveType) &&
classifierContextIncludeAssistantTurns !== undefined && {
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
}),
});
): Partial<ComplexityRouterConfigPayload> => {
const supportsFallback = usesLlmClassifier(effectiveType) && !isForecastClassifier(effectiveType);
return {
...(usesLlmClassifier(effectiveType) &&
classifierLlmConfig && {
classifier_llm_config: isForecastClassifier(effectiveType)
? withoutForecastPromptOverrides(classifierLlmConfig)
: normalizeClassifierLlmConfig(classifierLlmConfig),
}),
...(supportsFallback && classifierFallback !== undefined && { classifier_fallback: classifierFallback }),
...(effectiveType === "heuristic_first" &&
heuristicFirstMaxTier?.trim() && { heuristic_first_max_tier: heuristicFirstMaxTier }),
...(effectiveType === "hybrid" &&
hybridBoundaryMargin !== undefined && { hybrid_boundary_margin: hybridBoundaryMargin }),
...(usesLlmClassifier(effectiveType) &&
classifierContextWindowSize !== undefined && {
classifier_context_window_size: classifierContextWindowSize,
}),
...(usesLlmClassifier(effectiveType) &&
classifierContextBudgetChars !== undefined && {
classifier_context_budget_chars: classifierContextBudgetChars,
}),
...(usesLlmClassifier(effectiveType) &&
classifierContextIncludeAssistantTurns !== undefined && {
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
}),
};
};
export const buildComplexityRouterConfig = ({
tiers,
@ -502,6 +557,8 @@ export const buildComplexityRouterConfig = ({
planModeMinTier,
tierLabels,
classifierType,
capabilityClassifierConfig,
llmV2Config,
classifierLlmConfig,
classifierContextWindowSize,
classifierContextBudgetChars,
@ -572,8 +629,12 @@ export const buildComplexityRouterConfig = ({
// the form never rewrote. The UI gates the same controls on this, not on the raw value.
const effectiveType: ClassifierType = customTierSet ? "llm" : classifierType;
const supportsOpeningPrompt =
!customTierSet && !isForecastClassifier(effectiveType) && usesLlmClassifier(effectiveType);
const payload: ComplexityRouterConfigPayload = {
tiers,
tiers: isForecastClassifier(effectiveType)
? Object.fromEntries(Object.entries(tiers).filter(([, models]) => models.length > 0))
: tiers,
// The backend rejects the flag beside a custom tier set.
...(!customTierSet && enableNonReasoningTier && { enable_non_reasoning_tier: true }),
...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }),
@ -582,10 +643,12 @@ export const buildComplexityRouterConfig = ({
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
classifier_type: classifierType,
...classifierWireFields(effectiveType, classifierInputs),
...(effectiveType === "capability" &&
capabilityClassifierConfig && { capability_classifier_config: capabilityClassifierConfig }),
...(effectiveType === "llm_v2" && { llm_v2_config: llmV2Config, adaptive: false }),
// A built-in router's opening instructions. Suppressed beside a legacy whole-prompt override,
// which the backend rejects as a second override of the same prompt.
...(!customTierSet &&
usesLlmClassifier(effectiveType) &&
...(supportsOpeningPrompt &&
!classifierLlmConfig?.system_prompt?.trim() && {
...(classificationPrompt?.trim() && { classification_prompt: classificationPrompt.trim() }),
...(classificationExamples?.trim() && { classification_examples: classificationExamples.trim() }),
@ -612,12 +675,13 @@ export const buildComplexityRouterConfig = ({
embedding_model: embeddingModel,
match_threshold: matchThreshold,
}),
...(adaptive && {
adaptive: true,
adaptive_weights: adaptiveWeights,
...(adaptiveEligible === "all" && { tier_distance_penalty: tierDistancePenalty }),
adaptive_eligible: adaptiveEligible,
}),
...(adaptive &&
effectiveType !== "llm_v2" && {
adaptive: true,
adaptive_weights: adaptiveWeights,
...(adaptiveEligible === "all" && { tier_distance_penalty: tierDistancePenalty }),
adaptive_eligible: adaptiveEligible,
}),
...(returnRawModelName && { return_raw_model_name: true }),
...(enableContextWindowEscalation !== undefined && {
enable_context_window_escalation: enableContextWindowEscalation,

View file

@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { transitionClassifierType } from "./classifier_type_transition";
const standard: ComplexityRouterConfigValue = {
classifier_type: "llm",
classifier_llm_config: { model: "judge", timeout_ms: 20000, classification_rubric: "business" },
classifier_context_window_size: 8,
classifier_context_budget_chars: 16000,
classifier_context_include_assistant_turns: true,
classifier_fallback: "default_model",
tiers: { SIMPLE: ["efficient"], MEDIUM: ["middle"], COMPLEX: [], REASONING: ["capable"] },
};
describe("transitionClassifierType", () => {
it.each(["heuristic_first", "hybrid"] as const)("keeps existing LLM settings when switching to %s", (target) => {
const result = transitionClassifierType(standard, target);
const expectedSettings = {
classifier_type: target,
classifier_llm_config: standard.classifier_llm_config,
classifier_context_window_size: 8,
classifier_context_budget_chars: 16000,
classifier_context_include_assistant_turns: true,
classifier_fallback: "default_model",
};
expect(result).toMatchObject(expectedSettings);
});
it.each(["capability", "llm_v2"] as const)("requires explicit policy input for a new %s classifier", (target) => {
const result = transitionClassifierType(standard, target);
expect(result.classifier_llm_config).toEqual({ model: "judge", timeout_ms: 20000 });
expect(result.classifier_fallback).toBeUndefined();
if (target === "capability") {
expect(result.capability_classifier_config?.base_threshold).toBeNaN();
} else {
expect(result.llm_v2_config).toMatchObject({ efficient_profile: "", capable_profile: "", harness: "" });
expect(result.llm_v2_config?.max_quality_gap).toBeNaN();
}
expect(standard.tiers.MEDIUM).toEqual(["middle"]);
expect(standard.classifier_llm_config?.classification_rubric).toBe("business");
});
it.each([
["capability", "llm"],
["capability", "heuristic_first"],
["capability", "hybrid"],
["llm_v2", "llm"],
["llm_v2", "heuristic_first"],
["llm_v2", "hybrid"],
] as const)("restores the complexity rubric from %s to %s while preserving the judge", (source, target) => {
const forecast = transitionClassifierType(standard, source);
const result = transitionClassifierType(forecast, target);
expect(result.classifier_llm_config).toEqual({
model: "judge",
timeout_ms: 20000,
classification_rubric: "agentic",
});
expect(result.capability_classifier_config).toBeUndefined();
expect(result.llm_v2_config).toBeUndefined();
});
it("clears the inactive non-reasoning pool and plan floor when switching to local classification", () => {
const initial: ComplexityRouterConfigValue = {
...standard,
tiers: { ...standard.tiers, NON_REASONING: ["chat"] },
enable_non_reasoning_tier: true,
plan_mode_min_tier: "NON_REASONING",
};
const result = transitionClassifierType(initial, "heuristic");
expect(result.classifier_llm_config).toBeUndefined();
expect(result.classifier_context_window_size).toBeUndefined();
expect(result.classifier_context_budget_chars).toBeUndefined();
expect(result.classifier_context_include_assistant_turns).toBeUndefined();
expect(result.classifier_fallback).toBeUndefined();
expect(result.tiers.NON_REASONING).toBeUndefined();
expect(result.enable_non_reasoning_tier).toBeUndefined();
expect(result.plan_mode_min_tier).toBeUndefined();
expect(result.tiers.SIMPLE).toEqual(["efficient"]);
});
});

View file

@ -0,0 +1,50 @@
import {
type ClassifierType,
type ComplexityRouterConfigValue,
DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE,
DEFAULT_CLASSIFIER_TIMEOUT_MS,
DEFAULT_HEURISTIC_FIRST_MAX_TIER,
DEFAULT_HYBRID_BOUNDARY_MARGIN,
NEW_CLASSIFIER_CLASSIFICATION_RUBRIC,
usesLlmClassifier,
} from "./ComplexityRouterConfig";
import { isForecastClassifier, prepareForecastClassifier } from "./forecast_classifier_config";
import { nonReasoningTierFields } from "./nonReasoningTierFields";
export const transitionClassifierType = (
value: ComplexityRouterConfigValue,
classifierType: ClassifierType,
): ComplexityRouterConfigValue => {
const startsLlmRubric =
!value.classifier_llm_config ||
(isForecastClassifier(value.classifier_type) && !isForecastClassifier(classifierType));
const judgeConfig = value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS };
const nextValue: ComplexityRouterConfigValue = {
...value,
classifier_llm_config: usesLlmClassifier(classifierType)
? {
...judgeConfig,
...(startsLlmRubric && { classification_rubric: NEW_CLASSIFIER_CLASSIFICATION_RUBRIC }),
}
: undefined,
classifier_context_window_size: usesLlmClassifier(classifierType)
? value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE
: undefined,
classifier_context_budget_chars: usesLlmClassifier(classifierType)
? value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS
: undefined,
classifier_context_include_assistant_turns: usesLlmClassifier(classifierType)
? value.classifier_context_include_assistant_turns
: undefined,
classifier_fallback: usesLlmClassifier(classifierType) ? value.classifier_fallback : undefined,
heuristic_first_max_tier:
classifierType === "heuristic_first"
? value.heuristic_first_max_tier ?? DEFAULT_HEURISTIC_FIRST_MAX_TIER
: undefined,
hybrid_boundary_margin:
classifierType === "hybrid" ? value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN : undefined,
...nonReasoningTierFields(classifierType, value),
};
return prepareForecastClassifier(nextValue, classifierType);
};

View file

@ -0,0 +1,284 @@
import { describe, expect, it } from "vitest";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { getForecastConfigError, prepareForecastClassifier } from "./forecast_classifier_config";
import { getKeywordTierRulesError } from "./build_complexity_router_config";
import { activeTierRows } from "./tier_rows";
import {
buildUpdatedComplexityRouterConfig,
hydrateComplexityRouterConfig,
} from "../edit_auto_router/edit_auto_router_modal";
const capability: ComplexityRouterConfigValue = {
classifier_type: "capability",
classifier_llm_config: { model: "judge", timeout_ms: 20000 },
tiers: { SIMPLE: ["efficient"], MEDIUM: [], COMPLEX: [], REASONING: ["capable"] },
capability_classifier_config: {
efficient_tier: "SIMPLE",
capable_tier: "REASONING",
base_threshold: 0.7,
threshold_step: 0.1,
},
};
const fuse: ComplexityRouterConfigValue = {
...capability,
classifier_type: "llm_v2",
capability_classifier_config: undefined,
adaptive: false,
llm_v2_config: {
efficient_profile: "A concise solver",
capable_profile: "A solver with more reasoning budget",
harness: "One attempt with shell and tests",
max_quality_gap: 0.05,
},
};
describe("forecast classifier configuration", () => {
it.each([
{ version: "eval", slope: 21, intercept: 0 },
{ version: "eval", slope: 1, intercept: -21 },
{ version: " eval ", slope: 1, intercept: 0 },
])("rejects capability calibration outside the server contract: %j", (calibration) => {
const value = {
...capability,
capability_classifier_config: { ...capability.capability_classifier_config!, calibration },
};
expect(getForecastConfigError(value)).toContain("calibration");
});
it.each([capability, fuse])("accepts a complete $classifier_type configuration", (value) => {
expect(getForecastConfigError(value)).toBeNull();
});
it("requires both solvers without requiring unused middle tiers", () => {
expect(getForecastConfigError({ ...capability, tiers: { ...capability.tiers, REASONING: [] } })).toContain("both");
expect(getForecastConfigError(capability)).toBeNull();
});
it("rejects a stepped threshold that exceeds one", () => {
expect(
getForecastConfigError({
...capability,
capability_classifier_config: { ...capability.capability_classifier_config!, threshold_step: 0.2 },
}),
).toContain("twice");
});
it.each([Number.NaN, -0.1, 1.1])("rejects an invalid probability %s", (base_threshold) => {
expect(
getForecastConfigError({
...capability,
capability_classifier_config: { ...capability.capability_classifier_config!, base_threshold },
}),
).not.toBeNull();
});
it.each(["efficient_profile", "capable_profile", "harness"] as const)("requires %s for Fuse", (field) => {
expect(getForecastConfigError({ ...fuse, llm_v2_config: { ...fuse.llm_v2_config!, [field]: " " } })).not.toBeNull();
});
it("requires distinct single model groups and disables adaptive selection", () => {
expect(getForecastConfigError({ ...fuse, tiers: { ...fuse.tiers, SIMPLE: ["capable"] } })).toContain("distinct");
expect(getForecastConfigError({ ...fuse, tiers: { ...fuse.tiers, SIMPLE: ["efficient", "second"] } })).toContain(
"distinct",
);
expect(getForecastConfigError({ ...fuse, adaptive: true })).toContain("adaptive");
});
it("removes incompatible prompt and tier settings when switching to Fuse", () => {
const previous: ComplexityRouterConfigValue = {
...fuse,
adaptive: true,
classifier_fallback: "default_model",
classifier_llm_config: {
model: "judge",
timeout_ms: 20000,
system_prompt: "old rubric",
classification_rubric: "business",
},
classification_prompt: "old prompt",
classification_examples: "old example",
tiers: { ...fuse.tiers, MEDIUM: ["extra"], SIMPLE: ["efficient", "second"] },
};
const next = prepareForecastClassifier(previous);
const saved = buildUpdatedComplexityRouterConfig({}, next);
expect(getForecastConfigError(next)).toBeNull();
expect(saved.adaptive).toBe(false);
expect(saved.tiers).toEqual({ SIMPLE: ["efficient"], REASONING: ["capable"] });
expect(saved.classifier_llm_config).toEqual({ model: "judge", timeout_ms: 20000 });
expect(saved).not.toHaveProperty("classification_prompt");
expect(saved).not.toHaveProperty("classification_examples");
expect(saved).not.toHaveProperty("classifier_fallback");
});
it.each([capability, fuse])(
"switching to $classifier_type removes hidden pools and their overrides while retaining the solver settings",
(value) => {
const efficientParams = { reasoning_effort: "low", speed: "fast", max_tokens: 1024 };
const secondEfficientParams = { reasoning_effort: "medium", max_tokens: 2048 };
const capableParams = { reasoning_effort: "high", max_tokens: 4096 };
const secondCapableParams = { speed: "fast" };
const previous: ComplexityRouterConfigValue = {
...value,
adaptive: true,
plan_mode_min_tier: "MEDIUM",
enable_non_reasoning_tier: true,
tiers: {
NON_REASONING: ["relay"],
SIMPLE: ["efficient", "second-efficient"],
MEDIUM: ["leftover-medium"],
COMPLEX: ["leftover-complex"],
REASONING: ["capable", "second-capable"],
},
tier_model_params: {
NON_REASONING: { relay: { max_tokens: 128 } },
SIMPLE: { efficient: efficientParams, "second-efficient": secondEfficientParams },
MEDIUM: { "leftover-medium": { speed: "fast" } },
COMPLEX: { "leftover-complex": { reasoning_effort: "high" } },
REASONING: { capable: capableParams, "second-capable": secondCapableParams },
LEGACY_CUSTOM: { "leftover-custom": { max_tokens: 512 } },
},
};
const next = prepareForecastClassifier(previous);
const preservesPools = value.classifier_type === "capability";
const expectedTiers = {
SIMPLE: preservesPools ? ["efficient", "second-efficient"] : ["efficient"],
MEDIUM: [],
COMPLEX: [],
REASONING: preservesPools ? ["capable", "second-capable"] : ["capable"],
};
expect(next.tiers).toEqual(expectedTiers);
expect(next.tier_model_params).toEqual({
SIMPLE: {
efficient: efficientParams,
...(preservesPools && { "second-efficient": secondEfficientParams }),
},
REASONING: {
capable: capableParams,
...(preservesPools && { "second-capable": secondCapableParams }),
},
});
expect(next.adaptive).toBe(preservesPools);
expect(next.plan_mode_min_tier).toBeUndefined();
expect(getForecastConfigError(next)).toBeNull();
expect(activeTierRows(next).map((row) => row.name)).toEqual(["SIMPLE", "REASONING"]);
expect(
getKeywordTierRulesError([{ id: "old-middle", keywords: ["invoice"], tier: "MEDIUM" }], activeTierRows(next)),
).toContain("no longer has");
expect(
getKeywordTierRulesError([{ id: "solver", keywords: ["audit"], tier: "REASONING" }], activeTierRows(next)),
).toBeNull();
const saved = buildUpdatedComplexityRouterConfig(previous, next);
expect(saved.tiers).toEqual({ SIMPLE: expectedTiers.SIMPLE, REASONING: expectedTiers.REASONING });
expect(saved.tier_model_configs).toEqual({
SIMPLE: [
{ model_name: "efficient", litellm_params: efficientParams },
...(preservesPools ? [{ model_name: "second-efficient", litellm_params: secondEfficientParams }] : []),
],
REASONING: [
{ model_name: "capable", litellm_params: capableParams },
...(preservesPools ? [{ model_name: "second-capable", litellm_params: secondCapableParams }] : []),
],
});
expect(saved).not.toHaveProperty("plan_mode_min_tier");
},
);
it("preserves fitted calibration through edits and removes it when disabled", () => {
const stored = {
...capability,
capability_classifier_config: {
...capability.capability_classifier_config!,
calibration: { version: "eval-a", slope: 1.2, intercept: -0.3 },
},
};
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
const edited = {
...hydrated,
capability_classifier_config: { ...hydrated.capability_classifier_config!, base_threshold: 0.6 },
};
expect(buildUpdatedComplexityRouterConfig(stored, edited).capability_classifier_config).toEqual({
...stored.capability_classifier_config,
base_threshold: 0.6,
});
const disabled = {
...edited,
capability_classifier_config: { ...edited.capability_classifier_config, calibration: undefined },
};
expect(buildUpdatedComplexityRouterConfig(stored, disabled).capability_classifier_config).toHaveProperty(
"calibration",
undefined,
);
});
it("preserves non-default tier assignments", () => {
const value = {
...capability,
capability_classifier_config: {
...capability.capability_classifier_config!,
efficient_tier: "MEDIUM",
capable_tier: "COMPLEX",
},
tiers: { SIMPLE: [], MEDIUM: ["efficient"], COMPLEX: ["capable"], REASONING: [] },
};
expect(getForecastConfigError(value)).toBeNull();
expect(buildUpdatedComplexityRouterConfig({}, value).capability_classifier_config).toEqual(
value.capability_classifier_config,
);
const previous = {
...value,
tiers: { ...value.tiers, SIMPLE: ["leftover-simple"], REASONING: ["leftover-reasoning"] },
plan_mode_min_tier: "COMPLEX",
tier_model_params: {
MEDIUM: { efficient: { max_tokens: 1024 } },
COMPLEX: { capable: { speed: "fast" } },
SIMPLE: { "leftover-simple": { speed: "fast" } },
},
};
const next = prepareForecastClassifier(previous);
expect(next.tiers).toEqual(value.tiers);
expect(next.plan_mode_min_tier).toBe("COMPLEX");
expect(next.tier_model_params).toEqual({
MEDIUM: { efficient: { max_tokens: 1024 } },
COMPLEX: { capable: { speed: "fast" } },
});
});
it("keeps configured extra Capability pools through hydration and an unrelated edit", () => {
const stored = {
...capability,
adaptive: true,
plan_mode_min_tier: "MEDIUM",
tiers: { ...capability.tiers, MEDIUM: ["middle"] },
tier_model_configs: { MEDIUM: [{ model_name: "middle", litellm_params: { speed: "fast", max_tokens: 1024 } }] },
};
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
expect(hydrated.tiers.MEDIUM).toEqual(["middle"]);
expect(hydrated.plan_mode_min_tier).toBe("MEDIUM");
expect(activeTierRows(hydrated).map((row) => row.name)).toEqual(["SIMPLE", "MEDIUM", "REASONING"]);
expect(
getKeywordTierRulesError([{ id: "kept", keywords: ["invoice"], tier: "MEDIUM" }], activeTierRows(hydrated)),
).toBeNull();
const saved = buildUpdatedComplexityRouterConfig(stored, {
...hydrated,
capability_classifier_config: { ...hydrated.capability_classifier_config!, base_threshold: 0.6 },
});
expect(saved.tiers).toEqual({ SIMPLE: ["efficient"], MEDIUM: ["middle"], REASONING: ["capable"] });
expect(saved.tier_model_configs).toEqual(stored.tier_model_configs);
expect(saved.adaptive).toBe(true);
expect(saved.plan_mode_min_tier).toBe("MEDIUM");
});
it("keeps empty built-in tiers available to standard classifiers", () => {
const standard: ComplexityRouterConfigValue = { ...capability, classifier_type: "llm" };
expect(activeTierRows(standard).map((row) => row.name)).toEqual(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
expect(
getKeywordTierRulesError([{ id: "middle", keywords: ["invoice"], tier: "MEDIUM" }], activeTierRows(standard)),
).toBeNull();
});
it.each([capability, fuse])("drops $classifier_type settings when switching to the heuristic", (value) => {
const saved = buildUpdatedComplexityRouterConfig(value, { ...value, classifier_type: "heuristic" });
expect(saved).not.toHaveProperty("capability_classifier_config");
expect(saved).not.toHaveProperty("llm_v2_config");
});
});

View file

@ -0,0 +1,188 @@
import { z } from "zod";
import type { ClassifierType, ComplexityRouterConfigValue, ComplexityTiers } from "./ComplexityRouterConfig";
import { pruneTierModelParams } from "./complexity_router_tiers";
import { tierOrderFor } from "./tier_rows";
const probability = z.number().finite().min(0).max(1);
const version = z.string().trim().min(1).max(512);
const profile = z.string().trim().min(1).max(4000);
const transport = {
max_output_tokens: z.number().int().positive().optional(),
response_format: z.enum(["json_schema", "json_object"]).optional(),
};
const coefficients = z.object({ slope: z.number().finite().positive(), intercept: z.number().finite() });
const capabilityShape = {
efficient_tier: z.string().min(1),
capable_tier: z.string().min(1),
base_threshold: probability,
threshold_step: z.number().finite().nonnegative().optional(),
...transport,
calibration: z
.object({
version: z
.string()
.min(1)
.max(128)
.regex(/^\S(?:.*\S)?$/),
slope: z.number().finite().min(0).max(20),
intercept: z.number().finite().min(-20).max(20),
})
.nullable()
.optional(),
};
export const capabilitySettingsSchema = z.object(capabilityShape);
const fuseCalibrationShape = {
version,
prompt_version: z.literal("llm-v2-1"),
efficient: coefficients,
capable: coefficients,
};
const fuseShape = {
efficient_tier: z.string().min(1).optional(),
capable_tier: z.string().min(1).optional(),
efficient_profile: profile,
capable_profile: profile,
harness: profile,
max_quality_gap: probability,
...transport,
calibration: z.object(fuseCalibrationShape).nullable().optional(),
};
export const fuseSettingsSchema = z.object(fuseShape);
export type CapabilitySettings = z.infer<typeof capabilitySettingsSchema>;
export type FuseSettings = z.infer<typeof fuseSettingsSchema>;
export const isForecastClassifier = (type: ClassifierType): boolean => type === "capability" || type === "llm_v2";
export const newCapabilitySettings = (): CapabilitySettings => ({
efficient_tier: "SIMPLE",
capable_tier: "REASONING",
base_threshold: Number.NaN,
});
export const newFuseSettings = (): FuseSettings => ({
efficient_profile: "",
capable_profile: "",
harness: "",
max_quality_gap: Number.NaN,
});
export const forecastTierNames = (
value: Pick<ComplexityRouterConfigValue, "classifier_type" | "capability_classifier_config" | "llm_v2_config">,
): readonly [string, string] => {
const settings = value.classifier_type === "capability" ? value.capability_classifier_config : value.llm_v2_config;
return [settings?.efficient_tier ?? "SIMPLE", settings?.capable_tier ?? "REASONING"];
};
export const forecastModels = (tiers: ComplexityTiers, tier: string): string[] =>
Object.entries(tiers).find(([name]) => name === tier)?.[1] ?? [];
export const withoutForecastPromptOverrides = <T extends { system_prompt?: string; classification_rubric?: string }>(
config: T,
): Omit<T, "system_prompt" | "classification_rubric"> => {
const { system_prompt: _prompt, classification_rubric: _rubric, ...rest } = config;
return rest;
};
export const prepareForecastClassifier = (
previous: ComplexityRouterConfigValue,
classifierType: ClassifierType = previous.classifier_type,
): ComplexityRouterConfigValue => {
const value = { ...previous, classifier_type: classifierType };
if (!isForecastClassifier(classifierType))
return {
...value,
capability_classifier_config: undefined,
llm_v2_config: undefined,
};
// Capture routing identity before replacing the source classifier's settings.
const [sourceEfficient, sourceCapable] = forecastTierNames(previous);
const transferredPair =
isForecastClassifier(previous.classifier_type) && previous.classifier_type !== classifierType
? { efficient_tier: sourceEfficient, capable_tier: sourceCapable }
: {};
const configured = {
...value,
capability_classifier_config:
value.classifier_type === "capability"
? { ...(value.capability_classifier_config ?? newCapabilitySettings()), ...transferredPair }
: undefined,
llm_v2_config:
value.classifier_type === "llm_v2"
? { ...(value.llm_v2_config ?? newFuseSettings()), ...transferredPair }
: undefined,
};
const [efficient, capable] = forecastTierNames(configured);
const solverModels = (tier: string) => {
const models = forecastModels(value.tiers, tier);
return value.classifier_type === "llm_v2" ? models.slice(0, 1) : models;
};
const tiers: ComplexityTiers = {
SIMPLE: [],
MEDIUM: [],
COMPLEX: [],
REASONING: [],
[efficient]: solverModels(efficient),
[capable]: solverModels(capable),
};
return {
...configured,
tiers,
tier_model_params: Object.keys(value.tier_model_params ?? {}).reduce(
(params, tier) => pruneTierModelParams(params, tier, forecastModels(tiers, tier)),
value.tier_model_params,
),
plan_mode_min_tier:
value.plan_mode_min_tier && forecastModels(tiers, value.plan_mode_min_tier).length > 0
? value.plan_mode_min_tier
: undefined,
custom_tier_set: undefined,
enable_non_reasoning_tier: false,
classification_prompt: undefined,
classification_examples: undefined,
classifier_fallback: undefined,
classifier_llm_config: value.classifier_llm_config && withoutForecastPromptOverrides(value.classifier_llm_config),
...(value.classifier_type === "llm_v2" && { adaptive: false }),
};
};
export const getForecastConfigError = (value: ComplexityRouterConfigValue): string | null => {
if (!isForecastClassifier(value.classifier_type) || value.custom_tier_set) return null;
const timeout = value.classifier_llm_config?.timeout_ms;
if (timeout !== undefined && (!Number.isInteger(timeout) || timeout <= 0))
return "Enter a positive whole-number classifier timeout";
const [efficient, capable] = forecastTierNames(value);
const order: readonly string[] = tierOrderFor(value.enable_non_reasoning_tier);
if (!order.includes(efficient) || !order.includes(capable) || order.indexOf(capable) <= order.indexOf(efficient))
return "The capable tier must be higher than the efficient tier";
const efficientModels = forecastModels(value.tiers, efficient);
const capableModels = forecastModels(value.tiers, capable);
if (!efficientModels.length || !capableModels.length)
return "Select models for both the efficient and capable solvers";
if (value.classifier_type === "capability") {
const result = capabilitySettingsSchema.safeParse(value.capability_classifier_config);
if (!result.success)
return "Enter a solve threshold between 0 and 1 and valid capability settings, including any calibration coefficients";
if (result.data.base_threshold + 2 * (result.data.threshold_step ?? 0) > 1)
return "The solve threshold plus twice the boundary step must be at most 1";
return null;
}
return getFuseConfigError(value, efficient, capable);
};
const getFuseConfigError = (value: ComplexityRouterConfigValue, efficient: string, capable: string): string | null => {
const efficientModels = forecastModels(value.tiers, efficient);
const capableModels = forecastModels(value.tiers, capable);
if (value.adaptive) return "Turn off adaptive routing for Fuse v2";
if (value.enable_non_reasoning_tier) return "Fuse v2 does not support the non-reasoning tier";
if (efficientModels.length !== 1 || capableModels.length !== 1 || efficientModels[0] === capableModels[0])
return "Fuse v2 requires one distinct model group for each solver";
if (Object.entries(value.tiers).some(([tier, models]) => models.length > 0 && ![efficient, capable].includes(tier)))
return "Fuse v2 supports only its efficient and capable tiers";
const result = fuseSettingsSchema.safeParse(value.llm_v2_config);
if (!result.success)
return "Complete both solver profiles, the harness, and a quality gap between 0 and 1; any calibration needs valid coefficients and a version";
return null;
};

View file

@ -1,4 +1,5 @@
import type { ComplexityTiers } from "./ComplexityRouterConfig";
import { isForecastClassifier } from "./forecast_classifier_config";
import type { ClassifierType, ComplexityTiers } from "./ComplexityRouterConfig";
import type { ComplexityTier } from "./KeywordTierRules";
import type { TierModelParams, TierModelParamsByTier } from "./complexity_router_tiers";
@ -32,6 +33,7 @@ export const MAX_TIER_NAME_CHARS = 64;
export const MAX_TIER_DEFINITION_CHARS = 500;
export interface ActiveTierSet {
classifier_type?: ClassifierType;
tiers: ComplexityTiers;
enable_non_reasoning_tier?: boolean;
custom_tier_set?: CustomTierSet;
@ -62,7 +64,12 @@ export const activeTierRows = (value: ActiveTierSet): ActiveTierRow[] => {
const rows =
value.custom_tier_set?.tiers ??
tierOrderFor(value.enable_non_reasoning_tier).map((tier) => builtInRow(tier, value.tiers));
return rows.map((row) => ({ ...row, params: value.tier_model_params?.[row.id] ?? {} }));
return rows
.filter(
(row) =>
value.custom_tier_set || !isForecastClassifier(value.classifier_type ?? "heuristic") || row.models.length > 0,
)
.map((row) => ({ ...row, params: value.tier_model_params?.[row.id] ?? {} }));
};
// The wire shape of an edited tier set, shared by the payload builder and the prompt preview so the

View file

@ -23,6 +23,35 @@ const apply = (value: ComplexityRouterConfigValue, action: Parameters<typeof app
applyTierSetAction(value, rules, action);
describe("applyTierSetAction", () => {
it.each(["llm", "capability", "llm_v2"] as const)(
"clears an emptied plan floor and can repopulate its pool for %s",
(classifier_type) => {
const initial: ComplexityRouterConfigValue = {
...builtIn,
classifier_type,
plan_mode_min_tier: "SIMPLE",
tier_model_params: { SIMPLE: { "gpt-3.5-turbo": { speed: "fast" } } },
};
const { value: cleared } = apply(initial, { kind: "models", id: "SIMPLE", models: [] });
expect(cleared.plan_mode_min_tier).toBeUndefined();
expect(cleared.tier_model_params).toBeUndefined();
expect(cleared.tiers.SIMPLE).toEqual([]);
const { value: restored } = apply(cleared, { kind: "models", id: "SIMPLE", models: ["gpt-4"] });
expect(restored.tiers.SIMPLE).toEqual(["gpt-4"]);
expect(restored.plan_mode_min_tier).toBeUndefined();
expect(restored.tier_model_params).toBeUndefined();
},
);
it("preserves a populated custom plan floor after removing only one model", () => {
const initial: ComplexityRouterConfigValue = { ...custom, plan_mode_min_tier: "sec" };
const { value: changed } = apply(initial, { kind: "models", id: "sec", models: ["new-model"] });
expect(changed.plan_mode_min_tier).toBe("sec");
const { value: cleared } = apply(changed, { kind: "models", id: "sec", models: [] });
expect(cleared.plan_mode_min_tier).toBeUndefined();
expect(cleared.custom_tier_set?.tiers.find((row) => row.id === "sec")?.models).toEqual([]);
});
it("adds a row and moves the form into an edited set, which the built-in record never leaves", () => {
const { value } = apply(builtIn, { kind: "add" });
expect(value.custom_tier_set?.tiers).toHaveLength(5);

View file

@ -93,6 +93,30 @@ const exitToBuiltInTiers = (value: ComplexityRouterConfigValue, rows: readonly A
return commitTierRows(activeTierRows(restored), "", restored);
};
/** Model changes reconcile params and the plan floor against the resulting populated pools. */
export const setTierModels = (
value: ComplexityRouterConfigValue,
id: string,
models: string[],
): ComplexityRouterConfigValue => {
const next: ComplexityRouterConfigValue = {
...value,
...(value.custom_tier_set
? {
custom_tier_set: {
...value.custom_tier_set,
tiers: value.custom_tier_set.tiers.map((row) => (row.id === id ? { ...row, models } : row)),
},
}
: { tiers: { ...value.tiers, [id]: models } }),
tier_model_params: pruneTierModelParams(value.tier_model_params, id, models),
};
const floor = next.plan_mode_min_tier;
return floor && !activeTierRows(next).some((row) => row.id === floor && row.models.length > 0)
? { ...next, plan_mode_min_tier: undefined }
: next;
};
const nextTierSetValue = (
value: ComplexityRouterConfigValue,
rows: ActiveTierRow[],
@ -102,11 +126,7 @@ const nextTierSetValue = (
switch (action.kind) {
case "models":
return commitTierRows(
rows.map((row) => (row.id === action.id ? { ...row, models: action.models } : row)),
fallbackId,
{ ...value, tier_model_params: pruneTierModelParams(value.tier_model_params, action.id, action.models) },
);
return setTierModels(value, action.id, action.models);
case "patch":
return commitTierRows(
rows.map((row) => (row.id === action.id ? { ...row, ...action.patch } : row)),

View file

@ -675,7 +675,11 @@ describe("managed keys survive an untouched open-and-save", () => {
// The opt-in fifth tier requires the LLM classifier, which this heuristic_first fixture is not,
// so it gets its own round trip below.
const KEYS_ANOTHER_TIER_LADDER_OWNS = new Set(["enable_non_reasoning_tier"]);
const KEYS_ANOTHER_TIER_LADDER_OWNS = new Set([
"capability_classifier_config",
"llm_v2_config",
"enable_non_reasoning_tier",
]);
it("carries every managed key a built-in router can hold through hydrate then save", () => {
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
@ -850,7 +854,12 @@ describe("LLM V2 configuration preservation", () => {
harness: "Shell access, one attempt",
max_quality_gap: 0.03,
response_format: "json_object",
calibration: { version: "pair-v1", prompt_version: "llm-v2-1" },
calibration: {
version: "pair-v1",
prompt_version: "llm-v2-1",
efficient: { slope: 1.2, intercept: -0.3 },
capable: { slope: 0.9, intercept: 0.1 },
},
};
const stored = {
tiers: { SIMPLE: ["efficient"], REASONING: ["capable"] },

View file

@ -1,3 +1,11 @@
import AutoRouterClassifierTabs from "../add_model/AutoRouterClassifierTabs";
import type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
export type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
import {
getForecastConfigError,
capabilitySettingsSchema,
fuseSettingsSchema,
} from "../add_model/forecast_classifier_config";
import React, { useEffect, useMemo, useState } from "react";
import {
complexityRouterSchema,
@ -62,13 +70,7 @@ import {
hydrateTokenThresholds,
} from "../add_model/heuristic_scoring_knobs";
import ComplexityRouterConfig, {
AdaptiveEligible,
AdaptiveRouterWeights,
ClassifierLLMConfig,
ClassifierType,
effectiveClassifierType,
ComplexityRouterConfigValue,
ComplexityTiers,
heuristicScoringRole,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
@ -97,47 +99,6 @@ interface EditAutoRouterModalProps {
// Keys this modal rewrites from its own form state on save. Anything absent from this set is
// carried through untouched from the stored config, so a key only belongs here once the modal
// actually renders a control that can set it.
/** The complexity_router_config as it comes back from the proxy, before any hydration. Fields the
* hydrators validate themselves stay `unknown`; the ones assigned straight through carry their type. */
export interface StoredComplexityRouterConfig {
tiers?: Partial<Record<keyof ComplexityTiers, unknown>>;
enable_non_reasoning_tier?: boolean;
tier_model_configs?: unknown;
default_model?: string | null;
plan_mode_min_tier?: unknown;
classification_prompt?: unknown;
classification_examples?: unknown;
heuristic_first_max_tier?: unknown;
hybrid_boundary_margin?: unknown;
tier_labels?: unknown;
classifier_type?: ClassifierType;
classifier_llm_config?: ClassifierLLMConfig;
classifier_context_window_size?: unknown;
classifier_context_budget_chars?: unknown;
classifier_context_include_assistant_turns?: unknown;
classifier_fallback?: unknown;
classification_mode?: unknown;
tier_boundaries?: unknown;
token_thresholds?: unknown;
dimension_weights?: unknown;
custom_dimensions?: unknown;
reasoning_override_min_score?: unknown;
session_affinity?: unknown;
session_affinity_ttl_seconds?: unknown;
modality_routing?: unknown;
modality_pin_override?: unknown;
deployment_affinity?: unknown;
adaptive?: boolean;
adaptive_weights?: AdaptiveRouterWeights;
tier_distance_penalty?: number;
adaptive_eligible?: AdaptiveEligible;
return_raw_model_name?: boolean;
enable_context_window_escalation?: unknown;
context_window_escalation_buffer?: unknown;
stall_escalation_enabled?: unknown;
stall_escalation_window?: unknown;
stall_escalation_repeat_threshold?: unknown;
}
/**
* The stored complexity_router_config as form state. Every key in MANAGED_COMPLEXITY_ROUTER_KEYS is
@ -164,6 +125,8 @@ export const hydrateComplexityRouterConfig = (
plan_mode_min_tier: hydratePlanModeMinTier(parsedConfig.plan_mode_min_tier, custom_tier_set),
tier_labels: hydrateTierLabels(parsedConfig.tier_labels),
classifier_type: parsedConfig.classifier_type || "heuristic",
capability_classifier_config: capabilitySettingsSchema.safeParse(parsedConfig.capability_classifier_config).data,
llm_v2_config: fuseSettingsSchema.safeParse(parsedConfig.llm_v2_config).data,
classifier_llm_config: parsedConfig.classifier_llm_config,
classifier_context_window_size:
typeof parsedConfig.classifier_context_window_size === "number"
@ -251,6 +214,8 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"plan_mode_min_tier",
"tier_labels",
"classifier_type",
"capability_classifier_config",
"llm_v2_config",
"classifier_llm_config",
"classifier_context_window_size",
"classifier_context_budget_chars",
@ -339,7 +304,6 @@ export const buildUpdatedComplexityRouterConfig = (
keywordMatching?: KeywordMatchingState,
): Record<string, unknown> => {
const isManaged = (key: string): boolean => {
if (key === "llm_v2_config" && effectiveClassifierType(value) !== "llm_v2") return true;
if (MANAGED_COMPLEXITY_ROUTER_KEYS.has(key)) return true;
if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
return customTechnicalKeywords !== undefined && key === "custom_technical_keywords";
@ -362,6 +326,8 @@ export const buildUpdatedComplexityRouterConfig = (
classificationMode: value.classification_mode,
tierLabels: value.tier_labels,
classifierType: value.classifier_type,
capabilityClassifierConfig: value.capability_classifier_config,
llmV2Config: value.llm_v2_config,
classifierLlmConfig: value.classifier_llm_config,
classifierContextWindowSize: value.classifier_context_window_size,
classifierContextBudgetChars: value.classifier_context_budget_chars,
@ -458,6 +424,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ??
getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig)) ??
getClassifierModelError(complexityRouterConfig) ??
getForecastConfigError(complexityRouterConfig) ??
(heuristicScoringRole(complexityRouterConfig) === "decides"
? customDimensionsError(complexityRouterConfig.custom_dimensions)
: null);
@ -589,6 +556,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
}
const classifierError =
getClassifierModelError(complexityRouterConfig) ??
getForecastConfigError(complexityRouterConfig) ??
(heuristicScoringRole(complexityRouterConfig) === "decides"
? customDimensionsError(complexityRouterConfig.custom_dimensions)
: null);
@ -742,6 +710,14 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
{ value: "custom", label: "Enter custom model name" },
];
const routerNameField = (
<FormField control={form.control} name="auto_router_name" label="Auto Router Name">
{({ ref, ...field }) => (
<Input {...field} ref={ref} readOnly={isMemberManaged} placeholder="e.g., auto_router_1, smart_routing" />
)}
</FormField>
);
return (
<Dialog open={isVisible} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
@ -755,48 +731,41 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
<form onSubmit={(event) => event.preventDefault()} noValidate>
<FieldGroup>
<FormField control={form.control} name="auto_router_name" label="Auto Router Name">
{({ ref, ...field }) => (
<Input
{...field}
ref={ref}
readOnly={isMemberManaged}
placeholder="e.g., auto_router_1, smart_routing"
/>
)}
</FormField>
{routerNameField}
{isComplexityRouterModel ? (
/* Complexity Router Configuration */
<div className="w-full">
<ComplexityRouterConfig
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
showValidationErrors={showValidationErrors}
modelInfo={modelInfo}
value={complexityRouterConfig}
onChange={(config) => {
setComplexityRouterConfig(config);
}}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
keywordTierRules={keywordTierRules}
onKeywordTierRulesChange={setKeywordTierRules}
keywordRulesError={getKeywordTierRulesError(
keywordTierRules,
activeTierRows(complexityRouterConfig),
)}
semanticMatchingEnabled={semanticMatchingEnabled}
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
embeddingModel={embeddingModel}
onEmbeddingModelChange={setEmbeddingModel}
matchThreshold={matchThreshold}
onMatchThresholdChange={setMatchThreshold}
escalationKeywords={escalationKeywords}
onEscalationKeywordsChange={setEscalationKeywords}
autoRouterCompression={autoRouterCompression}
onAutoRouterCompressionChange={isMemberManaged ? undefined : setAutoRouterCompression}
/>
<AutoRouterClassifierTabs value={complexityRouterConfig} onChange={setComplexityRouterConfig}>
<ComplexityRouterConfig
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
showValidationErrors={showValidationErrors}
modelInfo={modelInfo}
value={complexityRouterConfig}
onChange={(config) => {
setComplexityRouterConfig(config);
}}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
keywordTierRules={keywordTierRules}
onKeywordTierRulesChange={setKeywordTierRules}
keywordRulesError={getKeywordTierRulesError(
keywordTierRules,
activeTierRows(complexityRouterConfig),
)}
semanticMatchingEnabled={semanticMatchingEnabled}
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
embeddingModel={embeddingModel}
onEmbeddingModelChange={setEmbeddingModel}
matchThreshold={matchThreshold}
onMatchThresholdChange={setMatchThreshold}
escalationKeywords={escalationKeywords}
onEscalationKeywordsChange={setEscalationKeywords}
autoRouterCompression={autoRouterCompression}
onAutoRouterCompressionChange={isMemberManaged ? undefined : setAutoRouterCompression}
/>
</AutoRouterClassifierTabs>
</div>
) : (
<>