diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index bff798314e3..a7af270cf8e 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -49,8 +49,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 = ({ @@ -60,8 +60,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; @@ -227,10 +228,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 92dfeab8a7d..2aa1cde95ac 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -631,3 +631,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 39e6e97bf1d..97d99eb5f90 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"; @@ -50,6 +51,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; @@ -135,11 +138,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 @@ -156,6 +156,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, @@ -242,6 +248,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. + +
@@ -265,7 +301,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..8aabfa57ab6 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", @@ -621,7 +621,10 @@ const AddAutoRouterTab: React.FC = ({ 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 503390aa238..1692c6b0244 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 @@ -22,6 +22,7 @@ export const normalizeClassifierLlmConfig = (config: ClassifierLLMConfig): Class export interface BuildComplexityRouterConfigParams { tiers: ComplexityTiers; + defaultModel: string | undefined; tierLabels: ComplexityTierLabels | undefined; classifierType: ClassifierType; classifierLlmConfig: ClassifierLLMConfig | undefined; @@ -46,6 +47,7 @@ export interface BuildComplexityRouterConfigParams { export interface ComplexityRouterConfigPayload { tiers: ComplexityTiers; + default_model?: string; tier_labels?: ComplexityTierLabels; classifier_type: ClassifierType; classifier_llm_config?: ClassifierLLMConfig; @@ -131,6 +133,7 @@ export const getSemanticConfigError = ({ export const buildComplexityRouterConfig = ({ tiers, + defaultModel, tierLabels, classifierType, classifierLlmConfig, @@ -158,6 +161,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..208a63d37df 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,111 @@ 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( + , + ); + + 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] }); + }); + + // 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..7d98d758de7 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, @@ -48,6 +48,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 +82,9 @@ const toRecord = (value: unknown): Record => { : {}; }; +export const hydratePinnedDefaultModel = (stored: unknown): string | undefined => + typeof stored === "string" && stored.trim() ? stored : undefined; + export interface KeywordMatchingState { keywordTierRules: KeywordTierRule[]; escalationKeywords: string[]; @@ -109,6 +113,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 @@ -243,6 +248,7 @@ const EditAutoRouterModal: React.FC = ({ COMPLEX: normalizeTierModels(parsedConfig.tiers?.COMPLEX), REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING), }, + default_model: hydratePinnedDefaultModel(parsedConfig.default_model), tier_labels: hydrateTierLabels(parsedConfig.tier_labels), classifier_type: parsedConfig.classifier_type || "heuristic", classifier_llm_config: parsedConfig.classifier_llm_config, @@ -362,7 +368,19 @@ 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. 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; + } + const updatedLitellmParams = { ...modelData.litellm_params, complexity_router_config: buildUpdatedComplexityRouterConfig( 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, },