From 04b1b077de07373613f3905f98f100f0b86aee04 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:54:54 -0700 Subject: [PATCH] fix(ui): keep untouched stored auto-router booleans and reminder marker casing on save (#42703) Co-authored-by: yuneng Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...d_updated_complexity_router_config.test.ts | 102 ++++++++++++++++++ .../edit_auto_router_modal.tsx | 18 +++- 2 files changed, 119 insertions(+), 1 deletion(-) 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 162414b7814..4b8e298191d 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 @@ -1115,3 +1115,105 @@ describe("LLM V2 configuration preservation", () => { expect(saved).not.toHaveProperty("classifier_llm_config"); }); }); + +describe("untouched save round trip", () => { + const STORED_PRE_MANAGED_BOOLEANS: Record = { + tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: ["gpt-4o"], COMPLEX: ["opus"], REASONING: ["o1"] }, + tier_model_configs: { REASONING: [{ model_name: "o1", litellm_params: { reasoning_effort: "high" } }] }, + default_model: "gpt-4o", + plan_mode_min_tier: "COMPLEX", + tier_labels: { SIMPLE: "Cheap" }, + classifier_type: "heuristic_first", + heuristic_v2_success_threshold: 0.89, + heuristic_first_max_tier: "SIMPLE", + classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000, reasoning_effort: "low" }, + classifier_context_window_size: 5, + classifier_context_budget_chars: 4000, + classifier_context_include_assistant_turns: true, + classifier_fallback: "default_model", + classification_prompt: "Route for a payments team.", + classification_examples: "- refund status -> SIMPLE", + classification_mode: "user_turn", + session_affinity: true, + session_affinity_ttl_seconds: 300, + modality_routing: true, + modality_pin_override: true, + deployment_affinity: false, + adaptive: true, + adaptive_weights: { quality: 0.4, cost: 0.6 }, + tier_distance_penalty: 0.25, + adaptive_eligible: "all", + return_raw_model_name: true, + tier_boundaries: { simple_medium: 0.2, medium_complex: 0.4, complex_reasoning: 0.7 }, + token_thresholds: { simple: 20, complex: 500 }, + dimension_weights: { tokenCount: 0.1 }, + custom_dimensions: [{ name: "domain", weight: 0.9, keywords: ["orbitmesh"] }], + reasoning_override_min_score: 0.3, + enable_context_window_escalation: false, + context_window_escalation_buffer: 0.9, + code_keywords: ["async", "await"], + reasoning_keywords: ["prove"], + technical_keywords: ["api"], + simple_keywords: ["hello"], + plan_mode_patterns: ["plan now"], + route_housekeeping_to_cheapest_tier: true, + housekeeping_patterns: ["conversation title"], + reminder_markers: [{ open: "", close: "" }], + max_tokens_from_tier_model: true, + }; + + it("returns the stored config unchanged when nothing was edited", () => { + const hydrated = hydrateComplexityRouterConfig(STORED_PRE_MANAGED_BOOLEANS, undefined); + expect(buildUpdatedComplexityRouterConfig(STORED_PRE_MANAGED_BOOLEANS, hydrated)).toEqual( + STORED_PRE_MANAGED_BOOLEANS, + ); + }); + + it("keeps both housekeeping and max-token booleans stored as true", () => { + const hydrated = hydrateComplexityRouterConfig(STORED_PRE_MANAGED_BOOLEANS, undefined); + const saved = buildUpdatedComplexityRouterConfig(STORED_PRE_MANAGED_BOOLEANS, hydrated); + expect(saved.route_housekeeping_to_cheapest_tier).toBe(true); + expect(saved.max_tokens_from_tier_model).toBe(true); + }); + + it("keeps the stored reminder marker casing", () => { + const hydrated = hydrateComplexityRouterConfig(STORED_PRE_MANAGED_BOOLEANS, undefined); + const saved = buildUpdatedComplexityRouterConfig(STORED_PRE_MANAGED_BOOLEANS, hydrated); + expect(saved.reminder_markers).toEqual(STORED_PRE_MANAGED_BOOLEANS.reminder_markers); + }); + + it("lets an edited toggle win over the stored value", () => { + const hydrated = hydrateComplexityRouterConfig(STORED_PRE_MANAGED_BOOLEANS, undefined); + const edited = { + ...hydrated, + route_housekeeping_to_cheapest_tier: false, + max_tokens_from_tier_model: false, + }; + const saved = buildUpdatedComplexityRouterConfig(STORED_PRE_MANAGED_BOOLEANS, edited); + expect(saved.route_housekeeping_to_cheapest_tier).toBe(false); + expect(saved.max_tokens_from_tier_model).toBe(false); + + const storedDisabled: Record = { + ...STORED_PRE_MANAGED_BOOLEANS, + route_housekeeping_to_cheapest_tier: false, + max_tokens_from_tier_model: false, + }; + const enabled = { + ...hydrateComplexityRouterConfig(storedDisabled, undefined), + route_housekeeping_to_cheapest_tier: true, + max_tokens_from_tier_model: true, + }; + const resaved = buildUpdatedComplexityRouterConfig(storedDisabled, enabled); + expect(resaved).not.toHaveProperty("route_housekeeping_to_cheapest_tier"); + expect(resaved).not.toHaveProperty("max_tokens_from_tier_model"); + }); + + it("lowercases reminder markers the user edited", () => { + const hydrated = hydrateComplexityRouterConfig(STORED_PRE_MANAGED_BOOLEANS, undefined); + const saved = buildUpdatedComplexityRouterConfig(STORED_PRE_MANAGED_BOOLEANS, { + ...hydrated, + reminder_markers: [{ open: "", close: "" }], + }); + expect(saved.reminder_markers).toEqual([{ open: "", close: "" }]); + }); +}); 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 0429e7d4ad1..2fd4fba2b31 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 @@ -33,6 +33,7 @@ import { import { isComplexityRouter } from "../add_model/auto_router_strategies"; import { type BuildComplexityRouterConfigParams, + type StoredComplexityRouterConfig, buildComplexityRouterConfig, getClassifierModelError, getHeuristicV2SuccessThresholdError, @@ -152,6 +153,12 @@ const KEYWORD_MATCHING_KEYS = new Set([ "match_threshold", ]); +const UNEDITED_STORED_VALUE_KEYS: readonly (keyof ComplexityRouterConfigValue)[] = [ + "route_housekeeping_to_cheapest_tier", + "reminder_markers", + "max_tokens_from_tier_model", +]; + const toRecord = (value: unknown): Record => { const parsed: unknown = typeof value === "string" ? JSON.parse(value) : value; return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) @@ -179,7 +186,15 @@ export const buildUpdatedComplexityRouterConfig = ( customTechnicalKeywords?: string[], keywordMatching?: KeywordMatchingState, ): Record => { + const stored = toRecord(storedConfig); + const hydratedFromStored = hydrateComplexityRouterConfig(stored as StoredComplexityRouterConfig, undefined); + const unedited = new Set( + UNEDITED_STORED_VALUE_KEYS.filter( + (key) => key in stored && JSON.stringify(value[key]) === JSON.stringify(hydratedFromStored[key]), + ), + ); const isManaged = (key: string): boolean => { + if (unedited.has(key)) return false; if (key === "classifier_context_per_turn_chars") { return !usesClassifierContext(effectiveClassifierType(value)) || Object.prototype.hasOwnProperty.call(value, key); } @@ -190,7 +205,7 @@ export const buildUpdatedComplexityRouterConfig = ( }; const dropped = customTierDroppedKeys(value); const preservedConfig = Object.fromEntries( - Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key) && !dropped.includes(key)), + Object.entries(stored).filter(([key]) => !isManaged(key) && !dropped.includes(key)), ); const builderParams: BuildComplexityRouterConfigParams = { @@ -208,6 +223,7 @@ export const buildUpdatedComplexityRouterConfig = ( const unowned: readonly string[] = [ ...(keywordMatching === undefined ? [...KEYWORD_MATCHING_KEYS].filter((key) => !isManaged(key)) : []), ...(customTechnicalKeywords === undefined ? ["custom_technical_keywords"] : []), + ...unedited, ]; return { ...preservedConfig,