mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
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
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:
parent
2c50a44110
commit
ba2331341f
6 changed files with 121 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
69
ui/litellm-dashboard/src/app/moyai/page.test.tsx
Normal file
69
ui/litellm-dashboard/src/app/moyai/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(/\/+$/, "");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue