diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 2de589564ac..a17c9d10311 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -377,6 +377,11 @@ class UISettings(LiteLLMBaseModel): description="If true, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth.", ) + disable_aria: bool = Field( + default=False, + description="Hides Aria for everyone on this deployment.", + ) + moyai_url: str | None = Field( default=None, description="URL of a connected Moyai deployment. When set, the Moyai entry in the UI navigation opens this deployment instead of the Moyai landing page.", @@ -432,6 +437,7 @@ ALLOWED_UI_SETTINGS_FIELDS: Final = { "disable_custom_api_keys", "disable_key_generate_for_org_admin", "enable_chat_ui", + "disable_aria", "moyai_url", TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, } diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx index fa4b48a5a9d..6cc38ddb5a2 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx @@ -9,7 +9,7 @@ const { getSettings } = vi.hoisted(() => ({ getSettings: vi.fn().mockResolvedValue({ values: { enable_projects_ui: true } }), })); -vi.mock("@/components/networking", () => ({ getUiSettings: getSettings })); +vi.mock("@/components/networking", () => ({ getUiSettings: getSettings, getProxyBaseUrl: () => "http://localhost" })); vi.mock("@/components/leftnav", () => ({ default: ({ enableProjectsUI }: ComponentProps) => ( diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled.test.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled.test.ts index 2215817b618..7521d754bc0 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled.test.ts +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled.test.ts @@ -7,6 +7,7 @@ import { PTU_FLAG_REFRESH_MS, usePtuCostAttributionEnabled } from "./usePtuCostA import { useUISettings } from "./useUISettings"; vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: () => "http://localhost", getUiSettings: vi.fn(), })); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.test.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.test.ts index 0fc3bda27fc..bb1b550b3c7 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.test.ts +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.test.ts @@ -7,6 +7,7 @@ import { useUISettings } from "./useUISettings"; // Mock the networking function vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: () => "http://localhost", getUiSettings: vi.fn(), })); @@ -56,7 +57,7 @@ describe("useUISettings", () => { expect(result.current.data).toEqual(mockUISettings); expect(result.current.error).toBeNull(); - expect(getUiSettings).toHaveBeenCalledWith(); + expect(getUiSettings).toHaveBeenCalledWith("http://localhost"); expect(getUiSettings).toHaveBeenCalledTimes(1); }); @@ -80,7 +81,7 @@ describe("useUISettings", () => { expect(result.current.error).toEqual(testError); expect(result.current.data).toBeUndefined(); - expect(getUiSettings).toHaveBeenCalledWith(); + expect(getUiSettings).toHaveBeenCalledWith("http://localhost"); expect(getUiSettings).toHaveBeenCalledTimes(1); }); @@ -97,7 +98,7 @@ describe("useUISettings", () => { }); expect(result.current.data).toEqual({}); - expect(getUiSettings).toHaveBeenCalledWith(); + expect(getUiSettings).toHaveBeenCalledWith("http://localhost"); }); it("should handle network timeout error", async () => { diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts index 749fc98c0d8..616f3eba7b3 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts @@ -1,4 +1,4 @@ -import { getUiSettings } from "@/components/networking"; +import { getProxyBaseUrl, getUiSettings } from "@/components/networking"; import { useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; @@ -13,12 +13,14 @@ const uiSettingsKeys = createQueryKeys("uiSettings"); * cached copy stale; a screen that stays mounted and focused never refetches on its own, * so a caller that needs to notice a change also has to poll. */ -export const useUISettings = (options?: { staleTime?: number; refetchInterval?: number }) => { +export const useUISettings = (options?: { staleTime?: number; refetchInterval?: number; enabled?: boolean }) => { + const proxyBaseUrl = getProxyBaseUrl(); return useQuery>({ - queryKey: uiSettingsKeys.list({}), - queryFn: async () => await getUiSettings(), + queryKey: [...uiSettingsKeys.list({}), proxyBaseUrl], + queryFn: async () => await getUiSettings(proxyBaseUrl), staleTime: options?.staleTime ?? 60 * 60 * 1000, // 1 hour - data rarely changes gcTime: 60 * 60 * 1000, // 1 hour - keep in cache for 1 hour refetchInterval: options?.refetchInterval, + enabled: options?.enabled, }); }; diff --git a/litellm/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx b/litellm/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx index c2bfd6076d4..73787def35b 100644 --- a/litellm/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx +++ b/litellm/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx @@ -101,6 +101,31 @@ describe("UISettings", () => { expect(screen.getByRole("switch", { name: "Require authentication for public AI Hub" })).toBeInTheDocument(); }); + it.each([false, true])("saves the deployment Aria visibility toggle from %s", (disabled) => { + const mutate = vi.fn(); + mockUseUISettings.mockReturnValue(buildSettingsResponse({ data: { values: { disable_aria: disabled } } })); + mockUseUpdateUISettings.mockReturnValue({ mutate, isPending: false, error: null }); + render(); + const toggle = screen.getByRole("switch", { name: "Hide Aria Agent for everyone" }); + if (disabled) expect(toggle).toBeChecked(); + else expect(toggle).not.toBeChecked(); + fireEvent.click(toggle); + expect(mutate).toHaveBeenCalledWith({ disable_aria: !disabled }, expect.any(Object)); + }); + + it("keeps configuration-owned Aria visibility read-only", () => { + mockUseUISettings.mockReturnValue( + buildSettingsResponse({ data: { values: { disable_aria: true }, source: { disable_aria: "config" } } }), + ); + render(); + expect(screen.getByRole("switch", { name: "Hide Aria Agent for everyone" })).toBeChecked(); + expect(screen.getByRole("switch", { name: "Hide Aria Agent for everyone" })).toHaveAttribute( + "aria-disabled", + "true", + ); + expect(screen.getByText("Managed by the deployment configuration.")).toBeInTheDocument(); + }); + it("should toggle setting and call update", () => { const mutateMock = vi.fn((_settings, options) => { options?.onSuccess?.(); diff --git a/litellm/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/litellm/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index 1a3a1b6e28f..26c3c80d2dc 100644 --- a/litellm/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/litellm/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -81,6 +81,7 @@ export default function UISettings() { relativeTimeOptions.find((option) => option.settingValue === values.usage_default_time_range)?.settingValue ?? "last_7_days"; const usageRangeConfigOwned = data?.source?.usage_default_time_range === "config"; + const ariaConfigOwned = data?.source?.disable_aria === "config"; const moyaiUrl = (values.moyai_url as string | undefined) ?? null; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); @@ -364,6 +365,29 @@ export default function UISettings() {

{t("adminPanel.uiSettings.usageRange.managedByConfig")}

)} +
+ + updateSettings( + { disable_aria: checked }, + { + onSuccess: () => toast.success(t("adminPanel.uiSettings.toasts.updated")), + onError: (error) => toast.fromError(error), + }, + ) + } + ariaLabel={t("adminPanel.uiSettings.settings.disableAria.label")} + label={t("adminPanel.uiSettings.settings.disableAria.label")} + description={t("adminPanel.uiSettings.settings.disableAria.description")} + /> + {ariaConfigOwned && ( +

+ {t("adminPanel.uiSettings.settings.disableAria.managedByConfig")} +

+ )} +
Promise; usage?: () => Promise; settings?: { target: string; status: number } | ((request: Request) => Promise); + uiSettings?: () => Promise; useSiTerminology?: boolean; } @@ -191,6 +192,7 @@ function gateway( if (typeof settings === "function") return settings(request); return json({ PROXY_BASE_URL: MANAGEMENT, LITELLM_UI_API_DOC_BASE_URL: settings.target }, settings.status); } + if (path.endsWith("/get/ui_settings")) return options.uiSettings ? options.uiSettings() : json({ values: {} }); if (path.endsWith("/model_group/info")) return json({ data: options.models ?? [ @@ -254,6 +256,57 @@ afterEach(() => { }); describe("Aria Agent in the gateway", () => { + it.each([ + ["proxy_admin", true], + ["proxy_admin", false], + ["proxy_admin_viewer", true], + ["internal_user", true], + ["internal_user_viewer", false], + ["org_admin", true], + ] as const)("hides every Aria entry point for %s with premium=%s when disabled", async (role, premium) => { + session(role, "first-admin", { premium_user: premium }); + const requests = gateway([], { uiSettings: async () => json({ values: { disable_aria: true } }) }); + const { client } = renderWidget(undefined, false, true); + await screen.findByText("Session ready"); + await waitFor(() => expect(requests.some((request) => request.url === `${MANAGEMENT}/get/ui_settings`)).toBe(true)); + await waitFor(() => expect(client.isFetching()).toBe(0)); + expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Ask Aria" })).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Ask Aria…")).not.toBeInTheDocument(); + fireEvent.keyDown(document, { key: "j", metaKey: true }); + fireEvent.keyDown(document, { key: "j", ctrlKey: true }); + expect(push).not.toHaveBeenCalled(); + expect(requests.some((request) => request.url.endsWith("/models"))).toBe(false); + }); + + it("waits for settings and does not show Aria when the initial read fails", async () => { + const settings = deferred(); + gateway([], { uiSettings: () => settings.promise }); + const { client } = renderWidget(undefined, false, true); + await screen.findByText("Session ready"); + expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument(); + await act(async () => settings.resolve(json({ error: "Unavailable" }, 503))); + await waitFor(() => expect(client.isFetching()).toBe(0)); + expect(screen.queryByRole("link", { name: "Ask Aria" })).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Ask Aria…")).not.toBeInTheDocument(); + }); + + it("waits for the destination gateway's settings instead of reusing an enabled gateway", async () => { + const settings = deferred(); + gateway([], { + uiSettings: async () => + localStorage.getItem("litellm_worker_url") ? settings.promise : json({ values: { disable_aria: false } }), + }); + const view = renderWidget(undefined, false, true, { showPage: false }); + await screen.findByRole("link", { name: "Ask Aria" }); + switchToWorkerUrl("https://other-gateway.test"); + view.refresh(); + expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument(); + await act(async () => settings.resolve(json({ values: { disable_aria: true } }))); + await waitFor(() => expect(view.client.isFetching()).toBe(0)); + expect(screen.queryByRole("link", { name: "Ask Aria" })).not.toBeInTheDocument(); + }); + it.each([true, false])( "keeps the answer, draft and model when returning to Aria with premium=%s", async (premium) => { @@ -643,7 +696,7 @@ describe("Aria Agent in the gateway", () => { const { client } = renderWidget(undefined, true); await screen.findByText("Session ready"); await waitFor(() => expect(client.isFetching()).toBe(0)); - expect(screen.getByRole("link", { name: "Aria Agent" })).toBeVisible(); + expect(await screen.findByRole("link", { name: "Aria Agent" })).toBeVisible(); expect(screen.queryByRole("region", { name: "Ask Aria Agent", hidden: true })).not.toBeInTheDocument(); await waitFor(() => expect(screen.getByPlaceholderText("Ask Aria…")).toBeEnabled()); act(() => send("Show my usage")); @@ -1040,19 +1093,20 @@ describe("Aria Agent in the gateway", () => { }); it.each( - ["account", "gateway", "destination", "permission"].flatMap((transition) => + ["account", "gateway", "destination", "permission", "visibility"].flatMap((transition) => ["pending", "approval", "draft"].map((stage) => [transition, stage] as const), ), )("should clear the conversation when %s changes during %s", async (transition, stage) => { const pending = deferred(); const settings = { target: INFERENCE, status: 200 }; + const visibility = { disable_aria: false }; const replies = [ answer("Old answer."), ...(stage === "pending" ? [pending.promise] : []), ...(stage === "approval" ? [toolReply("key_create", keyArguments)] : []), answer("Fresh session."), ]; - const requests = gateway(replies, { settings }); + const requests = gateway(replies, { settings, uiSettings: async () => json({ values: visibility }) }); const view = renderWidget(undefined, true); await openWidget(); act(() => send("Old question")); @@ -1079,6 +1133,13 @@ describe("Aria Agent in the gateway", () => { } else if (transition === "destination") { settings.target = INFERENCE + "/updated"; await act(async () => view.client.invalidateQueries({ queryKey: ["proxySettings"] })); + } else if (transition === "visibility") { + visibility.disable_aria = true; + await act(async () => view.client.invalidateQueries({ queryKey: ["uiSettings"] })); + await waitFor(() => expect(screen.queryByRole("link", { name: "Aria Agent" })).not.toBeInTheDocument()); + expect(screen.queryByPlaceholderText("Ask Aria…")).not.toBeInTheDocument(); + visibility.disable_aria = false; + await act(async () => view.client.invalidateQueries({ queryKey: ["uiSettings"] })); } else { session("proxy_admin_viewer", "first-admin", { premium_user: true }, "sk-session-first-admin-viewer"); view.refresh(); diff --git a/litellm/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx b/litellm/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx index 3c128b6f6e9..b5c4a2f40b9 100644 --- a/litellm/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx +++ b/litellm/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx @@ -22,6 +22,7 @@ import { RotateCcw } from "lucide-react"; import { z } from "zod"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { useProxySettingsQuery } from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; import { ChatComposer } from "@/app/(dashboard)/playground/components/chat_ui/ChatComposer"; import { EndpointType, isModeCompatibleWithEndpoint } from "@/components/chat_ui/mode_endpoint_mapping"; @@ -56,7 +57,9 @@ function useLiteAdminSession() { const auth = useAuthorized(); const [disabled] = useDisableLiteAdmin(auth.userId); const sessionReady = !auth.isLoading && auth.isAuthorized; + const { data: uiSettings } = useUISettings({ staleTime: 0, refetchInterval: 30_000, enabled: sessionReady }); const admin = auth.premiumUser === true && !auth.isViewOnly && isProxyAdminRole(auth.userRole); + if (!uiSettings || uiSettings.values?.disable_aria === true) return null; if (!sessionReady || (admin && disabled)) return null; if (!auth.token || !auth.accessToken || !auth.userId) return null; const access: LiteAdminSession["access"] = admin diff --git a/litellm/ui/litellm-dashboard/src/components/networking.tsx b/litellm/ui/litellm-dashboard/src/components/networking.tsx index 1501c136803..6c9379cb1d8 100644 --- a/litellm/ui/litellm-dashboard/src/components/networking.tsx +++ b/litellm/ui/litellm-dashboard/src/components/networking.tsx @@ -6802,8 +6802,7 @@ export const exchangeLoginCode = async (code: string, workerBaseUrl?: string | n return data.token; }; -export const getUiSettings = async () => { - const proxyBaseUrl = getProxyBaseUrl(); +export const getUiSettings = async (proxyBaseUrl = getProxyBaseUrl()) => { const url = proxyBaseUrl ? `${proxyBaseUrl}/get/ui_settings` : `/get/ui_settings`; const response = await fetch(url, { method: "GET", diff --git a/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx b/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx index ba303491551..ea074eaf15c 100644 --- a/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx +++ b/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx @@ -38,6 +38,8 @@ vi.mock("@/app/(dashboard)/hooks/keys/useResetKeySpend", () => ({ })); vi.mock("../networking", () => ({ serverRootPath: "", + getProxyBaseUrl: vi.fn(() => ""), + getUiSettings: vi.fn().mockResolvedValue({ values: {} }), keyDeleteCall: vi.fn().mockResolvedValue({}), keyUpdateCall: vi.fn().mockResolvedValue({}), getPolicyInfoWithGuardrails: vi.fn().mockResolvedValue({ resolved_guardrails: [] }), diff --git a/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index 78607a70058..ee3bc712668 100644 --- a/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -98,6 +98,8 @@ import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolset vi.mock("../networking", () => ({ serverRootPath: "", + getProxyBaseUrl: vi.fn(() => ""), + getUiSettings: vi.fn().mockResolvedValue({ values: {} }), keyDeleteCall: vi.fn().mockResolvedValue({}), keyUpdateCall: vi.fn().mockResolvedValue({}), regenerateKeyCall: vi.fn().mockResolvedValue({ key: "sk-regenerated", token: "regenerated-token" }), diff --git a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json index a2369c9f077..1669c7ed15c 100644 --- a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json @@ -5212,6 +5212,11 @@ "managedByConfig": "Managed by the deployment configuration." }, "settings": { + "disableAria": { + "label": "Hide Aria Agent for everyone", + "description": "Hides Aria from navigation, Usage, and its workspace for all users, including admins. Active dashboards check for changes every 30 seconds.", + "managedByConfig": "Managed by the deployment configuration." + }, "disableModelAdd": { "label": "Disable model add for internal users" }, diff --git a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 7a67d4c5024..8050df85bf8 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -4663,9 +4663,20 @@ class TestMoyaiUrlSetting: assert persisted["moyai_url"] is None -@pytest.mark.parametrize("preset", ["today", "last_7_days", "last_30_days", "month_to_date", "year_to_date"]) -def test_usage_default_range_round_trips_without_overwriting_other_settings( - mock_auth, monkeypatch: pytest.MonkeyPatch, preset: str, +@pytest.mark.parametrize("setting,initial_value,submitted,default", ( + ("usage_default_time_range", None, "today", "last_7_days"), + ("usage_default_time_range", None, "last_7_days", "last_7_days"), + ("usage_default_time_range", None, "last_30_days", "last_7_days"), + ("usage_default_time_range", None, "month_to_date", "last_7_days"), + ("usage_default_time_range", None, "year_to_date", "last_7_days"), + ("disable_aria", None, False, False), + ("disable_aria", None, True, False), + ("disable_aria", False, True, False), + ("disable_aria", True, False, False), +)) +def test_ui_setting_round_trips_without_overwriting_other_settings( + mock_auth: None, monkeypatch: pytest.MonkeyPatch, setting: str, + initial_value: str | bool | None, submitted: str | bool, default: str | bool, ) -> None: from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -4676,7 +4687,7 @@ def test_usage_default_range_round_trips_without_overwriting_other_settings( prisma: Final = MagicMock() prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=SimpleNamespace( - ui_settings={"disable_custom_api_keys": True}, + ui_settings={"disable_custom_api_keys": True, **({} if initial_value is None else {setting: initial_value})}, )) prisma.db.litellm_uisettings.upsert = AsyncMock() monkeypatch.setattr(proxy_server, "prisma_client", prisma) @@ -4686,22 +4697,28 @@ def test_usage_default_range_round_trips_without_overwriting_other_settings( app.dependency_overrides[user_api_key_auth] = lambda: actor try: initial: Final = client.get("/get/ui_settings") - assert initial.json()["values"]["usage_default_time_range"] == "last_7_days" - response: Final = client.patch("/update/ui_settings", json={"usage_default_time_range": preset}) + assert initial.status_code == 200, initial.text + assert initial.json()["values"][setting] == (default if initial_value is None else initial_value) + assert initial.json()["source"][setting] == ("default" if initial_value is None else "db") + response: Final = client.patch("/update/ui_settings", json={setting: submitted}) assert response.status_code == 200, response.text stored: Final = json.loads(prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]["update"]["ui_settings"]) - assert stored == {"disable_custom_api_keys": True, "usage_default_time_range": preset} - prisma.db.litellm_uisettings.find_unique.return_value = SimpleNamespace(ui_settings=stored) + assert stored == {"disable_custom_api_keys": True, setting: submitted} + prisma.db.litellm_uisettings.find_unique.return_value = SimpleNamespace(ui_settings=json.dumps(stored)) fetched: Final = client.get("/get/ui_settings") - assert fetched.json()["values"]["usage_default_time_range"] == preset - assert fetched.json()["source"]["usage_default_time_range"] == "db" + assert fetched.status_code == 200, fetched.text + assert fetched.json()["values"][setting] == submitted + assert fetched.json()["values"]["disable_custom_api_keys"] is True + assert fetched.json()["source"][setting] == "db" + assert "disable_aria" not in proxy_server.general_settings finally: app.dependency_overrides.clear() @pytest.mark.parametrize("submitted", ["invalid", None, 7]) -def test_usage_default_range_rejects_invalid_values_without_writing( - mock_auth, monkeypatch: pytest.MonkeyPatch, submitted: object, +@pytest.mark.parametrize("setting", ("usage_default_time_range", "disable_aria")) +def test_ui_setting_rejects_invalid_values_without_writing( + mock_auth: None, monkeypatch: pytest.MonkeyPatch, submitted: object, setting: str, ) -> None: from unittest.mock import AsyncMock, MagicMock from litellm.proxy import proxy_server @@ -4709,13 +4726,14 @@ def test_usage_default_range_rejects_invalid_values_without_writing( from litellm.proxy.auth.user_api_key_auth import user_api_key_auth prisma: Final = MagicMock() + prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None) prisma.db.litellm_uisettings.upsert = AsyncMock() monkeypatch.setattr(proxy_server, "prisma_client", prisma) monkeypatch.setattr(proxy_server, "store_model_in_db", True) actor: Final = UserAPIKeyAuth(user_id="test-admin", user_role=LitellmUserRoles.PROXY_ADMIN) app.dependency_overrides[user_api_key_auth] = lambda: actor try: - response: Final = client.patch("/update/ui_settings", json={"usage_default_time_range": submitted}) + response: Final = client.patch("/update/ui_settings", json={setting: submitted}) assert response.status_code == 422, response.text prisma.db.litellm_uisettings.upsert.assert_not_called() finally: @@ -4723,8 +4741,9 @@ def test_usage_default_range_rejects_invalid_values_without_writing( @pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY]) -def test_usage_default_range_cannot_be_changed_by_non_admins( - mock_auth, monkeypatch: pytest.MonkeyPatch, role: LitellmUserRoles, +@pytest.mark.parametrize("setting,value", (("usage_default_time_range", "month_to_date"), ("disable_aria", True))) +def test_ui_setting_cannot_be_changed_by_non_admins( + mock_auth: None, monkeypatch: pytest.MonkeyPatch, role: LitellmUserRoles, setting: str, value: str | bool, ) -> None: from unittest.mock import AsyncMock, MagicMock from litellm.proxy import proxy_server @@ -4738,14 +4757,21 @@ def test_usage_default_range_cannot_be_changed_by_non_admins( actor: Final = UserAPIKeyAuth(user_id="test-user", user_role=role) app.dependency_overrides[user_api_key_auth] = lambda: actor try: - response: Final = client.patch("/update/ui_settings", json={"usage_default_time_range": "month_to_date"}) + response: Final = client.patch("/update/ui_settings", json={setting: value}) assert response.status_code == 403, response.text prisma.db.litellm_uisettings.upsert.assert_not_called() finally: app.dependency_overrides.clear() -def test_usage_default_range_reports_config_precedence(mock_auth, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("setting,stored,configured", ( + ("usage_default_time_range", "today", "month_to_date"), + ("disable_aria", False, True), + ("disable_aria", True, False), +)) +def test_ui_setting_reports_config_precedence( + mock_auth: None, monkeypatch: pytest.MonkeyPatch, setting: str, stored: str | bool, configured: str | bool, +) -> None: from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock from litellm.proxy import proxy_server @@ -4753,16 +4779,16 @@ def test_usage_default_range_reports_config_precedence(mock_auth, monkeypatch: p prisma: Final = MagicMock() prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=SimpleNamespace( - ui_settings={"usage_default_time_range": "today"}, + ui_settings={setting: stored}, )) monkeypatch.setattr(proxy_server, "prisma_client", prisma) settings: Final = SettingsStore("general_settings") - settings.load_yaml({"usage_default_time_range": "month_to_date"}) + settings.load_yaml({setting: configured}) monkeypatch.setattr(proxy_server.proxy_config, "settings", settings) response: Final = client.get("/get/ui_settings") assert response.status_code == 200, response.text - assert response.json()["values"]["usage_default_time_range"] == "month_to_date" - assert response.json()["source"]["usage_default_time_range"] == "config" + assert response.json()["values"][setting] == configured + assert response.json()["source"][setting] == "config" @pytest.fixture