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 133a32a7951..7185e77dea8 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, renderWithProviders, screen, within } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; +import { useState } from "react"; import { vi } from "vitest"; import ComplexityRouterConfig, { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; vi.mock( @@ -102,6 +103,42 @@ describe("ComplexityRouterConfig", () => { expect(screen.queryByRole("option", { name: "xhigh" })).not.toBeInTheDocument(); }); + it("prunes tier params when a model is removed", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const Wrapper = () => { + const [value, setValue] = useState({ + ...defaultValue, + tiers: { ...defaultValue.tiers, REASONING: ["reasoning-model"] }, + tier_model_params: {}, + }); + return ( + { + setValue(nextValue); + onChange(nextValue); + }} + /> + ); + }; + + renderWithProviders(); + await user.click(screen.getByRole("combobox", { name: "Reasoning effort for reasoning-model" })); + await user.click(screen.getByText("high")); + await user.click(screen.getByRole("combobox", { name: "Select model(s) for reasoning queries" })); + await user.keyboard("{Backspace}"); + + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + tiers: expect.objectContaining({ REASONING: [] }), + tier_model_params: {}, + }), + ); + }); + it("should display the how classification works section", () => { renderWithProviders(); fireEvent.click(screen.getByText("Advanced: Classification Method")); diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 9435877b039..7c8c9182226 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -268,7 +268,7 @@ const ComplexityRouterConfig: React.FC = ({ ? { ...existingParams, reasoning_effort: reasoningEffort } : Object.fromEntries(Object.entries(existingParams).filter(([key]) => key !== "reasoning_effort")); const updatedTierModelParams = - updatedParams && Object.keys(updatedParams).length > 0 + Object.keys(updatedParams).length > 0 ? { ...tierModelParams, [model]: updatedParams } : Object.fromEntries(Object.entries(tierModelParams).filter(([key]) => key !== model)); onChange({ 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 5554c615927..85f17de3a95 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 @@ -15,7 +15,7 @@ import { effectiveTierLabel, heuristicScoringRoleFor, } from "./ComplexityRouterConfig"; -import { serializeTierConfig, type TierModelParamsByTier } from "./complexity_router_tiers"; +import { serializeTierConfig, type SerializedTierConfig, type TierModelParamsByTier } from "./complexity_router_tiers"; /** * Drop an empty system_prompt so the payload carries an override only when there is one. The @@ -104,7 +104,7 @@ export interface BuildComplexityRouterConfigParams { } export interface ComplexityRouterConfigPayload { - tiers: Record; + tiers: SerializedTierConfig; default_model?: string; plan_mode_min_tier?: string; tier_labels?: ComplexityTierLabels; 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 06ad0e72e4b..5b74bb50714 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 @@ -74,7 +74,7 @@ describe("normalizeTierModels", () => { const tiers = { REASONING: normalizeTierModels(stored) }; const params = { REASONING: extractTierModelParams(stored) }; - expect(serializeTierConfig(tiers, params)).toEqual({ REASONING: stored }); + expect(serializeTierConfig(tiers, params)).toEqual({ REASONING: [stored] }); }); }); 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 7e415504fd2..ce658bcece8 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 @@ -51,7 +51,7 @@ export const tierOptions = ( export type TierModelParams = Record; export type SerializedTierModel = string | { model_name: string; litellm_params: TierModelParams }; -export type TierModelParamsByTier = Partial>>; +export type TierModelParamsByTier = Partial>>; export const extractTierModelParams = (value: unknown): Record => { const entries = Array.isArray(value) ? value : [value]; @@ -75,19 +75,23 @@ export const extractTierModelParams = (value: unknown): Record | undefined, -): SerializedTierModel | SerializedTierModel[] => { +): SerializedTierModel[] => { const entries = models.map((model) => { const params = paramsByModel?.[model]; return params && Object.keys(params).length > 0 ? { model_name: model, litellm_params: params } : model; }); - if (entries.length === 1 && typeof entries[0] !== "string") return entries[0]; return entries; }; +export type SerializedTierConfig = Partial>; + export const serializeTierConfig = ( tiers: Partial, paramsByTier: TierModelParamsByTier | undefined, -): Record => +): SerializedTierConfig => Object.fromEntries( - Object.entries(tiers).map(([tier, models]) => [tier, serializeTierModels(models ?? [], paramsByTier?.[tier])]), - ); + Object.entries(tiers).map(([tier, models]) => [ + tier, + serializeTierModels(models ?? [], paramsByTier?.[tier as keyof ComplexityTiers]), + ]), + ) as SerializedTierConfig; diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts index 2be18d19e83..e38668aec33 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts +++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts @@ -68,7 +68,39 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => { }, }; - expect(buildUpdatedComplexityRouterConfig(stored, value, undefined, hydratedState).tiers).toEqual(stored.tiers); + expect(buildUpdatedComplexityRouterConfig(stored, value, undefined, hydratedState).tiers).toEqual({ + ...stored.tiers, + SIMPLE: [stored.tiers.SIMPLE], + }); + }); + + it("keeps plain tier lists unchanged and widens a stored bare string tier", () => { + const stored = { + ...STORED, + tiers: { + SIMPLE: "gpt-4o-mini", + MEDIUM: ["gpt-4o"], + COMPLEX: ["claude-sonnet-4"], + REASONING: ["o1-preview"], + }, + }; + + const formValue = { + ...FORM_VALUE, + tiers: { + SIMPLE: ["gpt-4o-mini"], + MEDIUM: ["gpt-4o"], + COMPLEX: ["claude-sonnet-4"], + REASONING: ["o1-preview"], + }, + }; + + expect(buildUpdatedComplexityRouterConfig(stored, formValue, undefined, hydratedState).tiers).toEqual({ + SIMPLE: ["gpt-4o-mini"], + MEDIUM: ["gpt-4o"], + COMPLEX: ["claude-sonnet-4"], + REASONING: ["o1-preview"], + }); }); it("persists an edited keyword rule", () => { 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 60be7c71c1d..a728e7bcb25 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 @@ -348,10 +348,10 @@ const EditAutoRouterModal: React.FC = ({ const hydratedComplexityRouterConfig: ComplexityRouterConfigValue = { tiers: hydratedTiers, tier_model_params: Object.fromEntries( - ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"].map((tier) => [ - tier, - extractTierModelParams(parsedConfig.tiers?.[tier]), - ]), + ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"].flatMap((tier) => { + const params = extractTierModelParams(parsedConfig.tiers?.[tier]); + return Object.keys(params).length > 0 ? [[tier, params]] : []; + }), ), default_model: hydratePinnedDefaultModel( parsedConfig.default_model,