diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json
index 0b71b51dc3f..ebdc7f52ed4 100644
--- a/ui/litellm-dashboard/package-lock.json
+++ b/ui/litellm-dashboard/package-lock.json
@@ -22,6 +22,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dayjs": "1.11.19",
+ "i18next": "26.4.2",
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.30.1",
@@ -36,6 +37,7 @@
"react-copy-to-clipboard": "5.1.1",
"react-dom": "19.2.8",
"react-hook-form": "7.82.0",
+ "react-i18next": "17.0.13",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
@@ -402,9 +404,9 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
- "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -7287,6 +7289,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/html-parse-stringify": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
+ "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://locize.com"
+ }
+ },
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -7334,6 +7345,34 @@
"ms": "^2.0.0"
}
},
+ "node_modules/i18next": {
+ "version": "26.4.2",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
+ "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
+ "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 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -10602,6 +10641,33 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
+ "node_modules/react-i18next": {
+ "version": "17.0.13",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz",
+ "integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.7",
+ "html-parse-stringify": "^4.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.2.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "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",
@@ -12071,7 +12137,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json
index 233a0e63881..41c275039ab 100644
--- a/ui/litellm-dashboard/package.json
+++ b/ui/litellm-dashboard/package.json
@@ -38,6 +38,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dayjs": "1.11.19",
+ "i18next": "26.4.2",
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.30.1",
@@ -52,6 +53,7 @@
"react-copy-to-clipboard": "5.1.1",
"react-dom": "19.2.8",
"react-hook-form": "7.82.0",
+ "react-i18next": "17.0.13",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx
index 60db7104a03..81a4109b9c6 100644
--- a/ui/litellm-dashboard/src/app/layout.tsx
+++ b/ui/litellm-dashboard/src/app/layout.tsx
@@ -1,3 +1,4 @@
+import LanguageProvider from "@/i18n/LanguageProvider";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
@@ -28,12 +29,14 @@ export default function RootLayout({
-
-
- {children}
-
-
-
+
+
+
+ {children}
+
+
+
+
diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.integration.test.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.integration.test.tsx
index bfcc895e579..e552e8edf70 100644
--- a/ui/litellm-dashboard/src/app/login/LoginPage.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/login/LoginPage.integration.test.tsx
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "./LoginPage";
+import LanguageProvider from "@/i18n/LanguageProvider";
const mockPush = vi.fn();
const mockReplace = vi.fn();
@@ -60,7 +61,9 @@ const renderLoginPage = () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
-
+
+
+
,
);
};
@@ -77,6 +80,7 @@ const originalLocation = window.location;
describe("LoginPage submit payload", () => {
beforeEach(() => {
vi.clearAllMocks();
+ window.localStorage.removeItem("litellm_ui_language");
WORKERS = [];
setSearch("");
(useUIConfig as ReturnType).mockReturnValue({
@@ -91,6 +95,7 @@ describe("LoginPage submit payload", () => {
});
afterEach(() => {
+ window.localStorage.removeItem("litellm_ui_language");
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
});
@@ -190,3 +195,36 @@ describe("LoginPage submit payload", () => {
expect(switchToWorkerUrl).toHaveBeenCalledWith("http://worker-a:4000");
});
});
+
+describe("localized login", () => {
+ afterEach(() => {
+ window.localStorage.removeItem("litellm_ui_language");
+ });
+
+ it("updates existing validation errors when switching language and keeps the submitted payload unchanged", async () => {
+ vi.clearAllMocks();
+ WORKERS = [];
+ setSearch("");
+ window.localStorage.removeItem("litellm_ui_language");
+ (useUIConfig as ReturnType).mockReturnValue({ data: { sso_configured: false }, isLoading: false });
+ const user = userEvent.setup();
+ renderLoginPage();
+ await user.click(await screen.findByRole("button", { name: "Login" }));
+ expect(await screen.findByText("Please enter your username")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Language" }));
+ await user.click(await screen.findByRole("menuitemradio", { name: "简体中文" }));
+ expect(await screen.findByText("请输入用户名")).toBeInTheDocument();
+ expect(screen.getByText("请输入密码")).toBeInTheDocument();
+ expect(mockMutate).not.toHaveBeenCalled();
+ fireEvent.change(screen.getByLabelText("用户名"), { target: { value: "admin" } });
+ fireEvent.change(screen.getByLabelText("密码"), { target: { value: "opaque-password" } });
+ await user.click(screen.getByRole("button", { name: "登录" }));
+ await waitFor(() => expect(mockMutate).toHaveBeenCalledTimes(1));
+ expect(mockMutate.mock.calls[0][0]).toStrictEqual({ username: "admin", password: "opaque-password", useV3: false });
+ expect(screen.getByText("MASTER_KEY")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "查看文档" })).toHaveAttribute(
+ "href",
+ "https://docs.litellm.ai/docs/proxy/ui",
+ );
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.tsx
index 11cf5ffad3d..dcdf1dbef71 100644
--- a/ui/litellm-dashboard/src/app/login/LoginPage.tsx
+++ b/ui/litellm-dashboard/src/app/login/LoginPage.tsx
@@ -1,5 +1,8 @@
"use client";
+import LanguageSwitcher from "@/components/LanguageSwitcher";
+import { Trans } from "react-i18next";
+import { useTranslation } from "@/i18n/useTranslation";
import { useLogin } from "@/app/(dashboard)/hooks/login/useLogin";
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
import LoadingScreen from "@/components/common_components/LoadingScreen";
@@ -24,14 +27,10 @@ import { useEffect, useId, useState } from "react";
import { z } from "zod/v4";
import { useWorker } from "@/hooks/useWorker";
-const loginSchema = z.object({
- username: z.string().min(1, "Please enter your username"),
- password: z.string().min(1, "Please enter your password"),
-});
-
-type LoginFormValues = z.infer;
+type LoginFormValues = { username: string; password: string };
function SsoEnabledNotice() {
+ const { t, i18n } = useTranslation();
const [dismissed, setDismissed] = useState(false);
if (dismissed) {
@@ -42,13 +41,15 @@ function 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{" "}
- AUTO_REDIRECT_UI_LOGIN_TO_SSO=true in your
- environment configuration.
+ }}
+ />
- setDismissed(true)}>
+ setDismissed(true)}>
@@ -57,6 +58,11 @@ function SsoEnabledNotice() {
}
function LoginPageContent() {
+ const { t, i18n } = useTranslation();
+ const loginSchema = z.object({
+ username: z.string().min(1, t("Please enter your username")),
+ password: z.string().min(1, t("Please enter your password")),
+ });
const [isLoading, setIsLoading] = useState(true);
const { data: uiConfig, isLoading: isConfigLoading } = useUIConfig();
const loginMutation = useLogin();
@@ -66,6 +72,12 @@ function LoginPageContent() {
const workerFieldId = useId();
const form = useZodForm(loginSchema, { defaultValues: { username: "", password: "" } });
+ useEffect(() => {
+ if (form.getFieldState("username").error || form.getFieldState("password").error) {
+ void form.trigger();
+ }
+ }, [form, t]);
+
// Pre-select worker from URL param (e.g. /ui/login?worker=team-b)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -190,6 +202,9 @@ function LoginPageContent() {
+
+
+
🚅 LiteLLM
@@ -197,11 +212,12 @@ function LoginPageContent() {
- Admin UI Disabled
+ {t("Admin UI Disabled")}
- The Admin UI has been disabled by the administrator. To re-enable it, please update the following
- environment variable:
+ {t(
+ "The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:",
+ )}{" "}
DISABLE_ADMIN_UI=False
@@ -219,6 +235,9 @@ function LoginPageContent() {
+
+
+
@@ -226,26 +245,34 @@ function LoginPageContent() {
-
Login
-
Access your LiteLLM Admin UI.
+
{t("Login")}
+
{t("Access your LiteLLM Admin UI.")}
{!uiConfig?.hide_default_credentials_hint && (
- Default Credentials
+ {t("Default Credentials")}
- By default, Username is admin and
- Password is your set LiteLLM Proxy
- MASTER_KEY.
+ }}
+ />
- Need to set UI credentials or SSO?{" "}
-
- Check the documentation
-
- .
+
+ ),
+ }}
+ />
@@ -262,14 +289,14 @@ function LoginPageContent() {
{uiConfig?.is_control_plane && workers.length > 0 && (
- Worker
+ {t("Worker")}
({ label: worker.name, value: worker.worker_id }))}
value={selectedWorkerId}
onValueChange={(value: string | null) => setSelectedWorkerId(value)}
>
-
+
{workers.map((worker) => (
@@ -282,12 +309,12 @@ function LoginPageContent() {
)}
-
+
{({ ref, ...field }) => (
-
+
{({ ref, ...field }) => (
- {isLoginLoading && }
- {isLoginLoading ? "Logging in..." : "Login"}
+ {isLoginLoading && }
+ {isLoginLoading ? t("Logging in...") : t("Login")}
{!uiConfig?.sso_configured ? (
}>
- Login with SSO
+ {t("Login with SSO")}
- Please configure SSO to log in with SSO.
+ {t("Please configure SSO to log in with SSO.")}
) : (
- Login with SSO
+ {t("Login with SSO")}
)}
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx
index 57d734d05b3..5fb63c08397 100644
--- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx
+++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx
@@ -1,5 +1,7 @@
"use client";
+import LanguageSwitcher from "@/components/LanguageSwitcher";
+import { useTranslation } from "@/i18n/useTranslation";
import {
Breadcrumb,
BreadcrumbItem,
@@ -25,6 +27,7 @@ import { usePathname } from "next/navigation";
// Top bar for the dashboard shell. Sits only over the content column (the brand
// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout.
export function DashboardHeader() {
+ const { t } = useTranslation();
const { title } = getBreadcrumb(usePathname());
const { isControlPlane, selectedWorker } = useWorker();
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
@@ -47,7 +50,7 @@ export function DashboardHeader() {
- {title}
+ {t(title)}
@@ -63,6 +66,7 @@ export function DashboardHeader() {
{!hideCommunityLinks &&
}
+
diff --git a/ui/litellm-dashboard/src/components/LanguageSwitcher.tsx b/ui/litellm-dashboard/src/components/LanguageSwitcher.tsx
new file mode 100644
index 00000000000..c95565e1869
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/LanguageSwitcher.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { Languages } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { isLanguage, LANGUAGE_STORAGE_KEY } from "@/i18n";
+import { useTranslation } from "@/i18n/useTranslation";
+
+export default function LanguageSwitcher() {
+ const { t, i18n } = useTranslation();
+
+ const selectLanguage = (language: string) => {
+ if (!isLanguage(language)) return;
+ void i18n.changeLanguage(language);
+ try {
+ window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
+ } catch {}
+ };
+
+ return (
+
+ }
+ >
+
+
+
+
+
+ English
+
+
+ 简体中文
+
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.integration.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.integration.test.tsx
new file mode 100644
index 00000000000..f3ef3f267d8
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.integration.test.tsx
@@ -0,0 +1,80 @@
+import { act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { I18nextProvider } from "react-i18next";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createDashboardI18n } from "@/i18n";
+import { renderWithProviders, screen } from "../../../../tests/test-utils";
+import { BlogDropdown } from "./BlogDropdown";
+
+const mockRefetch = vi.fn();
+let mockIsError = false;
+
+vi.mock("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({
+ useDisableBlogPosts: () => false,
+}));
+
+vi.mock("@/app/(dashboard)/hooks/blogPosts/useBlogPosts", () => ({
+ useBlogPosts: () => ({
+ data: {
+ posts: [{ title: "Post One", date: "2026-02-01", description: "Description one", url: "https://example.com/1" }],
+ },
+ isLoading: false,
+ isError: mockIsError,
+ refetch: mockRefetch,
+ }),
+}));
+
+async function openDropdown() {
+ await userEvent.setup().hover(screen.getByRole("button", { name: "Blog" }));
+}
+
+describe("blog dropdown language changes", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockIsError = false;
+ });
+
+ it("should translate Retry after switching languages and still refetch posts", async () => {
+ mockIsError = true;
+ const user = userEvent.setup();
+ const i18n = createDashboardI18n();
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await openDropdown();
+ expect(await screen.findByRole("button", { name: "Retry" })).toBeInTheDocument();
+ await act(async () => {
+ await i18n.changeLanguage("zh-CN");
+ });
+ expect(screen.getByText("加载文章失败")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "重试" }));
+ expect(mockRefetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("should update visible dates when switching between English and Chinese", async () => {
+ const i18n = createDashboardI18n();
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await openDropdown();
+ expect(await screen.findByText("Feb 1, 2026")).toBeInTheDocument();
+ await act(async () => {
+ await i18n.changeLanguage("zh-CN");
+ });
+ expect(screen.getByText("2026年2月1日")).toBeInTheDocument();
+ expect(screen.queryByText("Feb 1, 2026")).not.toBeInTheDocument();
+ expect(screen.getByText("Post One")).toBeInTheDocument();
+ await act(async () => {
+ await i18n.changeLanguage("en");
+ });
+ expect(screen.getByText("Feb 1, 2026")).toBeInTheDocument();
+ expect(screen.queryByText("2026年2月1日")).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx
index eaaf3301a8b..19880e07b16 100644
--- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "@/i18n/useTranslation";
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
@@ -12,9 +13,9 @@ import {
import { ChevronDown, LoaderCircle } from "lucide-react";
import React from "react";
-function formatDate(dateStr: string): string {
+function formatDate(dateStr: string, language: string): string {
const date = new Date(dateStr + "T00:00:00");
- return date.toLocaleDateString("en-US", {
+ return date.toLocaleDateString(language === "zh-CN" ? "zh-CN" : "en-US", {
month: "short",
day: "numeric",
year: "numeric",
@@ -22,6 +23,7 @@ function formatDate(dateStr: string): string {
}
export const BlogDropdown: React.FC = () => {
+ const { t, i18n } = useTranslation();
const disableBlogPosts = useDisableBlogPosts();
const { data, isLoading, isError, refetch } = useBlogPosts();
@@ -34,7 +36,7 @@ export const BlogDropdown: React.FC = () => {
if (isLoading) {
return (
-
+
);
}
@@ -42,16 +44,16 @@ export const BlogDropdown: React.FC = () => {
if (isError) {
return (
- Failed to load posts
+ {t("Failed to load posts")}
refetch()}>
- Retry
+ {t("Retry")}
);
}
if (!data || data.posts.length === 0) {
- return No posts available
;
+ return {t("No posts available")}
;
}
return (
@@ -63,7 +65,7 @@ export const BlogDropdown: React.FC = () => {
{post.title}
- {formatDate(post.date)}
+ {formatDate(post.date, i18n.language)}
{post.description}
@@ -72,7 +74,7 @@ export const BlogDropdown: React.FC = () => {
- View all posts
+ {t("View all posts")}
>
@@ -87,8 +89,7 @@ export const BlogDropdown: React.FC = () => {
closeDelay={100}
render={ }
>
- Blog
-
+ {t("Blog")}
{renderMenuContent()}
diff --git a/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx b/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx
index 9b4f4c90b64..21118f6ff5d 100644
--- a/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "@/i18n/useTranslation";
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
import { ChevronDown } from "lucide-react";
import React from "react";
@@ -8,11 +9,13 @@ const ChevronWidthSpacer: React.FC = () => (
);
-export const DocsLink: React.FC = () => (
-
- Docs
-
-
-);
+export const DocsLink: React.FC = () => {
+ const { t } = useTranslation();
+ return (
+
+ {t("Docs")}
+
+ );
+};
export default DocsLink;
diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
index 8b9b7d1d0d2..f04ca7cad1b 100644
--- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTranslation } from "@/i18n/useTranslation";
import {
HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY,
useHideAutoRouterAnnouncement,
@@ -15,6 +16,7 @@ import React, { useState } from "react";
export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing";
export const NotificationsBell: React.FC = () => {
+ const { t } = useTranslation();
const hidden = useHideAutoRouterAnnouncement();
const hasUnread = !hidden;
const [open, setOpen] = useState(false);
@@ -27,9 +29,9 @@ export const NotificationsBell: React.FC = () => {
const content = (
-
LiteLLM Auto Router
+
{t("LiteLLM Auto Router")}
- Route every request to the cheapest model that can handle it, no prompt changes needed.
+ {t("Route every request to the cheapest model that can handle it, no prompt changes needed.")}
@@ -53,7 +55,7 @@ export const NotificationsBell: React.FC = () => {
diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx
index ab9a723e57a..8dd504ac347 100644
--- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "@/i18n/useTranslation";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
@@ -78,6 +79,7 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar
} = useAuthorized();
const router = useRouter();
const [open, setOpen] = useState(false);
+ const { t } = useTranslation();
const disableShowPrompts = useDisableShowPrompts();
const disableBlogPosts = useDisableBlogPosts();
const disableBouncingIcon = useDisableBouncingIcon();
@@ -100,16 +102,16 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar
{premiumUser ? (
- Premium
+ {t("Premium")}
) : (
}>
- Standard
+ {t("Standard")}
- Upgrade to Premium for advanced features
+ {t("Upgrade to Premium for advanced features")}
)}
@@ -118,25 +120,25 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar
- User ID
+ {t("User ID")}
{userId || "-"}
-
+
- Role
+ {t("Role")}
{userRole}
- Hide New Feature Indicators
+ {t("Hide New Feature Indicators")}
= ({ onLogout, variant = "navbar
emitLocalStorageChange("disableShowNewBadge");
}
}}
- aria-label="Toggle hide new feature indicators"
+ aria-label={t("Toggle hide new feature indicators")}
/>
- Hide All Prompts
+ {t("Hide All Prompts")}
= ({ onLogout, variant = "navbar
emitLocalStorageChange("disableShowPrompts");
}
}}
- aria-label="Toggle hide all prompts"
+ aria-label={t("Toggle hide all prompts")}
/>
- Hide Blog Posts
+ {t("Hide Blog Posts")}
= ({ onLogout, variant = "navbar
emitLocalStorageChange("disableBlogPosts");
}
}}
- aria-label="Toggle hide blog posts"
+ aria-label={t("Toggle hide blog posts")}
/>
- Hide Bouncing Icon
+ {t("Hide Bouncing Icon")}
= ({ onLogout, variant = "navbar
emitLocalStorageChange("disableBouncingIcon");
}
}}
- aria-label="Toggle hide bouncing icon"
+ aria-label={t("Toggle hide bouncing icon")}
/>
{canUseLiteAdmin && (
@@ -221,7 +223,7 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar
const seed = userEmail || userId || "user";
const initials = initialsFromIdentity(userEmail, userId);
const hue = hueFromString(seed);
- const displayName = navAccountDisplayName(userEmail, userId);
+ const displayName = navAccountDisplayName(userEmail, userId, t("Account"));
return (
@@ -234,7 +236,10 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left",
)}
- aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
+ aria-label={t("accountMenu", {
+ role: userRole ?? t("Unknown role"),
+ identity: userEmail || userId || t("unknown"),
+ })}
aria-haspopup="dialog"
title={collapsed ? displayName : undefined}
/>
@@ -261,7 +266,10 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar
}
@@ -304,7 +312,7 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
>
- Logout
+ {t("Logout")}
diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx
index b3aba7155d1..5de3420f72b 100644
--- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "@/i18n/useTranslation";
import React from "react";
import { usePathname } from "next/navigation";
import {
@@ -22,6 +23,7 @@ interface ViewSwitcherItem {
}
export default function ViewSwitcher() {
+ const { t } = useTranslation();
const { mode, setMode, plugins } = usePluginMode();
const { data: uiSettings } = useUISettings();
const pathname = usePathname();
@@ -32,10 +34,10 @@ export default function ViewSwitcher() {
const normalizedPathname = (pathname ?? "").replace(/\/+$/, "");
const isChatRoute = chatEnabled && (normalizedPathname === chatHref || normalizedPathname.startsWith(`${chatHref}/`));
- const activeLabel = isChatRoute ? "Chat" : plugins.find((p) => p.name === mode)?.display_name ?? "AI Gateway";
+ const activeLabel = isChatRoute ? t("Chat") : plugins.find((p) => p.name === mode)?.display_name ?? t("AI Gateway");
const modeEntries = [
- { key: GATEWAY, label: "AI Gateway" },
+ { key: GATEWAY, label: t("AI Gateway") },
...plugins.map((p) => ({ key: p.name, label: p.display_name })),
];
@@ -53,7 +55,7 @@ export default function ViewSwitcher() {
key: CHAT,
label: (
- Chat
+ {t("Chat")}
{isChatRoute && }
),
@@ -64,9 +66,9 @@ export default function ViewSwitcher() {
disabled: true,
label: (
- Chat
+ {t("Chat")}
- Admins can enable in Settings
+ {t("Admins can enable in Settings")}
),
diff --git a/ui/litellm-dashboard/src/components/Navbar/navDisplayName.test.ts b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.test.ts
index 96e768c5d31..f66a4b75ad0 100644
--- a/ui/litellm-dashboard/src/components/Navbar/navDisplayName.test.ts
+++ b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.test.ts
@@ -15,3 +15,9 @@ describe("navAccountDisplayName", () => {
expect(navAccountDisplayName(null, "user-uuid-123")).toBe("user-uuid-123");
});
});
+
+it("localizes only the placeholder account label, preserving real account identities", () => {
+ expect(navAccountDisplayName(null, "default_user_id", "账户")).toBe("账户");
+ expect(navAccountDisplayName(null, "Account", "账户")).toBe("Account");
+ expect(navAccountDisplayName("admin@example.com", null, "账户")).toBe("admin@example.com");
+});
diff --git a/ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts
index d6f51dc37a8..deb90ec53fb 100644
--- a/ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts
+++ b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts
@@ -1,15 +1,15 @@
/** Primary label for the navbar account control — avoids raw placeholder JWT/user IDs in the UI. */
-export function navAccountDisplayName(userEmail: string | null, userId: string | null): string {
+export function navAccountDisplayName(userEmail: string | null, userId: string | null, fallback = "Account"): string {
const email = userEmail?.trim();
if (email) {
return email;
}
const id = userId?.trim();
if (!id) {
- return "Account";
+ return fallback;
}
if (/^default[_\s-]?user[_\s-]?id$/i.test(id)) {
- return "Account";
+ return fallback;
}
return id;
}
diff --git a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx
index e580b8610d0..28615a36fe9 100644
--- a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx
+++ b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "@/i18n/useTranslation";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
@@ -97,6 +98,7 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla
} = useAuthorized();
const router = useRouter();
const [open, setOpen] = React.useState(false);
+ const { t } = useTranslation();
const { data: healthData } = useHealthReadinessDetails(accessToken);
const version = healthData?.litellm_version;
const disableShowPrompts = useDisableShowPrompts();
@@ -118,29 +120,29 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla
const toggles = [
{
key: "disableShowNewBadge",
- label: "Hide New Feature Indicators",
- ariaLabel: "Toggle hide new feature indicators",
+ label: t("Hide New Feature Indicators"),
+ ariaLabel: t("Toggle hide new feature indicators"),
checked: disableShowNewBadge,
onCheckedChange: (checked: boolean) => setFlag("disableShowNewBadge", checked),
},
{
key: "disableShowPrompts",
- label: "Hide All Prompts",
- ariaLabel: "Toggle hide all prompts",
+ label: t("Hide All Prompts"),
+ ariaLabel: t("Toggle hide all prompts"),
checked: disableShowPrompts,
onCheckedChange: (checked: boolean) => setFlag("disableShowPrompts", checked),
},
{
key: "disableBlogPosts",
- label: "Hide Blog Posts",
- ariaLabel: "Toggle hide blog posts",
+ label: t("Hide Blog Posts"),
+ ariaLabel: t("Toggle hide blog posts"),
checked: disableBlogPosts,
onCheckedChange: (checked: boolean) => setFlag("disableBlogPosts", checked),
},
{
key: "disableBouncingIcon",
- label: "Hide Bouncing Icon",
- ariaLabel: "Toggle hide bouncing icon",
+ label: t("Hide Bouncing Icon"),
+ ariaLabel: t("Toggle hide bouncing icon"),
checked: disableBouncingIcon,
onCheckedChange: (checked: boolean) => setFlag("disableBouncingIcon", checked),
},
@@ -149,8 +151,11 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla
const seed = userEmail || userId || "user";
const initials = initialsFromIdentity(userEmail, userId);
const hue = hueFromString(seed);
- const displayName = navAccountDisplayName(userEmail, userId);
- const triggerLabel = `Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`;
+ const displayName = navAccountDisplayName(userEmail, userId, t("Account"));
+ const triggerLabel = t("accountMenu", {
+ role: userRole ?? t("Unknown role"),
+ identity: userEmail || userId || t("unknown"),
+ });
return (
@@ -191,7 +196,7 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla
🌴
@@ -210,27 +215,27 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla
- } label="Tier">
+ } label={t("Tier")}>
{premiumUser ? (
- Premium
+ {t("Premium")}
) : (
-
+
- Standard
+ {t("Standard")}
)}
- } label="Role">
+ } label={t("Role")}>
{userRole}
- } label="Email">
-
+ } label={t("Email")}>
+
- } label="User ID">
-
+ } label={t("User ID")}>
+
@@ -283,7 +288,7 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla
className="h-[42px] w-full justify-start gap-2.5 rounded-none px-3 text-sm font-medium text-foreground"
>
- Logout
+ {t("Logout")}
diff --git a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx
index 2f941dbc2d2..ac1a6342356 100644
--- a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx
+++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTranslation } from "@/i18n/useTranslation";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import React from "react";
@@ -7,9 +8,10 @@ import React from "react";
import { Button } from "@/components/ui/button";
const ThemeToggle: React.FC = () => {
+ const { t } = useTranslation();
const { setTheme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
- const label = isDark ? "Switch to light mode" : "Switch to dark mode (beta)";
+ const label = isDark ? t("Switch to light mode") : t("Switch to dark mode (beta)");
return (
🚅 LiteLLM
- Loading...
+ {t("Loading...")}
);
diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx
index 5fa6e9728cf..6b098566671 100644
--- a/ui/litellm-dashboard/src/components/leftnav.test.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx
@@ -1,5 +1,7 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { I18nextProvider } from "react-i18next";
+import { createDashboardI18n } from "@/i18n";
import { renderWithProviders } from "../../tests/test-utils";
import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
@@ -623,3 +625,37 @@ describe("getBreadcrumb", () => {
expect(getBreadcrumb("/ui/some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" });
});
});
+
+describe("localized navigation", () => {
+ it("changes visible and collapsed labels while retaining routes and permission filtering", async () => {
+ const internalUser = {
+ userId: "test-user-id",
+ accessToken: "test-access-token",
+ userRole: "internal",
+ isViewOnly: false,
+ token: "test-token",
+ userEmail: "test@example.com",
+ premiumUser: false,
+ disabledPersonalKeyCreation: false,
+ showSSOBanner: false,
+ };
+ mockUseAuthorized.mockReturnValue(internalUser);
+ const instance = createDashboardI18n("zh-CN");
+ renderWithProviders(
+
+
+ ,
+ );
+ expect(screen.getByRole("link", { name: "虚拟密钥" })).toHaveAttribute("href", "/ui/api-keys");
+ expect(screen.getByRole("link", { name: "虚拟密钥" })).toHaveAttribute("title", "虚拟密钥");
+ expect(screen.getByRole("link", { name: "模型与端点" })).toHaveAttribute("href", "/ui/models-and-endpoints");
+ expect(screen.queryByRole("link", { name: "内部用户" })).not.toBeInTheDocument();
+ await act(async () => {
+ await instance.changeLanguage("en");
+ });
+ expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("href", "/ui/api-keys");
+ expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("title", "Virtual Keys");
+ expect(screen.queryByRole("link", { name: "Internal Users" })).not.toBeInTheDocument();
+ expect(menuGroups[0].items[0].label).toBe("Virtual Keys");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx
index 9d772f45153..0b4f833a9d2 100644
--- a/ui/litellm-dashboard/src/components/leftnav.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "@/i18n/useTranslation";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
@@ -102,6 +103,7 @@ interface MenuItem {
roles?: string[];
children?: MenuItem[];
icon?: React.ReactNode;
+ badge?: React.ReactNode;
external_url?: string;
}
@@ -209,11 +211,8 @@ const menuGroups: MenuGroup[] = [
page: "cost-optimization",
icon: ,
roles: [...all_admin_roles, ...internalUserRoles],
- label: (
-
- Cost Optimization
-
- ),
+ label: "Cost Optimization",
+ badge: ,
},
{ key: "logs", page: "logs", label: "Logs", icon: },
{
@@ -232,11 +231,8 @@ const menuGroups: MenuGroup[] = [
{
key: "projects",
page: "projects",
- label: (
-
- Projects
-
- ),
+ label: "Projects",
+ badge: ,
icon: ,
roles: all_admin_roles,
},
@@ -428,6 +424,7 @@ const Sidebar_: React.FC = ({
disableVectorStoresForInternalUsers,
allowVectorStoresForTeamAdmins,
}) => {
+ const { t } = useTranslation();
const { userId, accessToken, userRole, isViewOnly } = useAuthorized();
const isOrgAdmin = useIsOrgAdmin();
const { data: teams } = useTeams();
@@ -521,10 +518,24 @@ const Sidebar_: React.FC = ({
});
};
+ const translatedLabel = (item: MenuItem) => {
+ const label = typeof item.label === "string" ? t(item.label) : item.label;
+ return item.badge ? (
+
+ {label}
+ {item.badge}
+
+ ) : (
+ label
+ );
+ };
+
const renderLeaf = (item: MenuItem, isChild: boolean) => {
const active = selectedKey === item.key;
const size = isChild ? "sub" : "default";
- const label = {item.label} ;
+ const label = (
+ {translatedLabel(item)}
+ );
if (item.external_url) {
return (
@@ -533,7 +544,7 @@ const Sidebar_: React.FC = ({
href={item.external_url}
target="_blank"
rel="noopener noreferrer"
- title={collapsed ? labelText(item) : undefined}
+ title={collapsed ? t(labelText(item)) : undefined}
data-active={active || undefined}
className={cn(sidebarMenuButtonVariants({ isActive: active, size }))}
>
@@ -548,7 +559,7 @@ const Sidebar_: React.FC = ({
@@ -572,10 +583,10 @@ const Sidebar_: React.FC = ({
isActive={active}
aria-expanded={open}
onClick={() => toggleGroup(item.key)}
- title={collapsed ? labelText(item) : undefined}
+ title={collapsed ? t(labelText(item)) : undefined}
>
{item.icon}
- {item.label}
+ {translatedLabel(item)}
= ({
-
+
= ({
variant="ghost"
size="icon-sm"
onClick={onToggleCollapsed}
- aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
+ aria-label={collapsed ? t("Expand sidebar") : t("Collapse sidebar")}
className="flex-none text-muted-foreground"
>
{collapsed ?
:
}
@@ -642,7 +653,7 @@ const Sidebar_: React.FC
= ({
{visibleGroups.map((group, gi) => (
{gi > 0 && }
- {group.groupLabel}
+ {t(group.groupLabel)}
{group.items.map((item) => renderItem(item))}
))}
diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx
index 8bc9b06969e..64d05a43dfe 100644
--- a/ui/litellm-dashboard/src/components/navbar.tsx
+++ b/ui/litellm-dashboard/src/components/navbar.tsx
@@ -1,3 +1,5 @@
+import LanguageSwitcher from "@/components/LanguageSwitcher";
+import { useTranslation } from "@/i18n/useTranslation";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
@@ -37,6 +39,7 @@ const Navbar: React.FC = ({
onToggleSidebar,
}) => {
const baseUrl = getProxyBaseUrl();
+ const { t } = useTranslation();
const { logoUrl } = useTheme();
const { data: healthData } = useHealthReadinessDetails(accessToken);
const version = healthData?.litellm_version;
@@ -65,7 +68,7 @@ const Navbar: React.FC = ({
{sidebarCollapsed ? (
@@ -97,7 +100,7 @@ const Navbar: React.FC = ({
🌑
@@ -131,7 +134,7 @@ const Navbar: React.FC = ({
)}
@@ -147,6 +150,7 @@ const Navbar: React.FC = ({
{!isPublicPage && (
+
diff --git a/ui/litellm-dashboard/src/components/shared/PasswordInput.tsx b/ui/litellm-dashboard/src/components/shared/PasswordInput.tsx
index e92385de8be..962201e4d77 100644
--- a/ui/litellm-dashboard/src/components/shared/PasswordInput.tsx
+++ b/ui/litellm-dashboard/src/components/shared/PasswordInput.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTranslation } from "@/i18n/useTranslation";
import { Eye, EyeOff } from "lucide-react";
import * as React from "react";
@@ -12,6 +13,7 @@ export type PasswordInputProps = Omit
, "
export const PasswordInput = React.forwardRef(
({ className, groupClassName, disabled, ...props }, ref) => {
const [revealed, setRevealed] = React.useState(false);
+ const { t } = useTranslation();
return (
@@ -26,7 +28,7 @@ export const PasswordInput = React.forwardRef setRevealed((current) => !current)}
>
{revealed ? : }
diff --git a/ui/litellm-dashboard/src/i18n/LanguageProvider.integration.test.tsx b/ui/litellm-dashboard/src/i18n/LanguageProvider.integration.test.tsx
new file mode 100644
index 00000000000..f37abd1390c
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/LanguageProvider.integration.test.tsx
@@ -0,0 +1,82 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderToString } from "react-dom/server";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import LanguageSwitcher from "@/components/LanguageSwitcher";
+import LoadingScreen from "@/components/common_components/LoadingScreen";
+import { PasswordInput } from "@/components/shared/PasswordInput";
+import LanguageProvider from "./LanguageProvider";
+import { LANGUAGE_STORAGE_KEY } from ".";
+
+function LanguageExample() {
+ return (
+
+
+
+
+
+ );
+}
+
+async function selectChinese() {
+ const user = userEvent.setup();
+ await user.click(screen.getByRole("button", { name: "Language" }));
+ await user.click(await screen.findByRole("menuitemradio", { name: "简体中文" }));
+}
+
+beforeEach(() => {
+ window.localStorage.clear();
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ window.localStorage.clear();
+});
+
+describe("dashboard language preference", () => {
+ it("switches all consumers, preserves password fields, and restores the explicit preference after remount", async () => {
+ const user = userEvent.setup();
+ const first = render( );
+ await user.type(screen.getByLabelText("API key"), "opaque-value");
+ await selectChinese();
+ expect(screen.getByText("加载中…")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "显示密码" })).toBeInTheDocument();
+ expect(screen.getByLabelText("API key")).toHaveValue("opaque-value");
+ expect(document.documentElement).toHaveAttribute("lang", "zh-CN");
+ expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
+ first.unmount();
+ render( );
+ expect(await screen.findByRole("button", { name: "语言" })).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "语言" }));
+ expect(await screen.findByRole("menuitemradio", { name: "简体中文" })).toBeChecked();
+ await user.click(await screen.findByRole("menuitemradio", { name: "English" }));
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+ expect(document.documentElement).toHaveAttribute("lang", "en");
+ expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("en");
+ });
+
+ it("renders English on the server even when the browser has a Chinese preference", () => {
+ window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
+ expect(renderToString( )).toContain("Loading...");
+ });
+
+ it("ignores invalid stored values without changing storage or the default UI", () => {
+ window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "fr");
+ render( );
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+ expect(document.documentElement).toHaveAttribute("lang", "en");
+ expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("fr");
+ });
+
+ it("remains usable when browser storage throws on both reads and writes", async () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new DOMException("Blocked", "SecurityError");
+ });
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new DOMException("Blocked", "SecurityError");
+ });
+ render( );
+ await selectChinese();
+ expect(screen.getByText("加载中…")).toBeInTheDocument();
+ expect(document.documentElement).toHaveAttribute("lang", "zh-CN");
+ });
+});
diff --git a/ui/litellm-dashboard/src/i18n/LanguageProvider.tsx b/ui/litellm-dashboard/src/i18n/LanguageProvider.tsx
new file mode 100644
index 00000000000..c68a018b044
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/LanguageProvider.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useEffect, useState, type ReactNode } from "react";
+import { I18nextProvider } from "react-i18next";
+import { createDashboardI18n, isLanguage, LANGUAGE_STORAGE_KEY } from ".";
+
+export default function LanguageProvider({ children }: { children: ReactNode }) {
+ const [instance] = useState(createDashboardI18n);
+
+ useEffect(() => {
+ const updateDocumentLanguage = (language: string) => {
+ document.documentElement.lang = language;
+ };
+ instance.on("languageChanged", updateDocumentLanguage);
+ try {
+ const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
+ if (isLanguage(stored)) void instance.changeLanguage(stored);
+ } catch {}
+ updateDocumentLanguage(instance.language);
+ return () => {
+ instance.off("languageChanged", updateDocumentLanguage);
+ };
+ }, [instance]);
+
+ return {children} ;
+}
diff --git a/ui/litellm-dashboard/src/i18n/README.md b/ui/litellm-dashboard/src/i18n/README.md
new file mode 100644
index 00000000000..2b8692d0339
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/README.md
@@ -0,0 +1,13 @@
+# Dashboard translations
+
+The dashboard defaults to English. Select 简体中文 from the language menu on the login page or dashboard header to switch to Simplified Chinese. The explicit choice is saved in `localStorage.litellm_ui_language`. Unsupported stored values fall back to English. When browser storage is unavailable, the selection works until the page reloads
+
+This initial coverage includes login and validation, the sidebar and collapsed navigation labels, breadcrumbs, view switching, account controls, and header controls. Dashboard page bodies, server error messages, worker and plugin names, and role labels are outside this change
+
+Translations are bundled with the static export using i18next and react-i18next. Each mounted `LanguageProvider` owns its instance, starts in English for server rendering and hydration, then restores the stored preference after mounting. Changing language updates the HTML `lang` attribute without changing URLs or remounting the form. No translation service or language-specific backend is required
+
+Use `useTranslation` from `@/i18n/useTranslation` for React components. It uses the root provider, with an isolated English fallback for standalone components. Add the same key to `locales/en.json` and `locales/zh-CN.json`. Plain display text serves as its own key; longer sentences with markup use descriptive keys and `Trans`. Key and namespace separators are disabled, so punctuation in display text stays literal
+
+Translate labels at render time. Keep identifiers, URLs, form field names, permission checks, plugin names, and API payloads unchanged. Use interpolation for user values and `Trans` for inline links or code, rather than joining translated fragments. React escapes interpolated display values
+
+Chinese terminology adapts the MIT-licensed locale contributed by [2099383411 in PR #30499](https://github.com/BerriAI/litellm/pull/30499) at `e787b318a932c9c6b897830f6d01045f05bbd0a8`, with additions for the current Base UI dashboard. That PR's Ant Design integration is not included here
diff --git a/ui/litellm-dashboard/src/i18n/index.test.ts b/ui/litellm-dashboard/src/i18n/index.test.ts
new file mode 100644
index 00000000000..224dc1b601f
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/index.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { createDashboardI18n, isLanguage } from ".";
+import en from "./locales/en.json";
+import zhCN from "./locales/zh-CN.json";
+
+describe("dashboard translations", () => {
+ it("keeps complete English and Chinese catalogs with matching interpolation fields and markup", () => {
+ expect(Object.keys(zhCN).sort()).toEqual(Object.keys(en).sort());
+ for (const key of Object.keys(en) as (keyof typeof en)[]) {
+ expect(zhCN[key].trim(), key).not.toBe("");
+ expect(zhCN[key].match(/{{\w+}}|<\/?\w+>/g) ?? [], key).toEqual(en[key].match(/{{\w+}}|<\/?\w+>/g) ?? []);
+ }
+ });
+
+ it("preserves punctuation in natural-language keys and falls back to English", () => {
+ const instance = createDashboardI18n("zh-CN");
+ expect(instance.t("Access your LiteLLM Admin UI.")).toBe("登录 LiteLLM 管理界面。");
+ instance.addResource("en", "translation", "English fallback.", "English fallback.");
+ expect(instance.t("English fallback.")).toBe("English fallback.");
+ expect(instance.t("An untranslated label")).toBe("An untranslated label");
+ });
+
+ it("keeps server-rendered and standalone instances independent of another user's selection", async () => {
+ const first = createDashboardI18n();
+ const second = createDashboardI18n();
+ await first.changeLanguage("zh-CN");
+ expect(first.t("Login")).toBe("登录");
+ expect(second.t("Login")).toBe("Login");
+ });
+
+ it("only accepts the supported explicit preferences", () => {
+ expect(["en", "zh-CN"].every(isLanguage)).toBe(true);
+ expect([null, undefined, "", "fr", "zh-TW", "__proto__"].some(isLanguage)).toBe(false);
+ });
+});
diff --git a/ui/litellm-dashboard/src/i18n/index.ts b/ui/litellm-dashboard/src/i18n/index.ts
new file mode 100644
index 00000000000..ff895316046
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/index.ts
@@ -0,0 +1,27 @@
+import { createInstance, type InitOptions } from "i18next";
+import en from "./locales/en.json";
+import zhCN from "./locales/zh-CN.json";
+
+export type Language = "en" | "zh-CN";
+export const LANGUAGE_STORAGE_KEY = "litellm_ui_language";
+
+export function isLanguage(value: unknown): value is Language {
+ return value === "en" || value === "zh-CN";
+}
+
+export function createDashboardI18n(language: Language = "en") {
+ const instance = createInstance();
+ const options: InitOptions = {
+ lng: language,
+ fallbackLng: "en",
+ supportedLngs: ["en", "zh-CN"],
+ resources: { en: { translation: { ...en } }, "zh-CN": { translation: { ...zhCN } } },
+ initAsync: false,
+ keySeparator: false,
+ nsSeparator: false,
+ interpolation: { escapeValue: false },
+ react: { useSuspense: false },
+ };
+ void instance.init(options);
+ return instance;
+}
diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json
new file mode 100644
index 00000000000..67ea7246186
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/locales/en.json
@@ -0,0 +1,116 @@
+{
+ "ACCESS CONTROL": "ACCESS CONTROL",
+ "AI GATEWAY": "AI GATEWAY",
+ "AI Gateway": "AI Gateway",
+ "AI Hub": "AI Hub",
+ "API Playground": "API Playground",
+ "API Reference": "API Reference",
+ "Access Groups": "Access Groups",
+ "Access your LiteLLM Admin UI.": "Access your LiteLLM Admin UI.",
+ "Account": "Account",
+ "Admin Settings": "Admin Settings",
+ "Admin UI Disabled": "Admin UI Disabled",
+ "Admins can enable in Settings": "Admins can enable in Settings",
+ "Agentic": "Agentic",
+ "Agents": "Agents",
+ "Blog": "Blog",
+ "Budgets": "Budgets",
+ "Chat": "Chat",
+ "Choose a worker to connect to": "Choose a worker to connect to",
+ "Close": "Close",
+ "Collapse sidebar": "Collapse sidebar",
+ "Copy User ID": "Copy User ID",
+ "Copy email": "Copy email",
+ "Copy user ID": "Copy user ID",
+ "Cost Optimization": "Cost Optimization",
+ "Cost Tracking": "Cost Tracking",
+ "DEVELOPER TOOLS": "DEVELOPER TOOLS",
+ "Default Credentials": "Default Credentials",
+ "Docs": "Docs",
+ "Email": "Email",
+ "Enter your password": "Enter your password",
+ "Enter your username": "Enter your username",
+ "Expand sidebar": "Expand sidebar",
+ "Experimental": "Experimental",
+ "Failed to load posts": "Failed to load posts",
+ "Guardrails": "Guardrails",
+ "Guardrails Monitor": "Guardrails Monitor",
+ "Hide All Prompts": "Hide All Prompts",
+ "Hide Blog Posts": "Hide Blog Posts",
+ "Hide Bouncing Icon": "Hide Bouncing Icon",
+ "Hide New Feature Indicators": "Hide New Feature Indicators",
+ "Hide password": "Hide password",
+ "Internal Users": "Internal Users",
+ "Language": "Language",
+ "Learning Resources": "Learning Resources",
+ "LiteLLM Auto Router": "LiteLLM Auto Router",
+ "LiteLLM home": "LiteLLM home",
+ "Loading...": "Loading...",
+ "Logging & Alerts": "Logging & Alerts",
+ "Logging in...": "Logging in...",
+ "Login": "Login",
+ "Login with SSO": "Login with SSO",
+ "Logout": "Logout",
+ "Logs": "Logs",
+ "MCP Servers": "MCP Servers",
+ "Mark as read": "Mark as read",
+ "Memory": "Memory",
+ "Models + Endpoints": "Models + Endpoints",
+ "No posts available": "No posts available",
+ "Notifications": "Notifications",
+ "OBSERVABILITY": "OBSERVABILITY",
+ "Old Usage": "Old Usage",
+ "Organizations": "Organizations",
+ "Password": "Password",
+ "Playground": "Playground",
+ "Please configure SSO to log in with SSO.": "Please configure SSO to log in with SSO.",
+ "Please enter your password": "Please enter your password",
+ "Please enter your username": "Please enter your username",
+ "Policies": "Policies",
+ "Premium": "Premium",
+ "Product documentation": "Product documentation",
+ "Projects": "Projects",
+ "Prompts": "Prompts",
+ "Read the docs": "Read the docs",
+ "Response Cache": "Response Cache",
+ "Retry": "Retry",
+ "Role": "Role",
+ "Route every request to the cheapest model that can handle it, no prompt changes needed.": "Route every request to the cheapest model that can handle it, no prompt changes needed.",
+ "Router Settings": "Router Settings",
+ "SETTINGS": "SETTINGS",
+ "Search Tools": "Search Tools",
+ "Settings": "Settings",
+ "Show password": "Show password",
+ "Skills": "Skills",
+ "Standard": "Standard",
+ "Switch to dark mode (beta)": "Switch to dark mode (beta)",
+ "Switch to light mode": "Switch to light mode",
+ "Tag Management": "Tag Management",
+ "Teams": "Teams",
+ "Thanks for using LiteLLM!": "Thanks for using LiteLLM!",
+ "The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:": "The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:",
+ "Tier": "Tier",
+ "Toggle hide all prompts": "Toggle hide all prompts",
+ "Toggle hide blog posts": "Toggle hide blog posts",
+ "Toggle hide bouncing icon": "Toggle hide bouncing icon",
+ "Toggle hide new feature indicators": "Toggle hide new feature indicators",
+ "Tool Policies": "Tool Policies",
+ "Tools": "Tools",
+ "UI Theme": "UI Theme",
+ "Unknown role": "Unknown role",
+ "Upgrade to Premium for advanced features": "Upgrade to Premium for advanced features",
+ "Usage": "Usage",
+ "User ID": "User ID",
+ "Username": "Username",
+ "Vector Stores": "Vector Stores",
+ "View all posts": "View all posts",
+ "Virtual Keys": "Virtual Keys",
+ "Worker": "Worker",
+ "Workflow Runs": "Workflow Runs",
+ "accountMenu": "Account menu — {{role}} — signed in as {{identity}}",
+ "loading": "loading",
+ "login.credentialsHelp": "Need to set UI credentials or SSO? Check the documentation .",
+ "login.defaultCredentials": "By default, Username is admin and Password is your set LiteLLM Proxy MASTER_KEY.",
+ "login.ssoNotice": "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 AUTO_REDIRECT_UI_LOGIN_TO_SSO=true in your environment configuration.",
+ "unknown": "unknown"
+}
diff --git a/ui/litellm-dashboard/src/i18n/locales/zh-CN.json b/ui/litellm-dashboard/src/i18n/locales/zh-CN.json
new file mode 100644
index 00000000000..0bb1cf75bb7
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/locales/zh-CN.json
@@ -0,0 +1,116 @@
+{
+ "ACCESS CONTROL": "访问控制",
+ "AI GATEWAY": "AI 网关",
+ "AI Gateway": "AI 网关",
+ "AI Hub": "AI 资源中心",
+ "API Playground": "API 试用",
+ "API Reference": "API 参考",
+ "Access Groups": "访问组",
+ "Access your LiteLLM Admin UI.": "登录 LiteLLM 管理界面。",
+ "Account": "账户",
+ "Admin Settings": "管理设置",
+ "Admin UI Disabled": "管理界面已禁用",
+ "Admins can enable in Settings": "管理员可在设置中启用",
+ "Agentic": "智能体应用",
+ "Agents": "智能体",
+ "Blog": "博客",
+ "Budgets": "预算",
+ "Chat": "聊天",
+ "Choose a worker to connect to": "选择要连接的工作节点",
+ "Close": "关闭",
+ "Collapse sidebar": "收起侧栏",
+ "Copy User ID": "复制用户 ID",
+ "Copy email": "复制邮箱",
+ "Copy user ID": "复制用户 ID",
+ "Cost Optimization": "成本优化",
+ "Cost Tracking": "成本追踪",
+ "DEVELOPER TOOLS": "开发工具",
+ "Default Credentials": "默认登录信息",
+ "Docs": "文档",
+ "Email": "邮箱",
+ "Enter your password": "请输入密码",
+ "Enter your username": "请输入用户名",
+ "Expand sidebar": "展开侧栏",
+ "Experimental": "实验功能",
+ "Failed to load posts": "加载文章失败",
+ "Guardrails": "安全护栏",
+ "Guardrails Monitor": "护栏监控",
+ "Hide All Prompts": "隐藏所有提示",
+ "Hide Blog Posts": "隐藏博客文章",
+ "Hide Bouncing Icon": "隐藏跳动图标",
+ "Hide New Feature Indicators": "隐藏新功能提示",
+ "Hide password": "隐藏密码",
+ "Internal Users": "内部用户",
+ "Language": "语言",
+ "Learning Resources": "学习资料",
+ "LiteLLM Auto Router": "LiteLLM 自动路由",
+ "LiteLLM home": "LiteLLM 首页",
+ "Loading...": "加载中…",
+ "Logging & Alerts": "日志与告警",
+ "Logging in...": "正在登录…",
+ "Login": "登录",
+ "Login with SSO": "使用单点登录",
+ "Logout": "退出登录",
+ "Logs": "请求日志",
+ "MCP Servers": "MCP 服务",
+ "Mark as read": "标记为已读",
+ "Memory": "记忆",
+ "Models + Endpoints": "模型与端点",
+ "No posts available": "暂无文章",
+ "Notifications": "通知",
+ "OBSERVABILITY": "运行监控",
+ "Old Usage": "旧版用量",
+ "Organizations": "组织",
+ "Password": "密码",
+ "Playground": "模型试用",
+ "Please configure SSO to log in with SSO.": "请先配置单点登录。",
+ "Please enter your password": "请输入密码",
+ "Please enter your username": "请输入用户名",
+ "Policies": "策略",
+ "Premium": "高级版",
+ "Product documentation": "产品文档",
+ "Projects": "项目",
+ "Prompts": "提示词",
+ "Read the docs": "查看文档",
+ "Response Cache": "响应缓存",
+ "Retry": "重试",
+ "Role": "角色",
+ "Route every request to the cheapest model that can handle it, no prompt changes needed.": "自动将每个请求分配给能够处理它的最低成本模型,无需修改提示词。",
+ "Router Settings": "路由设置",
+ "SETTINGS": "设置",
+ "Search Tools": "搜索工具",
+ "Settings": "设置",
+ "Show password": "显示密码",
+ "Skills": "技能",
+ "Standard": "标准版",
+ "Switch to dark mode (beta)": "切换到深色模式(测试版)",
+ "Switch to light mode": "切换到浅色模式",
+ "Tag Management": "标签管理",
+ "Teams": "团队",
+ "Thanks for using LiteLLM!": "感谢使用 LiteLLM!",
+ "The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:": "管理员已禁用管理界面。如需重新启用,请修改以下环境变量:",
+ "Tier": "套餐",
+ "Toggle hide all prompts": "切换所有提示显示",
+ "Toggle hide blog posts": "切换博客文章显示",
+ "Toggle hide bouncing icon": "切换跳动图标显示",
+ "Toggle hide new feature indicators": "切换新功能提示显示",
+ "Tool Policies": "工具策略",
+ "Tools": "工具",
+ "UI Theme": "界面主题",
+ "Unknown role": "未知角色",
+ "Upgrade to Premium for advanced features": "升级到高级版以使用更多功能",
+ "Usage": "用量统计",
+ "User ID": "用户 ID",
+ "Username": "用户名",
+ "Vector Stores": "向量存储",
+ "View all posts": "查看全部文章",
+ "Virtual Keys": "虚拟密钥",
+ "Worker": "工作节点",
+ "Workflow Runs": "工作流运行",
+ "accountMenu": "账户菜单:{{role}},当前登录为 {{identity}}",
+ "loading": "加载中",
+ "login.credentialsHelp": "需要设置管理界面登录凭据或单点登录?查看文档 。",
+ "login.defaultCredentials": "默认用户名为 admin,密码为你设置的 LiteLLM 网关 MASTER_KEY。",
+ "login.ssoNotice": "已启用单点登录(SSO)。打开此页面时,LiteLLM 不再自动跳转到单点登录流程。若要恢复自动跳转,请在环境配置中设置 AUTO_REDIRECT_UI_LOGIN_TO_SSO=true。",
+ "unknown": "未知"
+}
diff --git a/ui/litellm-dashboard/src/i18n/useTranslation.ts b/ui/litellm-dashboard/src/i18n/useTranslation.ts
new file mode 100644
index 00000000000..c3633dbe27c
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/useTranslation.ts
@@ -0,0 +1,12 @@
+"use client";
+
+import { useContext } from "react";
+import { I18nContext, useTranslation as useReactTranslation } from "react-i18next";
+import { createDashboardI18n } from ".";
+
+const englishFallback = createDashboardI18n();
+
+export function useTranslation() {
+ const context = useContext(I18nContext);
+ return useReactTranslation(undefined, { i18n: context?.i18n ?? englishFallback });
+}