diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index edc9ab8c88d..b1488e105be 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -54,8 +54,8 @@ interface ClassificationMethodConfigProps { customTechnicalKeywords?: string[]; onCustomTechnicalKeywordsChange?: (keywords: string[]) => void; showValidationErrors?: boolean; - /** Enables the default-model fallback, which the backend rejects without a default model. */ - hasDefaultModel?: boolean; + /** The resolved default model - see resolveComplexityDefaultModel. Names and gates the radio. */ + defaultModel?: string; } const ClassificationMethodConfig: React.FC = ({ @@ -65,8 +65,9 @@ const ClassificationMethodConfig: React.FC = ({ customTechnicalKeywords, onCustomTechnicalKeywordsChange, showValidationErrors = false, - hasDefaultModel = false, + defaultModel, }) => { + const hasDefaultModel = Boolean(defaultModel); const classifierModelMissing = showValidationErrors && value.classifier_type === "llm" && !value.classifier_llm_config?.model; const usesCustomPrompt = Boolean(value.classifier_llm_config?.system_prompt?.trim()); @@ -277,10 +278,14 @@ const ClassificationMethodConfig: React.FC = ({ - Route to the default model{" "} + Route to the default model{defaultModel ? ` (${defaultModel})` : ""}{" "} — right when your prompt grades something other than complexity diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx index 030b41f634d..9ef8350553b 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -706,3 +706,76 @@ describe("ComplexityRouterConfig affinity panel", () => { expect(screen.getByRole("switch", { name: "Pin a session to one deployment per model group" })).not.toBeChecked(); }); }); + +describe("ComplexityRouterConfig default model", () => { + const getDefaultModelSelect = () => screen.getByRole("combobox", { name: "Default model" }); + + it("shows what the tiers currently imply, so an untouched router still names its default", () => { + renderWithProviders(); + expect(screen.getByText("Derived from tiers: gpt-3.5-turbo")).toBeInTheDocument(); + }); + + it("asks for a model rather than naming a derived one when no tier holds one", () => { + const noTiers: ComplexityRouterConfigValue = { + ...defaultValue, + tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }, + }; + renderWithProviders(); + expect(screen.getByText("Add a model to the Simple or Medium tier")).toBeInTheDocument(); + }); + + it("records a pinned model", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithProviders(); + + await user.click(getDefaultModelSelect()); + await user.click((await screen.findAllByTitle("claude-3-opus")).slice(-1)[0]); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ default_model: "claude-3-opus" })); + }); + + it("drops the key when the pin is cleared, so an emptied select reads as tier-tracking", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const pinned: ComplexityRouterConfigValue = { ...defaultValue, default_model: "claude-3-opus" }; + renderWithProviders(); + + await user.click(document.querySelector(".ant-select-clear") as HTMLElement); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ default_model: undefined })); + }); + + it("shows a pinned model as the selection instead of the tier-derived one", () => { + const pinned: ComplexityRouterConfigValue = { ...defaultValue, default_model: "claude-3-opus" }; + renderWithProviders(); + expect( + within(getDefaultModelSelect().closest(".ant-select") as HTMLElement).getByTitle("claude-3-opus"), + ).toBeInTheDocument(); + }); + + it("unlocks the default model fallback on a pin alone, with no tier to derive from", () => { + const pinnedNoTiers: ComplexityRouterConfigValue = { + ...defaultValue, + classifier_type: "llm", + classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 }, + tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }, + default_model: "claude-3-opus", + }; + renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Classification Method")); + expect(screen.getByRole("radio", { name: /Route to the default model/ })).toBeEnabled(); + }); + + it("names the resolved default on the fallback option, so the destination is not a guess", () => { + const pinned: ComplexityRouterConfigValue = { + ...defaultValue, + classifier_type: "llm", + classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 }, + default_model: "claude-3-opus", + }; + renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Classification Method")); + expect(screen.getByRole("radio", { name: /Route to the default model \(claude-3-opus\)/ })).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 5c9c5e248e6..cc9f3146a3a 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -4,6 +4,7 @@ import React from "react"; import { ModelGroup } from "@/components/llm_calls/fetch_models"; import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig"; import ClassificationMethodConfig from "./ClassificationMethodConfig"; +import { resolveComplexityDefaultModel } from "./complexity_router_tiers"; import EscalationKeywords from "./EscalationKeywords"; import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules"; import SemanticKeywordMatching from "./SemanticKeywordMatching"; @@ -89,6 +90,8 @@ export type ComplexityTierLabels = Partial export interface ComplexityRouterConfigValue { tiers: ComplexityTiers; tier_labels?: ComplexityTierLabels; + /** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */ + default_model?: string; classifier_type: ClassifierType; classifier_llm_config?: ClassifierLLMConfig; classifier_context_window_size?: number; @@ -174,11 +177,8 @@ const ComplexityRouterConfig: React.FC = ({ onEscalationKeywordsChange, showValidationErrors = false, }) => { - // The deployment's default model is derived from the tiers on submit, mirroring the order - // add_auto_router_tab uses, so the fallback option is offered exactly when one will exist. - const hasDefaultModel = Boolean( - value.tiers.MEDIUM[0] || value.tiers.SIMPLE[0] || value.tiers.COMPLEX[0] || value.tiers.REASONING[0], - ); + const derivedDefaultModel = resolveComplexityDefaultModel(value.tiers); + const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model); // Embedding models can't serve a chat-completion role, so they're excluded here. const modelOptions = modelInfo @@ -195,6 +195,12 @@ const ComplexityRouterConfig: React.FC = ({ }); }; + // Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as + // "track the tiers" everywhere downstream instead of as a blank model name. + const handleDefaultModelChange = (model: string | undefined) => { + onChange({ ...value, default_model: model || undefined }); + }; + const handleTierLabelChange = (tier: keyof ComplexityTiers, label: string) => { onChange({ ...value, @@ -281,6 +287,36 @@ const ComplexityRouterConfig: React.FC = ({ ); })} + + +
+
+ + Default Model + + + + +
+ + + Used when the tier the request lands in has no model, and when the classifier fails with "Route to the + default model" selected. + +
@@ -304,7 +340,7 @@ const ComplexityRouterConfig: React.FC = ({ customTechnicalKeywords={customTechnicalKeywords} onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange} showValidationErrors={showValidationErrors} - hasDefaultModel={hasDefaultModel} + defaultModel={defaultModel} /> ), }, 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 7d42d85ccfb..4c834022291 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 @@ -600,6 +600,71 @@ describe("AddAutoRouterTab", () => { }); }); + describe("default model pin", () => { + const PINNED_MODEL = "pinned-default-model"; + + const waitForPresetEnabled = async (label: string) => { + openTemplateDropdown(); + await waitFor(() => { + expect(isOptionDisabled(optionByLabel(label)!)).toBe(false); + }); + }; + + const applyPresetAndPin = async (user: ReturnType) => { + await waitForPresetEnabled("Anthropic Family"); + fireEvent.click(optionByLabel("Anthropic Family")!); + + // Applying a preset collapses Detailed Configuration, so the default model row is behind it. + expandDetailedConfiguration(); + await user.click(screen.getByRole("combobox", { name: "Default model" })); + await user.click((await screen.findAllByTitle(PINNED_MODEL)).slice(-1)[0]); + }; + + beforeEach(() => { + mockFetchAvailableModels.mockResolvedValue([...ALL_FAMILY_MODELS, { model_group: PINNED_MODEL, mode: "chat" }]); + }); + + it("submits the pinned model in place of the one the tiers derive", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await applyPresetAndPin(user); + await user.type(screen.getByPlaceholderText(/smart_router/i), "pinned-router"); + await user.click(screen.getByRole("button", { name: /add auto router/i })); + + await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled()); + const submitted = vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]; + // The pin rides on litellm_params for the backend and is recorded in the config so the edit + // modal can read it back as a pin rather than guessing from the tiers. + expect(submitted).toMatchObject({ + auto_router_default_model: PINNED_MODEL, + complexity_router_config: { tiers: ANTHROPIC_TIERS, default_model: PINNED_MODEL }, + }); + expect(PINNED_MODEL).not.toBe(ANTHROPIC_TIERS.MEDIUM[0]); + }); + + it("blocks a submit whose pinned model is no longer available", async () => { + const user = userEvent.setup(); + const { container } = renderWithProviders(); + + await applyPresetAndPin(user); + fireEvent.change(screen.getByPlaceholderText(/smart_router/i), { target: { value: "stale-pin-router" } }); + expect(screen.getByRole("button", { name: /add auto router/i })).toBeEnabled(); + + // Only the pinned model disappears - the tier models all survive, so nothing but the pin can + // be what blocks the submit. + testQueryClient.setQueryData(["availableModels", "autoRouter", "token"], ALL_FAMILY_MODELS); + await waitFor(() => expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled()); + + fireEvent.submit(container.querySelector("form")!); + + await waitFor(() => + expect(NotificationManager.fromBackend).toHaveBeenCalledWith(expect.stringContaining(PINNED_MODEL)), + ); + expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled(); + }); + }); + describe("deployment-matched presets", () => { const renamedDeploymentsFor = (presetKey: string) => [...getRequiredModelsInPreset(getPresetByKey(presetKey)!)].map((model, index) => ({ 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 7b6403f250c..e4c8b9ac1f4 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 @@ -29,6 +29,7 @@ import { getSemanticConfigError, getTierLabelsError, } from "./build_complexity_router_config"; +import { resolveComplexityDefaultModel } from "./complexity_router_tiers"; import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets"; import AutoRouterConnectionTest from "./auto_router_connection_test"; import AutoRouterRoutingTest from "./AutoRouterRoutingTest"; @@ -89,9 +90,6 @@ const isPresetHintAlarming = (availability: PresetAvailability): boolean => avai // this is resolved once at import time rather than re-called from inside the component every render. const presets = getAllPresets(); -const resolveDefaultModel = (tiers: ComplexityTiers): string | undefined => - tiers.MEDIUM[0] || tiers.SIMPLE[0] || tiers.COMPLEX[0] || tiers.REASONING[0]; - // 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 => { @@ -272,6 +270,7 @@ const AddAutoRouterTab: React.FC = ({ classifierLlmConfig: complexityRouterConfig.classifier_llm_config, semanticMatchingEnabled, embeddingModel, + defaultModel: complexityRouterConfig.default_model, }; const submitBlockedReason = getSubmitBlockedReason( @@ -283,6 +282,7 @@ const AddAutoRouterTab: React.FC = ({ const complexityRouterConfigParams: BuildComplexityRouterConfigParams = { tiers: complexityRouterConfig.tiers, + defaultModel: complexityRouterConfig.default_model, tierLabels: complexityRouterConfig.tier_labels, classifierType: complexityRouterConfig.classifier_type, classifierLlmConfig: complexityRouterConfig.classifier_llm_config, @@ -353,7 +353,7 @@ const AddAutoRouterTab: React.FC = ({ return; } - const defaultModel = resolveDefaultModel(tiers); + const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model); form.setFieldsValue({ custom_llm_provider: "auto_router", @@ -365,6 +365,10 @@ const AddAutoRouterTab: React.FC = ({ form .validateFields(requiresTeamScope ? ["auto_router_name", "team_id"] : ["auto_router_name"]) .then((values) => { + // auto_router_default_model (-> litellm_params, read by the backend at init) and + // complexity_router_config.default_model (-> the pin marker read back on edit, see + // hydratePinnedDefaultModel in edit_auto_router_modal.tsx) must both come from the same + // `defaultModel`, or the two fields diverge and hydration's divergence check misfires. const submitValues = { ...values, auto_router_name: name, @@ -395,11 +399,13 @@ const AddAutoRouterTab: React.FC = ({ }; const handleTestConnection = () => { - const targets = buildAutoRouterTestTargets({ + const testTargetParams = { tiers: complexityRouterConfig.tiers, semanticMatchingEnabled, embeddingModel, - }); + defaultModel: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model), + }; + const targets = buildAutoRouterTestTargets(testTargetParams); if (targets.length === 0) { NotificationManager.fromBackend("Please select at least one model for a complexity tier"); @@ -621,7 +627,10 @@ const AddAutoRouterTab: React.FC = ({ 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 f8ab4bab903..85fd846ddbc 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 @@ -77,4 +77,46 @@ describe("buildAutoRouterTestTargets", () => { }); expect(targets).toEqual([{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }]); }); + + // A pin outside every tier is still a live fallback destination (empty-tier landings, and a + // failed LLM classifier routing to it), so a passing test must reach it too or it is only + // proving the tiers are reachable, not the router. + it("appends the default model as its own target when it is not already in a tier", () => { + const targets = buildAutoRouterTestTargets({ + tiers, + semanticMatchingEnabled: false, + embeddingModel: undefined, + defaultModel: "claude-3-opus", + }); + expect(targets).toEqual([ + { labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }, + { labels: ["MEDIUM", "COMPLEX"], modelGroup: "claude-sonnet-4", mode: "chat" }, + { labels: ["REASONING"], modelGroup: "o3", mode: "chat" }, + { labels: ["Default"], modelGroup: "claude-3-opus", mode: "chat" }, + ]); + }); + + it("does not duplicate a default model that a tier already covers", () => { + const targets = buildAutoRouterTestTargets({ + tiers, + semanticMatchingEnabled: false, + embeddingModel: undefined, + defaultModel: "claude-sonnet-4", + }); + expect(targets).toEqual([ + { labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }, + { labels: ["MEDIUM", "COMPLEX"], modelGroup: "claude-sonnet-4", mode: "chat" }, + { labels: ["REASONING"], modelGroup: "o3", mode: "chat" }, + ]); + }); + + it.each([[undefined], [""], [" "]])("adds no default target for %o", (defaultModel) => { + const targets = buildAutoRouterTestTargets({ + tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + semanticMatchingEnabled: false, + embeddingModel: undefined, + defaultModel, + }); + expect(targets).toEqual([{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }]); + }); }); 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 708a25c16f0..471552fc84f 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 @@ -12,6 +12,9 @@ export interface BuildAutoRouterTestTargetsParams { tiers: ComplexityTiers; semanticMatchingEnabled: boolean; embeddingModel: string | undefined; + /** The resolved default model - see resolveComplexityDefaultModel. A live fallback destination, + * so it is probed even when no tier lists it. */ + defaultModel?: string; } // Keys drive iteration order; `satisfies Record` makes it a @@ -27,8 +30,9 @@ export const buildAutoRouterTestTargets = ({ tiers, semanticMatchingEnabled, embeddingModel, + defaultModel, }: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => { - const groupedByModel = TIER_ORDER.reduce>((acc, tier) => { + const tieredByModel = TIER_ORDER.reduce>((acc, tier) => { return (tiers[tier] ?? []).reduce((tierAcc, rawModel) => { const modelGroup = rawModel?.trim(); if (!modelGroup) return tierAcc; @@ -36,6 +40,15 @@ export const buildAutoRouterTestTargets = ({ }, acc); }, {}); + // The default is a live destination whenever the chosen tier has no model, and when an LLM + // classifier fails with "Route to the default model", so a green test that skipped it would be + // reporting on a router it had not fully reached. + const resolvedDefault = defaultModel?.trim(); + const groupedByModel = + resolvedDefault && !(resolvedDefault in tieredByModel) + ? { ...tieredByModel, [resolvedDefault]: ["Default"] } + : tieredByModel; + const tierTargets: AutoRouterTestTarget[] = Object.entries(groupedByModel).map(([modelGroup, labels]) => ({ labels, modelGroup, 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 d55057416c6..a6dcb9a61cf 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 @@ -38,6 +38,7 @@ export const normalizeClassifierLlmConfig = ({ export interface BuildComplexityRouterConfigParams { tiers: ComplexityTiers; + defaultModel: string | undefined; tierLabels: ComplexityTierLabels | undefined; classifierType: ClassifierType; classifierLlmConfig: ClassifierLLMConfig | undefined; @@ -62,6 +63,7 @@ export interface BuildComplexityRouterConfigParams { export interface ComplexityRouterConfigPayload { tiers: ComplexityTiers; + default_model?: string; tier_labels?: ComplexityTierLabels; classifier_type: ClassifierType; classifier_llm_config?: ClassifierLLMConfig; @@ -120,6 +122,12 @@ 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); if (missing.length === 0) return null; @@ -147,6 +155,7 @@ export const getSemanticConfigError = ({ export const buildComplexityRouterConfig = ({ tiers, + defaultModel, tierLabels, classifierType, classifierLlmConfig, @@ -174,6 +183,7 @@ export const buildComplexityRouterConfig = ({ return { tiers, + ...(defaultModel?.trim() && { default_model: defaultModel }), ...(cleanedTierLabels && { tier_labels: cleanedTierLabels }), classifier_type: classifierType, ...(classifierType === "llm" && 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 d4604265d3a..8f75b26f250 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 @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; -import { normalizeTierModels } from "./complexity_router_tiers"; +import { normalizeTierModels, resolveComplexityDefaultModel } from "./complexity_router_tiers"; + +import type { ComplexityTiers } from "./ComplexityRouterConfig"; // The backend types a tier as `str | list[str]` and widens with // `models if isinstance(models, list) else [models]` @@ -28,3 +30,43 @@ describe("normalizeTierModels", () => { expect(normalizeTierModels(value)).toEqual([]); }); }); + +// router.py derives the default as `MEDIUM or SIMPLE` and raises when neither holds a model, so +// the resolver must not invent a COMPLEX/REASONING fallthrough the backend would never take. +describe("resolveComplexityDefaultModel", () => { + const tiers: ComplexityTiers = { + SIMPLE: ["simple-model"], + MEDIUM: ["medium-model"], + COMPLEX: ["complex-model"], + REASONING: ["reasoning-model"], + }; + const noTiers: ComplexityTiers = { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }; + + it("derives from MEDIUM first when nothing is pinned", () => { + expect(resolveComplexityDefaultModel(tiers)).toBe("medium-model"); + }); + + it("falls back to SIMPLE when MEDIUM is empty", () => { + expect(resolveComplexityDefaultModel({ ...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(); + }); + + it("lets a pin beat the tiers rather than merely filling in for them", () => { + expect(resolveComplexityDefaultModel(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"); + }); + + it.each([[""], [" "], [undefined]])("reads %o as no pin and goes back to the tiers", (pinned) => { + expect(resolveComplexityDefaultModel(tiers, pinned)).toBe("medium-model"); + }); + + it("resolves to nothing when neither a pin nor a tier offers a model", () => { + expect(resolveComplexityDefaultModel(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 bb34d221b2d..2e7859964bc 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,3 +1,5 @@ +import type { ComplexityTiers } from "./ComplexityRouterConfig"; + /** * A complexity tier maps to `str | list[str]` on the backend * (litellm/router_strategy/complexity_router/config.py: "string = pin; list = random pick"), @@ -12,3 +14,11 @@ export const normalizeTierModels = (value: unknown): string[] => { if (typeof value === "string" && value) return [value]; return []; }; + +/** + * 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]; diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx index 74dea8cc2ed..b38d5492a45 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx @@ -548,3 +548,197 @@ describe("EditAutoRouterModal custom classifier prompt and fallback", () => { expect(savedConfig().classifier_llm_config).not.toHaveProperty("system_prompt"); }); }); + +describe("EditAutoRouterModal default model", () => { + beforeEach(() => { + modelPatchUpdateCall.mockClear(); + }); + + const savedDefaultModel = () => { + const [, payload] = modelPatchUpdateCall.mock.calls.at(-1) ?? []; + return payload?.litellm_params?.complexity_router_default_model; + }; + + const renderWithStoredPin = (default_model?: string) => + renderWithProviders( + , + ); + + // No config blob marker — only litellm_params.complexity_router_default_model, as an untouched + // router looked before this PR's marker existed, or one an external API call wrote directly to. + const renderWithLitellmParamsDefaultOnly = (complexityRouterDefaultModel: string) => + renderWithProviders( + , + ); + + it("preserves a stored pin through an untouched open-and-save", async () => { + const user = userEvent.setup(); + renderWithStoredPin("out-of-band-default"); + + await user.click(await screen.findByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedDefaultModel()).toBe("out-of-band-default"); + expect(savedConfig()).toMatchObject({ default_model: "out-of-band-default" }); + }); + + it("shows a stored pin as the selection, so the saved value is not a hidden one", async () => { + renderWithStoredPin("out-of-band-default"); + + const select = await screen.findByRole("combobox", { name: "Default model" }); + expect(within(select.closest(".ant-select") as HTMLElement).getByTitle("out-of-band-default")).toBeInTheDocument(); + }); + + // The pin is recorded in the config rather than inferred by comparing the stored default to a + // re-derivation, so pinning the model the tiers already imply still reads back as a pin. + it("keeps a pin that matches what the tiers derive", async () => { + const user = userEvent.setup(); + renderWithStoredPin(STORED_CONFIG.tiers.MEDIUM[0]); + + const select = await screen.findByRole("combobox", { name: "Default model" }); + expect( + within(select.closest(".ant-select") as HTMLElement).getByTitle(STORED_CONFIG.tiers.MEDIUM[0]), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedConfig()).toMatchObject({ default_model: STORED_CONFIG.tiers.MEDIUM[0] }); + }); + + // Greptile P1 on #36615: with no config blob marker, a litellm_params default that merely + // matches what the tiers derive is indistinguishable from the pre-PR auto-derive-and-write + // behavior (main always wrote a tier-derived value there on every save). Treating it as a pin + // would freeze every pre-existing router's default away from its tiers, so it stays unpinned. + it("treats a litellm_params default matching tier-derivation as unpinned, not a frozen-in pin", async () => { + const user = userEvent.setup(); + renderWithLitellmParamsDefaultOnly(STORED_CONFIG.tiers.MEDIUM[0]); + + const select = await screen.findByRole("combobox", { name: "Default model" }); + expect(select.closest(".ant-select")?.querySelector(".ant-select-selection-item")).toBeNull(); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedConfig()).not.toHaveProperty("default_model"); + expect(savedDefaultModel()).toBe(STORED_CONFIG.tiers.MEDIUM[0]); + }); + + // Greptile P1 on #36615: a litellm_params default that diverges from tier-derivation could only + // have gotten there via an explicit override — set by the API directly, since this UI's own + // save path keeps it in sync with tiers whenever there's no pin. That divergence must survive + // the next save instead of being silently recomputed away. + it("treats a diverging litellm_params default as an external pin and preserves it", async () => { + const user = userEvent.setup(); + renderWithLitellmParamsDefaultOnly("claude-sonnet-4"); + + const select = await screen.findByRole("combobox", { name: "Default model" }); + expect(within(select.closest(".ant-select") as HTMLElement).getByTitle("claude-sonnet-4")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedConfig()).toMatchObject({ default_model: "claude-sonnet-4" }); + expect(savedDefaultModel()).toBe("claude-sonnet-4"); + }); + + // The config blob marker is this UI's own authoritative record of intent (see + // hydratePinnedDefaultModel), so it wins even over a litellm_params value that disagrees — + // e.g. a stale value from before the operator most recently changed the pin. + it("prefers the config blob marker over a diverging litellm_params value", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + const select = await screen.findByRole("combobox", { name: "Default model" }); + expect(within(select.closest(".ant-select") as HTMLElement).getByTitle("blob-pin")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedConfig()).toMatchObject({ default_model: "blob-pin" }); + }); + + // This modal only requires one non-empty tier, so a COMPLEX-only router is reachable here even + // though the backend raises on it. The block keeps that failure at save time instead of init. + it("blocks a save when neither the tiers nor a pin give the backend a default", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(await screen.findByRole("button", { name: /save changes/i })); + + await waitFor(() => + expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(expect.stringContaining("Simple or Medium tier")), + ); + expect(modelPatchUpdateCall).not.toHaveBeenCalled(); + }); + + it("leaves a router with no stored pin tracking its tiers", async () => { + const user = userEvent.setup(); + renderWithStoredPin(); + + const select = await screen.findByRole("combobox", { name: "Default model" }); + expect(select.closest(".ant-select")?.querySelector(".ant-select-selection-item")).toBeNull(); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedDefaultModel()).toBe(STORED_CONFIG.tiers.MEDIUM[0]); + expect(savedConfig()).not.toHaveProperty("default_model"); + }); +}); 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 6818d3b850c..ae01584e174 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 @@ -4,7 +4,7 @@ import { TextInput } from "@tremor/react"; import { modelAvailableCall, modelPatchUpdateCall } from "../networking"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; import RouterConfigBuilder from "../add_model/RouterConfigBuilder"; -import { normalizeTierModels } from "../add_model/complexity_router_tiers"; +import { normalizeTierModels, resolveComplexityDefaultModel } from "../add_model/complexity_router_tiers"; import { isComplexityRouter } from "../add_model/auto_router_strategies"; import { getKeywordTierRulesError, @@ -19,6 +19,7 @@ import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching"; import { hydrateKeywordTierRules, serializeKeywordTierRules } from "../add_model/complexity_router_keywords"; import ComplexityRouterConfig, { ComplexityRouterConfigValue, + ComplexityTiers, DEFAULT_ADAPTIVE_WEIGHTS, DEFAULT_SESSION_AFFINITY, DEFAULT_DEPLOYMENT_AFFINITY, @@ -48,6 +49,7 @@ interface EditAutoRouterModalProps { // actually renders a control that can set it. const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([ "tiers", + "default_model", "tier_labels", "classifier_type", "classifier_llm_config", @@ -81,6 +83,24 @@ const toRecord = (value: unknown): Record => { : {}; }; +// A pin lives in two places: complexity_router_config.default_model (this UI's own marker, added +// by PR #36615) and litellm_params.complexity_router_default_model (what the backend reads). Only +// the marker proves an operator picked it, because before #36615 every save wrote a tier-derived +// value into litellm_params. So with no marker, a litellm_params value counts as a pin only when +// it diverges from what the tiers alone derive; a match stays unpinned and keeps tracking tiers. +export const hydratePinnedDefaultModel = ( + storedConfigDefaultModel: unknown, + litellmParamsDefaultModel: string | null | undefined, + tiers: ComplexityTiers, +): string | undefined => { + if (typeof storedConfigDefaultModel === "string" && storedConfigDefaultModel.trim()) { + return storedConfigDefaultModel; + } + const tierDerived = resolveComplexityDefaultModel(tiers); + const externalOverride = litellmParamsDefaultModel?.trim(); + return externalOverride && externalOverride !== tierDerived ? externalOverride : undefined; +}; + export interface KeywordMatchingState { keywordTierRules: KeywordTierRule[]; escalationKeywords: string[]; @@ -109,6 +129,7 @@ export const buildUpdatedComplexityRouterConfig = ( return { ...preservedConfig, tiers: value.tiers, + ...(value.default_model?.trim() && { default_model: value.default_model }), ...(serializedTierLabels && { tier_labels: serializedTierLabels }), classifier_type: value.classifier_type, ...(value.classifier_type === "llm" && value.classifier_llm_config @@ -236,13 +257,20 @@ const EditAutoRouterModal: React.FC = ({ parsedConfig = JSON.parse(parsedConfig); } + const hydratedTiers: ComplexityTiers = { + SIMPLE: normalizeTierModels(parsedConfig.tiers?.SIMPLE), + MEDIUM: normalizeTierModels(parsedConfig.tiers?.MEDIUM), + COMPLEX: normalizeTierModels(parsedConfig.tiers?.COMPLEX), + REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING), + }; + const hydratedComplexityRouterConfig: ComplexityRouterConfigValue = { - tiers: { - SIMPLE: normalizeTierModels(parsedConfig.tiers?.SIMPLE), - MEDIUM: normalizeTierModels(parsedConfig.tiers?.MEDIUM), - COMPLEX: normalizeTierModels(parsedConfig.tiers?.COMPLEX), - REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING), - }, + tiers: hydratedTiers, + default_model: hydratePinnedDefaultModel( + parsedConfig.default_model, + modelData.litellm_params?.complexity_router_default_model, + hydratedTiers, + ), tier_labels: hydrateTierLabels(parsedConfig.tier_labels), classifier_type: parsedConfig.classifier_type || "heuristic", classifier_llm_config: parsedConfig.classifier_llm_config, @@ -362,7 +390,23 @@ const EditAutoRouterModal: React.FC = ({ return; } - const defaultModel = tiers.MEDIUM[0] || tiers.SIMPLE[0] || tiers.COMPLEX[0] || tiers.REASONING[0]; + // Unlike the create form, this modal only requires one non-empty tier, so a router can reach + // here with nothing the backend would pick as a default (see getMissingTiersError in + // 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); + if (!defaultModel) { + setShowValidationErrors(true); + NotificationsManager.fromBackend( + "Add a model to the Simple or Medium tier, or pin a default model, so requests have somewhere to route.", + ); + return; + } + + // Dual write: complexity_router_config.default_model (the pin marker hydratePinnedDefaultModel + // reads back) and complexity_router_default_model (what the backend routes on) must always be + // written together from the same value. Same pairing in add_auto_router_tab.tsx. const updatedLitellmParams = { ...modelData.litellm_params, complexity_router_config: buildUpdatedComplexityRouterConfig( diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 5eb17fee2c4..46bd80a0bc5 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -1056,6 +1056,44 @@ describe("ModelInfoView", () => { expect(mockTestModelGroupConnection).not.toHaveBeenCalled(); }); + // Bugbot finding on #36615: complexity_router_config.default_model is a UI-only bookkeeping + // marker — init_complexity_router_deployment (litellm/router.py) never reads it, falling back + // to tier-derivation instead when litellm_params.complexity_router_default_model is absent. + // Probing the blob field here would test a model the running router never calls. + it("ignores an unused config blob pin when litellm_params has no default, matching the backend's own tier-derivation fallback", async () => { + const complexityRouterModelData = { + ...defaultModelData, + litellm_params: { + ...defaultModelData.litellm_params, + model: "auto_router/complexity_router", + complexity_router_config: { + tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + default_model: "unused-blob-pin", + }, + // no complexity_router_default_model + }, + }; + + mockUseModelsInfo.mockReturnValue({ + data: { + data: [complexityRouterModelData], + }, + isLoading: false, + error: null, + }); + mockTestModelGroupConnection.mockResolvedValue({ status: "success" }); + + render(, { wrapper }); + const testConnectionButton = await screen.findByTestId("test-connection-button"); + await userEvent.click(testConnectionButton); + + await waitFor(() => { + expect(mockTestModelGroupConnection).toHaveBeenCalledWith("test-token", "gpt-4o-mini", "chat"); + }); + expect(mockTestModelGroupConnection).not.toHaveBeenCalledWith("test-token", "unused-blob-pin", "chat"); + expect(mockTestModelGroupConnection).toHaveBeenCalledTimes(1); + }); + it("should display model access groups field", async () => { render(, { wrapper }); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index a0c545e17aa..0532238b872 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -41,7 +41,7 @@ import { isMaskedSecret, stripMaskedSecrets } from "../utils/maskedSecretUtils"; import { formItemValidateJSON, 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 } from "./add_model/complexity_router_tiers"; +import { normalizeTierModels, resolveComplexityDefaultModel } from "./add_model/complexity_router_tiers"; import { hasAutoRouterEditor, isAutoRouterDeployment, @@ -153,6 +153,7 @@ interface ComplexityRouterTierConfig { }; semantic_keyword_matching?: boolean; embedding_model?: string; + default_model?: string; } interface ComplexityRouterModelData { @@ -177,25 +178,26 @@ const buildComplexityRouterTestTargets = ( config = rawConfig; } - const tierTargets = buildAutoRouterTestTargets({ - tiers: { - SIMPLE: normalizeTierModels(config.tiers?.SIMPLE), - MEDIUM: normalizeTierModels(config.tiers?.MEDIUM), - COMPLEX: normalizeTierModels(config.tiers?.COMPLEX), - REASONING: normalizeTierModels(config.tiers?.REASONING), - }, + const tiers = { + SIMPLE: normalizeTierModels(config.tiers?.SIMPLE), + MEDIUM: normalizeTierModels(config.tiers?.MEDIUM), + COMPLEX: normalizeTierModels(config.tiers?.COMPLEX), + REASONING: normalizeTierModels(config.tiers?.REASONING), + }; + + // 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 + // never reads — folding it in here could point Test Connection at a model the router never + // calls (see PR #36615 discussion). + const effectiveDefaultModel = modelData?.litellm_params?.complexity_router_default_model || undefined; + + const testTargetParams = { + tiers, semanticMatchingEnabled: Boolean(config.semantic_keyword_matching), embeddingModel: config.embedding_model, - }); - - const defaultModel = modelData?.litellm_params?.complexity_router_default_model?.trim(); - if (!defaultModel || tierTargets.some((target) => target.modelGroup === defaultModel)) { - return tierTargets; - } - return [ - ...tierTargets, - { labels: ["Default (unconfigured tiers)"], modelGroup: defaultModel, mode: "chat" as const }, - ]; + defaultModel: resolveComplexityDefaultModel(tiers, effectiveDefaultModel), + }; + return buildAutoRouterTestTargets(testTargetParams); }; export default function ModelInfoView({ diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts index 5caff6fe714..7ef0e06e2e6 100644 --- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts +++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts @@ -40,10 +40,18 @@ export const getPresetByKey = (key: string): AutoRouterPreset | undefined => PRE // bundled config or a caller's actually-built config - the two need to agree, since a preset only // prefills once and the config is edited freely after (see AddAutoRouterTab.submitBlockedReason). export const getRequiredModels = ( - config: Pick, + config: Pick, ): Set => { - const { tiers, classifier_llm_config: classifier, embedding_model: embedding } = config; - const models = [...tiers.SIMPLE, ...tiers.MEDIUM, ...tiers.COMPLEX, ...tiers.REASONING, classifier?.model, embedding]; + 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, + ]; // 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))); @@ -164,7 +172,7 @@ const resolveAvailableModel = (requiredModel: string, availability: ModelAvailab }; export const getMissingModels = ( - config: Pick, + config: Parameters[0], availability: ModelAvailability, ): string[] => [...getRequiredModels(config)].filter((model) => resolveAvailableModel(model, availability) === undefined).sort(); @@ -188,12 +196,14 @@ export const getReferencedModelsError = ( classifierLlmConfig: ClassifierLLMConfig | undefined; semanticMatchingEnabled: boolean; embeddingModel: string | undefined; + defaultModel?: string; }, availability: ModelAvailability, ): string | null => { const missing = getMissingModels( { tiers: params.tiers, + default_model: params.defaultModel, classifier_llm_config: params.classifierType === "llm" ? params.classifierLlmConfig : undefined, embedding_model: params.semanticMatchingEnabled ? params.embeddingModel : undefined, },