feat(ui): add i18n support with language selector and zh-CN translations

Adds react-i18next infrastructure to the dashboard as a minimal first step
for issue 19730. Covers the sidebar, navbar, user dropdown and login page
with English and Simplified Chinese translations.

Language resolution order is localStorage (litellm_ui_language) first, then
browser language (exact match, then primary subtag), then English. Only an
explicit pick in the new navbar language selector is persisted, so users who
never choose keep following their browser language. Switching language also
syncs the antd ConfigProvider locale, the dayjs global locale and the html
lang attribute; the languageChanged listener is registered before init()
because i18next emits the initial event synchronously during init.

Sidebar menu labels move from a module-level menuGroups constant to a
getMenuGroups(t) factory so labels resolve against the active language;
getAvailablePages takes the t function for the same reason.
This commit is contained in:
吹雪 2026-06-05 18:43:34 +09:00
parent 8259d6cd85
commit bfaaea8998
19 changed files with 656 additions and 122 deletions

View file

@ -20,6 +20,7 @@
"antd": "5.29.3",
"cva": "1.0.0-beta.4",
"dayjs": "1.11.19",
"i18next": "26.3.0",
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.30.1",
@ -29,6 +30,7 @@
"react": "18.3.1",
"react-copy-to-clipboard": "5.1.1",
"react-dom": "18.3.1",
"react-i18next": "17.0.8",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
@ -7392,6 +7394,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@ -7439,6 +7450,34 @@
"ms": "^2.0.0"
}
},
"node_modules/i18next": {
"version": "26.3.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -11215,6 +11254,33 @@
"react": "^18.3.1"
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@ -13388,6 +13454,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View file

@ -32,6 +32,7 @@
"antd": "5.29.3",
"cva": "1.0.0-beta.4",
"dayjs": "1.11.19",
"i18next": "26.3.0",
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.30.1",
@ -41,6 +42,7 @@
"react": "18.3.1",
"react-copy-to-clipboard": "5.1.1",
"react-dom": "18.3.1",
"react-i18next": "17.0.8",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",

View file

@ -11,9 +11,11 @@ import { InfoCircleOutlined, CloudServerOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Form, Input, Popover, Select, Space, Typography } from "antd";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useWorker } from "@/hooks/useWorker";
function LoginPageContent() {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(true);
@ -154,13 +156,10 @@ function LoginPageContent() {
</div>
<Alert
message="Admin UI Disabled"
message={t("login.adminUiDisabledTitle")}
description={
<>
<Paragraph className="text-sm">
The Admin UI has been disabled by the administrator. To re-enable it, please update the following
environment variable:
</Paragraph>
<Paragraph className="text-sm">{t("login.adminUiDisabledBody")}</Paragraph>
<Paragraph className="text-sm">
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">DISABLE_ADMIN_UI=False</code>
</Paragraph>
@ -184,25 +183,29 @@ function LoginPageContent() {
</div>
<div className="text-center">
<Title level={3}>Login</Title>
<Text type="secondary">Access your LiteLLM Admin UI.</Text>
<Title level={3}>{t("login.title")}</Title>
<Text type="secondary">{t("login.subtitle")}</Text>
</div>
<Alert
message="Default Credentials"
message={t("login.defaultCredentialsTitle")}
description={
<>
<Paragraph className="text-sm">
By default, Username is <code className="bg-gray-100 px-1 py-0.5 rounded text-xs">admin</code> and
Password is your set LiteLLM Proxy
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">MASTER_KEY</code>.
<Trans
i18nKey="login.defaultCredentialsBody"
components={{ code: <code className="bg-gray-100 px-1 py-0.5 rounded text-xs" /> }}
/>
</Paragraph>
<Paragraph className="text-sm">
Need to set UI credentials or SSO?{" "}
<a href="https://docs.litellm.ai/docs/proxy/ui" target="_blank" rel="noopener noreferrer">
Check the documentation
</a>
.
<Trans
i18nKey="login.docsHint"
components={{
docsLink: (
<a href="https://docs.litellm.ai/docs/proxy/ui" target="_blank" rel="noopener noreferrer" />
),
}}
/>
</Paragraph>
</>
}
@ -215,11 +218,11 @@ function LoginPageContent() {
<Form onFinish={handleSubmit} layout="vertical" requiredMark={false}>
{uiConfig?.is_control_plane && workers.length > 0 && (
<Form.Item label="Worker" style={{ marginBottom: 16 }}>
<Form.Item label={t("login.worker")} style={{ marginBottom: 16 }}>
<Select
value={selectedWorkerId || undefined}
onChange={(value) => setSelectedWorkerId(value)}
placeholder="Choose a worker to connect to"
placeholder={t("login.workerPlaceholder")}
size="large"
suffixIcon={<CloudServerOutlined />}
options={workers.map((w) => ({
@ -231,12 +234,12 @@ function LoginPageContent() {
)}
<Form.Item
label="Username"
label={t("login.username")}
name="username"
rules={[{ required: true, message: "Please enter your username" }]}
rules={[{ required: true, message: t("login.usernameRequired") }]}
>
<Input
placeholder="Enter your username"
placeholder={t("login.usernamePlaceholder")}
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
@ -247,12 +250,12 @@ function LoginPageContent() {
</Form.Item>
<Form.Item
label="Password"
label={t("login.password")}
name="password"
rules={[{ required: true, message: "Please enter your password" }]}
rules={[{ required: true, message: t("login.passwordRequired") }]}
>
<Input.Password
placeholder="Enter your password"
placeholder={t("login.passwordPlaceholder")}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
@ -270,14 +273,14 @@ function LoginPageContent() {
block
size="large"
>
{isLoginLoading ? "Logging in..." : "Login"}
{isLoginLoading ? t("login.loggingIn") : t("login.loginButton")}
</Button>
</Form.Item>
<Form.Item>
{!uiConfig?.sso_configured ? (
<Popover content="Please configure SSO to log in with SSO." trigger="hover">
<Popover content={t("login.ssoNotConfigured")} trigger="hover">
<Button disabled block size="large">
Login with SSO
{t("login.loginWithSso")}
</Button>
</Popover>
) : (
@ -299,7 +302,7 @@ function LoginPageContent() {
block
size="large"
>
Login with SSO
{t("login.loginWithSso")}
</Button>
)}
</Form.Item>
@ -312,9 +315,7 @@ function LoginPageContent() {
closable
message={
<Text>
Single Sign-On (SSO) is enabled. LiteLLM no longer automatically redirects to the SSO login flow upon
loading this page. To re-enable auto-redirect-to-SSO, set{" "}
<Text code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</Text> in your environment configuration.
<Trans i18nKey="login.ssoEnabledNotice" components={{ code: <Text code /> }} />
</Text>
}
/>

View file

@ -5,12 +5,13 @@ import { DownOutlined, LoadingOutlined } from "@ant-design/icons";
import { Button, Dropdown, Space, Typography } from "antd";
import type { MenuProps } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
const { Text, Title, Paragraph } = Typography;
function formatDate(dateStr: string): string {
function formatDate(dateStr: string, locale: string): string {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", {
return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
year: "numeric",
@ -18,6 +19,7 @@ function formatDate(dateStr: string): string {
}
export const BlogDropdown: React.FC = () => {
const { t, i18n } = useTranslation();
const disableBlogPosts = useDisableBlogPosts();
const { data, isLoading, isError, refetch } = useBlogPosts();
@ -36,9 +38,9 @@ export const BlogDropdown: React.FC = () => {
key: "error",
label: (
<Space>
<Text type="danger">Failed to load posts</Text>
<Text type="danger">{t("navbar.failedToLoadPosts")}</Text>
<Button size="small" onClick={() => refetch()}>
Retry
{t("navbar.retry")}
</Button>
</Space>
),
@ -46,7 +48,7 @@ export const BlogDropdown: React.FC = () => {
},
];
} else if (!data || data.posts.length === 0) {
items = [{ key: "empty", label: <Text type="secondary">No posts available</Text>, disabled: true }];
items = [{ key: "empty", label: <Text type="secondary">{t("navbar.noPostsAvailable")}</Text>, disabled: true }];
} else {
items = [
...data.posts.slice(0, 5).map((post: BlogPost) => ({
@ -57,7 +59,7 @@ export const BlogDropdown: React.FC = () => {
{post.title}
</Title>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatDate(post.date)}
{formatDate(post.date, i18n.language)}
</Text>
<Paragraph ellipsis={{ rows: 2 }}>{post.description}</Paragraph>
</a>
@ -68,7 +70,7 @@ export const BlogDropdown: React.FC = () => {
key: "view-all",
label: (
<a href="https://docs.litellm.ai/blog" target="_blank" rel="noopener noreferrer">
View all posts
{t("navbar.viewAllPosts")}
</a>
),
},
@ -79,7 +81,7 @@ export const BlogDropdown: React.FC = () => {
return (
<Dropdown menu={{ items }} trigger={["hover"]} placement="bottomRight">
<Button type="text" className={`${NAV_PRODUCT_LINK_CLASS} !border-0 !bg-transparent`}>
Blog
{t("navbar.blog")}
<DownOutlined className="text-[10px] text-gray-500" aria-hidden />
</Button>
</Dropdown>

View file

@ -2,11 +2,13 @@ import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPro
import { GithubOutlined, SlackOutlined } from "@ant-design/icons";
import { Tooltip } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
const iconBtnClass =
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 cursor-pointer";
export const CommunityEngagementButtons: React.FC = () => {
const { t } = useTranslation();
const disableShowPrompts = useDisableShowPrompts();
if (disableShowPrompts) {
@ -16,26 +18,26 @@ export const CommunityEngagementButtons: React.FC = () => {
return (
<div
className="flex items-center gap-0.5 rounded-md border border-gray-200/80 bg-gray-50 px-0.5 py-0"
aria-label="Community links"
aria-label={t("navbar.communityLinks")}
>
<Tooltip title="LiteLLM Slack community">
<Tooltip title={t("navbar.slackCommunity")}>
<a
href="https://www.litellm.ai/support"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="Join Slack"
aria-label={t("navbar.joinSlack")}
>
<SlackOutlined className="text-lg" />
</a>
</Tooltip>
<Tooltip title="LiteLLM on GitHub">
<Tooltip title={t("navbar.githubRepo")}>
<a
href="https://github.com/BerriAI/litellm"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="LiteLLM on GitHub"
aria-label={t("navbar.githubRepo")}
>
<GithubOutlined className="text-lg" />
</a>

View file

@ -0,0 +1,33 @@
import i18n, { LANGUAGE_STORAGE_KEY } from "@/lib/i18n";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import LanguageSelector from "./LanguageSelector";
describe("LanguageSelector", () => {
afterEach(async () => {
await i18n.changeLanguage("en");
window.localStorage.clear();
});
it("lists all supported languages in the dropdown", async () => {
render(<LanguageSelector />);
await userEvent.click(screen.getByRole("button", { name: "Language" }));
expect(await screen.findByText("English")).toBeInTheDocument();
expect(screen.getByText("简体中文")).toBeInTheDocument();
});
it("switches the language and persists the choice", async () => {
render(<LanguageSelector />);
await userEvent.click(screen.getByRole("button", { name: "Language" }));
await userEvent.click(await screen.findByText("简体中文"));
await waitFor(() => expect(i18n.language).toBe("zh-CN"));
expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
// The selector's own label re-renders in the new language
expect(screen.getByRole("button", { name: "语言" })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,42 @@
import { LANGUAGE_STORAGE_KEY, SUPPORTED_LANGUAGES } from "@/lib/i18n";
import { setLocalStorageItem } from "@/utils/localStorageUtils";
import { GlobalOutlined } from "@ant-design/icons";
import type { MenuProps } from "antd";
import { Button, Dropdown } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
const LanguageSelector: React.FC = () => {
const { t, i18n } = useTranslation();
const items: MenuProps["items"] = SUPPORTED_LANGUAGES.map((lang) => ({
key: lang.code,
label: lang.label,
}));
const handleSelect: MenuProps["onClick"] = ({ key }) => {
i18n.changeLanguage(key);
// Only persist explicit choices, so browser-language auto-detection
// keeps working for users who never picked a language
setLocalStorageItem(LANGUAGE_STORAGE_KEY, key);
};
return (
<Dropdown
menu={{ items, selectedKeys: [i18n.language], onClick: handleSelect }}
trigger={["click"]}
placement="bottomRight"
>
<Button
type="text"
className="!flex h-9 w-9 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700"
aria-label={t("navbar.language")}
aria-haspopup="menu"
>
<GlobalOutlined className="text-lg" />
</Button>
</Dropdown>
);
};
export default LanguageSelector;

View file

@ -21,6 +21,7 @@ import {
import type { MenuProps } from "antd";
import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
const { Text } = Typography;
@ -62,6 +63,7 @@ interface UserDropdownProps {
}
const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
const { t } = useTranslation();
const { userId, userEmail, userRole, premiumUser } = useAuthorized();
const disableShowPrompts = useDisableShowPrompts();
const disableUsageIndicator = useDisableUsageIndicator();
@ -80,7 +82,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
label: (
<Space>
<LogoutOutlined />
Logout
{t("user.logout")}
</Space>
),
onClick: onLogout,
@ -96,11 +98,11 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
</Space>
{premiumUser ? (
<Tag icon={<CrownOutlined />} color="gold">
Premium
{t("user.premium")}
</Tag>
) : (
<Tooltip title="Upgrade to Premium for advanced features" placement="left">
<Tag icon={<CrownOutlined />}>Standard</Tag>
<Tooltip title={t("user.upgradeTooltip")} placement="left">
<Tag icon={<CrownOutlined />}>{t("user.standard")}</Tag>
</Tooltip>
)}
</Space>
@ -108,7 +110,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<UserOutlined />
<Text type="secondary">User ID</Text>
<Text type="secondary">{t("user.userId")}</Text>
</Space>
<Text copyable ellipsis style={{ maxWidth: "150px" }} title={userId || "-"}>
{userId || "-"}
@ -117,13 +119,13 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<SafetyOutlined />
<Text type="secondary">Role</Text>
<Text type="secondary">{t("user.role")}</Text>
</Space>
<Text>{userRole}</Text>
</Space>
<Divider style={{ margin: "8px 0" }} />
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide New Feature Indicators</Text>
<Text type="secondary">{t("user.hideNewFeatureIndicators")}</Text>
<Switch
size="small"
checked={disableShowNewBadge}
@ -141,7 +143,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide All Prompts</Text>
<Text type="secondary">{t("user.hideAllPrompts")}</Text>
<Switch
size="small"
checked={disableShowPrompts}
@ -158,7 +160,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide Usage Indicator</Text>
<Text type="secondary">{t("user.hideUsageIndicator")}</Text>
<Switch
size="small"
checked={disableUsageIndicator}
@ -175,7 +177,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide Blog Posts</Text>
<Text type="secondary">{t("user.hideBlogPosts")}</Text>
<Switch
size="small"
checked={disableBlogPosts}
@ -192,7 +194,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide Bouncing Icon</Text>
<Text type="secondary">{t("user.hideBouncingIcon")}</Text>
<Switch
size="small"
checked={disableBouncingIcon}

View file

@ -3,6 +3,7 @@
import { getAvailablePages } from "@/components/page_utils";
import { Button, Checkbox, Collapse, Space, Tag, Typography } from "antd";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
interface PageVisibilitySettingsProps {
enabledPagesInternalUsers: string[] | null | undefined;
@ -17,11 +18,13 @@ export default function PageVisibilitySettings({
isUpdating,
onUpdate,
}: PageVisibilitySettingsProps) {
const { t } = useTranslation();
// Check if page visibility is set (null/undefined means "not set" = all pages visible)
const isPageVisibilitySet = enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined;
// Get available pages from leftnav configuration
const availablePages = useMemo(() => getAvailablePages(), []);
const availablePages = useMemo(() => getAvailablePages(t), [t]);
// Group pages by their group for better UI
const pagesByGroup = useMemo(() => {

View file

@ -31,7 +31,9 @@ import {
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { ConfigProvider, Layout, Menu } from "antd";
import type { TFunction } from "i18next";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
all_admin_roles,
internalUserRoles,
@ -99,28 +101,29 @@ interface MenuGroup {
roles?: string[];
}
// Menu groups organized by category - defined outside component for export
const menuGroups: MenuGroup[] = [
// Menu groups organized by category; built through a factory so labels resolve
// against the active language
const getMenuGroups = (t: TFunction): MenuGroup[] => [
{
groupLabel: "AI GATEWAY",
groupLabel: t("nav.groups.aiGateway"),
items: [
{
key: "api-keys",
page: "api-keys",
label: "Virtual Keys",
label: t("nav.virtualKeys"),
icon: <KeyOutlined />,
},
{
key: "llm-playground",
page: "llm-playground",
label: "Playground",
label: t("nav.playground"),
icon: <PlayCircleOutlined />,
roles: rolesWithWriteAccess,
},
{
key: "models",
page: "models",
label: "Models + Endpoints",
label: t("nav.modelsEndpoints"),
icon: <BlockOutlined />,
// Admin Viewer can view models read-only (write actions are
// hidden inside the page); Playground above stays write-only.
@ -129,13 +132,13 @@ const menuGroups: MenuGroup[] = [
{
key: "agentic",
page: "agentic",
label: "Agentic",
label: t("nav.agentic"),
icon: <RobotOutlined />,
children: [
{
key: "agents",
page: "agents",
label: "Agents",
label: t("nav.agents"),
icon: <RobotOutlined />,
// Admin Viewer can view agents read-only (write actions are
// hidden inside the page); Playground above stays write-only.
@ -144,13 +147,13 @@ const menuGroups: MenuGroup[] = [
{
key: "workflows",
page: "workflows",
label: "Workflow Runs",
label: t("nav.workflowRuns"),
icon: <ApartmentOutlined />,
},
{
key: "memory",
page: "memory",
label: "Memory",
label: t("nav.memory"),
icon: <BookOutlined />,
},
],
@ -158,51 +161,51 @@ const menuGroups: MenuGroup[] = [
{
key: "mcp-servers",
page: "mcp-servers",
label: "MCP Servers",
label: t("nav.mcpServers"),
icon: <ToolOutlined />,
},
{
key: "skills",
page: "skills",
label: "Skills",
label: t("nav.skills"),
icon: <ApiOutlined />,
roles: all_admin_roles,
},
{
key: "guardrails",
page: "guardrails",
label: "Guardrails",
label: t("nav.guardrails"),
icon: <SafetyOutlined />,
},
{
key: "policies",
page: "policies",
label: <span className="flex items-center gap-4">Policies</span>,
label: <span className="flex items-center gap-4">{t("nav.policies")}</span>,
icon: <AuditOutlined />,
roles: all_admin_roles,
},
{
key: "tools",
page: "tools",
label: "Tools",
label: t("nav.tools"),
icon: <ToolOutlined />,
children: [
{
key: "search-tools",
page: "search-tools",
label: "Search Tools",
label: t("nav.searchTools"),
icon: <SearchOutlined />,
},
{
key: "vector-stores",
page: "vector-stores",
label: "Vector Stores",
label: t("nav.vectorStores"),
icon: <DatabaseOutlined />,
},
{
key: "tool-policies",
page: "tool-policies",
label: "Tool Policies",
label: t("nav.toolPolicies"),
icon: <SafetyOutlined />,
},
],
@ -210,37 +213,37 @@ const menuGroups: MenuGroup[] = [
],
},
{
groupLabel: "OBSERVABILITY",
groupLabel: t("nav.groups.observability"),
items: [
{
key: "new_usage",
page: "new_usage",
icon: <BarChartOutlined />,
roles: [...all_admin_roles, ...internalUserRoles],
label: "Usage",
label: t("nav.usage"),
},
{
key: "logs",
page: "logs",
label: "Logs",
label: t("nav.logs"),
icon: <LineChartOutlined />,
},
{
key: "guardrails-monitor",
page: "guardrails-monitor",
label: "Guardrails Monitor",
label: t("nav.guardrailsMonitor"),
icon: <SafetyOutlined />,
roles: [...all_admin_roles, ...internalUserRoles],
},
],
},
{
groupLabel: "ACCESS CONTROL",
groupLabel: t("nav.groups.accessControl"),
items: [
{
key: "teams",
page: "teams",
label: "Teams",
label: t("nav.teams"),
icon: <TeamOutlined />,
},
{
@ -248,7 +251,7 @@ const menuGroups: MenuGroup[] = [
page: "projects",
label: (
<span className="flex items-center gap-2">
Projects <NewBadge />
{t("nav.projects")} <NewBadge />
</span>
),
icon: <FolderOutlined />,
@ -257,94 +260,94 @@ const menuGroups: MenuGroup[] = [
{
key: "users",
page: "users",
label: "Internal Users",
label: t("nav.internalUsers"),
icon: <UserOutlined />,
roles: all_admin_roles,
},
{
key: "organizations",
page: "organizations",
label: "Organizations",
label: t("nav.organizations"),
icon: <BankOutlined />,
roles: all_admin_roles,
},
{
key: "access-groups",
page: "access-groups",
label: "Access Groups",
label: t("nav.accessGroups"),
icon: <BlockOutlined />,
roles: all_admin_roles,
},
{
key: "budgets",
page: "budgets",
label: "Budgets",
label: t("nav.budgets"),
icon: <CreditCardOutlined />,
roles: all_admin_roles,
},
],
},
{
groupLabel: "DEVELOPER TOOLS",
groupLabel: t("nav.groups.developerTools"),
items: [
{
key: "api_ref",
page: "api_ref",
label: "API Reference",
label: t("nav.apiReference"),
icon: <ApiOutlined />,
},
{
key: "model-hub-table",
page: "model-hub-table",
label: "AI Hub",
label: t("nav.aiHub"),
icon: <AppstoreOutlined />,
},
{
key: "learning-resources",
page: "learning-resources",
label: "Learning Resources",
label: t("nav.learningResources"),
icon: <BookOutlined />,
external_url: "https://models.litellm.ai/cookbook",
},
{
key: "experimental",
page: "experimental",
label: "Experimental",
label: t("nav.experimental"),
icon: <ExperimentOutlined />,
children: [
{
key: "caching",
page: "caching",
label: "Caching",
label: t("nav.caching"),
icon: <DatabaseOutlined />,
roles: all_admin_roles,
},
{
key: "prompts",
page: "prompts",
label: "Prompts",
label: t("nav.prompts"),
icon: <FileTextOutlined />,
roles: all_admin_roles,
},
{
key: "transform-request",
page: "transform-request",
label: "API Playground",
label: t("nav.apiPlayground"),
icon: <ApiOutlined />,
roles: [...all_admin_roles, ...internalUserRoles],
},
{
key: "tag-management",
page: "tag-management",
label: "Tag Management",
label: t("nav.tagManagement"),
icon: <TagsOutlined />,
roles: all_admin_roles,
},
{
key: "4",
page: "usage",
label: "Old Usage",
label: t("nav.oldUsage"),
icon: <BarChartOutlined />,
},
],
@ -352,7 +355,7 @@ const menuGroups: MenuGroup[] = [
],
},
{
groupLabel: "SETTINGS",
groupLabel: t("nav.groups.settings"),
roles: all_admin_roles,
items: [
{
@ -360,7 +363,7 @@ const menuGroups: MenuGroup[] = [
page: "settings",
label: (
<span className="flex items-center gap-2">
Settings <NewBadge />
{t("nav.settings")} <NewBadge />
</span>
),
icon: <SettingOutlined />,
@ -369,14 +372,14 @@ const menuGroups: MenuGroup[] = [
{
key: "router-settings",
page: "router-settings",
label: "Router Settings",
label: t("nav.routerSettings"),
icon: <SettingOutlined />,
roles: all_admin_roles,
},
{
key: "logging-and-alerts",
page: "logging-and-alerts",
label: "Logging & Alerts",
label: t("nav.loggingAlerts"),
icon: <SettingOutlined />,
roles: all_admin_roles,
},
@ -385,7 +388,7 @@ const menuGroups: MenuGroup[] = [
page: "admin-panel",
label: (
<span className="flex items-center gap-2">
Admin Settings{" "}
{t("nav.adminSettings")}{" "}
<NewBadge dot>
<span />
</NewBadge>
@ -397,14 +400,14 @@ const menuGroups: MenuGroup[] = [
{
key: "cost-tracking",
page: "cost-tracking",
label: "Cost Tracking",
label: t("nav.costTracking"),
icon: <BarChartOutlined />,
roles: all_admin_roles,
},
{
key: "ui-theme",
page: "ui-theme",
label: "UI Theme",
label: t("nav.uiTheme"),
icon: <BgColorsOutlined />,
roles: all_admin_roles,
},
@ -428,6 +431,9 @@ const Sidebar: React.FC<SidebarProps> = ({
const { userId, accessToken, userRole } = useAuthorized();
const { data: organizations } = useOrganizations();
const { data: teams } = useTeams();
const { t } = useTranslation();
const menuGroups = useMemo(() => getMenuGroups(t), [t]);
// Check if user is an org_admin
const isOrgAdmin = useMemo(() => {
@ -704,5 +710,4 @@ const Sidebar: React.FC<SidebarProps> = ({
export default Sidebar;
// Also export menuGroups for advanced use cases
export { menuGroups };
export { getMenuGroups };

View file

@ -11,8 +11,10 @@ import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/
import { Tag } from "antd";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
import LanguageSelector from "./Navbar/LanguageSelector/LanguageSelector";
import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass";
import { NotificationsBell } from "./Navbar/NotificationsBell/NotificationsBell";
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
@ -35,6 +37,7 @@ const Navbar: React.FC<NavbarProps> = ({
sidebarCollapsed = false,
onToggleSidebar,
}) => {
const { t } = useTranslation();
const baseUrl = getProxyBaseUrl();
const [logoutUrl, setLogoutUrl] = useState("");
const { logoUrl } = useTheme();
@ -89,7 +92,7 @@ const Navbar: React.FC<NavbarProps> = ({
<button
onClick={onToggleSidebar}
className="mr-2 flex h-9 w-9 items-center justify-center rounded-md text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
title={sidebarCollapsed ? t("navbar.expandSidebar") : t("navbar.collapseSidebar")}
>
<span className="text-lg">{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}</span>
</button>
@ -113,7 +116,7 @@ const Navbar: React.FC<NavbarProps> = ({
<span
className="absolute -left-2 -top-1 animate-bounce text-lg"
style={{ animationDuration: "2s" }}
title="Thanks for using LiteLLM!"
title={t("navbar.thanksForUsing")}
>
🌑
</span>
@ -150,13 +153,17 @@ const Navbar: React.FC<NavbarProps> = ({
rel="noopener noreferrer"
className={NAV_PRODUCT_LINK_CLASS}
>
Docs
{t("navbar.docs")}
{/* Layout parity with Blog chevron — intentional single-level link */}
<DownOutlined className="pointer-events-none text-[10px] opacity-0" aria-hidden />
</a>
<BlogDropdown />
</nav>
<div className="flex shrink-0 items-center border-l border-gray-200 pl-4">
<LanguageSelector />
</div>
{!hideCommunityLinks && (
<div className="flex shrink-0 items-center border-l border-gray-200 pl-4">
<CommunityEngagementButtons />

View file

@ -4,11 +4,15 @@
*/
import { describe, it, expect } from "vitest";
import i18n from "@/lib/i18n";
import { getAvailablePages } from "./page_utils";
import { menuGroups } from "./leftnav";
import { getMenuGroups } from "./leftnav";
import { pageDescriptions } from "./page_metadata";
import { internalUserRoles } from "@/utils/roles";
const t = i18n.t;
const menuGroups = getMenuGroups(t);
/**
* Check if a page is accessible to internal users
* A page is accessible if:
@ -26,7 +30,7 @@ const isPageAccessibleToInternalUsers = (pageRoles?: string[]): boolean => {
describe("Page Utils - LeftNav Sync", () => {
it("should return all pages from leftnav configuration", () => {
const availablePages = getAvailablePages();
const availablePages = getAvailablePages(t);
// Should have pages
expect(availablePages.length).toBeGreaterThan(0);
@ -45,7 +49,7 @@ describe("Page Utils - LeftNav Sync", () => {
});
it("should include all navigable pages from menuGroups", () => {
const availablePages = getAvailablePages();
const availablePages = getAvailablePages(t);
const availablePageKeys = availablePages.map((p) => p.page);
// Collect all page keys from menuGroups (excluding parent containers and pages not accessible to internal users)
@ -71,14 +75,14 @@ describe("Page Utils - LeftNav Sync", () => {
// Every menu page accessible to internal users should be in available pages
menuPageKeys.forEach((pageKey) => {
expect(availablePageKeys, `Page "${pageKey}" from menuGroups should be in getAvailablePages() output`).toContain(
expect(availablePageKeys, `Page "${pageKey}" from menuGroups should be in getAvailablePages(t) output`).toContain(
pageKey,
);
});
});
it("should not include parent container pages (tools, experimental, settings)", () => {
const availablePages = getAvailablePages();
const availablePages = getAvailablePages(t);
const availablePageKeys = availablePages.map((p) => p.page);
const excludedParents = ["tools", "experimental", "settings"];
@ -89,7 +93,7 @@ describe("Page Utils - LeftNav Sync", () => {
});
it("should have descriptions for all pages", () => {
const availablePages = getAvailablePages();
const availablePages = getAvailablePages(t);
availablePages.forEach((page) => {
expect(page.description, `Page "${page.page}" should have a description`).toBeTruthy();
@ -167,7 +171,7 @@ describe("Page Utils - LeftNav Sync", () => {
});
it("should have proper group hierarchy for nested pages", () => {
const availablePages = getAvailablePages();
const availablePages = getAvailablePages(t);
// Find pages that should be nested (children of Tools, Experimental, Settings)
const nestedPages = availablePages.filter((page) => page.group.includes(" > "));
@ -188,7 +192,7 @@ describe("Page Utils - LeftNav Sync", () => {
});
it("should have unique page keys", () => {
const availablePages = getAvailablePages();
const availablePages = getAvailablePages(t);
const pageKeys = availablePages.map((p) => p.page);
const uniquePageKeys = new Set(pageKeys);
@ -196,7 +200,7 @@ describe("Page Utils - LeftNav Sync", () => {
});
it("should match the structure expected by PageVisibilitySettings component", () => {
const availablePages = getAvailablePages();
const availablePages = getAvailablePages(t);
// Group pages by their group (same logic as in PageVisibilitySettings)
const grouped: Record<string, typeof availablePages> = {};

View file

@ -2,7 +2,8 @@
* Utility functions for working with navigation pages
*/
import { menuGroups } from "./leftnav";
import type { TFunction } from "i18next";
import { getMenuGroups } from "./leftnav";
import { pageDescriptions, PageMetadata } from "./page_metadata";
import { internalUserRoles } from "@/utils/roles";
@ -29,10 +30,10 @@ const isPageAccessibleToInternalUsers = (pageRoles?: string[]): boolean => {
* Pages restricted to admin-only roles are excluded because internal users
* cannot see them regardless of the UI visibility setting.
*/
export const getAvailablePages = (): PageMetadata[] => {
export const getAvailablePages = (t: TFunction): PageMetadata[] => {
const pages: PageMetadata[] = [];
menuGroups.forEach((group) => {
getMenuGroups(t).forEach((group) => {
group.items.forEach((item) => {
// Add top-level items (skip parent containers like 'tools', 'experimental', 'settings')
// Also skip items that internal users cannot access

View file

@ -1,14 +1,22 @@
"use client";
import "@/lib/i18n";
import React, { useEffect, useRef } from "react";
import { notification, message } from "antd";
import { notification, message, ConfigProvider } from "antd";
import type { Locale } from "antd/es/locale";
import zhCN from "antd/locale/zh_CN";
import { useTranslation } from "react-i18next";
import { setNotificationInstance } from "@/components/molecules/notifications_manager";
import { setMessageInstance } from "@/components/molecules/message_manager";
// antd falls back to its built-in English locale when undefined
const ANTD_LOCALES: Record<string, Locale> = { "zh-CN": zhCN };
export default function AntdGlobalProvider({ children }: { children: React.ReactNode }) {
const [notificationApi, notificationContextHolder] = notification.useNotification();
const [messageApi, messageContextHolder] = message.useMessage();
const initialized = useRef(false);
const { i18n } = useTranslation();
useEffect(() => {
if (!initialized.current) {
@ -19,10 +27,10 @@ export default function AntdGlobalProvider({ children }: { children: React.React
}, [notificationApi, messageApi]);
return (
<>
<ConfigProvider locale={ANTD_LOCALES[i18n.language]}>
{notificationContextHolder}
{messageContextHolder}
{children}
</>
</ConfigProvider>
);
}

View file

@ -0,0 +1,87 @@
import dayjs from "dayjs";
import { afterEach, describe, expect, it, vi } from "vitest";
import en from "@/locales/en.json";
import zhCN from "@/locales/zh-CN.json";
import i18n, { detectLanguage, LANGUAGE_STORAGE_KEY } from "./i18n";
const mockBrowserLanguage = (language: string) => {
vi.spyOn(window.navigator, "language", "get").mockReturnValue(language);
};
// Must run before any test changes the language: this fresh test environment
// imported @/lib/i18n via setupTests, so the initial language's side effects
// must already be applied. Catches listeners registered after init(), which
// miss the languageChanged event that init() emits synchronously.
it("applies the detected language's side effects at module load", () => {
expect(i18n.language).not.toBe("");
expect(document.documentElement.lang).toBe(i18n.language);
});
describe("detectLanguage", () => {
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it("prefers a supported language stored in localStorage", () => {
mockBrowserLanguage("en-US");
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
expect(detectLanguage()).toBe("zh-CN");
});
it("ignores an unsupported stored value and falls back to the browser language", () => {
mockBrowserLanguage("zh-CN");
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "fr");
expect(detectLanguage()).toBe("zh-CN");
});
it("matches the browser language exactly", () => {
mockBrowserLanguage("zh-CN");
expect(detectLanguage()).toBe("zh-CN");
});
it("matches by primary subtag when there is no exact match", () => {
mockBrowserLanguage("zh-TW");
expect(detectLanguage()).toBe("zh-CN");
});
it("defaults to English for unsupported browser languages", () => {
mockBrowserLanguage("fr-FR");
expect(detectLanguage()).toBe("en");
});
});
describe("languageChanged side effects", () => {
afterEach(async () => {
await i18n.changeLanguage("en");
});
it("syncs the html lang attribute and dayjs locale", async () => {
await i18n.changeLanguage("zh-CN");
expect(document.documentElement.lang).toBe("zh-CN");
expect(dayjs.locale()).toBe("zh-cn");
await i18n.changeLanguage("en");
expect(document.documentElement.lang).toBe("en");
expect(dayjs.locale()).toBe("en");
});
it("translates keys after switching language", async () => {
expect(i18n.t("nav.virtualKeys")).toBe("Virtual Keys");
await i18n.changeLanguage("zh-CN");
expect(i18n.t("nav.virtualKeys")).toBe("虚拟密钥");
});
});
describe("locale files", () => {
const flattenKeys = (obj: Record<string, unknown>, prefix = ""): string[] =>
Object.entries(obj).flatMap(([key, value]) =>
typeof value === "object" && value !== null
? flattenKeys(value as Record<string, unknown>, `${prefix}${key}.`)
: [`${prefix}${key}`],
);
it("keeps en and zh-CN key sets identical, so fallbackLng never papers over a missing translation", () => {
expect(flattenKeys(zhCN).sort()).toEqual(flattenKeys(en).sort());
});
});

View file

@ -0,0 +1,57 @@
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "@/locales/en.json";
import zhCN from "@/locales/zh-CN.json";
import { getLocalStorageItem } from "@/utils/localStorageUtils";
export const LANGUAGE_STORAGE_KEY = "litellm_ui_language";
export const SUPPORTED_LANGUAGES = [
{ code: "en", label: "English" },
{ code: "zh-CN", label: "简体中文" },
] as const;
type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]["code"];
const DAYJS_LOCALES: Record<string, string> = { "zh-CN": "zh-cn" };
export function detectLanguage(): LanguageCode {
if (typeof window === "undefined") return "en";
const stored = getLocalStorageItem(LANGUAGE_STORAGE_KEY);
const fromStorage = SUPPORTED_LANGUAGES.find((lang) => lang.code === stored);
if (fromStorage) return fromStorage.code;
const browser = window.navigator.language;
const exact = SUPPORTED_LANGUAGES.find((lang) => lang.code === browser);
if (exact) return exact.code;
const primarySubtag = browser?.split("-")[0];
const byPrimarySubtag = SUPPORTED_LANGUAGES.find((lang) => lang.code.split("-")[0] === primarySubtag);
return byPrimarySubtag?.code ?? "en";
}
// Registered before init so the initial language also syncs these side effects
i18n.on("languageChanged", (lng) => {
dayjs.locale(DAYJS_LOCALES[lng] ?? "en");
if (typeof window !== "undefined") {
document.documentElement.lang = lng;
}
});
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
"zh-CN": { translation: zhCN },
},
lng: detectLanguage(),
fallbackLng: "en",
// React already escapes interpolated values
interpolation: { escapeValue: false },
// Initialize synchronously so SSG prerendering and tests never render untranslated keys
initAsync: false,
});
export default i18n;

View file

@ -0,0 +1,101 @@
{
"nav": {
"groups": {
"aiGateway": "AI GATEWAY",
"observability": "OBSERVABILITY",
"accessControl": "ACCESS CONTROL",
"developerTools": "DEVELOPER TOOLS",
"settings": "SETTINGS"
},
"virtualKeys": "Virtual Keys",
"playground": "Playground",
"modelsEndpoints": "Models + Endpoints",
"agentic": "Agentic",
"agents": "Agents",
"workflowRuns": "Workflow Runs",
"memory": "Memory",
"mcpServers": "MCP Servers",
"skills": "Skills",
"guardrails": "Guardrails",
"policies": "Policies",
"tools": "Tools",
"searchTools": "Search Tools",
"vectorStores": "Vector Stores",
"toolPolicies": "Tool Policies",
"usage": "Usage",
"logs": "Logs",
"guardrailsMonitor": "Guardrails Monitor",
"teams": "Teams",
"projects": "Projects",
"internalUsers": "Internal Users",
"organizations": "Organizations",
"accessGroups": "Access Groups",
"budgets": "Budgets",
"apiReference": "API Reference",
"aiHub": "AI Hub",
"learningResources": "Learning Resources",
"experimental": "Experimental",
"caching": "Caching",
"prompts": "Prompts",
"apiPlayground": "API Playground",
"tagManagement": "Tag Management",
"oldUsage": "Old Usage",
"settings": "Settings",
"routerSettings": "Router Settings",
"loggingAlerts": "Logging & Alerts",
"adminSettings": "Admin Settings",
"costTracking": "Cost Tracking",
"uiTheme": "UI Theme"
},
"navbar": {
"expandSidebar": "Expand sidebar",
"collapseSidebar": "Collapse sidebar",
"docs": "Docs",
"blog": "Blog",
"thanksForUsing": "Thanks for using LiteLLM!",
"failedToLoadPosts": "Failed to load posts",
"retry": "Retry",
"noPostsAvailable": "No posts available",
"viewAllPosts": "View all posts",
"communityLinks": "Community links",
"slackCommunity": "LiteLLM Slack community",
"joinSlack": "Join Slack",
"githubRepo": "LiteLLM on GitHub",
"language": "Language"
},
"user": {
"logout": "Logout",
"premium": "Premium",
"standard": "Standard",
"upgradeTooltip": "Upgrade to Premium for advanced features",
"userId": "User ID",
"role": "Role",
"hideNewFeatureIndicators": "Hide New Feature Indicators",
"hideAllPrompts": "Hide All Prompts",
"hideUsageIndicator": "Hide Usage Indicator",
"hideBlogPosts": "Hide Blog Posts",
"hideBouncingIcon": "Hide Bouncing Icon"
},
"login": {
"title": "Login",
"subtitle": "Access your LiteLLM Admin UI.",
"defaultCredentialsTitle": "Default Credentials",
"defaultCredentialsBody": "By default, Username is <code>admin</code> and Password is your set LiteLLM Proxy <code>MASTER_KEY</code>.",
"docsHint": "Need to set UI credentials or SSO? <docsLink>Check the documentation</docsLink>.",
"worker": "Worker",
"workerPlaceholder": "Choose a worker to connect to",
"username": "Username",
"usernamePlaceholder": "Enter your username",
"usernameRequired": "Please enter your username",
"password": "Password",
"passwordPlaceholder": "Enter your password",
"passwordRequired": "Please enter your password",
"loginButton": "Login",
"loggingIn": "Logging in...",
"loginWithSso": "Login with SSO",
"ssoNotConfigured": "Please configure SSO to log in with SSO.",
"adminUiDisabledTitle": "Admin UI Disabled",
"adminUiDisabledBody": "The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:",
"ssoEnabledNotice": "Single Sign-On (SSO) is enabled. LiteLLM no longer automatically redirects to the SSO login flow upon loading this page. To re-enable auto-redirect-to-SSO, set <code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code> in your environment configuration."
}
}

View file

@ -0,0 +1,101 @@
{
"nav": {
"groups": {
"aiGateway": "AI 网关",
"observability": "可观测性",
"accessControl": "访问控制",
"developerTools": "开发者工具",
"settings": "设置"
},
"virtualKeys": "虚拟密钥",
"playground": "Playground",
"modelsEndpoints": "模型与端点",
"agentic": "智能体",
"agents": "Agents",
"workflowRuns": "工作流运行",
"memory": "记忆",
"mcpServers": "MCP 服务器",
"skills": "技能",
"guardrails": "安全护栏",
"policies": "策略",
"tools": "工具",
"searchTools": "搜索工具",
"vectorStores": "向量存储",
"toolPolicies": "工具策略",
"usage": "用量",
"logs": "日志",
"guardrailsMonitor": "护栏监控",
"teams": "团队",
"projects": "项目",
"internalUsers": "内部用户",
"organizations": "组织",
"accessGroups": "访问组",
"budgets": "预算",
"apiReference": "API 参考",
"aiHub": "AI Hub",
"learningResources": "学习资源",
"experimental": "实验性功能",
"caching": "缓存",
"prompts": "提示词",
"apiPlayground": "API Playground",
"tagManagement": "标签管理",
"oldUsage": "旧版用量",
"settings": "设置",
"routerSettings": "路由设置",
"loggingAlerts": "日志与告警",
"adminSettings": "管理员设置",
"costTracking": "成本追踪",
"uiTheme": "界面主题"
},
"navbar": {
"expandSidebar": "展开侧边栏",
"collapseSidebar": "收起侧边栏",
"docs": "文档",
"blog": "博客",
"thanksForUsing": "感谢使用 LiteLLM!",
"failedToLoadPosts": "加载文章失败",
"retry": "重试",
"noPostsAvailable": "暂无文章",
"viewAllPosts": "查看全部文章",
"communityLinks": "社区链接",
"slackCommunity": "LiteLLM Slack 社区",
"joinSlack": "加入 Slack",
"githubRepo": "LiteLLM 的 GitHub 仓库",
"language": "语言"
},
"user": {
"logout": "退出登录",
"premium": "高级版",
"standard": "标准版",
"upgradeTooltip": "升级到高级版以使用高级功能",
"userId": "用户 ID",
"role": "角色",
"hideNewFeatureIndicators": "隐藏新功能标记",
"hideAllPrompts": "隐藏所有提示",
"hideUsageIndicator": "隐藏用量指示器",
"hideBlogPosts": "隐藏博客文章",
"hideBouncingIcon": "隐藏跳动图标"
},
"login": {
"title": "登录",
"subtitle": "访问您的 LiteLLM 管理界面。",
"defaultCredentialsTitle": "默认凭据",
"defaultCredentialsBody": "默认情况下,用户名为 <code>admin</code>,密码为您设置的 LiteLLM Proxy <code>MASTER_KEY</code>。",
"docsHint": "需要配置 UI 凭据或 SSO?<docsLink>查看文档</docsLink>。",
"worker": "Worker",
"workerPlaceholder": "选择要连接的 worker",
"username": "用户名",
"usernamePlaceholder": "请输入用户名",
"usernameRequired": "请输入用户名",
"password": "密码",
"passwordPlaceholder": "请输入密码",
"passwordRequired": "请输入密码",
"loginButton": "登录",
"loggingIn": "登录中...",
"loginWithSso": "使用 SSO 登录",
"ssoNotConfigured": "请先配置 SSO 后再使用 SSO 登录。",
"adminUiDisabledTitle": "管理界面已禁用",
"adminUiDisabledBody": "管理员已禁用管理界面。如需重新启用,请更新以下环境变量:",
"ssoEnabledNotice": "单点登录(SSO)已启用。LiteLLM 不再在加载此页面时自动跳转到 SSO 登录流程。如需重新启用自动跳转,请在环境配置中设置 <code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code>。"
}
}

View file

@ -1,4 +1,5 @@
import "@testing-library/jest-dom";
import "@/lib/i18n";
import { cleanup } from "@testing-library/react";
import React from "react";
import { afterEach, vi } from "vitest";