mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
feat(ui): configure capability and Fuse v2 classifiers
This commit is contained in:
parent
878716f806
commit
39b7812810
26 changed files with 2593 additions and 742 deletions
|
|
@ -104,8 +104,8 @@ export function AutoRoutersPanel({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Add Auto Router</DialogTitle>
|
||||
<DialogDescription>
|
||||
Routes each request to a model by classifying its complexity. Called like any other model, so clients keep
|
||||
using a single model name.
|
||||
Choose a classifier to route each request to a model. Called like any other model, so clients keep using a
|
||||
single model name.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AddAutoRouterTab
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ const dedupe = (models: string[]): string[] => Array.from(new Set(models));
|
|||
|
||||
const COMPLEXITY_TYPE_LABELS: Record<string, string> = {
|
||||
llm: "LLM Classifier",
|
||||
capability: "Capability",
|
||||
llm_v2: "Fuse v2",
|
||||
heuristic_first: "Heuristic first",
|
||||
hybrid: "Hybrid",
|
||||
custom: "Custom classifier",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import React, { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
const initial: ComplexityRouterConfigValue = {
|
||||
classifier_type: "llm",
|
||||
tiers: { SIMPLE: ["efficient"], MEDIUM: [], COMPLEX: [], REASONING: ["capable"] },
|
||||
};
|
||||
|
||||
function Form({ initialValue = initial }: { initialValue?: ComplexityRouterConfigValue }) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return (
|
||||
<AutoRouterClassifierTabs value={value} onChange={setValue}>
|
||||
<output aria-label="Classifier type">{value.classifier_type}</output>
|
||||
</AutoRouterClassifierTabs>
|
||||
);
|
||||
}
|
||||
|
||||
describe("AutoRouterClassifierTabs", () => {
|
||||
it.each(["heuristic", "heuristic_v2", "llm", "heuristic_first", "hybrid"] as const)(
|
||||
"groups %s under Complexity without resetting its configuration",
|
||||
(classifier_type) => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<AutoRouterClassifierTabs value={{ ...initial, classifier_type }} onChange={onChange}>
|
||||
Existing classifier settings
|
||||
</AutoRouterClassifierTabs>,
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Complexity" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel", { name: "Complexity" })).toHaveTextContent("Existing classifier settings");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Complexity" }));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["capability", "Capability"],
|
||||
["llm_v2", "Fuse v2"],
|
||||
] as const)("opens saved %s settings and switches back to local Complexity", (classifier_type, label) => {
|
||||
renderWithProviders(<Form initialValue={{ ...initial, classifier_type }} />);
|
||||
expect(screen.getByRole("tab", { name: label })).toHaveAttribute("aria-selected", "true");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Complexity" }));
|
||||
expect(screen.getByRole("tab", { name: "Complexity" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("status", { name: "Classifier type" })).toHaveTextContent("heuristic");
|
||||
});
|
||||
|
||||
it("keeps custom tiers editable under Complexity and explains why forecast tabs are disabled", () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<AutoRouterClassifierTabs
|
||||
value={{
|
||||
...initial,
|
||||
custom_tier_set: {
|
||||
tiers: [{ id: "review", name: "REVIEW", definition: "Code reviews", models: ["capable"] }],
|
||||
fallback_tier_id: "review",
|
||||
},
|
||||
}}
|
||||
onChange={onChange}
|
||||
>
|
||||
Custom tiers
|
||||
</AutoRouterClassifierTabs>,
|
||||
);
|
||||
expect(screen.getByRole("tabpanel", { name: "Complexity" })).toHaveTextContent("Custom tiers");
|
||||
for (const name of ["Capability", "Fuse v2"]) {
|
||||
const tab = screen.getByRole("tab", { name });
|
||||
expect(tab).toHaveAttribute("aria-disabled", "true");
|
||||
expect(tab).toHaveAccessibleDescription("Restore standard tiers to use Capability or Fuse v2.");
|
||||
fireEvent.click(tab);
|
||||
}
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Restore standard tiers to use Capability or Fuse v2.")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import React, { useId } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { effectiveClassifierType, type ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
import { transitionClassifierType } from "./classifier_type_transition";
|
||||
import { isForecastClassifier } from "./forecast_classifier_config";
|
||||
|
||||
interface AutoRouterClassifierTabsProps {
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ value, onChange, children }) => {
|
||||
const restrictionId = useId();
|
||||
const classifierType = effectiveClassifierType(value);
|
||||
const selected = isForecastClassifier(classifierType) ? classifierType : "complexity";
|
||||
const hasCustomTiers = Boolean(value.custom_tier_set);
|
||||
|
||||
const handleChange = (tab: unknown) => {
|
||||
if (tab === selected) return;
|
||||
if (tab === "complexity") {
|
||||
onChange(transitionClassifierType(value, isForecastClassifier(classifierType) ? "heuristic" : classifierType));
|
||||
} else if (!hasCustomTiers && (tab === "capability" || tab === "llm_v2")) {
|
||||
onChange(transitionClassifierType(value, tab));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={selected} onValueChange={handleChange}>
|
||||
<p className="text-sm font-medium">Classifier type</p>
|
||||
<TabsList aria-label="Classifier type" className="w-full">
|
||||
<TabsTrigger value="complexity">Complexity</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="capability"
|
||||
disabled={hasCustomTiers}
|
||||
aria-describedby={hasCustomTiers ? restrictionId : undefined}
|
||||
>
|
||||
Capability
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="llm_v2"
|
||||
disabled={hasCustomTiers}
|
||||
aria-describedby={hasCustomTiers ? restrictionId : undefined}
|
||||
>
|
||||
Fuse v2
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{hasCustomTiers && (
|
||||
<p id={restrictionId} className="text-sm text-muted-foreground">
|
||||
Restore standard tiers to use Capability or Fuse v2.
|
||||
</p>
|
||||
)}
|
||||
<TabsContent value={selected}>{children}</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoRouterClassifierTabs;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { transitionClassifierType } from "./classifier_type_transition";
|
||||
import { Info } from "lucide-react";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
|
|
@ -17,7 +18,6 @@ import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect";
|
|||
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
|
||||
import ClassifierVisionConfig from "./ClassifierVisionConfig";
|
||||
import type { ReasoningEffort } from "./complexity_router_tiers";
|
||||
import { nonReasoningTierFields } from "./nonReasoningTierFields";
|
||||
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
|
||||
import {
|
||||
ClassificationFrequency,
|
||||
|
|
@ -33,12 +33,10 @@ import {
|
|||
DEFAULT_CLASSIFIER_FALLBACK,
|
||||
DEFAULT_CLASSIFIER_TIMEOUT_MS,
|
||||
DEFAULT_CLASSIFICATION_RUBRIC,
|
||||
NEW_CLASSIFIER_CLASSIFICATION_RUBRIC,
|
||||
ClassificationRubric,
|
||||
effectiveTierLabel,
|
||||
heuristicScoringRole,
|
||||
usesLlmClassifier,
|
||||
DEFAULT_HEURISTIC_FIRST_MAX_TIER,
|
||||
DEFAULT_HYBRID_BOUNDARY_MARGIN,
|
||||
HEURISTIC_FIRST_MAX_TIER_KEYS,
|
||||
effectiveClassifierType,
|
||||
|
|
@ -263,35 +261,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
const explicitlySupportedClassifierEfforts = effortOptionsByModel[classifierModel];
|
||||
|
||||
const handleClassifierTypeChange = (classifierType: ClassifierType) => {
|
||||
const nextValue: ComplexityRouterConfigValue = {
|
||||
...value,
|
||||
classifier_type: classifierType,
|
||||
classifier_llm_config: usesLlmClassifier(classifierType)
|
||||
? value.classifier_llm_config ?? {
|
||||
model: "",
|
||||
timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS,
|
||||
classification_rubric: NEW_CLASSIFIER_CLASSIFICATION_RUBRIC,
|
||||
}
|
||||
: undefined,
|
||||
classifier_context_window_size: usesLlmClassifier(classifierType)
|
||||
? value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE
|
||||
: undefined,
|
||||
classifier_context_budget_chars: usesLlmClassifier(classifierType)
|
||||
? value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS
|
||||
: undefined,
|
||||
classifier_context_include_assistant_turns: usesLlmClassifier(classifierType)
|
||||
? value.classifier_context_include_assistant_turns
|
||||
: undefined,
|
||||
classifier_fallback: usesLlmClassifier(classifierType) ? value.classifier_fallback : undefined,
|
||||
heuristic_first_max_tier:
|
||||
classifierType === "heuristic_first"
|
||||
? value.heuristic_first_max_tier ?? DEFAULT_HEURISTIC_FIRST_MAX_TIER
|
||||
: undefined,
|
||||
hybrid_boundary_margin:
|
||||
classifierType === "hybrid" ? value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN : undefined,
|
||||
...nonReasoningTierFields(classifierType, value),
|
||||
};
|
||||
onChange(nextValue);
|
||||
onChange(transitionClassifierType(value, classifierType));
|
||||
};
|
||||
|
||||
const handleHeuristicFirstMaxTierChange = (tier: string) => {
|
||||
|
|
@ -433,27 +403,6 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
if (classifierType === "capability") {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This router uses capability forecasting. Configure its classifier, threshold, and calibration through YAML or
|
||||
the API. Saving preserves those settings
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (classifierType === "llm_v2") {
|
||||
return (
|
||||
<div className="rounded-md border p-4 text-sm">
|
||||
<strong>LLM V2 classifier (experimental)</strong>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Combines task demands and model capability in one forecast. Its solver profiles and quality allowance are
|
||||
configured through the API. Saving this router preserves those settings
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClassifierTypeRadios value={value} classifierType={classifierType} onTypeChange={handleClassifierTypeChange} />
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import RoutingOptions from "./RoutingOptions";
|
||||
import PlanModeOverrideControls from "./PlanModeOverrideControls";
|
||||
import ForecastClassifierConfig, { ForecastSolverModels } from "./ForecastClassifierConfig";
|
||||
import { isForecastClassifier, type CapabilitySettings, type FuseSettings } from "./forecast_classifier_config";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import DefaultModelField from "./DefaultModelField";
|
||||
import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
import { AffinityControls } from "./AffinityControls";
|
||||
import NonReasoningTierToggle from "./NonReasoningTierToggle";
|
||||
|
|
@ -204,11 +207,6 @@ const rowOrigin = (row: TierRow, editing: boolean): string => {
|
|||
return isBuiltInTierName(row.name) ? "built-in" : "custom";
|
||||
};
|
||||
|
||||
const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => {
|
||||
if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`;
|
||||
return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier";
|
||||
};
|
||||
|
||||
const builtInTierInfo = (rowId: string): { label: string; description: string; examples: string } | undefined => {
|
||||
const builtIn = ALL_BUILT_IN_TIERS.find((tier) => tier === rowId);
|
||||
return builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined;
|
||||
|
|
@ -376,6 +374,8 @@ export interface ComplexityRouterConfigValue {
|
|||
/** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */
|
||||
default_model?: string;
|
||||
classifier_type: ClassifierType;
|
||||
capability_classifier_config?: CapabilitySettings;
|
||||
llm_v2_config?: FuseSettings;
|
||||
classifier_llm_config?: ClassifierLLMConfig;
|
||||
classifier_context_window_size?: number;
|
||||
classifier_context_budget_chars?: number;
|
||||
|
|
@ -535,44 +535,6 @@ export const DEFAULT_HYBRID_BOUNDARY_MARGIN = 0.03;
|
|||
*/
|
||||
export const HEURISTIC_FIRST_MAX_TIER_KEYS = TIER_ORDER.slice(0, -1);
|
||||
|
||||
const PlanModeOverrideControls: React.FC<{
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
planModeTierOptions: { value: string; label: string }[];
|
||||
}> = ({ value, onChange, planModeTierOptions }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.plan_mode_min_tier !== undefined}
|
||||
disabled={planModeTierOptions.length === 0}
|
||||
onCheckedChange={(enabled) =>
|
||||
onChange({
|
||||
...value,
|
||||
plan_mode_min_tier: enabled ? planModeTierOptions.at(-1)?.value : undefined,
|
||||
})
|
||||
}
|
||||
aria-label="Route plan-mode requests to a minimum tier"
|
||||
/>
|
||||
<strong className="font-semibold">Route plan-mode requests to a minimum tier</strong>
|
||||
</div>
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier. The classifier
|
||||
still wins when it picks higher, and the override only lasts while plan mode is active.
|
||||
{planModeTierOptions.length === 0 && " Add models to a tier to enable this."}
|
||||
</span>
|
||||
{value.plan_mode_min_tier !== undefined && (
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<TierRowSelect
|
||||
label="Plan-mode minimum tier"
|
||||
options={planModeTierOptions}
|
||||
value={value.plan_mode_min_tier ?? null}
|
||||
onValueChange={(tier) => onChange({ ...value, plan_mode_min_tier: tier })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
||||
modelInfo,
|
||||
value,
|
||||
|
|
@ -596,6 +558,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
onAutoRouterCompressionChange,
|
||||
showValidationErrors = false,
|
||||
}) => {
|
||||
const forecast = isForecastClassifier(value.classifier_type);
|
||||
const customTierSet = value.custom_tier_set;
|
||||
const tierRows = activeTierRows(value);
|
||||
const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null;
|
||||
|
|
@ -605,8 +568,6 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
value: row.id,
|
||||
label: tierRowLabel(row, value.tier_labels),
|
||||
}));
|
||||
const derivedDefaultModel = resolveComplexityDefaultModel(value);
|
||||
const defaultModelPlaceholder = defaultModelPlaceholderFor(derivedDefaultModel, Boolean(customTierSet));
|
||||
const defaultModel = resolveComplexityDefaultModel(value, value.default_model);
|
||||
|
||||
const dispatch = (action: TierSetAction) => {
|
||||
|
|
@ -641,298 +602,321 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
tier_model_params: setTierModelParam(value.tier_model_params, tier, model, change),
|
||||
});
|
||||
|
||||
// Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as
|
||||
// "track the tiers" everywhere downstream instead of as a blank model name.
|
||||
const handleDefaultModelChange = (model: string | null | undefined) => {
|
||||
onChange({ ...value, default_model: model || undefined });
|
||||
};
|
||||
|
||||
const handleTierLabelChange = (tier: keyof ComplexityTiers, label: string) => {
|
||||
onChange({
|
||||
...value,
|
||||
tier_labels: { ...value.tier_labels, [tier]: label },
|
||||
});
|
||||
};
|
||||
const handleTierLabelChange = (tier: keyof ComplexityTiers, label: string) =>
|
||||
onChange({ ...value, tier_labels: { ...value.tier_labels, [tier]: label } });
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-none">
|
||||
<div className="inline-flex items-center gap-2 mb-4">
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">Complexity Tier Configuration</h4>
|
||||
<SimpleTooltip content="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">
|
||||
{forecast ? "Solver models" : "Complexity Tier Configuration"}
|
||||
</h4>
|
||||
{!forecast && (
|
||||
<SimpleTooltip content="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TierConfigIntro value={value} />
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
{!customTierSet && (
|
||||
<NonReasoningTierToggle value={value} onChange={onChange} available={value.classifier_type === "llm"} />
|
||||
)}
|
||||
|
||||
{tierRows.map((row, index) => {
|
||||
const tierInfo = builtInTierInfo(row.id);
|
||||
const label = tierRowLabel(row, value.tier_labels);
|
||||
const tierMissing = showValidationErrors && row.models.length === 0;
|
||||
const needsDefinition = Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name);
|
||||
const definitionMissing = showValidationErrors && needsDefinition;
|
||||
const showsDisplayName = !customTierSet && !editingTiers;
|
||||
return (
|
||||
<div key={row.id}>
|
||||
{index > 0 && <Separator className="my-4" />}
|
||||
<div className="mb-4">
|
||||
<TierRowHeader
|
||||
row={row}
|
||||
index={index}
|
||||
rowCount={tierRows.length}
|
||||
label={label}
|
||||
description={tierInfo?.description}
|
||||
editing={editingTiers}
|
||||
isCustomSet={Boolean(customTierSet)}
|
||||
onRemove={() => removeTierRow(row.id)}
|
||||
/>
|
||||
{tierInfo && !customTierSet && (
|
||||
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
|
||||
)}
|
||||
{editingTiers && (
|
||||
<TierRowEditFields
|
||||
row={row}
|
||||
index={index}
|
||||
definitionMissing={definitionMissing}
|
||||
onPatch={(patch) => updateTierRow(row.id, patch)}
|
||||
/>
|
||||
)}
|
||||
{showsDisplayName && tierInfo && (
|
||||
<InputGroup className="mb-2">
|
||||
<InputGroupInput
|
||||
value={value.tier_labels?.[row.id as keyof ComplexityTiers] ?? ""}
|
||||
onChange={(event) => handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)}
|
||||
placeholder={`Display name (default: ${tierInfo.label})`}
|
||||
aria-label={`Display name for the ${tierInfo.label} tier`}
|
||||
/>
|
||||
{value.tier_labels?.[row.id as keyof ComplexityTiers] && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Clear display name for the ${tierInfo.label} tier`}
|
||||
onClick={() => handleTierLabelChange(row.id as keyof ComplexityTiers, "")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
)}
|
||||
<MultiSelect
|
||||
options={modelOptions}
|
||||
value={row.models}
|
||||
onValueChange={(models: string[]) => setRowModels(row, models)}
|
||||
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
|
||||
emptyText="No models found"
|
||||
className={tierMissing ? "w-full border-destructive" : "w-full"}
|
||||
/>
|
||||
<TierModelEffortRows
|
||||
tierLabel={label}
|
||||
models={row.models}
|
||||
effortOptionsByModel={tierEffortOptionsByModel}
|
||||
paramsByModel={row.params}
|
||||
fastModeByModel={fastModeByModel}
|
||||
onEffortChange={(model, effort) =>
|
||||
handleTierModelParamChange(row.id, model, ["reasoning_effort", effort])
|
||||
}
|
||||
onFastModeChange={(model, enabled) =>
|
||||
handleTierModelParamChange(row.id, model, ["speed", enabled ? "fast" : undefined])
|
||||
}
|
||||
/>
|
||||
{row.models.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Multiple models selected: the router randomly picks among them per request (or Thompson-samples
|
||||
within the pool when adaptive routing is on).
|
||||
</span>
|
||||
)}
|
||||
{tierMissing && <span className="text-xs text-destructive">The {label} tier is required</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<TierSetToolbar
|
||||
editing={editingTiers}
|
||||
isCustomSet={Boolean(customTierSet)}
|
||||
rowCount={tierRows.length}
|
||||
rowsError={tierRowsError}
|
||||
keywordRulesError={keywordRulesError}
|
||||
onEditingChange={onEditingTiersChange}
|
||||
onAdd={addCustomTier}
|
||||
onRestore={exitToBuiltInTiers}
|
||||
{forecast ? (
|
||||
<>
|
||||
<ForecastSolverModels
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
modelOptions={modelOptions}
|
||||
effortOptionsByModel={tierEffortOptionsByModel}
|
||||
fastModeByModel={fastModeByModel}
|
||||
/>
|
||||
<ForecastClassifierConfig
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
modelOptions={modelOptions}
|
||||
effortOptionsByModel={classifierEffortOptionsByModel}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TierConfigIntro value={value} />
|
||||
|
||||
{customTierSet && (
|
||||
<FallbackTierField
|
||||
rows={tierRows}
|
||||
fallbackTierId={customTierSet.fallback_tier_id}
|
||||
onValueChange={(fallbackTierId) => onChange(setFallbackTier(value, fallbackTierId))}
|
||||
/>
|
||||
)}
|
||||
<Card>
|
||||
<CardContent>
|
||||
{!customTierSet && (
|
||||
<NonReasoningTierToggle value={value} onChange={onChange} available={value.classifier_type === "llm"} />
|
||||
)}
|
||||
|
||||
<Separator className="my-4" />
|
||||
{tierRows.map((row, index) => {
|
||||
const tierInfo = builtInTierInfo(row.id);
|
||||
const label = tierRowLabel(row, value.tier_labels);
|
||||
const tierMissing = showValidationErrors && row.models.length === 0;
|
||||
const needsDefinition =
|
||||
Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name);
|
||||
const definitionMissing = showValidationErrors && needsDefinition;
|
||||
const showsDisplayName = !customTierSet && !editingTiers;
|
||||
return (
|
||||
<div key={row.id}>
|
||||
{index > 0 && <Separator className="my-4" />}
|
||||
<div className="mb-4">
|
||||
<TierRowHeader
|
||||
row={row}
|
||||
index={index}
|
||||
rowCount={tierRows.length}
|
||||
label={label}
|
||||
description={tierInfo?.description}
|
||||
editing={editingTiers}
|
||||
isCustomSet={Boolean(customTierSet)}
|
||||
onRemove={() => removeTierRow(row.id)}
|
||||
/>
|
||||
{tierInfo && !customTierSet && (
|
||||
<span className="block mb-2 text-xs text-muted-foreground">Examples: {tierInfo.examples}</span>
|
||||
)}
|
||||
{editingTiers && (
|
||||
<TierRowEditFields
|
||||
row={row}
|
||||
index={index}
|
||||
definitionMissing={definitionMissing}
|
||||
onPatch={(patch) => updateTierRow(row.id, patch)}
|
||||
/>
|
||||
)}
|
||||
{showsDisplayName && tierInfo && (
|
||||
<InputGroup className="mb-2">
|
||||
<InputGroupInput
|
||||
value={value.tier_labels?.[row.id as keyof ComplexityTiers] ?? ""}
|
||||
onChange={(event) =>
|
||||
handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)
|
||||
}
|
||||
placeholder={`Display name (default: ${tierInfo.label})`}
|
||||
aria-label={`Display name for the ${tierInfo.label} tier`}
|
||||
/>
|
||||
{value.tier_labels?.[row.id as keyof ComplexityTiers] && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Clear display name for the ${tierInfo.label} tier`}
|
||||
onClick={() => handleTierLabelChange(row.id as keyof ComplexityTiers, "")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
)}
|
||||
<MultiSelect
|
||||
options={modelOptions}
|
||||
value={row.models}
|
||||
onValueChange={(models: string[]) => setRowModels(row, models)}
|
||||
placeholder={`Select model(s) for ${label.toLowerCase()} queries`}
|
||||
emptyText="No models found"
|
||||
className={tierMissing ? "w-full border-destructive" : "w-full"}
|
||||
/>
|
||||
<TierModelEffortRows
|
||||
tierLabel={label}
|
||||
models={row.models}
|
||||
effortOptionsByModel={tierEffortOptionsByModel}
|
||||
paramsByModel={row.params}
|
||||
fastModeByModel={fastModeByModel}
|
||||
onEffortChange={(model, effort) =>
|
||||
handleTierModelParamChange(row.id, model, ["reasoning_effort", effort])
|
||||
}
|
||||
onFastModeChange={(model, enabled) =>
|
||||
handleTierModelParamChange(row.id, model, ["speed", enabled ? "fast" : undefined])
|
||||
}
|
||||
/>
|
||||
{row.models.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Multiple models selected: the router randomly picks among them per request (or
|
||||
Thompson-samples within the pool when adaptive routing is on).
|
||||
</span>
|
||||
)}
|
||||
{tierMissing && <span className="text-xs text-destructive">The {label} tier is required</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">Default Model</strong>
|
||||
<SimpleTooltip content="Leave empty to follow the tiers. A model chosen here is pinned: it stays the default however the tiers change.">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<SearchSelect
|
||||
options={modelOptions}
|
||||
value={value.default_model ?? ""}
|
||||
onValueChange={handleDefaultModelChange}
|
||||
placeholder={defaultModelPlaceholder}
|
||||
emptyText="No models found"
|
||||
aria-label="Default model"
|
||||
/>
|
||||
<span className="block mt-1 text-xs text-muted-foreground">
|
||||
Used when the tier the request lands in has no model, and when the classifier fails with "Route to
|
||||
the default model" selected.
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TierSetToolbar
|
||||
editing={editingTiers}
|
||||
isCustomSet={Boolean(customTierSet)}
|
||||
rowCount={tierRows.length}
|
||||
rowsError={tierRowsError}
|
||||
keywordRulesError={keywordRulesError}
|
||||
onEditingChange={onEditingTiersChange}
|
||||
onAdd={addCustomTier}
|
||||
onRestore={exitToBuiltInTiers}
|
||||
/>
|
||||
|
||||
{customTierSet && (
|
||||
<FallbackTierField
|
||||
rows={tierRows}
|
||||
fallbackTierId={customTierSet.fallback_tier_id}
|
||||
onValueChange={(fallbackTierId) => onChange(setFallbackTier(value, fallbackTierId))}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{!forecast && <DefaultModelField value={value} onChange={onChange} modelOptions={modelOptions} />}
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted">
|
||||
{[
|
||||
{
|
||||
key: "classifier",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Classification Method</strong>,
|
||||
children: (
|
||||
<ClassificationMethodConfig
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
modelOptions={modelOptions}
|
||||
effortOptionsByModel={classifierEffortOptionsByModel}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange}
|
||||
showValidationErrors={showValidationErrors}
|
||||
defaultModel={defaultModel}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "adaptive",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Adaptive Routing</strong>,
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "adaptive")}>
|
||||
<AdaptiveRoutingConfig value={value} onChange={onChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "affinity",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Affinity</strong>,
|
||||
children: <AffinityControls value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "modality",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Modality Routing</strong>,
|
||||
children: <ModalityRoutingControls value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "plan-mode",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Plan-Mode Override</strong>,
|
||||
children: (
|
||||
<PlanModeOverrideControls value={value} onChange={onChange} planModeTierOptions={planModeTierOptions} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "context-window",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Context Window Escalation</strong>,
|
||||
children: <ContextWindowEscalationConfig value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "stall-escalation",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Stalled Task Escalation</strong>,
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "stallEscalation")}>
|
||||
<StallEscalationConfig value={value} onChange={onChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "response",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Response Format</strong>,
|
||||
children: <ResponseFormatControls value={value} onChange={onChange} />,
|
||||
},
|
||||
...(onEscalationKeywordsChange
|
||||
? [
|
||||
{
|
||||
key: "escalation",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Escalation Keywords</strong>,
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "escalation")}>
|
||||
<EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onAutoRouterCompressionChange
|
||||
? [
|
||||
{
|
||||
key: "compression",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Compression</strong>,
|
||||
children: (
|
||||
<CompressionControls value={autoRouterCompression} onChange={onAutoRouterCompressionChange} />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onKeywordTierRulesChange || onSemanticMatchingEnabledChange
|
||||
? [
|
||||
{
|
||||
key: "keyword-semantic",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Keyword/Semantic Matching</strong>,
|
||||
children: (
|
||||
<>
|
||||
{onKeywordTierRulesChange && (
|
||||
<KeywordTierRules
|
||||
rules={keywordTierRules}
|
||||
onChange={onKeywordTierRulesChange}
|
||||
tierLabels={value.tier_labels}
|
||||
tierNames={customTierSet && tierRows.map(activeTierName).filter(Boolean)}
|
||||
/>
|
||||
)}
|
||||
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}
|
||||
{onSemanticMatchingEnabledChange && (
|
||||
<SemanticKeywordMatching
|
||||
enabled={semanticMatchingEnabled}
|
||||
onEnabledChange={onSemanticMatchingEnabledChange}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={onEmbeddingModelChange}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={onMatchThresholdChange}
|
||||
modelInfo={modelInfo}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].map(({ key, label, children }) => (
|
||||
<Collapsible key={key} className="border-b border-border last:border-b-0">
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left">
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
|
||||
{label}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-4">{children}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
<RoutingOptions forecast={forecast}>
|
||||
{forecast && (
|
||||
<>
|
||||
<DefaultModelField value={value} onChange={onChange} modelOptions={modelOptions} />
|
||||
<ForecastSolverModels
|
||||
additionalPoolsOnly
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
modelOptions={modelOptions}
|
||||
effortOptionsByModel={tierEffortOptionsByModel}
|
||||
fastModeByModel={fastModeByModel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="rounded-lg border border-border bg-muted">
|
||||
{[
|
||||
...(!forecast
|
||||
? [
|
||||
{
|
||||
key: "classifier",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Classification Method</strong>,
|
||||
children: (
|
||||
<ClassificationMethodConfig
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
modelOptions={modelOptions}
|
||||
effortOptionsByModel={classifierEffortOptionsByModel}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange}
|
||||
showValidationErrors={showValidationErrors}
|
||||
defaultModel={defaultModel}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(value.classifier_type !== "llm_v2"
|
||||
? [
|
||||
{
|
||||
key: "adaptive",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Adaptive Routing</strong>,
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "adaptive")}>
|
||||
<AdaptiveRoutingConfig value={value} onChange={onChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "affinity",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Affinity</strong>,
|
||||
children: <AffinityControls value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "modality",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Modality Routing</strong>,
|
||||
children: <ModalityRoutingControls value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "plan-mode",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Plan-Mode Override</strong>,
|
||||
children: (
|
||||
<PlanModeOverrideControls value={value} onChange={onChange} planModeTierOptions={planModeTierOptions} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "context-window",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Context Window Escalation</strong>,
|
||||
children: <ContextWindowEscalationConfig value={value} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
key: "stall-escalation",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Stalled Task Escalation</strong>,
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "stallEscalation")}>
|
||||
<StallEscalationConfig value={value} onChange={onChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "response",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Response Format</strong>,
|
||||
children: <ResponseFormatControls value={value} onChange={onChange} />,
|
||||
},
|
||||
...(onEscalationKeywordsChange
|
||||
? [
|
||||
{
|
||||
key: "escalation",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Escalation Keywords</strong>,
|
||||
children: (
|
||||
<Restricted by={restrictedBy(value, "escalation")}>
|
||||
<EscalationKeywords keywords={escalationKeywords} onChange={onEscalationKeywordsChange} />
|
||||
</Restricted>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onAutoRouterCompressionChange
|
||||
? [
|
||||
{
|
||||
key: "compression",
|
||||
label: <strong className="text-foreground font-semibold">Advanced: Compression</strong>,
|
||||
children: (
|
||||
<CompressionControls value={autoRouterCompression} onChange={onAutoRouterCompressionChange} />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onKeywordTierRulesChange || onSemanticMatchingEnabledChange
|
||||
? [
|
||||
{
|
||||
key: "keyword-semantic",
|
||||
label: (
|
||||
<strong className="text-foreground font-semibold">Advanced: Keyword/Semantic Matching</strong>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{onKeywordTierRulesChange && (
|
||||
<KeywordTierRules
|
||||
rules={keywordTierRules}
|
||||
onChange={onKeywordTierRulesChange}
|
||||
tierLabels={value.tier_labels}
|
||||
tierNames={
|
||||
customTierSet || isForecastClassifier(value.classifier_type)
|
||||
? tierRows.map(activeTierName).filter(Boolean)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{onKeywordTierRulesChange && onSemanticMatchingEnabledChange && <Separator className="my-4" />}
|
||||
{onSemanticMatchingEnabledChange && (
|
||||
<SemanticKeywordMatching
|
||||
enabled={semanticMatchingEnabled}
|
||||
onEnabledChange={onSemanticMatchingEnabledChange}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={onEmbeddingModelChange}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={onMatchThresholdChange}
|
||||
modelInfo={modelInfo}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].map(({ key, label, children }) => (
|
||||
<Collapsible key={key} className="border-b border-border last:border-b-0">
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left">
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
|
||||
{label}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-4">{children}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</RoutingOptions>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import { renderWithProviders, screen, within } from "../../../tests/test-utils";
|
||||
import {
|
||||
buildUpdatedComplexityRouterConfig,
|
||||
hydrateComplexityRouterConfig,
|
||||
} from "../edit_auto_router/edit_auto_router_modal";
|
||||
import type { KeywordTierRule } from "./KeywordTierRules";
|
||||
import type { ModelGroup } from "../llm_calls/fetch_models";
|
||||
import ComplexityRouterConfig, { type ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
|
|
@ -50,8 +51,8 @@ it.each([false, true])("edits and round-trips independent model settings with cu
|
|||
const view = renderWithProviders(editor(initial));
|
||||
const fast = () => screen.getByRole("switch", { name: `Fast mode for primary in the ${label} tier` });
|
||||
|
||||
expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(3);
|
||||
expect(screen.queryByRole("switch", { name: /^Fast mode for (blocked|missing)/ })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(4);
|
||||
expect(screen.queryByRole("switch", { name: /^Fast mode for missing/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("combobox", { name: /^Reasoning effort for secondary/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: `Fast mode for secondary in the ${label} tier` })).toBeChecked();
|
||||
expect(fast()).not.toBeChecked();
|
||||
|
|
@ -125,19 +126,170 @@ it.each([false, true])("edits and round-trips independent model settings with cu
|
|||
);
|
||||
});
|
||||
|
||||
describe("Fast mode metadata", () => {
|
||||
it("offers nothing before model capabilities load and leaves stored speed untouched", () => {
|
||||
const value: ComplexityRouterConfigValue = {
|
||||
tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifier_type: "heuristic",
|
||||
tier_model_params: { SIMPLE: { primary: { speed: "fast" } } },
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<ComplexityRouterConfig modelInfo={[]} value={value} onChange={onChange} />);
|
||||
expect(screen.queryByRole("switch", { name: /^Fast mode for/ })).not.toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(buildUpdatedComplexityRouterConfig({}, value).tier_model_configs).toEqual({
|
||||
SIMPLE: [{ model_name: "primary", litellm_params: { speed: "fast" } }],
|
||||
});
|
||||
it.each(["capability", "llm_v2"] as const)("preserves Fast mode controls for %s solvers", async (classifierType) => {
|
||||
const user = userEvent.setup();
|
||||
const initial: ComplexityRouterConfigValue = {
|
||||
classifier_type: classifierType,
|
||||
classifier_llm_config: { model: "primary", timeout_ms: 3000 },
|
||||
tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["blocked"] },
|
||||
capability_classifier_config: { efficient_tier: "SIMPLE", capable_tier: "REASONING", base_threshold: 0.7 },
|
||||
llm_v2_config: {
|
||||
efficient_profile: "Small solver",
|
||||
capable_profile: "Large solver",
|
||||
harness: "One attempt",
|
||||
max_quality_gap: 0.05,
|
||||
},
|
||||
tier_model_params: { SIMPLE: { primary: { reasoning_effort: "high", max_tokens: 1024 } } },
|
||||
};
|
||||
const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>();
|
||||
const editor = (value: ComplexityRouterConfigValue) => (
|
||||
<ComplexityRouterConfig modelInfo={modelInfo} value={value} onChange={onChange} />
|
||||
);
|
||||
const view = renderWithProviders(editor(initial));
|
||||
const fast = () => screen.getByRole("switch", { name: "Fast mode for primary in the Efficient solver tier" });
|
||||
expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(1);
|
||||
expect(fast()).not.toBeChecked();
|
||||
await user.click(fast());
|
||||
const enabled = onChange.mock.lastCall![0];
|
||||
expect(enabled.tier_model_params?.SIMPLE.primary).toEqual({
|
||||
reasoning_effort: "high",
|
||||
max_tokens: 1024,
|
||||
speed: "fast",
|
||||
});
|
||||
const saved = buildUpdatedComplexityRouterConfig({}, enabled);
|
||||
expect(saved.tier_model_configs).toEqual({
|
||||
SIMPLE: [{ model_name: "primary", litellm_params: { reasoning_effort: "high", max_tokens: 1024, speed: "fast" } }],
|
||||
});
|
||||
view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined)));
|
||||
expect(fast()).toBeChecked();
|
||||
await user.click(fast());
|
||||
expect(onChange.mock.lastCall![0].tier_model_params?.SIMPLE.primary).toEqual({
|
||||
reasoning_effort: "high",
|
||||
max_tokens: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fast mode metadata", () => {
|
||||
it.each(["heuristic", "capability", "llm_v2"] as const)(
|
||||
"can clear stored Fast mode without current capability metadata for %s",
|
||||
async (classifier_type) => {
|
||||
const user = userEvent.setup();
|
||||
const value: ComplexityRouterConfigValue = {
|
||||
tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["secondary"] },
|
||||
classifier_type,
|
||||
tier_model_params: { SIMPLE: { primary: { speed: "fast", max_tokens: 512 } } },
|
||||
};
|
||||
const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>();
|
||||
const editor = (current: ComplexityRouterConfigValue, info: ModelGroup[]) => (
|
||||
<ComplexityRouterConfig modelInfo={info} value={current} onChange={onChange} />
|
||||
);
|
||||
const view = renderWithProviders(editor(value, []));
|
||||
const fast = () => screen.getByRole("switch", { name: /^Fast mode for primary/ });
|
||||
expect(fast()).toBeChecked();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
view.rerender(editor(value, [{ model_group: "primary", supports_fast_mode: false }]));
|
||||
expect(fast()).toBeChecked();
|
||||
await user.click(fast());
|
||||
const cleared = onChange.mock.lastCall![0];
|
||||
expect(cleared.tier_model_params?.SIMPLE.primary).toEqual({ max_tokens: 512 });
|
||||
const saved = buildUpdatedComplexityRouterConfig({}, cleared);
|
||||
expect(saved.tier_model_configs).toEqual({
|
||||
SIMPLE: [{ model_name: "primary", litellm_params: { max_tokens: 512 } }],
|
||||
});
|
||||
view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined), []));
|
||||
expect(screen.queryByRole("switch", { name: /^Fast mode for primary/ })).not.toBeInTheDocument();
|
||||
view.rerender(editor(cleared, modelInfo));
|
||||
expect(fast()).not.toBeChecked();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["MEDIUM", "REASONING"])("clears a legacy Capability pool while reconciling plan floor %s", async (floor) => {
|
||||
const user = userEvent.setup();
|
||||
const stored = {
|
||||
classifier_type: "capability" as const,
|
||||
plan_mode_min_tier: floor,
|
||||
tiers: { SIMPLE: ["primary"], MEDIUM: ["secondary"], COMPLEX: [], REASONING: ["blocked"] },
|
||||
tier_model_configs: { MEDIUM: [{ model_name: "secondary", litellm_params: { speed: "fast" } }] },
|
||||
};
|
||||
const value = hydrateComplexityRouterConfig(stored, undefined);
|
||||
const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>();
|
||||
renderWithProviders(<ComplexityRouterConfig modelInfo={modelInfo} value={value} onChange={onChange} />);
|
||||
await user.click(screen.getByRole("button", { name: "Advanced routing options" }));
|
||||
expect(screen.getByRole("switch", { name: "Fast mode for secondary in the Medium routing pool tier" })).toBeChecked();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("combobox", { name: "Select medium routing pool models" }));
|
||||
await user.click(await screen.findByRole("option", { name: "secondary" }));
|
||||
await user.keyboard("{Escape}");
|
||||
const cleared = onChange.mock.lastCall![0];
|
||||
expect(cleared.tiers.MEDIUM).toEqual([]);
|
||||
expect(cleared.plan_mode_min_tier).toBe(floor === "MEDIUM" ? undefined : floor);
|
||||
expect(cleared.tier_model_params).toBeUndefined();
|
||||
expect(buildUpdatedComplexityRouterConfig(stored, cleared).tiers).toEqual({
|
||||
SIMPLE: ["primary"],
|
||||
REASONING: ["blocked"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["capability", "llm_v2"] as const)(
|
||||
"shows and clears a persisted default model in %s",
|
||||
async (classifier_type) => {
|
||||
const user = userEvent.setup();
|
||||
const stored = {
|
||||
classifier_type,
|
||||
default_model: "legacy-default",
|
||||
tiers: { SIMPLE: ["primary"], REASONING: ["secondary"] },
|
||||
};
|
||||
const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>();
|
||||
const editor = (value: ComplexityRouterConfigValue) => (
|
||||
<ComplexityRouterConfig modelInfo={modelInfo} value={value} onChange={onChange} />
|
||||
);
|
||||
const view = renderWithProviders(editor(hydrateComplexityRouterConfig(stored, undefined)));
|
||||
await user.click(screen.getByRole("button", { name: "Advanced routing options" }));
|
||||
const select = () => screen.getByRole("combobox", { name: "Default model" });
|
||||
expect(select()).toHaveValue("legacy-default");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
await user.click(select());
|
||||
await user.click(await screen.findByRole("option", { name: "blocked" }));
|
||||
const changed = onChange.mock.lastCall![0];
|
||||
expect(buildUpdatedComplexityRouterConfig(stored, changed).default_model).toBe("blocked");
|
||||
view.rerender(editor(changed));
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "Default model configuration" })).getByRole("button", { name: "Clear" }),
|
||||
);
|
||||
const cleared = onChange.mock.lastCall![0];
|
||||
expect(cleared.default_model).toBeUndefined();
|
||||
const saved = buildUpdatedComplexityRouterConfig(stored, cleared);
|
||||
expect(saved).not.toHaveProperty("default_model");
|
||||
view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined)));
|
||||
expect(select()).toHaveValue("");
|
||||
expect(select()).toHaveAttribute("placeholder", expect.stringContaining("primary"));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["capability", "llm_v2"] as const)("offers only populated keyword targets for %s", async (classifier_type) => {
|
||||
const user = userEvent.setup();
|
||||
const value: ComplexityRouterConfigValue = {
|
||||
classifier_type,
|
||||
tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["secondary"] },
|
||||
};
|
||||
const onRulesChange = vi.fn<(rules: KeywordTierRule[]) => void>();
|
||||
const editor = (rules: KeywordTierRule[]) => (
|
||||
<ComplexityRouterConfig
|
||||
value={value}
|
||||
onChange={vi.fn()}
|
||||
modelInfo={modelInfo}
|
||||
keywordTierRules={rules}
|
||||
onKeywordTierRulesChange={onRulesChange}
|
||||
/>
|
||||
);
|
||||
const view = renderWithProviders(editor([]));
|
||||
await user.click(screen.getByRole("button", { name: "Advanced routing options" }));
|
||||
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
|
||||
await user.click(screen.getByRole("button", { name: "Add keyword rule" }));
|
||||
const rules = onRulesChange.mock.lastCall![0];
|
||||
expect(rules[0].tier).toBe("SIMPLE");
|
||||
view.rerender(editor(rules));
|
||||
await user.click(screen.getByRole("combobox", { name: "Route keyword rule 1 to tier" }));
|
||||
expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual(["Simple", "Reasoning"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import React from "react";
|
||||
import { Info } from "lucide-react";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
import { isForecastClassifier } from "./forecast_classifier_config";
|
||||
import { resolveComplexityDefaultModel } from "./tier_rows";
|
||||
|
||||
interface DefaultModelFieldProps {
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
modelOptions: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => {
|
||||
if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`;
|
||||
return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier";
|
||||
};
|
||||
|
||||
const DefaultModelField = ({ value, onChange, modelOptions }: DefaultModelFieldProps) => {
|
||||
const defaultModelPlaceholder = defaultModelPlaceholderFor(
|
||||
resolveComplexityDefaultModel(value),
|
||||
Boolean(value.custom_tier_set),
|
||||
);
|
||||
// Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as
|
||||
// "track the tiers" everywhere downstream instead of as a blank model name.
|
||||
const handleDefaultModelChange = (model: string | null | undefined) => {
|
||||
onChange({ ...value, default_model: model || undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 mb-2" role="group" aria-label="Default model configuration">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<strong className="text-base font-semibold">Default Model</strong>
|
||||
<SimpleTooltip content="Leave empty to follow the tiers. A model chosen here is pinned: it stays the default however the tiers change.">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<SearchSelect
|
||||
options={modelOptions}
|
||||
value={value.default_model ?? ""}
|
||||
onValueChange={handleDefaultModelChange}
|
||||
placeholder={defaultModelPlaceholder}
|
||||
emptyText="No models found"
|
||||
aria-label="Default model"
|
||||
/>
|
||||
<span className="block mt-1 text-xs text-muted-foreground">
|
||||
{isForecastClassifier(value.classifier_type)
|
||||
? "Used when routing cannot find a suitable model. Classifier failures route to the capable solver."
|
||||
: 'Used when the tier the request lands in has no model, and when the classifier fails with "Route to the default model" selected.'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultModelField;
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
import React, { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import ClassificationMethodConfig from "./ClassificationMethodConfig";
|
||||
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
|
||||
import ForecastClassifierConfig from "./ForecastClassifierConfig";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
import { getForecastConfigError, isForecastClassifier } from "./forecast_classifier_config";
|
||||
import { buildUpdatedComplexityRouterConfig } from "../edit_auto_router/edit_auto_router_modal";
|
||||
|
||||
vi.mock("@/components/networking", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/components/networking")>()),
|
||||
getComplexityScorerDefaults: vi.fn(async () => ({
|
||||
tier_boundaries: {},
|
||||
token_thresholds: {},
|
||||
dimension_weights: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
const initial: ComplexityRouterConfigValue = {
|
||||
classifier_type: "capability",
|
||||
classifier_llm_config: { model: "judge", timeout_ms: 20000 },
|
||||
tiers: { SIMPLE: ["efficient"], MEDIUM: [], COMPLEX: [], REASONING: ["capable"] },
|
||||
capability_classifier_config: { efficient_tier: "SIMPLE", capable_tier: "REASONING", base_threshold: 0.7 },
|
||||
};
|
||||
const fuseInitial: ComplexityRouterConfigValue = {
|
||||
...initial,
|
||||
classifier_type: "llm_v2",
|
||||
capability_classifier_config: undefined,
|
||||
adaptive: false,
|
||||
llm_v2_config: {
|
||||
efficient_profile: "Small solver",
|
||||
capable_profile: "Larger solver",
|
||||
harness: "One attempt",
|
||||
max_quality_gap: 0.05,
|
||||
},
|
||||
};
|
||||
const options = ["judge", "efficient", "capable"].map((model) => ({ value: model, label: model }));
|
||||
|
||||
function Form({ initialValue = initial }: { initialValue?: ComplexityRouterConfigValue }) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [saved, setSaved] = useState("");
|
||||
return (
|
||||
<>
|
||||
<AutoRouterClassifierTabs value={value} onChange={setValue}>
|
||||
{isForecastClassifier(value.classifier_type) ? (
|
||||
<ForecastClassifierConfig
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
modelOptions={options}
|
||||
effortOptionsByModel={{}}
|
||||
/>
|
||||
) : (
|
||||
<ClassificationMethodConfig
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
modelOptions={options}
|
||||
effortOptionsByModel={{}}
|
||||
/>
|
||||
)}
|
||||
</AutoRouterClassifierTabs>
|
||||
<button
|
||||
disabled={Boolean(getForecastConfigError(value))}
|
||||
onClick={() => setSaved(JSON.stringify(buildUpdatedComplexityRouterConfig({}, value)))}
|
||||
>
|
||||
Save configuration
|
||||
</button>
|
||||
<output aria-label="Saved configuration">{saved}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("forecast classifier form", () => {
|
||||
it("switches a populated standard router to Capability without saving hidden pools or their overrides", () => {
|
||||
renderWithProviders(
|
||||
<Form
|
||||
initialValue={{
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "judge", timeout_ms: 20000, classification_rubric: "agentic" },
|
||||
adaptive: true,
|
||||
plan_mode_min_tier: "MEDIUM",
|
||||
tiers: {
|
||||
SIMPLE: ["efficient", "second-efficient"],
|
||||
MEDIUM: ["leftover-medium"],
|
||||
COMPLEX: ["leftover-complex"],
|
||||
REASONING: ["capable"],
|
||||
},
|
||||
tier_model_params: {
|
||||
SIMPLE: { efficient: { reasoning_effort: "low", speed: "fast", max_tokens: 1024 } },
|
||||
MEDIUM: { "leftover-medium": { speed: "fast" } },
|
||||
COMPLEX: { "leftover-complex": { max_tokens: 4096 } },
|
||||
REASONING: { capable: { reasoning_effort: "high" } },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Capability" }));
|
||||
fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.7" } });
|
||||
expect(screen.getByRole("button", { name: "Save configuration" })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save configuration" }));
|
||||
const output = screen.getByRole("status", { name: "Saved configuration" });
|
||||
expect(output).toHaveTextContent('"classifier_type":"capability"');
|
||||
expect(output).toHaveTextContent('"SIMPLE":["efficient","second-efficient"]');
|
||||
expect(output).toHaveTextContent('"REASONING":["capable"]');
|
||||
expect(output).toHaveTextContent('"reasoning_effort":"low","speed":"fast","max_tokens":1024');
|
||||
expect(output).toHaveTextContent('"reasoning_effort":"high"');
|
||||
expect(output).toHaveTextContent('"adaptive":true');
|
||||
expect(output).not.toHaveTextContent("leftover-medium");
|
||||
expect(output).not.toHaveTextContent("leftover-complex");
|
||||
expect(output).not.toHaveTextContent('"plan_mode_min_tier"');
|
||||
});
|
||||
|
||||
it.each(["capability", "llm_v2"] as const)(
|
||||
"carries non-default solver assignments when switching away from %s",
|
||||
(source) => {
|
||||
const pair = { efficient_tier: "MEDIUM", capable_tier: "COMPLEX" };
|
||||
const previous: ComplexityRouterConfigValue = {
|
||||
...(source === "capability" ? initial : fuseInitial),
|
||||
tiers: { SIMPLE: [], MEDIUM: ["efficient"], COMPLEX: ["capable"], REASONING: [] },
|
||||
capability_classifier_config:
|
||||
source === "capability" ? { ...initial.capability_classifier_config!, ...pair } : undefined,
|
||||
llm_v2_config: source === "llm_v2" ? { ...fuseInitial.llm_v2_config!, ...pair } : undefined,
|
||||
plan_mode_min_tier: "COMPLEX",
|
||||
tier_model_params: { MEDIUM: { efficient: { max_tokens: 128 } }, COMPLEX: { capable: { speed: "fast" } } },
|
||||
};
|
||||
renderWithProviders(<Form initialValue={previous} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: source === "capability" ? "Fuse v2" : "Capability" }));
|
||||
if (source === "capability") {
|
||||
fireEvent.change(screen.getByLabelText("Efficient solver profile"), { target: { value: "Small solver" } });
|
||||
fireEvent.change(screen.getByLabelText("Capable solver profile"), { target: { value: "Large solver" } });
|
||||
fireEvent.change(screen.getByLabelText("Harness and budget"), { target: { value: "One attempt" } });
|
||||
fireEvent.change(screen.getByLabelText("Maximum quality gap"), { target: { value: "0.05" } });
|
||||
} else {
|
||||
fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.7" } });
|
||||
}
|
||||
expect(screen.getByRole("button", { name: "Save configuration" })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save configuration" }));
|
||||
const output = screen.getByRole("status", { name: "Saved configuration" });
|
||||
expect(output).toHaveTextContent('"efficient_tier":"MEDIUM","capable_tier":"COMPLEX"');
|
||||
expect(output).toHaveTextContent('"tiers":{"MEDIUM":["efficient"],"COMPLEX":["capable"]}');
|
||||
expect(output).toHaveTextContent('"plan_mode_min_tier":"COMPLEX"');
|
||||
expect(output).toHaveTextContent('"max_tokens":128');
|
||||
expect(output).toHaveTextContent('"speed":"fast"');
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps decimal and negative numbers when entered one character at a time", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Form />);
|
||||
const threshold = screen.getByLabelText("Solve probability threshold");
|
||||
await user.clear(threshold);
|
||||
await user.type(threshold, "0.65");
|
||||
expect(threshold).toHaveValue(0.65);
|
||||
await user.click(screen.getByRole("button", { name: "Classifier options" }));
|
||||
await user.click(screen.getByRole("switch", { name: "Use fitted calibration" }));
|
||||
await user.type(screen.getByLabelText("Efficient intercept"), "-0.3");
|
||||
expect(screen.getByLabelText("Efficient intercept")).toHaveValue(-0.3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["capability", "LLM Classifier"],
|
||||
["capability", "Heuristic first"],
|
||||
["capability", "Hybrid"],
|
||||
["llm_v2", "LLM Classifier"],
|
||||
["llm_v2", "Heuristic first"],
|
||||
["llm_v2", "Hybrid"],
|
||||
] as const)("restores the current rubric when switching %s through Complexity to %s", async (source, target) => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Form initialValue={source === "capability" ? initial : fuseInitial} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Complexity" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: new RegExp(`^${target}`) }));
|
||||
await user.click(screen.getByRole("combobox", { name: "Classifier Model" }));
|
||||
await user.click(screen.getByRole("option", { name: "judge", exact: true }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save configuration" }));
|
||||
const output = screen.getByRole("status", { name: "Saved configuration" });
|
||||
expect(output).toHaveTextContent('"classification_rubric":"agentic"');
|
||||
expect(output).toHaveTextContent('"model":"judge"');
|
||||
expect(output).toHaveTextContent('"timeout_ms":3000');
|
||||
expect(output).not.toHaveTextContent('"capability_classifier_config"');
|
||||
expect(output).not.toHaveTextContent('"llm_v2_config"');
|
||||
});
|
||||
|
||||
it("saves capability threshold edits together with fitted calibration", () => {
|
||||
renderWithProviders(<Form />);
|
||||
fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.6" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Classifier options" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Use fitted calibration" }));
|
||||
expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText("Calibration version"), { target: { value: "eval-a" } });
|
||||
fireEvent.change(screen.getByLabelText("Efficient slope"), { target: { value: "1.2" } });
|
||||
fireEvent.change(screen.getByLabelText("Efficient intercept"), { target: { value: "-0.3" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save configuration" }));
|
||||
const output = screen.getByRole("status", { name: "Saved configuration" });
|
||||
expect(output).toHaveTextContent('"base_threshold":0.6');
|
||||
expect(output).toHaveTextContent('"calibration":{"version":"eval-a","slope":1.2,"intercept":-0.3}');
|
||||
fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "" } });
|
||||
expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("switches to Fuse, requires solver context, and saves the filled fields", () => {
|
||||
renderWithProviders(<Form />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Fuse v2" }));
|
||||
expect(screen.queryByLabelText("Solve probability threshold")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText("Efficient solver profile"), {
|
||||
target: { value: "Short reasoning budget" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Capable solver profile"), { target: { value: "Larger reasoning budget" } });
|
||||
fireEvent.change(screen.getByLabelText("Harness and budget"), {
|
||||
target: { value: "Shell and test runner, one attempt" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Maximum quality gap"), { target: { value: "0.05" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save configuration" }));
|
||||
const output = screen.getByRole("status", { name: "Saved configuration" });
|
||||
expect(output).toHaveTextContent('"classifier_type":"llm_v2"');
|
||||
expect(output).toHaveTextContent('"efficient_profile":"Short reasoning budget"');
|
||||
expect(output).toHaveTextContent('"capable_profile":"Larger reasoning budget"');
|
||||
expect(output).toHaveTextContent('"harness":"Shell and test runner, one attempt"');
|
||||
expect(output).toHaveTextContent('"max_quality_gap":0.05');
|
||||
expect(output).toHaveTextContent('"adaptive":false');
|
||||
expect(output).not.toHaveTextContent('"capability_classifier_config"');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
import React from "react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import {
|
||||
type ComplexityRouterConfigValue,
|
||||
type ClassificationFrequency,
|
||||
classificationFrequency,
|
||||
withClassificationFrequency,
|
||||
DEFAULT_CLASSIFIER_TIMEOUT_MS,
|
||||
} from "./ComplexityRouterConfig";
|
||||
import {
|
||||
forecastTierNames,
|
||||
forecastModels,
|
||||
getForecastConfigError,
|
||||
newCapabilitySettings,
|
||||
newFuseSettings,
|
||||
type CapabilitySettings,
|
||||
type FuseSettings,
|
||||
} from "./forecast_classifier_config";
|
||||
import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect";
|
||||
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
|
||||
import ClassifierVisionConfig from "./ClassifierVisionConfig";
|
||||
import TierModelEffortRows from "./TierModelEffortRows";
|
||||
import { activeTierRows } from "./tier_rows";
|
||||
import { setTierModels } from "./tier_set_actions";
|
||||
import { tierRowLabel, setTierModelParam, setTierModelReasoningEffort } from "./complexity_router_tiers";
|
||||
|
||||
interface Props {
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
modelOptions: { value: string; label: string }[];
|
||||
effortOptionsByModel: Record<string, string[] | null | undefined>;
|
||||
}
|
||||
|
||||
const NumberField = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step = "any",
|
||||
help,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number | "any";
|
||||
help?: string;
|
||||
}) => {
|
||||
const id = React.useId();
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={Number.isFinite(value) ? value : ""}
|
||||
onChange={(event) => onChange(event.target.value === "" ? Number.NaN : Number(event.target.value))}
|
||||
/>
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ForecastSolverModels = ({
|
||||
value,
|
||||
onChange,
|
||||
modelOptions,
|
||||
effortOptionsByModel,
|
||||
fastModeByModel,
|
||||
additionalPoolsOnly = false,
|
||||
}: Props & { fastModeByModel: Record<string, boolean>; additionalPoolsOnly?: boolean }) => {
|
||||
const id = React.useId();
|
||||
const names = forecastTierNames(value);
|
||||
const additionalRows =
|
||||
value.classifier_type === "capability"
|
||||
? activeTierRows(value)
|
||||
.filter((row) => !names.includes(row.id) && row.models.length > 0)
|
||||
.map((row) => ({ tier: row.id, label: `${tierRowLabel(row, value.tier_labels)} routing pool` }))
|
||||
: [];
|
||||
const rows = additionalPoolsOnly
|
||||
? additionalRows
|
||||
: names.map((tier, index) => ({ tier, label: index === 0 ? "Efficient solver" : "Capable solver" }));
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
{rows.map(({ tier, label }) => {
|
||||
const models = forecastModels(value.tiers, tier);
|
||||
const setModels = (next: string[]) => onChange(setTierModels(value, tier, next));
|
||||
return (
|
||||
<div key={tier} className="space-y-2">
|
||||
<Label htmlFor={`${id}-${tier}`} className="block text-sm font-semibold">
|
||||
{label}
|
||||
</Label>
|
||||
{value.classifier_type === "llm_v2" ? (
|
||||
<SearchSelect
|
||||
options={modelOptions}
|
||||
inputId={`${id}-${tier}`}
|
||||
value={models[0] ?? ""}
|
||||
aria-label={label}
|
||||
placeholder={`Select ${label.toLowerCase()}`}
|
||||
onValueChange={(model) => setModels(model ? [model] : [])}
|
||||
/>
|
||||
) : (
|
||||
<MultiSelect
|
||||
options={modelOptions}
|
||||
id={`${id}-${tier}`}
|
||||
value={models}
|
||||
onValueChange={setModels}
|
||||
placeholder={`Select ${label.toLowerCase()} models`}
|
||||
/>
|
||||
)}
|
||||
<TierModelEffortRows
|
||||
tierLabel={label}
|
||||
models={models}
|
||||
effortOptionsByModel={Object.fromEntries(
|
||||
Object.entries(effortOptionsByModel).map(([model, efforts]) => [model, efforts ?? []]),
|
||||
)}
|
||||
paramsByModel={value.tier_model_params?.[tier] ?? {}}
|
||||
fastModeByModel={fastModeByModel}
|
||||
onFastModeChange={(model, enabled) =>
|
||||
onChange({
|
||||
...value,
|
||||
tier_model_params: setTierModelParam(value.tier_model_params, tier, model, [
|
||||
"speed",
|
||||
enabled ? "fast" : undefined,
|
||||
]),
|
||||
})
|
||||
}
|
||||
onEffortChange={(model, effort) =>
|
||||
onChange({
|
||||
...value,
|
||||
tier_model_params: setTierModelReasoningEffort(value.tier_model_params, tier, model, effort),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!additionalPoolsOnly && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Invalid forecasts and classifier failures route to the capable solver
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CalibrationFields = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
bounded = false,
|
||||
}: {
|
||||
label: string;
|
||||
bounded?: boolean;
|
||||
value: { slope: number; intercept: number };
|
||||
onChange: (value: { slope: number; intercept: number }) => void;
|
||||
}) => (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NumberField
|
||||
label={`${label} slope`}
|
||||
value={value.slope}
|
||||
min={0}
|
||||
max={bounded ? 20 : undefined}
|
||||
onChange={(slope) => onChange({ ...value, slope })}
|
||||
/>
|
||||
<NumberField
|
||||
label={`${label} intercept`}
|
||||
value={value.intercept}
|
||||
min={bounded ? -20 : undefined}
|
||||
max={bounded ? 20 : undefined}
|
||||
onChange={(intercept) => onChange({ ...value, intercept })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const emptyCoefficients = () => ({ slope: Number.NaN, intercept: Number.NaN });
|
||||
|
||||
const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptionsByModel }: Props) => {
|
||||
const id = React.useId();
|
||||
const isCapability = value.classifier_type === "capability";
|
||||
const capability = value.capability_classifier_config ?? newCapabilitySettings();
|
||||
const fuse = value.llm_v2_config ?? newFuseSettings();
|
||||
const config = isCapability ? capability : fuse;
|
||||
const llm = value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS };
|
||||
const updateCapability = (next: CapabilitySettings) => onChange({ ...value, capability_classifier_config: next });
|
||||
const updateFuse = (next: FuseSettings) => onChange({ ...value, llm_v2_config: next });
|
||||
const updateTransport = (patch: { max_output_tokens?: number; response_format?: "json_schema" | "json_object" }) =>
|
||||
isCapability ? updateCapability({ ...capability, ...patch }) : updateFuse({ ...fuse, ...patch });
|
||||
const setCalibrationVersion = (version: string) => {
|
||||
if (isCapability && capability.calibration)
|
||||
updateCapability({ ...capability, calibration: { ...capability.calibration, version } });
|
||||
if (!isCapability && fuse.calibration) updateFuse({ ...fuse, calibration: { ...fuse.calibration, version } });
|
||||
};
|
||||
const error = getForecastConfigError(value);
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isCapability
|
||||
? "Forecasts whether the efficient solver can complete the task using the bundled capability card"
|
||||
: "Forecasts success for both solvers and selects efficient when the estimated quality gap is within your allowance"}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`${id}-judge`}>Judge model</Label>
|
||||
<SearchSelect
|
||||
inputId={`${id}-judge`}
|
||||
aria-label="Judge model"
|
||||
options={modelOptions}
|
||||
value={llm.model}
|
||||
placeholder="Select the judge model"
|
||||
onValueChange={(model) => {
|
||||
if (model === llm.model) return;
|
||||
onChange({ ...value, classifier_llm_config: { ...llm, model: model ?? "", reasoning_effort: undefined } });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isCapability ? (
|
||||
<>
|
||||
<NumberField
|
||||
label="Solve probability threshold"
|
||||
value={capability.base_threshold}
|
||||
min={0}
|
||||
max={1}
|
||||
help="Minimum estimated chance of whole-task success required to use the efficient solver"
|
||||
onChange={(base_threshold) => updateCapability({ ...capability, base_threshold })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(["efficient_profile", "capable_profile", "harness"] as const).map((field) => {
|
||||
const label = {
|
||||
efficient_profile: "Efficient solver profile",
|
||||
capable_profile: "Capable solver profile",
|
||||
harness: "Harness and budget",
|
||||
}[field];
|
||||
return (
|
||||
<div key={field} className="space-y-1">
|
||||
<Label htmlFor={`${id}-${field}`}>{label}</Label>
|
||||
<Textarea
|
||||
id={`${id}-${field}`}
|
||||
value={fuse[field]}
|
||||
maxLength={4000}
|
||||
placeholder={
|
||||
field === "harness"
|
||||
? "Tools, execution environment, verification, and budget available to each solver"
|
||||
: "Describe this solver's strengths, limitations, and settings"
|
||||
}
|
||||
onChange={(event) => updateFuse({ ...fuse, [field]: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<NumberField
|
||||
label="Maximum quality gap"
|
||||
value={fuse.max_quality_gap}
|
||||
min={0}
|
||||
max={1}
|
||||
help="Allowed difference between capable and efficient success probabilities, from 0 to 1. This is an estimate, not a measured quality guarantee"
|
||||
onChange={(max_quality_gap) => updateFuse({ ...fuse, max_quality_gap })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Collapsible className="rounded-lg border">
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left font-medium">
|
||||
<ChevronRight className="size-4 transition-transform group-data-panel-open:rotate-90" />
|
||||
Classifier options
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 px-4 pb-4">
|
||||
<ClassifierReasoningEffortSelect
|
||||
model={llm.model}
|
||||
value={llm.reasoning_effort}
|
||||
explicitlySupported={effortOptionsByModel[llm.model]}
|
||||
onChange={(reasoning_effort) => onChange({ ...value, classifier_llm_config: { ...llm, reasoning_effort } })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Timeout (ms)"
|
||||
min={1}
|
||||
step={1}
|
||||
value={llm.timeout_ms}
|
||||
help="Allow enough time for the judge to produce its forecast"
|
||||
onChange={(timeout_ms) => onChange({ ...value, classifier_llm_config: { ...llm, timeout_ms } })}
|
||||
/>
|
||||
<ClassifierCircuitBreakerConfig
|
||||
value={llm}
|
||||
onChange={(classifier_llm_config) => onChange({ ...value, classifier_llm_config })}
|
||||
/>
|
||||
<ClassifierVisionConfig
|
||||
value={llm}
|
||||
onChange={(classifier_llm_config) => onChange({ ...value, classifier_llm_config })}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`${id}-frequency`}>How often to classify</Label>
|
||||
<SearchSelect
|
||||
inputId={`${id}-frequency`}
|
||||
aria-label="How often to classify"
|
||||
value={classificationFrequency(value)}
|
||||
allowClear={false}
|
||||
options={[
|
||||
{ value: "every_request", label: "Every request" },
|
||||
{ value: "user_turn", label: "Every new user message" },
|
||||
{ value: "session", label: "Once per session" },
|
||||
]}
|
||||
onValueChange={(frequency) => {
|
||||
if (frequency) onChange(withClassificationFrequency(value, frequency as ClassificationFrequency));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isCapability && (
|
||||
<NumberField
|
||||
label="Capability boundary step"
|
||||
value={capability.threshold_step ?? 0}
|
||||
min={0}
|
||||
max={0.5}
|
||||
help="Added once for uncertain or unmatched tasks and twice for unsupported tasks; the final threshold cannot exceed 1"
|
||||
onChange={(threshold_step) => updateCapability({ ...capability, threshold_step })}
|
||||
/>
|
||||
)}
|
||||
<NumberField
|
||||
label="Classifier output token limit"
|
||||
min={1}
|
||||
step={1}
|
||||
value={config.max_output_tokens ?? (isCapability ? 4096 : 1024)}
|
||||
onChange={(max_output_tokens) => updateTransport({ max_output_tokens })}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`${id}-format`}>Forecast response format</Label>
|
||||
<SearchSelect
|
||||
inputId={`${id}-format`}
|
||||
aria-label="Forecast response format"
|
||||
value={config.response_format ?? "json_schema"}
|
||||
allowClear={false}
|
||||
options={[
|
||||
{ value: "json_schema", label: "Strict JSON schema" },
|
||||
{ value: "json_object", label: "JSON object (for judges without strict schema support)" },
|
||||
]}
|
||||
onValueChange={(response_format) => {
|
||||
if (response_format === "json_schema" || response_format === "json_object")
|
||||
updateTransport({ response_format });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<Label>
|
||||
<Switch
|
||||
checked={Boolean(config.calibration)}
|
||||
onCheckedChange={(enabled) =>
|
||||
isCapability
|
||||
? updateCapability({
|
||||
...capability,
|
||||
calibration: enabled ? { version: "", ...emptyCoefficients() } : undefined,
|
||||
})
|
||||
: updateFuse({
|
||||
...fuse,
|
||||
calibration: enabled
|
||||
? {
|
||||
version: "",
|
||||
prompt_version: "llm-v2-1",
|
||||
efficient: emptyCoefficients(),
|
||||
capable: emptyCoefficients(),
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Use fitted calibration
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional coefficients fitted for your judge, solvers, and harness. Leave off to use raw forecasts
|
||||
</p>
|
||||
{config.calibration && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`${id}-version`}>Calibration version</Label>
|
||||
<Input
|
||||
id={`${id}-version`}
|
||||
value={config.calibration.version}
|
||||
maxLength={isCapability ? 128 : 512}
|
||||
onChange={(event) => setCalibrationVersion(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isCapability && capability.calibration && (
|
||||
<CalibrationFields
|
||||
label="Efficient"
|
||||
bounded
|
||||
value={capability.calibration}
|
||||
onChange={(next) =>
|
||||
updateCapability({
|
||||
...capability,
|
||||
calibration: { version: capability.calibration?.version ?? "", ...next },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isCapability &&
|
||||
fuse.calibration &&
|
||||
(["efficient", "capable"] as const).map((role) => (
|
||||
<CalibrationFields
|
||||
key={role}
|
||||
label={role === "efficient" ? "Efficient" : "Capable"}
|
||||
value={fuse.calibration![role]}
|
||||
onChange={(next) => {
|
||||
if (fuse.calibration) updateFuse({ ...fuse, calibration: { ...fuse.calibration, [role]: next } });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The classifier uses its bundled prompt and always falls back to the capable solver
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastClassifierConfig;
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import React from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import TierRowSelect from "./TierRowSelect";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
const PlanModeOverrideControls: React.FC<{
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
planModeTierOptions: { value: string; label: string }[];
|
||||
}> = ({ value, onChange, planModeTierOptions }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.plan_mode_min_tier !== undefined}
|
||||
disabled={planModeTierOptions.length === 0}
|
||||
onCheckedChange={(enabled) =>
|
||||
onChange({
|
||||
...value,
|
||||
plan_mode_min_tier: enabled ? planModeTierOptions.at(-1)?.value : undefined,
|
||||
})
|
||||
}
|
||||
aria-label="Route plan-mode requests to a minimum tier"
|
||||
/>
|
||||
<strong className="font-semibold">Route plan-mode requests to a minimum tier</strong>
|
||||
</div>
|
||||
<span className="block text-xs mb-3 text-muted-foreground">
|
||||
Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier. The classifier
|
||||
still wins when it picks higher, and the override only lasts while plan mode is active.
|
||||
{planModeTierOptions.length === 0 && " Add models to a tier to enable this."}
|
||||
</span>
|
||||
{value.plan_mode_min_tier !== undefined && (
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<TierRowSelect
|
||||
label="Plan-mode minimum tier"
|
||||
options={planModeTierOptions}
|
||||
value={value.plan_mode_min_tier ?? null}
|
||||
onValueChange={(tier) => onChange({ ...value, plan_mode_min_tier: tier })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
export default PlanModeOverrideControls;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import React from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
||||
interface RoutingOptionsProps {
|
||||
forecast: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const RoutingOptions = ({ forecast, children }: RoutingOptionsProps) =>
|
||||
forecast ? (
|
||||
<Collapsible className="rounded-lg border">
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left font-medium">
|
||||
<ChevronRight className="size-4 transition-transform group-data-panel-open:rotate-90" />
|
||||
Advanced routing options
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 px-4 pb-4">{children}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
export default RoutingOptions;
|
||||
|
|
@ -23,6 +23,12 @@ interface TierModelEffortRowsProps {
|
|||
onFastModeChange: (model: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const canEditFastMode = (
|
||||
model: string,
|
||||
fastModeByModel: TierModelEffortRowsProps["fastModeByModel"],
|
||||
paramsByModel: TierModelEffortRowsProps["paramsByModel"],
|
||||
): boolean => fastModeByModel?.[model] === true || paramsByModel?.[model]?.speed === "fast";
|
||||
|
||||
export interface TierEffortRow {
|
||||
model: string;
|
||||
effort: ReasoningEffort | undefined;
|
||||
|
|
@ -49,7 +55,7 @@ export const tierEffortRows = ({
|
|||
const listed = effort !== undefined && !supported.includes(effort) ? [...supported, effort] : supported;
|
||||
return { model, effort, options: Array.from(new Set(listed)) };
|
||||
})
|
||||
.filter(({ model, options }) => options.length > 0 || fastModeByModel?.[model] === true);
|
||||
.filter(({ model, options }) => options.length > 0 || canEditFastMode(model, fastModeByModel, paramsByModel));
|
||||
|
||||
const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = (props) => {
|
||||
const { tierLabel, paramsByModel, onEffortChange, fastModeByModel, onFastModeChange } = props;
|
||||
|
|
@ -101,7 +107,7 @@ const TierModelEffortRows: React.FC<TierModelEffortRowsProps> = (props) => {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{fastModeByModel?.[model] === true && (
|
||||
{canEditFastMode(model, fastModeByModel, paramsByModel) && (
|
||||
<SimpleTooltip content="Fast mode has higher pricing and requires an eligible provider account. Off removes this tier's speed override and inherits the request or provider default">
|
||||
<label
|
||||
className="flex items-center gap-2 text-xs"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,17 @@ import React from "react";
|
|||
import { CUSTOM_TIER_RESTRICTIONS, CustomTierSet, TierRestriction } from "./tier_rows";
|
||||
|
||||
export const restrictedBy = (
|
||||
value: { custom_tier_set?: CustomTierSet },
|
||||
value: { custom_tier_set?: CustomTierSet; classifier_type?: string },
|
||||
key: keyof typeof CUSTOM_TIER_RESTRICTIONS,
|
||||
): TierRestriction | undefined => (value.custom_tier_set ? CUSTOM_TIER_RESTRICTIONS[key] : undefined);
|
||||
): TierRestriction | undefined => {
|
||||
if (value.custom_tier_set) return CUSTOM_TIER_RESTRICTIONS[key];
|
||||
if (value.classifier_type === "llm_v2" && key === "adaptive")
|
||||
return {
|
||||
omit: ["adaptive", "adaptive_weights", "adaptive_eligible", "tier_distance_penalty"],
|
||||
reason: "Fuse v2 uses its quality-gap decision directly; adaptive routing is unavailable",
|
||||
};
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const Restricted: React.FC<{ by: TierRestriction | undefined; children: React.ReactNode }> = ({
|
||||
by,
|
||||
|
|
|
|||
|
|
@ -167,6 +167,114 @@ describe("AddAutoRouterTab", () => {
|
|||
mockFetchAllModelDeployments.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it.each(["Capability", "Fuse v2"])(
|
||||
"creates %s from its dedicated tab without complexity templates",
|
||||
async (label) => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchAvailableModels.mockResolvedValue([
|
||||
{ model_group: "efficient", mode: "chat" },
|
||||
{ model_group: "capable", mode: "chat" },
|
||||
{ model_group: "judge", mode: "chat" },
|
||||
]);
|
||||
renderWithProviders(<Harness />);
|
||||
await user.type(screen.getByLabelText("Auto Router Name"), "forecast-router");
|
||||
await user.click(screen.getByRole("tab", { name: label, exact: true }));
|
||||
expect(screen.getByLabelText("Auto Router Name")).toHaveValue("forecast-router");
|
||||
expect(screen.queryByTestId("template-selector")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("configure-automatically-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("detailed-configuration-toggle")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Complexity Tier Configuration")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Advanced: Classification Method")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Advanced: Adaptive Routing")).not.toBeInTheDocument();
|
||||
const capability = label === "Capability";
|
||||
for (const [role, model] of [
|
||||
["Efficient", "efficient"],
|
||||
["Capable", "capable"],
|
||||
]) {
|
||||
await user.click(
|
||||
screen.getByRole("combobox", {
|
||||
name: capability ? `Select ${role.toLowerCase()} solver models` : `${role} solver`,
|
||||
}),
|
||||
);
|
||||
await user.click(await screen.findByRole("option", { name: model, exact: true }));
|
||||
if (capability) await user.keyboard("{Escape}");
|
||||
}
|
||||
await user.click(screen.getByRole("combobox", { name: "Judge model" }));
|
||||
await user.click(await screen.findByRole("option", { name: "judge", exact: true }));
|
||||
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeDisabled();
|
||||
if (capability) {
|
||||
fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.7" } });
|
||||
} else {
|
||||
fireEvent.change(screen.getByLabelText("Efficient solver profile"), { target: { value: "Small solver" } });
|
||||
fireEvent.change(screen.getByLabelText("Capable solver profile"), { target: { value: "Large solver" } });
|
||||
fireEvent.change(screen.getByLabelText("Harness and budget"), { target: { value: "One attempt" } });
|
||||
fireEvent.change(screen.getByLabelText("Maximum quality gap"), { target: { value: "0.05" } });
|
||||
}
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled();
|
||||
await user.click(screen.getByRole("button", { name: "Advanced routing options" }));
|
||||
if (capability) expect(screen.getByText("Advanced: Adaptive Routing")).toBeInTheDocument();
|
||||
else expect(screen.queryByText("Advanced: Adaptive Routing")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Advanced: Classification Method")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Advanced: Affinity")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject({
|
||||
classifier_type: capability ? "capability" : "llm_v2",
|
||||
tiers: { SIMPLE: ["efficient"], REASONING: ["capable"] },
|
||||
classifier_llm_config: { model: "judge" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("restores the automatic/template/detail flow on the Complexity tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
renderWithProviders(<Harness />);
|
||||
await screen.findByTestId("configure-automatically-button");
|
||||
await user.click(screen.getByRole("tab", { name: "Capability", exact: true }));
|
||||
expect(screen.queryByTestId("configure-automatically-button")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Complexity", exact: true }));
|
||||
expect(screen.getByTestId("configure-automatically-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("template-selector")).toBeInTheDocument();
|
||||
expandDetailedConfiguration();
|
||||
expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument();
|
||||
await user.click(screen.getByText("Advanced: Classification Method"));
|
||||
expect(screen.queryByRole("radio", { name: /^Capability/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("radio", { name: /^Fuse v2/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: /^Heuristic \(default/ })).toBeChecked();
|
||||
});
|
||||
|
||||
it.each(["Capability", "Fuse v2"])(
|
||||
"retries failed model loading on %s without losing entered settings",
|
||||
async (label) => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchAvailableModels.mockRejectedValueOnce(new Error("Model list unavailable")).mockResolvedValue([
|
||||
{ model_group: "efficient", mode: "chat" },
|
||||
{ model_group: "capable", mode: "chat" },
|
||||
{ model_group: "judge", mode: "chat" },
|
||||
]);
|
||||
renderWithProviders(<Harness />);
|
||||
await user.click(screen.getByRole("tab", { name: label, exact: true }));
|
||||
await user.type(screen.getByLabelText("Auto Router Name"), "forecast-retry");
|
||||
const capability = label === "Capability";
|
||||
const policyField = capability ? "Solve probability threshold" : "Efficient solver profile";
|
||||
fireEvent.change(screen.getByLabelText(policyField), {
|
||||
target: { value: capability ? "0.7" : "Small solver" },
|
||||
});
|
||||
expect(await screen.findByText("Could not load available models.")).toBeVisible();
|
||||
expect(screen.queryByTestId("template-selector")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Retry", exact: true }));
|
||||
await waitFor(() => expect(screen.queryByText("Could not load available models.")).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("tab", { name: label, exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByLabelText("Auto Router Name")).toHaveValue("forecast-retry");
|
||||
expect(screen.getByLabelText(policyField)).toHaveValue(capability ? 0.7 : "Small solver");
|
||||
await user.click(screen.getByRole("combobox", { name: "Judge model" }));
|
||||
expect(await screen.findByRole("option", { name: "judge", exact: true })).toBeVisible();
|
||||
expect(mockFetchAvailableModels).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
// Detailed Configuration starts collapsed so the modal opens onto just Name + Template; a caller
|
||||
// opts into the full tier/classifier form rather than always seeing it up front.
|
||||
it("keeps Detailed Configuration collapsed until a caller opens it", () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
|
||||
import { getForecastConfigError, isForecastClassifier } from "../add_model/forecast_classifier_config";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWatch } from "react-hook-form";
|
||||
|
|
@ -138,7 +140,9 @@ export const getSubmitBlockedReason = (
|
|||
(config.custom_tier_set
|
||||
? getCustomTierRowsError(config.custom_tier_set)
|
||||
: getTierLabelsError(config.tier_labels)) ??
|
||||
getMissingTiersError(activeTierRows(config)) ??
|
||||
(isForecastClassifier(config.classifier_type)
|
||||
? getForecastConfigError(config)
|
||||
: getMissingTiersError(activeTierRows(config))) ??
|
||||
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
|
||||
getClassifierModelError(config) ??
|
||||
|
|
@ -401,6 +405,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
classificationMode: complexityRouterConfig.classification_mode,
|
||||
tierLabels: complexityRouterConfig.tier_labels,
|
||||
classifierType: complexityRouterConfig.classifier_type,
|
||||
capabilityClassifierConfig: complexityRouterConfig.capability_classifier_config,
|
||||
llmV2Config: complexityRouterConfig.llm_v2_config,
|
||||
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
|
||||
classifierContextWindowSize: complexityRouterConfig.classifier_context_window_size,
|
||||
classifierContextBudgetChars: complexityRouterConfig.classifier_context_budget_chars,
|
||||
|
|
@ -543,81 +549,138 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
setIsTestModalVisible(true);
|
||||
};
|
||||
|
||||
const configurationForm = (
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
keywordRulesError={getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig))}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
autoRouterCompression={autoRouterCompression}
|
||||
onAutoRouterCompressionChange={isMemberManaged ? undefined : setAutoRouterCompression}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
);
|
||||
const forecast = isForecastClassifier(complexityRouterConfig.classifier_type);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form onSubmit={form.handleSubmit(() => handleAutoRouterSubmit())} noValidate>
|
||||
<FieldGroup>
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auto_router_name"
|
||||
label={labelWithHint("Auto Router Name", "Unique name for this auto router configuration")}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="e.g., smart_router, auto_router_1" />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{!automaticSetupLoading && automaticRouterConfig && (
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Not sure where to start?</p>
|
||||
<p className="text-sm text-muted-foreground">Let us pick models for each complexity tier.</p>
|
||||
</div>
|
||||
<Button type="button" data-testid="configure-automatically-button" onClick={handleAutomaticSetup}>
|
||||
Configure automatically
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">Template</label>
|
||||
<Select
|
||||
items={templateItems}
|
||||
value={selectedPreset ?? null}
|
||||
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
|
||||
>
|
||||
<SelectTrigger data-testid="template-selector" className="w-full">
|
||||
<SelectValue placeholder="Choose a template or select Custom to define your own" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
|
||||
const disabledHint = presetDisabledHint(presetState);
|
||||
const hintClass = isPresetHintAlarming(presetState)
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground";
|
||||
const matchedHint =
|
||||
presetState.kind === "available" && presetState.viaDeployments
|
||||
? "Matches your deployments"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={preset.key}
|
||||
value={preset.key}
|
||||
label={preset.label}
|
||||
disabled={disabledHint !== null}
|
||||
title={disabledHint ?? preset.description}
|
||||
<div className="mb-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auto_router_name"
|
||||
label={labelWithHint("Auto Router Name", "Unique name for this auto router configuration")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g., smart_router, auto_router_1" />}
|
||||
</FormField>
|
||||
</div>
|
||||
<AutoRouterClassifierTabs
|
||||
value={complexityRouterConfig}
|
||||
onChange={(config) => {
|
||||
setSelectedPreset(undefined);
|
||||
setComplexityRouterConfig(config);
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<div>
|
||||
{!forecast && (
|
||||
<>
|
||||
{!automaticSetupLoading && automaticRouterConfig && (
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Not sure where to start?</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Let us pick models for each complexity tier.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="configure-automatically-button"
|
||||
onClick={handleAutomaticSetup}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
|
||||
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
<SelectItem value="custom" label="Custom Configuration">
|
||||
<div>
|
||||
<div className="font-medium">Custom Configuration</div>
|
||||
<div className="text-xs text-muted-foreground">Define your auto router from scratch</div>
|
||||
Configure automatically
|
||||
</Button>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">Template</label>
|
||||
<Select
|
||||
items={templateItems}
|
||||
value={selectedPreset ?? null}
|
||||
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
|
||||
>
|
||||
<SelectTrigger data-testid="template-selector" className="w-full">
|
||||
<SelectValue placeholder="Choose a template or select Custom to define your own" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
|
||||
const disabledHint = presetDisabledHint(presetState);
|
||||
const hintClass = isPresetHintAlarming(presetState)
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground";
|
||||
const matchedHint =
|
||||
presetState.kind === "available" && presetState.viaDeployments
|
||||
? "Matches your deployments"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={preset.key}
|
||||
value={preset.key}
|
||||
label={preset.label}
|
||||
disabled={disabledHint !== null}
|
||||
title={disabledHint ?? preset.description}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
|
||||
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
<SelectItem value="custom" label="Custom Configuration">
|
||||
<div>
|
||||
<div className="font-medium">Custom Configuration</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Define your auto router from scratch
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{presetsPending && (
|
||||
<div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>
|
||||
)}
|
||||
{presetsUnavailable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load templates, so only Custom Configuration is shown.{" "}
|
||||
<button type="button" className="underline" onClick={() => void refetchPresets()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{modelsUnverifiable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load available models.{" "}
|
||||
|
|
@ -626,163 +689,129 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
{presetsPending && <div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>}
|
||||
{presetsUnavailable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load templates, so only Custom Configuration is shown.{" "}
|
||||
<button type="button" className="underline" onClick={() => void refetchPresets()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requiresTeamScope && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="team_id"
|
||||
label={labelWithHint(
|
||||
"Select Team",
|
||||
"Select the team this auto router belongs to. Only keys for this team will be able to call it.",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<TeamDropdown
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
filterTeam={(team) => canCreateAutoRouterForTeam(actor, team)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsExpanded((expanded) => !expanded)}
|
||||
className="w-full flex flex-col gap-1 px-4 py-3 text-left hover:bg-muted"
|
||||
data-testid="detailed-configuration-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
{detailsExpanded ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
{requiresTeamScope && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="team_id"
|
||||
label={labelWithHint(
|
||||
"Select Team",
|
||||
"Select the team this auto router belongs to. Only keys for this team will be able to call it.",
|
||||
)}
|
||||
Detailed Configuration
|
||||
</span>
|
||||
{!detailsExpanded && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tierConfigSummary(complexityRouterConfig)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{detailsExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
keywordRulesError={getKeywordTierRulesError(
|
||||
keywordTierRules,
|
||||
activeTierRows(complexityRouterConfig),
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<TeamDropdown
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
filterTeam={(team) => canCreateAutoRouterForTeam(actor, team)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{forecast ? (
|
||||
configurationForm
|
||||
) : (
|
||||
<div className="border border-border rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsExpanded((expanded) => !expanded)}
|
||||
className="w-full flex flex-col gap-1 px-4 py-3 text-left hover:bg-muted"
|
||||
data-testid="detailed-configuration-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
{detailsExpanded ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
Detailed Configuration
|
||||
</span>
|
||||
{!detailsExpanded && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tierConfigSummary(complexityRouterConfig)}
|
||||
</span>
|
||||
)}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
autoRouterCompression={autoRouterCompression}
|
||||
onAutoRouterCompressionChange={isMemberManaged ? undefined : setAutoRouterCompression}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
</button>
|
||||
{detailsExpanded && <div className="px-4 pb-4">{configurationForm}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_access_group"
|
||||
label={labelWithHint(
|
||||
"Model Access Group",
|
||||
"Use model access groups to control who can access this auto router",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<AccessGroupTagsCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={modelAccessGroups}
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
{isAdmin && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_access_group"
|
||||
label={labelWithHint(
|
||||
"Model Access Group",
|
||||
"Use model access groups to control who can access this auto router",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<AccessGroupTagsCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={modelAccessGroups}
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Need Help?
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
<TooltipContent>Get help on our github</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex gap-2">
|
||||
<BlockedReasonTooltip reason={submitBlockedReason}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="auto-router-test-routing-btn"
|
||||
disabled={submitBlockedReason !== null || isSubmitting}
|
||||
onClick={() => setIsRoutingTestVisible(true)}
|
||||
>
|
||||
Need Help?
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Get help on our github</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex gap-2">
|
||||
<BlockedReasonTooltip reason={submitBlockedReason}>
|
||||
Test Routing
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="auto-router-test-routing-btn"
|
||||
disabled={submitBlockedReason !== null || isSubmitting}
|
||||
onClick={() => setIsRoutingTestVisible(true)}
|
||||
data-testid="auto-router-test-connect-btn"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTestingConnection}
|
||||
>
|
||||
Test Routing
|
||||
{isTestingConnection && <UiLoadingSpinner className="size-4" />}
|
||||
Test Connection
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="auto-router-test-connect-btn"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTestingConnection}
|
||||
>
|
||||
{isTestingConnection && <UiLoadingSpinner className="size-4" />}
|
||||
Test Connection
|
||||
</Button>
|
||||
<BlockedReasonTooltip reason={submitBlockedReason}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitBlockedReason !== null || isSubmitting}
|
||||
onClick={() => {
|
||||
void handleAutoRouterSubmit();
|
||||
}}
|
||||
>
|
||||
Add Auto Router
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
<BlockedReasonTooltip reason={submitBlockedReason}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitBlockedReason !== null || isSubmitting}
|
||||
onClick={() => {
|
||||
void handleAutoRouterSubmit();
|
||||
}}
|
||||
>
|
||||
Add Auto Router
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldGroup>
|
||||
</AutoRouterClassifierTabs>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import {
|
||||
isForecastClassifier,
|
||||
withoutForecastPromptOverrides,
|
||||
type CapabilitySettings,
|
||||
type FuseSettings,
|
||||
} from "./forecast_classifier_config";
|
||||
import type { ModelGroup } from "../llm_calls/fetch_models";
|
||||
import { KeywordTierRule } from "./KeywordTierRules";
|
||||
import {
|
||||
|
|
@ -126,6 +132,48 @@ const scorerKnobPayload = ({
|
|||
};
|
||||
};
|
||||
|
||||
export interface StoredComplexityRouterConfig {
|
||||
tiers?: Partial<Record<keyof ComplexityTiers, unknown>>;
|
||||
enable_non_reasoning_tier?: boolean;
|
||||
tier_model_configs?: unknown;
|
||||
default_model?: string | null;
|
||||
plan_mode_min_tier?: unknown;
|
||||
classification_prompt?: unknown;
|
||||
classification_examples?: unknown;
|
||||
heuristic_first_max_tier?: unknown;
|
||||
hybrid_boundary_margin?: unknown;
|
||||
tier_labels?: unknown;
|
||||
classifier_type?: ClassifierType;
|
||||
capability_classifier_config?: unknown;
|
||||
llm_v2_config?: unknown;
|
||||
classifier_llm_config?: ClassifierLLMConfig;
|
||||
classifier_context_window_size?: unknown;
|
||||
classifier_context_budget_chars?: unknown;
|
||||
classifier_context_include_assistant_turns?: unknown;
|
||||
classifier_fallback?: unknown;
|
||||
classification_mode?: unknown;
|
||||
tier_boundaries?: unknown;
|
||||
token_thresholds?: unknown;
|
||||
dimension_weights?: unknown;
|
||||
custom_dimensions?: unknown;
|
||||
reasoning_override_min_score?: unknown;
|
||||
session_affinity?: unknown;
|
||||
session_affinity_ttl_seconds?: unknown;
|
||||
modality_routing?: unknown;
|
||||
modality_pin_override?: unknown;
|
||||
deployment_affinity?: unknown;
|
||||
adaptive?: boolean;
|
||||
adaptive_weights?: AdaptiveRouterWeights;
|
||||
tier_distance_penalty?: number;
|
||||
adaptive_eligible?: AdaptiveEligible;
|
||||
return_raw_model_name?: boolean;
|
||||
enable_context_window_escalation?: unknown;
|
||||
context_window_escalation_buffer?: unknown;
|
||||
stall_escalation_enabled?: unknown;
|
||||
stall_escalation_window?: unknown;
|
||||
stall_escalation_repeat_threshold?: unknown;
|
||||
}
|
||||
|
||||
export interface BuildComplexityRouterConfigParams {
|
||||
tiers: ComplexityTiers;
|
||||
enableNonReasoningTier?: boolean;
|
||||
|
|
@ -134,6 +182,8 @@ export interface BuildComplexityRouterConfigParams {
|
|||
planModeMinTier: string | undefined;
|
||||
tierLabels: ComplexityTierLabels | undefined;
|
||||
classifierType: ClassifierType;
|
||||
capabilityClassifierConfig?: CapabilitySettings;
|
||||
llmV2Config?: FuseSettings;
|
||||
classifierLlmConfig: ClassifierLLMConfigWire | undefined;
|
||||
classifierContextWindowSize: number | undefined;
|
||||
classifierContextBudgetChars: number | undefined;
|
||||
|
|
@ -198,6 +248,8 @@ export interface ComplexityRouterConfigPayload {
|
|||
plan_mode_min_tier?: string;
|
||||
tier_labels?: ComplexityTierLabels;
|
||||
classifier_type: ClassifierType;
|
||||
capability_classifier_config?: CapabilitySettings;
|
||||
llm_v2_config?: FuseSettings;
|
||||
classifier_llm_config?: ClassifierLLMConfig;
|
||||
classifier_context_window_size?: number;
|
||||
classifier_context_budget_chars?: number;
|
||||
|
|
@ -468,31 +520,34 @@ const classifierWireFields = (
|
|||
| "classifierContextBudgetChars"
|
||||
| "classifierContextIncludeAssistantTurns"
|
||||
>,
|
||||
): Partial<ComplexityRouterConfigPayload> => ({
|
||||
...(usesLlmClassifier(effectiveType) &&
|
||||
classifierLlmConfig && {
|
||||
classifier_llm_config:
|
||||
effectiveType === "capability" ? classifierLlmConfig : normalizeClassifierLlmConfig(classifierLlmConfig),
|
||||
}),
|
||||
...(usesLlmClassifier(effectiveType) &&
|
||||
classifierFallback !== undefined && { classifier_fallback: classifierFallback }),
|
||||
...(effectiveType === "heuristic_first" &&
|
||||
heuristicFirstMaxTier?.trim() && { heuristic_first_max_tier: heuristicFirstMaxTier }),
|
||||
...(effectiveType === "hybrid" &&
|
||||
hybridBoundaryMargin !== undefined && { hybrid_boundary_margin: hybridBoundaryMargin }),
|
||||
...(usesLlmClassifier(effectiveType) &&
|
||||
classifierContextWindowSize !== undefined && {
|
||||
classifier_context_window_size: classifierContextWindowSize,
|
||||
}),
|
||||
...(usesLlmClassifier(effectiveType) &&
|
||||
classifierContextBudgetChars !== undefined && {
|
||||
classifier_context_budget_chars: classifierContextBudgetChars,
|
||||
}),
|
||||
...(usesLlmClassifier(effectiveType) &&
|
||||
classifierContextIncludeAssistantTurns !== undefined && {
|
||||
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
|
||||
}),
|
||||
});
|
||||
): Partial<ComplexityRouterConfigPayload> => {
|
||||
const supportsFallback = usesLlmClassifier(effectiveType) && !isForecastClassifier(effectiveType);
|
||||
return {
|
||||
...(usesLlmClassifier(effectiveType) &&
|
||||
classifierLlmConfig && {
|
||||
classifier_llm_config: isForecastClassifier(effectiveType)
|
||||
? withoutForecastPromptOverrides(classifierLlmConfig)
|
||||
: normalizeClassifierLlmConfig(classifierLlmConfig),
|
||||
}),
|
||||
...(supportsFallback && classifierFallback !== undefined && { classifier_fallback: classifierFallback }),
|
||||
...(effectiveType === "heuristic_first" &&
|
||||
heuristicFirstMaxTier?.trim() && { heuristic_first_max_tier: heuristicFirstMaxTier }),
|
||||
...(effectiveType === "hybrid" &&
|
||||
hybridBoundaryMargin !== undefined && { hybrid_boundary_margin: hybridBoundaryMargin }),
|
||||
...(usesLlmClassifier(effectiveType) &&
|
||||
classifierContextWindowSize !== undefined && {
|
||||
classifier_context_window_size: classifierContextWindowSize,
|
||||
}),
|
||||
...(usesLlmClassifier(effectiveType) &&
|
||||
classifierContextBudgetChars !== undefined && {
|
||||
classifier_context_budget_chars: classifierContextBudgetChars,
|
||||
}),
|
||||
...(usesLlmClassifier(effectiveType) &&
|
||||
classifierContextIncludeAssistantTurns !== undefined && {
|
||||
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildComplexityRouterConfig = ({
|
||||
tiers,
|
||||
|
|
@ -502,6 +557,8 @@ export const buildComplexityRouterConfig = ({
|
|||
planModeMinTier,
|
||||
tierLabels,
|
||||
classifierType,
|
||||
capabilityClassifierConfig,
|
||||
llmV2Config,
|
||||
classifierLlmConfig,
|
||||
classifierContextWindowSize,
|
||||
classifierContextBudgetChars,
|
||||
|
|
@ -572,8 +629,12 @@ export const buildComplexityRouterConfig = ({
|
|||
// the form never rewrote. The UI gates the same controls on this, not on the raw value.
|
||||
const effectiveType: ClassifierType = customTierSet ? "llm" : classifierType;
|
||||
|
||||
const supportsOpeningPrompt =
|
||||
!customTierSet && !isForecastClassifier(effectiveType) && usesLlmClassifier(effectiveType);
|
||||
const payload: ComplexityRouterConfigPayload = {
|
||||
tiers,
|
||||
tiers: isForecastClassifier(effectiveType)
|
||||
? Object.fromEntries(Object.entries(tiers).filter(([, models]) => models.length > 0))
|
||||
: tiers,
|
||||
// The backend rejects the flag beside a custom tier set.
|
||||
...(!customTierSet && enableNonReasoningTier && { enable_non_reasoning_tier: true }),
|
||||
...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }),
|
||||
|
|
@ -582,10 +643,12 @@ export const buildComplexityRouterConfig = ({
|
|||
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
|
||||
classifier_type: classifierType,
|
||||
...classifierWireFields(effectiveType, classifierInputs),
|
||||
...(effectiveType === "capability" &&
|
||||
capabilityClassifierConfig && { capability_classifier_config: capabilityClassifierConfig }),
|
||||
...(effectiveType === "llm_v2" && { llm_v2_config: llmV2Config, adaptive: false }),
|
||||
// A built-in router's opening instructions. Suppressed beside a legacy whole-prompt override,
|
||||
// which the backend rejects as a second override of the same prompt.
|
||||
...(!customTierSet &&
|
||||
usesLlmClassifier(effectiveType) &&
|
||||
...(supportsOpeningPrompt &&
|
||||
!classifierLlmConfig?.system_prompt?.trim() && {
|
||||
...(classificationPrompt?.trim() && { classification_prompt: classificationPrompt.trim() }),
|
||||
...(classificationExamples?.trim() && { classification_examples: classificationExamples.trim() }),
|
||||
|
|
@ -612,12 +675,13 @@ export const buildComplexityRouterConfig = ({
|
|||
embedding_model: embeddingModel,
|
||||
match_threshold: matchThreshold,
|
||||
}),
|
||||
...(adaptive && {
|
||||
adaptive: true,
|
||||
adaptive_weights: adaptiveWeights,
|
||||
...(adaptiveEligible === "all" && { tier_distance_penalty: tierDistancePenalty }),
|
||||
adaptive_eligible: adaptiveEligible,
|
||||
}),
|
||||
...(adaptive &&
|
||||
effectiveType !== "llm_v2" && {
|
||||
adaptive: true,
|
||||
adaptive_weights: adaptiveWeights,
|
||||
...(adaptiveEligible === "all" && { tier_distance_penalty: tierDistancePenalty }),
|
||||
adaptive_eligible: adaptiveEligible,
|
||||
}),
|
||||
...(returnRawModelName && { return_raw_model_name: true }),
|
||||
...(enableContextWindowEscalation !== undefined && {
|
||||
enable_context_window_escalation: enableContextWindowEscalation,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
import { transitionClassifierType } from "./classifier_type_transition";
|
||||
|
||||
const standard: ComplexityRouterConfigValue = {
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "judge", timeout_ms: 20000, classification_rubric: "business" },
|
||||
classifier_context_window_size: 8,
|
||||
classifier_context_budget_chars: 16000,
|
||||
classifier_context_include_assistant_turns: true,
|
||||
classifier_fallback: "default_model",
|
||||
tiers: { SIMPLE: ["efficient"], MEDIUM: ["middle"], COMPLEX: [], REASONING: ["capable"] },
|
||||
};
|
||||
|
||||
describe("transitionClassifierType", () => {
|
||||
it.each(["heuristic_first", "hybrid"] as const)("keeps existing LLM settings when switching to %s", (target) => {
|
||||
const result = transitionClassifierType(standard, target);
|
||||
const expectedSettings = {
|
||||
classifier_type: target,
|
||||
classifier_llm_config: standard.classifier_llm_config,
|
||||
classifier_context_window_size: 8,
|
||||
classifier_context_budget_chars: 16000,
|
||||
classifier_context_include_assistant_turns: true,
|
||||
classifier_fallback: "default_model",
|
||||
};
|
||||
expect(result).toMatchObject(expectedSettings);
|
||||
});
|
||||
|
||||
it.each(["capability", "llm_v2"] as const)("requires explicit policy input for a new %s classifier", (target) => {
|
||||
const result = transitionClassifierType(standard, target);
|
||||
expect(result.classifier_llm_config).toEqual({ model: "judge", timeout_ms: 20000 });
|
||||
expect(result.classifier_fallback).toBeUndefined();
|
||||
if (target === "capability") {
|
||||
expect(result.capability_classifier_config?.base_threshold).toBeNaN();
|
||||
} else {
|
||||
expect(result.llm_v2_config).toMatchObject({ efficient_profile: "", capable_profile: "", harness: "" });
|
||||
expect(result.llm_v2_config?.max_quality_gap).toBeNaN();
|
||||
}
|
||||
expect(standard.tiers.MEDIUM).toEqual(["middle"]);
|
||||
expect(standard.classifier_llm_config?.classification_rubric).toBe("business");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["capability", "llm"],
|
||||
["capability", "heuristic_first"],
|
||||
["capability", "hybrid"],
|
||||
["llm_v2", "llm"],
|
||||
["llm_v2", "heuristic_first"],
|
||||
["llm_v2", "hybrid"],
|
||||
] as const)("restores the complexity rubric from %s to %s while preserving the judge", (source, target) => {
|
||||
const forecast = transitionClassifierType(standard, source);
|
||||
const result = transitionClassifierType(forecast, target);
|
||||
expect(result.classifier_llm_config).toEqual({
|
||||
model: "judge",
|
||||
timeout_ms: 20000,
|
||||
classification_rubric: "agentic",
|
||||
});
|
||||
expect(result.capability_classifier_config).toBeUndefined();
|
||||
expect(result.llm_v2_config).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears the inactive non-reasoning pool and plan floor when switching to local classification", () => {
|
||||
const initial: ComplexityRouterConfigValue = {
|
||||
...standard,
|
||||
tiers: { ...standard.tiers, NON_REASONING: ["chat"] },
|
||||
enable_non_reasoning_tier: true,
|
||||
plan_mode_min_tier: "NON_REASONING",
|
||||
};
|
||||
const result = transitionClassifierType(initial, "heuristic");
|
||||
expect(result.classifier_llm_config).toBeUndefined();
|
||||
expect(result.classifier_context_window_size).toBeUndefined();
|
||||
expect(result.classifier_context_budget_chars).toBeUndefined();
|
||||
expect(result.classifier_context_include_assistant_turns).toBeUndefined();
|
||||
expect(result.classifier_fallback).toBeUndefined();
|
||||
expect(result.tiers.NON_REASONING).toBeUndefined();
|
||||
expect(result.enable_non_reasoning_tier).toBeUndefined();
|
||||
expect(result.plan_mode_min_tier).toBeUndefined();
|
||||
expect(result.tiers.SIMPLE).toEqual(["efficient"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
type ClassifierType,
|
||||
type ComplexityRouterConfigValue,
|
||||
DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
|
||||
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE,
|
||||
DEFAULT_CLASSIFIER_TIMEOUT_MS,
|
||||
DEFAULT_HEURISTIC_FIRST_MAX_TIER,
|
||||
DEFAULT_HYBRID_BOUNDARY_MARGIN,
|
||||
NEW_CLASSIFIER_CLASSIFICATION_RUBRIC,
|
||||
usesLlmClassifier,
|
||||
} from "./ComplexityRouterConfig";
|
||||
import { isForecastClassifier, prepareForecastClassifier } from "./forecast_classifier_config";
|
||||
import { nonReasoningTierFields } from "./nonReasoningTierFields";
|
||||
|
||||
export const transitionClassifierType = (
|
||||
value: ComplexityRouterConfigValue,
|
||||
classifierType: ClassifierType,
|
||||
): ComplexityRouterConfigValue => {
|
||||
const startsLlmRubric =
|
||||
!value.classifier_llm_config ||
|
||||
(isForecastClassifier(value.classifier_type) && !isForecastClassifier(classifierType));
|
||||
const judgeConfig = value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS };
|
||||
const nextValue: ComplexityRouterConfigValue = {
|
||||
...value,
|
||||
classifier_llm_config: usesLlmClassifier(classifierType)
|
||||
? {
|
||||
...judgeConfig,
|
||||
...(startsLlmRubric && { classification_rubric: NEW_CLASSIFIER_CLASSIFICATION_RUBRIC }),
|
||||
}
|
||||
: undefined,
|
||||
classifier_context_window_size: usesLlmClassifier(classifierType)
|
||||
? value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE
|
||||
: undefined,
|
||||
classifier_context_budget_chars: usesLlmClassifier(classifierType)
|
||||
? value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS
|
||||
: undefined,
|
||||
classifier_context_include_assistant_turns: usesLlmClassifier(classifierType)
|
||||
? value.classifier_context_include_assistant_turns
|
||||
: undefined,
|
||||
classifier_fallback: usesLlmClassifier(classifierType) ? value.classifier_fallback : undefined,
|
||||
heuristic_first_max_tier:
|
||||
classifierType === "heuristic_first"
|
||||
? value.heuristic_first_max_tier ?? DEFAULT_HEURISTIC_FIRST_MAX_TIER
|
||||
: undefined,
|
||||
hybrid_boundary_margin:
|
||||
classifierType === "hybrid" ? value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN : undefined,
|
||||
...nonReasoningTierFields(classifierType, value),
|
||||
};
|
||||
return prepareForecastClassifier(nextValue, classifierType);
|
||||
};
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
import { getForecastConfigError, prepareForecastClassifier } from "./forecast_classifier_config";
|
||||
import { getKeywordTierRulesError } from "./build_complexity_router_config";
|
||||
import { activeTierRows } from "./tier_rows";
|
||||
import {
|
||||
buildUpdatedComplexityRouterConfig,
|
||||
hydrateComplexityRouterConfig,
|
||||
} from "../edit_auto_router/edit_auto_router_modal";
|
||||
|
||||
const capability: ComplexityRouterConfigValue = {
|
||||
classifier_type: "capability",
|
||||
classifier_llm_config: { model: "judge", timeout_ms: 20000 },
|
||||
tiers: { SIMPLE: ["efficient"], MEDIUM: [], COMPLEX: [], REASONING: ["capable"] },
|
||||
capability_classifier_config: {
|
||||
efficient_tier: "SIMPLE",
|
||||
capable_tier: "REASONING",
|
||||
base_threshold: 0.7,
|
||||
threshold_step: 0.1,
|
||||
},
|
||||
};
|
||||
const fuse: ComplexityRouterConfigValue = {
|
||||
...capability,
|
||||
classifier_type: "llm_v2",
|
||||
capability_classifier_config: undefined,
|
||||
adaptive: false,
|
||||
llm_v2_config: {
|
||||
efficient_profile: "A concise solver",
|
||||
capable_profile: "A solver with more reasoning budget",
|
||||
harness: "One attempt with shell and tests",
|
||||
max_quality_gap: 0.05,
|
||||
},
|
||||
};
|
||||
|
||||
describe("forecast classifier configuration", () => {
|
||||
it.each([
|
||||
{ version: "eval", slope: 21, intercept: 0 },
|
||||
{ version: "eval", slope: 1, intercept: -21 },
|
||||
{ version: " eval ", slope: 1, intercept: 0 },
|
||||
])("rejects capability calibration outside the server contract: %j", (calibration) => {
|
||||
const value = {
|
||||
...capability,
|
||||
capability_classifier_config: { ...capability.capability_classifier_config!, calibration },
|
||||
};
|
||||
expect(getForecastConfigError(value)).toContain("calibration");
|
||||
});
|
||||
|
||||
it.each([capability, fuse])("accepts a complete $classifier_type configuration", (value) => {
|
||||
expect(getForecastConfigError(value)).toBeNull();
|
||||
});
|
||||
|
||||
it("requires both solvers without requiring unused middle tiers", () => {
|
||||
expect(getForecastConfigError({ ...capability, tiers: { ...capability.tiers, REASONING: [] } })).toContain("both");
|
||||
expect(getForecastConfigError(capability)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a stepped threshold that exceeds one", () => {
|
||||
expect(
|
||||
getForecastConfigError({
|
||||
...capability,
|
||||
capability_classifier_config: { ...capability.capability_classifier_config!, threshold_step: 0.2 },
|
||||
}),
|
||||
).toContain("twice");
|
||||
});
|
||||
|
||||
it.each([Number.NaN, -0.1, 1.1])("rejects an invalid probability %s", (base_threshold) => {
|
||||
expect(
|
||||
getForecastConfigError({
|
||||
...capability,
|
||||
capability_classifier_config: { ...capability.capability_classifier_config!, base_threshold },
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each(["efficient_profile", "capable_profile", "harness"] as const)("requires %s for Fuse", (field) => {
|
||||
expect(getForecastConfigError({ ...fuse, llm_v2_config: { ...fuse.llm_v2_config!, [field]: " " } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("requires distinct single model groups and disables adaptive selection", () => {
|
||||
expect(getForecastConfigError({ ...fuse, tiers: { ...fuse.tiers, SIMPLE: ["capable"] } })).toContain("distinct");
|
||||
expect(getForecastConfigError({ ...fuse, tiers: { ...fuse.tiers, SIMPLE: ["efficient", "second"] } })).toContain(
|
||||
"distinct",
|
||||
);
|
||||
expect(getForecastConfigError({ ...fuse, adaptive: true })).toContain("adaptive");
|
||||
});
|
||||
|
||||
it("removes incompatible prompt and tier settings when switching to Fuse", () => {
|
||||
const previous: ComplexityRouterConfigValue = {
|
||||
...fuse,
|
||||
adaptive: true,
|
||||
classifier_fallback: "default_model",
|
||||
classifier_llm_config: {
|
||||
model: "judge",
|
||||
timeout_ms: 20000,
|
||||
system_prompt: "old rubric",
|
||||
classification_rubric: "business",
|
||||
},
|
||||
classification_prompt: "old prompt",
|
||||
classification_examples: "old example",
|
||||
tiers: { ...fuse.tiers, MEDIUM: ["extra"], SIMPLE: ["efficient", "second"] },
|
||||
};
|
||||
const next = prepareForecastClassifier(previous);
|
||||
const saved = buildUpdatedComplexityRouterConfig({}, next);
|
||||
expect(getForecastConfigError(next)).toBeNull();
|
||||
expect(saved.adaptive).toBe(false);
|
||||
expect(saved.tiers).toEqual({ SIMPLE: ["efficient"], REASONING: ["capable"] });
|
||||
expect(saved.classifier_llm_config).toEqual({ model: "judge", timeout_ms: 20000 });
|
||||
expect(saved).not.toHaveProperty("classification_prompt");
|
||||
expect(saved).not.toHaveProperty("classification_examples");
|
||||
expect(saved).not.toHaveProperty("classifier_fallback");
|
||||
});
|
||||
|
||||
it.each([capability, fuse])(
|
||||
"switching to $classifier_type removes hidden pools and their overrides while retaining the solver settings",
|
||||
(value) => {
|
||||
const efficientParams = { reasoning_effort: "low", speed: "fast", max_tokens: 1024 };
|
||||
const secondEfficientParams = { reasoning_effort: "medium", max_tokens: 2048 };
|
||||
const capableParams = { reasoning_effort: "high", max_tokens: 4096 };
|
||||
const secondCapableParams = { speed: "fast" };
|
||||
const previous: ComplexityRouterConfigValue = {
|
||||
...value,
|
||||
adaptive: true,
|
||||
plan_mode_min_tier: "MEDIUM",
|
||||
enable_non_reasoning_tier: true,
|
||||
tiers: {
|
||||
NON_REASONING: ["relay"],
|
||||
SIMPLE: ["efficient", "second-efficient"],
|
||||
MEDIUM: ["leftover-medium"],
|
||||
COMPLEX: ["leftover-complex"],
|
||||
REASONING: ["capable", "second-capable"],
|
||||
},
|
||||
tier_model_params: {
|
||||
NON_REASONING: { relay: { max_tokens: 128 } },
|
||||
SIMPLE: { efficient: efficientParams, "second-efficient": secondEfficientParams },
|
||||
MEDIUM: { "leftover-medium": { speed: "fast" } },
|
||||
COMPLEX: { "leftover-complex": { reasoning_effort: "high" } },
|
||||
REASONING: { capable: capableParams, "second-capable": secondCapableParams },
|
||||
LEGACY_CUSTOM: { "leftover-custom": { max_tokens: 512 } },
|
||||
},
|
||||
};
|
||||
const next = prepareForecastClassifier(previous);
|
||||
const preservesPools = value.classifier_type === "capability";
|
||||
const expectedTiers = {
|
||||
SIMPLE: preservesPools ? ["efficient", "second-efficient"] : ["efficient"],
|
||||
MEDIUM: [],
|
||||
COMPLEX: [],
|
||||
REASONING: preservesPools ? ["capable", "second-capable"] : ["capable"],
|
||||
};
|
||||
expect(next.tiers).toEqual(expectedTiers);
|
||||
expect(next.tier_model_params).toEqual({
|
||||
SIMPLE: {
|
||||
efficient: efficientParams,
|
||||
...(preservesPools && { "second-efficient": secondEfficientParams }),
|
||||
},
|
||||
REASONING: {
|
||||
capable: capableParams,
|
||||
...(preservesPools && { "second-capable": secondCapableParams }),
|
||||
},
|
||||
});
|
||||
expect(next.adaptive).toBe(preservesPools);
|
||||
expect(next.plan_mode_min_tier).toBeUndefined();
|
||||
expect(getForecastConfigError(next)).toBeNull();
|
||||
expect(activeTierRows(next).map((row) => row.name)).toEqual(["SIMPLE", "REASONING"]);
|
||||
expect(
|
||||
getKeywordTierRulesError([{ id: "old-middle", keywords: ["invoice"], tier: "MEDIUM" }], activeTierRows(next)),
|
||||
).toContain("no longer has");
|
||||
expect(
|
||||
getKeywordTierRulesError([{ id: "solver", keywords: ["audit"], tier: "REASONING" }], activeTierRows(next)),
|
||||
).toBeNull();
|
||||
const saved = buildUpdatedComplexityRouterConfig(previous, next);
|
||||
expect(saved.tiers).toEqual({ SIMPLE: expectedTiers.SIMPLE, REASONING: expectedTiers.REASONING });
|
||||
expect(saved.tier_model_configs).toEqual({
|
||||
SIMPLE: [
|
||||
{ model_name: "efficient", litellm_params: efficientParams },
|
||||
...(preservesPools ? [{ model_name: "second-efficient", litellm_params: secondEfficientParams }] : []),
|
||||
],
|
||||
REASONING: [
|
||||
{ model_name: "capable", litellm_params: capableParams },
|
||||
...(preservesPools ? [{ model_name: "second-capable", litellm_params: secondCapableParams }] : []),
|
||||
],
|
||||
});
|
||||
expect(saved).not.toHaveProperty("plan_mode_min_tier");
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves fitted calibration through edits and removes it when disabled", () => {
|
||||
const stored = {
|
||||
...capability,
|
||||
capability_classifier_config: {
|
||||
...capability.capability_classifier_config!,
|
||||
calibration: { version: "eval-a", slope: 1.2, intercept: -0.3 },
|
||||
},
|
||||
};
|
||||
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
|
||||
const edited = {
|
||||
...hydrated,
|
||||
capability_classifier_config: { ...hydrated.capability_classifier_config!, base_threshold: 0.6 },
|
||||
};
|
||||
expect(buildUpdatedComplexityRouterConfig(stored, edited).capability_classifier_config).toEqual({
|
||||
...stored.capability_classifier_config,
|
||||
base_threshold: 0.6,
|
||||
});
|
||||
const disabled = {
|
||||
...edited,
|
||||
capability_classifier_config: { ...edited.capability_classifier_config, calibration: undefined },
|
||||
};
|
||||
expect(buildUpdatedComplexityRouterConfig(stored, disabled).capability_classifier_config).toHaveProperty(
|
||||
"calibration",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves non-default tier assignments", () => {
|
||||
const value = {
|
||||
...capability,
|
||||
capability_classifier_config: {
|
||||
...capability.capability_classifier_config!,
|
||||
efficient_tier: "MEDIUM",
|
||||
capable_tier: "COMPLEX",
|
||||
},
|
||||
tiers: { SIMPLE: [], MEDIUM: ["efficient"], COMPLEX: ["capable"], REASONING: [] },
|
||||
};
|
||||
expect(getForecastConfigError(value)).toBeNull();
|
||||
expect(buildUpdatedComplexityRouterConfig({}, value).capability_classifier_config).toEqual(
|
||||
value.capability_classifier_config,
|
||||
);
|
||||
const previous = {
|
||||
...value,
|
||||
tiers: { ...value.tiers, SIMPLE: ["leftover-simple"], REASONING: ["leftover-reasoning"] },
|
||||
plan_mode_min_tier: "COMPLEX",
|
||||
tier_model_params: {
|
||||
MEDIUM: { efficient: { max_tokens: 1024 } },
|
||||
COMPLEX: { capable: { speed: "fast" } },
|
||||
SIMPLE: { "leftover-simple": { speed: "fast" } },
|
||||
},
|
||||
};
|
||||
const next = prepareForecastClassifier(previous);
|
||||
expect(next.tiers).toEqual(value.tiers);
|
||||
expect(next.plan_mode_min_tier).toBe("COMPLEX");
|
||||
expect(next.tier_model_params).toEqual({
|
||||
MEDIUM: { efficient: { max_tokens: 1024 } },
|
||||
COMPLEX: { capable: { speed: "fast" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps configured extra Capability pools through hydration and an unrelated edit", () => {
|
||||
const stored = {
|
||||
...capability,
|
||||
adaptive: true,
|
||||
plan_mode_min_tier: "MEDIUM",
|
||||
tiers: { ...capability.tiers, MEDIUM: ["middle"] },
|
||||
tier_model_configs: { MEDIUM: [{ model_name: "middle", litellm_params: { speed: "fast", max_tokens: 1024 } }] },
|
||||
};
|
||||
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
|
||||
expect(hydrated.tiers.MEDIUM).toEqual(["middle"]);
|
||||
expect(hydrated.plan_mode_min_tier).toBe("MEDIUM");
|
||||
expect(activeTierRows(hydrated).map((row) => row.name)).toEqual(["SIMPLE", "MEDIUM", "REASONING"]);
|
||||
expect(
|
||||
getKeywordTierRulesError([{ id: "kept", keywords: ["invoice"], tier: "MEDIUM" }], activeTierRows(hydrated)),
|
||||
).toBeNull();
|
||||
const saved = buildUpdatedComplexityRouterConfig(stored, {
|
||||
...hydrated,
|
||||
capability_classifier_config: { ...hydrated.capability_classifier_config!, base_threshold: 0.6 },
|
||||
});
|
||||
expect(saved.tiers).toEqual({ SIMPLE: ["efficient"], MEDIUM: ["middle"], REASONING: ["capable"] });
|
||||
expect(saved.tier_model_configs).toEqual(stored.tier_model_configs);
|
||||
expect(saved.adaptive).toBe(true);
|
||||
expect(saved.plan_mode_min_tier).toBe("MEDIUM");
|
||||
});
|
||||
|
||||
it("keeps empty built-in tiers available to standard classifiers", () => {
|
||||
const standard: ComplexityRouterConfigValue = { ...capability, classifier_type: "llm" };
|
||||
expect(activeTierRows(standard).map((row) => row.name)).toEqual(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
|
||||
expect(
|
||||
getKeywordTierRulesError([{ id: "middle", keywords: ["invoice"], tier: "MEDIUM" }], activeTierRows(standard)),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([capability, fuse])("drops $classifier_type settings when switching to the heuristic", (value) => {
|
||||
const saved = buildUpdatedComplexityRouterConfig(value, { ...value, classifier_type: "heuristic" });
|
||||
expect(saved).not.toHaveProperty("capability_classifier_config");
|
||||
expect(saved).not.toHaveProperty("llm_v2_config");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import { z } from "zod";
|
||||
import type { ClassifierType, ComplexityRouterConfigValue, ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
import { pruneTierModelParams } from "./complexity_router_tiers";
|
||||
import { tierOrderFor } from "./tier_rows";
|
||||
|
||||
const probability = z.number().finite().min(0).max(1);
|
||||
const version = z.string().trim().min(1).max(512);
|
||||
const profile = z.string().trim().min(1).max(4000);
|
||||
const transport = {
|
||||
max_output_tokens: z.number().int().positive().optional(),
|
||||
response_format: z.enum(["json_schema", "json_object"]).optional(),
|
||||
};
|
||||
const coefficients = z.object({ slope: z.number().finite().positive(), intercept: z.number().finite() });
|
||||
|
||||
const capabilityShape = {
|
||||
efficient_tier: z.string().min(1),
|
||||
capable_tier: z.string().min(1),
|
||||
base_threshold: probability,
|
||||
threshold_step: z.number().finite().nonnegative().optional(),
|
||||
...transport,
|
||||
calibration: z
|
||||
.object({
|
||||
version: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^\S(?:.*\S)?$/),
|
||||
slope: z.number().finite().min(0).max(20),
|
||||
intercept: z.number().finite().min(-20).max(20),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
};
|
||||
export const capabilitySettingsSchema = z.object(capabilityShape);
|
||||
|
||||
const fuseCalibrationShape = {
|
||||
version,
|
||||
prompt_version: z.literal("llm-v2-1"),
|
||||
efficient: coefficients,
|
||||
capable: coefficients,
|
||||
};
|
||||
const fuseShape = {
|
||||
efficient_tier: z.string().min(1).optional(),
|
||||
capable_tier: z.string().min(1).optional(),
|
||||
efficient_profile: profile,
|
||||
capable_profile: profile,
|
||||
harness: profile,
|
||||
max_quality_gap: probability,
|
||||
...transport,
|
||||
calibration: z.object(fuseCalibrationShape).nullable().optional(),
|
||||
};
|
||||
export const fuseSettingsSchema = z.object(fuseShape);
|
||||
|
||||
export type CapabilitySettings = z.infer<typeof capabilitySettingsSchema>;
|
||||
export type FuseSettings = z.infer<typeof fuseSettingsSchema>;
|
||||
|
||||
export const isForecastClassifier = (type: ClassifierType): boolean => type === "capability" || type === "llm_v2";
|
||||
|
||||
export const newCapabilitySettings = (): CapabilitySettings => ({
|
||||
efficient_tier: "SIMPLE",
|
||||
capable_tier: "REASONING",
|
||||
base_threshold: Number.NaN,
|
||||
});
|
||||
|
||||
export const newFuseSettings = (): FuseSettings => ({
|
||||
efficient_profile: "",
|
||||
capable_profile: "",
|
||||
harness: "",
|
||||
max_quality_gap: Number.NaN,
|
||||
});
|
||||
|
||||
export const forecastTierNames = (
|
||||
value: Pick<ComplexityRouterConfigValue, "classifier_type" | "capability_classifier_config" | "llm_v2_config">,
|
||||
): readonly [string, string] => {
|
||||
const settings = value.classifier_type === "capability" ? value.capability_classifier_config : value.llm_v2_config;
|
||||
return [settings?.efficient_tier ?? "SIMPLE", settings?.capable_tier ?? "REASONING"];
|
||||
};
|
||||
|
||||
export const forecastModels = (tiers: ComplexityTiers, tier: string): string[] =>
|
||||
Object.entries(tiers).find(([name]) => name === tier)?.[1] ?? [];
|
||||
|
||||
export const withoutForecastPromptOverrides = <T extends { system_prompt?: string; classification_rubric?: string }>(
|
||||
config: T,
|
||||
): Omit<T, "system_prompt" | "classification_rubric"> => {
|
||||
const { system_prompt: _prompt, classification_rubric: _rubric, ...rest } = config;
|
||||
return rest;
|
||||
};
|
||||
|
||||
export const prepareForecastClassifier = (
|
||||
previous: ComplexityRouterConfigValue,
|
||||
classifierType: ClassifierType = previous.classifier_type,
|
||||
): ComplexityRouterConfigValue => {
|
||||
const value = { ...previous, classifier_type: classifierType };
|
||||
if (!isForecastClassifier(classifierType))
|
||||
return {
|
||||
...value,
|
||||
capability_classifier_config: undefined,
|
||||
llm_v2_config: undefined,
|
||||
};
|
||||
// Capture routing identity before replacing the source classifier's settings.
|
||||
const [sourceEfficient, sourceCapable] = forecastTierNames(previous);
|
||||
const transferredPair =
|
||||
isForecastClassifier(previous.classifier_type) && previous.classifier_type !== classifierType
|
||||
? { efficient_tier: sourceEfficient, capable_tier: sourceCapable }
|
||||
: {};
|
||||
const configured = {
|
||||
...value,
|
||||
capability_classifier_config:
|
||||
value.classifier_type === "capability"
|
||||
? { ...(value.capability_classifier_config ?? newCapabilitySettings()), ...transferredPair }
|
||||
: undefined,
|
||||
llm_v2_config:
|
||||
value.classifier_type === "llm_v2"
|
||||
? { ...(value.llm_v2_config ?? newFuseSettings()), ...transferredPair }
|
||||
: undefined,
|
||||
};
|
||||
const [efficient, capable] = forecastTierNames(configured);
|
||||
const solverModels = (tier: string) => {
|
||||
const models = forecastModels(value.tiers, tier);
|
||||
return value.classifier_type === "llm_v2" ? models.slice(0, 1) : models;
|
||||
};
|
||||
const tiers: ComplexityTiers = {
|
||||
SIMPLE: [],
|
||||
MEDIUM: [],
|
||||
COMPLEX: [],
|
||||
REASONING: [],
|
||||
[efficient]: solverModels(efficient),
|
||||
[capable]: solverModels(capable),
|
||||
};
|
||||
return {
|
||||
...configured,
|
||||
tiers,
|
||||
tier_model_params: Object.keys(value.tier_model_params ?? {}).reduce(
|
||||
(params, tier) => pruneTierModelParams(params, tier, forecastModels(tiers, tier)),
|
||||
value.tier_model_params,
|
||||
),
|
||||
plan_mode_min_tier:
|
||||
value.plan_mode_min_tier && forecastModels(tiers, value.plan_mode_min_tier).length > 0
|
||||
? value.plan_mode_min_tier
|
||||
: undefined,
|
||||
custom_tier_set: undefined,
|
||||
enable_non_reasoning_tier: false,
|
||||
classification_prompt: undefined,
|
||||
classification_examples: undefined,
|
||||
classifier_fallback: undefined,
|
||||
classifier_llm_config: value.classifier_llm_config && withoutForecastPromptOverrides(value.classifier_llm_config),
|
||||
...(value.classifier_type === "llm_v2" && { adaptive: false }),
|
||||
};
|
||||
};
|
||||
|
||||
export const getForecastConfigError = (value: ComplexityRouterConfigValue): string | null => {
|
||||
if (!isForecastClassifier(value.classifier_type) || value.custom_tier_set) return null;
|
||||
const timeout = value.classifier_llm_config?.timeout_ms;
|
||||
if (timeout !== undefined && (!Number.isInteger(timeout) || timeout <= 0))
|
||||
return "Enter a positive whole-number classifier timeout";
|
||||
const [efficient, capable] = forecastTierNames(value);
|
||||
const order: readonly string[] = tierOrderFor(value.enable_non_reasoning_tier);
|
||||
if (!order.includes(efficient) || !order.includes(capable) || order.indexOf(capable) <= order.indexOf(efficient))
|
||||
return "The capable tier must be higher than the efficient tier";
|
||||
const efficientModels = forecastModels(value.tiers, efficient);
|
||||
const capableModels = forecastModels(value.tiers, capable);
|
||||
if (!efficientModels.length || !capableModels.length)
|
||||
return "Select models for both the efficient and capable solvers";
|
||||
if (value.classifier_type === "capability") {
|
||||
const result = capabilitySettingsSchema.safeParse(value.capability_classifier_config);
|
||||
if (!result.success)
|
||||
return "Enter a solve threshold between 0 and 1 and valid capability settings, including any calibration coefficients";
|
||||
if (result.data.base_threshold + 2 * (result.data.threshold_step ?? 0) > 1)
|
||||
return "The solve threshold plus twice the boundary step must be at most 1";
|
||||
return null;
|
||||
}
|
||||
return getFuseConfigError(value, efficient, capable);
|
||||
};
|
||||
|
||||
const getFuseConfigError = (value: ComplexityRouterConfigValue, efficient: string, capable: string): string | null => {
|
||||
const efficientModels = forecastModels(value.tiers, efficient);
|
||||
const capableModels = forecastModels(value.tiers, capable);
|
||||
if (value.adaptive) return "Turn off adaptive routing for Fuse v2";
|
||||
if (value.enable_non_reasoning_tier) return "Fuse v2 does not support the non-reasoning tier";
|
||||
if (efficientModels.length !== 1 || capableModels.length !== 1 || efficientModels[0] === capableModels[0])
|
||||
return "Fuse v2 requires one distinct model group for each solver";
|
||||
if (Object.entries(value.tiers).some(([tier, models]) => models.length > 0 && ![efficient, capable].includes(tier)))
|
||||
return "Fuse v2 supports only its efficient and capable tiers";
|
||||
const result = fuseSettingsSchema.safeParse(value.llm_v2_config);
|
||||
if (!result.success)
|
||||
return "Complete both solver profiles, the harness, and a quality gap between 0 and 1; any calibration needs valid coefficients and a version";
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
import { isForecastClassifier } from "./forecast_classifier_config";
|
||||
import type { ClassifierType, ComplexityTiers } from "./ComplexityRouterConfig";
|
||||
import type { ComplexityTier } from "./KeywordTierRules";
|
||||
import type { TierModelParams, TierModelParamsByTier } from "./complexity_router_tiers";
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ export const MAX_TIER_NAME_CHARS = 64;
|
|||
export const MAX_TIER_DEFINITION_CHARS = 500;
|
||||
|
||||
export interface ActiveTierSet {
|
||||
classifier_type?: ClassifierType;
|
||||
tiers: ComplexityTiers;
|
||||
enable_non_reasoning_tier?: boolean;
|
||||
custom_tier_set?: CustomTierSet;
|
||||
|
|
@ -62,7 +64,12 @@ export const activeTierRows = (value: ActiveTierSet): ActiveTierRow[] => {
|
|||
const rows =
|
||||
value.custom_tier_set?.tiers ??
|
||||
tierOrderFor(value.enable_non_reasoning_tier).map((tier) => builtInRow(tier, value.tiers));
|
||||
return rows.map((row) => ({ ...row, params: value.tier_model_params?.[row.id] ?? {} }));
|
||||
return rows
|
||||
.filter(
|
||||
(row) =>
|
||||
value.custom_tier_set || !isForecastClassifier(value.classifier_type ?? "heuristic") || row.models.length > 0,
|
||||
)
|
||||
.map((row) => ({ ...row, params: value.tier_model_params?.[row.id] ?? {} }));
|
||||
};
|
||||
|
||||
// The wire shape of an edited tier set, shared by the payload builder and the prompt preview so the
|
||||
|
|
|
|||
|
|
@ -23,6 +23,35 @@ const apply = (value: ComplexityRouterConfigValue, action: Parameters<typeof app
|
|||
applyTierSetAction(value, rules, action);
|
||||
|
||||
describe("applyTierSetAction", () => {
|
||||
it.each(["llm", "capability", "llm_v2"] as const)(
|
||||
"clears an emptied plan floor and can repopulate its pool for %s",
|
||||
(classifier_type) => {
|
||||
const initial: ComplexityRouterConfigValue = {
|
||||
...builtIn,
|
||||
classifier_type,
|
||||
plan_mode_min_tier: "SIMPLE",
|
||||
tier_model_params: { SIMPLE: { "gpt-3.5-turbo": { speed: "fast" } } },
|
||||
};
|
||||
const { value: cleared } = apply(initial, { kind: "models", id: "SIMPLE", models: [] });
|
||||
expect(cleared.plan_mode_min_tier).toBeUndefined();
|
||||
expect(cleared.tier_model_params).toBeUndefined();
|
||||
expect(cleared.tiers.SIMPLE).toEqual([]);
|
||||
const { value: restored } = apply(cleared, { kind: "models", id: "SIMPLE", models: ["gpt-4"] });
|
||||
expect(restored.tiers.SIMPLE).toEqual(["gpt-4"]);
|
||||
expect(restored.plan_mode_min_tier).toBeUndefined();
|
||||
expect(restored.tier_model_params).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a populated custom plan floor after removing only one model", () => {
|
||||
const initial: ComplexityRouterConfigValue = { ...custom, plan_mode_min_tier: "sec" };
|
||||
const { value: changed } = apply(initial, { kind: "models", id: "sec", models: ["new-model"] });
|
||||
expect(changed.plan_mode_min_tier).toBe("sec");
|
||||
const { value: cleared } = apply(changed, { kind: "models", id: "sec", models: [] });
|
||||
expect(cleared.plan_mode_min_tier).toBeUndefined();
|
||||
expect(cleared.custom_tier_set?.tiers.find((row) => row.id === "sec")?.models).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds a row and moves the form into an edited set, which the built-in record never leaves", () => {
|
||||
const { value } = apply(builtIn, { kind: "add" });
|
||||
expect(value.custom_tier_set?.tiers).toHaveLength(5);
|
||||
|
|
|
|||
|
|
@ -93,6 +93,30 @@ const exitToBuiltInTiers = (value: ComplexityRouterConfigValue, rows: readonly A
|
|||
return commitTierRows(activeTierRows(restored), "", restored);
|
||||
};
|
||||
|
||||
/** Model changes reconcile params and the plan floor against the resulting populated pools. */
|
||||
export const setTierModels = (
|
||||
value: ComplexityRouterConfigValue,
|
||||
id: string,
|
||||
models: string[],
|
||||
): ComplexityRouterConfigValue => {
|
||||
const next: ComplexityRouterConfigValue = {
|
||||
...value,
|
||||
...(value.custom_tier_set
|
||||
? {
|
||||
custom_tier_set: {
|
||||
...value.custom_tier_set,
|
||||
tiers: value.custom_tier_set.tiers.map((row) => (row.id === id ? { ...row, models } : row)),
|
||||
},
|
||||
}
|
||||
: { tiers: { ...value.tiers, [id]: models } }),
|
||||
tier_model_params: pruneTierModelParams(value.tier_model_params, id, models),
|
||||
};
|
||||
const floor = next.plan_mode_min_tier;
|
||||
return floor && !activeTierRows(next).some((row) => row.id === floor && row.models.length > 0)
|
||||
? { ...next, plan_mode_min_tier: undefined }
|
||||
: next;
|
||||
};
|
||||
|
||||
const nextTierSetValue = (
|
||||
value: ComplexityRouterConfigValue,
|
||||
rows: ActiveTierRow[],
|
||||
|
|
@ -102,11 +126,7 @@ const nextTierSetValue = (
|
|||
|
||||
switch (action.kind) {
|
||||
case "models":
|
||||
return commitTierRows(
|
||||
rows.map((row) => (row.id === action.id ? { ...row, models: action.models } : row)),
|
||||
fallbackId,
|
||||
{ ...value, tier_model_params: pruneTierModelParams(value.tier_model_params, action.id, action.models) },
|
||||
);
|
||||
return setTierModels(value, action.id, action.models);
|
||||
case "patch":
|
||||
return commitTierRows(
|
||||
rows.map((row) => (row.id === action.id ? { ...row, ...action.patch } : row)),
|
||||
|
|
|
|||
|
|
@ -675,7 +675,11 @@ describe("managed keys survive an untouched open-and-save", () => {
|
|||
|
||||
// The opt-in fifth tier requires the LLM classifier, which this heuristic_first fixture is not,
|
||||
// so it gets its own round trip below.
|
||||
const KEYS_ANOTHER_TIER_LADDER_OWNS = new Set(["enable_non_reasoning_tier"]);
|
||||
const KEYS_ANOTHER_TIER_LADDER_OWNS = new Set([
|
||||
"capability_classifier_config",
|
||||
"llm_v2_config",
|
||||
"enable_non_reasoning_tier",
|
||||
]);
|
||||
|
||||
it("carries every managed key a built-in router can hold through hydrate then save", () => {
|
||||
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
|
||||
|
|
@ -850,7 +854,12 @@ describe("LLM V2 configuration preservation", () => {
|
|||
harness: "Shell access, one attempt",
|
||||
max_quality_gap: 0.03,
|
||||
response_format: "json_object",
|
||||
calibration: { version: "pair-v1", prompt_version: "llm-v2-1" },
|
||||
calibration: {
|
||||
version: "pair-v1",
|
||||
prompt_version: "llm-v2-1",
|
||||
efficient: { slope: 1.2, intercept: -0.3 },
|
||||
capable: { slope: 0.9, intercept: 0.1 },
|
||||
},
|
||||
};
|
||||
const stored = {
|
||||
tiers: { SIMPLE: ["efficient"], REASONING: ["capable"] },
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
import AutoRouterClassifierTabs from "../add_model/AutoRouterClassifierTabs";
|
||||
import type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
|
||||
export type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
|
||||
import {
|
||||
getForecastConfigError,
|
||||
capabilitySettingsSchema,
|
||||
fuseSettingsSchema,
|
||||
} from "../add_model/forecast_classifier_config";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
complexityRouterSchema,
|
||||
|
|
@ -62,13 +70,7 @@ import {
|
|||
hydrateTokenThresholds,
|
||||
} from "../add_model/heuristic_scoring_knobs";
|
||||
import ComplexityRouterConfig, {
|
||||
AdaptiveEligible,
|
||||
AdaptiveRouterWeights,
|
||||
ClassifierLLMConfig,
|
||||
ClassifierType,
|
||||
effectiveClassifierType,
|
||||
ComplexityRouterConfigValue,
|
||||
ComplexityTiers,
|
||||
heuristicScoringRole,
|
||||
DEFAULT_ADAPTIVE_WEIGHTS,
|
||||
DEFAULT_SESSION_AFFINITY,
|
||||
|
|
@ -97,47 +99,6 @@ interface EditAutoRouterModalProps {
|
|||
// Keys this modal rewrites from its own form state on save. Anything absent from this set is
|
||||
// carried through untouched from the stored config, so a key only belongs here once the modal
|
||||
// actually renders a control that can set it.
|
||||
/** The complexity_router_config as it comes back from the proxy, before any hydration. Fields the
|
||||
* hydrators validate themselves stay `unknown`; the ones assigned straight through carry their type. */
|
||||
export interface StoredComplexityRouterConfig {
|
||||
tiers?: Partial<Record<keyof ComplexityTiers, unknown>>;
|
||||
enable_non_reasoning_tier?: boolean;
|
||||
tier_model_configs?: unknown;
|
||||
default_model?: string | null;
|
||||
plan_mode_min_tier?: unknown;
|
||||
classification_prompt?: unknown;
|
||||
classification_examples?: unknown;
|
||||
heuristic_first_max_tier?: unknown;
|
||||
hybrid_boundary_margin?: unknown;
|
||||
tier_labels?: unknown;
|
||||
classifier_type?: ClassifierType;
|
||||
classifier_llm_config?: ClassifierLLMConfig;
|
||||
classifier_context_window_size?: unknown;
|
||||
classifier_context_budget_chars?: unknown;
|
||||
classifier_context_include_assistant_turns?: unknown;
|
||||
classifier_fallback?: unknown;
|
||||
classification_mode?: unknown;
|
||||
tier_boundaries?: unknown;
|
||||
token_thresholds?: unknown;
|
||||
dimension_weights?: unknown;
|
||||
custom_dimensions?: unknown;
|
||||
reasoning_override_min_score?: unknown;
|
||||
session_affinity?: unknown;
|
||||
session_affinity_ttl_seconds?: unknown;
|
||||
modality_routing?: unknown;
|
||||
modality_pin_override?: unknown;
|
||||
deployment_affinity?: unknown;
|
||||
adaptive?: boolean;
|
||||
adaptive_weights?: AdaptiveRouterWeights;
|
||||
tier_distance_penalty?: number;
|
||||
adaptive_eligible?: AdaptiveEligible;
|
||||
return_raw_model_name?: boolean;
|
||||
enable_context_window_escalation?: unknown;
|
||||
context_window_escalation_buffer?: unknown;
|
||||
stall_escalation_enabled?: unknown;
|
||||
stall_escalation_window?: unknown;
|
||||
stall_escalation_repeat_threshold?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored complexity_router_config as form state. Every key in MANAGED_COMPLEXITY_ROUTER_KEYS is
|
||||
|
|
@ -164,6 +125,8 @@ export const hydrateComplexityRouterConfig = (
|
|||
plan_mode_min_tier: hydratePlanModeMinTier(parsedConfig.plan_mode_min_tier, custom_tier_set),
|
||||
tier_labels: hydrateTierLabels(parsedConfig.tier_labels),
|
||||
classifier_type: parsedConfig.classifier_type || "heuristic",
|
||||
capability_classifier_config: capabilitySettingsSchema.safeParse(parsedConfig.capability_classifier_config).data,
|
||||
llm_v2_config: fuseSettingsSchema.safeParse(parsedConfig.llm_v2_config).data,
|
||||
classifier_llm_config: parsedConfig.classifier_llm_config,
|
||||
classifier_context_window_size:
|
||||
typeof parsedConfig.classifier_context_window_size === "number"
|
||||
|
|
@ -251,6 +214,8 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
|||
"plan_mode_min_tier",
|
||||
"tier_labels",
|
||||
"classifier_type",
|
||||
"capability_classifier_config",
|
||||
"llm_v2_config",
|
||||
"classifier_llm_config",
|
||||
"classifier_context_window_size",
|
||||
"classifier_context_budget_chars",
|
||||
|
|
@ -339,7 +304,6 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
keywordMatching?: KeywordMatchingState,
|
||||
): Record<string, unknown> => {
|
||||
const isManaged = (key: string): boolean => {
|
||||
if (key === "llm_v2_config" && effectiveClassifierType(value) !== "llm_v2") return true;
|
||||
if (MANAGED_COMPLEXITY_ROUTER_KEYS.has(key)) return true;
|
||||
if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
|
||||
return customTechnicalKeywords !== undefined && key === "custom_technical_keywords";
|
||||
|
|
@ -362,6 +326,8 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
classificationMode: value.classification_mode,
|
||||
tierLabels: value.tier_labels,
|
||||
classifierType: value.classifier_type,
|
||||
capabilityClassifierConfig: value.capability_classifier_config,
|
||||
llmV2Config: value.llm_v2_config,
|
||||
classifierLlmConfig: value.classifier_llm_config,
|
||||
classifierContextWindowSize: value.classifier_context_window_size,
|
||||
classifierContextBudgetChars: value.classifier_context_budget_chars,
|
||||
|
|
@ -458,6 +424,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig)) ??
|
||||
getClassifierModelError(complexityRouterConfig) ??
|
||||
getForecastConfigError(complexityRouterConfig) ??
|
||||
(heuristicScoringRole(complexityRouterConfig) === "decides"
|
||||
? customDimensionsError(complexityRouterConfig.custom_dimensions)
|
||||
: null);
|
||||
|
|
@ -589,6 +556,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
}
|
||||
const classifierError =
|
||||
getClassifierModelError(complexityRouterConfig) ??
|
||||
getForecastConfigError(complexityRouterConfig) ??
|
||||
(heuristicScoringRole(complexityRouterConfig) === "decides"
|
||||
? customDimensionsError(complexityRouterConfig.custom_dimensions)
|
||||
: null);
|
||||
|
|
@ -742,6 +710,14 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
{ value: "custom", label: "Enter custom model name" },
|
||||
];
|
||||
|
||||
const routerNameField = (
|
||||
<FormField control={form.control} name="auto_router_name" label="Auto Router Name">
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} readOnly={isMemberManaged} placeholder="e.g., auto_router_1, smart_routing" />
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isVisible} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
|
|
@ -755,48 +731,41 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="auto_router_name" label="Auto Router Name">
|
||||
{({ ref, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
readOnly={isMemberManaged}
|
||||
placeholder="e.g., auto_router_1, smart_routing"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
{routerNameField}
|
||||
|
||||
{isComplexityRouterModel ? (
|
||||
/* Complexity Router Configuration */
|
||||
<div className="w-full">
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
showValidationErrors={showValidationErrors}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={(config) => {
|
||||
setComplexityRouterConfig(config);
|
||||
}}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
keywordRulesError={getKeywordTierRulesError(
|
||||
keywordTierRules,
|
||||
activeTierRows(complexityRouterConfig),
|
||||
)}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
autoRouterCompression={autoRouterCompression}
|
||||
onAutoRouterCompressionChange={isMemberManaged ? undefined : setAutoRouterCompression}
|
||||
/>
|
||||
<AutoRouterClassifierTabs value={complexityRouterConfig} onChange={setComplexityRouterConfig}>
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
showValidationErrors={showValidationErrors}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={(config) => {
|
||||
setComplexityRouterConfig(config);
|
||||
}}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
keywordRulesError={getKeywordTierRulesError(
|
||||
keywordTierRules,
|
||||
activeTierRows(complexityRouterConfig),
|
||||
)}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
autoRouterCompression={autoRouterCompression}
|
||||
onAutoRouterCompressionChange={isMemberManaged ? undefined : setAutoRouterCompression}
|
||||
/>
|
||||
</AutoRouterClassifierTabs>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue