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; }