diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index e68008135aa..a7f7ee20a1a 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -375,6 +375,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.", ) + enable_moyai_ui: bool = Field( + default=True, + description="If true, shows Moyai in the UI workspace switcher and lets admins connect a Moyai 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.", @@ -430,6 +435,7 @@ ALLOWED_UI_SETTINGS_FIELDS: Final = { "disable_custom_api_keys", "disable_key_generate_for_org_admin", "enable_chat_ui", + "enable_moyai_ui", "moyai_url", TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, } 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 77d0132ec72..da1ef41f722 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 @@ -4054,6 +4054,44 @@ class TestEnableChatUISetting: assert response.json()["values"]["enable_chat_ui"] is False +class TestEnableMoyaiUISetting: + def test_moyai_ui_is_on_when_nothing_is_stored(self, mock_auth, monkeypatch): + TestPtuCostAttributionUISetting._mock_prisma(monkeypatch) + + response = client.get("/get/ui_settings") + + assert response.status_code == 200 + assert response.json()["values"]["enable_moyai_ui"] is True + + def test_admin_can_turn_moyai_ui_off(self, mock_auth, monkeypatch): + TestPtuCostAttributionUISetting._mock_prisma(monkeypatch, stored={"enable_moyai_ui": False}) + + response = client.get("/get/ui_settings") + + assert response.status_code == 200 + assert response.json()["values"]["enable_moyai_ui"] is False + + def test_admin_can_patch_moyai_ui_off(self, mock_auth, monkeypatch): + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + mock_prisma = TestPtuCostAttributionUISetting._mock_prisma(monkeypatch) + + try: + response = client.patch("/update/ui_settings", json={"enable_moyai_ui": False}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + upsert_data = mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"] + assert json.loads(upsert_data["create"]["ui_settings"])["enable_moyai_ui"] is False + + class TestApplyUserBudgetToTeamKeysUISetting: """``apply_user_budget_to_team_keys`` mirrors general_settings on every GET. diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx index 64bb8f15585..9d480ababcb 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx @@ -179,7 +179,9 @@ describe("ViewSwitcher", () => { it("should switch from Chat to the connected Moyai deployment", async () => { state.pathname = "/ui/chat"; - const settings = { values: { enable_chat_ui: true, moyai_url: "https://moyai.example.com" } }; + const settings = { + values: { enable_chat_ui: true, enable_moyai_ui: true, moyai_url: "https://moyai.example.com" }, + }; getUiSettingsMock.mockResolvedValue(settings); const user = userEvent.setup(); renderSwitcher(); @@ -189,7 +191,20 @@ describe("ViewSwitcher", () => { expect(assignSpy).toHaveBeenCalledWith("https://moyai.example.com"); }); + it("should hide Moyai from the switcher when an admin turns it off", async () => { + getUiSettingsMock.mockResolvedValue({ + values: { enable_chat_ui: true, enable_moyai_ui: false, moyai_url: "https://moyai.example.com" }, + }); + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(screen.getByRole("button", { name: "Switch workspace, current: AI Gateway" })); + await screen.findByRole("menuitem", { name: "Chat Chat UI for using LLMs, MCPs, Agents" }); + expect(screen.queryByRole("menuitem", { name: /Moyai/ })).not.toBeInTheDocument(); + }); + it("should keep the local Moyai route available when Chat is disabled", async () => { + getUiSettingsMock.mockResolvedValue({ values: { enable_chat_ui: false, enable_moyai_ui: true } }); const user = userEvent.setup(); renderSwitcher(); diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index aa860226e3b..cba272788fc 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -54,6 +54,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch const pathname = usePathname(); const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui); + const moyaiEnabled = Boolean(uiSettings?.values?.enable_moyai_ui); const moyaiUrl = (uiSettings?.values?.moyai_url as string | undefined) ?? null; const normalizedPathname = (pathname ?? "").replace(/\/+$/, ""); @@ -101,6 +102,20 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch onClick: chatEnabled ? () => window.location.assign(uiHref(CHAT)) : undefined, }; + const moyaiItem: ViewSwitcherItem = { + key: MOYAI, + label: ( +
+ + Moyai{" "} + Cloud Coding Agent + + {isMoyaiRoute && } +
+ ), + onClick: () => window.location.assign(moyaiUrl ?? uiHref(MOYAI)), + }; + const items: ViewSwitcherItem[] = [ ...modeEntries.map((e) => ({ key: e.key, @@ -113,19 +128,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch onClick: () => selectMode(e.key), })), chatItem, - { - key: MOYAI, - label: ( -
- - Moyai{" "} - Cloud Coding Agent - - {isMoyaiRoute && } -
- ), - onClick: () => window.location.assign(moyaiUrl ?? uiHref(MOYAI)), - }, + ...(moyaiEnabled ? [moyaiItem] : []), ]; return ( diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx index c2bfd6076d4..4aade313698 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx @@ -173,7 +173,11 @@ describe("UISettings", () => { buildSettingsResponse({ data: { ...buildSettingsResponse().data, - values: { ...buildSettingsResponse().data.values, moyai_url: "https://moyai.example.com" }, + values: { + ...buildSettingsResponse().data.values, + enable_moyai_ui: true, + moyai_url: "https://moyai.example.com", + }, }, }), ); @@ -192,12 +196,78 @@ describe("UISettings", () => { }); it("shows a link to the Moyai page when not connected", () => { + mockUseUISettings.mockReturnValue( + buildSettingsResponse({ + data: { + ...buildSettingsResponse().data, + values: { ...buildSettingsResponse().data.values, enable_moyai_ui: true }, + }, + }), + ); render(); expect(screen.getByText("Not connected")).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Connect from the Moyai page" })).toHaveAttribute("href", "/ui/moyai"); }); + it("shows Moyai as disabled without a connect link when an admin turns it off", () => { + mockUseUISettings.mockReturnValue( + buildSettingsResponse({ + data: { + ...buildSettingsResponse().data, + values: { ...buildSettingsResponse().data.values, enable_moyai_ui: false }, + }, + }), + ); + render(); + + expect(screen.getByText("Disabled")).toBeInTheDocument(); + expect(screen.queryByText("Not connected")).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Connect from the Moyai page" })).not.toBeInTheDocument(); + }); + + it("locks the Moyai switch when the config file owns enable_moyai_ui", () => { + mockUseUISettings.mockReturnValue( + buildSettingsResponse({ + data: { + ...buildSettingsResponse().data, + values: { ...buildSettingsResponse().data.values, enable_moyai_ui: false }, + source: { enable_moyai_ui: "config" }, + }, + }), + ); + render(); + + expect(screen.getByRole("switch", { name: "Enable Moyai" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText(/Managed by general_settings.enable_moyai_ui in the config file/)).toBeInTheDocument(); + }); + + it("turns Moyai off by patching enable_moyai_ui", () => { + const mutateMock = vi.fn((_settings, options) => { + options?.onSuccess?.(); + }); + mockUseUpdateUISettings.mockReturnValue({ mutate: mutateMock, isPending: false, error: null }); + mockUseUISettings.mockReturnValue( + buildSettingsResponse({ + data: { + ...buildSettingsResponse().data, + values: { ...buildSettingsResponse().data.values, enable_moyai_ui: true }, + }, + }), + ); + render(); + + act(() => { + fireEvent.click(screen.getByRole("switch", { name: "Enable Moyai" })); + }); + + expect(mutateMock).toHaveBeenCalledWith( + { enable_moyai_ui: false }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + expect(toast.success).toHaveBeenCalledWith("UI settings updated successfully"); + }); + it("should toggle require auth for public AI Hub setting and call update", () => { const mutateMock = vi.fn((_settings, options) => { options?.onSuccess?.(); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index 6fe86c0f352..d95232d013f 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -1,6 +1,7 @@ "use client"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { useTerminology } from "@/contexts/TerminologyContext"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings"; @@ -53,6 +54,12 @@ function SettingRow({ ); } +function moyaiStatusLabel(t: TFunction, isEnabled: boolean, url: string | null): string { + if (!isEnabled) return t("adminPanel.uiSettings.moyai.disabled"); + if (url) return t("adminPanel.uiSettings.moyai.connectedTo", { url }); + return t("adminPanel.uiSettings.moyai.notConnected"); +} + export default function UISettings() { const { t } = useTranslation(); const term = useTerminology(); @@ -68,6 +75,7 @@ export default function UISettings() { const forwardLLMProviderAuthHeadersProperty = schema?.properties?.forward_llm_provider_auth_headers; const enableProjectsUIProperty = schema?.properties?.enable_projects_ui; const enableChatUIProperty = schema?.properties?.enable_chat_ui; + const enableMoyaiUIProperty = schema?.properties?.enable_moyai_ui; const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users; const disableAgentsProperty = schema?.properties?.disable_agents_for_internal_users; const allowAgentsTeamAdminsProperty = schema?.properties?.allow_agents_for_team_admins; @@ -81,6 +89,9 @@ export default function UISettings() { "last_7_days"; const usageRangeConfigOwned = data?.source?.usage_default_time_range === "config"; const moyaiUrl = (values.moyai_url as string | undefined) ?? null; + const isMoyaiEnabled = Boolean(values.enable_moyai_ui); + const moyaiConfigOwned = data?.source?.enable_moyai_ui === "config"; + const moyaiStatus = moyaiStatusLabel(t, isMoyaiEnabled, moyaiUrl); const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); const isAgentsDisabled = Boolean(values.disable_agents_for_internal_users); @@ -183,6 +194,20 @@ export default function UISettings() { ); }; + const handleToggleEnableMoyaiUI = (checked: boolean) => { + updateSettings( + { enable_moyai_ui: checked }, + { + onSuccess: () => { + toast.success(t("adminPanel.uiSettings.toasts.updated")); + }, + onError: (error) => { + toast.fromError(error); + }, + }, + ); + }; + const handleToggleRequireAuthForPublicAIHub = (checked: boolean) => { updateSettings( { require_auth_for_public_ai_hub: checked }, @@ -447,6 +472,23 @@ export default function UISettings() { enableChatUIProperty?.description ?? t("adminPanel.uiSettings.settings.enableChat.description") } /> +
+ + {moyaiConfigOwned && ( +

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

+ )} +
@@ -454,17 +496,14 @@ export default function UISettings() {

Moyai

-

- {moyaiUrl - ? t("adminPanel.uiSettings.moyai.connectedTo", { url: moyaiUrl }) - : t("adminPanel.uiSettings.moyai.notConnected")} -

+

{moyaiStatus}

- {moyaiUrl ? ( + {moyaiUrl && ( - ) : ( + )} + {!moyaiUrl && isMoyaiEnabled && ( {t("adminPanel.uiSettings.moyai.connectLink")} diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index e98c29e4343..35143ff9d56 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -2866,6 +2866,12 @@ "label": "Enable Chat page (page will refresh)", "description": "If enabled, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth." }, + "enableMoyai": { + "ariaLabel": "Enable Moyai", + "label": "[BETA] Enable Moyai", + "description": "If enabled, shows Moyai in the workspace switcher and lets admins connect a Moyai deployment.", + "managedByConfig": "Managed by general_settings.enable_moyai_ui in the config file. Change it there." + }, "disableAgents": { "label": "Disable agents for internal users" }, @@ -2890,6 +2896,7 @@ "moyai": { "connectedTo": "Connected to {{url}}", "notConnected": "Not connected", + "disabled": "Disabled", "disconnect": "Disconnect", "connectLink": "Connect from the Moyai page" }