feat(ui): configure capability routers

This commit is contained in:
moe-berri 2026-09-01 15:31:35 -07:00 • committed by Tin
parent 2e0442bdd2
commit 00fe75e83d
15 changed files with 861 additions and 225 deletions

View file

@ -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;

View file

@ -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);

View file

@ -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])),
};

View file

@ -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(

View file

@ -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;

View file

@ -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}
/>

View file

@ -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,
},
});
});
});

View file

@ -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;
/**

View file

@ -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 } : {}),

View file

@ -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);
});
});

View file

@ -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 ?? {}) },
};
};

View file

@ -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 } : {}),

View file

@ -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

View file

@ -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 };
}
};

View file

@ -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 && (