From 460f72f055f1d2939626143797df3b2a971f8584 Mon Sep 17 00:00:00 2001 From: yassin Date: Sat, 10 Oct 2026 20:44:04 +0000 Subject: [PATCH] fix(ui): gray out router settings and fallbacks that config.yaml owns Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../RouterSettings/Fallbacks/AddFallbacks.tsx | 24 ++++-- .../Fallbacks/Fallbacks.test.tsx | 67 ++++++++++++++- .../RouterSettings/Fallbacks/Fallbacks.tsx | 56 +++++++++--- .../router_settings/ConfigOwned.tsx | 28 ++++++ .../LatencyBasedConfiguration.tsx | 14 ++- .../ReliabilityRetriesSection.tsx | 6 ++ ...> RouterSettingsForm.integration.test.tsx} | 32 +++++++ .../router_settings/RouterSettingsForm.tsx | 16 +++- .../RoutingStrategySelector.tsx | 7 +- .../router_settings/TagFilteringToggle.tsx | 19 +++- ...ex.test.tsx => index.integration.test.tsx} | 35 ++++++++ .../src/components/router_settings/index.tsx | 86 +++++++++++-------- 12 files changed, 328 insertions(+), 62 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/router_settings/ConfigOwned.tsx rename ui/litellm-dashboard/src/components/router_settings/{RouterSettingsForm.test.tsx => RouterSettingsForm.integration.test.tsx} (73%) rename ui/litellm-dashboard/src/components/router_settings/{index.test.tsx => index.integration.test.tsx} (79%) diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx index aca47e6b5c3..6ad7624ec60 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { toast } from "@/lib/toast"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; @@ -20,9 +21,10 @@ interface AddFallbacksProps { accessToken: string; value?: Fallbacks; // Current fallbacks value from form onChange?: (fallbacks: Fallbacks) => Promise; // Callback to update form value + disabledReason?: string; // When set, the button is disabled and this explains why } -export default function AddFallbacks({ accessToken, value = [], onChange }: AddFallbacksProps) { +export default function AddFallbacks({ accessToken, value = [], onChange, disabledReason }: AddFallbacksProps) { const [isModalVisible, setIsModalVisible] = useState(false); const [modelInfo, setModelInfo] = useState([]); const [modalKey, setModalKey] = useState(0); // Key to force remount of form when modal opens @@ -116,10 +118,22 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF return (
- + {disabledReason ? ( + + }> + + + {disabledReason} + + ) : ( + + )} ({ getCallbacksCall: vi.fn(), + getRouterSettingsCall: vi.fn(), setCallbacksCall: vi.fn(), })); @@ -57,7 +58,7 @@ vi.mock("../../../common_components/DeleteResourceModal", () => ({ vi.mock("./AddFallbacks", () => ({ __esModule: true, - default: ({ value, onChange }: any) => { + default: ({ value, onChange, disabledReason }: any) => { const handleClick = async () => { if (onChange) { try { @@ -69,7 +70,7 @@ vi.mock("./AddFallbacks", () => ({ } }; return ( - ); @@ -107,6 +108,7 @@ describe("Fallbacks", () => { vi.mocked(networkingModule.getCallbacksCall).mockResolvedValue({ router_settings: mockRouterSettings, }); + vi.mocked(networkingModule.getRouterSettingsCall).mockResolvedValue({ source: { fallbacks: "db" } }); vi.mocked(networkingModule.setCallbacksCall).mockResolvedValue(undefined); vi.mocked(fetchModelsModule.fetchAvailableModels).mockResolvedValue([ { model_group: "gpt-4", mode: "chat" }, @@ -175,6 +177,67 @@ describe("Fallbacks", () => { }); }); + describe("when the router settings source cannot be loaded", () => { + beforeEach(() => { + vi.mocked(networkingModule.getRouterSettingsCall).mockRejectedValue(new Error("500 Internal Server Error")); + }); + + it("keeps add, edit and delete grayed out instead of guessing the list is editable", async () => { + renderWithQueryClient(); + + expect((await screen.findAllByText("gpt-4")).length).toBeGreaterThan(0); + expect(screen.getByTestId("add-fallbacks-button")).toBeDisabled(); + for (const button of screen.getAllByTestId("edit-fallback-button")) { + expect(button).toHaveAttribute("aria-disabled", "true"); + } + for (const button of screen.getAllByTestId("delete-fallback-button")) { + expect(button).toHaveAttribute("aria-disabled", "true"); + } + expect(screen.queryByTestId("config-owned-badge")).not.toBeInTheDocument(); + }); + }); + + describe("when config.yaml owns fallbacks", () => { + beforeEach(() => { + vi.mocked(networkingModule.getRouterSettingsCall).mockResolvedValue({ source: { fallbacks: "config" } }); + }); + + it("shows the config rules but grays out add, edit and delete", async () => { + renderWithQueryClient(); + + await waitFor(() => { + expect(screen.getByTestId("config-owned-badge")).toBeInTheDocument(); + }); + expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); + expect(screen.getByTestId("add-fallbacks-button")).toBeDisabled(); + for (const button of screen.getAllByTestId("edit-fallback-button")) { + expect(button).toHaveAttribute("aria-disabled", "true"); + } + for (const button of screen.getAllByTestId("delete-fallback-button")) { + expect(button).toHaveAttribute("aria-disabled", "true"); + } + }); + + it("does not open the edit or delete modals from click or keyboard", async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + await waitFor(() => { + expect(screen.getByTestId("config-owned-badge")).toBeInTheDocument(); + }); + + await user.click(screen.getAllByTestId("delete-fallback-button")[0]); + screen.getAllByTestId("delete-fallback-button")[0].focus(); + await user.keyboard("{Enter}"); + expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument(); + + await user.click(screen.getAllByTestId("edit-fallback-button")[0]); + screen.getAllByTestId("edit-fallback-button")[0].focus(); + await user.keyboard("{Enter}"); + expect(screen.queryByText("Configure Model Fallbacks")).not.toBeInTheDocument(); + expect(networkingModule.setCallbacksCall).not.toHaveBeenCalled(); + }); + }); + it("should open delete modal when delete icon is clicked", async () => { const user = userEvent.setup(); renderWithQueryClient(); 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 2ccd1a70dc5..03fd1d43510 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx @@ -7,7 +7,14 @@ import React, { useEffect, useState } from "react"; import DeleteResourceModal from "../../../common_components/DeleteResourceModal"; import { ProviderLogo } from "../../../molecules/models/ProviderLogo"; import { toast } from "@/lib/toast"; -import { getCallbacksCall, setCallbacksCall } from "../../../networking"; +import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../../../networking"; +import { + CONFIG_OWNED_HINT, + SOURCE_UNKNOWN_HINT, + ConfigOwnedBadge, + RouterSettingsSource, + isOwnedByConfig, +} from "../../../router_settings/ConfigOwned"; import { isProxyAdminRole } from "@/utils/roles"; import AddFallbacks from "./AddFallbacks"; import EditFallbacks from "./EditFallbacks"; @@ -119,12 +126,19 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str } } +function fallbacksWriteHint(source: RouterSettingsSource | null, ownedByConfig: boolean): string | undefined { + if (ownedByConfig) return CONFIG_OWNED_HINT; + if (source === null) return SOURCE_UNKNOWN_HINT; + return undefined; +} + const Fallbacks: React.FC = ({ accessToken, userRole, userID }) => { const [routerSettings, setRouterSettings] = useState<{ [key: string]: any }>({}); const [isDeleting, setIsDeleting] = useState(false); const [fallbackToDelete, setFallbackToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [fallbackToEdit, setFallbackToEdit] = useState(null); + const [routerSettingsSource, setRouterSettingsSource] = useState(null); const { data: modelCostMapData } = useModelCostMap(); const getProviderFromModel = (model: string): string => { @@ -145,6 +159,9 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) } setRouterSettings(router_settings); }); + getRouterSettingsCall(accessToken) + .then((data) => setRouterSettingsSource(data.source ?? {})) + .catch(() => setRouterSettingsSource(null)); }, [accessToken, userRole, userID]); const handleDeleteClick = (fallbackEntry: FallbackEntry) => { @@ -250,15 +267,24 @@ 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 ownedByConfig = routerSettingsSource !== null && isOwnedByConfig(routerSettingsSource, "fallbacks"); + const writeHint = fallbacksWriteHint(routerSettingsSource, ownedByConfig); + const canWrite = canModify && writeHint === undefined; + const writeActionClass = (hoverClass: string) => + canWrite ? `${iconWrapperClass} cursor-pointer ${hoverClass}` : `${iconWrapperClass} cursor-not-allowed opacity-50`; return ( {canModify && ( - +
+ + {ownedByConfig && } +
)} {!hasFallbacks ? (
@@ -309,15 +335,16 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) data-testid="edit-fallback-button" role="button" tabIndex={0} - onClick={() => handleEditClick(item)} - onKeyDown={(e) => e.key === "Enter" && handleEditClick(item)} - className={`${iconWrapperClass} cursor-pointer hover:text-info`} + aria-disabled={!canWrite} + onClick={() => canWrite && handleEditClick(item)} + onKeyDown={(e) => canWrite && e.key === "Enter" && handleEditClick(item)} + className={writeActionClass("hover:text-info")} /> } > - Edit fallback + {writeHint ?? "Edit fallback"} = ({ accessToken, userRole, userID }) data-testid="delete-fallback-button" role="button" tabIndex={0} - onClick={() => handleDeleteClick(item)} - onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)} - className={`${iconWrapperClass} cursor-pointer hover:text-destructive`} + aria-disabled={!canWrite} + onClick={() => canWrite && handleDeleteClick(item)} + onKeyDown={(e) => canWrite && e.key === "Enter" && handleDeleteClick(item)} + className={writeActionClass("hover:text-destructive")} /> } > - Delete fallback + {writeHint ?? "Delete fallback"} )} diff --git a/ui/litellm-dashboard/src/components/router_settings/ConfigOwned.tsx b/ui/litellm-dashboard/src/components/router_settings/ConfigOwned.tsx new file mode 100644 index 00000000000..4587b62c2ce --- /dev/null +++ b/ui/litellm-dashboard/src/components/router_settings/ConfigOwned.tsx @@ -0,0 +1,28 @@ +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Lock } from "lucide-react"; +import React from "react"; + +export type RouterSettingSource = "config" | "db" | "env" | "default" | "unset"; +export type RouterSettingsSource = { [key: string]: RouterSettingSource }; + +export const CONFIG_OWNED_HINT = "Set in config.yaml. Edit the file to change it."; +export const SOURCE_UNKNOWN_HINT = "Could not load which settings config.yaml owns. Reload the page to make changes."; + +export function isOwnedByConfig(source: RouterSettingsSource, key: string): boolean { + return source[key] === "config"; +} + +export function ConfigOwnedBadge(): React.ReactElement { + return ( + + } + > + + config.yaml + + {CONFIG_OWNED_HINT} + + ); +} diff --git a/ui/litellm-dashboard/src/components/router_settings/LatencyBasedConfiguration.tsx b/ui/litellm-dashboard/src/components/router_settings/LatencyBasedConfiguration.tsx index 61f3abbbb0f..1212a732697 100644 --- a/ui/litellm-dashboard/src/components/router_settings/LatencyBasedConfiguration.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/LatencyBasedConfiguration.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { CONFIG_OWNED_HINT, ConfigOwnedBadge } from "./ConfigOwned"; import { Input } from "@/components/ui/input"; interface routingStrategyArgs { @@ -13,9 +14,13 @@ const defaultLowestLatencyArgs: routingStrategyArgs = { interface LatencyBasedConfigurationProps { routingStrategyArgs: { [key: string]: any }; + ownedByConfig?: boolean; } -const LatencyBasedConfiguration: React.FC = ({ routingStrategyArgs }) => { +const LatencyBasedConfiguration: React.FC = ({ + routingStrategyArgs, + ownedByConfig = false, +}) => { const paramExplanation: { [key: string]: string } = { ttl: "Sliding window to look back over when calculating the average latency of a deployment. Default - 1 hour (in seconds).", lowest_latency_buffer: @@ -26,7 +31,10 @@ const LatencyBasedConfiguration: React.FC = ({ r <>
-

Latency-Based Configuration

+

+ Latency-Based Configuration + {ownedByConfig && } +

Fine-tune latency-based routing behavior

@@ -41,6 +49,8 @@ const LatencyBasedConfiguration: React.FC = ({ r diff --git a/ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.tsx b/ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.tsx index 09bc7e65c57..1ca5d89f662 100644 --- a/ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.tsx @@ -1,14 +1,17 @@ import React from "react"; +import { CONFIG_OWNED_HINT, ConfigOwnedBadge, RouterSettingsSource, isOwnedByConfig } from "./ConfigOwned"; import { Input } from "@/components/ui/input"; interface ReliabilityRetriesSectionProps { routerSettings: { [key: string]: any }; routerFieldsMetadata: { [key: string]: any }; + routerSettingsSource?: RouterSettingsSource; } const ReliabilityRetriesSection: React.FC = ({ routerSettings, routerFieldsMetadata, + routerSettingsSource = {}, }) => { return (
@@ -36,6 +39,7 @@ const ReliabilityRetriesSection: React.FC = ({ {routerFieldsMetadata[param]?.ui_field_name || param} + {isOwnedByConfig(routerSettingsSource, param) && }

{routerFieldsMetadata[param]?.field_description || ""}

@@ -49,6 +53,8 @@ const ReliabilityRetriesSection: React.FC = ({ : value?.toString() || "" } placeholder="—" + disabled={isOwnedByConfig(routerSettingsSource, param)} + title={isOwnedByConfig(routerSettingsSource, param) ? CONFIG_OWNED_HINT : undefined} className="font-mono text-sm w-full" /> diff --git a/ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.test.tsx b/ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.integration.test.tsx similarity index 73% rename from ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.test.tsx rename to ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.integration.test.tsx index 64f9f113fcc..06649f67a6e 100644 --- a/ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.test.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.integration.test.tsx @@ -88,6 +88,38 @@ describe("RouterSettingsForm", () => { expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ enableTagFiltering: true })); }); + it("disables every control whose key config.yaml owns and leaves the rest editable", () => { + const props = { + ...baseProps, + value: { + ...defaultValue, + selectedStrategy: "latency-based-routing", + routerSettings: { + routing_strategy: "latency-based-routing", + routing_strategy_args: { ttl: 3600 }, + num_retries: 3, + timeout: 60, + }, + }, + availableRoutingStrategies: ["simple-shuffle", "latency-based-routing"], + routerSettingsSource: { + routing_strategy: "config" as const, + routing_strategy_args: "config" as const, + enable_tag_filtering: "config" as const, + num_retries: "config" as const, + timeout: "db" as const, + }, + }; + render(); + + expect(screen.getByRole("combobox")).toBeDisabled(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-disabled", "true"); + expect(document.querySelector('input[name="ttl"]')).toBeDisabled(); + expect(document.querySelector('input[name="num_retries"]')).toBeDisabled(); + expect(document.querySelector('input[name="timeout"]')).toBeEnabled(); + expect(screen.getAllByTestId("config-owned-badge")).toHaveLength(4); + }); + it("should show the Reliability & Retries section", () => { render(); expect(screen.getByText("Reliability & Retries")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.tsx b/ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.tsx index 05e38993575..8e20296302f 100644 --- a/ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.tsx @@ -1,5 +1,6 @@ import React from "react"; import LatencyBasedConfiguration from "./LatencyBasedConfiguration"; +import { RouterSettingsSource, isOwnedByConfig } from "./ConfigOwned"; import ReliabilityRetriesSection from "./ReliabilityRetriesSection"; import RoutingStrategySelector from "./RoutingStrategySelector"; import TagFilteringToggle from "./TagFilteringToggle"; @@ -16,6 +17,7 @@ interface RouterSettingsFormProps { routerFieldsMetadata: { [key: string]: any }; availableRoutingStrategies: string[]; routingStrategyDescriptions: { [key: string]: string }; + routerSettingsSource?: RouterSettingsSource; } const RouterSettingsForm: React.FC = ({ @@ -24,6 +26,7 @@ const RouterSettingsForm: React.FC = ({ routerFieldsMetadata, availableRoutingStrategies, routingStrategyDescriptions, + routerSettingsSource = {}, }) => { const handleStrategyChange = (strategy: string) => { onChange({ @@ -56,6 +59,7 @@ const RouterSettingsForm: React.FC = ({ routingStrategyDescriptions={routingStrategyDescriptions} routerFieldsMetadata={routerFieldsMetadata} onStrategyChange={handleStrategyChange} + ownedByConfig={isOwnedByConfig(routerSettingsSource, "routing_strategy")} /> )} @@ -64,6 +68,7 @@ const RouterSettingsForm: React.FC = ({ enabled={value.enableTagFiltering} routerFieldsMetadata={routerFieldsMetadata} onToggle={handleTagFilteringToggle} + ownedByConfig={isOwnedByConfig(routerSettingsSource, "enable_tag_filtering")} />
@@ -72,11 +77,18 @@ const RouterSettingsForm: React.FC = ({ {/* Strategy-Specific Args - Show immediately after strategy if latency-based */} {value.selectedStrategy === "latency-based-routing" && ( - + )} {/* Other Settings */} - +
); }; diff --git a/ui/litellm-dashboard/src/components/router_settings/RoutingStrategySelector.tsx b/ui/litellm-dashboard/src/components/router_settings/RoutingStrategySelector.tsx index 313732b8b3d..b24cadd63a3 100644 --- a/ui/litellm-dashboard/src/components/router_settings/RoutingStrategySelector.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/RoutingStrategySelector.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { CONFIG_OWNED_HINT, ConfigOwnedBadge } from "./ConfigOwned"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; interface RoutingStrategySelectorProps { @@ -7,6 +8,7 @@ interface RoutingStrategySelectorProps { routingStrategyDescriptions: { [key: string]: string }; routerFieldsMetadata: { [key: string]: any }; onStrategyChange: (strategy: string) => void; + ownedByConfig?: boolean; } const RoutingStrategySelector: React.FC = ({ @@ -15,6 +17,7 @@ const RoutingStrategySelector: React.FC = ({ routingStrategyDescriptions, routerFieldsMetadata, onStrategyChange, + ownedByConfig = false, }) => { return (
@@ -22,6 +25,7 @@ const RoutingStrategySelector: React.FC = ({ + {ownedByConfig && }

{routerFieldsMetadata["routing_strategy"]?.field_description || ""}

@@ -30,8 +34,9 @@ const RoutingStrategySelector: React.FC = ({