= ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
+
export const effectiveTierLabel = (tier: keyof ComplexityTiers, tierLabels: ComplexityTierLabels | undefined): string =>
tierLabels?.[tier]?.trim() || TIER_DESCRIPTIONS[tier].label;
@@ -528,9 +551,46 @@ 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.
+ * circuit every request and leave the classifier unreachable, which the backend rejects. So is
+ * NON_REASONING, which the backend refuses alongside heuristic_first because the local scorer
+ * cannot produce it.
*/
-export const HEURISTIC_FIRST_MAX_TIER_KEYS = TIER_KEYS.slice(0, -1);
+export const HEURISTIC_FIRST_MAX_TIER_KEYS = BUILT_IN_TIER_ORDER.slice(0, -1);
+
+/**
+ * The opt-in fifth tier. Only offered on the LLM classification method, matching the backend: the
+ * heuristic scorers cannot produce the tier, so enabling it there would buy a rubric bullet and a
+ * model pool that no request ever reaches.
+ */
+const NonReasoningTierToggle: React.FC<{
+ value: ComplexityRouterConfigValue;
+ onChange: (value: ComplexityRouterConfigValue) => void;
+ available: boolean;
+}> = ({ value, onChange, available }) => (
+ <>
+
+ {
+ const { NON_REASONING: _dropped, ...keptTiers } = value.tiers;
+ onChange({
+ ...value,
+ enable_non_reasoning_tier: enabled ? true : undefined,
+ tiers: enabled ? { ...keptTiers, NON_REASONING: value.tiers.NON_REASONING ?? [] } : keptTiers,
+ });
+ }}
+ aria-label="Add a non-reasoning tier"
+ />
+ Add a non-reasoning tier
+
+
+ Adds NON_REASONING below Simple, for operational agent traffic that relays or reformats information rather than
+ reasoning about it. Escalation still moves up out of it when a request needs more.
+ {!available && " Requires the LLM classification method."}
+
+ >
+);
const PlanModeOverrideControls: React.FC<{
value: ComplexityRouterConfigValue;
@@ -742,6 +802,10 @@ const ComplexityRouterConfig: React.FC = ({
);
})}
+ {!customTierSet && (
+
+ )}
+
= ({
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
tiers: complexityRouterConfig.tiers,
+ enableNonReasoningTier: complexityRouterConfig.enable_non_reasoning_tier,
customTierSet: complexityRouterConfig.custom_tier_set,
defaultModel: complexityRouterConfig.default_model,
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
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 7769fb832fe..2f36ec2f43d 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,6 +119,7 @@ const scorerKnobPayload = ({
export interface BuildComplexityRouterConfigParams {
tiers: ComplexityTiers;
+ enableNonReasoningTier?: boolean;
customTierSet?: CustomTierSet;
defaultModel: string | undefined;
planModeMinTier: string | undefined;
@@ -180,6 +181,7 @@ export interface TierDefinitionPayload {
export interface ComplexityRouterConfigPayload {
tiers: ComplexityTiers | Record;
+ enable_non_reasoning_tier?: boolean;
tier_definitions?: TierDefinitionPayload[];
fallback_tier?: string;
default_model?: string;
@@ -460,6 +462,7 @@ const classifierWireFields = (
export const buildComplexityRouterConfig = ({
tiers,
+ enableNonReasoningTier,
customTierSet,
defaultModel,
planModeMinTier,
@@ -535,6 +538,9 @@ export const buildComplexityRouterConfig = ({
const payload: ComplexityRouterConfigPayload = {
tiers,
+ // Only written when on, and never beside a custom tier set: the backend rejects the two
+ // together, and an explicit false on a four-tier router would be a key it never carried.
+ ...(!customTierSet && enableNonReasoningTier && { enable_non_reasoning_tier: true }),
...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }),
...(defaultModel?.trim() && { default_model: defaultModel }),
...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }),
diff --git a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts
index eb58c94b789..3fec63518e5 100644
--- a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts
+++ b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts
@@ -1,6 +1,6 @@
import type { ComplexityTier } from "./KeywordTierRules";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
-import { TIER_ORDER } from "./tier_rows";
+import { ALL_BUILT_IN_TIERS, TIER_ORDER } from "./tier_rows";
export type TierModelParams = Record;
@@ -145,13 +145,14 @@ export const pruneTierModelParams = (
};
export const DEFAULT_TIER_LABELS: Record = {
+ NON_REASONING: "Non-reasoning",
SIMPLE: "Simple",
MEDIUM: "Medium",
COMPLEX: "Complex",
REASONING: "Reasoning",
};
-const isBuiltInTier = (tier: string): tier is ComplexityTier => (TIER_ORDER as string[]).includes(tier);
+const isBuiltInTier = (tier: string): tier is ComplexityTier => (ALL_BUILT_IN_TIERS as string[]).includes(tier);
const builtInTierLabel = (
tierLabels: Partial> | undefined,
@@ -164,7 +165,7 @@ export const tierRowLabel = (
row: { id: string; name: string },
tierLabels?: Partial>,
): string => {
- const builtIn = TIER_ORDER.find((tier) => tier === row.id);
+ const builtIn = ALL_BUILT_IN_TIERS.find((tier) => tier === row.id);
const named = row.name.trim();
if (!builtIn || named !== builtIn) return named || "New";
return builtInTierLabel(tierLabels, builtIn);
diff --git a/ui/litellm-dashboard/src/components/add_model/tier_rows.test.ts b/ui/litellm-dashboard/src/components/add_model/tier_rows.test.ts
index df50f116f60..07b9a4702aa 100644
--- a/ui/litellm-dashboard/src/components/add_model/tier_rows.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/tier_rows.test.ts
@@ -168,3 +168,34 @@ describe("tierParamsByRowId", () => {
expect(tierParamsByRowId(undefined, rows)).toBeUndefined();
});
});
+
+describe("the opt-in non-reasoning tier", () => {
+ const withTierZero = { SIMPLE: ["a"], MEDIUM: ["b"], COMPLEX: ["c"], REASONING: ["d"], NON_REASONING: ["cheap"] };
+
+ it("renders no fifth row while the toggle is off", () => {
+ // The regression for every existing router: the tier exists in the type, and the form must
+ // still show the four rows it always showed.
+ expect(activeTierRows({ tiers: withTierZero }).map((row) => row.id)).toEqual([
+ "SIMPLE",
+ "MEDIUM",
+ "COMPLEX",
+ "REASONING",
+ ]);
+ });
+
+ it("renders it first, as tier 0, when enabled", () => {
+ const rows = activeTierRows({ tiers: withTierZero, enable_non_reasoning_tier: true });
+ expect(rows.map((row) => row.id)).toEqual(["NON_REASONING", "SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
+ expect(rows[0].models).toEqual(["cheap"]);
+ });
+
+ it("renders an enabled tier with no models as an empty row rather than crashing", () => {
+ const rows = activeTierRows({ tiers, enable_non_reasoning_tier: true });
+ expect(rows[0]).toEqual({ id: "NON_REASONING", name: "NON_REASONING", definition: "", models: [], params: {} });
+ });
+
+ it("counts as a built-in name either way, so a custom set cannot claim the name", () => {
+ expect(isBuiltInTierName("NON_REASONING")).toBe(true);
+ expect(isBuiltInTierName("non_reasoning")).toBe(true);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/add_model/tier_rows.ts b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts
index 5e2a32addee..289a5645b51 100644
--- a/ui/litellm-dashboard/src/components/add_model/tier_rows.ts
+++ b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts
@@ -4,6 +4,16 @@ import type { TierModelParams, TierModelParamsByTier } from "./complexity_router
export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
+/** Every built-in tier name, so a stored NON_REASONING row is recognized as built-in either way. */
+export const ALL_BUILT_IN_TIERS: ComplexityTier[] = ["NON_REASONING", ...TIER_ORDER];
+
+/**
+ * The ladder one router renders, ascending. NON_REASONING is tier 0 and appears only when enabled,
+ * which is what keeps an existing four-tier router's form, payload, and rubric unchanged.
+ */
+export const tierOrderFor = (enableNonReasoningTier: boolean | undefined): ComplexityTier[] =>
+ enableNonReasoningTier ? ALL_BUILT_IN_TIERS : TIER_ORDER;
+
export interface TierRow {
id: string;
name: string;
@@ -27,6 +37,7 @@ export const MAX_TIER_DEFINITION_CHARS = 500;
export interface ActiveTierSet {
tiers: ComplexityTiers;
+ enable_non_reasoning_tier?: boolean;
custom_tier_set?: CustomTierSet;
tier_model_params?: TierModelParamsByTier;
}
@@ -39,7 +50,8 @@ export const activeTierName = (row: TierRow): string => row.name.trim();
export const sameTierIdentity = (left: string, right: string): boolean =>
left.trim().toLowerCase() === right.trim().toLowerCase();
-export const isBuiltInTierName = (name: string): boolean => TIER_ORDER.some((tier) => sameTierIdentity(tier, name));
+export const isBuiltInTierName = (name: string): boolean =>
+ ALL_BUILT_IN_TIERS.some((tier) => sameTierIdentity(tier, name));
const builtInRow = (tier: keyof ComplexityTiers, tiers: ComplexityTiers): TierRow => ({
id: tier,
@@ -51,7 +63,9 @@ const builtInRow = (tier: keyof ComplexityTiers, tiers: ComplexityTiers): TierRo
// The only reader of the tier set. Built-in rows carry the canonical tier key as their id, so every
// pointer into the set is a row id in both modes and nothing downstream branches on the mode.
export const activeTierRows = (value: ActiveTierSet): ActiveTierRow[] => {
- const rows = value.custom_tier_set?.tiers ?? TIER_ORDER.map((tier) => builtInRow(tier, value.tiers));
+ const rows =
+ value.custom_tier_set?.tiers ??
+ tierOrderFor(value.enable_non_reasoning_tier).map((tier) => builtInRow(tier, value.tiers));
return rows.map((row) => ({ ...row, params: value.tier_model_params?.[row.id] ?? {} }));
};
diff --git a/ui/litellm-dashboard/src/components/add_model/tier_set_actions.ts b/ui/litellm-dashboard/src/components/add_model/tier_set_actions.ts
index c8254b6bff9..f737e0fdff6 100644
--- a/ui/litellm-dashboard/src/components/add_model/tier_set_actions.ts
+++ b/ui/litellm-dashboard/src/components/add_model/tier_set_actions.ts
@@ -4,11 +4,12 @@ import { pruneTierModelParams } from "./complexity_router_tiers";
import {
type ActiveTierRow,
type TierRow,
- TIER_ORDER,
+ ALL_BUILT_IN_TIERS,
activeTierName,
activeTierRows,
rowParamsByTier,
sameTierIdentity,
+ tierOrderFor,
tierRowById,
tierRowByName,
} from "./tier_rows";
@@ -74,13 +75,13 @@ const rulesFollowingRows = (
// Models and params both come from these rows, so the two cannot be keyed differently.
const exitToBuiltInTiers = (value: ComplexityRouterConfigValue, rows: readonly ActiveTierRow[]) => {
const { custom_tier_set: _dropped, ...rest } = value;
- const builtInRows: ActiveTierRow[] = TIER_ORDER.map(
+ const builtInRows: ActiveTierRow[] = tierOrderFor(value.enable_non_reasoning_tier).map(
(tier) =>
tierRowById(rows, tier) ?? {
id: tier,
name: tier,
definition: "",
- models: value.tiers[tier],
+ models: value.tiers[tier] ?? [],
params: value.tier_model_params?.[tier] ?? {},
},
);
@@ -121,7 +122,7 @@ const nextTierSetValue = (
case "remove": {
const removed = tierRowById(rows, action.id);
const snapshot =
- removed && (TIER_ORDER as string[]).includes(action.id)
+ removed && (ALL_BUILT_IN_TIERS as string[]).includes(action.id)
? { ...value, tiers: { ...value.tiers, [action.id]: removed.models } }
: value;
return commitTierRows(
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 0144dff3498..5b09dabd214 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
@@ -598,6 +598,10 @@ describe("managed keys survive an untouched open-and-save", () => {
"stall_escalation_repeat_threshold",
]);
+ // The opt-in fifth tier requires the LLM classifier, which this heuristic_first fixture is not,
+ // so it gets its own round trip below.
+ const KEYS_ANOTHER_TIER_LADDER_OWNS = new Set(["enable_non_reasoning_tier"]);
+
it("carries every managed key a built-in router can hold through hydrate then save", () => {
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
const saved = buildUpdatedComplexityRouterConfig(STORED_ALL_MANAGED, hydrated);
@@ -605,10 +609,55 @@ describe("managed keys survive an untouched open-and-save", () => {
const dropped = [...MANAGED_COMPLEXITY_ROUTER_KEYS]
.filter((key) => !KEYS_ANOTHER_CLASSIFIER_TYPE_OWNS.has(key))
.filter((key) => !KEYS_ANOTHER_CLASSIFICATION_FREQUENCY_OWNS.has(key))
+ .filter((key) => !KEYS_ANOTHER_TIER_LADDER_OWNS.has(key))
.filter((key) => saved[key] === undefined);
expect(dropped).toEqual([]);
});
+ it("carries an enabled non-reasoning tier and its models through their own round trip", () => {
+ // `tiers` is rewritten wholesale on save, so this is the regression that matters: opening an
+ // enabled router and saving an unrelated edit must not delete the tier or its pool.
+ const stored: Record = {
+ ...STORED_ALL_MANAGED,
+ classifier_type: "llm",
+ classifier_llm_config: { model: "haiku-classifier" },
+ heuristic_first_max_tier: undefined,
+ enable_non_reasoning_tier: true,
+ tiers: { ...(STORED_ALL_MANAGED.tiers as object), NON_REASONING: ["gpt-4o-mini"] },
+ };
+ const hydrated = hydrateComplexityRouterConfig(stored, undefined);
+ const saved = buildUpdatedComplexityRouterConfig(stored, hydrated);
+
+ expect(saved.enable_non_reasoning_tier).toBe(true);
+ expect((saved.tiers as Record).NON_REASONING).toEqual(["gpt-4o-mini"]);
+ });
+
+ it("keeps a stored non-reasoning tier when the stored config never wrote the flag", () => {
+ // A hand-written config that names the tier: the flag is inferred from the stored pool, so an
+ // edit made for an unrelated reason cannot silently turn the tier off.
+ const stored: Record = {
+ ...STORED_ALL_MANAGED,
+ classifier_type: "llm",
+ classifier_llm_config: { model: "haiku-classifier" },
+ heuristic_first_max_tier: undefined,
+ tiers: { ...(STORED_ALL_MANAGED.tiers as object), NON_REASONING: ["gpt-4o-mini"] },
+ };
+ const saved = buildUpdatedComplexityRouterConfig(stored, hydrateComplexityRouterConfig(stored, undefined));
+
+ expect(saved.enable_non_reasoning_tier).toBe(true);
+ expect((saved.tiers as Record).NON_REASONING).toEqual(["gpt-4o-mini"]);
+ });
+
+ it("leaves the tier and its flag out of a saved config that never had it on", () => {
+ const saved = buildUpdatedComplexityRouterConfig(
+ STORED_ALL_MANAGED,
+ hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined),
+ );
+
+ expect(saved).not.toHaveProperty("enable_non_reasoning_tier");
+ expect(saved.tiers).not.toHaveProperty("NON_REASONING");
+ });
+
it("carries the stall-escalation keys through their own round trip", () => {
const stored: Record = {
...STORED_ALL_MANAGED,
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 3c0013e267e..283fe978790 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
@@ -91,6 +91,7 @@ interface EditAutoRouterModalProps {
* hydrators validate themselves stay `unknown`; the ones assigned straight through carry their type. */
export interface StoredComplexityRouterConfig {
tiers?: Partial>;
+ enable_non_reasoning_tier?: boolean;
tier_model_configs?: unknown;
default_model?: string | null;
plan_mode_min_tier?: unknown;
@@ -135,18 +136,27 @@ export const hydrateComplexityRouterConfig = (
parsedConfig: StoredComplexityRouterConfig,
complexityRouterDefaultModel: string | null | undefined,
): ComplexityRouterConfigValue => {
+ // `tiers` is rewritten wholesale on save, so a stored tier this misses is deleted from the
+ // router by any edit at all, including one made for an unrelated reason. NON_REASONING is
+ // therefore read back from the stored config rather than assumed absent, and the toggle follows
+ // what is actually stored so the round-trip cannot silently turn the tier off.
+ const storedNonReasoning: string[] = normalizeTierModels(parsedConfig.tiers?.NON_REASONING);
+ const enable_non_reasoning_tier: boolean =
+ parsedConfig.enable_non_reasoning_tier === true || storedNonReasoning.length > 0;
const hydratedTiers: ComplexityTiers = {
SIMPLE: normalizeTierModels(parsedConfig.tiers?.SIMPLE),
MEDIUM: normalizeTierModels(parsedConfig.tiers?.MEDIUM),
COMPLEX: normalizeTierModels(parsedConfig.tiers?.COMPLEX),
REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING),
+ ...(enable_non_reasoning_tier && { NON_REASONING: storedNonReasoning }),
};
const custom_tier_set = hydrateCustomTierSet(parsedConfig);
- const activeTiers = { tiers: hydratedTiers, custom_tier_set };
+ const activeTiers = { tiers: hydratedTiers, enable_non_reasoning_tier, custom_tier_set };
return {
tiers: hydratedTiers,
+ enable_non_reasoning_tier,
custom_tier_set,
tier_model_params: tierParamsByRowId(
hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs),
@@ -234,6 +244,7 @@ export const hydrateComplexityRouterConfig = (
export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"tiers",
+ "enable_non_reasoning_tier",
"tier_definitions",
"fallback_tier",
"tier_model_configs",
@@ -339,6 +350,7 @@ 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,
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 232b366d8ce..6a60b0db58a 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -23573,16 +23573,22 @@ export interface components {
};
/**
* AutoRouterPresetTiers
- * @description Exactly the four built-in tiers the dashboard's preset prefill can apply.
+ * @description The built-in tiers the dashboard's preset prefill can apply.
*
* extra="forbid" on purpose: a tier name this dashboard cannot apply would grey out or crash the
- * picker, so such a catalog is rejected wholesale and the bundled one serves instead.
+ * picker, so such a catalog is rejected wholesale and the bundled one serves instead. NON_REASONING
+ * is optional rather than required so this proxy accepts both a catalog that ships the opt-in fifth
+ * tier and the four-tier catalogs that predate it. It stays None when unset rather than defaulting
+ * to an empty pool, so a four-tier preset serves the tier set it was published with instead of
+ * growing a key the dashboard would render as an empty fifth tier row.
*/
AutoRouterPresetTiers: {
/** Complex */
COMPLEX: string[];
/** Medium */
MEDIUM: string[];
+ /** Non Reasoning */
+ NON_REASONING?: string[] | null;
/** Reasoning */
REASONING: string[];
/** Simple */
@@ -25579,7 +25585,7 @@ export interface components {
* @description Complexity tiers for routing decisions.
* @enum {string}
*/
- ComplexityTier: "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
+ ComplexityTier: "NON_REASONING" | "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
/** ComplexityTierModel */
ComplexityTierModel: {
/** Litellm Params */
@@ -34947,6 +34953,12 @@ export interface components {
* @default true
*/
enable_context_window_escalation: boolean;
+ /**
+ * Enable Non Reasoning Tier
+ * @description Add NON_REASONING as a fifth built-in tier below SIMPLE, for operational agent traffic that relays or reformats information rather than reasoning about it. Off by default: turning it on adds a rung to this router's ladder, a bullet to the LLM classifier's rubric, and a value the classifier may return, all of which move tier decisions and spend on an already-deployed router. Requires an LLM classifier or a custom classifier plugin, since the heuristic scorers cannot produce the tier, and a model in `tiers` under the NON_REASONING key. Escalation still walks up from it, and it is never the savings baseline or a `heuristic_v2` prediction.
+ * @default false
+ */
+ enable_non_reasoning_tier: boolean;
/**
* Escalation Keywords
* @description Case-sensitive phrases a user can include to force a bump to the next-higher complexity tier when they aren't satisfied with results (they can force a stronger model, but not choose which one). Defaults to ['LITELLM ESCALATE'] when unset; set to an empty list to disable.
@@ -37212,7 +37224,7 @@ export interface components {
TierDefinition: {
/**
* Description
- * @description What belongs in this tier; rendered as this tier's bullet in the classifier rubric. Required unless the name is a built-in tier (SIMPLE/MEDIUM/COMPLEX/REASONING), which inherits the built-in criteria when omitted
+ * @description What belongs in this tier; rendered as this tier's bullet in the classifier rubric. Required unless the name is a built-in tier (NON_REASONING, SIMPLE, MEDIUM, COMPLEX, REASONING), which inherits the built-in criteria when omitted
*/
description?: string | null;
/**