fix(ui): block /ui/moyai when Moyai is off, keep it shown by default, refuse config-owned PATCH
Some checks failed
Cost map pins recheck / rerun-cost-map-guard (push) Has been cancelled
LiteLLM Rust DB / db-queries (push) Has been cancelled
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

Redirect the Moyai page home when enable_moyai_ui is false, mirroring the Chat
route guard. Show Moyai in the switcher unless the setting is explicitly false,
so it no longer disappears while settings load. PATCH /update/ui_settings now
refuses an enable_moyai_ui write the config file owns.
This commit is contained in:
Jesus Velarde 2026-11-06 17:56:47 -08:00
parent 2c50a44110
commit ba2331341f
6 changed files with 121 additions and 3 deletions

View file

@ -2090,6 +2090,7 @@ async def update_ui_settings(
from litellm.proxy.proxy_server import (
create_config_audit_log,
prisma_client,
proxy_config,
store_model_in_db,
)
@ -2172,6 +2173,10 @@ async def update_ui_settings(
# Enforce allowlist and drop anything unexpected
incoming: Final = {k: v for k, v in settings_dict.items() if k in ALLOWED_UI_SETTINGS_FIELDS}
proxy_config.reject_config_owned_writes(
section_name="general_settings",
changed_keys={k: v for k, v in incoming.items() if k == "enable_moyai_ui"},
)
# Merge with existing persisted settings so a partial PATCH doesn't
# overwrite fields the caller didn't send.

View file

@ -4174,6 +4174,30 @@ class TestEnableMoyaiUISetting:
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
def test_patch_refuses_moyai_ui_owned_by_config_file(self, mock_auth, monkeypatch):
from litellm.proxy import proxy_server
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
store: Final = proxy_server.proxy_config.settings
store.load_yaml({"enable_moyai_ui": False})
monkeypatch.setattr(proxy_server.proxy_config, "_settings_stores", {"general_settings": store})
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: Final = TestPtuCostAttributionUISetting._mock_prisma(monkeypatch)
try:
response: Final = client.patch("/update/ui_settings", json={"enable_moyai_ui": True})
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json()["detail"]["keys"] == ["enable_moyai_ui"]
mock_prisma.db.litellm_uisettings.upsert.assert_not_called()
class TestApplyUserBudgetToTeamKeysUISetting:
"""``apply_user_budget_to_team_keys`` mirrors general_settings on every GET.

View file

@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders as render } from "@/../tests/test-utils";
import MoyaiPage from "./page";
const { mockReplace, state } = vi.hoisted(() => ({
mockReplace: vi.fn(),
state: {
values: {} as Record<string, unknown>,
isLoading: false,
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "token-123", userRole: "Admin" }),
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => ({ data: { values: state.values }, isLoading: state.isLoading, refetch: vi.fn() }),
}));
vi.mock("@/utils/uiHref", () => ({ uiHref: (segment: string) => `/mocked-ui/${segment}` }));
vi.mock("@/components/navbar", () => ({ default: () => <div data-testid="navbar" /> }));
vi.mock("@/contexts/ThemeContext", () => ({
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/contexts/PluginModeContext", () => ({
PluginModeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/moyai/MoyaiLanding", () => ({ default: () => <div data-testid="moyai-landing" /> }));
describe("MoyaiPage", () => {
afterEach(() => {
state.values = {};
state.isLoading = false;
mockReplace.mockClear();
vi.unstubAllGlobals();
});
it("should show the Moyai landing page when the setting is not stored", () => {
render(<MoyaiPage />);
expect(screen.getByTestId("moyai-landing")).toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it("should redirect home instead of showing Moyai when an admin turns it off", () => {
state.values = { enable_moyai_ui: false };
render(<MoyaiPage />);
expect(screen.queryByTestId("moyai-landing")).not.toBeInTheDocument();
expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/");
});
it("should not open a connected Moyai deployment when an admin turns Moyai off", () => {
const replace = vi.fn();
vi.stubGlobal("location", { ...window.location, search: "", replace });
state.values = { enable_moyai_ui: false, moyai_url: "https://moyai.example.com" };
render(<MoyaiPage />);
expect(replace).not.toHaveBeenCalled();
expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/");
});
it("should not redirect while UI settings are loading", () => {
state.isLoading = true;
render(<MoyaiPage />);
expect(screen.queryByTestId("moyai-landing")).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
});

View file

@ -2,6 +2,7 @@
import { Suspense, useEffect, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/navigation";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import Navbar from "@/components/navbar";
@ -17,6 +18,7 @@ function MoyaiPageContent() {
const { t } = useTranslation();
const { accessToken, userRole } = useAuthorized();
const { data: uiSettings, isLoading, refetch } = useUISettings();
const router = useRouter();
const parsed = useSyncExternalStore(
() => () => {},
@ -46,8 +48,13 @@ function MoyaiPageContent() {
}, [connectedParams, refetch]);
const moyaiUrl = (uiSettings?.values?.moyai_url as string | undefined) ?? null;
const blocked = !isLoading && uiSettings?.values?.enable_moyai_ui === false;
const settingsReady = parsed && !isLoading;
useEffect(() => {
if (blocked) router.replace(uiHref(""));
}, [blocked, router]);
const settingsReady = parsed && !isLoading && !blocked;
const shouldOpenMoyai = settingsReady && !connectedParams && moyaiUrl;
useEffect(() => {
if (shouldOpenMoyai) {
@ -61,7 +68,7 @@ function MoyaiPageContent() {
};
let content: React.ReactNode = null;
if (parsed && !isLoading) {
if (settingsReady) {
if (connectedParams) {
content = (
<MoyaiConnected moyaiUrl={moyaiUrl} keyAlias={connectedParams.keyAlias} models={connectedParams.models} />

View file

@ -203,6 +203,19 @@ describe("ViewSwitcher", () => {
expect(screen.queryByRole("menuitem", { name: /Moyai/ })).not.toBeInTheDocument();
});
it.each([
["settings are still loading", () => new Promise(() => {})],
["the proxy does not report the setting", () => Promise.resolve({ values: { enable_chat_ui: false } })],
])("should keep Moyai in the switcher while %s", async (_, loadSettings) => {
getUiSettingsMock.mockImplementation(loadSettings);
const user = userEvent.setup();
renderSwitcher();
await user.click(screen.getByRole("button", { name: "Switch workspace, current: AI Gateway" }));
await user.click(await screen.findByRole("menuitem", { name: "Moyai Cloud Coding Agent" }));
expect(assignSpy).toHaveBeenCalledWith("/ui/moyai");
});
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();

View file

@ -59,7 +59,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 moyaiEnabled = uiSettings?.values?.enable_moyai_ui !== false;
const moyaiUrl = (uiSettings?.values?.moyai_url as string | undefined) ?? null;
const normalizedPathname = (pathname ?? "").replace(/\/+$/, "");