From 8e4c26fea3cc7e9676cc7580177c00f84867fd3f Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 23 Sep 2026 01:56:40 +0000
Subject: [PATCH] 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.
---
.../add_model/ClassificationMethodConfig.tsx | 264 +++++-------------
.../add_model/ComplexityRouterConfig.tsx | 33 +--
.../JevClassifierConfig.integration.test.tsx | 4 -
.../add_model/add_auto_router_tab.test.tsx | 69 +++--
.../add_model/auto_router_connection_test.tsx | 64 ++---
.../build_complexity_router_config.ts | 111 ++++----
...d_updated_complexity_router_config.test.ts | 7 +-
.../edit_auto_router_modal.tsx | 186 ++++--------
8 files changed, 270 insertions(+), 468 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx
index 3b3343154a3..ecc9e7cccad 100644
--- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx
@@ -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 }> =
How Classification Works
{scoringExplanation(value)}
{scorerRuns && ranges && (
-
+
{effectiveTierLabel("SIMPLE", value.tier_labels)} : Score < {ranges.simpleMedium}
@@ -159,7 +146,6 @@ interface ClassificationMethodConfigProps {
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
modelOptions: { value: string; label: string }[];
- effortOptionsByModel: Record;
customTechnicalKeywords?: string[];
onCustomTechnicalKeywordsChange?: (keywords: string[]) => void;
showValidationErrors?: boolean;
@@ -192,17 +178,6 @@ const ClassifierTypeRadios: React.FC<{
-
-
-
-
- Heuristic v2 {" "}
-
- uses bundled calibrated four-tier probabilities with no API call
-
-
-
-
@@ -228,18 +203,6 @@ const ClassifierTypeRadios: React.FC<{
-
-
-
-
- Hybrid {" "}
-
- keeps the local score at any tier, and only pays for the classifier when that score lands near a tier
- boundary
-
-
-
-
);
@@ -249,7 +212,6 @@ const ClassificationMethodConfig: React.FC = ({
value,
onChange,
modelOptions,
- effortOptionsByModel,
customTechnicalKeywords,
onCustomTechnicalKeywordsChange,
showValidationErrors = false,
@@ -258,16 +220,12 @@ const ClassificationMethodConfig: React.FC = ({
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 = ({
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 = ({
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 = ({
)}
- {classifierType === "hybrid" && (
-
-
Boundary margin
-
handleHybridBoundaryMarginChange(event.target.value)}
- onBlur={() => setDraft(null)}
- className="w-full"
- />
-
- A score further than this from every tier boundary routes on the scorer'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
-
-
- )}
-
-
-
How often to classify
-
- handleClassificationFrequencyChange(frequency as ClassificationFrequency)
- }
- >
-
-
-
-
- Every request {" "}
- : score every turn, tool-result continuations included
-
-
-
-
-
- Every new user message {" "}
-
- : score each new human ask, then hold that tier for the tool calls that follow it
-
-
-
-
-
-
- Once per session {" "}
-
- {sessionFrequencyRestriction?.reason ??
- ": score the first turn only, then hold that tier and its deployment for the whole session"}
-
-
-
-
-
-
- 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
-
-
-
{classifierType === "jev" && }
{usesLlmClassifier(classifierType) && (
@@ -525,12 +368,6 @@ const ClassificationMethodConfig: React.FC = ({
/>
{classifierModelMissing && A classifier model is required }
-
Timeout (ms)
@@ -563,18 +400,60 @@ const ClassificationMethodConfig: React.FC = ({
value={value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS }}
onChange={(classifier_llm_config) => onChange({ ...value, classifier_llm_config })}
/>
- onChange({ ...value, classifier_llm_config })}
- />
- Classifier Prompt
-
+ Classification Rubric
+
- {!value.custom_tier_set && usesCustomPrompt ? (
+
+ ({
+ value: preset,
+ label: CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label,
+ }))}
+ value={classificationRubric}
+ onValueChange={(preset: ClassificationRubric | null) =>
+ preset && handleClassificationRubricChange(preset)
+ }
+ disabled={usesCustomPrompt || Boolean(value.custom_tier_set)}
+ >
+
+
+
+
+ {CLASSIFICATION_RUBRIC_KEYS.map((preset) => (
+
+ {CLASSIFICATION_RUBRIC_DESCRIPTIONS[preset].label}
+
+ ))}
+
+
+
+
+ {restrictedBy(value, "classificationRubric")?.reason ??
+ (usesCustomPrompt
+ ? "Not in use: the custom prompt below is the classifier's entire rubric."
+ : CLASSIFICATION_RUBRIC_DESCRIPTIONS[classificationRubric].description)}
+
+
+
+ Classifier Prompt
+ {value.custom_tier_set ? (
+
+ ) : (
= ({
tierLabels={value.tier_labels}
classificationRubric={classificationRubric}
/>
- ) : (
-
)}
@@ -666,9 +528,9 @@ const ClassificationMethodConfig: React.FC = ({
className="w-full"
/>
- 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 "now do the same for the streaming path" is classified against
+ what it refers to. Set to 0 to send only the current message.
diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx
index f0b5dc817cb..f72c9167209 100644
--- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx
@@ -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
,
-): 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 = ({
};
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;
\ No newline at end of file
diff --git a/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx
index b671c6f50e7..6e7f5537d5f 100644
--- a/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx
@@ -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();
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" } });
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
index 456e69dadc0..2a47b4d86f6 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
@@ -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( );
- 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( );
+ 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(
diff --git a/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx
index 721669100c0..60e6bb0d006 100644
--- a/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx
@@ -79,6 +79,15 @@ const AutoRouterConnectionTest: React.FC = ({
No complexity tiers are configured yet, so there is nothing to test.
+ );
+ }
+
+ return (
+
+
+ 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.
+
{jevRequest && (
JEV Classifier
@@ -89,38 +98,6 @@ const AutoRouterConnectionTest: React.FC
= ({
)}
- );
- }
-
-export function AutoRouterConnectionTestDialog({
- open,
- onClose,
- testId,
- ...props
-}: AutoRouterConnectionTestProps & { open: boolean; onClose: () => void; testId: number }) {
- return (
-
!next && onClose()}>
-
-
- Connection Test Results
-
- {open && }
-
-
- Close
-
-
-
-
- );
-}
-
- return (
-
-
- 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.
-
{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 (
+
!next && onClose()}>
+
+
+ Connection Test Results
+
+ {open && }
+
+
+ Close
+
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
index 2f29b61a68e..c887fb45b2b 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
@@ -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
,
+ 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 => {
+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 => {
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 => ({
...(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,
\ No newline at end of file
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
index afeb2323178..443ffe98f28 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
@@ -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);
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
index 3ebe5aad7e4..3bda331b1e3 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
@@ -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;
- enable_non_reasoning_tier?: boolean;
+ tiers?: Partial>;
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 => {
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;
+
+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 = ({
const [modelInfo, setModelInfo] = useState([]);
const [showValidationErrors, setShowValidationErrors] = useState(false);
const [editingTiers, setEditingTiers] = useState(false);
- const [routerConfig, setRouterConfig] = useState(null);
+ const [routerConfig, setRouterConfig] = useState(null);
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState([]);
const [keywordTierRules, setKeywordTierRules] = useState([]);
const [escalationKeywords, setEscalationKeywords] = useState([]);
const [semanticMatchingEnabled, setSemanticMatchingEnabled] = useState(false);
const [embeddingModel, setEmbeddingModel] = useState(undefined);
const [matchThreshold, setMatchThreshold] = useState(DEFAULT_MATCH_THRESHOLD);
- const [autoRouterCompression, setAutoRouterCompression] = useState(
- DEFAULT_AUTO_ROUTER_COMPRESSION,
- );
const [complexityRouterConfig, setComplexityRouterConfig] = useState({
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
@@ -481,10 +425,7 @@ const EditAutoRouterModal: React.FC = ({
: 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 = ({
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 = ({
// 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 = ({
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 = ({
...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 = ({
// 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 = ({
})();
} 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 = ({
onMatchThresholdChange={setMatchThreshold}
escalationKeywords={escalationKeywords}
onEscalationKeywordsChange={setEscalationKeywords}
- autoRouterCompression={autoRouterCompression}
- onAutoRouterCompressionChange={setAutoRouterCompression}
/>
) : (