fix(ui): adapt the jev dashboard pieces to stable/1.100.x

Merge debris cleanup and 1.102.x-only imports fixed; usesClassifierContext re-exported, JEV custom-tier emission, and preset test adapted to the static preset registry.
This commit is contained in:
Devin AI 2026-09-23 01:56:40 +00:00 • committed by mateo
parent 746686c34e
commit 8e4c26fea3
8 changed files with 270 additions and 468 deletions

View file

@ -12,51 +12,39 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import React from "react";
import ClassifierPromptEditor from "./ClassifierPromptEditor";
import OpeningPromptEditor, { type OpeningPromptSelection } from "./OpeningPromptEditor";
import CustomTierPromptEditor from "./CustomTierPromptEditor";
import { RestrictedSection, restrictedBy } from "./TierRestrictions";
import HeuristicScoringConfig from "./HeuristicScoringConfig";
import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect";
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
import ClassifierVisionConfig from "./ClassifierVisionConfig";
import type { ReasoningEffort } from "./complexity_router_tiers";
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
import {
ClassificationFrequency,
ClassifierFallback,
ClassifierLLMConfig,
ClassifierType,
ComplexityRouterConfigValue,
classificationFrequency,
withClassificationFrequency,
DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
MIN_QUOTED_CONTEXT_TURN_CHARS,
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE,
DEFAULT_CLASSIFIER_FALLBACK,
DEFAULT_CLASSIFIER_TIMEOUT_MS,
DEFAULT_CLASSIFICATION_RUBRIC,
CLASSIFICATION_RUBRIC_DESCRIPTIONS,
CLASSIFICATION_RUBRIC_KEYS,
ClassificationRubric,
effectiveTierLabel,
heuristicScoringRole,
usesLlmClassifier,
usesClassifierContext,
DEFAULT_HYBRID_BOUNDARY_MARGIN,
HEURISTIC_FIRST_MAX_TIER_KEYS,
effectiveClassifierType,
} from "./ComplexityRouterConfig";
const DEFAULT_SCORING_EXPLANATION =
"The router scores each request across 7 built-in dimensions: token count, code presence, reasoning markers, technical " +
"terms, simple indicators, multi-step patterns, and question complexity, plus any custom dimensions you add. " +
"The weighted score determines the tier:";
const HEURISTIC_V2_EXPLANATION =
"The router estimates success probability for all four tiers with the bundled calibrated model, then selects " +
"the first tier that meets its trained threshold. It runs locally with no classifier API call.";
"The router scores each request across 7 dimensions: token count, code presence, reasoning markers, technical " +
"terms, simple indicators, multi-step patterns, and question complexity. The weighted score determines the tier:";
const CLASSIFIER_TIMEOUT_ID = "classifier-timeout-ms";
const CLASSIFIER_CONTEXT_WINDOW_SIZE_ID = "classifier-context-window-size";
const CLASSIFIER_CONTEXT_BUDGET_CHARS_ID = "classifier-context-budget-chars";
const HYBRID_BOUNDARY_MARGIN_ID = "hybrid-boundary-margin";
const CUSTOM_PROMPT_WITH_HEURISTIC_FALLBACK =
"This router classifies with your own prompt, so the tier comes from whatever rubric it states. The four tier " +
@ -73,7 +61,6 @@ const CUSTOM_PROMPT_WITH_DEFAULT_MODEL_FALLBACK =
* at all, so the panel must not keep implying a score is involved on either router.
*/
const scoringExplanation = (value: ComplexityRouterConfigValue): string => {
if (value.classifier_type === "heuristic_v2") return HEURISTIC_V2_EXPLANATION;
const usesCustomPrompt =
usesLlmClassifier(value.classifier_type) && Boolean(value.classifier_llm_config?.system_prompt?.trim());
if (!usesCustomPrompt) return DEFAULT_SCORING_EXPLANATION;
@ -126,7 +113,7 @@ const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> =
<strong className="block mb-2 font-semibold">How Classification Works</strong>
<span className="text-[13px] text-muted-foreground">{scoringExplanation(value)}</span>
{scorerRuns && ranges && (
<ul className="mt-2 pl-5 text-[13px] text-muted-foreground">
<ul style={{ marginTop: 8, marginBottom: 0, paddingLeft: 20, fontSize: 13, color: "rgba(0, 0, 0, 0.45)" }}>
<li>
<strong>{effectiveTierLabel("SIMPLE", value.tier_labels)}</strong>: Score &lt; {ranges.simpleMedium}
</li>
@ -159,7 +146,6 @@ interface ClassificationMethodConfigProps {
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
modelOptions: { value: string; label: string }[];
effortOptionsByModel: Record<string, string[] | null | undefined>;
customTechnicalKeywords?: string[];
onCustomTechnicalKeywordsChange?: (keywords: string[]) => void;
showValidationErrors?: boolean;
@ -192,17 +178,6 @@ const ClassifierTypeRadios: React.FC<{
</span>
</Label>
</SimpleTooltip>
<SimpleTooltip content={scorerLockedReason}>
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
<RadioGroupItem value="heuristic_v2" className="mt-0.5" disabled={scorerLocked} />
<span>
<strong className="font-semibold">Heuristic v2</strong>{" "}
<span className="text-muted-foreground">
uses bundled calibrated four-tier probabilities with no API call
</span>
</span>
</Label>
</SimpleTooltip>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="llm" className="mt-0.5" />
<span>
@ -228,18 +203,6 @@ const ClassifierTypeRadios: React.FC<{
</span>
</Label>
</SimpleTooltip>
<SimpleTooltip content={scorerLockedReason}>
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
<RadioGroupItem value="hybrid" className="mt-0.5" disabled={scorerLocked} />
<span>
<strong className="font-semibold">Hybrid</strong>{" "}
<span className="text-muted-foreground">
keeps the local score at any tier, and only pays for the classifier when that score lands near a tier
boundary
</span>
</span>
</Label>
</SimpleTooltip>
</div>
</RadioGroup>
);
@ -249,7 +212,6 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
value,
onChange,
modelOptions,
effortOptionsByModel,
customTechnicalKeywords,
onCustomTechnicalKeywordsChange,
showValidationErrors = false,
@ -258,16 +220,12 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
const [draft, setDraft] = React.useState<{ id: string; raw: string } | null>(null);
const hasDefaultModel = Boolean(defaultModel);
const classifierType = effectiveClassifierType(value);
const sessionFrequencyRestriction = restrictedBy(value, "sessionAffinity");
const classifierModelMissing =
showValidationErrors && usesLlmClassifier(classifierType) && !value.classifier_llm_config?.model;
const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim());
const contextBudget = value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS;
const contextBudgetQuotesNothing = contextBudget > 0 && contextBudget < MIN_QUOTED_CONTEXT_TURN_CHARS;
const classificationRubric = value.classifier_llm_config?.classification_rubric ?? DEFAULT_CLASSIFICATION_RUBRIC;
const classifierModel = value.classifier_llm_config?.model ?? "";
const classifierReasoningEffort = value.classifier_llm_config?.reasoning_effort;
const explicitlySupportedClassifierEfforts = effortOptionsByModel[classifierModel];
const handleClassifierTypeChange = (classifierType: ClassifierType) => {
onChange(transitionClassifierType(value, classifierType));
@ -277,65 +235,21 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
onChange({ ...value, heuristic_first_max_tier: tier });
};
const handleHybridBoundaryMarginChange = (raw: string) => {
setDraft({ id: HYBRID_BOUNDARY_MARGIN_ID, raw });
const parsed = Number(raw);
if (raw.trim() === "" || !Number.isFinite(parsed)) return;
onChange({ ...value, hybrid_boundary_margin: Math.min(1, Math.max(0, parsed)) });
const handleClassificationPromptChange = (classificationPrompt: string | undefined) => {
onChange({ ...value, classification_prompt: classificationPrompt });
};
// One write for everything the prompt dialog owns. The rubric arrives here rather than through the
// rubric handler because two onChange calls in one tick would both spread this render's `value`,
// so whichever landed second would drop the other's edit.
const handleClassificationPromptChange = ({
classificationPrompt,
classificationExamples,
classificationRubric: selectedRubric,
}: OpeningPromptSelection) => {
const rubricConfig: ClassifierLLMConfig = {
...value.classifier_llm_config,
model: value.classifier_llm_config?.model ?? "",
timeout_ms: value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS,
classification_rubric: selectedRubric,
};
const nextValue: ComplexityRouterConfigValue = {
...value,
...(selectedRubric && { classifier_llm_config: rubricConfig }),
classification_prompt: classificationPrompt,
classification_examples: classificationExamples,
};
onChange(nextValue);
};
const handleClassifierModelChange = (model: string | null) => {
if (model === null) return;
if (model === value.classifier_llm_config?.model) return;
const { reasoning_effort: _reasoningEffort, ...classifierLlmConfig } = value.classifier_llm_config ?? {
model: "",
timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS,
};
const handleClassifierModelChange = (model: string) => {
onChange({
...value,
classifier_llm_config: {
...classifierLlmConfig,
...value.classifier_llm_config,
model,
timeout_ms: classifierLlmConfig.timeout_ms,
timeout_ms: value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS,
},
});
};
const handleClassifierReasoningEffortChange = (reasoningEffort: ReasoningEffort | undefined) => {
if (!value.classifier_llm_config) return;
const { reasoning_effort: _reasoningEffort, ...classifierLlmConfig } = value.classifier_llm_config;
onChange({
...value,
classifier_llm_config:
reasoningEffort === undefined
? classifierLlmConfig
: { ...classifierLlmConfig, reasoning_effort: reasoningEffort },
});
};
const handleClassifierTimeoutChange = (timeoutMs: number) => {
onChange({
...value,
@ -375,10 +289,6 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
onChange({ ...value, classifier_fallback: fallback });
};
const handleClassificationFrequencyChange = (frequency: ClassificationFrequency) => {
onChange(withClassificationFrequency(value, frequency));
};
const handleClassifierContextWindowSizeChange = (windowSize: number) => {
onChange({
...value,
@ -441,73 +351,6 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</div>
)}
{classifierType === "hybrid" && (
<div className="mt-4 space-y-2">
<strong className="block font-semibold">Boundary margin</strong>
<Input
id={HYBRID_BOUNDARY_MARGIN_ID}
type="text"
inputMode="decimal"
value={
draft?.id === HYBRID_BOUNDARY_MARGIN_ID
? draft.raw
: String(value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN)
}
onChange={(event) => handleHybridBoundaryMarginChange(event.target.value)}
onBlur={() => setDraft(null)}
className="w-full"
/>
<p className="text-sm text-muted-foreground">
A score further than this from every tier boundary routes on the scorer&apos;s own tier, however expensive
that tier is. A score closer than this, and anything the scorer found no signal for at all, goes to the
classifier to break the tie
</p>
</div>
)}
<div className="mt-4 space-y-2">
<strong className="block font-semibold">How often to classify</strong>
<RadioGroup
value={classificationFrequency(value)}
onValueChange={(frequency: unknown) =>
handleClassificationFrequencyChange(frequency as ClassificationFrequency)
}
>
<div className="inline-flex flex-col gap-2">
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="every_request" className="mt-0.5" />
<span>
<span>Every request</span>{" "}
<span className="text-muted-foreground">: score every turn, tool-result continuations included</span>
</span>
</Label>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="user_turn" className="mt-0.5" />
<span>
<span>Every new user message</span>{" "}
<span className="text-muted-foreground">
: score each new human ask, then hold that tier for the tool calls that follow it
</span>
</span>
</Label>
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="session" className="mt-0.5" disabled={Boolean(sessionFrequencyRestriction)} />
<span>
<span>Once per session</span>{" "}
<span className="text-muted-foreground">
{sessionFrequencyRestriction?.reason ??
": score the first turn only, then hold that tier and its deployment for the whole session"}
</span>
</span>
</Label>
</div>
</RadioGroup>
<p className="text-sm text-muted-foreground">
Holding the tier keeps an agent on one model for a whole tool loop and cuts scoring cost. A turn the router
cannot match to a held decision, such as one with no session id or an expired one, is scored again
</p>
</div>
{classifierType === "jev" && <JevClassifierConfig value={value} onChange={onChange} />}
{usesLlmClassifier(classifierType) && (
<div className="mt-4 space-y-3">
@ -525,12 +368,6 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
/>
{classifierModelMissing && <span className="text-xs text-destructive">A classifier model is required</span>}
</div>
<ClassifierReasoningEffortSelect
model={classifierModel}
value={classifierReasoningEffort}
explicitlySupported={explicitlySupportedClassifierEfforts}
onChange={handleClassifierReasoningEffortChange}
/>
<div>
<Label htmlFor={CLASSIFIER_TIMEOUT_ID} className="block mb-1 font-semibold">
Timeout (ms)
@ -563,18 +400,60 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
value={value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS }}
onChange={(classifier_llm_config) => onChange({ ...value, classifier_llm_config })}
/>
<ClassifierVisionConfig
value={value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS }}
onChange={(classifier_llm_config) => onChange({ ...value, classifier_llm_config })}
/>
<div>
<div className="flex items-center gap-2 mb-1">
<strong className="font-semibold">Classifier Prompt</strong>
<SimpleTooltip content="Every rubric uses the same four tiers. They differ in the worked examples that show the classifier where the boundary between tiers sits, and the Business rubric also rewrites the tier definitions for business traffic. Pick the rubric, and write your own opening instructions and calibration examples, inside the prompt editor.">
<strong className="font-semibold">Classification Rubric</strong>
<SimpleTooltip content="Every rubric uses the same four tiers. They differ in the worked examples that show the classifier where the boundary between tiers sits, and the Business rubric also rewrites the tier definitions for business traffic.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
</div>
{!value.custom_tier_set && usesCustomPrompt ? (
<SimpleTooltip
content={
restrictedBy(value, "classificationRubric")?.reason ??
(usesCustomPrompt ? "Your custom prompt replaces the built-in rubric entirely" : undefined)
}
className="w-full"
>
<Select
items={CLASSIFICATION_RUBRIC_KEYS.map((preset) => ({
value: preset,
label: CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label,
}))}
value={classificationRubric}
onValueChange={(preset: ClassificationRubric | null) =>
preset && handleClassificationRubricChange(preset)
}
disabled={usesCustomPrompt || Boolean(value.custom_tier_set)}
>
<SelectTrigger aria-label="Classification Rubric" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLASSIFICATION_RUBRIC_KEYS.map((preset) => (
<SelectItem key={preset} value={preset}>
{CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label}
</SelectItem>
))}
</SelectContent>
</Select>
</SimpleTooltip>
<span className="block text-xs text-muted-foreground">
{restrictedBy(value, "classificationRubric")?.reason ??
(usesCustomPrompt
? "Not in use: the custom prompt below is the classifier's entire rubric."
: CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description)}
</span>
</div>
<div>
<strong className="block mb-1 font-semibold">Classifier Prompt</strong>
{value.custom_tier_set ? (
<CustomTierPromptEditor
classificationPrompt={value.classification_prompt}
onChange={handleClassificationPromptChange}
tierRows={value.custom_tier_set.tiers}
contextWindowSize={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
/>
) : (
<ClassifierPromptEditor
systemPrompt={value.classifier_llm_config?.system_prompt}
onChange={handleClassifierSystemPromptChange}
@ -582,23 +461,6 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
tierLabels={value.tier_labels}
classificationRubric={classificationRubric}
/>
) : (
<OpeningPromptEditor
classificationPrompt={value.classification_prompt}
classificationExamples={value.classification_examples}
onChange={handleClassificationPromptChange}
tierSource={
value.custom_tier_set
? { kind: "custom", tierRows: value.custom_tier_set.tiers }
: {
kind: "builtIn",
tierLabels: value.tier_labels,
classificationRubric,
rubricRestriction: restrictedBy(value, "classificationRubric")?.reason,
}
}
contextWindowSize={value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE}
/>
)}
</div>
</div>
@ -666,9 +528,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
className="w-full"
/>
<span className="text-xs text-muted-foreground">
Number of prior user turns sent to the classifier provider, excluding tool output and harness reminders.
LLM and JEV default to 3 turns; JEV sends them to the configured TypeSafe endpoint. Set to 0 to omit
conversation history. The current message and selected system text are still sent.
Number of prior user turns (tool output and harness reminders excluded) sent to the classifier as context,
so a referring follow-up like &quot;now do the same for the streaming path&quot; is classified against
what it refers to. Set to 0 to send only the current message.
</span>
</div>
<div>

View file

@ -1,3 +1,6 @@
import type { JevClassifierConfig } from "./jev_classifier_config";
import { type ClassifierType, usesLlmClassifier } from "./classifier_types";
export { type ClassifierType, usesLlmClassifier, usesClassifierContext } from "./classifier_types";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
@ -56,6 +59,7 @@ export const DEFAULT_SESSION_AFFINITY = false;
export const DEFAULT_DEPLOYMENT_AFFINITY = true;
export type ComplexityTiers = {
NON_REASONING?: string[];
SIMPLE: string[];
MEDIUM: string[];
COMPLEX: string[];
@ -116,16 +120,6 @@ export interface ClassifierLLMConfig {
system_prompt?: string;
}
export type ClassifierType = "heuristic" | "llm" | "heuristic_first";
/**
* Whether this router can call classifier_llm_config.model. Mirrors the backend's
* ComplexityRouterConfig.uses_llm_classifier, and is the single gate for every classifier-only
* control and payload key, so a new chaining type cannot strip knobs the operator set.
*/
export const usesLlmClassifier = (classifierType: ClassifierType): boolean =>
classifierType === "llm" || classifierType === "heuristic_first";
export type ClassifierFallback = "heuristic" | "default_model";
export const DEFAULT_CLASSIFIER_FALLBACK: ClassifierFallback = "heuristic";
@ -159,7 +153,7 @@ export const heuristicScoringRole = (value: ComplexityRouterConfigValue): Heuris
// Derived, never written into the value, so undoing a tier edit reverts the form with nothing left behind.
export const effectiveClassifierType = (
value: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type">,
): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type);
): ClassifierType => (value.custom_tier_set && value.classifier_type !== "jev" ? "llm" : value.classifier_type);
const rowOrigin = (row: TierRow, editing: boolean): string => {
if (!editing) return row.id;
@ -376,6 +370,7 @@ export interface ComplexityRouterConfigValue {
default_model?: string;
classifier_type: ClassifierType;
classifier_llm_config?: ClassifierLLMConfig;
jev_classifier_config?: JevClassifierConfig;
classifier_context_window_size?: number;
classifier_context_budget_chars?: number;
classifier_context_per_turn_chars?: number;
@ -383,8 +378,12 @@ export interface ComplexityRouterConfigValue {
classifier_fallback?: ClassifierFallback;
/** Opening instructions only; the router appends the tier bullets and the injection guard after them. */
classification_prompt?: string;
classification_examples?: string;
/** Highest tier the scorer may decide alone under heuristic_first. Required by that type, rejected by the others. */
heuristic_first_max_tier?: string;
hybrid_boundary_margin?: number;
/** Opt into the NON_REASONING tier below SIMPLE; off keeps the four-tier ladder. */
enable_non_reasoning_tier?: boolean;
session_affinity?: boolean;
deployment_affinity?: boolean;
/** Plan-mode floor as a tier ROW ID, unset meaning off. The wire carries the row's name. */
@ -444,6 +443,11 @@ export const TIER_DESCRIPTIONS: Record<
keyof ComplexityTiers,
{ label: string; description: string; examples: string }
> = {
NON_REASONING: {
label: "Non-reasoning",
description: "Operational relay work: passing information along with no judgment about it",
examples: '"Reformat this tool output", "Acknowledge the write succeeded"',
},
SIMPLE: {
label: "Simple",
description: "Basic questions, greetings, simple factual queries",
@ -473,6 +477,8 @@ export const effectiveTierLabel = (tier: keyof ComplexityTiers, tierLabels: Comp
export const DEFAULT_HEURISTIC_FIRST_MAX_TIER = "SIMPLE";
export const DEFAULT_HYBRID_BOUNDARY_MARGIN = 0.03;
/**
* Tiers the heuristic_first threshold may name. The top tier is excluded because it would short
* circuit every request and leave the classifier unreachable, which the backend rejects.
@ -895,8 +901,3 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
};
export default ComplexityRouterConfig;
import type { JevClassifierConfig } from "./jev_classifier_config";
import { type ClassifierType } from "./classifier_types";
export { type ClassifierType, usesLlmClassifier, usesClassifierContext } from "./classifier_types";
jev_classifier_config?: JevClassifierConfig;

View file

@ -98,16 +98,12 @@ describe("JEV classifier editor", () => {
it("uses built-in JEV without a license and preserves custom tiers and context through reload", () => {
renderWithProviders(<Form />);
expect(screen.getByLabelText("Classifier Model")).toBeInTheDocument();
expect(screen.getByText("Reasoning Effort")).toBeInTheDocument();
expect(screen.getByText("Classifier Prompt")).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Use images for classification" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: /JEV Classifier/ }));
expect(screen.getByLabelText("JEV Model")).toHaveValue("jev-latest");
expect(screen.getByLabelText("JEV Instructions")).toBeDisabled();
expect(screen.queryByLabelText("Classifier Model")).not.toBeInTheDocument();
expect(screen.queryByText("Reasoning Effort")).not.toBeInTheDocument();
expect(screen.queryByText("Classifier Prompt")).not.toBeInTheDocument();
expect(screen.queryByRole("switch", { name: "Use images for classification" })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("JEV Model"), { target: { value: "jev-test" } });
fireEvent.change(screen.getByLabelText("JEV Timeout (ms)"), { target: { value: "4200" } });
fireEvent.change(screen.getByLabelText("Context Window Size"), { target: { value: "6" } });

View file

@ -1,6 +1,6 @@
import { renderWithProviders, screen, waitFor, within, fireEvent, testQueryClient } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AddAutoRouterTab from "./add_auto_router_tab";
import { toast } from "@/lib/toast";
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
@ -15,40 +15,6 @@ vi.mock(
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
);
it("preserves a JEV preset's per-turn bound in the create request", async () => {
vi.clearAllMocks();
testQueryClient.clear();
vi.mocked(handleAddAutoRouterSubmit).mockReset();
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
vi.mocked(useAutoRouterPresets).mockReturnValue({
...LOADED_PRESETS_QUERY,
data: [
{
...ANTHROPIC_PRESET,
key: "bounded_jev",
label: "Bounded JEV",
complexity_router_config: {
...ANTHROPIC_PRESET.complexity_router_config,
classifier_type: "jev",
jev_classifier_config: { model: "jev-test", timeout_ms: 3000 },
classifier_context_per_turn_chars: 450,
},
},
],
});
renderWithProviders(<Harness />);
await waitForPresetEnabled("Bounded JEV");
await selectTemplate("Bounded JEV");
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "bounded-router" } });
fireEvent.click(screen.getByRole("button", { name: "Add Auto Router" }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledOnce());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject({
classifier_type: "jev",
classifier_context_per_turn_chars: 450,
});
});
const ANTHROPIC_PRESET = getPresetByKey("anthropic_family")!;
const ANTHROPIC_TIERS = ANTHROPIC_PRESET.complexity_router_config.tiers;
@ -499,6 +465,39 @@ describe("AddAutoRouterTab", () => {
expect(labels).toEqual(["Anthropic Family", "Gemini Family", "Lite", "OpenAI Family", "Custom Configuration"]);
});
it("preserves a JEV preset's per-turn bound in the create request", async () => {
const presets = getAllPresets();
const anthropic = getPresetByKey("anthropic_family")!;
const boundedJev = {
...anthropic,
key: "bounded_jev",
label: "Bounded JEV",
complexity_router_config: {
...anthropic.complexity_router_config,
classifier_type: "jev" as const,
jev_classifier_config: { model: "jev-test", timeout_ms: 3000 },
classifier_context_per_turn_chars: 450,
},
};
presets.push(boundedJev);
try {
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
await waitForPresetEnabled("Bounded JEV");
await selectTemplate("Bounded JEV");
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "bounded-router" } });
fireEvent.click(screen.getByRole("button", { name: "Add Auto Router" }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledOnce());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject({
classifier_type: "jev",
classifier_context_per_turn_chars: 450,
});
} finally {
presets.pop();
}
});
describe("routing test", () => {
it("offers no routing test until the config is complete enough to route", async () => {
const actual = await vi.importActual<typeof import("./build_complexity_router_config")>(

View file

@ -79,6 +79,15 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
<p className="text-sm text-muted-foreground">
No complexity tiers are configured yet, so there is nothing to test.
</p>
);
}
return (
<div className="space-y-3">
<p className="mb-2 text-sm text-muted-foreground">
Each configured tier routes to a saved model group. Test Connection sends a minimal request through the proxy to
each one, exactly as the auto router would.
</p>
{jevRequest && (
<div role="status" aria-label="JEV connection" className="rounded-lg border p-3 text-sm">
<strong>JEV Classifier</strong>
@ -89,38 +98,6 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
</p>
</div>
)}
);
}
export function AutoRouterConnectionTestDialog({
open,
onClose,
testId,
...props
}: AutoRouterConnectionTestProps & { open: boolean; onClose: () => void; testId: number }) {
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>Connection Test Results</DialogTitle>
</DialogHeader>
{open && <AutoRouterConnectionTest key={testId} {...props} />}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<div className="space-y-3">
<p className="mb-2 text-sm text-muted-foreground">
Each configured tier routes to a saved model group. Test Connection sends a minimal request through the proxy to
each one, exactly as the auto router would.
</p>
{targets.map((target, index) => {
const result = results[index] ?? { status: "pending" };
return (
@ -160,3 +137,26 @@ export function AutoRouterConnectionTestDialog({
};
export default AutoRouterConnectionTest;
export function AutoRouterConnectionTestDialog({
open,
onClose,
testId,
...props
}: AutoRouterConnectionTestProps & { open: boolean; onClose: () => void; testId: number }) {
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>Connection Test Results</DialogTitle>
</DialogHeader>
{open && <AutoRouterConnectionTest key={testId} {...props} />}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -119,8 +119,10 @@ export interface BuildComplexityRouterConfigParams {
tierLabels: ComplexityTierLabels | undefined;
classifierType: ClassifierType;
classifierLlmConfig: ClassifierLLMConfig | undefined;
jevClassifierConfig?: JevClassifierConfig;
classifierContextWindowSize: number | undefined;
classifierContextBudgetChars: number | undefined;
classifierContextPerTurnChars: number | undefined;
classifierContextIncludeAssistantTurns: boolean | undefined;
classifierFallback: ClassifierFallback | undefined;
classificationPrompt: string | undefined;
@ -170,7 +172,7 @@ export interface ComplexityRouterConfigPayload {
tier_labels?: ComplexityTierLabels;
classifier_type: ClassifierType;
classifier_llm_config?: ClassifierLLMConfig;
jev_classifier_config?: unknown;
jev_classifier_config?: JevClassifierConfig;
classifier_context_window_size?: number;
classifier_context_budget_chars?: number;
classifier_context_per_turn_chars?: number;
@ -267,8 +269,15 @@ export const getKeywordTierRulesError = (
// An edited tier set forces the LLM classifier, so the model requirement follows the EFFECTIVE type.
// Both forms' submit gates and their submit handlers read this one answer so they cannot drift.
export const getClassifierModelError = (
config: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type" | "classifier_llm_config">,
config: Pick<
ComplexityRouterConfigValue,
"custom_tier_set" | "classifier_type" | "classifier_llm_config" | "jev_classifier_config"
>,
): string | null => {
if (effectiveClassifierType(config) === "jev") {
const parsed = jevClassifierConfigSchema.safeParse(config.jev_classifier_config ?? {});
return parsed.success ? null : "Enter a JEV model, a positive whole-number timeout and a positive cooldown";
}
if (!usesLlmClassifier(effectiveClassifierType(config)) || config.classifier_llm_config?.model) return null;
return config.custom_tier_set
? "Please select a classifier model: an edited tier set routes with the LLM classifier"
@ -288,12 +297,21 @@ export const getSemanticConfigError = ({
return null;
};
export const customTierWireFields = (
customTierSet: CustomTierSet,
classifierLlmConfig: ClassifierLLMConfig | undefined,
planModeMinTierId: string | undefined,
classificationPrompt: string | undefined,
): Partial<ComplexityRouterConfigPayload> => {
interface CustomTierWireFieldInputs {
customTierSet: CustomTierSet;
classifierType: ClassifierType;
classifierLlmConfig: ClassifierLLMConfig | undefined;
planModeMinTierId: string | undefined;
classificationPrompt: string | undefined;
}
export const customTierWireFields = ({
customTierSet,
classifierType,
classifierLlmConfig,
planModeMinTierId,
classificationPrompt,
}: CustomTierWireFieldInputs): Partial<ComplexityRouterConfigPayload> => {
const rows = customTierSet.tiers;
const fallback = tierRowById(rows, customTierSet.fallback_tier_id);
const floor = tierRowById(rows, planModeMinTierId);
@ -301,24 +319,26 @@ export const customTierWireFields = (
tiers: Object.fromEntries(rows.map((row) => [activeTierName(row), row.models])),
tier_definitions: tierDefinitionsFromRows(rows),
...(fallback && { fallback_tier: activeTierName(fallback) }),
classifier_type: "llm",
classifier_type: classifierType === "jev" ? "jev" : "llm",
// Rebuilt from the two fields an edited tier set allows. The backend rejects system_prompt and
// classification_rubric beside tier_definitions, and both live inside this object rather than at
// the top level the omit list covers. The opening instructions ride classification_prompt below.
...(classifierLlmConfig && {
classifier_llm_config: {
model: classifierLlmConfig.model,
timeout_ms: classifierLlmConfig.timeout_ms,
...(classifierLlmConfig.circuit_breaker_enabled !== undefined && {
circuit_breaker_enabled: classifierLlmConfig.circuit_breaker_enabled,
}),
...(classifierLlmConfig.circuit_breaker_cooldown_seconds !== undefined && {
circuit_breaker_cooldown_seconds: classifierLlmConfig.circuit_breaker_cooldown_seconds,
}),
},
}),
...(classifierType !== "jev" &&
classifierLlmConfig && {
classifier_llm_config: {
model: classifierLlmConfig.model,
timeout_ms: classifierLlmConfig.timeout_ms,
...(classifierLlmConfig.circuit_breaker_enabled !== undefined && {
circuit_breaker_enabled: classifierLlmConfig.circuit_breaker_enabled,
}),
...(classifierLlmConfig.circuit_breaker_cooldown_seconds !== undefined && {
circuit_breaker_cooldown_seconds: classifierLlmConfig.circuit_breaker_cooldown_seconds,
}),
},
}),
session_affinity: false,
...(classificationPrompt?.trim() && { classification_prompt: classificationPrompt.trim() }),
...(classifierType !== "jev" &&
classificationPrompt?.trim() && { classification_prompt: classificationPrompt.trim() }),
...(floor && { plan_mode_min_tier: activeTierName(floor) }),
};
};
@ -374,6 +394,7 @@ const classifierWireFields = (
heuristicFirstMaxTier,
classifierContextWindowSize,
classifierContextBudgetChars,
classifierContextPerTurnChars,
classifierContextIncludeAssistantTurns,
}: Pick<
BuildComplexityRouterConfigParams,
@ -382,24 +403,29 @@ const classifierWireFields = (
| "heuristicFirstMaxTier"
| "classifierContextWindowSize"
| "classifierContextBudgetChars"
| "classifierContextPerTurnChars"
| "classifierContextIncludeAssistantTurns"
>,
): Partial<ComplexityRouterConfigPayload> => ({
...(usesLlmClassifier(effectiveType) &&
classifierLlmConfig && { classifier_llm_config: normalizeClassifierLlmConfig(classifierLlmConfig) }),
...(usesLlmClassifier(effectiveType) &&
...(usesClassifierContext(effectiveType) &&
classifierFallback !== undefined && { classifier_fallback: classifierFallback }),
...(effectiveType === "heuristic_first" &&
heuristicFirstMaxTier?.trim() && { heuristic_first_max_tier: heuristicFirstMaxTier }),
...(usesLlmClassifier(effectiveType) &&
...(usesClassifierContext(effectiveType) &&
classifierContextWindowSize !== undefined && {
classifier_context_window_size: classifierContextWindowSize,
}),
...(usesLlmClassifier(effectiveType) &&
...(usesClassifierContext(effectiveType) &&
classifierContextBudgetChars !== undefined && {
classifier_context_budget_chars: classifierContextBudgetChars,
}),
...(usesLlmClassifier(effectiveType) &&
...(usesClassifierContext(effectiveType) &&
classifierContextPerTurnChars !== undefined && {
classifier_context_per_turn_chars: classifierContextPerTurnChars,
}),
...(usesClassifierContext(effectiveType) &&
classifierContextIncludeAssistantTurns !== undefined && {
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
}),
@ -413,8 +439,10 @@ export const buildComplexityRouterConfig = ({
tierLabels,
classifierType,
classifierLlmConfig,
jevClassifierConfig,
classifierContextWindowSize,
classifierContextBudgetChars,
classifierContextPerTurnChars,
classifierContextIncludeAssistantTurns,
classifierFallback,
classificationPrompt,
@ -462,11 +490,12 @@ export const buildComplexityRouterConfig = ({
heuristicFirstMaxTier,
classifierContextWindowSize,
classifierContextBudgetChars,
classifierContextPerTurnChars,
classifierContextIncludeAssistantTurns,
};
// An edited tier set forces the LLM classifier, so llm-only inputs must survive a classifier_type
// the form never rewrote. The UI gates the same controls on this, not on the raw value.
const effectiveType: ClassifierType = customTierSet ? "llm" : classifierType;
const effectiveType = effectiveClassifierType({ custom_tier_set: customTierSet, classifier_type: classifierType });
const payload: ComplexityRouterConfigPayload = {
tiers,
@ -475,6 +504,7 @@ export const buildComplexityRouterConfig = ({
...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }),
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
classifier_type: classifierType,
...(effectiveType === "jev" && { jev_classifier_config: normalizeJevClassifierConfig(jevClassifierConfig) }),
...classifierWireFields(effectiveType, classifierInputs),
session_affinity: sessionAffinity,
deployment_affinity: deploymentAffinity,
@ -499,25 +529,12 @@ export const buildComplexityRouterConfig = ({
const kept = Object.fromEntries(
Object.entries(payload).filter(([key]) => !CUSTOM_TIER_STRIPPED_KEYS.includes(key)),
) as ComplexityRouterConfigPayload;
return {
...kept,
...customTierWireFields(customTierSet, classifierLlmConfig, planModeMinTier, classificationPrompt),
const customTierInputs: CustomTierWireFieldInputs = {
customTierSet,
classifierType,
classifierLlmConfig,
planModeMinTierId: planModeMinTier,
classificationPrompt,
};
return { ...kept, ...customTierWireFields(customTierInputs) };
};
classifier_context_per_turn_chars?: unknown;
jevClassifierConfig?: JevClassifierConfig;
classifierContextPerTurnChars?: number;
jev_classifier_config?: JevClassifierConfig;
if (effectiveClassifierType(config) === "jev") {
const parsed = jevClassifierConfigSchema.safeParse(config.jev_classifier_config ?? {});
return parsed.success ? null : "Enter a JEV model, a positive whole-number timeout and a positive cooldown";
}
classifierType?: ClassifierType;
classifierContextPerTurnChars,
| "classifierContextPerTurnChars"
jevClassifierConfig,
classifierContextPerTurnChars,
classifierContextPerTurnChars,
...(effectiveType === "jev" && { jev_classifier_config: normalizeJevClassifierConfig(jevClassifierConfig) }),
classifierType: effectiveType,

View file

@ -622,7 +622,12 @@ describe("managed keys survive an untouched open-and-save", () => {
// tier_definitions, fallback_tier and classification_prompt cannot sit beside heuristic_first, which
// this fixture uses, so no single stored config can hold every managed key. They get their own round
// trip below.
const CUSTOM_TIER_ONLY_KEYS = new Set(["tier_definitions", "fallback_tier", "classification_prompt"]);
const CUSTOM_TIER_ONLY_KEYS = new Set([
"tier_definitions",
"fallback_tier",
"classification_prompt",
"jev_classifier_config",
]);
it("carries every managed key a built-in router can hold through hydrate then save", () => {
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);

View file

@ -1,12 +1,7 @@
import { usesClassifierContext } from "../add_model/classifier_types";
import { defaultJevClassifierConfig, jevClassifierConfigSchema } from "../add_model/jev_classifier_config";
import React, { useEffect, useMemo, useState } from "react";
import {
complexityRouterSchema,
semanticRouterSchema,
EMPTY_FORM_VALUES,
type EditAutoRouterFormValues,
} from "./editAutoRouterFormSchema";
import { z } from "zod/v4";
import { toast } from "@/lib/toast";
import { CircleHelp } from "lucide-react";
import { FieldGroup } from "@/components/ui/field";
@ -20,8 +15,8 @@ import AccessGroupTagsCombobox from "../add_model/AccessGroupTagsCombobox";
import ModelChoiceCombobox, { type ModelChoice } from "../add_model/ModelChoiceCombobox";
import { modelAvailableCall, modelPatchUpdateCall, validateAutoRouterConfig } from "../networking";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import RouterConfigBuilder, { type RouterConfig, serializeRouterConfig } from "../add_model/RouterConfigBuilder";
import { hydrateTierModelParams } from "../add_model/complexity_router_tiers";
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
import { hydrateTierModelParams, normalizeTierModels } from "../add_model/complexity_router_tiers";
import {
type ActiveTierSet,
CUSTOM_TIER_OMITTED_KEYS,
@ -35,13 +30,11 @@ import {
type BuildComplexityRouterConfigParams,
buildComplexityRouterConfig,
getClassifierModelError,
getClassifierReasoningEffortError,
getKeywordTierRulesError,
getMissingTiersError,
getSemanticConfigError,
getPlanModeTierError,
getTierLabelsError,
hydrateBuiltInTiers,
hydrateCustomTierSet,
hydratePlanModeMinTier,
hydrateTierLabels,
@ -49,14 +42,7 @@ import {
} from "../add_model/build_complexity_router_config";
import { KeywordTierRule } from "../add_model/KeywordTierRules";
import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching";
import {
type AutoRouterCompressionState,
buildAutoRouterCompressionPatch,
DEFAULT_AUTO_ROUTER_COMPRESSION,
hydrateAutoRouterCompression,
} from "../add_model/buildAutoRouterCompression";
import { hydrateKeywordTierRules } from "../add_model/complexity_router_keywords";
import { customDimensionsError, hydrateCustomDimensions } from "../add_model/custom_dimensions";
import {
hydrateDimensionWeights,
hydrateReasoningOverrideMinScore,
@ -69,8 +55,8 @@ import ComplexityRouterConfig, {
ClassifierLLMConfig,
ClassifierType,
ComplexityRouterConfigValue,
ComplexityTiers,
effectiveClassifierType,
heuristicScoringRole,
DEFAULT_ADAPTIVE_WEIGHTS,
DEFAULT_SESSION_AFFINITY,
DEFAULT_DEPLOYMENT_AFFINITY,
@ -100,15 +86,12 @@ interface EditAutoRouterModalProps {
/** 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?: Record<string, unknown>;
enable_non_reasoning_tier?: boolean;
tiers?: Partial<Record<keyof ComplexityTiers, unknown>>;
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;
@ -118,27 +101,17 @@ export interface StoredComplexityRouterConfig {
classifier_context_per_turn_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;
}
/**
@ -149,14 +122,18 @@ export const hydrateComplexityRouterConfig = (
parsedConfig: StoredComplexityRouterConfig,
complexityRouterDefaultModel: string | null | undefined,
): ComplexityRouterConfigValue => {
const builtIn = hydrateBuiltInTiers(parsedConfig.tiers, parsedConfig.enable_non_reasoning_tier);
const { tiers: hydratedTiers, enable_non_reasoning_tier } = builtIn;
const hydratedTiers: ComplexityTiers = {
SIMPLE: normalizeTierModels(parsedConfig.tiers?.SIMPLE),
MEDIUM: normalizeTierModels(parsedConfig.tiers?.MEDIUM),
COMPLEX: normalizeTierModels(parsedConfig.tiers?.COMPLEX),
REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING),
};
const custom_tier_set = hydrateCustomTierSet(parsedConfig);
const activeTiers = { ...builtIn, custom_tier_set };
const activeTiers = { tiers: hydratedTiers, custom_tier_set };
return {
tiers: hydratedTiers,
enable_non_reasoning_tier,
custom_tier_set,
tier_model_params: tierParamsByRowId(
hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs),
@ -196,35 +173,16 @@ export const hydrateComplexityRouterConfig = (
typeof parsedConfig.classification_prompt === "string" && parsedConfig.classification_prompt.trim() !== ""
? parsedConfig.classification_prompt
: undefined,
classification_examples:
typeof parsedConfig.classification_examples === "string" && parsedConfig.classification_examples.trim() !== ""
? parsedConfig.classification_examples
: undefined,
heuristic_first_max_tier:
typeof parsedConfig.heuristic_first_max_tier === "string" && parsedConfig.heuristic_first_max_tier.trim() !== ""
? parsedConfig.heuristic_first_max_tier
: undefined,
hybrid_boundary_margin:
typeof parsedConfig.hybrid_boundary_margin === "number" ? parsedConfig.hybrid_boundary_margin : undefined,
classification_mode:
parsedConfig.classification_mode === "user_turn" || parsedConfig.classification_mode === "every_request"
? parsedConfig.classification_mode
: undefined,
tier_boundaries: hydrateTierBoundaries(parsedConfig.tier_boundaries),
token_thresholds: hydrateTokenThresholds(parsedConfig.token_thresholds),
dimension_weights: hydrateDimensionWeights(parsedConfig.dimension_weights),
custom_dimensions: hydrateCustomDimensions(parsedConfig.custom_dimensions),
reasoning_override_min_score: hydrateReasoningOverrideMinScore(parsedConfig.reasoning_override_min_score),
session_affinity:
typeof parsedConfig.session_affinity === "boolean" ? parsedConfig.session_affinity : DEFAULT_SESSION_AFFINITY,
session_affinity_ttl_seconds:
typeof parsedConfig.session_affinity_ttl_seconds === "number" &&
Number.isFinite(parsedConfig.session_affinity_ttl_seconds)
? parsedConfig.session_affinity_ttl_seconds
: undefined,
modality_routing: typeof parsedConfig.modality_routing === "boolean" ? parsedConfig.modality_routing : false,
modality_pin_override:
typeof parsedConfig.modality_pin_override === "boolean" ? parsedConfig.modality_pin_override : false,
deployment_affinity:
typeof parsedConfig.deployment_affinity === "boolean"
? parsedConfig.deployment_affinity
@ -234,27 +192,11 @@ export const hydrateComplexityRouterConfig = (
tier_distance_penalty: parsedConfig.tier_distance_penalty,
adaptive_eligible: parsedConfig.adaptive_eligible || "all",
return_raw_model_name: parsedConfig.return_raw_model_name || false,
enable_context_window_escalation:
typeof parsedConfig.enable_context_window_escalation === "boolean"
? parsedConfig.enable_context_window_escalation
: undefined,
context_window_escalation_buffer:
typeof parsedConfig.context_window_escalation_buffer === "number"
? parsedConfig.context_window_escalation_buffer
: undefined,
stall_escalation_enabled: parsedConfig.stall_escalation_enabled === true || undefined,
stall_escalation_window:
typeof parsedConfig.stall_escalation_window === "number" ? parsedConfig.stall_escalation_window : undefined,
stall_escalation_repeat_threshold:
typeof parsedConfig.stall_escalation_repeat_threshold === "number"
? parsedConfig.stall_escalation_repeat_threshold
: undefined,
};
};
export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"tiers",
"enable_non_reasoning_tier",
"tier_definitions",
"fallback_tier",
"tier_model_configs",
@ -269,14 +211,8 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"classifier_context_include_assistant_turns",
"classifier_fallback",
"classification_prompt",
"classification_examples",
"heuristic_first_max_tier",
"hybrid_boundary_margin",
"classification_mode",
"session_affinity",
"session_affinity_ttl_seconds",
"modality_routing",
"modality_pin_override",
"deployment_affinity",
"adaptive",
"adaptive_weights",
@ -286,13 +222,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"tier_boundaries",
"token_thresholds",
"dimension_weights",
"custom_dimensions",
"reasoning_override_min_score",
"enable_context_window_escalation",
"context_window_escalation_buffer",
"stall_escalation_enabled",
"stall_escalation_window",
"stall_escalation_repeat_threshold",
]);
// Managed only when the caller passes the corresponding state. A caller that does not render
@ -339,8 +269,8 @@ export interface KeywordMatchingState {
}
// A custom save drops the stored keys an edited tier set forbids. classification_prompt needs no
// entry here: it is a managed key, so every save rewrites it from form state and the builder
// re-emits it on both branches only when the form still holds one.
// entry here: it is a managed key, so a built-in save already drops it through isManaged and the
// built-in branch of the builder never re-emits it.
const customTierDroppedKeys = (value: ComplexityRouterConfigValue): readonly string[] =>
value.custom_tier_set ? CUSTOM_TIER_OMITTED_KEYS : [];
@ -351,8 +281,9 @@ export const buildUpdatedComplexityRouterConfig = (
keywordMatching?: KeywordMatchingState,
): Record<string, unknown> => {
const isManaged = (key: string): boolean => {
if (key === "classifier_context_per_turn_chars")
if (key === "classifier_context_per_turn_chars") {
return !usesClassifierContext(effectiveClassifierType(value)) || Object.prototype.hasOwnProperty.call(value, key);
}
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";
@ -364,28 +295,21 @@ export const buildUpdatedComplexityRouterConfig = (
const builderParams: BuildComplexityRouterConfigParams = {
tiers: value.tiers,
enableNonReasoningTier: value.enable_non_reasoning_tier,
customTierSet: value.custom_tier_set,
defaultModel: value.default_model,
planModeMinTier: value.plan_mode_min_tier,
classificationPrompt: value.classification_prompt,
classificationExamples: value.classification_examples,
heuristicFirstMaxTier: value.heuristic_first_max_tier,
hybridBoundaryMargin: value.hybrid_boundary_margin,
classificationMode: value.classification_mode,
tierLabels: value.tier_labels,
classifierType: value.classifier_type,
jevClassifierConfig: value.jev_classifier_config,
classifierLlmConfig: value.classifier_llm_config,
jevClassifierConfig: value.jev_classifier_config,
classifierContextWindowSize: value.classifier_context_window_size,
classifierContextBudgetChars: value.classifier_context_budget_chars,
classifierContextPerTurnChars: value.classifier_context_per_turn_chars,
classifierContextIncludeAssistantTurns: value.classifier_context_include_assistant_turns,
classifierFallback: value.classifier_fallback,
sessionAffinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY,
sessionAffinityTtlSeconds: value.session_affinity_ttl_seconds,
modalityRouting: value.modality_routing ?? false,
modalityPinOverride: value.modality_pin_override ?? false,
deploymentAffinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY,
customTechnicalKeywords: customTechnicalKeywords ?? [],
keywordTierRules: keywordMatching?.keywordTierRules ?? [],
@ -401,14 +325,8 @@ export const buildUpdatedComplexityRouterConfig = (
tierBoundaries: value.tier_boundaries,
tokenThresholds: value.token_thresholds,
dimensionWeights: value.dimension_weights,
customDimensions: value.custom_dimensions,
reasoningOverrideMinScore: value.reasoning_override_min_score,
tierModelParams: value.tier_model_params,
enableContextWindowEscalation: value.enable_context_window_escalation,
contextWindowEscalationBuffer: value.context_window_escalation_buffer,
stallEscalationEnabled: value.stall_escalation_enabled,
stallEscalationWindow: value.stall_escalation_window,
stallEscalationRepeatThreshold: value.stall_escalation_repeat_threshold,
};
const built = buildComplexityRouterConfig(builderParams);
@ -423,6 +341,35 @@ export const buildUpdatedComplexityRouterConfig = (
};
};
const sharedShape = {
auto_router_name: z.string().min(1, "Auto router name is required"),
model_access_group: z.array(z.string()),
};
const complexityRouterShape = {
...sharedShape,
auto_router_default_model: z.string(),
auto_router_embedding_model: z.string(),
};
const semanticRouterShape = {
...sharedShape,
auto_router_default_model: z.string().min(1, "Default model is required"),
auto_router_embedding_model: z.string().min(1, "Embedding model is required"),
};
const complexityRouterSchema = z.object(complexityRouterShape);
const semanticRouterSchema = z.object(semanticRouterShape);
type EditAutoRouterFormValues = z.infer<typeof semanticRouterSchema>;
const EMPTY_FORM_VALUES: EditAutoRouterFormValues = {
auto_router_name: "",
auto_router_default_model: "",
auto_router_embedding_model: "",
model_access_group: [],
};
const labelWithHint = (label: string, hint: string): React.ReactNode => (
<>
{label}
@ -446,16 +393,13 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
const [editingTiers, setEditingTiers] = useState(false);
const [routerConfig, setRouterConfig] = useState<RouterConfig | null>(null);
const [routerConfig, setRouterConfig] = useState<any>(null);
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState<string[]>([]);
const [keywordTierRules, setKeywordTierRules] = useState<KeywordTierRule[]>([]);
const [escalationKeywords, setEscalationKeywords] = useState<string[]>([]);
const [semanticMatchingEnabled, setSemanticMatchingEnabled] = useState<boolean>(false);
const [embeddingModel, setEmbeddingModel] = useState<string | undefined>(undefined);
const [matchThreshold, setMatchThreshold] = useState<number>(DEFAULT_MATCH_THRESHOLD);
const [autoRouterCompression, setAutoRouterCompression] = useState<AutoRouterCompressionState>(
DEFAULT_AUTO_ROUTER_COMPRESSION,
);
const [complexityRouterConfig, setComplexityRouterConfig] = useState<ComplexityRouterConfigValue>({
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
@ -481,10 +425,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
: null) ?? getTierLabelsError(complexityRouterConfig.tier_labels)) ??
getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ??
getKeywordTierRulesError(keywordTierRules, activeTierRows(complexityRouterConfig)) ??
getClassifierModelError(complexityRouterConfig) ??
(heuristicScoringRole(complexityRouterConfig) === "decides"
? customDimensionsError(complexityRouterConfig.custom_dimensions)
: null);
getClassifierModelError(complexityRouterConfig);
useEffect(() => {
if (isVisible && modelData) {
@ -551,12 +492,6 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
setMatchThreshold(
typeof parsedConfig.match_threshold === "number" ? parsedConfig.match_threshold : DEFAULT_MATCH_THRESHOLD,
);
setAutoRouterCompression(
hydrateAutoRouterCompression({
auto_router_routing_compression: modelData.litellm_params?.auto_router_routing_compression,
auto_router_model_compression: modelData.litellm_params?.auto_router_model_compression,
}),
);
form.reset({
...EMPTY_FORM_VALUES,
@ -581,8 +516,8 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
// Set form values
form.reset({
auto_router_name: modelData.model_name,
auto_router_default_model: modelData.litellm_params?.auto_router_default_model || null,
auto_router_embedding_model: modelData.litellm_params?.auto_router_embedding_model || null,
auto_router_default_model: modelData.litellm_params?.auto_router_default_model || "",
auto_router_embedding_model: modelData.litellm_params?.auto_router_embedding_model || "",
model_access_group: modelData.model_info?.access_groups || [],
});
} catch (error) {
@ -604,22 +539,12 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
toast.fromError(tierSetError);
return;
}
const classifierError =
getClassifierModelError(complexityRouterConfig) ??
(heuristicScoringRole(complexityRouterConfig) === "decides"
? customDimensionsError(complexityRouterConfig.custom_dimensions)
: null);
const classifierError = getClassifierModelError(complexityRouterConfig);
if (classifierError) {
setShowValidationErrors(true);
toast.fromError(classifierError);
return;
}
const classifierEffortError = getClassifierReasoningEffortError(complexityRouterConfig, modelInfo);
if (classifierEffortError) {
setShowValidationErrors(true);
toast.fromError(classifierEffortError);
return;
}
// Same guards the create form applies (add_auto_router_tab.tsx). The backend rejects a
// keyword rule with no keyword, and semantic_keyword_matching without an embedding model
// or keyword rules (complexity_router/config.py), so without these a save fails as a raw
@ -673,7 +598,6 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
...modelData.litellm_params,
complexity_router_config: updatedConfig,
complexity_router_default_model: defaultModel,
...buildAutoRouterCompressionPatch(autoRouterCompression, modelData.litellm_params ?? {}),
};
const updatedModelInfo = {
...modelData.model_info,
@ -700,7 +624,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
// Prepare the updated litellm_params
const updatedLitellmParams = {
...modelData.litellm_params,
auto_router_config: serializeRouterConfig(routerConfig),
auto_router_config: JSON.stringify(routerConfig),
auto_router_default_model: values.auto_router_default_model,
auto_router_embedding_model: values.auto_router_embedding_model || undefined,
};
@ -739,7 +663,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
})();
} catch (error) {
console.error("Error updating auto router:", error);
toast.fromError(error);
toast.fromError("Failed to update auto router configuration");
} finally {
setLoading(false);
}
@ -795,8 +719,6 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
onMatchThresholdChange={setMatchThreshold}
escalationKeywords={escalationKeywords}
onEscalationKeywordsChange={setEscalationKeywords}
autoRouterCompression={autoRouterCompression}
onAutoRouterCompressionChange={setAutoRouterCompression}
/>
</div>
) : (