This commit is contained in:
jesus-berri 2026-11-04 18:03:03 +00:00 • committed by GitHub
commit fc3a554e5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 200 additions and 22 deletions

View file

@ -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,
}

View file

@ -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.

View file

@ -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();

View file

@ -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: (
<div className="flex w-full items-center justify-between gap-6 py-0.5">
<span className="flex flex-col">
<span className="font-medium">Moyai</span>{" "}
<span className="text-xs leading-snug text-muted-foreground">Cloud Coding Agent</span>
</span>
{isMoyaiRoute && <Check className="size-4 text-info" />}
</div>
),
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: (
<div className="flex w-full items-center justify-between gap-6 py-0.5">
<span className="flex flex-col">
<span className="font-medium">Moyai</span>{" "}
<span className="text-xs leading-snug text-muted-foreground">Cloud Coding Agent</span>
</span>
{isMoyaiRoute && <Check className="size-4 text-info" />}
</div>
),
onClick: () => window.location.assign(moyaiUrl ?? uiHref(MOYAI)),
},
...(moyaiEnabled ? [moyaiItem] : []),
];
return (

View file

@ -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(<UISettings />);
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(<UISettings />);
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(<UISettings />);
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(<UISettings />);
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?.();

View file

@ -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")
}
/>
<div className="space-y-1">
<SettingRow
checked={isMoyaiEnabled}
disabled={isUpdating || moyaiConfigOwned}
onCheckedChange={handleToggleEnableMoyaiUI}
ariaLabel={t("adminPanel.uiSettings.settings.enableMoyai.ariaLabel")}
label={t("adminPanel.uiSettings.settings.enableMoyai.label")}
description={
enableMoyaiUIProperty?.description ?? t("adminPanel.uiSettings.settings.enableMoyai.description")
}
/>
{moyaiConfigOwned && (
<p className="ml-11 text-sm text-muted-foreground">
{t("adminPanel.uiSettings.settings.enableMoyai.managedByConfig")}
</p>
)}
</div>
<BackgroundChatSettings />
@ -454,17 +496,14 @@ export default function UISettings() {
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Moyai</p>
<p className="text-sm text-muted-foreground">
{moyaiUrl
? t("adminPanel.uiSettings.moyai.connectedTo", { url: moyaiUrl })
: t("adminPanel.uiSettings.moyai.notConnected")}
</p>
<p className="text-sm text-muted-foreground">{moyaiStatus}</p>
</div>
{moyaiUrl ? (
{moyaiUrl && (
<Button variant="outline" size="sm" disabled={isUpdating} onClick={handleDisconnectMoyai}>
{t("adminPanel.uiSettings.moyai.disconnect")}
</Button>
) : (
)}
{!moyaiUrl && isMoyaiEnabled && (
<a href={uiHref("moyai")} className="text-sm text-primary underline underline-offset-4">
{t("adminPanel.uiSettings.moyai.connectLink")}
</a>

View file

@ -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"
}