mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
feat(ui): configure capability routers
This commit is contained in:
parent
2e0442bdd2
commit
00fe75e83d
15 changed files with 861 additions and 225 deletions
|
|
@ -92,6 +92,7 @@ export interface AutoRouterDeployment extends AutoRouterCandidateDeployment {
|
|||
model?: string | null;
|
||||
complexity_router_config?: unknown;
|
||||
complexity_router_default_model?: string | null;
|
||||
capability_router_config?: unknown;
|
||||
auto_router_config?: unknown;
|
||||
auto_router_default_model?: string | null;
|
||||
auto_router_embedding_model?: string | null;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,32 @@ const semanticDeployment = {
|
|||
};
|
||||
|
||||
describe("autoRouterRows", () => {
|
||||
it("classifies a capability router and lists its candidate models", () => {
|
||||
const row = toAutoRouterRow(
|
||||
{
|
||||
model_name: "cost-router",
|
||||
litellm_params: {
|
||||
model: "auto_router/capability_router",
|
||||
capability_router_config: {
|
||||
candidates: [
|
||||
{ model: "small", description: "bounded tasks" },
|
||||
{ model: "frontier", description: "hard tasks" },
|
||||
],
|
||||
},
|
||||
},
|
||||
model_info: { id: "cap-1", db_model: true },
|
||||
},
|
||||
0,
|
||||
ADMIN,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(row.kind).toBe("capability");
|
||||
expect(row.typeLabel).toBe("Capability");
|
||||
expect(row.targets).toEqual(["small", "frontier"]);
|
||||
expect(row.canEdit).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies a complexity router and unions its tier models as targets", () => {
|
||||
const row = toAutoRouterRow(complexityDeployment, 0, ADMIN, null);
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,14 @@ const configManaged = (label: string, config: Record<string, unknown>): Presenta
|
|||
|
||||
/** How each strategy renders itself, given its own config object. */
|
||||
const PRESENTERS: Record<AutoRouterKind, (config: Record<string, unknown>) => Presentation> = {
|
||||
capability: (config) => ({
|
||||
typeLabel: "Capability",
|
||||
targets: dedupe(
|
||||
(Array.isArray(config.candidates) ? config.candidates : [])
|
||||
.map((candidate) => asRecord(candidate).model)
|
||||
.filter((model): model is string => typeof model === "string" && model.length > 0),
|
||||
),
|
||||
}),
|
||||
complexity: (config) => ({
|
||||
typeLabel: complexityTypeLabel(config),
|
||||
targets: dedupe(Object.values(asRecord(config.tiers)).flatMap(normalizeTierModels)),
|
||||
|
|
@ -116,7 +124,9 @@ export const toAutoRouterRow = (
|
|||
canDelete: canDelete && mayActOnRow,
|
||||
editBlockedReason,
|
||||
createdAt: info.created_at ?? undefined,
|
||||
defaultModel: (params[strategy.defaultModelKey] as string | null | undefined) ?? null,
|
||||
defaultModel: strategy.defaultModelKey
|
||||
? (params[strategy.defaultModelKey] as string | null | undefined) ?? null
|
||||
: null,
|
||||
deployment,
|
||||
...PRESENTERS[strategy.kind](asRecord(params[strategy.configKey])),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import RoutingDecisionCard from "@/components/view_logs/LogDetailsDrawer/Routing
|
|||
import { AutoRouterRoutingTestResult, testAutoRouterRouting } from "../networking";
|
||||
import { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
|
||||
import { buildAutoRouterRoutingTestRequest } from "./build_auto_router_routing_test_request";
|
||||
import { CapabilityRouterConfigValue } from "./capability_router_config";
|
||||
|
||||
interface AutoRouterRoutingTestProps {
|
||||
accessToken: string;
|
||||
config: ComplexityRouterConfigPayload;
|
||||
config?: ComplexityRouterConfigPayload;
|
||||
capabilityConfig?: CapabilityRouterConfigValue;
|
||||
defaultModel: string | undefined;
|
||||
routerName: string | undefined;
|
||||
teamId: string | undefined;
|
||||
|
|
@ -25,6 +27,7 @@ type TestState =
|
|||
const AutoRouterRoutingTest: React.FC<AutoRouterRoutingTestProps> = ({
|
||||
accessToken,
|
||||
config,
|
||||
capabilityConfig,
|
||||
defaultModel,
|
||||
routerName,
|
||||
teamId,
|
||||
|
|
@ -34,7 +37,7 @@ const AutoRouterRoutingTest: React.FC<AutoRouterRoutingTestProps> = ({
|
|||
|
||||
const send = async () => {
|
||||
setState({ status: "running" });
|
||||
const params = { prompt, config, defaultModel, routerName, teamId };
|
||||
const params = { prompt, config, capabilityConfig, defaultModel, routerName, teamId };
|
||||
const request = buildAutoRouterRoutingTestRequest(params);
|
||||
const response = await testAutoRouterRouting(accessToken, request);
|
||||
setState(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
import React from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { type ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { type CapabilityRouterConfigValue } from "./capability_router_config";
|
||||
|
||||
interface Props {
|
||||
modelInfo: ModelGroup[];
|
||||
value: CapabilityRouterConfigValue;
|
||||
onChange: (value: CapabilityRouterConfigValue) => void;
|
||||
}
|
||||
|
||||
const CapabilityRouterConfig: React.FC<Props> = ({ modelInfo, value, onChange }) => {
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
Array.from(new Set(modelInfo.filter((model) => model.mode !== "embedding").map((model) => model.model_group)))
|
||||
.filter((model) => !model.startsWith("auto_router/"))
|
||||
.map((model) => ({ value: model, label: model })),
|
||||
[modelInfo],
|
||||
);
|
||||
|
||||
const updateCandidate = (index: number, patch: Partial<CapabilityRouterConfigValue["candidates"][number]>) => {
|
||||
const previousModel = value.candidates[index]?.model;
|
||||
const candidates = value.candidates.map((candidate, row) =>
|
||||
row === index ? { ...candidate, ...patch } : candidate,
|
||||
);
|
||||
const fallback_model =
|
||||
previousModel === value.fallback_model && patch.model !== undefined ? patch.model : value.fallback_model;
|
||||
onChange({ ...value, candidates, fallback_model });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Candidate models</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Describe the tasks each model can reliably complete. Price is calculated separately.
|
||||
</p>
|
||||
</div>
|
||||
{value.candidates.map((candidate, index) => (
|
||||
<div key={index} className="space-y-3 rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Candidate {index + 1}</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={value.candidates.length <= 2}
|
||||
aria-label={`Remove candidate ${index + 1}`}
|
||||
onClick={() => {
|
||||
const candidates = value.candidates.filter((_, row) => row !== index);
|
||||
onChange({
|
||||
...value,
|
||||
candidates,
|
||||
fallback_model: candidates.some((item) => item.model === value.fallback_model)
|
||||
? value.fallback_model
|
||||
: candidates[0]?.model ?? "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
<SearchSelect
|
||||
options={options.filter(
|
||||
(option) =>
|
||||
option.value === candidate.model ||
|
||||
!value.candidates.some((item, row) => row !== index && item.model === option.value),
|
||||
)}
|
||||
value={candidate.model}
|
||||
onValueChange={(model) => updateCandidate(index, { model })}
|
||||
placeholder="Select a model group"
|
||||
emptyText="No models found"
|
||||
/>
|
||||
<Textarea
|
||||
value={candidate.description}
|
||||
onChange={(event) => updateCandidate(index, { description: event.target.value })}
|
||||
placeholder="Example: Fast and reliable for extraction, summarization, and bounded code edits; weaker on long-horizon debugging."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onChange({ ...value, candidates: [...value.candidates, { model: "", description: "" }] })}
|
||||
>
|
||||
<Plus /> Add candidate
|
||||
</Button>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Classifier model</Label>
|
||||
<SearchSelect
|
||||
options={options}
|
||||
value={value.classifier.model}
|
||||
onValueChange={(model) => onChange({ ...value, classifier: { ...value.classifier, model } })}
|
||||
placeholder="Select classifier"
|
||||
emptyText="No models found"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Fallback model</Label>
|
||||
<SearchSelect
|
||||
options={value.candidates
|
||||
.filter((candidate) => candidate.model)
|
||||
.map((candidate) => ({ value: candidate.model, label: candidate.model }))}
|
||||
value={value.fallback_model}
|
||||
onValueChange={(fallback_model) => onChange({ ...value, fallback_model })}
|
||||
placeholder="Select fallback"
|
||||
emptyText="Select candidates first"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="capability-probability-threshold">Probability threshold</Label>
|
||||
<Input
|
||||
id="capability-probability-threshold"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={value.probability_threshold}
|
||||
onChange={(event) => onChange({ ...value, probability_threshold: Number(event.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Higher values favor reliability; lower values favor cost savings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="capability-cache-ttl">Decision cache TTL (seconds)</Label>
|
||||
<Input
|
||||
id="capability-cache-ttl"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={value.cache_ttl_seconds}
|
||||
onChange={(event) => onChange({ ...value, cache_ttl_seconds: Number(event.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Reuses the decision for repeated calls in the same user turn.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CapabilityRouterConfig;
|
||||
|
|
@ -50,6 +50,12 @@ import { tierRowLabel } from "./complexity_router_tiers";
|
|||
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
|
||||
import AutoRouterConnectionTest from "./auto_router_connection_test";
|
||||
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
|
||||
import CapabilityRouterConfig from "./CapabilityRouterConfig";
|
||||
import {
|
||||
capabilityRouterConfigError,
|
||||
defaultCapabilityRouterConfig,
|
||||
type CapabilityRouterConfigValue,
|
||||
} from "./capability_router_config";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
getMissingModelsInPreset,
|
||||
|
|
@ -186,6 +192,9 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
});
|
||||
const [routerKind, setRouterKind] = useState<"complexity" | "capability">("complexity");
|
||||
const [capabilityRouterConfig, setCapabilityRouterConfig] =
|
||||
useState<CapabilityRouterConfigValue>(defaultCapabilityRouterConfig);
|
||||
|
||||
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState<string[]>([]);
|
||||
const [keywordTierRules, setKeywordTierRules] = useState<KeywordTierRule[]>([]);
|
||||
|
|
@ -342,13 +351,16 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
defaultModel: complexityRouterConfig.default_model,
|
||||
};
|
||||
|
||||
const submitBlockedReason = getSubmitBlockedReason(
|
||||
complexityRouterConfig,
|
||||
keywordTierRules,
|
||||
referencedModelsParams,
|
||||
groupsOnlyAvailability,
|
||||
modelInfo,
|
||||
);
|
||||
const submitBlockedReason =
|
||||
routerKind === "capability"
|
||||
? capabilityRouterConfigError(capabilityRouterConfig)
|
||||
: getSubmitBlockedReason(
|
||||
complexityRouterConfig,
|
||||
keywordTierRules,
|
||||
referencedModelsParams,
|
||||
groupsOnlyAvailability,
|
||||
modelInfo,
|
||||
);
|
||||
|
||||
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
|
|
@ -460,27 +472,75 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await submitRecommendedRouter(name);
|
||||
if (routerKind === "capability") {
|
||||
const blockedReason = capabilityRouterConfigError(capabilityRouterConfig);
|
||||
if (blockedReason) {
|
||||
toast.fromError(blockedReason);
|
||||
return;
|
||||
}
|
||||
if (!(await form.trigger(requiresTeamScope ? ["auto_router_name", "team_id"] : ["auto_router_name"]))) {
|
||||
return;
|
||||
}
|
||||
const serverVerdict = await validateAutoRouterConfig(
|
||||
accessToken,
|
||||
capabilityRouterConfig as unknown as Record<string, unknown>,
|
||||
requiresTeamScope ? form.getValues("team_id") : undefined,
|
||||
"capability",
|
||||
);
|
||||
const dryRunError = dryRunRejection(serverVerdict);
|
||||
if (dryRunError) {
|
||||
toast.fromError(dryRunError);
|
||||
return;
|
||||
}
|
||||
await handleAddAutoRouterSubmit(
|
||||
{
|
||||
auto_router_name: name,
|
||||
...teamScopePayload(requiresTeamScope, form.getValues("team_id")),
|
||||
model_type: "capability_router",
|
||||
capability_router_config: capabilityRouterConfig,
|
||||
model_access_group: form.getValues("model_access_group"),
|
||||
},
|
||||
accessToken,
|
||||
() => form.reset(EMPTY_FORM_VALUES),
|
||||
handleOk,
|
||||
);
|
||||
} else {
|
||||
await submitRecommendedRouter(name);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = () => {
|
||||
const testTargetParams = {
|
||||
tiers: activeTierRows(complexityRouterConfig).map(
|
||||
(row) => [activeTierName(row), row.models] as [string, string[]],
|
||||
),
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
defaultModel: resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model),
|
||||
classifier: usesLlmClassifier(effectiveClassifierType(complexityRouterConfig))
|
||||
const testTargetParams =
|
||||
routerKind === "capability"
|
||||
? {
|
||||
model: complexityRouterConfig.classifier_llm_config?.model ?? "",
|
||||
reasoningEffort: complexityRouterConfig.classifier_llm_config?.reasoning_effort,
|
||||
tiers: [
|
||||
["Candidates", capabilityRouterConfig.candidates.map((candidate) => candidate.model).filter(Boolean)] as [
|
||||
string,
|
||||
string[],
|
||||
],
|
||||
],
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: undefined,
|
||||
defaultModel: capabilityRouterConfig.fallback_model,
|
||||
classifier: { model: capabilityRouterConfig.classifier.model },
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
: {
|
||||
tiers: activeTierRows(complexityRouterConfig).map(
|
||||
(row) => [activeTierName(row), row.models] as [string, string[]],
|
||||
),
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
defaultModel: resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model),
|
||||
classifier: usesLlmClassifier(effectiveClassifierType(complexityRouterConfig))
|
||||
? {
|
||||
model: complexityRouterConfig.classifier_llm_config?.model ?? "",
|
||||
reasoningEffort: complexityRouterConfig.classifier_llm_config?.reasoning_effort,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
const targets = buildAutoRouterTestTargets(testTargetParams);
|
||||
|
||||
if (targets.length === 0) {
|
||||
|
|
@ -508,71 +568,100 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g., smart_router, auto_router_1" />}
|
||||
</FormField>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">Template</label>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">Router type</label>
|
||||
<Select
|
||||
items={templateItems}
|
||||
value={selectedPreset ?? null}
|
||||
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
|
||||
value={routerKind}
|
||||
onValueChange={(value: string | null) =>
|
||||
setRouterKind(value === "capability" ? "capability" : "complexity")
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid="template-selector" className="w-full">
|
||||
<SelectValue placeholder="Choose a template or select Custom to define your own" />
|
||||
<SelectTrigger className="w-full" data-testid="router-type-selector">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
|
||||
const disabledHint = presetDisabledHint(presetState);
|
||||
const hintClass = isPresetHintAlarming(presetState)
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground";
|
||||
const matchedHint =
|
||||
presetState.kind === "available" && presetState.viaDeployments
|
||||
? "Matches your deployments"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={preset.key}
|
||||
value={preset.key}
|
||||
label={preset.label}
|
||||
disabled={disabledHint !== null}
|
||||
title={disabledHint ?? preset.description}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
|
||||
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
<SelectItem value="custom" label="Custom Configuration">
|
||||
<div>
|
||||
<div className="font-medium">Custom Configuration</div>
|
||||
<div className="text-xs text-muted-foreground">Define your auto router from scratch</div>
|
||||
</div>
|
||||
<SelectItem value="complexity" label="Complexity">
|
||||
Complexity
|
||||
</SelectItem>
|
||||
<SelectItem value="capability" label="Capability">
|
||||
Capability
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelsUnverifiable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load available models.{" "}
|
||||
<button type="button" className="underline" onClick={() => refetchModels()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{presetsPending && <div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>}
|
||||
{presetsUnavailable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load templates, so only Custom Configuration is shown.{" "}
|
||||
<button type="button" className="underline" onClick={() => void refetchPresets()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{routerKind === "capability"
|
||||
? "Estimate each model's probability of success, then choose the cheapest model above your threshold."
|
||||
: "Route requests by estimated task complexity."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{routerKind === "complexity" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">Template</label>
|
||||
<Select
|
||||
items={templateItems}
|
||||
value={selectedPreset ?? null}
|
||||
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
|
||||
>
|
||||
<SelectTrigger data-testid="template-selector" className="w-full">
|
||||
<SelectValue placeholder="Choose a template or select Custom to define your own" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
|
||||
const disabledHint = presetDisabledHint(presetState);
|
||||
const hintClass = isPresetHintAlarming(presetState)
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground";
|
||||
const matchedHint =
|
||||
presetState.kind === "available" && presetState.viaDeployments
|
||||
? "Matches your deployments"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={preset.key}
|
||||
value={preset.key}
|
||||
label={preset.label}
|
||||
disabled={disabledHint !== null}
|
||||
title={disabledHint ?? preset.description}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
|
||||
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
<SelectItem value="custom" label="Custom Configuration">
|
||||
<div>
|
||||
<div className="font-medium">Custom Configuration</div>
|
||||
<div className="text-xs text-muted-foreground">Define your auto router from scratch</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelsUnverifiable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load available models.{" "}
|
||||
<button type="button" className="underline" onClick={() => refetchModels()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{presetsPending && <div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>}
|
||||
{presetsUnavailable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load templates, so only Custom Configuration is shown.{" "}
|
||||
<button type="button" className="underline" onClick={() => void refetchPresets()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requiresTeamScope && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
@ -588,56 +677,64 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsExpanded((expanded) => !expanded)}
|
||||
className="w-full flex flex-col gap-1 px-4 py-3 text-left hover:bg-muted"
|
||||
data-testid="detailed-configuration-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
{detailsExpanded ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
Detailed Configuration
|
||||
</span>
|
||||
{!detailsExpanded && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tierConfigSummary(complexityRouterConfig)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{detailsExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
keywordRulesError={getKeywordTierRulesError(
|
||||
keywordTierRules,
|
||||
activeTierRows(complexityRouterConfig),
|
||||
{routerKind === "complexity" ? (
|
||||
<div className="border border-border rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsExpanded((expanded) => !expanded)}
|
||||
className="w-full flex flex-col gap-1 px-4 py-3 text-left hover:bg-muted"
|
||||
data-testid="detailed-configuration-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
{detailsExpanded ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
Detailed Configuration
|
||||
</span>
|
||||
{!detailsExpanded && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tierConfigSummary(complexityRouterConfig)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{detailsExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
keywordRulesError={getKeywordTierRulesError(
|
||||
keywordTierRules,
|
||||
activeTierRows(complexityRouterConfig),
|
||||
)}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<CapabilityRouterConfig
|
||||
modelInfo={modelInfo}
|
||||
value={capabilityRouterConfig}
|
||||
onChange={setCapabilityRouterConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<FormField
|
||||
|
|
@ -723,8 +820,15 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
{isRoutingTestVisible && (
|
||||
<AutoRouterRoutingTest
|
||||
accessToken={accessToken}
|
||||
config={buildComplexityRouterConfig(complexityRouterConfigParams)}
|
||||
defaultModel={resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model)}
|
||||
config={
|
||||
routerKind === "complexity" ? buildComplexityRouterConfig(complexityRouterConfigParams) : undefined
|
||||
}
|
||||
capabilityConfig={routerKind === "capability" ? capabilityRouterConfig : undefined}
|
||||
defaultModel={
|
||||
routerKind === "complexity"
|
||||
? resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model)
|
||||
: undefined
|
||||
}
|
||||
routerName={watchedName}
|
||||
teamId={requiresTeamScope ? watchedTeamId : undefined}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { fireEvent, renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import AddAutoRouterTab from "./add_auto_router_tab";
|
||||
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
|
||||
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
|
||||
);
|
||||
|
||||
const { mockFetchAvailableModels, mockFetchAllModelDeployments, validateAutoRouterConfig } = vi.hoisted(() => ({
|
||||
mockFetchAvailableModels: vi.fn(),
|
||||
mockFetchAllModelDeployments: vi.fn(),
|
||||
validateAutoRouterConfig: vi.fn().mockResolvedValue({ valid: true }),
|
||||
}));
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
validateAutoRouterConfig,
|
||||
}));
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModels: mockFetchAvailableModels }));
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModels", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/app/(dashboard)/hooks/models/useModels")>();
|
||||
return { ...actual, fetchAllModelDeployments: mockFetchAllModelDeployments };
|
||||
});
|
||||
vi.mock("./handle_add_auto_router_submit", () => ({ handleAddAutoRouterSubmit: vi.fn() }));
|
||||
vi.mock("./CapabilityRouterConfig", () => ({
|
||||
default: ({ onChange }: { onChange: (value: unknown) => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
candidates: [
|
||||
{ model: "small", description: "Bounded extraction" },
|
||||
{ model: "frontier", description: "Ambiguous multi-step work" },
|
||||
],
|
||||
classifier: { model: "classifier", timeout_ms: 3000, max_output_tokens: 1024 },
|
||||
probability_threshold: 0.75,
|
||||
fallback_model: "frontier",
|
||||
estimated_output_tokens: 1000,
|
||||
cache_ttl_seconds: 3600,
|
||||
})
|
||||
}
|
||||
>
|
||||
Fill capability config
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("AddAutoRouterTab capability flow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
mockFetchAvailableModels.mockResolvedValue(
|
||||
["small", "frontier", "classifier"].map((model_group) => ({ model_group, mode: "chat" })),
|
||||
);
|
||||
mockFetchAllModelDeployments.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("validates and submits the capability config shown in the form", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Admin" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("router-type-selector"));
|
||||
await user.click(screen.getByRole("option", { name: "Capability" }));
|
||||
expect(screen.queryByTestId("template-selector")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Fill capability config" }));
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "cost-router");
|
||||
await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledOnce());
|
||||
expect(validateAutoRouterConfig).toHaveBeenCalledWith(
|
||||
"token",
|
||||
expect.objectContaining({ probability_threshold: 0.75, cache_ttl_seconds: 3600 }),
|
||||
undefined,
|
||||
"capability",
|
||||
);
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0]?.[0]).toMatchObject({
|
||||
auto_router_name: "cost-router",
|
||||
model_type: "capability_router",
|
||||
capability_router_config: {
|
||||
fallback_model: "frontier",
|
||||
probability_threshold: 0.75,
|
||||
cache_ttl_seconds: 3600,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -13,12 +13,13 @@
|
|||
* prefixes are matched first, and only a bare `auto_router/<name>` is the semantic router.
|
||||
*/
|
||||
|
||||
export type AutoRouterKind = "complexity" | "adaptive" | "quality" | "semantic";
|
||||
export type AutoRouterKind = "complexity" | "capability" | "adaptive" | "quality" | "semantic";
|
||||
|
||||
export interface AutoRouterParams {
|
||||
model?: string | null;
|
||||
complexity_router_config?: unknown;
|
||||
complexity_router_default_model?: string | null;
|
||||
capability_router_config?: unknown;
|
||||
auto_router_config?: unknown;
|
||||
auto_router_default_model?: string | null;
|
||||
adaptive_router_config?: unknown;
|
||||
|
|
@ -32,7 +33,7 @@ export interface AutoRouterStrategy {
|
|||
/** Type-pill label. The complexity router overrides this with its classifier. */
|
||||
label: string;
|
||||
configKey: keyof AutoRouterParams;
|
||||
defaultModelKey: keyof AutoRouterParams;
|
||||
defaultModelKey?: keyof AutoRouterParams;
|
||||
/** Whether the dashboard has a form that understands this strategy's config shape. */
|
||||
hasEditor: boolean;
|
||||
matches: (params: AutoRouterParams) => boolean;
|
||||
|
|
@ -42,6 +43,13 @@ const startsWith = (params: AutoRouterParams, prefix: string): boolean => params
|
|||
|
||||
/** Ordered; the semantic entry matches anything left and must stay last. */
|
||||
export const AUTO_ROUTER_STRATEGIES: readonly AutoRouterStrategy[] = [
|
||||
{
|
||||
kind: "capability",
|
||||
label: "Capability",
|
||||
configKey: "capability_router_config",
|
||||
hasEditor: true,
|
||||
matches: (p) => startsWith(p, "auto_router/capability_router") || p.capability_router_config != null,
|
||||
},
|
||||
{
|
||||
kind: "complexity",
|
||||
label: "Complexity",
|
||||
|
|
@ -84,10 +92,14 @@ export const autoRouterStrategy = (params: AutoRouterParams | null | undefined):
|
|||
export const isComplexityRouter = (params: AutoRouterParams | null | undefined): boolean =>
|
||||
autoRouterStrategy(params).kind === "complexity";
|
||||
|
||||
export const isCapabilityRouter = (params: AutoRouterParams | null | undefined): boolean =>
|
||||
autoRouterStrategy(params).kind === "capability";
|
||||
|
||||
/** Any `auto_router/*` deployment, whatever its strategy. Use for listing and filtering. */
|
||||
export const isAutoRouterDeployment = (params: AutoRouterParams | null | undefined): boolean =>
|
||||
params?.model?.startsWith("auto_router/") === true ||
|
||||
params?.complexity_router_config != null ||
|
||||
params?.capability_router_config != null ||
|
||||
params?.auto_router_config != null;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { AutoRouterRoutingTestRequest } from "../networking";
|
||||
import { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
|
||||
import { CapabilityRouterConfigValue } from "./capability_router_config";
|
||||
|
||||
export interface BuildAutoRouterRoutingTestRequestParams {
|
||||
prompt: string;
|
||||
config: ComplexityRouterConfigPayload;
|
||||
config?: ComplexityRouterConfigPayload;
|
||||
capabilityConfig?: CapabilityRouterConfigValue;
|
||||
defaultModel: string | undefined;
|
||||
routerName: string | undefined;
|
||||
teamId: string | undefined;
|
||||
|
|
@ -12,12 +14,15 @@ export interface BuildAutoRouterRoutingTestRequestParams {
|
|||
export const buildAutoRouterRoutingTestRequest = ({
|
||||
prompt,
|
||||
config,
|
||||
capabilityConfig,
|
||||
defaultModel,
|
||||
routerName,
|
||||
teamId,
|
||||
}: BuildAutoRouterRoutingTestRequestParams): AutoRouterRoutingTestRequest => ({
|
||||
prompt,
|
||||
complexity_router_config: config,
|
||||
...(capabilityConfig
|
||||
? { capability_router_config: capabilityConfig as unknown as Record<string, unknown> }
|
||||
: { complexity_router_config: config }),
|
||||
...(defaultModel ? { default_model: defaultModel } : {}),
|
||||
...(routerName?.trim() ? { router_name: routerName.trim() } : {}),
|
||||
...(teamId ? { team_id: teamId } : {}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
capabilityRouterConfigError,
|
||||
defaultCapabilityRouterConfig,
|
||||
hydrateCapabilityRouterConfig,
|
||||
} from "./capability_router_config";
|
||||
|
||||
const validConfig = () => ({
|
||||
...defaultCapabilityRouterConfig(),
|
||||
candidates: [
|
||||
{ model: "small", description: "Good for bounded extraction" },
|
||||
{ model: "frontier", description: "Good for ambiguous multi-step work" },
|
||||
],
|
||||
classifier: { model: "classifier", timeout_ms: 3000, max_output_tokens: 1024 },
|
||||
fallback_model: "frontier",
|
||||
});
|
||||
|
||||
describe("capability router config", () => {
|
||||
it("accepts the minimal complete configuration", () => {
|
||||
expect(capabilityRouterConfigError(validConfig())).toBeNull();
|
||||
});
|
||||
|
||||
it("requires descriptions, unique models, and a candidate fallback", () => {
|
||||
expect(
|
||||
capabilityRouterConfigError({
|
||||
...validConfig(),
|
||||
candidates: [{ model: "small", description: "" }, validConfig().candidates[1]],
|
||||
}),
|
||||
).toContain("Describe");
|
||||
expect(
|
||||
capabilityRouterConfigError({
|
||||
...validConfig(),
|
||||
candidates: [validConfig().candidates[0], validConfig().candidates[0]],
|
||||
}),
|
||||
).toContain("unique");
|
||||
expect(capabilityRouterConfigError({ ...validConfig(), fallback_model: "other" })).toContain("fallback");
|
||||
});
|
||||
|
||||
it("hydrates stored values while supplying newly introduced defaults", () => {
|
||||
const hydrated = hydrateCapabilityRouterConfig({
|
||||
candidates: validConfig().candidates,
|
||||
classifier: { model: "classifier" },
|
||||
fallback_model: "frontier",
|
||||
probability_threshold: 0.8,
|
||||
});
|
||||
|
||||
expect(hydrated.probability_threshold).toBe(0.8);
|
||||
expect(hydrated.classifier.timeout_ms).toBe(3000);
|
||||
expect(hydrated.cache_ttl_seconds).toBe(3600);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
export interface CapabilityCandidate {
|
||||
model: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CapabilityRouterConfigValue {
|
||||
candidates: CapabilityCandidate[];
|
||||
classifier: { model: string; timeout_ms: number; max_output_tokens: number };
|
||||
probability_threshold: number;
|
||||
fallback_model: string;
|
||||
estimated_output_tokens: number;
|
||||
cache_ttl_seconds: number;
|
||||
}
|
||||
|
||||
export const defaultCapabilityRouterConfig = (): CapabilityRouterConfigValue => ({
|
||||
candidates: [
|
||||
{ model: "", description: "" },
|
||||
{ model: "", description: "" },
|
||||
],
|
||||
classifier: { model: "", timeout_ms: 3000, max_output_tokens: 1024 },
|
||||
probability_threshold: 0.7,
|
||||
fallback_model: "",
|
||||
estimated_output_tokens: 1000,
|
||||
cache_ttl_seconds: 3600,
|
||||
});
|
||||
|
||||
export const capabilityRouterConfigError = (config: CapabilityRouterConfigValue): string | null => {
|
||||
if (config.candidates.length < 2) return "Add at least two candidate models";
|
||||
if (config.candidates.some((candidate) => !candidate.model.trim())) return "Select a model for every candidate";
|
||||
if (new Set(config.candidates.map((candidate) => candidate.model)).size !== config.candidates.length) {
|
||||
return "Candidate models must be unique";
|
||||
}
|
||||
if (config.candidates.some((candidate) => !candidate.description.trim())) {
|
||||
return "Describe what every candidate model is good at";
|
||||
}
|
||||
if (!config.classifier.model.trim()) return "Select a classifier model";
|
||||
if (!config.candidates.some((candidate) => candidate.model === config.fallback_model)) {
|
||||
return "Select one candidate as the fallback model";
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(config.probability_threshold) ||
|
||||
config.probability_threshold < 0 ||
|
||||
config.probability_threshold > 1
|
||||
) {
|
||||
return "Probability threshold must be between 0 and 1";
|
||||
}
|
||||
if (!Number.isInteger(config.cache_ttl_seconds) || config.cache_ttl_seconds < 1) {
|
||||
return "Cache TTL must be at least one second";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const hydrateCapabilityRouterConfig = (stored: unknown): CapabilityRouterConfigValue => {
|
||||
const defaults = defaultCapabilityRouterConfig();
|
||||
if (typeof stored !== "object" || stored === null || Array.isArray(stored)) return defaults;
|
||||
const value = stored as Partial<CapabilityRouterConfigValue>;
|
||||
return {
|
||||
...defaults,
|
||||
...value,
|
||||
candidates: Array.isArray(value.candidates) ? value.candidates : defaults.candidates,
|
||||
classifier: { ...defaults.classifier, ...(value.classifier ?? {}) },
|
||||
};
|
||||
};
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
import { modelCreateCall } from "../networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
|
||||
import type { CapabilityRouterConfigValue } from "./capability_router_config";
|
||||
|
||||
export interface AddAutoRouterValues {
|
||||
auto_router_name: string;
|
||||
auto_router_default_model: string | undefined;
|
||||
model_type: "complexity_router";
|
||||
complexity_router_config: ComplexityRouterConfigPayload;
|
||||
model_type: "complexity_router" | "capability_router";
|
||||
auto_router_default_model?: string;
|
||||
complexity_router_config?: ComplexityRouterConfigPayload;
|
||||
capability_router_config?: CapabilityRouterConfigValue;
|
||||
team_id?: string;
|
||||
model_access_group?: string[];
|
||||
}
|
||||
|
|
@ -18,13 +20,20 @@ export const handleAddAutoRouterSubmit = async (
|
|||
callback?: () => void,
|
||||
) => {
|
||||
try {
|
||||
const litellmParams =
|
||||
values.model_type === "capability_router"
|
||||
? {
|
||||
model: "auto_router/capability_router",
|
||||
capability_router_config: values.capability_router_config!,
|
||||
}
|
||||
: {
|
||||
model: "auto_router/complexity_router",
|
||||
complexity_router_config: values.complexity_router_config!,
|
||||
complexity_router_default_model: values.auto_router_default_model,
|
||||
};
|
||||
const autoRouterConfig = {
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: {
|
||||
model: "auto_router/complexity_router",
|
||||
complexity_router_config: values.complexity_router_config,
|
||||
complexity_router_default_model: values.auto_router_default_model,
|
||||
},
|
||||
litellm_params: litellmParams,
|
||||
model_info: {
|
||||
...(values.team_id ? { team_id: values.team_id } : {}),
|
||||
...(values.model_access_group?.length ? { access_groups: values.model_access_group } : {}),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,14 @@ import {
|
|||
tierParamsByRowId,
|
||||
resolveComplexityDefaultModel,
|
||||
} from "../add_model/tier_rows";
|
||||
import { isComplexityRouter } from "../add_model/auto_router_strategies";
|
||||
import { isCapabilityRouter, isComplexityRouter } from "../add_model/auto_router_strategies";
|
||||
import CapabilityRouterConfig from "../add_model/CapabilityRouterConfig";
|
||||
import {
|
||||
capabilityRouterConfigError,
|
||||
defaultCapabilityRouterConfig,
|
||||
hydrateCapabilityRouterConfig,
|
||||
type CapabilityRouterConfigValue,
|
||||
} from "../add_model/capability_router_config";
|
||||
import {
|
||||
type BuildComplexityRouterConfigParams,
|
||||
buildComplexityRouterConfig,
|
||||
|
|
@ -428,28 +435,36 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
});
|
||||
const [capabilityRouterConfig, setCapabilityRouterConfig] =
|
||||
useState<CapabilityRouterConfigValue>(defaultCapabilityRouterConfig);
|
||||
const isComplexityRouterModel = isComplexityRouter(modelData?.litellm_params);
|
||||
const isCapabilityRouterModel = isCapabilityRouter(modelData?.litellm_params);
|
||||
|
||||
const schema = useMemo(
|
||||
() => (isComplexityRouterModel ? complexityRouterSchema : semanticRouterSchema),
|
||||
[isComplexityRouterModel],
|
||||
() => (isComplexityRouterModel || isCapabilityRouterModel ? complexityRouterSchema : semanticRouterSchema),
|
||||
[isCapabilityRouterModel, isComplexityRouterModel],
|
||||
);
|
||||
const form = useZodForm(schema, { defaultValues: EMPTY_FORM_VALUES });
|
||||
|
||||
// Mirrors the create form: the button says why it is unavailable and disables on the same
|
||||
// answer. Tiers use this modal's own rule, which allows a partly filled router, so an edit that
|
||||
// is legal today stays legal.
|
||||
const submitBlockedReason = !isComplexityRouterModel
|
||||
? null
|
||||
: (complexityRouterConfig.custom_tier_set
|
||||
? getCustomTierRowsError(complexityRouterConfig.custom_tier_set) ??
|
||||
getMissingTiersError(activeTierRows(complexityRouterConfig))
|
||||
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
|
||||
? "Please select at least one model for a complexity tier"
|
||||
: null) ?? getTierLabelsError(complexityRouterConfig.tier_labels)) ??
|
||||
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig)) ??
|
||||
getClassifierModelError(complexityRouterConfig);
|
||||
const complexitySubmitBlockedReason =
|
||||
(complexityRouterConfig.custom_tier_set
|
||||
? getCustomTierRowsError(complexityRouterConfig.custom_tier_set) ??
|
||||
getMissingTiersError(activeTierRows(complexityRouterConfig))
|
||||
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
|
||||
? "Please select at least one model for a complexity tier"
|
||||
: null) ?? getTierLabelsError(complexityRouterConfig.tier_labels)) ??
|
||||
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig)) ??
|
||||
getClassifierModelError(complexityRouterConfig);
|
||||
let submitBlockedReason: string | null = null;
|
||||
if (isCapabilityRouterModel) {
|
||||
submitBlockedReason = capabilityRouterConfigError(capabilityRouterConfig);
|
||||
} else if (isComplexityRouterModel) {
|
||||
submitBlockedReason = complexitySubmitBlockedReason;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && modelData) {
|
||||
|
|
@ -487,6 +502,15 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
const initializeForm = () => {
|
||||
setEditingTiers(false);
|
||||
try {
|
||||
if (isCapabilityRouterModel) {
|
||||
setCapabilityRouterConfig(hydrateCapabilityRouterConfig(modelData.litellm_params?.capability_router_config));
|
||||
form.reset({
|
||||
...EMPTY_FORM_VALUES,
|
||||
auto_router_name: modelData.model_name,
|
||||
model_access_group: modelData.model_info?.access_groups || [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isComplexityRouterModel) {
|
||||
// Parse the complexity_router_config if it exists and is a string
|
||||
let parsedConfig = modelData.litellm_params?.complexity_router_config || {};
|
||||
|
|
@ -551,6 +575,46 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
};
|
||||
|
||||
const saveValues = async (values: EditAutoRouterFormValues) => {
|
||||
if (isCapabilityRouterModel) {
|
||||
const validationError = capabilityRouterConfigError(capabilityRouterConfig);
|
||||
if (validationError) {
|
||||
toast.fromError(validationError);
|
||||
return;
|
||||
}
|
||||
const serverVerdict = await validateAutoRouterConfig(
|
||||
accessToken,
|
||||
capabilityRouterConfig as unknown as Record<string, unknown>,
|
||||
modelData?.model_info?.team_id,
|
||||
"capability",
|
||||
);
|
||||
const dryRunError = dryRunRejection(serverVerdict);
|
||||
if (dryRunError) {
|
||||
toast.fromError(dryRunError);
|
||||
return;
|
||||
}
|
||||
const updatedLitellmParams = {
|
||||
...modelData.litellm_params,
|
||||
capability_router_config: capabilityRouterConfig,
|
||||
};
|
||||
const updatedModelInfo = {
|
||||
...modelData.model_info,
|
||||
access_groups: values.model_access_group || [],
|
||||
};
|
||||
await modelPatchUpdateCall(
|
||||
accessToken,
|
||||
{ model_name: values.auto_router_name, litellm_params: updatedLitellmParams, model_info: updatedModelInfo },
|
||||
modelData.model_info.id,
|
||||
);
|
||||
toast.success("Auto router configuration updated successfully");
|
||||
onSuccess({
|
||||
...modelData,
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: updatedLitellmParams,
|
||||
model_info: updatedModelInfo,
|
||||
});
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (isComplexityRouterModel) {
|
||||
const { tiers, custom_tier_set, classifier_llm_config } = complexityRouterConfig;
|
||||
const rows = activeTierRows(complexityRouterConfig);
|
||||
|
|
@ -721,78 +785,86 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g., auto_router_1, smart_routing" />}
|
||||
</FormField>
|
||||
|
||||
{isComplexityRouterModel ? (
|
||||
/* Complexity Router Configuration */
|
||||
<div className="w-full">
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
showValidationErrors={showValidationErrors}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={(config) => {
|
||||
setComplexityRouterConfig(config);
|
||||
}}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
keywordRulesError={getKeywordTierRulesError(
|
||||
keywordTierRules,
|
||||
activeTierRows(complexityRouterConfig),
|
||||
)}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Router Configuration Builder */}
|
||||
{isCapabilityRouterModel && (
|
||||
<CapabilityRouterConfig
|
||||
modelInfo={modelInfo}
|
||||
value={capabilityRouterConfig}
|
||||
onChange={setCapabilityRouterConfig}
|
||||
/>
|
||||
)}
|
||||
{!isCapabilityRouterModel &&
|
||||
(isComplexityRouterModel ? (
|
||||
/* Complexity Router Configuration */
|
||||
<div className="w-full">
|
||||
<RouterConfigBuilder
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
showValidationErrors={showValidationErrors}
|
||||
modelInfo={modelInfo}
|
||||
value={routerConfig}
|
||||
value={complexityRouterConfig}
|
||||
onChange={(config) => {
|
||||
setRouterConfig(config);
|
||||
setComplexityRouterConfig(config);
|
||||
}}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
keywordRulesError={getKeywordTierRulesError(
|
||||
keywordTierRules,
|
||||
activeTierRows(complexityRouterConfig),
|
||||
)}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField control={form.control} name="auto_router_default_model" label="Default Model">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<ModelChoiceCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
choices={modelChoices}
|
||||
placeholder="Select a default model"
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
) : (
|
||||
<>
|
||||
{/* Router Configuration Builder */}
|
||||
<div className="w-full">
|
||||
<RouterConfigBuilder
|
||||
modelInfo={modelInfo}
|
||||
value={routerConfig}
|
||||
onChange={(config) => {
|
||||
setRouterConfig(config);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField control={form.control} name="auto_router_embedding_model" label="Embedding Model">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<ModelChoiceCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
choices={modelChoices}
|
||||
placeholder="Select an embedding model"
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
<FormField control={form.control} name="auto_router_default_model" label="Default Model">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<ModelChoiceCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
choices={modelChoices}
|
||||
placeholder="Select a default model"
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="auto_router_embedding_model" label="Embedding Model">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<ModelChoiceCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
choices={modelChoices}
|
||||
placeholder="Select an embedding model"
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</>
|
||||
))}
|
||||
|
||||
{userRole === "Admin" && (
|
||||
<FormField
|
||||
|
|
|
|||
|
|
@ -2411,7 +2411,8 @@ export const testModelGroupConnection = async (
|
|||
|
||||
export interface AutoRouterRoutingTestRequest {
|
||||
prompt: string;
|
||||
complexity_router_config: ComplexityRouterConfigPayload;
|
||||
complexity_router_config?: ComplexityRouterConfigPayload;
|
||||
capability_router_config?: Record<string, unknown>;
|
||||
default_model?: string;
|
||||
router_name?: string;
|
||||
team_id?: string;
|
||||
|
|
@ -2452,16 +2453,18 @@ export interface ComplexityRouterConfigValidation {
|
|||
// write gate stays authoritative.
|
||||
export const validateAutoRouterConfig = async (
|
||||
accessToken: string,
|
||||
complexityRouterConfig: Record<string, unknown>,
|
||||
routerConfig: Record<string, unknown>,
|
||||
teamId?: string,
|
||||
routerType: "complexity" | "capability" = "complexity",
|
||||
): Promise<ComplexityRouterConfigValidation> => {
|
||||
try {
|
||||
return await apiClient.post<ComplexityRouterConfigValidation>("/auto_router/validate_complexity_router_config", {
|
||||
const configKey = `${routerType}_router_config`;
|
||||
return await apiClient.post<ComplexityRouterConfigValidation>(`/auto_router/validate_${routerType}_router_config`, {
|
||||
accessToken,
|
||||
body: { complexity_router_config: complexityRouterConfig, ...(teamId && { team_id: teamId }) },
|
||||
body: { [configKey]: routerConfig, ...(teamId && { team_id: teamId }) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Could not dry-run the complexity router config; the save will be validated server side", error);
|
||||
console.warn(`Could not dry-run the ${routerType} router config; the save will be validated server side`, error);
|
||||
return { valid: true };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,12 +27,19 @@ export interface RoutingDecision {
|
|||
escalated?: boolean;
|
||||
tier_boundaries?: RoutingDecisionTierBoundaries;
|
||||
reasoning_override_min_score?: number;
|
||||
probability_threshold?: number;
|
||||
candidate_probabilities?: Record<string, number>;
|
||||
candidate_costs?: Record<string, number>;
|
||||
qualified_models?: string[];
|
||||
fallback_reason?: string;
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
const ROUTER_TYPE_LABELS: Record<string, string> = {
|
||||
complexity: "Auto-Router v2",
|
||||
adaptive: "Adaptive router",
|
||||
quality: "Quality router",
|
||||
capability: "Capability router",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -99,6 +106,9 @@ const CONSTANT_CAUSE_LABELS: Record<string, string> = {
|
|||
default_fallback: "Default model, no route matched",
|
||||
classifier_fallback: "Fallback tier, LLM classifier failed",
|
||||
default_model_fallback: "Default model, LLM classifier failed",
|
||||
capability_classifier: "Capability classifier",
|
||||
capability_cache: "Cached capability decision",
|
||||
capability_fallback: "Capability fallback",
|
||||
};
|
||||
|
||||
function describeCause(decision: RoutingDecision): string {
|
||||
|
|
@ -171,6 +181,12 @@ export function RoutingDecisionCard({
|
|||
escalated,
|
||||
escalation_keyword: escalationKeyword,
|
||||
tier_boundaries: tierBoundaries,
|
||||
probability_threshold: probabilityThreshold,
|
||||
candidate_probabilities: candidateProbabilities,
|
||||
candidate_costs: candidateCosts,
|
||||
qualified_models: qualifiedModels,
|
||||
fallback_reason: fallbackReason,
|
||||
cached,
|
||||
} = decision;
|
||||
|
||||
// On an override row the score did not decide the tier, so showing it against a
|
||||
|
|
@ -218,6 +234,25 @@ export function RoutingDecisionCard({
|
|||
|
||||
{routedModel && <Row label="Routed to">{routedModel}</Row>}
|
||||
|
||||
{probabilityThreshold !== undefined && <Row label="Threshold">{Math.round(probabilityThreshold * 100)}%</Row>}
|
||||
|
||||
{candidateProbabilities && Object.keys(candidateProbabilities).length > 0 && (
|
||||
<Row label="Candidates">
|
||||
<span className="space-y-1">
|
||||
{Object.entries(candidateProbabilities).map(([model, probability]) => (
|
||||
<span key={model} className="block">
|
||||
{model}: {Math.round(probability * 100)}%
|
||||
{candidateCosts?.[model] !== undefined && ` · $${candidateCosts[model].toFixed(6)}`}
|
||||
{qualifiedModels?.includes(model) ? " · qualified" : ""}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{fallbackReason && <Row label="Fallback">{fallbackReason.replaceAll("_", " ")}</Row>}
|
||||
{cached !== undefined && <Row label="Cached">{cached ? "Yes" : "No"}</Row>}
|
||||
|
||||
{escalated !== undefined && <Row label="Escalated">{describeEscalation(escalated, escalationKeyword)}</Row>}
|
||||
|
||||
{signals && signals.length > 0 && (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue