feat(ui): simplify auto router setup with deployed model defaults (#48451)

* feat(ui): simplify auto router setup with deployed model defaults

* fix(ui): recognize catalog Opus deployment formats

* fix(ui): address auto router setup review gaps

* fix(ui): preserve Gemini auto router suggestions

* fix(ui): prefer current aliases and wait for team selection

* fix(ui): default auto routers to classify each user turn

* fix(ui): parse Grok snapshot dates separately from releases

* refactor(ui): use non-mutating auto router sorting

* feat(ui): allow custom models in simple auto router setup
This commit is contained in:
moe-berri 2026-10-26 15:40:03 -07:00 • committed by GitHub
parent 48a893aefc
commit 9a35ed93f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1413 additions and 682 deletions

View file

@ -121,6 +121,7 @@ export interface AutoRouterDeployment extends AutoRouterCandidateDeployment {
/** False for config.yaml-defined deployments, which the update and delete routes refuse. */
db_model?: boolean | null;
base_model?: string | null;
blocked?: boolean | null;
mode?: string | null;
created_at?: string | null;
updated_at?: string | null;

View file

@ -106,10 +106,7 @@ export function AutoRoutersPanel({
>
<DialogHeader>
<DialogTitle>Add Auto Router</DialogTitle>
<DialogDescription>
Choose a classifier to route each request to a model. Called like any other model, so clients keep using a
single model name.
</DialogDescription>
<DialogDescription>Choose models for your auto router.</DialogDescription>
</DialogHeader>
<AddAutoRouterTab
handleOk={handleCreated}

View file

@ -0,0 +1,125 @@
import React from "react";
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select";
import { type AutoRouterPreset } from "@/lib/autorouter_presets";
export type PresetAvailability =
| { kind: "available"; viaDeployments: boolean }
| { kind: "loading" }
| { kind: "unverifiable" }
| { kind: "missing_models"; models: readonly string[] };
// Every non-"available" state disables the option. Selection derives from this same function
// (see presetAvailability below), so an option a caller can click is always one that can be applied.
const presetDisabledHint = (availability: PresetAvailability): string | null => {
switch (availability.kind) {
case "available":
return null;
case "loading":
return "Checking model availability...";
case "unverifiable":
return "Cannot verify these models are available";
case "missing_models":
return `Missing: ${availability.models.join(", ")}`;
}
};
// "loading"/"unverifiable" are transient system states, not a gap specific to this preset; only a
// caller-specific missing-model reason gets the alarming red treatment.
const isPresetHintAlarming = (availability: PresetAvailability): boolean => availability.kind === "missing_models";
type Props = {
selectedPreset?: string;
presets: AutoRouterPreset[];
presetAvailability: (preset: AutoRouterPreset) => PresetAvailability;
onPresetChange: (key: string | undefined) => void;
pending: boolean;
unavailable: boolean;
onRetry: () => void;
};
export default function AutoRouterPresetSelect({
selectedPreset,
presets,
presetAvailability,
onPresetChange,
pending,
unavailable,
onRetry,
}: Props) {
const sortedPresetOptions = React.useMemo(
() =>
presets
.map((preset) => ({ preset, availability: presetAvailability(preset) }))
.sort((a, b) => Number(b.availability.kind === "available") - Number(a.availability.kind === "available")),
[presets, presetAvailability],
);
const templateItems = React.useMemo(
() => [
...sortedPresetOptions.map(({ preset }) => ({ value: preset.key, label: preset.label })),
{ value: "custom", label: "Start from scratch" },
],
[sortedPresetOptions],
);
return (
<div className="mb-4 w-full space-y-2">
<div className="space-y-2">
<Select
items={templateItems}
value={selectedPreset ?? null}
onValueChange={(presetKey: string | null) => onPresetChange(presetKey ?? undefined)}
>
<SelectTrigger aria-label="Use a template" data-testid="template-selector" className="w-full">
<SelectValue placeholder="Use a template" />
</SelectTrigger>
<SelectContent className="[&_[data-slot=select-item]>div]:min-w-0 [&_[data-slot=select-item]>div]:shrink [&_[data-slot=select-item]>div]:whitespace-normal [&_[data-slot=select-item]>div]:break-words">
{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>
<div className="text-xs text-muted-foreground">Replaces classifier and model choices</div>
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
</div>
</SelectItem>
);
})}
<SelectSeparator className="mx-0" />
<SelectItem value="custom" label="Start from scratch">
<div>
<div className="font-medium">Start from scratch</div>
<div className="text-xs text-muted-foreground">
Clears tier models, switches to Heuristics, and resets routing settings
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Replaces classifier and model choices</p>
{pending && <div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>}
{unavailable && (
<div className="text-xs mt-1 text-destructive">
Could not load templates, so only Start from scratch is shown.{" "}
<button type="button" className="underline" onClick={onRetry}>
Retry
</button>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,190 @@
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { ROUTER_FAMILIES, ROUTER_GOALS, type FamilyPreference, type RouterGoal, type SolverModel } from "./auto_setup";
type Props = {
customized: boolean;
hasSuggestions: boolean;
onReset: () => void;
goal: RouterGoal;
family: FamilyPreference;
models: readonly SolverModel[];
everyday?: SolverModel;
hard?: SolverModel;
waitingForTeam: boolean;
loading: boolean;
error: boolean;
notice: string | null;
onGoalChange: (goal: RouterGoal) => void;
onFamilyChange: (family: FamilyPreference) => void;
onModelChange: (role: "everyday" | "hard", model: string) => void;
onAdvanced: () => void;
onRetry: () => void;
};
const sharedFamily = (everyday?: SolverModel, hard?: SolverModel) =>
everyday?.family && everyday.family === hard?.family ? ROUTER_FAMILIES[everyday.family] : null;
const modelDescription = (model: SolverModel) =>
[model.model !== model.name ? model.model : null, model.provider].filter(Boolean).join(" · ");
export default function AutoRouterSimpleSetup({
customized,
hasSuggestions,
onReset,
goal,
family,
models,
everyday,
hard,
waitingForTeam,
loading,
error,
notice,
onGoalChange,
onFamilyChange,
onModelChange,
onAdvanced,
onRetry,
}: Props) {
const inventoryReady = !waitingForTeam && !loading;
const showPreferences = !inventoryReady || error || hasSuggestions;
const sameFamily = sharedFamily(everyday, hard);
const familyItems = [
{ value: "auto", label: sameFamily ? `Auto (${sameFamily})` : "Auto" },
...Object.entries(ROUTER_FAMILIES).map(([value, label]) => ({ value, label })),
];
return (
<div className="space-y-4">
{showPreferences && (
<>
<div className="space-y-2">
<Label>Optimize for</Label>
<div className="grid grid-cols-3 gap-2" role="group" aria-label="Routing goal">
{Object.entries(ROUTER_GOALS).map(([key, label]) => (
<Button
key={key}
type="button"
className="w-full"
variant={goal === key ? "default" : "outline"}
aria-pressed={goal === key}
onClick={() => onGoalChange(key as RouterGoal)}
>
{label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="auto-router-family">Model family</Label>
<Select
items={familyItems}
value={family}
onValueChange={(value) => {
if (value === "auto" || (value && value in ROUTER_FAMILIES)) onFamilyChange(value as FamilyPreference);
}}
>
<SelectTrigger id="auto-router-family" aria-label="Model family" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{familyItems.map(({ value, label }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{waitingForTeam && <p className="text-sm text-muted-foreground">Select a team to see available models.</p>}
{!waitingForTeam && loading && (
<p className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
<UiLoadingSpinner className="size-4" />
Loading deployed models
</p>
)}
{inventoryReady && error && (
<p role="alert" className="text-sm text-destructive">
Could not load deployed models.{" "}
<Button type="button" variant="link" onClick={onRetry}>
Retry
</Button>
</p>
)}
{inventoryReady && !error && (
<>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>Chosen models</Label>
{customized && hasSuggestions && (
<Button type="button" variant="link" className="h-auto p-0 text-xs" onClick={onReset}>
Use suggested models
</Button>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
{(["everyday", "hard"] as const).map((role) => {
const chosen = role === "everyday" ? everyday : hard;
const other = role === "everyday" ? hard : everyday;
const choices = models.filter((model) => model.name !== other?.name);
const items = choices.map((model) => ({ value: model.name, label: model.name }));
const label = role === "everyday" ? "Everyday" : "Hard tasks";
return (
<div className="min-w-0 space-y-3 rounded-lg border bg-muted/20 p-4" key={role}>
<div className="space-y-1">
<Label htmlFor={`auto-router-${role}`}>{label}</Label>
<p className="text-xs text-muted-foreground">
{role === "everyday" ? "Your cheaper model" : "Your stronger model"}
</p>
</div>
<Select
items={items}
value={chosen?.name ?? null}
onValueChange={(value) => {
if (value) onModelChange(role, value);
}}
>
<SelectTrigger id={`auto-router-${role}`} aria-label={`${label} model`} className="w-full">
<SelectValue placeholder="Select a deployed model" />
</SelectTrigger>
<SelectContent>
{choices.map((model) => (
<SelectItem key={model.name} value={model.name} label={model.name}>
<div>
<div>{model.name}</div>
<div className="text-xs text-muted-foreground">{modelDescription(model)}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{chosen && <p className="break-words text-xs text-muted-foreground">{modelDescription(chosen)}</p>}
</div>
);
})}
</div>
</div>
{(!everyday || !hard) && (
<p role="status" className="text-sm text-muted-foreground">
{models.length < 2
? "Two deployed models are needed. Add a model or use Advanced configuration."
: "Choose two models to get started."}
</p>
)}
{notice && !customized && (
<p role="status" className="text-xs text-muted-foreground">
{notice}
</p>
)}
</>
)}
<Button type="button" variant="link" className="px-0" onClick={onAdvanced}>
Advanced configuration
</Button>
</div>
);
}

View file

@ -13,7 +13,7 @@ import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select";
import AutoRouterPresetSelect, { type PresetAvailability } from "./AutoRouterPresetSelect";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
@ -79,7 +79,16 @@ import {
} from "@/lib/autorouter_presets";
import { useAutoRouterPresets } from "@/app/(dashboard)/hooks/autoRouter/useAutoRouterPresets";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { buildAutomaticRouterConfig, buildPreferredTierModels, prefillEmptyTiers } from "./auto_setup";
import AutoRouterSimpleSetup from "./AutoRouterSimpleSetup";
import {
deployedRouterModels,
nextRouterName,
simpleSetupError,
recommendSolverPair,
solverPairConfig,
type RouterGoal,
type FamilyPreference,
} from "./auto_setup";
interface AddAutoRouterTabProps {
handleOk: () => void;
@ -95,31 +104,6 @@ interface AddAutoRouterTabProps {
teams?: Team[] | null;
}
type PresetAvailability =
| { kind: "available"; viaDeployments: boolean }
| { kind: "loading" }
| { kind: "unverifiable" }
| { kind: "missing_models"; models: readonly string[] };
// Every non-"available" state disables the option. Selection derives from this same function
// (see presetAvailability below), so an option a caller can click is always one that can be applied.
const presetDisabledHint = (availability: PresetAvailability): string | null => {
switch (availability.kind) {
case "available":
return null;
case "loading":
return "Checking model availability...";
case "unverifiable":
return "Cannot verify these models are available";
case "missing_models":
return `Missing: ${availability.models.join(", ")}`;
}
};
// "loading"/"unverifiable" are transient system states, not a gap specific to this preset; only a
// caller-specific missing-model reason gets the alarming red treatment.
const isPresetHintAlarming = (availability: PresetAvailability): boolean => availability.kind === "missing_models";
const NO_PRESETS: AutoRouterPreset[] = [];
// Why the submit is unavailable, or null when it is available. The button reads this to disable
@ -168,7 +152,7 @@ const autoRouterSchema = (requiresTeamScope: boolean) =>
type AddAutoRouterFormValues = z.infer<ReturnType<typeof autoRouterSchema>>;
const EMPTY_FORM_VALUES: AddAutoRouterFormValues = {
auto_router_name: "",
auto_router_name: "auto-router",
team_id: null,
model_access_group: undefined,
};
@ -210,11 +194,18 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
});
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
const [complexityRouterConfig, setComplexityRouterConfig] = useState<ComplexityRouterConfigValue>({
const [advancedConfig, setComplexityRouterConfig] = useState<ComplexityRouterConfigValue>({
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
classification_mode: "user_turn",
});
const [advanced, setAdvanced] = useState(false);
const [prefillAttempted, setPrefillAttempted] = useState(false);
const [goal, setGoal] = useState<RouterGoal>("balanced");
const [family, setFamily] = useState<FamilyPreference>("auto");
const [solverOverrides, setSolverOverrides] = useState<{ everyday?: string; hard?: string }>({});
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState<string[]>([]);
const [keywordTierRules, setKeywordTierRules] = useState<KeywordTierRule[]>([]);
const [semanticMatchingEnabled, setSemanticMatchingEnabled] = useState<boolean>(false);
@ -250,17 +241,30 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
isError: modelsError,
refetch: refetchModels,
} = useQuery({
queryKey: ["availableModels", "autoRouter", accessToken, ...(isMemberManaged ? [watchedTeamId] : [])],
queryKey: ["availableModels", "autoRouter", accessToken, ...(requiresTeamScope ? [watchedTeamId] : [])],
queryFn: () =>
isMemberManaged ? fetchAutoRouterModels(accessToken, watchedTeamId) : fetchAvailableModels(accessToken),
enabled: Boolean(accessToken && (!isMemberManaged || watchedTeamId)),
requiresTeamScope ? fetchAutoRouterModels(accessToken, watchedTeamId) : fetchAvailableModels(accessToken),
enabled: Boolean(accessToken && (!requiresTeamScope || watchedTeamId)),
});
const { data: deployments, isLoading: deploymentsLoading } = useQuery({
const {
data: deployments,
isLoading: deploymentsLoading,
isError: deploymentsError,
refetch: refetchDeployments,
} = useQuery({
queryKey: autoRouterListKey(userId ?? "", userRole),
queryFn: () => fetchAllModelDeployments(accessToken, userId ?? "", userRole),
enabled: Boolean(accessToken),
});
const modelsLoading = groupsLoading || deploymentsLoading;
useEffect(() => {
if (!deployments || form.getFieldState("auto_router_name").isDirty) return;
form.setValue(
"auto_router_name",
nextRouterName(deployments.flatMap((row) => (row.model_name ? [row.model_name] : []))),
);
}, [deployments, form]);
const modelInfo = React.useMemo(() => data ?? [], [data]);
const {
data: presetsData,
@ -269,12 +273,12 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
refetch: refetchPresets,
} = useAutoRouterPresets();
const presets = presetsData ?? NO_PRESETS;
const automaticSetupLoading = modelsLoading || presetsPending;
const presetsUnavailable = presetsError && presetsData === undefined;
// react-query keeps the last successful list around when a later refetch fails, so isError alone
// can't tell "never loaded" apart from "loaded, then a background refetch errored" - only the
// former leaves us with nothing trustworthy to verify a preset's models against.
const modelsUnverifiable = modelsError && data === undefined;
const inventoryUnavailable = modelsUnverifiable || (deploymentsError && deployments === undefined);
const isAdmin = all_admin_roles.includes(userRole);
@ -294,14 +298,33 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
),
[modelInfo],
);
const preferredTierModels = React.useMemo(
() => buildPreferredTierModels(presets, availability),
[presets, availability],
const solverModels = React.useMemo(
() => deployedRouterModels(modelInfo, deployments ?? []),
[modelInfo, deployments],
);
const automaticRouterConfig = React.useMemo(
() => buildAutomaticRouterConfig(modelInfo, deployments ?? [], preferredTierModels),
[modelInfo, deployments, preferredTierModels],
const recommended = React.useMemo(
() => recommendSolverPair(solverModels, goal, family),
[solverModels, goal, family],
);
const everyday = solverOverrides.everyday
? solverModels.find((model) => model.name === solverOverrides.everyday)
: recommended?.everyday;
const hard = solverOverrides.hard
? solverModels.find((model) => model.name === solverOverrides.hard)
: recommended?.hard;
const simpleConfig = everyday && hard ? solverPairConfig(everyday.name, hard.name) : null;
const complexityRouterConfig = advanced
? advancedConfig
: simpleConfig ?? {
classifier_type: "heuristic" as const,
classification_mode: "user_turn" as const,
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
};
const openAdvanced = () => {
setComplexityRouterConfig(complexityRouterConfig);
setPrefillAttempted(simpleConfig !== null);
setAdvanced(true);
};
// A preset's models can only be trusted against a successfully loaded list. Selection and the
// greyed-out state derive from this one function, so a preset that cannot be selected can never
@ -322,22 +345,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
[modelsLoading, modelsUnverifiable, availability, groupsOnlyAvailability],
);
const sortedPresetOptions = React.useMemo(
() =>
presets
.map((preset) => ({ preset, availability: presetAvailability(preset) }))
.sort((a, b) => Number(b.availability.kind === "available") - Number(a.availability.kind === "available")),
[presets, presetAvailability],
);
const templateItems = React.useMemo(
() => [
...sortedPresetOptions.map(({ preset }) => ({ value: preset.key, label: preset.label })),
{ value: "custom", label: "Start from scratch" },
],
[sortedPresetOptions],
);
const applyPrefill = (prefill: PresetPrefill) => {
setEditingTiers(false);
setComplexityRouterConfig(prefill.complexityRouterConfig);
@ -349,16 +356,20 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
setEscalationKeywords(prefill.escalationKeywords);
};
const [prefillAttempted, setPrefillAttempted] = useState(false);
const automaticPrefill = automaticSetupLoading || modelsUnverifiable ? null : automaticRouterConfig;
if (automaticPrefill && !prefillAttempted) {
if (advanced && !prefillAttempted && !modelsLoading && simpleConfig) {
setPrefillAttempted(true);
setComplexityRouterConfig((current) => prefillEmptyTiers(current, automaticPrefill));
setComplexityRouterConfig((current) =>
!current.custom_tier_set &&
!isForecastClassifier(current.classifier_type) &&
activeTierRows(current).every((row) => row.models.length === 0)
? { ...current, tiers: simpleConfig.tiers }
: current,
);
}
const handlePresetChange = (presetKey: string | undefined) => {
setPrefillAttempted(true);
if (!presetKey || presetKey === "custom") {
setPrefillAttempted(true);
setSelectedPreset(presetKey);
applyPrefill(buildEmptyPrefill());
@ -372,7 +383,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const presetState = presetAvailability(preset);
if (presetState.kind !== "available") return;
setPrefillAttempted(true);
setSelectedPreset(presetKey);
applyPrefill(buildPresetPrefill(preset.complexity_router_config, availability));
};
@ -413,7 +423,11 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
},
!requiresTeamScope || Boolean(watchedTeamId),
);
const saveBlockedReason = submitBlockedReason ?? routerAvailability.saveBlockedReason;
const simpleBlockedReason = advanced ? null : simpleSetupError(modelsLoading, inventoryUnavailable, everyday, hard);
const saveBlockedReason =
!advanced && requiresTeamScope && !watchedTeamId
? "Select a team to see available models."
: simpleBlockedReason ?? submitBlockedReason ?? routerAvailability.saveBlockedReason;
const handleClassifierChange = (config: ComplexityRouterConfigValue) => {
setSelectedPreset(undefined);
setComplexityRouterConfig(config);
@ -540,71 +554,20 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
setIsTestModalVisible(true);
};
const tierSetup = (
<div className="mb-4 w-full space-y-2">
<div className="space-y-2">
<Select
items={templateItems}
value={selectedPreset ?? null}
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
>
<SelectTrigger aria-label="Use a template" data-testid="template-selector" className="w-full">
<SelectValue placeholder="Use a template" />
</SelectTrigger>
<SelectContent className="[&_[data-slot=select-item]>div]:min-w-0 [&_[data-slot=select-item]>div]:shrink [&_[data-slot=select-item]>div]:whitespace-normal [&_[data-slot=select-item]>div]:break-words">
{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>
<div className="text-xs text-muted-foreground">Replaces classifier and model choices</div>
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
</div>
</SelectItem>
);
})}
<SelectSeparator className="mx-0" />
<SelectItem value="custom" label="Start from scratch">
<div>
<div className="font-medium">Start from scratch</div>
<div className="text-xs text-muted-foreground">
Clears tier models, switches to Heuristics, and resets routing settings
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Replaces classifier and model choices</p>
{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 Start from scratch is shown.{" "}
<button type="button" className="underline" onClick={() => void refetchPresets()}>
Retry
</button>
</div>
)}
</div>
</div>
);
const configurationForm = (
<ComplexityRouterConfig
creationFlow
tierSetup={tierSetup}
tierSetup={
<AutoRouterPresetSelect
selectedPreset={selectedPreset}
presets={presets}
presetAvailability={presetAvailability}
onPresetChange={handlePresetChange}
pending={presetsPending}
unavailable={presetsUnavailable}
onRetry={() => void refetchPresets()}
/>
}
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
modelInfo={modelInfo}
@ -646,8 +609,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
)}
</FormField>
</div>
<div className="mb-6 border-b pb-6">
{isAdmin && (
<div className={advanced ? "mb-6 border-b pb-6" : ""}>
{isAdmin && advanced && (
<div>
<FormField
control={form.control}
@ -691,91 +654,138 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
</FormField>
)}
<AutoRouterClassifierTabs
value={complexityRouterConfig}
onChange={handleClassifierChange}
decisionModel={
<DecisionModelSelect
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
modelInfo={modelInfo}
deployments={deployments}
/>
}
judgeModel={
<ClassifierPrimarySettings
section="judge"
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
modelOptions={modelInfo
.filter((model) => model.mode !== "embedding")
.map((model) => ({ value: model.model_group, label: model.model_group }))}
showValidationErrors={showValidationErrors}
/>
}
>
<FieldGroup>
{modelsUnverifiable && (
<p className="text-sm text-destructive">
Could not load available models.{" "}
<button type="button" className="underline" onClick={() => void refetchModels()}>
Retry
</button>
</p>
)}
{configurationForm}
<div className="flex flex-wrap items-center justify-between gap-3">
<Tooltip>
<TooltipTrigger
render={
<a
href="https://github.com/BerriAI/litellm/issues"
className="text-sm text-primary underline-offset-4 hover:underline"
>
Need Help?
</a>
}
/>
<TooltipContent>Get help on our github</TooltipContent>
</Tooltip>
<div className="flex flex-wrap gap-2">
<BlockedReasonTooltip reason={submitBlockedReason}>
<Button
type="button"
variant="outline"
data-testid="auto-router-test-routing-btn"
disabled={submitBlockedReason !== null || isSubmitting}
onClick={() => setIsRoutingTestVisible(true)}
>
Test Routing
</Button>
</BlockedReasonTooltip>
<Button
type="button"
variant="outline"
data-testid="auto-router-test-connect-btn"
onClick={handleTestConnection}
disabled={isTestingConnection}
{!advanced && (
<AutoRouterSimpleSetup
customized={Boolean(solverOverrides.everyday || solverOverrides.hard)}
hasSuggestions={Boolean(recommended || recommendSolverPair(solverModels, goal, "auto"))}
onReset={() => setSolverOverrides({})}
goal={goal}
family={family}
models={solverModels}
everyday={everyday}
hard={hard}
waitingForTeam={requiresTeamScope && !watchedTeamId}
loading={modelsLoading}
error={inventoryUnavailable}
notice={
Object.keys(solverOverrides).length > 0
? "Your model choices are kept."
: recommended?.notice ?? null
}
onGoalChange={setGoal}
onFamilyChange={(next) => {
setFamily(next);
setSolverOverrides({});
}}
onModelChange={(role, model) =>
setSolverOverrides({ everyday: everyday?.name, hard: hard?.name, [role]: model })
}
onAdvanced={openAdvanced}
onRetry={() => {
void refetchModels();
void refetchDeployments();
}}
/>
)}
{advanced && (
<AutoRouterClassifierTabs
value={complexityRouterConfig}
onChange={handleClassifierChange}
decisionModel={
<DecisionModelSelect
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
modelInfo={modelInfo}
deployments={deployments}
/>
}
judgeModel={
<ClassifierPrimarySettings
section="judge"
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
modelOptions={modelInfo
.filter((model) => model.mode !== "embedding")
.map((model) => ({ value: model.model_group, label: model.model_group }))}
showValidationErrors={showValidationErrors}
/>
}
>
<FieldGroup>
{modelsUnverifiable && (
<p className="text-sm text-destructive">
Could not load available models.{" "}
<button type="button" className="underline" onClick={() => void refetchModels()}>
Retry
</button>
</p>
)}
{configurationForm}
</FieldGroup>
</AutoRouterClassifierTabs>
)}
{!advanced && routerAvailability.isError && (
<p className="mt-3 text-sm text-destructive" role="alert">
Could not check availability.{" "}
<Button type="button" variant="link" onClick={() => void routerAvailability.refetch()}>
Retry
</Button>
</p>
)}
{!advanced && routerAvailability.data?.error && (
<p className="mt-3 text-sm text-destructive" role="alert">
{routerAvailability.data.error}
</p>
)}
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<Tooltip>
<TooltipTrigger
render={
<a
href="https://github.com/BerriAI/litellm/issues"
className="text-sm text-primary underline-offset-4 hover:underline"
>
{isTestingConnection && <UiLoadingSpinner className="size-4" />}
Test Connection
</Button>
<BlockedReasonTooltip reason={saveBlockedReason}>
<Button
type="button"
disabled={saveBlockedReason !== null || isSubmitting}
onClick={() => {
void handleAutoRouterSubmit();
}}
>
Add Auto Router
</Button>
</BlockedReasonTooltip>
</div>
</div>
</FieldGroup>
</AutoRouterClassifierTabs>
Need Help?
</a>
}
/>
<TooltipContent>Get help on our github</TooltipContent>
</Tooltip>
<div className="flex flex-wrap gap-2">
<BlockedReasonTooltip reason={submitBlockedReason}>
<Button
type="button"
variant="outline"
data-testid="auto-router-test-routing-btn"
disabled={submitBlockedReason !== null || isSubmitting}
onClick={() => setIsRoutingTestVisible(true)}
>
Test Routing
</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={saveBlockedReason}>
<Button
type="button"
disabled={saveBlockedReason !== null || isSubmitting}
onClick={() => {
void handleAutoRouterSubmit();
}}
>
Add Auto Router
</Button>
</BlockedReasonTooltip>
</div>
</div>
</form>
</CardContent>
</Card>

View file

@ -1,274 +1,325 @@
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import { buildModelAvailability, deploymentRefsFromModelInfo } from "@/lib/autorouter_presets";
import {
buildAutomaticRouterConfig,
buildPreferredTierModels,
prefillEmptyTiers,
type PreferredTierModels,
deployedRouterModels,
recommendSolverPair,
solverPairConfig,
nextRouterName,
simpleSetupError,
} from "./auto_setup";
const models = (...names: string[]) => names.map((model_group) => ({ model_group, mode: "chat" }));
const reasoningModel = (model_group: string, supported_reasoning_efforts: string[]) => ({
model_group,
mode: "chat",
supports_reasoning: true,
supported_reasoning_efforts,
});
const deployment = (model_name: string, model = model_name): AutoRouterDeployment => ({
model_name,
const deployment = (name: string, model: string, extra: Partial<AutoRouterDeployment> = {}): AutoRouterDeployment => ({
model_name: name,
litellm_params: { model },
...extra,
});
const tierModels = (config: ReturnType<typeof buildAutomaticRouterConfig>) =>
config && Object.values(config.tiers).map((tier) => (typeof tier === "string" ? tier : tier[0]));
const resolve = (...rows: AutoRouterDeployment[]) =>
deployedRouterModels(
rows.map((row) => ({ model_group: row.model_name!, mode: "chat" })),
rows,
);
const names = (pair: ReturnType<typeof recommendSolverPair>) => pair && [pair.everyday.name, pair.hard.name];
const ladder = () =>
resolve(
deployment("small", "anthropic/claude-haiku-5-5"),
deployment("medium", "anthropic/claude-sonnet-5-5"),
deployment("large", "anthropic/claude-opus-5-5"),
);
describe("buildPreferredTierModels", () => {
it("recognizes curated models that are not in a preset", () => {
const available = ["gpt-5.6-luna", "claude-sonnet-5", "grok-4.6", "deepseek-v4-pro"];
const availability = buildModelAvailability(available, []);
const preferred = buildPreferredTierModels([], availability);
const expected: PreferredTierModels = {
SIMPLE: ["gpt-5.6-luna"],
MEDIUM: ["claude-sonnet-5"],
COMPLEX: ["deepseek-v4-pro", "grok-4.6"],
REASONING: ["deepseek-v4-pro", "grok-4.6"],
};
const catalog: Record<string, { mode?: string; litellm_provider?: string }> = JSON.parse(
readFileSync(new URL("../../../../../model_prices_and_context_window.json", import.meta.url), "utf8"),
);
const catalogOpusModels = Object.entries(catalog).filter(
([id, metadata]) => metadata.mode === "chat" && /claude-opus-\d/.test(id) && !/-(?:fast|think)$/.test(id),
);
expect(preferred).toEqual(expected);
});
it("prefers the current model ladder over older preset entries", () => {
const availability = buildModelAvailability(["gpt-5.6-luna", "gpt-4o-mini"], []);
const preferred = buildPreferredTierModels(
[
{
key: "old",
label: "Old",
description: "Old model",
complexity_router_config: {
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic_v2",
},
},
],
availability,
);
expect(preferred.SIMPLE).toEqual(["gpt-5.6-luna", "gpt-4o-mini"]);
});
});
describe("buildAutomaticRouterConfig", () => {
it("selects native Terra and Sol groups with their reasoning settings, retaining cloud fallback", () => {
const modelNames = ["gpt-5.6-terra", "gpt-5.6-sol"];
const deployments = modelNames.flatMap((model) => [
deployment(model, `azure/${model}`),
deployment(`z-native-${model}`, `openai/${model}`),
]);
const available = deployments.map(({ model_name }) => reasoningModel(model_name!, ["none", "high"]));
const availability = buildModelAvailability(
available.map(({ model_group }) => model_group),
deploymentRefsFromModelInfo(deployments),
);
const preferred = buildPreferredTierModels([], availability);
const config = buildAutomaticRouterConfig(available, deployments, preferred);
expect(tierModels(config)).toEqual([
"z-native-gpt-5.6-terra",
"z-native-gpt-5.6-terra",
"z-native-gpt-5.6-sol",
"z-native-gpt-5.6-sol",
]);
expect(config?.tier_model_params).toEqual({
REASONING: { "z-native-gpt-5.6-sol": { reasoning_effort: "high" } },
});
const cloudOnly = models(...modelNames);
const cloudAvailability = buildModelAvailability(modelNames, deploymentRefsFromModelInfo(deployments));
const cloudPreferred = buildPreferredTierModels([], cloudAvailability);
expect(tierModels(buildAutomaticRouterConfig(cloudOnly, deployments, cloudPreferred))).toEqual([
"gpt-5.6-terra",
"gpt-5.6-terra",
"gpt-5.6-sol",
"gpt-5.6-sol",
]);
});
it("selects one preferred model for each tier", () => {
const preferred: PreferredTierModels = {
SIMPLE: ["simple"],
MEDIUM: ["medium"],
COMPLEX: ["complex"],
REASONING: ["reasoning"],
};
expect(
tierModels(buildAutomaticRouterConfig(models("simple", "medium", "complex", "reasoning"), [], preferred)),
).toEqual(["simple", "medium", "complex", "reasoning"]);
});
it.each([
{
provider: "OpenAI",
available: ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-6-astra"],
expected: ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-6-astra", "gpt-6-astra"],
supportedEfforts: ["low", "medium", "high", "xhigh", "max"],
effort: "max",
},
{
provider: "Anthropic",
available: ["claude-haiku-4-5", "claude-sonnet-5", "claude-opus-5"],
expected: ["claude-haiku-4-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-5"],
supportedEfforts: ["low", "medium", "high", "max"],
effort: "max",
},
{
provider: "Google",
available: ["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-pro-preview"],
expected: ["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-pro-preview", "gemini-3.1-pro-preview"],
supportedEfforts: ["low", "medium", "high"],
effort: "high",
},
{
provider: "DeepSeek",
available: ["deepseek-v4-flash", "deepseek-v4-pro"],
expected: ["deepseek-v4-flash", "deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-pro"],
supportedEfforts: ["none", "high"],
effort: "high",
},
{
provider: "xAI",
available: ["grok-4.6"],
expected: ["grok-4.6", "grok-4.6", "grok-4.6", "grok-4.6"],
supportedEfforts: ["low", "medium", "high", "xhigh"],
effort: "xhigh",
},
])(
"uses the current $provider ladder and strongest advertised reasoning effort",
({ available, expected, supportedEfforts, effort }) => {
const availability = buildModelAvailability(available, []);
const preferred = buildPreferredTierModels([], availability);
const modelInfo = models(...available).map((model) =>
model.model_group === expected[3] ? reasoningModel(model.model_group, supportedEfforts) : model,
);
const config = buildAutomaticRouterConfig(modelInfo, [], preferred);
expect(tierModels(config)).toEqual(expected);
expect(config?.tier_model_params).toEqual({
REASONING: { [expected[3]]: { reasoning_effort: effort } },
});
describe("deployedRouterModels", () => {
it.each(catalogOpusModels)(
"recognizes the catalog's standard Opus deployment %s behind a custom alias",
(id, metadata) => {
const row = {
model_name: "custom-deployment",
litellm_params: { model: id, custom_llm_provider: metadata.litellm_provider },
};
const models = deployedRouterModels([{ model_group: "custom-deployment", mode: "chat" }], [row]);
expect(models).toEqual([expect.objectContaining({ name: "custom-deployment", family: "claude", line: "opus" })]);
},
);
it("never exceeds the selected model group's advertised reasoning efforts", () => {
const available = ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"];
const availability = buildModelAvailability(available, []);
const preferred = buildPreferredTierModels([], availability);
const modelInfo = [
...models("gpt-5.6-luna", "gpt-5.6-terra"),
reasoningModel("gpt-5.6-sol", ["none", "low", "medium", "high", "xhigh"]),
];
const config = buildAutomaticRouterConfig(modelInfo, [], preferred);
expect(config?.tier_model_params).toEqual({
REASONING: { "gpt-5.6-sol": { reasoning_effort: "xhigh" } },
});
it("resolves underlying identities independently of deployment aliases and serving providers", () => {
const models = resolve(
deployment("everyday", "bedrock/us.openai.gpt-6.1-sol"),
deployment("hard", "bedrock_converse/global.openai.gpt-6-astra"),
);
const expectedEveryday = { name: "everyday", family: "openai", line: "sol", version: [6, 1], provider: "Bedrock" };
const expectedHard = { name: "hard", family: "openai", line: "astra", version: [6], provider: "Bedrock" };
expect(models).toEqual(
expect.arrayContaining([expect.objectContaining(expectedEveryday), expect.objectContaining(expectedHard)]),
);
expect(names(recommendSolverPair(models, "quality", "openai"))).toEqual(["everyday", "hard"]);
});
it("leaves reasoning effort unset when the proxy does not report supported values", () => {
const available = ["grok-4.6"];
const availability = buildModelAvailability(available, []);
const preferred = buildPreferredTierModels([], availability);
const config = buildAutomaticRouterConfig(models(...available), [], preferred);
expect(config?.tier_model_params).toBeUndefined();
});
it("reuses the closest available tier when a tier has no match", () => {
const preferred: PreferredTierModels = {
SIMPLE: ["simple"],
MEDIUM: [],
COMPLEX: ["complex"],
REASONING: [],
};
expect(tierModels(buildAutomaticRouterConfig(models("simple", "complex"), [], preferred))).toEqual([
"simple",
"simple",
"complex",
"complex",
it("recognizes provider-specific Claude names and a declared base model for opaque deployments", () => {
const models = resolve(
deployment("small", "vertex_ai/claude-haiku-4-5@20251001"),
deployment("large", "bedrock/arn:example:inference-profile/us.anthropic.claude-opus-5-5-v1:0"),
deployment("middle", "azure/private-alias", {
litellm_params: { model: "azure/private-alias" },
model_info: { base_model: "gpt-6.1-sol" },
}),
);
expect(models.map(({ name, family, provider }) => ({ name, family, provider }))).toEqual([
{ name: "large", family: "claude", provider: "Bedrock" },
{ name: "middle", family: "openai", provider: "Azure" },
{ name: "small", family: "claude", provider: "Vertex AI" },
]);
});
it("returns null when none of the available models are recommended", () => {
const preferred: PreferredTierModels = {
SIMPLE: ["missing-simple"],
MEDIUM: ["missing-medium"],
COMPLEX: ["missing-complex"],
REASONING: ["missing-reasoning"],
};
expect(buildAutomaticRouterConfig(models("unknown-model"), [], preferred)).toBeNull();
it("uses only caller-visible chat models and excludes blocked and recursive targets", () => {
const rows = [
deployment("hidden", "anthropic/claude-opus-6"),
deployment("blocked", "anthropic/claude-opus-5", { model_info: { blocked: true } }),
deployment("embedding", "anthropic/claude-opus-5"),
deployment("recursive", "auto_router/complexity_router"),
deployment("ready", "anthropic/claude-sonnet-5"),
];
expect(
deployedRouterModels(
[
{ model_group: "blocked", mode: "chat" },
{ model_group: "embedding", mode: "embedding" },
{ model_group: "recursive" },
{ model_group: "ready", mode: "chat" },
],
rows,
).map((model) => model.name),
).toEqual(["ready"]);
});
it("ignores non-chat models and existing auto routers", () => {
const preferred: PreferredTierModels = {
SIMPLE: ["gpt-4o-mini", "smart-router"],
MEDIUM: [],
COMPLEX: [],
REASONING: [],
};
const available = [
{ model_group: "gpt-4o-mini", mode: "chat" },
{ model_group: "image-model", mode: "image_generation" },
{ model_group: "smart-router", mode: "chat" },
it("does not claim a mixed-version or unknown model group is a known version", () => {
const rows = [
deployment("mixed", "anthropic/claude-opus-5"),
deployment("mixed", "anthropic/claude-opus-5-5"),
deployment("claude-sonnet-5", "openai/custom-model"),
deployment("wildcard", "openai/*"),
];
const models = deployedRouterModels(
[
{ model_group: "mixed", mode: "chat" },
{ model_group: "claude-sonnet-5", mode: "chat" },
{ model_group: "wildcard", mode: "chat" },
],
rows,
);
expect(models.map((model) => model.name)).toEqual(["claude-sonnet-5", "mixed"]);
expect(models.every((model) => model.family === undefined)).toBe(true);
expect(recommendSolverPair(models, "balanced", "auto")).toBeNull();
});
it("keeps homogeneous replicas and ignores blocked replicas when resolving identity", () => {
const rows = [
deployment("group", "anthropic/claude-opus-5-5"),
deployment("group", "bedrock/anthropic.claude-opus-5-5-v1:0"),
deployment("group", "anthropic/claude-opus-5", { model_info: { blocked: true } }),
];
const models = deployedRouterModels([{ model_group: "group" }], rows);
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({ name: "group", version: [5, 5], provider: "Anthropic, Bedrock" });
});
it("keeps arbitrary canonical names available without pretending an unversioned alias is latest", () => {
expect(
tierModels(
buildAutomaticRouterConfig(available, [deployment("smart-router", "auto_router/complexity_router")], preferred),
deployedRouterModels([{ model_group: "claude-opus-8-12" }, { model_group: "claude-opus-latest" }], []).map(
(model) => model.name,
),
).toEqual(["gpt-4o-mini", "gpt-4o-mini", "gpt-4o-mini", "gpt-4o-mini"]);
).toEqual(["claude-opus-8-12", "claude-opus-latest"]);
});
});
describe("prefillEmptyTiers", () => {
const empty: ComplexityRouterConfigValue = {
classifier_type: "jev",
jev_classifier_config: { model: "jev-custom", timeout_ms: 4200 },
classification_mode: "user_turn",
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
};
const automatic: ComplexityRouterConfigValue = {
classifier_type: "heuristic_v2",
tiers: { SIMPLE: ["fast"], MEDIUM: ["mid"], COMPLEX: ["strong"], REASONING: ["strong"] },
tier_model_params: { REASONING: { strong: { reasoning_effort: "high" } } },
};
it("fills tiers while preserving a classifier configured before models load", () => {
expect(prefillEmptyTiers(empty, automatic)).toEqual({
...empty,
tiers: automatic.tiers,
tier_model_params: automatic.tier_model_params,
describe("recommendSolverPair", () => {
it.each([
["cheapest", ["small", "medium"]],
["balanced", ["small", "large"]],
["quality", ["medium", "large"]],
] as const)("uses the requested %s pair", (goal, expected) => {
expect(names(recommendSolverPair(ladder(), goal, "claude"))).toEqual(expected);
});
it("selects future deployed releases numerically within each line, without a version allowlist", () => {
const models = resolve(
deployment("old-small", "anthropic/claude-haiku-8-9"),
deployment("new-small", "bedrock/anthropic.claude-haiku-8-10-v1:0"),
deployment("old-large", "anthropic/claude-opus-8-9"),
deployment("new-large", "anthropic/claude-opus-9"),
);
expect(names(recommendSolverPair(models, "balanced", "claude"))).toEqual(["new-small", "new-large"]);
});
it("orders snapshots within a version without confusing a snapshot with the next version", () => {
const models = resolve(
deployment("old", "anthropic/claude-haiku-4-5-20251001"),
deployment("snapshot", "anthropic/claude-haiku-4-5-20251101"),
deployment("next", "anthropic/claude-haiku-4-6"),
deployment("large", "anthropic/claude-opus-5"),
);
expect(names(recommendSolverPair(models, "balanced", "claude"))).toEqual(["next", "large"]);
expect(
names(
recommendSolverPair(
models.filter((model) => model.name !== "next"),
"balanced",
"claude",
),
),
).toEqual(["snapshot", "large"]);
});
it("prefers rolling aliases over snapshots of the same release", () => {
const models = resolve(
deployment("pin", "anthropic/claude-haiku-4-5-20251101"),
deployment("alias", "anthropic/claude-haiku-4-5"),
deployment("hard", "anthropic/claude-opus-5"),
deployment("luna-pin", "openai/gpt-6-luna-2026-01-01"),
deployment("luna", "openai/gpt-6-luna"),
deployment("astra", "openai/gpt-6-astra"),
);
expect(names(recommendSolverPair(models, "balanced", "claude"))).toEqual(["alias", "hard"]);
expect(names(recommendSolverPair(models, "balanced", "openai"))).toEqual(["luna", "astra"]);
});
it.each([
{ models: ["grok-4.20", "grok-4.20-0309"], expected: "grok-4.20" },
{ models: ["grok-4.20-0309", "grok-4.20-0410"], expected: "grok-4.20-0410" },
{ models: ["grok-4.20-0309", "grok-4.21"], expected: "grok-4.21" },
])("orders Grok releases and short-date snapshots: $expected", ({ models, expected }) => {
const available = resolve(
deployment("everyday", "openai/gpt-6-luna"),
...models.map((model) => deployment(model, `xai/${model}`)),
);
expect(names(recommendSolverPair(available, "balanced", "grok"))).toEqual(["everyday", expected]);
});
it("does not rank a major-release snapshot above a newer minor release", () => {
const models = resolve(
deployment("old", "anthropic/claude-5-haiku-20260101"),
deployment("next", "anthropic/claude-haiku-5-1"),
deployment("hard", "anthropic/claude-opus-5"),
);
expect(names(recommendSolverPair(models, "balanced", "claude"))).toEqual(["next", "hard"]);
});
it("prefers a same-family substitute over a matching rank from another family", () => {
const models = [
...ladder().filter((model) => model.line !== "haiku"),
...resolve(deployment("luna", "openai/gpt-6-luna")),
];
const pair = recommendSolverPair(models, "balanced", "claude");
expect(names(pair)).toEqual(["medium", "large"]);
expect(pair?.notice).toContain("closest available pair");
});
it("prefers a complete family for Auto and explains a cross-family substitute when explicitly requested", () => {
const models = [
...ladder().filter((model) => model.line === "sonnet"),
...resolve(
deployment("luna", "bedrock/us.openai.gpt-6-luna"),
deployment("astra", "bedrock/us.openai.gpt-6-astra"),
),
];
expect(names(recommendSolverPair(models, "balanced", "auto"))).toEqual(["luna", "astra"]);
const selected = recommendSolverPair(models, "quality", "claude");
expect(names(selected)).toEqual(["medium", "astra"]);
expect(selected?.notice).toContain("two model families");
});
it.each([
["glm", "zai/glm-7.3-flash", "zai/glm-7.3"],
["deepseek", "deepseek/deepseek-v9-flash", "deepseek/deepseek-v9-pro"],
] as const)("handles the %s two-line family for every goal", (family, small, large) => {
const models = resolve(deployment("small", small), deployment("large", large));
for (const goal of ["cheapest", "balanced", "quality"] as const)
expect(names(recommendSolverPair(models, goal, family))).toEqual(["small", "large"]);
});
it("does not fabricate a second solver or substitute a different family when none of the requested family exists", () => {
expect(recommendSolverPair(ladder().slice(0, 1), "balanced", "auto")).toBeNull();
expect(recommendSolverPair(ladder(), "balanced", "openai")).toBeNull();
expect(recommendSolverPair([], "balanced", "auto")).toBeNull();
});
it("retains Gemini family suggestions and recognizes the existing Grok fallback", () => {
const models = resolve(
deployment("lite", "gemini/gemini-3.5-flash-lite"),
deployment("flash-old", "vertex_ai/gemini-3.5-flash"),
deployment("flash", "vertex_ai/gemini-3.8-flash"),
deployment("pro", "gemini/gemini-3.1-pro-preview"),
deployment("grok", "xai/grok-4.6"),
);
expect(names(recommendSolverPair(models, "cheapest", "gemini"))).toEqual(["lite", "flash"]);
expect(names(recommendSolverPair(models, "balanced", "gemini"))).toEqual(["lite", "pro"]);
expect(names(recommendSolverPair(models, "quality", "gemini"))).toEqual(["flash", "pro"]);
expect(models.find((m) => m.name === "grok")).toMatchObject({ family: "grok", provider: "xAI" });
expect(names(recommendSolverPair(models, "balanced", "grok"))).toEqual(["lite", "grok"]);
});
it("uses the latest deployed Terra as a fallback without replacing the preferred OpenAI pairs", () => {
const models = resolve(
deployment("terra-old", "openai/gpt-5.6-terra"),
deployment("terra", "openai/gpt-6.1-terra"),
deployment("sol", "openai/gpt-6.1-sol"),
);
expect(names(recommendSolverPair(models, "balanced", "openai"))).toEqual(["terra", "sol"]);
const complete = [
...models,
...resolve(deployment("luna", "openai/gpt-6-luna"), deployment("astra", "openai/gpt-6-astra")),
];
expect(names(recommendSolverPair(complete, "cheapest", "openai"))).toEqual(["luna", "sol"]);
expect(names(recommendSolverPair(complete, "balanced", "openai"))).toEqual(["luna", "astra"]);
expect(names(recommendSolverPair(complete, "quality", "openai"))).toEqual(["sol", "astra"]);
});
it("suggests a deterministic equal-rank cross-family fallback only when no ordered pair exists", () => {
const models = resolve(deployment("opus", "anthropic/claude-opus-5-5"), deployment("astra", "openai/gpt-6-astra"));
const pair = recommendSolverPair(models, "balanced", "auto");
expect(names(pair)).toEqual(["astra", "opus"]);
expect(pair?.notice).toContain("two model families");
expect(names(recommendSolverPair([...models].reverse(), "balanced", "auto"))).toEqual(names(pair));
const withLuna = [...models, ...resolve(deployment("luna", "openai/gpt-6-luna"))];
expect(names(recommendSolverPair(withLuna, "balanced", "auto"))).toEqual(["luna", "astra"]);
});
it("does not prefer native-provider deployments over a newer cloud deployment", () => {
const models = resolve(
deployment("native", "openai/gpt-6-luna"),
deployment("cloud", "bedrock/global.openai.gpt-6.2-luna"),
deployment("strong", "bedrock/global.openai.gpt-6-astra"),
);
expect(names(recommendSolverPair(models, "balanced", "openai"))).toEqual(["cloud", "strong"]);
});
});
describe("solverPairConfig", () => {
it("builds two solver references using built-in rules without paid classifiers, custom tiers or output overrides", () => {
expect(solverPairConfig("everyday", "hard")).toEqual({
classifier_type: "heuristic",
classification_mode: "user_turn",
tiers: { SIMPLE: ["everyday"], MEDIUM: ["everyday"], COMPLEX: ["hard"], REASONING: ["hard"] },
});
});
it("leaves the draft alone when no recommended models are available", () => {
expect(prefillEmptyTiers(empty, null)).toBe(empty);
});
describe("simple setup validation", () => {
it("avoids existing names without requiring a naming decision", () => {
expect(nextRouterName([])).toBe("auto-router");
expect(nextRouterName(["auto-router", "auto-router-2", "auto-router-4"])).toBe("auto-router-3");
});
it("preserves a partial manual assignment", () => {
const partial = { ...empty, tiers: { ...empty.tiers, SIMPLE: ["manual"] } };
expect(prefillEmptyTiers(partial, automatic)).toBe(partial);
});
it("preserves a custom tier set created before models load", () => {
const custom = { ...empty, custom_tier_set: { tiers: [], fallback_tier_id: "" } };
expect(prefillEmptyTiers(custom, automatic)).toBe(custom);
});
it.each(["capability", "llm_v2"] as const)("does not prefill %s solver pools", (classifier_type) => {
const solver = { ...empty, classifier_type };
expect(prefillEmptyTiers(solver, automatic)).toBe(solver);
it("blocks stale or incomplete inventory and same-line selections", () => {
const [small, large] = [ladder()[0], ladder()[2]];
expect(simpleSetupError(true, false, small, large)).not.toBeNull();
expect(simpleSetupError(false, true, small, large)).not.toBeNull();
expect(simpleSetupError(false, false, small)).not.toBeNull();
expect(simpleSetupError(false, false, small, small)).not.toBeNull();
expect(simpleSetupError(false, false, small, large)).toBeNull();
});
});

View file

@ -1,110 +1,263 @@
import { activeTierRows } from "./tier_rows";
import { isForecastClassifier } from "./forecast_classifier_config";
import { isAutoRouterDeployment, type AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import { isAutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
import { resolveAvailableModel, type AutoRouterPreset, type ModelAvailability } from "@/lib/autorouter_presets";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
const TIER_NAMES = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"] as const;
type TierName = (typeof TIER_NAMES)[number];
export type PreferredTierModels = Record<TierName, string[]>;
export const ROUTER_FAMILIES = {
claude: "Claude",
openai: "OpenAI",
gemini: "Gemini",
grok: "Grok",
glm: "GLM",
deepseek: "DeepSeek",
} as const;
export const ROUTER_GOALS = { cheapest: "Cheapest", balanced: "Balanced", quality: "Best quality" } as const;
export type RouterFamily = keyof typeof ROUTER_FAMILIES;
export type RouterGoal = keyof typeof ROUTER_GOALS;
export type FamilyPreference = RouterFamily | "auto";
const REASONING_EFFORT_STRENGTH = ["max", "xhigh", "high", "medium", "low", "minimal", "none"] as const;
type ModelIdentity = {
family: RouterFamily;
line: string;
rank: number;
version: readonly number[];
snapshot: number;
model: string;
};
export type SolverModel = Partial<ModelIdentity> & { name: string; provider: string; model: string };
export type RouterModel = ModelIdentity & SolverModel;
export type SolverPair = { everyday: RouterModel; hard: RouterModel; notice: string | null };
const CURRENT_TIER_MODELS: PreferredTierModels = {
SIMPLE: ["gpt-5.6-luna", "claude-haiku-4-5", "gemini-3.5-flash-lite", "deepseek-v4-flash"],
MEDIUM: ["gpt-5.6-terra", "claude-sonnet-5", "gemini-3.8-flash", "deepseek-v4-flash"],
COMPLEX: ["gpt-6-astra", "gpt-5.6-sol", "claude-opus-5", "gemini-3.1-pro-preview", "deepseek-v4-pro", "grok-4.6"],
REASONING: ["gpt-6-astra", "gpt-5.6-sol", "claude-opus-5", "gemini-3.1-pro-preview", "deepseek-v4-pro", "grok-4.6"],
const releaseVersion = (raw: string, date?: string) => {
const snapshot = raw.match(/[-.](\d{8})$/);
const release = snapshot ? raw.slice(0, -9) : raw;
return {
version: release.split(/[.-]/).map(Number),
// An undated alias follows the current snapshot of its release.
snapshot: date || snapshot ? Number((date ?? snapshot![1]).replaceAll("-", "")) : Number.MAX_SAFE_INTEGER,
};
};
export const buildPreferredTierModels = (
presets: AutoRouterPreset[],
availability: ModelAvailability,
): PreferredTierModels =>
Object.fromEntries(
TIER_NAMES.map((tier) => [
tier,
Array.from(
new Set(
[
...CURRENT_TIER_MODELS[tier],
...presets.flatMap((preset) => preset.complexity_router_config.tiers[tier]),
].flatMap((model) => {
const resolved = resolveAvailableModel(model, availability);
return resolved ? [resolved] : [];
}),
),
const parseModel = (raw: string): ModelIdentity | null => {
const model = raw
.toLowerCase()
.split("/")
.at(-1)!
.replace(/^(?:us|eu|apac|global|au|jp|us-gov)\./, "")
.replace(/^(?:anthropic|openai|zai|deepseek)\./, "")
.replace(/^databricks-/, "")
.replace(/@default$/, "")
.replace(/-v\d+(?::\d+)?$/, "");
const versionFirst = model.match(/^claude-(\d+(?:[.-]\d+)*)-(haiku|sonnet|opus)(?:[-@]([0-9]{8}))?$/);
const claude =
model.match(/^claude-(haiku|sonnet|opus)-(\d+(?:[.-]\d+)*)(?:@([0-9]+))?$/) ??
(versionFirst ? [versionFirst[0], versionFirst[2], versionFirst[1], versionFirst[3]] : null);
if (claude)
return {
family: "claude",
line: claude[1],
rank: ["haiku", "sonnet", "opus"].indexOf(claude[1]),
...releaseVersion(claude[2], claude[3]),
model,
};
const openai = model.match(/^gpt-(\d+(?:[.-]\d+)*)-(luna|terra|sol|astra)(?:-(\d{4}-\d{2}-\d{2}))?$/);
if (openai)
return {
family: "openai",
line: openai[2],
rank: openai[2] === "terra" ? 0.5 : ["luna", "sol", "astra"].indexOf(openai[2]),
...releaseVersion(openai[1], openai[3]),
model,
};
const gemini = model.match(/^gemini-(\d+(?:[.-]\d+)*)-(flash-lite|flash|pro)(?:-preview)?$/);
if (gemini)
return {
family: "gemini",
line: gemini[2],
rank: ["flash-lite", "flash", "pro"].indexOf(gemini[2]),
...releaseVersion(gemini[1]),
model,
};
const grok = model.match(/^grok-(\d+(?:[.-]\d+)*?)(?:-(\d{4}))?$/);
if (grok) return { family: "grok", line: "grok", rank: 2, ...releaseVersion(grok[1], grok[2]), model };
const glm = model.match(/^glm-(\d+(?:[.-]\d+)*)(-flash)?$/);
if (glm)
return {
family: "glm",
line: glm[2] ? "flash" : "glm",
rank: glm[2] ? 0 : 2,
...releaseVersion(glm[1]),
model,
};
const deepseek = model.match(/^deepseek-v(\d+(?:[.-]\d+)*)-(flash|pro)$/);
if (deepseek)
return {
family: "deepseek",
line: deepseek[2],
rank: deepseek[2] === "flash" ? 0 : 2,
...releaseVersion(deepseek[1]),
model,
};
return null;
};
const identityOf = (deployment: AutoRouterDeployment): ModelIdentity | null =>
parseModel(deployment.litellm_params?.model ?? "") ??
parseModel(deployment.model_info?.base_model ?? deployment.litellm_params?.base_model ?? "");
const providerOf = (deployment: AutoRouterDeployment, group: ModelGroup, family?: RouterFamily): string => {
const raw = deployment.litellm_params?.model ?? "";
const provider =
deployment.litellm_params?.custom_llm_provider ||
(raw.includes("/") ? raw.split("/")[0] : group.providers?.join(", ")) ||
(family
? { claude: "anthropic", glm: "zai", openai: "openai", gemini: "gemini", grok: "xai", deepseek: "deepseek" }[
family
]
: "");
if (provider.startsWith("bedrock")) return "Bedrock";
return (
(
{
anthropic: "Anthropic",
openai: "OpenAI",
gemini: "Google AI",
xai: "xAI",
azure: "Azure",
azure_ai: "Azure AI",
databricks: "Databricks",
github_copilot: "GitHub Copilot",
"vertex_ai-anthropic_models": "Vertex AI",
vertex_ai: "Vertex AI",
zai: "Z.ai",
deepseek: "DeepSeek",
} as Record<string, string>
)[provider] ?? provider
);
};
const compareVersion = (left: readonly number[], right: readonly number[]): number => {
const index = Array.from({ length: Math.max(left.length, right.length) }, (_, i) => i).find(
(i) => (left[i] ?? 0) !== (right[i] ?? 0),
);
return index === undefined ? 0 : (left[index] ?? 0) - (right[index] ?? 0);
};
const compareRelease = (left: ModelIdentity, right: ModelIdentity): number =>
compareVersion(left.version, right.version) || left.snapshot - right.snapshot;
export const deployedRouterModels = (
groups: readonly ModelGroup[],
deployments: readonly AutoRouterDeployment[],
): SolverModel[] =>
groups
.flatMap((group) => {
if (group.mode && group.mode !== "chat") return [];
const rows = deployments.filter((row) => row.model_name === group.model_group);
if (
rows.some(isAutoRouterDeployment) ||
group.model_group.includes("*") ||
group.model_group.startsWith("auto_router/")
)
return [];
const active = rows.filter((row) => !row.model_info?.blocked);
if (rows.length > 0 && active.length === 0) return [];
if (active.some((row) => row.model_info?.mode && row.model_info.mode !== "chat")) return [];
if (active.some((row) => row.litellm_params?.model?.includes("*"))) return [];
const identities = active.length > 0 ? active.map(identityOf) : [parseModel(group.model_group)];
const first = identities[0];
const homogeneous =
first &&
identities.every((identity) => {
if (!identity) return false;
const sameLine = identity.family === first.family && identity.line === first.line;
return sameLine && compareRelease(identity, first) === 0;
});
const identity = homogeneous ? first : undefined;
const providers =
active.length > 0
? active.map((row) => providerOf(row, group, identity?.family))
: [providerOf({}, group, identity?.family)];
return [
{
...identity,
name: group.model_group,
model: identity?.model ?? group.model_group,
provider: [...new Set(providers)].toSorted().join(", "),
},
];
})
.toSorted((a, b) => a.name.localeCompare(b.name));
const latestLines = (models: readonly RouterModel[]): RouterModel[] =>
models.filter(
(model) =>
!models.some(
(other) => other.family === model.family && other.line === model.line && compareRelease(other, model) > 0,
),
]),
) as PreferredTierModels;
const selectPreferredTierModels = (
preferredByTier: PreferredTierModels,
usableNames: ReadonlySet<string>,
): [string, string, string, string] | null => {
const preferred = TIER_NAMES.map((tier) => preferredByTier[tier].find((name) => usableNames.has(name)));
const candidates = preferred.flatMap((model, tier) => (model ? [{ model, tier }] : []));
if (candidates.length === 0) return null;
const nearest = (tier: number): string =>
[...candidates].sort(
(left, right) => Math.abs(left.tier - tier) - Math.abs(right.tier - tier) || left.tier - right.tier,
)[0].model;
return preferred.map((model, tier) => model ?? nearest(tier)) as [string, string, string, string];
};
export const buildAutomaticRouterConfig = (
models: ModelGroup[],
deployments: AutoRouterDeployment[],
preferredByTier: PreferredTierModels,
): ComplexityRouterConfigValue | null => {
const autoRouterNames: ReadonlySet<string> = new Set(
deployments
.filter(isAutoRouterDeployment)
.flatMap((deployment) => (deployment.model_name ? [deployment.model_name] : [])),
);
const names = Array.from(
new Set(
models
.filter((model) => model.mode === undefined || model.mode === "chat")
.map((model) => model.model_group)
.filter((name) => name && !name.startsWith("auto_router/") && !autoRouterNames.has(name)),
),
const requestedRanks = (goal: RouterGoal): readonly [number, number] =>
(({ cheapest: [0, 1], quality: [1, 2], balanced: [0, 2] }) as const)[goal];
export const recommendSolverPair = (
models: readonly SolverModel[],
goal: RouterGoal,
family: FamilyPreference,
): SolverPair | null => {
const recognized = models.filter((model): model is RouterModel => model.family !== undefined);
const latest = latestLines(recognized);
const [low, high] = requestedRanks(goal);
const pairs = latest.flatMap((everyday) =>
latest
.filter((hard) => {
const matchesFamily = family === "auto" || everyday.family === family || hard.family === family;
const ordered = hard.rank > everyday.rank;
const equalRankFallback = hard.rank === everyday.rank && hard.family !== everyday.family;
return (ordered || equalRankFallback) && matchesFamily;
})
.map((hard) => ({ everyday, hard })),
);
if (names.length === 0) return null;
const usableNames: ReadonlySet<string> = new Set(names);
const selected = selectPreferredTierModels(preferredByTier, usableNames);
if (selected === null) return null;
const supportedReasoningEfforts = models.find(
(model) => model.model_group === selected[3],
)?.supported_reasoning_efforts;
const reasoningEffort = REASONING_EFFORT_STRENGTH.find((effort) => supportedReasoningEfforts?.includes(effort));
return {
tiers: {
SIMPLE: [selected[0]],
MEDIUM: [selected[1]],
COMPLEX: [selected[2]],
REASONING: [selected[3]],
},
classifier_type: "heuristic_v2",
...(reasoningEffort && {
tier_model_params: { REASONING: { [selected[3]]: { reasoning_effort: reasoningEffort } } },
}),
};
const score = ({ everyday, hard }: { everyday: RouterModel; hard: RouterModel }): number =>
(everyday.family === hard.family ? 0 : 100) +
(everyday.rank === hard.rank ? 10 : 0) +
Math.abs(everyday.rank - low) +
Math.abs(hard.rank - high);
const chosen = pairs.toSorted(
(a, b) =>
score(a) - score(b) || a.everyday.name.localeCompare(b.everyday.name) || a.hard.name.localeCompare(b.hard.name),
)[0];
if (!chosen) return null;
const mixed = chosen.everyday.family !== chosen.hard.family;
const substituted = chosen.everyday.rank !== low || chosen.hard.rank !== high;
const mixedNotice = mixed ? "A complete pair is unavailable in one family. Using two model families." : null;
const substituteNotice = substituted ? "Using the closest available pair in this family." : null;
return { ...chosen, notice: mixedNotice ?? substituteNotice };
};
export const prefillEmptyTiers = (
current: ComplexityRouterConfigValue,
automatic: ComplexityRouterConfigValue | null,
): ComplexityRouterConfigValue => {
if (!automatic || current.custom_tier_set || isForecastClassifier(current.classifier_type)) return current;
if (activeTierRows(current).some((row) => row.models.length > 0)) return current;
return {
...current,
tiers: { ...current.tiers, ...automatic.tiers },
tier_model_params: { ...current.tier_model_params, ...automatic.tier_model_params },
};
export const solverPairConfig = (everyday: string, hard: string): ComplexityRouterConfigValue => ({
classifier_type: "heuristic",
classification_mode: "user_turn",
tiers: { SIMPLE: [everyday], MEDIUM: [everyday], COMPLEX: [hard], REASONING: [hard] },
});
export const simpleSetupError = (
loading: boolean,
failed: boolean,
everyday?: SolverModel,
hard?: SolverModel,
): string | null => {
if (loading) return "Loading deployed models";
if (failed) return "Could not verify deployed models";
if (!everyday || !hard) return "Select two different deployed models";
if (everyday.name === hard.name) return "Select two different deployed models";
return null;
};
export const nextRouterName = (names: readonly string[]): string => {
const candidates = [
"auto-router",
...Array.from({ length: names.length + 1 }, (_, index) => `auto-router-${index + 2}`),
];
return candidates.find((name) => !names.includes(name))!;
};