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..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";
@@ -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();
});
@@ -183,4 +183,102 @@ 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("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();
+
+ 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 () => {
+ 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 },
+ ) => 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({
+ ...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..9ed4bc8c747 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 { SourcesState } from "@/components/shared/ConfigOwnedField";
import { toast } from "@/lib/toast";
import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../networking";
import RouterSettingsForm, { RouterSettingsFormValue } from "./RouterSettingsForm";
@@ -24,6 +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 [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) {
@@ -42,42 +48,52 @@ const RouterSettings: React.FC = ({ accessToken, userRole,
selectedStrategy: initialStrategy,
}));
});
- getRouterSettingsCall(accessToken).then((data) => {
- 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);
+ 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
+ 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(() => {
+ if (!cancelled) setSourcesState({ sessionKey, sources: null, failed: true });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [accessToken, userRole, userID, sessionKey]);
const handleSaveChanges = async () => {
if (!accessToken) {
@@ -178,12 +194,25 @@ const RouterSettings: React.FC = ({ accessToken, userRole,
return null;
}
+ if (sourcesFailed) {
+ return (
+
+ Failed to load router settings. Reload the page to try again
+
+ );
+ }
+
+ if (routerSources === null) {
+ return null;
+ }
+
return (
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..1ded0e5cd35
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/ConfigOwnedField.tsx
@@ -0,0 +1,38 @@
+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 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 =>
+ 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}
+
+
+ );
+}