From 5cf2abfc7c1b6434068662b42a31a1d59a21accc Mon Sep 17 00:00:00 2001 From: yuneng Date: Tue, 22 Sep 2026 01:00:06 +0000 Subject: [PATCH] fix(ui): key router source map to the session and surface source load failures Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../Fallbacks/Fallbacks.test.tsx | 9 ++ .../RouterSettings/Fallbacks/Fallbacks.tsx | 21 +++-- .../components/router_settings/index.test.tsx | 19 ++++ .../src/components/router_settings/index.tsx | 90 +++++++++++-------- .../components/shared/ConfigOwnedField.tsx | 6 ++ 5 files changed, 103 insertions(+), 42 deletions(-) diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx index 4a3ed2ed5c6..b25a394d70b 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx @@ -417,6 +417,15 @@ describe("Fallbacks", () => { expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument(); }); + it("keeps fallbacks visible and explains why edits are unavailable when the source request fails", async () => { + vi.mocked(networkingModule.getRouterSettingsCall).mockRejectedValue(new Error("boom")); + renderWithQueryClient(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load router settings"); + expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); + expect(screen.queryByTestId("add-fallbacks-button")).not.toBeInTheDocument(); + }); + it("hides write actions until the source map has loaded", async () => { let resolveSources: (value: { fields: never[]; source: Record }) => void = () => {}; vi.mocked(networkingModule.getRouterSettingsCall).mockReturnValue( diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx index e2bc180d69c..86cd345adb8 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx @@ -8,7 +8,7 @@ import DeleteResourceModal from "../../../common_components/DeleteResourceModal" import { ProviderLogo } from "../../../molecules/models/ProviderLogo"; import { toast } from "@/lib/toast"; import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../../../networking"; -import { CONFIG_OWNED_MESSAGE, type FieldSourceMap, isConfigOwned } from "@/components/shared/ConfigOwnedField"; +import { CONFIG_OWNED_MESSAGE, isConfigOwned, type SourcesState } from "@/components/shared/ConfigOwnedField"; import { isProxyAdminRole } from "@/utils/roles"; import AddFallbacks from "./AddFallbacks"; import EditFallbacks from "./EditFallbacks"; @@ -122,7 +122,11 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str const Fallbacks: React.FC = ({ accessToken, userRole, userID }) => { const [routerSettings, setRouterSettings] = useState<{ [key: string]: any }>({}); - const [routerSources, setRouterSources] = useState(null); + const [sourcesState, setSourcesState] = useState(null); + const sessionKey = `${accessToken}:${userRole}:${userID}`; + const loadedSources = sourcesState?.sessionKey === sessionKey ? sourcesState : null; + const routerSources = loadedSources?.sources ?? null; + const sourcesFailed = loadedSources?.failed ?? false; const [isDeleting, setIsDeleting] = useState(false); const [fallbackToDelete, setFallbackToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -147,10 +151,10 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) } setRouterSettings(router_settings); }); - getRouterSettingsCall(accessToken).then((data) => { - setRouterSources(data.source ?? {}); - }); - }, [accessToken, userRole, userID]); + getRouterSettingsCall(accessToken) + .then((data) => setSourcesState({ sessionKey, sources: data.source ?? {}, failed: false })) + .catch(() => setSourcesState({ sessionKey, sources: null, failed: true })); + }, [accessToken, userRole, userID, sessionKey]); const handleDeleteClick = (fallbackEntry: FallbackEntry) => { setFallbackToDelete(fallbackEntry); @@ -260,6 +264,11 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) return ( + {sourcesFailed && ( +
+ Failed to load router settings. Reload the page to edit fallbacks +
+ )} {canModify && ( { expect(await screen.findByText("Set in config.yaml and cannot be changed here")).toBeInTheDocument(); }); + it("drops the previous source map while a new session is loading", async () => { + vi.mocked(getRouterSettingsCall).mockResolvedValueOnce({ ...mockRouterSettingsResponse, source: {} }); + const { rerender } = renderWithProviders(); + expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument(); + + vi.mocked(getRouterSettingsCall).mockReturnValue(new Promise(() => {})); + rerender(); + + expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument(); + }); + + it("shows an error instead of a blank page when the source request fails", async () => { + vi.mocked(getRouterSettingsCall).mockRejectedValue(new Error("boom")); + renderWithProviders(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load router settings"); + expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument(); + }); + it("holds the form until the source map has loaded so config owned fields never render editable", async () => { let resolveSources: ( value: typeof mockRouterSettingsResponse & { source: Record }, diff --git a/ui/litellm-dashboard/src/components/router_settings/index.tsx b/ui/litellm-dashboard/src/components/router_settings/index.tsx index 483e2726471..c611a7eed23 100644 --- a/ui/litellm-dashboard/src/components/router_settings/index.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/index.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import type { FieldSourceMap } from "@/components/shared/ConfigOwnedField"; +import type { SourcesState } from "@/components/shared/ConfigOwnedField"; import { toast } from "@/lib/toast"; import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../networking"; import RouterSettingsForm, { RouterSettingsFormValue } from "./RouterSettingsForm"; @@ -25,7 +25,11 @@ const RouterSettings: React.FC = ({ accessToken, userRole, const [availableRoutingStrategies, setAvailableRoutingStrategies] = useState([]); const [routerFieldsMetadata, setRouterFieldsMetadata] = useState<{ [key: string]: any }>({}); const [routingStrategyDescriptions, setRoutingStrategyDescriptions] = useState<{ [key: string]: string }>({}); - const [routerSources, setRouterSources] = useState(null); + const [sourcesState, setSourcesState] = useState(null); + const sessionKey = `${accessToken}:${userRole}:${userID}`; + const loadedSources = sourcesState?.sessionKey === sessionKey ? sourcesState : null; + const routerSources = loadedSources?.sources ?? null; + const sourcesFailed = loadedSources?.failed ?? false; useEffect(() => { if (!accessToken || !userRole || !userID) { @@ -44,43 +48,45 @@ const RouterSettings: React.FC = ({ accessToken, userRole, selectedStrategy: initialStrategy, })); }); - getRouterSettingsCall(accessToken).then((data) => { - setRouterSources(data.source ?? {}); - if (data.fields) { - // Build metadata map for easy lookup - const fieldsMap: { [key: string]: any } = {}; - data.fields.forEach((field: any) => { - fieldsMap[field.field_name] = { - ui_field_name: field.ui_field_name, - field_description: field.field_description, - options: field.options, - link: field.link, - }; - }); - setRouterFieldsMetadata(fieldsMap); + getRouterSettingsCall(accessToken) + .then((data) => { + setSourcesState({ sessionKey, sources: data.source ?? {}, failed: false }); + if (data.fields) { + // Build metadata map for easy lookup + const fieldsMap: { [key: string]: any } = {}; + data.fields.forEach((field: any) => { + fieldsMap[field.field_name] = { + ui_field_name: field.ui_field_name, + field_description: field.field_description, + options: field.options, + link: field.link, + }; + }); + setRouterFieldsMetadata(fieldsMap); - // Extract routing strategies from the routing_strategy field's options - const routingStrategyField = data.fields.find((field: any) => field.field_name === "routing_strategy"); - if (routingStrategyField?.options) { - setAvailableRoutingStrategies(routingStrategyField.options); - } + // Extract routing strategies from the routing_strategy field's options + const routingStrategyField = data.fields.find((field: any) => field.field_name === "routing_strategy"); + if (routingStrategyField?.options) { + setAvailableRoutingStrategies(routingStrategyField.options); + } - // Store routing strategy descriptions - if (data.routing_strategy_descriptions) { - setRoutingStrategyDescriptions(data.routing_strategy_descriptions); - } + // Store routing strategy descriptions + if (data.routing_strategy_descriptions) { + setRoutingStrategyDescriptions(data.routing_strategy_descriptions); + } - // Set enable_tag_filtering value - const tagFilteringField = data.fields.find((field: any) => field.field_name === "enable_tag_filtering"); - if (tagFilteringField?.field_value !== null && tagFilteringField?.field_value !== undefined) { - setFormValue((prev) => ({ - ...prev, - enableTagFiltering: tagFilteringField.field_value, - })); + // Set enable_tag_filtering value + const tagFilteringField = data.fields.find((field: any) => field.field_name === "enable_tag_filtering"); + if (tagFilteringField?.field_value !== null && tagFilteringField?.field_value !== undefined) { + setFormValue((prev) => ({ + ...prev, + enableTagFiltering: tagFilteringField.field_value, + })); + } } - } - }); - }, [accessToken, userRole, userID]); + }) + .catch(() => setSourcesState({ sessionKey, sources: null, failed: true })); + }, [accessToken, userRole, userID, sessionKey]); const handleSaveChanges = async () => { if (!accessToken) { @@ -177,7 +183,19 @@ const RouterSettings: React.FC = ({ accessToken, userRole, } }; - if (!accessToken || routerSources === null) { + if (!accessToken) { + return null; + } + + if (sourcesFailed) { + return ( +
+ Failed to load router settings. Reload the page to try again +
+ ); + } + + if (routerSources === null) { return null; } diff --git a/ui/litellm-dashboard/src/components/shared/ConfigOwnedField.tsx b/ui/litellm-dashboard/src/components/shared/ConfigOwnedField.tsx index 0879f459082..1ded0e5cd35 100644 --- a/ui/litellm-dashboard/src/components/shared/ConfigOwnedField.tsx +++ b/ui/litellm-dashboard/src/components/shared/ConfigOwnedField.tsx @@ -6,6 +6,12 @@ import type { components } from "@/lib/http/schema"; export type FieldSource = components["schemas"]["RouterSettingsResponse"]["source"][string]; export type FieldSourceMap = Partial>; +export interface SourcesState { + sessionKey: string; + sources: FieldSourceMap | null; + failed: boolean; +} + export const CONFIG_OWNED_MESSAGE = "Set in config.yaml and cannot be changed here"; export const isConfigOwned = (sources: FieldSourceMap | null | undefined, fieldName: string): boolean =>