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 && (
-
- 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)
- }
- >
-
-
-
-
-
-
-
- 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
-
@@ -525,12 +368,6 @@ const ClassificationMethodConfig: React.FC = ({
/>
{classifierModelMissing && A classifier model is required}
-
@@ -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.
+
- 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.
-