From 5c6d2a8627c6a2ceb1888e4d0eaf0d6c8e858464 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 27 Aug 2026 08:40:43 -0700 Subject: [PATCH] fix(ui): block an auto-router save on any dry-run rejection, message or not The gate read the verdict's error as well as its valid flag, so a rejection that carried no message fell through to the write and came back as the raw 400 the dry-run exists to replace. valid is derived from error server side today, which is why nothing had hit it, but the two arrive as independent fields and the UI should not depend on that derivation. Both forms now call one dryRunRejection, which gates on valid alone and falls back to its own message when the backend sent none. A transport failure still fails open as valid, leaving the write gate authoritative. Reported by Bugbot. --- .../add_model/add_auto_router_tab.tsx | 6 +++-- .../build_complexity_router_config.test.ts | 22 +++++++++++++++++++ .../build_complexity_router_config.ts | 11 ++++++++++ .../edit_auto_router_modal.tsx | 6 +++-- 4 files changed, 41 insertions(+), 4 deletions(-) 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 96894b079c4..0d26b983ba4 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 @@ -41,6 +41,7 @@ import { getPlanModeTierError, getSemanticConfigError, getTierLabelsError, + dryRunRejection, } from "./build_complexity_router_config"; import { activeTierName, activeTierRows, getCustomTierRowsError, resolveComplexityDefaultModel } from "./tier_rows"; import { tierRowLabel } from "./complexity_router_tiers"; @@ -405,9 +406,10 @@ const AddAutoRouterTab: React.FC = ({ complexityRouterConfigPayload as unknown as Record, requiresTeamScope ? form.getValues("team_id") : undefined, ); - if (!serverVerdict.valid && serverVerdict.error) { + const dryRunError = dryRunRejection(serverVerdict); + if (dryRunError) { setShowValidationErrors(true); - toast.fromError(serverVerdict.error); + toast.fromError(dryRunError); return; } 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 54443d40427..c3c2be0be50 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 @@ -10,6 +10,7 @@ import { getTierLabelsError, hydrateTierLabels, BuildComplexityRouterConfigParams, + dryRunRejection, } from "./build_complexity_router_config"; import { CUSTOM_TIER_RESTRICTIONS, activeTierRows } from "./tier_rows"; @@ -958,3 +959,24 @@ describe("hydrateCustomTierSet", () => { expect(hydrated?.fallback_tier_id).toBe(hydrated?.tiers[0].id); }); }); + +describe("dryRunRejection", () => { + it("blocks the save on a rejection whose message is missing, which the write would return as a raw 400", () => { + // The verdict's two fields arrive independently, so a rejection carrying no message must still + // stop the save rather than fall through to the write endpoint. + expect(dryRunRejection({ valid: false })).toBe("The proxy rejected this auto-router configuration"); + expect(dryRunRejection({ valid: false, error: null })).toBe("The proxy rejected this auto-router configuration"); + expect(dryRunRejection({ valid: false, error: " " })).toBe("The proxy rejected this auto-router configuration"); + }); + + it("surfaces the backend's own message when it sent one", () => { + expect(dryRunRejection({ valid: false, error: "session_affinity cannot be combined with tier_definitions" })).toBe( + "session_affinity cannot be combined with tier_definitions", + ); + }); + + it("lets a valid verdict through, including the fail-open one a transport failure returns", () => { + expect(dryRunRejection({ valid: true })).toBeNull(); + expect(dryRunRejection({ valid: true, error: null })).toBeNull(); + }); +}); 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 04e6e16f81e..24a588d4931 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 @@ -124,6 +124,17 @@ export interface BuildComplexityRouterConfigParams { tierModelParams?: TierModelParamsByTier; } +/** + * The message to surface when the dry-run rejects a save, or null to let it through. + * + * Gated on `valid` alone. The verdict's `valid` is derived from `error` server side today, but the + * two arrive as independent fields, so reading `error` as the gate would let a rejection whose + * message is missing or blank through to the write and back as a raw 400. A transport failure fails + * open as `{valid: true}`, which this passes, leaving the write gate authoritative. + */ +export const dryRunRejection = (verdict: { valid: boolean; error?: string | null }): string | null => + verdict.valid ? null : verdict.error?.trim() || "The proxy rejected this auto-router configuration"; + export interface TierDefinitionPayload { name: string; description?: string; 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 354d85a3cc9..fe0f6f4baba 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 @@ -36,6 +36,7 @@ import { hydrateCustomTierSet, hydratePlanModeMinTier, hydrateTierLabels, + dryRunRejection, } from "../add_model/build_complexity_router_config"; import { KeywordTierRule } from "../add_model/KeywordTierRules"; import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching"; @@ -562,9 +563,10 @@ const EditAutoRouterModal: React.FC = ({ { keywordTierRules, escalationKeywords, semanticMatchingEnabled, embeddingModel, matchThreshold }, ); const serverVerdict = await validateAutoRouterConfig(accessToken, updatedConfig, modelData?.model_info?.team_id); - if (!serverVerdict.valid && serverVerdict.error) { + const dryRunError = dryRunRejection(serverVerdict); + if (dryRunError) { setShowValidationErrors(true); - toast.fromError(serverVerdict.error); + toast.fromError(dryRunError); return; }