mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
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:
parent
48a893aefc
commit
9a35ed93f9
8 changed files with 1413 additions and 682 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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))!;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue