From cb4eb82249bf7bc08dce64194b7b91f2ae370cc6 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Thu, 20 Aug 2026 15:10:35 -0700 Subject: [PATCH] feat(ui): per-model reasoning effort in the complexity tier editor (#37673) * feat(ui): per-model reasoning effort in the complexity tier editor * feat(ui): gate the effort control on model group reasoning support --- .../add_model/ComplexityRouterConfig.test.tsx | 75 +++++++- .../add_model/ComplexityRouterConfig.tsx | 39 +++- .../add_model/TierModelEffortRows.tsx | 80 +++++++++ .../add_model/add_auto_router_tab.tsx | 1 + .../build_complexity_router_config.test.ts | 17 ++ .../build_complexity_router_config.ts | 6 + .../add_model/complexity_router_tiers.test.ts | 166 +++++++++++++++++- .../add_model/complexity_router_tiers.ts | 112 +++++++++++- ...d_updated_complexity_router_config.test.ts | 56 ++++++ .../edit_auto_router_modal.tsx | 12 +- .../src/components/llm_calls/fetch_models.tsx | 3 + 11 files changed, 555 insertions(+), 12 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/TierModelEffortRows.tsx 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 640b10ad163..0a848ed7deb 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -8,9 +8,9 @@ vi.mock( ); const mockModelInfo = [ - { model_group: "gpt-4", mode: "chat" }, + { model_group: "gpt-4", mode: "chat", supports_reasoning: true }, { model_group: "gpt-3.5-turbo", mode: "chat" }, - { model_group: "claude-3-opus", mode: "chat" }, + { model_group: "claude-3-opus", mode: "chat", supports_reasoning: true }, { model_group: "text-embedding-3-small", mode: "embedding" }, ] as any[]; @@ -403,7 +403,7 @@ describe("ComplexityRouterConfig", () => { renderWithProviders(); const simpleTierSection = screen.getByText("Simple Tier").closest(".mb-4") as HTMLElement; - const combobox = within(simpleTierSection).getByRole("combobox"); + const combobox = within(simpleTierSection).getByRole("combobox", { name: "Select model(s) for simple queries" }); await user.click(combobox); expect((await screen.findAllByText("gpt-3.5-turbo")).length).toBeGreaterThan(0); @@ -874,3 +874,72 @@ describe("plan-mode override", () => { expect(await screen.findByRole("switch", { name: switchName })).toHaveAttribute("aria-disabled", "true"); }); }); + +describe("ComplexityRouterConfig per-model reasoning effort", () => { + it("renders one effort select per selected model, defaulting to Default", () => { + renderWithProviders(); + const select = screen.getByRole("combobox", { name: "Reasoning effort for gpt-4 in the Complex tier" }); + expect(select).toHaveTextContent("Default"); + }); + + it("shows the hydrated effort for a model that has one stored", () => { + renderWithProviders( + , + ); + const select = screen.getByRole("combobox", { name: "Reasoning effort for gpt-4 in the Complex tier" }); + expect(select).toHaveTextContent("high"); + }); + + it("emits tier_model_params scoped to the tier and model when an effort is picked", async () => { + const onChange = vi.fn(); + renderWithProviders(); + const user = userEvent.setup(); + await user.click(screen.getByRole("combobox", { name: "Reasoning effort for gpt-4 in the Complex tier" })); + await user.click(await screen.findByRole("option", { name: "high" })); + expect(onChange).toHaveBeenCalledWith({ + ...defaultValue, + tier_model_params: { COMPLEX: { "gpt-4": { reasoning_effort: "high" } } }, + }); + }); + + it("picking Default removes the stored effort", async () => { + const onChange = vi.fn(); + renderWithProviders( + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("combobox", { name: "Reasoning effort for gpt-4 in the Complex tier" })); + await user.click(await screen.findByRole("option", { name: "Default" })); + expect(onChange).toHaveBeenCalledWith({ ...defaultValue, tier_model_params: undefined }); + }); +}); + +describe("ComplexityRouterConfig reasoning effort gating", () => { + it("offers no effort select for a model group without reasoning support", () => { + renderWithProviders(); + expect( + screen.queryByRole("combobox", { name: "Reasoning effort for gpt-3.5-turbo in the Simple tier" }), + ).not.toBeInTheDocument(); + }); + + // A stored effort on a model the group info calls non-reasoning must stay visible, or the + // operator has no way to clear it. + it("keeps the select for a non-reasoning model that already has a stored effort", () => { + renderWithProviders( + , + ); + expect( + screen.getByRole("combobox", { name: "Reasoning effort for gpt-3.5-turbo in the Simple tier" }), + ).toHaveTextContent("low"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 57028273d6d..fc731e2c77f 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -12,7 +12,15 @@ import React from "react"; import { ModelGroup } from "@/components/llm_calls/fetch_models"; import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig"; import ClassificationMethodConfig from "./ClassificationMethodConfig"; -import { resolveComplexityDefaultModel, tierOptions } from "./complexity_router_tiers"; +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"; @@ -153,6 +161,12 @@ export interface ComplexityRouterConfigValue { * floor tracks tier_boundaries.simple_medium; an explicit 0 is a real floor that promotes on the markers alone. */ reasoning_override_min_score?: number; + /** + * Per-(tier, model) litellm_params, serialized to the sibling tier_model_configs key. The full + * params object is held, not just reasoning_effort, so keys authored in config.yaml survive an + * edit round-trip. + */ + tier_model_params?: TierModelParamsByTier; } interface ComplexityRouterConfigProps { @@ -237,6 +251,10 @@ const ComplexityRouterConfig: React.FC = ({ const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model); // Embedding models can't serve a chat-completion role, so they're excluded here. + const reasoningModels = new Set( + modelInfo.filter((model) => model.supports_reasoning).map((model) => model.model_group), + ); + const modelOptions = modelInfo .filter((model) => model.mode !== "embedding") .map((model) => ({ @@ -248,6 +266,18 @@ const ComplexityRouterConfig: React.FC = ({ onChange({ ...value, tiers: { ...value.tiers, [tier]: models }, + tier_model_params: pruneTierModelParams(value.tier_model_params, tier, models), + }); + }; + + const handleTierModelEffortChange = ( + tier: keyof ComplexityTiers, + model: string, + effort: ReasoningEffort | undefined, + ) => { + onChange({ + ...value, + tier_model_params: setTierModelReasoningEffort(value.tier_model_params, tier, model, effort), }); }; @@ -332,6 +362,13 @@ const ComplexityRouterConfig: React.FC = ({ emptyText="No models found" className={tierMissing ? "w-full border-destructive" : "w-full"} /> + handleTierModelEffortChange(tier, model, effort)} + /> {value.tiers[tier].length > 1 && ( Multiple models selected — the router randomly picks among them per request (or Thompson-samples diff --git a/ui/litellm-dashboard/src/components/add_model/TierModelEffortRows.tsx b/ui/litellm-dashboard/src/components/add_model/TierModelEffortRows.tsx new file mode 100644 index 00000000000..67f583bd894 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/TierModelEffortRows.tsx @@ -0,0 +1,80 @@ +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { SimpleTooltip } from "@/components/ui/tooltip"; +import { Info } from "lucide-react"; +import React from "react"; +import { REASONING_EFFORT_OPTIONS, ReasoningEffort, TierModelParams } from "./complexity_router_tiers"; + +const PROVIDER_DEFAULT = "__provider_default__"; + +const asEffort = (params: TierModelParams | undefined): ReasoningEffort | undefined => { + const stored = params?.reasoning_effort; + if (typeof stored !== "string") return undefined; + return REASONING_EFFORT_OPTIONS.find((option) => option === stored); +}; + +interface TierModelEffortRowsProps { + tierLabel: string; + models: string[]; + reasoningModels: ReadonlySet; + paramsByModel: Record | undefined; + onEffortChange: (model: string, effort: ReasoningEffort | undefined) => void; +} + +const TierModelEffortRows: React.FC = ({ + tierLabel, + models, + reasoningModels, + paramsByModel, + onEffortChange, +}) => { + const shown = models.filter( + (model) => reasoningModels.has(model) || Object.keys(paramsByModel?.[model] ?? {}).length > 0, + ); + if (shown.length === 0) return null; + return ( +
+
+ Reasoning effort + + + +
+ {shown.map((model) => ( +
+ {model} + +
+ ))} +
+ ); +}; + +export default TierModelEffortRows; 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 71fd6f4f1f9..46ab0bc2c9f 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 @@ -368,6 +368,7 @@ const AddAutoRouterTab: React.FC = ({ tierDistancePenalty: complexityRouterConfig.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY, adaptiveEligible: complexityRouterConfig.adaptive_eligible ?? "all", returnRawModelName: complexityRouterConfig.return_raw_model_name ?? false, + tierModelParams: complexityRouterConfig.tier_model_params, tierBoundaries: complexityRouterConfig.tier_boundaries, tokenThresholds: complexityRouterConfig.token_thresholds, dimensionWeights: complexityRouterConfig.dimension_weights, 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 3e33136a3cc..cb55362c6a7 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 @@ -656,3 +656,20 @@ describe("getPlanModeTierError", () => { expect(getPlanModeTierError("COMPLEX", tiersWithEmptyComplex)).toContain("COMPLEX"); }); }); + +describe("buildComplexityRouterConfig tier model params", () => { + it("keeps tier_model_configs out of the payload when nothing is set", () => { + expect(buildComplexityRouterConfig(baseParams)).not.toHaveProperty("tier_model_configs"); + }); + + it("emits tier_model_configs beside string tiers when efforts are set", () => { + const config = buildComplexityRouterConfig({ + ...baseParams, + tierModelParams: { COMPLEX: { "claude-sonnet-4": { reasoning_effort: "high" } } }, + }); + expect(config.tiers).toEqual(tiers); + expect(config.tier_model_configs).toEqual({ + COMPLEX: [{ model_name: "claude-sonnet-4", litellm_params: { reasoning_effort: "high" } }], + }); + }); +}); 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 bddf8321ad2..677cbe7063f 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,5 +1,6 @@ import { KeywordTierRule } from "./KeywordTierRules"; import { emptyKeywordTierRuleIndexes, serializeKeywordTierRules } from "./complexity_router_keywords"; +import { TierModelParams, TierModelParamsByTier, serializeTierModelConfigs } from "./complexity_router_tiers"; import { AdaptiveEligible, AdaptiveRouterWeights, @@ -99,6 +100,7 @@ export interface BuildComplexityRouterConfigParams { tokenThresholds?: TokenThresholds; dimensionWeights?: DimensionWeights; reasoningOverrideMinScore?: number; + tierModelParams?: TierModelParamsByTier; } export interface ComplexityRouterConfigPayload { @@ -129,6 +131,7 @@ export interface ComplexityRouterConfigPayload { token_thresholds?: TokenThresholds; dimension_weights?: DimensionWeights; reasoning_override_min_score?: number; + tier_model_configs?: Record; } const TIER_KEYS: Array = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]; @@ -236,7 +239,9 @@ export const buildComplexityRouterConfig = ({ tokenThresholds, dimensionWeights, reasoningOverrideMinScore, + tierModelParams, }: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => { + const serializedTierModelConfigs = serializeTierModelConfigs(tiers, tierModelParams); const cleanedEscalationKeywords = escalationKeywords.map((keyword) => keyword.trim()).filter(Boolean); const cleanedKeywordTierRules = serializeKeywordTierRules(keywordTierRules); const cleanedTierLabels = serializeTierLabels(tierLabels); @@ -252,6 +257,7 @@ export const buildComplexityRouterConfig = ({ return { tiers, + ...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }), ...(defaultModel?.trim() && { default_model: defaultModel }), ...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }), ...(cleanedTierLabels && { tier_labels: cleanedTierLabels }), 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 8f75b26f250..4dffbbd2ac7 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,13 @@ import { describe, expect, it } from "vitest"; -import { normalizeTierModels, resolveComplexityDefaultModel } from "./complexity_router_tiers"; +import { + hydrateTierModelParams, + normalizeTierModels, + pruneTierModelParams, + resolveComplexityDefaultModel, + serializeTierModelConfigs, + setTierModelReasoningEffort, +} from "./complexity_router_tiers"; import type { ComplexityTiers } from "./ComplexityRouterConfig"; @@ -70,3 +77,160 @@ describe("resolveComplexityDefaultModel", () => { expect(resolveComplexityDefaultModel(noTiers)).toBeUndefined(); }); }); + +// The backend also accepts `{model_name, litellm_params}` entries and splits them into the +// sibling tier_model_configs key at validation (config.py `_normalize_tier_model_configs`). +// Before this widening, an object entry was silently dropped here, so opening the edit modal on +// a yaml-authored config rendered the tier empty and the next save destroyed it. +describe("normalizeTierModels object entries", () => { + it("reads model_name from an object entry the way the backend does", () => { + expect(normalizeTierModels([{ model_name: "opus", litellm_params: { reasoning_effort: "high" } }, "mini"])).toEqual( + ["opus", "mini"], + ); + }); + + it("widens a single object entry to a one-element pool", () => { + expect(normalizeTierModels({ model_name: "opus" })).toEqual(["opus"]); + }); + + it("drops an object without a model_name", () => { + expect(normalizeTierModels([{ litellm_params: { reasoning_effort: "high" } }])).toEqual([]); + }); +}); + +describe("hydrateTierModelParams", () => { + it("reads the sibling tier_model_configs key", () => { + expect( + hydrateTierModelParams( + { MEDIUM: ["opus"] }, + { MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }] }, + ), + ).toEqual({ MEDIUM: { opus: { reasoning_effort: "medium" } } }); + }); + + it("reads inline object entries out of tiers", () => { + expect( + hydrateTierModelParams( + { COMPLEX: [{ model_name: "opus", litellm_params: { reasoning_effort: "high" } }] }, + undefined, + ), + ).toEqual({ COMPLEX: { opus: { reasoning_effort: "high" } } }); + }); + + // config.py merges the two sources with tier_model_configs winning per (tier, model); hydrating + // the other way round would show the operator a value the router never uses. + it("lets tier_model_configs beat an inline entry for the same tier and model", () => { + expect( + hydrateTierModelParams( + { MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "low" } }] }, + { MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }] }, + ), + ).toEqual({ MEDIUM: { opus: { reasoning_effort: "medium" } } }); + }); + + it("hydrates to undefined when nothing carries params, so an untouched save stays byte-identical", () => { + expect( + hydrateTierModelParams({ SIMPLE: ["mini"], MEDIUM: [{ model_name: "opus", litellm_params: {} }] }, undefined), + ).toBeUndefined(); + }); +}); + +describe("serializeTierModelConfigs", () => { + const tiers: ComplexityTiers = { SIMPLE: ["mini"], MEDIUM: ["opus"], COMPLEX: ["opus"], REASONING: [] }; + + it("emits the sibling wire shape per tier and model", () => { + expect( + serializeTierModelConfigs(tiers, { + MEDIUM: { opus: { reasoning_effort: "medium" } }, + COMPLEX: { opus: { reasoning_effort: "high" } }, + }), + ).toEqual({ + MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }], + COMPLEX: [{ model_name: "opus", litellm_params: { reasoning_effort: "high" } }], + }); + }); + + it("prunes params for a model no longer selected in the tier", () => { + expect( + serializeTierModelConfigs(tiers, { MEDIUM: { "removed-model": { reasoning_effort: "low" } } }), + ).toBeUndefined(); + }); + + // Params authored in config.yaml alongside reasoning_effort must survive an edit round-trip. + it("carries params keys this editor has no control for", () => { + expect( + serializeTierModelConfigs(tiers, { MEDIUM: { opus: { reasoning_effort: "medium", max_tokens: 512 } } }), + ).toEqual({ + MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium", max_tokens: 512 } }], + }); + }); + + // This modal renders only the four built-in tiers; params stored under an operator-defined tier + // must pass through rather than being dropped the moment the key became managed. + it("passes tiers this editor does not render through untouched", () => { + expect(serializeTierModelConfigs(tiers, { DEEP_RESEARCH: { opus: { reasoning_effort: "xhigh" } } })).toEqual({ + DEEP_RESEARCH: [{ model_name: "opus", litellm_params: { reasoning_effort: "xhigh" } }], + }); + }); + + it("round-trips what hydration produced", () => { + const stored = { MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }] }; + expect(serializeTierModelConfigs(tiers, hydrateTierModelParams(tiers, stored))).toEqual(stored); + }); + + it("serializes to undefined when nothing is set", () => { + expect(serializeTierModelConfigs(tiers, undefined)).toBeUndefined(); + expect(serializeTierModelConfigs(tiers, { MEDIUM: {} })).toBeUndefined(); + }); +}); + +describe("setTierModelReasoningEffort", () => { + it("sets an effort for a tier and model", () => { + expect(setTierModelReasoningEffort(undefined, "MEDIUM", "opus", "medium")).toEqual({ + MEDIUM: { opus: { reasoning_effort: "medium" } }, + }); + }); + + it("unsetting removes the key and collapses empties back to undefined", () => { + const set = setTierModelReasoningEffort(undefined, "MEDIUM", "opus", "medium"); + expect(setTierModelReasoningEffort(set, "MEDIUM", "opus", undefined)).toBeUndefined(); + }); + + it("unsetting the effort keeps params keys it does not own", () => { + expect( + setTierModelReasoningEffort( + { MEDIUM: { opus: { reasoning_effort: "medium", max_tokens: 512 } } }, + "MEDIUM", + "opus", + undefined, + ), + ).toEqual({ MEDIUM: { opus: { max_tokens: 512 } } }); + }); + + it("leaves other tiers and models alone", () => { + expect( + setTierModelReasoningEffort({ COMPLEX: { opus: { reasoning_effort: "high" } } }, "MEDIUM", "opus", "low"), + ).toEqual({ + COMPLEX: { opus: { reasoning_effort: "high" } }, + MEDIUM: { opus: { reasoning_effort: "low" } }, + }); + }); +}); + +describe("pruneTierModelParams", () => { + it("drops params for models deselected from the tier", () => { + expect( + pruneTierModelParams({ MEDIUM: { opus: { reasoning_effort: "medium" } } }, "MEDIUM", ["mini"]), + ).toBeUndefined(); + }); + + it("keeps params for models still selected", () => { + const current = { MEDIUM: { opus: { reasoning_effort: "medium" } } }; + expect(pruneTierModelParams(current, "MEDIUM", ["opus", "mini"])).toEqual(current); + }); + + it("returns the input unchanged when the tier holds no params", () => { + const current = { COMPLEX: { opus: { reasoning_effort: "high" } } }; + expect(pruneTierModelParams(current, "MEDIUM", [])).toBe(current); + }); +}); 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 6f5eb7f877b..cc12746a755 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,19 +1,119 @@ import type { ComplexityTiers } from "./ComplexityRouterConfig"; import type { ComplexityTier } from "./KeywordTierRules"; +export type TierModelParams = Record; + +export type TierModelParamsByTier = Record>; + +export const REASONING_EFFORT_OPTIONS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const; +export type ReasoningEffort = (typeof REASONING_EFFORT_OPTIONS)[number]; + +const asRecord = (raw: unknown): Record | undefined => + typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record) : undefined; + +const asTierEntryObject = (entry: unknown): { model_name: string; litellm_params: TierModelParams } | undefined => { + const record = asRecord(entry); + if (record === undefined || typeof record.model_name !== "string" || !record.model_name) return undefined; + return { model_name: record.model_name, litellm_params: asRecord(record.litellm_params) ?? {} }; +}; + /** - * A complexity tier maps to `str | list[str]` on the backend - * (litellm/router_strategy/complexity_router/config.py: "string = pin; list = random pick"), - * and the router widens the bare string with `models if isinstance(models, list) else [models]`. + * A complexity tier maps to `str | object | list[str | object]` on the backend + * (litellm/router_strategy/complexity_router/config.py: string/object = pin; list = random pick; + * an object is `{model_name, litellm_params}`), and the router widens a bare value to a list. * * Every UI reader of a STORED complexity_router_config must widen the same way, so this is the * single owner of that rule. Readers of in-memory ComplexityTiers state are already string[] * and do not need it. */ export const normalizeTierModels = (value: unknown): string[] => { - if (Array.isArray(value)) return value.filter((model): model is string => typeof model === "string"); - if (typeof value === "string" && value) return [value]; - return []; + const entries = Array.isArray(value) ? value : [value]; + return entries.flatMap((entry) => { + if (typeof entry === "string" && entry) return [entry]; + const parsed = asTierEntryObject(entry); + return parsed ? [parsed.model_name] : []; + }); +}; + +const tierEntriesWithParams = (value: unknown): [string, TierModelParams][] => + (Array.isArray(value) ? value : [value]) + .map(asTierEntryObject) + .filter((entry): entry is { model_name: string; litellm_params: TierModelParams } => entry !== undefined) + .filter((entry) => Object.keys(entry.litellm_params).length > 0) + .map((entry) => [entry.model_name, entry.litellm_params]); + +/** + * Params can be stored two ways: inline object entries in `tiers`, or the sibling + * `tier_model_configs` key. The backend merges them with `tier_model_configs` winning per + * (tier, model) (config.py `_normalize_tier_model_configs`), so hydration must too. + */ +export const hydrateTierModelParams = ( + storedTiers: unknown, + storedTierModelConfigs: unknown, +): TierModelParamsByTier | undefined => { + const fromInline = Object.entries(asRecord(storedTiers) ?? {}).map( + ([tier, value]) => [tier, tierEntriesWithParams(value)] as const, + ); + const fromSibling = Object.entries(asRecord(storedTierModelConfigs) ?? {}).map( + ([tier, value]) => [tier, tierEntriesWithParams(value)] as const, + ); + const merged = [...fromInline, ...fromSibling].reduce( + (byTier, [tier, entries]) => + entries.length === 0 ? byTier : { ...byTier, [tier]: { ...byTier[tier], ...Object.fromEntries(entries) } }, + {}, + ); + return Object.keys(merged).length > 0 ? merged : undefined; +}; + +/** + * Undefined when empty rather than `{}`, so an untouched router keeps the key out of its payload; + * tiers this editor does not render pass through rather than being dropped now the key is managed. + */ +export const serializeTierModelConfigs = ( + tiers: ComplexityTiers, + 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 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 })); + return [tier, entries] as const; + }) + .filter(([, entries]) => entries.length > 0); + return serialized.length > 0 ? Object.fromEntries(serialized) : undefined; +}; + +export const setTierModelReasoningEffort = ( + current: TierModelParamsByTier | undefined, + tier: string, + model: string, + effort: ReasoningEffort | undefined, +): TierModelParamsByTier | undefined => { + const { reasoning_effort: _dropped, ...rest } = current?.[tier]?.[model] ?? {}; + const params = effort === undefined ? rest : { ...rest, reasoning_effort: effort }; + const byModel = Object.fromEntries( + Object.entries({ ...current?.[tier], [model]: params }).filter(([, value]) => Object.keys(value).length > 0), + ); + const next = Object.fromEntries( + Object.entries({ ...current, [tier]: byModel }).filter(([, value]) => Object.keys(value).length > 0), + ); + return Object.keys(next).length > 0 ? next : undefined; +}; + +export const pruneTierModelParams = ( + current: TierModelParamsByTier | undefined, + tier: string, + selectedModels: string[], +): TierModelParamsByTier | undefined => { + if (current?.[tier] === undefined) return current; + const byModel = Object.fromEntries(Object.entries(current[tier]).filter(([model]) => selectedModels.includes(model))); + const next = Object.fromEntries( + Object.entries({ ...current, [tier]: byModel }).filter(([, value]) => Object.keys(value).length > 0), + ); + return Object.keys(next).length > 0 ? next : undefined; }; /** 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 0ae782dc5fc..59254dbbe6f 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 @@ -373,3 +373,59 @@ describe("buildUpdatedComplexityRouterConfig plan-mode minimum tier", () => { expect(result.plan_mode_min_tier).toBe("MEDIUM"); }); }); + +describe("buildUpdatedComplexityRouterConfig tier model params", () => { + const storedWithParams = { + ...STORED, + tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: ["opus"], COMPLEX: ["opus"], REASONING: [] }, + tier_model_configs: { + MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }], + COMPLEX: [{ model_name: "opus", litellm_params: { reasoning_effort: "high" } }], + }, + }; + const formValueWithParams = { + ...FORM_VALUE, + tiers: storedWithParams.tiers, + tier_model_params: { + MEDIUM: { opus: { reasoning_effort: "medium" } }, + COMPLEX: { opus: { reasoning_effort: "high" } }, + }, + }; + + it("round-trips hydrated params on an untouched save", () => { + const result = buildUpdatedComplexityRouterConfig(storedWithParams, formValueWithParams, undefined, hydratedState); + expect(result.tier_model_configs).toEqual(storedWithParams.tier_model_configs); + }); + + // tier_model_configs is managed now that this modal renders a control for it. Before that, the + // stale stored key was carried through, so clearing the last effort could never persist. + it("drops the stored key entirely when the operator unsets every effort", () => { + const result = buildUpdatedComplexityRouterConfig( + storedWithParams, + { ...formValueWithParams, tier_model_params: undefined }, + undefined, + hydratedState, + ); + expect(result).not.toHaveProperty("tier_model_configs"); + }); + + it("drops params for a model removed from its tier", () => { + const result = buildUpdatedComplexityRouterConfig( + storedWithParams, + { + ...formValueWithParams, + tiers: { ...storedWithParams.tiers, COMPLEX: ["gpt-4o-mini"] }, + }, + undefined, + hydratedState, + ); + expect(result.tier_model_configs).toEqual({ + MEDIUM: [{ model_name: "opus", litellm_params: { reasoning_effort: "medium" } }], + }); + }); + + it("emits no tier_model_configs for a config that never had params", () => { + const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, hydratedState); + expect(result).not.toHaveProperty("tier_model_configs"); + }); +}); 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 012624454d3..811ff69642a 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,7 +14,12 @@ 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 { normalizeTierModels, resolveComplexityDefaultModel } from "../add_model/complexity_router_tiers"; +import { + hydrateTierModelParams, + normalizeTierModels, + resolveComplexityDefaultModel, + serializeTierModelConfigs, +} from "../add_model/complexity_router_tiers"; import { isComplexityRouter } from "../add_model/auto_router_strategies"; import { getKeywordTierRulesError, @@ -66,6 +71,7 @@ interface EditAutoRouterModalProps { // actually renders a control that can set it. const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([ "tiers", + "tier_model_configs", "default_model", "plan_mode_min_tier", "tier_labels", @@ -149,9 +155,12 @@ export const buildUpdatedComplexityRouterConfig = ( const serializedTierLabels = serializeTierLabels(value.tier_labels); const scorerRuns = heuristicScoringRole(value) !== "never"; + const serializedTierModelConfigs = serializeTierModelConfigs(value.tiers, value.tier_model_params); + 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 }), @@ -342,6 +351,7 @@ const EditAutoRouterModal: React.FC = ({ const hydratedComplexityRouterConfig: ComplexityRouterConfigValue = { tiers: hydratedTiers, + tier_model_params: hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs), default_model: hydratePinnedDefaultModel( parsedConfig.default_model, modelData.litellm_params?.complexity_router_default_model, diff --git a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx index 9fbaa868998..4f4c901a1c2 100644 --- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx +++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx @@ -6,6 +6,7 @@ import { modelAvailableCall, modelHubCall } from "@/components/networking"; export interface ModelGroup { model_group: string; mode?: string; + supports_reasoning?: boolean; } interface AvailableModel { @@ -13,6 +14,7 @@ interface AvailableModel { model_name?: string | null; id?: string | null; mode?: string | null; + supports_reasoning?: boolean | null; } export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: string): Promise => { @@ -36,6 +38,7 @@ export const fetchAvailableModels = async (accessToken: string): Promise ({ model_group: item.model_group || item.id || item.model_name || "", mode: item.mode || undefined, + supports_reasoning: item.supports_reasoning === true || undefined, })) .filter((model: ModelGroup) => model.model_group !== "");