From 8d6c6867e6108ce4d8d680d66c0da2f7c7fa6384 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Tue, 22 Sep 2026 00:17:30 +0000
Subject: [PATCH 1/5] feat(ui): freeze config.yaml owned settings in the
dashboard
Router, fallback, alerting and UI settings controls now read the source
map returned by GET /router/settings, GET /alerting/settings and
GET /get/ui_settings and render a control as disabled with a tooltip
when its source is config. Every other source stays editable
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../UISettings/PageVisibilitySettings.tsx | 37 +++++++----
.../TeamAdminEditableFieldsSettings.tsx | 35 ++++++----
.../UISettings/UISettings.test.tsx | 64 +++++++++++++++++-
.../AdminSettings/UISettings/UISettings.tsx | 28 +++++++-
.../RouterSettings/Fallbacks/AddFallbacks.tsx | 14 ++--
.../Fallbacks/Fallbacks.test.tsx | 55 +++++++++++++++-
.../RouterSettings/Fallbacks/Fallbacks.tsx | 36 +++++++---
.../components/alerting/alerting_settings.tsx | 2 +
.../dynamic_form.integration.test.tsx | 57 ++++++++++++++++
.../src/components/alerting/dynamic_form.tsx | 66 ++++++++++++-------
.../LatencyBasedConfiguration.tsx | 20 ++++--
.../ReliabilityRetriesSection.tsx | 30 +++++----
.../router_settings/RouterSettingsForm.tsx | 16 ++++-
.../RoutingStrategySelector.tsx | 8 ++-
.../router_settings/TagFilteringToggle.tsx | 13 +++-
.../components/router_settings/index.test.tsx | 46 +++++++++++++
.../src/components/router_settings/index.tsx | 4 ++
.../components/shared/ConfigOwnedField.tsx | 32 +++++++++
18 files changed, 475 insertions(+), 88 deletions(-)
create mode 100644 ui/litellm-dashboard/src/components/shared/ConfigOwnedField.tsx
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx
index cf449f0e869..0730342c2d5 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx
@@ -4,6 +4,7 @@ import { ChevronDown } from "lucide-react";
import { useMemo, useState } from "react";
import { getAvailablePages } from "@/components/page_utils";
+import { ConfigOwnedField } from "@/components/shared/ConfigOwnedField";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -13,6 +14,7 @@ interface PageVisibilitySettingsProps {
enabledPagesInternalUsers: string[] | null | undefined;
enabledPagesPropertyDescription?: string;
isUpdating: boolean;
+ configOwned?: boolean;
onUpdate: (settings: { enabled_ui_pages_internal_users: string[] | null }) => void;
}
@@ -20,6 +22,7 @@ export default function PageVisibilitySettings({
enabledPagesInternalUsers,
enabledPagesPropertyDescription,
isUpdating,
+ configOwned = false,
onUpdate,
}: PageVisibilitySettingsProps) {
const isPageVisibilitySet = enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined;
@@ -93,11 +96,14 @@ export default function PageVisibilitySettings({
const checkboxId = `page-visibility-${page.page}`;
return (
-
+
-
+
);
};
diff --git a/ui/litellm-dashboard/src/components/router_settings/TagFilteringToggle.tsx b/ui/litellm-dashboard/src/components/router_settings/TagFilteringToggle.tsx
index d0395979e50..39d452685b1 100644
--- a/ui/litellm-dashboard/src/components/router_settings/TagFilteringToggle.tsx
+++ b/ui/litellm-dashboard/src/components/router_settings/TagFilteringToggle.tsx
@@ -1,13 +1,20 @@
import React, { useId } from "react";
+import { ConfigOwnedField } from "@/components/shared/ConfigOwnedField";
import { Switch } from "@/components/ui/switch";
interface TagFilteringToggleProps {
enabled: boolean;
routerFieldsMetadata: { [key: string]: any };
+ disabled?: boolean;
onToggle: (enabled: boolean) => void;
}
-const TagFilteringToggle: React.FC = ({ enabled, routerFieldsMetadata, onToggle }) => {
+const TagFilteringToggle: React.FC = ({
+ enabled,
+ routerFieldsMetadata,
+ disabled = false,
+ onToggle,
+}) => {
const toggleId = useId();
return (
@@ -34,7 +41,9 @@ const TagFilteringToggle: React.FC = ({ enabled, router
)}
-
+
+
+
);
diff --git a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
index 1875085231a..e1c864a0247 100644
--- a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
+++ b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
@@ -183,4 +183,50 @@ describe("RouterSettings", () => {
});
expect(toast.success).not.toHaveBeenCalled();
});
+
+ describe("config.yaml owned fields", () => {
+ it("freezes routing strategy, tag filtering and reliability inputs whose source is config", async () => {
+ const user = userEvent.setup();
+ vi.mocked(getRouterSettingsCall).mockResolvedValue({
+ ...mockRouterSettingsResponse,
+ source: { routing_strategy: "config", enable_tag_filtering: "config", num_retries: "config", timeout: "db" },
+ });
+ renderWithProviders();
+
+ const strategySelect = await findStrategySelect();
+ expect(strategySelect).toHaveAttribute("data-disabled");
+ expect(screen.getByRole("switch")).toHaveAttribute("data-disabled");
+ expect(await screen.findByRole("textbox", { name: /num_retries/i })).toBeDisabled();
+ expect(screen.getByRole("textbox", { name: /timeout/i })).toBeEnabled();
+
+ await user.hover(strategySelect);
+ expect(await screen.findByText("Set in config.yaml and cannot be changed here")).toBeInTheDocument();
+ });
+
+ it.each(["env", "default", "db"])("keeps fields editable when source is %s", async (source) => {
+ const user = userEvent.setup();
+ vi.mocked(getRouterSettingsCall).mockResolvedValue({
+ ...mockRouterSettingsResponse,
+ source: { routing_strategy: source, enable_tag_filtering: source, num_retries: source },
+ });
+ renderWithProviders();
+
+ const strategySelect = await findStrategySelect();
+ expect(strategySelect).not.toHaveAttribute("data-disabled");
+ expect(screen.getByRole("switch")).not.toHaveAttribute("data-disabled");
+ const numRetries = await screen.findByRole("textbox", { name: /num_retries/i });
+ expect(numRetries).toBeEnabled();
+ expect(screen.queryByText("Set in config.yaml and cannot be changed here")).not.toBeInTheDocument();
+
+ fireEvent.change(numRetries, { target: { value: "7" } });
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() =>
+ expect(setCallbacksCall).toHaveBeenCalledWith(
+ "test-token",
+ expect.objectContaining({ router_settings: expect.objectContaining({ num_retries: 7 }) }),
+ ),
+ );
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/components/router_settings/index.tsx b/ui/litellm-dashboard/src/components/router_settings/index.tsx
index 53d35b81cec..4bd09bade9e 100644
--- a/ui/litellm-dashboard/src/components/router_settings/index.tsx
+++ b/ui/litellm-dashboard/src/components/router_settings/index.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
+import type { FieldSourceMap } from "@/components/shared/ConfigOwnedField";
import { toast } from "@/lib/toast";
import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../networking";
import RouterSettingsForm, { RouterSettingsFormValue } from "./RouterSettingsForm";
@@ -24,6 +25,7 @@ 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({});
useEffect(() => {
if (!accessToken || !userRole || !userID) {
@@ -43,6 +45,7 @@ const RouterSettings: React.FC = ({ accessToken, userRole,
}));
});
getRouterSettingsCall(accessToken).then((data) => {
+ setRouterSources(data.source ?? {});
if (data.fields) {
// Build metadata map for easy lookup
const fieldsMap: { [key: string]: any } = {};
@@ -184,6 +187,7 @@ const RouterSettings: React.FC = ({ accessToken, userRole,
value={formValue}
onChange={setFormValue}
routerFieldsMetadata={routerFieldsMetadata}
+ routerSources={routerSources}
availableRoutingStrategies={availableRoutingStrategies}
routingStrategyDescriptions={routingStrategyDescriptions}
/>
diff --git a/ui/litellm-dashboard/src/components/shared/ConfigOwnedField.tsx b/ui/litellm-dashboard/src/components/shared/ConfigOwnedField.tsx
new file mode 100644
index 00000000000..0879f459082
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/ConfigOwnedField.tsx
@@ -0,0 +1,32 @@
+import React from "react";
+
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import type { components } from "@/lib/http/schema";
+
+export type FieldSource = components["schemas"]["RouterSettingsResponse"]["source"][string];
+export type FieldSourceMap = Partial>;
+
+export const CONFIG_OWNED_MESSAGE = "Set in config.yaml and cannot be changed here";
+
+export const isConfigOwned = (sources: FieldSourceMap | null | undefined, fieldName: string): boolean =>
+ sources?.[fieldName] === "config";
+
+interface ConfigOwnedFieldProps {
+ frozen: boolean;
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function ConfigOwnedField({ frozen, children, className = "inline-flex w-full" }: ConfigOwnedFieldProps) {
+ if (!frozen) {
+ return {children};
+ }
+ return (
+
+
+ }>{children}
+ {CONFIG_OWNED_MESSAGE}
+
+
+ );
+}
From fbf0efa3ca0c2beab6e8eb9d1efecac61f0cbb09 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Tue, 22 Sep 2026 00:30:44 +0000
Subject: [PATCH 2/5] test(ui): use fireEvent.change in config owned alerting
tests
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../components/alerting/dynamic_form.integration.test.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/alerting/dynamic_form.integration.test.tsx b/ui/litellm-dashboard/src/components/alerting/dynamic_form.integration.test.tsx
index 59f8e58e598..9dbb7f00872 100644
--- a/ui/litellm-dashboard/src/components/alerting/dynamic_form.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/alerting/dynamic_form.integration.test.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import DynamicForm from "./dynamic_form";
@@ -274,7 +274,7 @@ describe("DynamicForm config.yaml owned fields", () => {
const toggle = screen.getByRole("switch");
expect(toggle).not.toHaveAttribute("data-disabled");
- await user.type(screen.getByDisplayValue("us-east"), "Z");
+ fireEvent.change(screen.getByDisplayValue("us-east"), { target: { value: "us-eastZ" } });
await user.click(toggle);
await submit(user);
@@ -293,7 +293,7 @@ describe("DynamicForm config.yaml owned fields", () => {
expect(screen.getByDisplayValue("us-east")).toBeDisabled();
expect(screen.getByDisplayValue("12")).toBeEnabled();
- await user.type(screen.getByDisplayValue("12"), "7");
+ fireEvent.change(screen.getByDisplayValue("12"), { target: { value: "127" } });
await submit(user);
expect(handleSubmit).toHaveBeenCalledWith({ daily_report_frequency: "127" });
From d943d556c4c60ddccbc12ee62bef66910b5e37c4 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Tue, 22 Sep 2026 00:42:52 +0000
Subject: [PATCH 3/5] fix(ui): hold router and fallback write controls until
the source map loads
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../Fallbacks/Fallbacks.test.tsx | 19 +++++++++++++++
.../RouterSettings/Fallbacks/Fallbacks.tsx | 4 ++--
.../components/router_settings/index.test.tsx | 23 +++++++++++++++++--
.../src/components/router_settings/index.tsx | 4 ++--
4 files changed, 44 insertions(+), 6 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 dd5b727bef7..4a3ed2ed5c6 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,25 @@ describe("Fallbacks", () => {
expect(screen.queryByTestId("delete-modal")).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(
+ new Promise((resolve) => {
+ resolveSources = resolve;
+ }),
+ );
+ renderWithQueryClient();
+
+ await waitFor(() => {
+ expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
+ });
+ expect(screen.queryByTestId("add-fallbacks-button")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("edit-fallback-button")).not.toBeInTheDocument();
+
+ resolveSources({ fields: [], source: { fallbacks: "config" } });
+ expect(await screen.findByTestId("add-fallbacks-button")).toBeDisabled();
+ });
+
it.each(["env", "default", "db"])("keeps fallbacks editable when source is %s", async (source) => {
const user = userEvent.setup();
vi.mocked(networkingModule.getRouterSettingsCall).mockResolvedValue({
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 d94f07d90b9..e2bc180d69c 100644
--- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx
@@ -122,7 +122,7 @@ 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({});
+ const [routerSources, setRouterSources] = useState(null);
const [isDeleting, setIsDeleting] = useState(false);
const [fallbackToDelete, setFallbackToDelete] = useState(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -254,7 +254,7 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID })
const hasFallbacks = Array.isArray(routerSettings.fallbacks) && routerSettings.fallbacks.length > 0;
// Admin Viewer follows the read-parity rule: see fallbacks, no writes.
- const canModify = isProxyAdminRole(userRole ?? "");
+ const canModify = isProxyAdminRole(userRole ?? "") && routerSources !== null;
const fallbacksFrozen = isConfigOwned(routerSources, "fallbacks");
const frozenActionClass = `${iconWrapperClass} cursor-not-allowed opacity-50`;
diff --git a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
index e1c864a0247..d601fa00fca 100644
--- a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
+++ b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
@@ -67,9 +67,9 @@ describe("RouterSettings", () => {
expect(container).toBeEmptyDOMElement();
});
- it("should render the Save Changes and Reset buttons when authenticated", () => {
+ it("should render the Save Changes and Reset buttons when authenticated", async () => {
renderWithProviders();
- expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
+ expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /reset/i })).toBeInTheDocument();
});
@@ -203,6 +203,25 @@ describe("RouterSettings", () => {
expect(await screen.findByText("Set in config.yaml and cannot be changed here")).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 },
+ ) => void = () => {};
+ vi.mocked(getRouterSettingsCall).mockReturnValue(
+ new Promise((resolve) => {
+ resolveSources = resolve;
+ }),
+ );
+ renderWithProviders();
+
+ await waitFor(() => expect(getCallbacksCall).toHaveBeenCalled());
+ expect(screen.queryByRole("textbox", { name: /num_retries/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument();
+
+ resolveSources({ ...mockRouterSettingsResponse, source: { num_retries: "config" } });
+ expect(await screen.findByRole("textbox", { name: /num_retries/i })).toBeDisabled();
+ });
+
it.each(["env", "default", "db"])("keeps fields editable when source is %s", async (source) => {
const user = userEvent.setup();
vi.mocked(getRouterSettingsCall).mockResolvedValue({
diff --git a/ui/litellm-dashboard/src/components/router_settings/index.tsx b/ui/litellm-dashboard/src/components/router_settings/index.tsx
index 4bd09bade9e..483e2726471 100644
--- a/ui/litellm-dashboard/src/components/router_settings/index.tsx
+++ b/ui/litellm-dashboard/src/components/router_settings/index.tsx
@@ -25,7 +25,7 @@ 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({});
+ const [routerSources, setRouterSources] = useState(null);
useEffect(() => {
if (!accessToken || !userRole || !userID) {
@@ -177,7 +177,7 @@ const RouterSettings: React.FC = ({ accessToken, userRole,
}
};
- if (!accessToken) {
+ if (!accessToken || routerSources === null) {
return null;
}
From 5cf2abfc7c1b6434068662b42a31a1d59a21accc Mon Sep 17 00:00:00 2001
From: yuneng
Date: Tue, 22 Sep 2026 01:00:06 +0000
Subject: [PATCH 4/5] 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 =>
From 7a258aa2318a7e1a179d67675baf43e7eb4a02ab Mon Sep 17 00:00:00 2001
From: yuneng
Date: Tue, 22 Sep 2026 01:10:01 +0000
Subject: [PATCH 5/5] fix(ui): ignore router source responses from a superseded
session
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../RouterSettings/Fallbacks/Fallbacks.tsx | 12 ++++++++--
.../components/router_settings/index.test.tsx | 22 +++++++++++++++----
.../src/components/router_settings/index.tsx | 9 +++++++-
3 files changed, 36 insertions(+), 7 deletions(-)
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 86cd345adb8..b82f19b416d 100644
--- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx
@@ -151,9 +151,17 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID })
}
setRouterSettings(router_settings);
});
+ let cancelled = false;
getRouterSettingsCall(accessToken)
- .then((data) => setSourcesState({ sessionKey, sources: data.source ?? {}, failed: false }))
- .catch(() => setSourcesState({ sessionKey, sources: null, failed: true }));
+ .then((data) => {
+ if (!cancelled) setSourcesState({ sessionKey, sources: data.source ?? {}, failed: false });
+ })
+ .catch(() => {
+ if (!cancelled) setSourcesState({ sessionKey, sources: null, failed: true });
+ });
+ return () => {
+ cancelled = true;
+ };
}, [accessToken, userRole, userID, sessionKey]);
const handleDeleteClick = (fallbackEntry: FallbackEntry) => {
diff --git a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
index 01f459d68e9..47ff3c2d51c 100644
--- a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
+++ b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { fireEvent, renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
+import { act, fireEvent, renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import RouterSettings from "./index";
@@ -208,10 +208,24 @@ describe("RouterSettings", () => {
const { rerender } = renderWithProviders();
expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument();
- vi.mocked(getRouterSettingsCall).mockReturnValue(new Promise(() => {}));
- rerender();
-
+ let resolveStale: (
+ value: typeof mockRouterSettingsResponse & { source: Record },
+ ) => void = () => {};
+ vi.mocked(getRouterSettingsCall).mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveStale = resolve;
+ }),
+ );
+ rerender();
expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument();
+
+ vi.mocked(getRouterSettingsCall).mockResolvedValueOnce({ ...mockRouterSettingsResponse, source: {} });
+ rerender();
+ expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument();
+
+ resolveStale({ ...mockRouterSettingsResponse, source: {} });
+ await act(async () => {});
+ expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
});
it("shows an error instead of a blank page when the source request fails", async () => {
diff --git a/ui/litellm-dashboard/src/components/router_settings/index.tsx b/ui/litellm-dashboard/src/components/router_settings/index.tsx
index c611a7eed23..9ed4bc8c747 100644
--- a/ui/litellm-dashboard/src/components/router_settings/index.tsx
+++ b/ui/litellm-dashboard/src/components/router_settings/index.tsx
@@ -48,8 +48,10 @@ const RouterSettings: React.FC = ({ accessToken, userRole,
selectedStrategy: initialStrategy,
}));
});
+ let cancelled = false;
getRouterSettingsCall(accessToken)
.then((data) => {
+ if (cancelled) return;
setSourcesState({ sessionKey, sources: data.source ?? {}, failed: false });
if (data.fields) {
// Build metadata map for easy lookup
@@ -85,7 +87,12 @@ const RouterSettings: React.FC = ({ accessToken, userRole,
}
}
})
- .catch(() => setSourcesState({ sessionKey, sources: null, failed: true }));
+ .catch(() => {
+ if (!cancelled) setSourcesState({ sessionKey, sources: null, failed: true });
+ });
+ return () => {
+ cancelled = true;
+ };
}, [accessToken, userRole, userID, sessionKey]);
const handleSaveChanges = async () => {