From f0a122d35fb2d859db708184b83ba9bdce235a0f Mon Sep 17 00:00:00 2001 From: tin-berri Date: Wed, 26 Aug 2026 14:53:06 -0700 Subject: [PATCH] refactor(ui): read the auto-router tier set through one row list (#38408) The dashboard resolved the complexity-router tier set three different ways: a private TIER_KEYS in build_complexity_router_config.ts, TIER_ORDER in complexity_router_tiers.ts, and TIER_KEYS in ComplexityRouterConfig.tsx. The edit modal went further and re-implemented the whole create payload builder, kept in sync only by a comment reading "Mirrors buildComplexityRouterConfig". tier_rows.ts now owns the tier set. Every consumer reads activeTierRows(value) and a row carries its own id, so the plan-mode floor and per-model params point at a row rather than at a position, and the leaves that already wanted entries (buildAutoRouterTestTargets, getRequiredModels, model_info_view) take them. buildUpdatedComplexityRouterConfig becomes preserve-unmanaged-keys around the shared builder instead of a second copy of it. Also drops the literal ", ]" that renders as visible text in two DialogFooter blocks on the auto-router routing-test and connection-test dialogs, left over from a JSX array-to-fragment conversion. No behaviour change: all 566 tests over the touched modules pass with fixture shape changes only, no assertion edited. --- .../add_model/ComplexityRouterConfig.tsx | 47 ++++--- .../add_model/add_auto_router_tab.tsx | 43 +++--- .../build_auto_router_test_targets.test.ts | 33 +++-- .../build_auto_router_test_targets.ts | 18 +-- .../build_complexity_router_config.test.ts | 17 +-- .../build_complexity_router_config.ts | 28 ++-- .../add_model/complexity_router_tiers.test.ts | 16 +-- .../add_model/complexity_router_tiers.ts | 16 +-- .../components/add_model/tier_rows.test.ts | 70 ++++++++++ .../src/components/add_model/tier_rows.ts | 40 ++++++ .../edit_auto_router_modal.tsx | 124 +++++++----------- .../src/components/model_info_view.tsx | 14 +- .../src/lib/autorouter_presets.ts | 13 +- 13 files changed, 260 insertions(+), 219 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/tier_rows.test.ts create mode 100644 ui/litellm-dashboard/src/components/add_model/tier_rows.ts diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 78e5b572492..71bce0b254c 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -17,15 +17,14 @@ import { ReasoningEffort, TierModelParamsByTier, pruneTierModelParams, - resolveComplexityDefaultModel, setTierModelReasoningEffort, - tierOptions, } from "./complexity_router_tiers"; import TierModelEffortRows from "./TierModelEffortRows"; import EscalationKeywords from "./EscalationKeywords"; import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules"; import SemanticKeywordMatching from "./SemanticKeywordMatching"; import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from "./heuristic_scoring_knobs"; +import { type TierRow, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows"; export type { DimensionWeights, TierBoundaries, TokenThresholds }; @@ -37,12 +36,12 @@ export const MIN_QUOTED_CONTEXT_TURN_CHARS = 120; export const DEFAULT_SESSION_AFFINITY = false; export const DEFAULT_DEPLOYMENT_AFFINITY = true; -export interface ComplexityTiers { +export type ComplexityTiers = { SIMPLE: string[]; MEDIUM: string[]; COMPLEX: string[]; REASONING: string[]; -} +}; export type ClassificationRubric = "legacy" | "agentic" | "chat" | "business"; @@ -224,10 +223,6 @@ export const TIER_KEYS = Object.keys(TIER_DESCRIPTIONS) as Array tierLabels?.[tier]?.trim() || TIER_DESCRIPTIONS[tier].label; -/** Tiers the plan-mode floor may name: the backend rejects a floor whose tier has no models. */ -export const planModeEligibleTiers = (tiers: ComplexityTiers): Array => - TIER_KEYS.filter((tier) => (tiers[tier] ?? []).length > 0); - const ComplexityRouterConfig: React.FC = ({ modelInfo, value, @@ -246,12 +241,12 @@ const ComplexityRouterConfig: React.FC = ({ onEscalationKeywordsChange, showValidationErrors = false, }) => { - const planModeTiers = planModeEligibleTiers(value.tiers); - const planModeTierOptions = tierOptions(value.tier_labels).filter((option) => - (planModeTiers as string[]).includes(option.value), - ); - const derivedDefaultModel = resolveComplexityDefaultModel(value.tiers); - const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model); + const tierRows = activeTierRows(value); + const planModeTierOptions = tierRows + .filter((row) => row.models.length > 0) + .map((row) => ({ value: row.id, label: effectiveTierLabel(row.id as keyof ComplexityTiers, value.tier_labels) })); + const derivedDefaultModel = resolveComplexityDefaultModel(value); + const defaultModel = resolveComplexityDefaultModel(value, value.default_model); // An absent list means the proxy does not send the field yet, so every level is offered as before. // An empty list is the group's own answer that its deployments share no level, and is left empty. @@ -325,12 +320,13 @@ const ComplexityRouterConfig: React.FC = ({ - {TIER_KEYS.map((tier, index) => { + {tierRows.map((row: TierRow, index) => { + const tier = row.id as keyof ComplexityTiers; const tierInfo = TIER_DESCRIPTIONS[tier]; const label = effectiveTierLabel(tier, value.tier_labels); - const tierMissing = showValidationErrors && value.tiers[tier].length === 0; + const tierMissing = showValidationErrors && row.models.length === 0; return ( -
+
{index > 0 && }
@@ -339,7 +335,7 @@ const ComplexityRouterConfig: React.FC = ({ - Tier {index + 1} of {TIER_KEYS.length} · {tier} + Tier {index + 1} of {tierRows.length} · {row.id}
Examples: {tierInfo.examples} @@ -364,7 +360,7 @@ const ComplexityRouterConfig: React.FC = ({ handleTierChange(tier, models)} placeholder={`Select model(s) for ${label.toLowerCase()} queries`} emptyText="No models found" @@ -372,12 +368,12 @@ const ComplexityRouterConfig: React.FC = ({ /> handleTierModelEffortChange(tier, model, effort)} /> - {value.tiers[tier].length > 1 && ( + {row.models.length > 1 && ( Multiple models selected โ€” the router randomly picks among them per request (or Thompson-samples within the pool when adaptive routing is on). @@ -483,9 +479,12 @@ const ComplexityRouterConfig: React.FC = ({
- onChange({ ...value, plan_mode_min_tier: enabled ? planModeTiers.at(-1) : undefined }) + onChange({ + ...value, + plan_mode_min_tier: enabled ? planModeTierOptions.at(-1)?.value : undefined, + }) } aria-label="Route plan-mode requests to a minimum tier" /> @@ -494,7 +493,7 @@ const ComplexityRouterConfig: React.FC = ({ Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier. The classifier still wins when it picks higher, and the override only lasts while plan mode is active. - {planModeTiers.length === 0 && " Add models to a tier to enable this."} + {planModeTierOptions.length === 0 && " Add models to a tier to enable this."} {value.plan_mode_min_tier !== undefined && (
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index 98ee2b7ae7c..87c4754f56d 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -22,7 +22,6 @@ import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels"; import ComplexityRouterConfig, { ComplexityRouterConfigValue, - ComplexityTiers, DEFAULT_ADAPTIVE_WEIGHTS, DEFAULT_SESSION_AFFINITY, DEFAULT_DEPLOYMENT_AFFINITY, @@ -40,7 +39,9 @@ import { getSemanticConfigError, getTierLabelsError, } from "./build_complexity_router_config"; -import { resolveComplexityDefaultModel } from "./complexity_router_tiers"; +import { activeTierName, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows"; +import { DEFAULT_TIER_LABELS } from "./complexity_router_tiers"; +import type { ComplexityTier } from "./KeywordTierRules"; import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets"; import AutoRouterConnectionTest from "./auto_router_connection_test"; import AutoRouterRoutingTest from "./AutoRouterRoutingTest"; @@ -104,17 +105,10 @@ const presets = getAllPresets(); // A one-line summary of what's configured, shown when the detailed section is collapsed so a // caller can see the shape of the config without opening it. -const tierConfigSummary = (tiers: ComplexityTiers): string => { - const parts = ( - [ - ["Simple", tiers.SIMPLE], - ["Medium", tiers.MEDIUM], - ["Complex", tiers.COMPLEX], - ["Reasoning", tiers.REASONING], - ] as const - ) - .filter(([, models]) => models.length > 0) - .map(([label, models]) => `${label}: ${models.join(", ")}`); +const tierConfigSummary = (config: ComplexityRouterConfigValue): string => { + const parts = activeTierRows(config) + .filter((row) => row.models.length > 0) + .map((row) => `${DEFAULT_TIER_LABELS[row.id as ComplexityTier] ?? activeTierName(row)}: ${row.models.join(", ")}`); return parts.length > 0 ? parts.join(" ยท ") : "No tiers configured yet"; }; @@ -128,9 +122,9 @@ const getSubmitBlockedReason = ( referencedModelsParams: Parameters[0], availability: ModelAvailability, ): string | null => - getMissingTiersError(config.tiers) ?? + getMissingTiersError(activeTierRows(config)) ?? getTierLabelsError(config.tier_labels) ?? - getPlanModeTierError(config.plan_mode_min_tier, config.tiers) ?? + getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ?? getKeywordTierRulesError(keywordTierRules) ?? getReferencedModelsError(referencedModelsParams, availability); @@ -378,7 +372,7 @@ const AddAutoRouterTab: React.FC = ({ const submitRecommendedRouter = async (name: string) => { const { tiers, tierLabels, classifierType, classifierLlmConfig } = complexityRouterConfigParams; - const missingTiersError = getMissingTiersError(tiers); + const missingTiersError = getMissingTiersError(activeTierRows(complexityRouterConfig)); if (missingTiersError) { setShowValidationErrors(true); toast.fromError(missingTiersError); @@ -423,7 +417,7 @@ const AddAutoRouterTab: React.FC = ({ return; } - const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model); + const defaultModel = resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model); const validatedFields = requiresTeamScope ? (["auto_router_name", "team_id"] as const) : (["auto_router_name"] as const); @@ -463,10 +457,12 @@ const AddAutoRouterTab: React.FC = ({ const handleTestConnection = () => { const testTargetParams = { - tiers: complexityRouterConfig.tiers, + tiers: activeTierRows(complexityRouterConfig).map( + (row) => [activeTierName(row), row.models] as [string, string[]], + ), semanticMatchingEnabled, embeddingModel, - defaultModel: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model), + defaultModel: resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model), }; const targets = buildAutoRouterTestTargets(testTargetParams); @@ -581,7 +577,7 @@ const AddAutoRouterTab: React.FC = ({ {!detailsExpanded && ( - {tierConfigSummary(complexityRouterConfig.tiers)} + {tierConfigSummary(complexityRouterConfig)} )} @@ -694,10 +690,7 @@ const AddAutoRouterTab: React.FC = ({ @@ -707,7 +700,6 @@ const AddAutoRouterTab: React.FC = ({ - , ] @@ -744,7 +736,6 @@ const AddAutoRouterTab: React.FC = ({ > Close - , ] diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts index 85fd846ddbc..7c29ea53060 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts @@ -1,11 +1,18 @@ import { buildAutoRouterTestTargets } from "./build_auto_router_test_targets"; -const tiers = { - SIMPLE: ["gpt-4o-mini"], - MEDIUM: ["claude-sonnet-4"], - COMPLEX: ["claude-sonnet-4"], - REASONING: ["o3"], -}; +const tierEntries = ( + SIMPLE: string[], + MEDIUM: string[] = [], + COMPLEX: string[] = [], + REASONING: string[] = [], +): [string, string[]][] => [ + ["SIMPLE", SIMPLE], + ["MEDIUM", MEDIUM], + ["COMPLEX", COMPLEX], + ["REASONING", REASONING], +]; + +const tiers = tierEntries(["gpt-4o-mini"], ["claude-sonnet-4"], ["claude-sonnet-4"], ["o3"]); describe("buildAutoRouterTestTargets", () => { it("dedups tiers that share a model group into one chat target carrying both labels", () => { @@ -19,7 +26,7 @@ describe("buildAutoRouterTestTargets", () => { it("emits a target per model when a tier has more than one, and dedups across tiers", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini", "claude-sonnet-4"], MEDIUM: ["claude-sonnet-4"], COMPLEX: [], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini", "claude-sonnet-4"], ["claude-sonnet-4"]), semanticMatchingEnabled: false, embeddingModel: undefined, }); @@ -31,7 +38,7 @@ describe("buildAutoRouterTestTargets", () => { it("drops empty/whitespace tiers", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [" "], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini"], [], [" "]), semanticMatchingEnabled: false, embeddingModel: undefined, }); @@ -41,7 +48,7 @@ describe("buildAutoRouterTestTargets", () => { it("returns [] when no tier is configured", () => { expect( buildAutoRouterTestTargets({ - tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }, + tiers: tierEntries([]), semanticMatchingEnabled: false, embeddingModel: undefined, }), @@ -50,7 +57,7 @@ describe("buildAutoRouterTestTargets", () => { it("appends an embedding target only when semantic matching is on and a model is set", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini"]), semanticMatchingEnabled: true, embeddingModel: "voyage-3-5", }); @@ -62,7 +69,7 @@ describe("buildAutoRouterTestTargets", () => { it("omits the embedding target when semantic matching is on but no model is chosen", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini"]), semanticMatchingEnabled: true, embeddingModel: undefined, }); @@ -71,7 +78,7 @@ describe("buildAutoRouterTestTargets", () => { it("omits the embedding target when a model is set but semantic matching is off", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini"]), semanticMatchingEnabled: false, embeddingModel: "voyage-3-5", }); @@ -112,7 +119,7 @@ describe("buildAutoRouterTestTargets", () => { it.each([[undefined], [""], [" "]])("adds no default target for %o", (defaultModel) => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini"]), semanticMatchingEnabled: false, embeddingModel: undefined, defaultModel, diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts index 471552fc84f..70b92dbf8cc 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts @@ -1,5 +1,3 @@ -import { ComplexityTiers } from "./ComplexityRouterConfig"; - export type AutoRouterTestMode = "chat" | "embedding"; export interface AutoRouterTestTarget { @@ -9,7 +7,8 @@ export interface AutoRouterTestTarget { } export interface BuildAutoRouterTestTargetsParams { - tiers: ComplexityTiers; + /** Ordered [tier name, model groups] entries of the active tier set. */ + tiers: readonly (readonly [string, string[]])[]; semanticMatchingEnabled: boolean; embeddingModel: string | undefined; /** The resolved default model - see resolveComplexityDefaultModel. A live fallback destination, @@ -17,23 +16,14 @@ export interface BuildAutoRouterTestTargetsParams { defaultModel?: string; } -// Keys drive iteration order; `satisfies Record` makes it a -// compile error to add a tier to ComplexityTiers without listing it here (and vice versa). -const TIER_ORDER = Object.keys({ - SIMPLE: null, - MEDIUM: null, - COMPLEX: null, - REASONING: null, -} satisfies Record) as (keyof ComplexityTiers)[]; - export const buildAutoRouterTestTargets = ({ tiers, semanticMatchingEnabled, embeddingModel, defaultModel, }: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => { - const tieredByModel = TIER_ORDER.reduce>((acc, tier) => { - return (tiers[tier] ?? []).reduce((tierAcc, rawModel) => { + const tieredByModel = tiers.reduce>((acc, [tier, models]) => { + return models.reduce((tierAcc, rawModel) => { const modelGroup = rawModel?.trim(); if (!modelGroup) return tierAcc; return { ...tierAcc, [modelGroup]: [...(tierAcc[modelGroup] ?? []), tier] }; diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts index 33f0fb8a539..63545f5b7de 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts @@ -9,6 +9,7 @@ import { hydrateTierLabels, BuildComplexityRouterConfigParams, } from "./build_complexity_router_config"; +import { activeTierRows } from "./tier_rows"; const tiers = { SIMPLE: ["gpt-4o-mini"], @@ -275,30 +276,30 @@ describe("buildComplexityRouterConfig", () => { describe("getMissingTiersError", () => { it("returns null when all four tiers have a model", () => { - expect(getMissingTiersError(tiers)).toBeNull(); + expect(getMissingTiersError(activeTierRows({ tiers: tiers }))).toBeNull(); }); it("names the specific missing tier when only one is blank", () => { - expect(getMissingTiersError({ ...tiers, REASONING: [] })).toBe( + expect(getMissingTiersError(activeTierRows({ tiers: { ...tiers, REASONING: [] } }))).toBe( "Select a model for the following tier(s): REASONING", ); }); it("names multiple missing tiers in SIMPLE/MEDIUM/COMPLEX/REASONING order", () => { - expect(getMissingTiersError({ ...tiers, SIMPLE: [], REASONING: [] })).toBe( + expect(getMissingTiersError(activeTierRows({ tiers: { ...tiers, SIMPLE: [], REASONING: [] } }))).toBe( "Select a model for the following tier(s): SIMPLE, REASONING", ); }); it("names all four tiers when none are filled", () => { const noTiers = { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }; - expect(getMissingTiersError(noTiers)).toBe( + expect(getMissingTiersError(activeTierRows({ tiers: noTiers }))).toBe( "Select a model for the following tier(s): SIMPLE, MEDIUM, COMPLEX, REASONING", ); }); it("treats a tier with more than one model as filled", () => { - expect(getMissingTiersError({ ...tiers, SIMPLE: ["gpt-4o-mini", "gpt-4o"] })).toBeNull(); + expect(getMissingTiersError(activeTierRows({ tiers: { ...tiers, SIMPLE: ["gpt-4o-mini", "gpt-4o"] } }))).toBeNull(); }); }); @@ -645,15 +646,15 @@ describe("getPlanModeTierError", () => { const tiersWithEmptyComplex = { SIMPLE: ["m1"], MEDIUM: ["m1"], COMPLEX: [], REASONING: [] }; it("passes when the override is off", () => { - expect(getPlanModeTierError(undefined, tiersWithEmptyComplex)).toBeNull(); + expect(getPlanModeTierError(undefined, activeTierRows({ tiers: tiersWithEmptyComplex }))).toBeNull(); }); it("passes when the named tier has models", () => { - expect(getPlanModeTierError("MEDIUM", tiersWithEmptyComplex)).toBeNull(); + expect(getPlanModeTierError("MEDIUM", activeTierRows({ tiers: tiersWithEmptyComplex }))).toBeNull(); }); it("blocks a tier whose models were removed, which the backend would reject with a 400", () => { - expect(getPlanModeTierError("COMPLEX", tiersWithEmptyComplex)).toContain("COMPLEX"); + expect(getPlanModeTierError("COMPLEX", activeTierRows({ tiers: tiersWithEmptyComplex }))).toContain("COMPLEX"); }); }); 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 bd95bea226a..241cae25705 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 @@ -1,4 +1,5 @@ import { KeywordTierRule } from "./KeywordTierRules"; +import { type TierRow, activeTierName, tierRowById } from "./tier_rows"; import { emptyKeywordTierRuleIndexes, serializeKeywordTierRules } from "./complexity_router_keywords"; import { TierModelParams, TierModelParamsByTier, serializeTierModelConfigs } from "./complexity_router_tiers"; import { @@ -10,6 +11,7 @@ import { ComplexityTierLabels, ComplexityTiers, DimensionWeights, + TIER_KEYS, TIER_DESCRIPTIONS, TierBoundaries, TokenThresholds, @@ -135,8 +137,6 @@ export interface ComplexityRouterConfigPayload { tier_model_configs?: Record; } -const TIER_KEYS: Array = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]; - export const serializeTierLabels = (tierLabels: ComplexityTierLabels | undefined): ComplexityTierLabels | undefined => { const renamed = TIER_KEYS.map((tier) => [tier, tierLabels?.[tier]?.trim() ?? ""] as const).filter( ([tier, label]) => label !== "" && label !== TIER_DESCRIPTIONS[tier].label, @@ -171,26 +171,20 @@ export const getTierLabelsError = (tierLabels: ComplexityTierLabels | undefined) return null; }; -// Requires all 4 tiers non-empty, so the create form can never reach the -// resolveComplexityDefaultModel(tiers, ...) === undefined case โ€” MEDIUM (or SIMPLE) is always -// populated. The edit modal has no equivalent of this check (it allows saving with only some -// tiers filled), which is why it needs its own explicit `!defaultModel` guard after deriving โ€” -// see edit_auto_router_modal.tsx's save handler. A future contributor copying this form's submit -// handler elsewhere should not assume the same guarantee holds without this check. -export const getMissingTiersError = (tiers: ComplexityTiers): string | null => { - const missing = TIER_KEYS.filter((tier) => tiers[tier].length === 0); +// Requires every active tier non-empty, so the create form can never reach the +// resolveComplexityDefaultModel === undefined case. The edit modal allows a partially filled +// set, which is why it keeps its own !defaultModel guard after deriving. +export const getMissingTiersError = (rows: readonly TierRow[]): string | null => { + const missing = rows.filter((row) => row.models.length === 0).map(activeTierName); if (missing.length === 0) return null; return `Select a model for the following tier(s): ${missing.join(", ")}`; }; -// The backend rejects a plan-mode floor naming a tier with no models. The create form's -// getMissingTiersError makes this unreachable there; the edit modal allows partially filled -// tiers, so both gates call this to keep the two forms symmetric. -export const getPlanModeTierError = (planModeMinTier: string | undefined, tiers: ComplexityTiers): string | null => { +export const getPlanModeTierError = (planModeMinTier: string | undefined, rows: readonly TierRow[]): string | null => { if (!planModeMinTier) return null; - const models = tiers[planModeMinTier as keyof ComplexityTiers] ?? []; - if (models.length > 0) return null; - return `The plan-mode minimum tier (${planModeMinTier}) has no models. Add one or turn the override off.`; + const floor = tierRowById(rows, planModeMinTier); + if (floor && floor.models.length > 0) return null; + return `The plan-mode minimum tier (${floor ? activeTierName(floor) : planModeMinTier}) has no models. Add one or turn the override off.`; }; export const getKeywordTierRulesError = (keywordTierRules: KeywordTierRule[]): string | null => { diff --git a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts index 4dffbbd2ac7..be48ff4958d 100644 --- a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts @@ -4,10 +4,10 @@ import { hydrateTierModelParams, normalizeTierModels, pruneTierModelParams, - resolveComplexityDefaultModel, serializeTierModelConfigs, setTierModelReasoningEffort, } from "./complexity_router_tiers"; +import { resolveComplexityDefaultModel } from "./tier_rows"; import type { ComplexityTiers } from "./ComplexityRouterConfig"; @@ -50,31 +50,31 @@ describe("resolveComplexityDefaultModel", () => { const noTiers: ComplexityTiers = { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }; it("derives from MEDIUM first when nothing is pinned", () => { - expect(resolveComplexityDefaultModel(tiers)).toBe("medium-model"); + expect(resolveComplexityDefaultModel({ tiers: tiers })).toBe("medium-model"); }); it("falls back to SIMPLE when MEDIUM is empty", () => { - expect(resolveComplexityDefaultModel({ ...tiers, MEDIUM: [] })).toBe("simple-model"); + expect(resolveComplexityDefaultModel({ tiers: { ...tiers, MEDIUM: [] } })).toBe("simple-model"); }); it("derives nothing from COMPLEX or REASONING, which the backend never falls through to", () => { - expect(resolveComplexityDefaultModel({ ...tiers, MEDIUM: [], SIMPLE: [] })).toBeUndefined(); + expect(resolveComplexityDefaultModel({ tiers: { ...tiers, MEDIUM: [], SIMPLE: [] } })).toBeUndefined(); }); it("lets a pin beat the tiers rather than merely filling in for them", () => { - expect(resolveComplexityDefaultModel(tiers, "pinned-model")).toBe("pinned-model"); + expect(resolveComplexityDefaultModel({ tiers: tiers }, "pinned-model")).toBe("pinned-model"); }); it("stands alone as the default when no tier holds a model", () => { - expect(resolveComplexityDefaultModel(noTiers, "pinned-model")).toBe("pinned-model"); + expect(resolveComplexityDefaultModel({ tiers: noTiers }, "pinned-model")).toBe("pinned-model"); }); it.each([[""], [" "], [undefined]])("reads %o as no pin and goes back to the tiers", (pinned) => { - expect(resolveComplexityDefaultModel(tiers, pinned)).toBe("medium-model"); + expect(resolveComplexityDefaultModel({ tiers: tiers }, pinned)).toBe("medium-model"); }); it("resolves to nothing when neither a pin nor a tier offers a model", () => { - expect(resolveComplexityDefaultModel(noTiers)).toBeUndefined(); + expect(resolveComplexityDefaultModel({ tiers: noTiers })).toBeUndefined(); }); }); 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 2ea1915ca03..ea0d34f6581 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,5 +1,5 @@ -import type { ComplexityTiers } from "./ComplexityRouterConfig"; import type { ComplexityTier } from "./KeywordTierRules"; +import { TIER_ORDER } from "./tier_rows"; export type TierModelParams = Record; @@ -80,13 +80,13 @@ export const hydrateTierModelParams = ( * tiers this editor does not render pass through rather than being dropped now the key is managed. */ export const serializeTierModelConfigs = ( - tiers: ComplexityTiers, + tiers: Record, tierModelParams: TierModelParamsByTier | undefined, ): Record | undefined => { if (tierModelParams === undefined) return undefined; const serialized = Object.entries(tierModelParams) .map(([tier, byModel]) => { - const selected = (TIER_ORDER as string[]).includes(tier) ? new Set(tiers[tier as ComplexityTier]) : undefined; + const selected = tier in tiers ? new Set(tiers[tier]) : undefined; const entries = Object.entries(byModel) .filter(([model, params]) => (selected === undefined || selected.has(model)) && Object.keys(params).length > 0) .map(([model_name, litellm_params]) => ({ model_name, litellm_params })); @@ -126,14 +126,6 @@ export const pruneTierModelParams = ( return Object.keys(next).length > 0 ? next : undefined; }; -/** - * Mirrors `init_complexity_router_deployment` (litellm/router.py): an explicit pin wins, otherwise - * the default is `MEDIUM or SIMPLE`. Deriving past SIMPLE would name a model the backend never - * picks, and it raises rather than falling through to COMPLEX/REASONING. - */ -export const resolveComplexityDefaultModel = (tiers: ComplexityTiers, pinned?: string): string | undefined => - pinned?.trim() || tiers.MEDIUM[0] || tiers.SIMPLE[0]; - export const DEFAULT_TIER_LABELS: Record = { SIMPLE: "Simple", MEDIUM: "Medium", @@ -141,8 +133,6 @@ export const DEFAULT_TIER_LABELS: Record = { REASONING: "Reasoning", }; -export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]; - export const tierOptions = ( tierLabels: Partial> | undefined, ): { value: ComplexityTier; label: string }[] => 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 new file mode 100644 index 00000000000..54d9e4f3f0a --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/tier_rows.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + activeTierName, + activeTierRows, + isBuiltInTierName, + resolveComplexityDefaultModel, + sameTierIdentity, + tierRowById, + tierRowByName, +} from "./tier_rows"; + +const tiers = { SIMPLE: ["a"], MEDIUM: ["b"], COMPLEX: ["c"], REASONING: ["d"] }; + +describe("activeTierRows", () => { + it("reads the tier set as rows whose id is the canonical tier key, in severity order", () => { + expect(activeTierRows({ tiers })).toEqual([ + { id: "SIMPLE", name: "SIMPLE", models: ["a"] }, + { id: "MEDIUM", name: "MEDIUM", models: ["b"] }, + { id: "COMPLEX", name: "COMPLEX", models: ["c"] }, + { id: "REASONING", name: "REASONING", models: ["d"] }, + ]); + }); + + it("gives a tier with no models an empty pool rather than dropping the row", () => { + expect(activeTierRows({ tiers: { ...tiers, COMPLEX: [] } })[2]).toEqual({ + id: "COMPLEX", + name: "COMPLEX", + models: [], + }); + }); + + it("finds a row by id and by name", () => { + const rows = activeTierRows({ tiers }); + expect(tierRowById(rows, "MEDIUM")?.models).toEqual(["b"]); + expect(tierRowById(rows, undefined)).toBeUndefined(); + expect(tierRowByName(rows, " medium ")?.id).toBe("MEDIUM"); + }); +}); + +describe("sameTierIdentity", () => { + it.each([ + ["AUDIT", "audit", true], + ["AUDIT", " audit ", true], + ["AUDIT", "AUDITS", false], + ])("compares %s and %s casefold, matching the backend's uniqueness rule", (left, right, expected) => { + expect(sameTierIdentity(left, right)).toBe(expected); + }); + + it("recognises the four built-in names regardless of case", () => { + expect(["SIMPLE", "medium", "Complex", "REASONING"].every(isBuiltInTierName)).toBe(true); + expect(isBuiltInTierName("SECURITY_REVIEW")).toBe(false); + }); + + it("trims a row name, since the backend matches fallback_tier and keyword rules exactly", () => { + expect(activeTierName({ id: "1", name: " AUDIT ", models: [] })).toBe("AUDIT"); + }); +}); + +describe("resolveComplexityDefaultModel", () => { + it("mirrors init_complexity_router_deployment: a pin wins, then MEDIUM, then SIMPLE", () => { + expect(resolveComplexityDefaultModel({ tiers }, "pinned")).toBe("pinned"); + expect(resolveComplexityDefaultModel({ tiers })).toBe("b"); + expect(resolveComplexityDefaultModel({ tiers: { ...tiers, MEDIUM: [] } })).toBe("a"); + }); + + it("resolves to nothing rather than falling through to COMPLEX, which the backend never picks", () => { + expect(resolveComplexityDefaultModel({ tiers: { ...tiers, SIMPLE: [], MEDIUM: [] } })).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/tier_rows.ts b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts new file mode 100644 index 00000000000..c320980916a --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts @@ -0,0 +1,40 @@ +import type { ComplexityTiers } from "./ComplexityRouterConfig"; +import type { ComplexityTier } from "./KeywordTierRules"; + +export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]; + +export interface TierRow { + id: string; + name: string; + models: string[]; +} + +export interface ActiveTierSet { + tiers: ComplexityTiers; +} + +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)); + +// The only reader of the tier set. A row's id is the canonical tier key, so anything pointing into +// the set (the plan-mode floor, per-model params) points at a row rather than at a position. +export const activeTierRows = (value: ActiveTierSet): TierRow[] => + TIER_ORDER.map((tier) => ({ id: tier, name: tier, models: value.tiers[tier] ?? [] })); + +export const tierRowById = (rows: readonly TierRow[], id: string | undefined): TierRow | undefined => + id === undefined ? undefined : rows.find((row) => row.id === id); + +export const tierRowByName = (rows: readonly TierRow[], name: string): TierRow | undefined => + rows.find((row) => sameTierIdentity(row.name, name)); + +// Mirrors init_complexity_router_deployment (litellm/router.py): a pin wins, then MEDIUM or SIMPLE +// looked up by exact name. +export const resolveComplexityDefaultModel = (value: ActiveTierSet, pinned?: string): string | undefined => { + const rows = activeTierRows(value); + const named = (name: string) => rows.find((row) => activeTierName(row) === name)?.models[0]; + return pinned?.trim() || named("MEDIUM") || named("SIMPLE"); +}; 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 bce96ec76f5..8f4b06d80fb 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 @@ -14,25 +14,21 @@ import ModelChoiceCombobox, { type ModelChoice } from "../add_model/ModelChoiceC import { modelAvailableCall, modelPatchUpdateCall } from "../networking"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; import RouterConfigBuilder from "../add_model/RouterConfigBuilder"; -import { - hydrateTierModelParams, - normalizeTierModels, - resolveComplexityDefaultModel, - serializeTierModelConfigs, -} from "../add_model/complexity_router_tiers"; +import { hydrateTierModelParams, normalizeTierModels } from "../add_model/complexity_router_tiers"; +import { type ActiveTierSet, activeTierRows, resolveComplexityDefaultModel } from "../add_model/tier_rows"; import { isComplexityRouter } from "../add_model/auto_router_strategies"; import { + type BuildComplexityRouterConfigParams, + buildComplexityRouterConfig, getKeywordTierRulesError, getSemanticConfigError, getPlanModeTierError, getTierLabelsError, hydrateTierLabels, - normalizeClassifierLlmConfig, - serializeTierLabels, } from "../add_model/build_complexity_router_config"; import { KeywordTierRule } from "../add_model/KeywordTierRules"; import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching"; -import { hydrateKeywordTierRules, serializeKeywordTierRules } from "../add_model/complexity_router_keywords"; +import { hydrateKeywordTierRules } from "../add_model/complexity_router_keywords"; import { hydrateDimensionWeights, hydrateReasoningOverrideMinScore, @@ -46,7 +42,6 @@ import ComplexityRouterConfig, { DEFAULT_SESSION_AFFINITY, DEFAULT_DEPLOYMENT_AFFINITY, DEFAULT_TIER_DISTANCE_PENALTY, - heuristicScoringRole, } from "../add_model/ComplexityRouterConfig"; import { Dialog, @@ -119,12 +114,12 @@ const toRecord = (value: unknown): Record => { export const hydratePinnedDefaultModel = ( storedConfigDefaultModel: unknown, litellmParamsDefaultModel: string | null | undefined, - tiers: ComplexityTiers, + activeTiers: ActiveTierSet, ): string | undefined => { if (typeof storedConfigDefaultModel === "string" && storedConfigDefaultModel.trim()) { return storedConfigDefaultModel; } - const tierDerived = resolveComplexityDefaultModel(tiers); + const tierDerived = resolveComplexityDefaultModel(activeTiers); const externalOverride = litellmParamsDefaultModel?.trim(); return externalOverride && externalOverride !== tierDerived ? externalOverride : undefined; }; @@ -148,73 +143,48 @@ export const buildUpdatedComplexityRouterConfig = ( if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true; return customTechnicalKeywords !== undefined && key === "custom_technical_keywords"; }; - const preservedConfig = Object.fromEntries(Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key))); - const adaptiveEligible = value.adaptive_eligible ?? "all"; - const storedKeywordRules = keywordMatching ? serializeKeywordTierRules(keywordMatching.keywordTierRules) : []; - const serializedTierLabels = serializeTierLabels(value.tier_labels); - const scorerRuns = heuristicScoringRole(value) !== "never"; - const serializedTierModelConfigs = serializeTierModelConfigs(value.tiers, value.tier_model_params); + const builderParams: BuildComplexityRouterConfigParams = { + tiers: value.tiers, + defaultModel: value.default_model, + planModeMinTier: value.plan_mode_min_tier, + tierLabels: value.tier_labels, + classifierType: value.classifier_type, + classifierLlmConfig: value.classifier_llm_config, + classifierContextWindowSize: value.classifier_context_window_size, + classifierContextBudgetChars: value.classifier_context_budget_chars, + classifierContextIncludeAssistantTurns: value.classifier_context_include_assistant_turns, + classifierFallback: value.classifier_fallback, + sessionAffinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY, + deploymentAffinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY, + customTechnicalKeywords: customTechnicalKeywords ?? [], + keywordTierRules: keywordMatching?.keywordTierRules ?? [], + semanticMatchingEnabled: keywordMatching?.semanticMatchingEnabled ?? false, + embeddingModel: keywordMatching?.embeddingModel, + matchThreshold: keywordMatching?.matchThreshold ?? DEFAULT_MATCH_THRESHOLD, + escalationKeywords: keywordMatching?.escalationKeywords ?? [], + adaptive: value.adaptive ?? false, + adaptiveWeights: value.adaptive_weights ?? DEFAULT_ADAPTIVE_WEIGHTS, + tierDistancePenalty: value.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY, + adaptiveEligible: value.adaptive_eligible ?? "all", + returnRawModelName: value.return_raw_model_name ?? false, + tierBoundaries: value.tier_boundaries, + tokenThresholds: value.token_thresholds, + dimensionWeights: value.dimension_weights, + reasoningOverrideMinScore: value.reasoning_override_min_score, + tierModelParams: value.tier_model_params, + }; + const built = buildComplexityRouterConfig(builderParams); + // Keys this call does not own stay as the stored config left them. + const unowned: readonly string[] = [ + ...(keywordMatching === undefined ? KEYWORD_MATCHING_KEYS : []), + ...(customTechnicalKeywords === undefined ? ["custom_technical_keywords"] : []), + ]; return { ...preservedConfig, - tiers: value.tiers, - ...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }), - ...(value.default_model?.trim() && { default_model: value.default_model }), - ...(value.plan_mode_min_tier?.trim() && { plan_mode_min_tier: value.plan_mode_min_tier }), - ...(serializedTierLabels && { tier_labels: serializedTierLabels }), - classifier_type: value.classifier_type, - ...(value.classifier_type === "llm" && value.classifier_llm_config - ? { classifier_llm_config: normalizeClassifierLlmConfig(value.classifier_llm_config) } - : {}), - ...(value.classifier_type === "llm" && - value.classifier_fallback !== undefined && { classifier_fallback: value.classifier_fallback }), - ...(value.classifier_type === "llm" && - value.classifier_context_window_size !== undefined && { - classifier_context_window_size: value.classifier_context_window_size, - }), - ...(value.classifier_type === "llm" && - value.classifier_context_budget_chars !== undefined && { - classifier_context_budget_chars: value.classifier_context_budget_chars, - }), - ...(value.classifier_type === "llm" && - value.classifier_context_include_assistant_turns !== undefined && { - classifier_context_include_assistant_turns: value.classifier_context_include_assistant_turns, - }), - session_affinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY, - deployment_affinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY, - ...(customTechnicalKeywords && - customTechnicalKeywords.length > 0 && { - custom_technical_keywords: customTechnicalKeywords, - }), - ...(value.adaptive && { - adaptive: true, - adaptive_weights: value.adaptive_weights ?? DEFAULT_ADAPTIVE_WEIGHTS, - ...(adaptiveEligible === "all" && { - tier_distance_penalty: value.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY, - }), - adaptive_eligible: adaptiveEligible, - }), - ...(value.return_raw_model_name && { return_raw_model_name: true }), - ...(keywordMatching && { - // Mirrors buildComplexityRouterConfig: the key only when there is a rule to write, - // escalation keywords always, semantic trio only when on. - ...(storedKeywordRules.length > 0 && { keyword_tier_rules: storedKeywordRules }), - escalation_keywords: keywordMatching.escalationKeywords.map((k) => k.trim()).filter(Boolean), - ...(keywordMatching.semanticMatchingEnabled && { - semantic_keyword_matching: true, - embedding_model: keywordMatching.embeddingModel, - match_threshold: keywordMatching.matchThreshold, - }), - }), - ...(scorerRuns && value.tier_boundaries !== undefined && { tier_boundaries: value.tier_boundaries }), - ...(scorerRuns && value.token_thresholds !== undefined && { token_thresholds: value.token_thresholds }), - ...(scorerRuns && value.dimension_weights !== undefined && { dimension_weights: value.dimension_weights }), - ...(scorerRuns && - value.reasoning_override_min_score !== undefined && { - reasoning_override_min_score: value.reasoning_override_min_score, - }), + ...Object.fromEntries(Object.entries(built).filter(([key]) => !unowned.includes(key))), }; }; @@ -297,7 +267,7 @@ const EditAutoRouterModal: React.FC = ({ ? "Please select at least one model for a complexity tier" : null) ?? getTierLabelsError(complexityRouterConfig.tier_labels) ?? - getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, complexityRouterConfig.tiers) ?? + getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ?? getKeywordTierRulesError(keywordTierRules); useEffect(() => { @@ -355,7 +325,7 @@ const EditAutoRouterModal: React.FC = ({ default_model: hydratePinnedDefaultModel( parsedConfig.default_model, modelData.litellm_params?.complexity_router_default_model, - hydratedTiers, + { tiers: hydratedTiers }, ), plan_mode_min_tier: typeof parsedConfig.plan_mode_min_tier === "string" && parsedConfig.plan_mode_min_tier.trim() !== "" @@ -486,7 +456,7 @@ const EditAutoRouterModal: React.FC = ({ // build_complexity_router_config.ts for why create never can). init_complexity_router_deployment // raises in that case (litellm/router.py), so block it rather than saving a router that // fails at init. - const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model); + const defaultModel = resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model); if (!defaultModel) { setShowValidationErrors(true); toast.fromError( diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 65b0a18f78a..f21e98e084c 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -16,7 +16,7 @@ import { stripMaskedSecrets } from "../utils/maskedSecretUtils"; import { truncateString } from "../utils/textUtils"; import AutoRouterConnectionTest from "./add_model/auto_router_connection_test"; import { AutoRouterTestTarget, buildAutoRouterTestTargets } from "./add_model/build_auto_router_test_targets"; -import { normalizeTierModels, resolveComplexityDefaultModel } from "./add_model/complexity_router_tiers"; +import { normalizeTierModels } from "./add_model/complexity_router_tiers"; import { hasAutoRouterEditor, isAutoRouterDeployment, @@ -91,12 +91,10 @@ const buildComplexityRouterTestTargets = ( config = rawConfig; } - const tiers = { - SIMPLE: normalizeTierModels(config.tiers?.SIMPLE), - MEDIUM: normalizeTierModels(config.tiers?.MEDIUM), - COMPLEX: normalizeTierModels(config.tiers?.COMPLEX), - REASONING: normalizeTierModels(config.tiers?.REASONING), - }; + const tiers: [string, string[]][] = + config.tiers && typeof config.tiers === "object" + ? Object.entries(config.tiers).map(([tier, models]) => [tier, normalizeTierModels(models)]) + : []; // Mirrors init_complexity_router_deployment (litellm/router.py): litellm_params wins, otherwise // pure tier-derivation. complexity_router_config.default_model is a UI-only marker the backend @@ -108,7 +106,7 @@ const buildComplexityRouterTestTargets = ( tiers, semanticMatchingEnabled: Boolean(config.semantic_keyword_matching), embeddingModel: config.embedding_model, - defaultModel: resolveComplexityDefaultModel(tiers, effectiveDefaultModel), + defaultModel: effectiveDefaultModel, }; return buildAutoRouterTestTargets(testTargetParams); }; diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts index f20bd3adb8a..72f0d6a7db4 100644 --- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts +++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts @@ -4,7 +4,6 @@ import { } from "@/components/add_model/build_complexity_router_config"; import { ComplexityRouterConfigValue, - ComplexityTiers, ClassifierType, ClassifierLLMConfig, DEFAULT_SESSION_AFFINITY, @@ -43,15 +42,7 @@ export const getRequiredModels = ( config: Pick, ): Set => { const { tiers, classifier_llm_config: classifier, embedding_model: embedding, default_model: pinned } = config; - const models = [ - ...tiers.SIMPLE, - ...tiers.MEDIUM, - ...tiers.COMPLEX, - ...tiers.REASONING, - classifier?.model, - embedding, - pinned, - ]; + const models = [...Object.values(tiers).flat(), classifier?.model, embedding, pinned]; // Boolean(), not != null: an empty-string placeholder (e.g. classifier_llm_config seeded before a // model is chosen) is never a real model reference either. return new Set(models.filter((model): model is string => Boolean(model))); @@ -191,7 +182,7 @@ export const getMissingModelsInPreset = (preset: AutoRouterPreset, availability: // effect would block submit for a model that was never going to be submitted. export const getReferencedModelsError = ( params: { - tiers: ComplexityTiers; + tiers: ComplexityRouterConfigPayload["tiers"]; classifierType: ClassifierType; classifierLlmConfig: ClassifierLLMConfig | undefined; semanticMatchingEnabled: boolean;