diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 4ccabff18b9..f6b50ce20bc 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -794,19 +794,15 @@ const ComplexityRouterConfig: React.FC = ({ }, ] : []), - ...(value.classifier_type !== "llm_v2" - ? [ - { - key: "adaptive", - label: Advanced: Adaptive Routing, - children: ( - - - - ), - }, - ] - : []), + { + key: "adaptive", + label: Advanced: Adaptive Routing, + children: ( + + + + ), + }, { key: "affinity", label: Advanced: Affinity, @@ -906,15 +902,17 @@ const ComplexityRouterConfig: React.FC = ({ }, ] : []), - ].map(({ key, label, children }) => ( - - - - {label} - - {children} - - ))} + ] + .filter(({ key }) => !forecast || !["adaptive", "context-window", "escalation"].includes(key)) + .map(({ key, label, children }) => ( + + + + {label} + + {children} + + ))} diff --git a/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx index c249a63899a..4a574ac736d 100644 --- a/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx @@ -105,7 +105,7 @@ describe("forecast classifier form", () => { expect(output).toHaveTextContent('"REASONING":["capable"]'); expect(output).toHaveTextContent('"reasoning_effort":"low","speed":"fast","max_tokens":1024'); expect(output).toHaveTextContent('"reasoning_effort":"high"'); - expect(output).toHaveTextContent('"adaptive":true'); + expect(output).toHaveTextContent('"adaptive":false'); expect(output).not.toHaveTextContent("leftover-medium"); expect(output).not.toHaveTextContent("leftover-complex"); expect(output).not.toHaveTextContent('"plan_mode_min_tier"'); 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 b98636c9c5c..48903d585ff 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 @@ -213,17 +213,24 @@ describe("AddAutoRouterTab", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled(); await user.click(screen.getByRole("button", { name: "Advanced routing options" })); - if (capability) expect(screen.getByText("Advanced: Adaptive Routing")).toBeInTheDocument(); - else expect(screen.queryByText("Advanced: Adaptive Routing")).not.toBeInTheDocument(); + for (const label of ["Adaptive Routing", "Context Window Escalation", "Escalation Keywords"]) { + expect(screen.queryByText(`Advanced: ${label}`)).not.toBeInTheDocument(); + } + expect(screen.getByText("Advanced: Stalled Task Escalation")).toBeInTheDocument(); + expect(screen.getByText("Advanced: Response Format")).toBeInTheDocument(); expect(screen.queryByText("Advanced: Classification Method")).not.toBeInTheDocument(); expect(screen.getByText("Advanced: Affinity")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Add Auto Router" })); await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledTimes(1)); - expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject({ + const expected = { classifier_type: capability ? "capability" : "llm_v2", + adaptive: false, + enable_context_window_escalation: false, + escalation_keywords: [], tiers: { SIMPLE: ["efficient"], REASONING: ["capable"] }, classifier_llm_config: { model: "judge" }, - }); + }; + expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject(expected); }, ); @@ -239,6 +246,9 @@ describe("AddAutoRouterTab", () => { expect(screen.getByTestId("template-selector")).toBeInTheDocument(); expandDetailedConfiguration(); expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument(); + for (const label of ["Adaptive Routing", "Context Window Escalation", "Escalation Keywords"]) { + expect(screen.getByText(`Advanced: ${label}`)).toBeInTheDocument(); + } await user.click(screen.getByText("Advanced: Classification Method")); expect(screen.queryByRole("radio", { name: /^Capability/ })).not.toBeInTheDocument(); expect(screen.queryByRole("radio", { name: /^Fuse v2/ })).not.toBeInTheDocument(); 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 bc30b591ea9..6e6e7a3c6cd 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 @@ -48,6 +48,37 @@ const baseParams: BuildComplexityRouterConfigParams = { }; describe("buildComplexityRouterConfig", () => { + it.each(["capability", "llm_v2", "heuristic"] as const)( + "disables the removed overrides only for forecast creates: %s", + (classifierType) => { + const forecast = classifierType !== "heuristic"; + const params = { + ...baseParams, + classifierType, + adaptive: true, + enableContextWindowEscalation: true, + contextWindowEscalationBuffer: 0.9, + }; + const config = buildComplexityRouterConfig(params); + expect(config.adaptive).toBe(!forecast); + expect(config.enable_context_window_escalation).toBe(!forecast); + expect(config.escalation_keywords).toEqual(forecast ? [] : ["LITELLM ESCALATE"]); + for (const key of [ + "adaptive_weights", + "adaptive_eligible", + "tier_distance_penalty", + "context_window_escalation_buffer", + ]) { + expect(Object.hasOwn(config, key)).toBe(!forecast); + } + if (forecast) { + const untouched = buildComplexityRouterConfig({ ...baseParams, classifierType }); + expect(untouched.enable_context_window_escalation).toBe(false); + expect(untouched.escalation_keywords).toEqual([]); + } + }, + ); + it("carries Fast and reasoning overrides independently into a new router payload", () => { const params = { speed: "fast", reasoning_effort: "high", max_tokens: 1024 }; const config = buildComplexityRouterConfig({ 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 d16be537ce6..8a377c17ad7 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 @@ -628,13 +628,11 @@ export const buildComplexityRouterConfig = ({ // An edited tier set forces the LLM classifier, so llm-only inputs must survive a classifier_type // the form never rewrote. The UI gates the same controls on this, not on the raw value. const effectiveType: ClassifierType = customTierSet ? "llm" : classifierType; + const forecast = isForecastClassifier(effectiveType); - const supportsOpeningPrompt = - !customTierSet && !isForecastClassifier(effectiveType) && usesLlmClassifier(effectiveType); + const supportsOpeningPrompt = !customTierSet && !forecast && usesLlmClassifier(effectiveType); const payload: ComplexityRouterConfigPayload = { - tiers: isForecastClassifier(effectiveType) - ? Object.fromEntries(Object.entries(tiers).filter(([, models]) => models.length > 0)) - : tiers, + tiers: forecast ? Object.fromEntries(Object.entries(tiers).filter(([, models]) => models.length > 0)) : tiers, // The backend rejects the flag beside a custom tier set. ...(!customTierSet && enableNonReasoningTier && { enable_non_reasoning_tier: true }), ...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }), @@ -645,7 +643,8 @@ export const buildComplexityRouterConfig = ({ ...classifierWireFields(effectiveType, classifierInputs), ...(effectiveType === "capability" && capabilityClassifierConfig && { capability_classifier_config: capabilityClassifierConfig }), - ...(effectiveType === "llm_v2" && { llm_v2_config: llmV2Config, adaptive: false }), + ...(effectiveType === "llm_v2" && { llm_v2_config: llmV2Config }), + ...(forecast && { adaptive: false }), // A built-in router's opening instructions. Suppressed beside a legacy whole-prompt override, // which the backend rejects as a second override of the same prompt. ...(supportsOpeningPrompt && @@ -660,7 +659,7 @@ export const buildComplexityRouterConfig = ({ modality_pin_override: modalityPinOverride ?? false, ...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }), ...(cleanedKeywordTierRules.length > 0 && { keyword_tier_rules: cleanedKeywordTierRules }), - escalation_keywords: cleanedEscalationKeywords, + escalation_keywords: forecast ? [] : cleanedEscalationKeywords, // Only written when on: the backend rejects it alongside session_affinity, user_turn mode and // a custom tier set, so an off router must not carry the key into any of those saves. ...(stallEscalationEnabled && { @@ -676,19 +675,21 @@ export const buildComplexityRouterConfig = ({ match_threshold: matchThreshold, }), ...(adaptive && - effectiveType !== "llm_v2" && { + !forecast && { adaptive: true, adaptive_weights: adaptiveWeights, ...(adaptiveEligible === "all" && { tier_distance_penalty: tierDistancePenalty }), adaptive_eligible: adaptiveEligible, }), ...(returnRawModelName && { return_raw_model_name: true }), - ...(enableContextWindowEscalation !== undefined && { - enable_context_window_escalation: enableContextWindowEscalation, - }), - ...(contextWindowEscalationBuffer !== undefined && { - context_window_escalation_buffer: contextWindowEscalationBuffer, + // Omission enables the backend default, so hidden forecast controls need an explicit opt-out. + ...((forecast || enableContextWindowEscalation !== undefined) && { + enable_context_window_escalation: forecast ? false : enableContextWindowEscalation, }), + ...(!forecast && + contextWindowEscalationBuffer !== undefined && { + context_window_escalation_buffer: contextWindowEscalationBuffer, + }), ...(sessionAffinityTtlSeconds !== undefined && { session_affinity_ttl_seconds: sessionAffinityTtlSeconds, }), diff --git a/ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.test.ts b/ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.test.ts index d07d4a24cf0..218eebe8430 100644 --- a/ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.test.ts @@ -264,7 +264,7 @@ describe("forecast classifier configuration", () => { }); expect(saved.tiers).toEqual({ SIMPLE: ["efficient"], MEDIUM: ["middle"], REASONING: ["capable"] }); expect(saved.tier_model_configs).toEqual(stored.tier_model_configs); - expect(saved.adaptive).toBe(true); + expect(saved.adaptive).toBe(false); expect(saved.plan_mode_min_tier).toBe("MEDIUM"); }); 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 1aa6f0e3d35..4ae6efbb12d 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 @@ -46,6 +46,43 @@ const hydratedState: KeywordMatchingState = { }; describe("buildUpdatedComplexityRouterConfig keyword matching", () => { + it.each(["capability", "llm_v2", "heuristic"] as const)( + "handles enabled stored overrides when editing %s with or without keyword form state", + (classifier_type) => { + const stored = { + ...STORED, + classifier_type, + adaptive: classifier_type !== "llm_v2", + adaptive_weights: { quality: 0.6, cost: 0.4 }, + adaptive_eligible: "all", + tier_distance_penalty: 0.8, + enable_context_window_escalation: true, + context_window_escalation_buffer: 0.9, + }; + const value = hydrateComplexityRouterConfig(stored, undefined); + for (const keywordState of [undefined, hydratedState]) { + const saved = buildUpdatedComplexityRouterConfig(stored, value, undefined, keywordState); + const forecast = classifier_type !== "heuristic"; + expect(saved.adaptive).toBe(!forecast); + expect(saved.enable_context_window_escalation).toBe(!forecast); + expect(saved.escalation_keywords).toEqual(forecast ? [] : stored.escalation_keywords); + for (const key of [ + "adaptive_weights", + "adaptive_eligible", + "tier_distance_penalty", + "context_window_escalation_buffer", + ]) { + expect(Object.hasOwn(saved, key)).toBe(!forecast); + } + expect(saved.keyword_tier_rules).toEqual(STORED.keyword_tier_rules); + expect(saved.semantic_keyword_matching).toBe(true); + expect(saved.some_future_backend_key).toEqual(STORED.some_future_backend_key); + } + expect(value.enable_context_window_escalation).toBe(true); + expect(stored.escalation_keywords).toEqual(["urgent", "outage"]); + }, + ); + it("round-trips an untouched edit without changing any keyword-matching value", () => { // Opening the modal hydrates state from STORED; saving with nothing changed must be a // no-op. These keys are now MANAGED, so a hydration bug silently wipes them. 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 4a6c22689cc..e25c7f07dd7 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 @@ -3,6 +3,7 @@ import type { StoredComplexityRouterConfig } from "../add_model/build_complexity export type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config"; import { getForecastConfigError, + isForecastClassifier, capabilitySettingsSchema, fuseSettingsSchema, } from "../add_model/forecast_classifier_config"; @@ -71,6 +72,7 @@ import { } from "../add_model/heuristic_scoring_knobs"; import ComplexityRouterConfig, { ComplexityRouterConfigValue, + effectiveClassifierType, heuristicScoringRole, DEFAULT_ADAPTIVE_WEIGHTS, DEFAULT_SESSION_AFFINITY, @@ -305,6 +307,7 @@ export const buildUpdatedComplexityRouterConfig = ( ): Record => { const isManaged = (key: string): boolean => { if (MANAGED_COMPLEXITY_ROUTER_KEYS.has(key)) return true; + if (key === "escalation_keywords" && isForecastClassifier(effectiveClassifierType(value))) return true; if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true; return customTechnicalKeywords !== undefined && key === "custom_technical_keywords"; }; @@ -365,7 +368,7 @@ export const buildUpdatedComplexityRouterConfig = ( // Keys this call does not own stay as the stored config left them. const unowned: readonly string[] = [ - ...(keywordMatching === undefined ? KEYWORD_MATCHING_KEYS : []), + ...(keywordMatching === undefined ? [...KEYWORD_MATCHING_KEYS].filter((key) => !isManaged(key)) : []), ...(customTechnicalKeywords === undefined ? ["custom_technical_keywords"] : []), ]; return {