diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json
index 4e5b0c1dda6..817ffb70ed1 100644
--- a/ui/litellm-dashboard/package-lock.json
+++ b/ui/litellm-dashboard/package-lock.json
@@ -21,6 +21,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",
@@ -35,6 +36,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",
@@ -47,6 +49,7 @@
},
"devDependencies": {
"@eslint/js": "9.39.2",
+ "@playwright/test": "^1.63.0",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/postcss": "4.3.2",
"@testing-library/dom": "10.4.1",
@@ -414,9 +417,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"
@@ -2509,6 +2512,22 @@
"win32"
]
},
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -7290,6 +7309,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",
@@ -7337,6 +7365,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",
@@ -10431,6 +10487,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -10650,6 +10735,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",
@@ -12171,7 +12283,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 ededdfb4606..96988b9f85c 100644
--- a/ui/litellm-dashboard/package.json
+++ b/ui/litellm-dashboard/package.json
@@ -37,6 +37,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",
@@ -51,6 +52,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",
@@ -63,6 +65,7 @@
},
"devDependencies": {
"@eslint/js": "9.39.2",
+ "@playwright/test": "^1.63.0",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/postcss": "4.3.2",
"@testing-library/dom": "10.4.1",
diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx
index 60db7104a03..95a49b3f875 100644
--- a/ui/litellm-dashboard/src/app/layout.tsx
+++ b/ui/litellm-dashboard/src/app/layout.tsx
@@ -8,6 +8,7 @@ import { ThemeProvider } from "next-themes";
import { AuthProvider } from "@/contexts/AuthContext";
import ReactQueryProvider from "@/contexts/ReactQueryProvider";
import { Toaster } from "@/components/ui/sonner";
+import { I18nProvider } from "@/i18n";
const inter = Inter({ subsets: ["latin"] });
@@ -27,14 +28,16 @@ export default function RootLayout({
// cannot predict; suppressHydrationWarning confines that mismatch to this element.
-
-
-
- {children}
-
-
-
-
+
+
+
+
+ {children}
+
+
+
+
+
);
diff --git a/ui/litellm-dashboard/src/components/LanguageSwitcher/LanguageSwitcher.tsx b/ui/litellm-dashboard/src/components/LanguageSwitcher/LanguageSwitcher.tsx
new file mode 100644
index 00000000000..c43f5f70487
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/LanguageSwitcher/LanguageSwitcher.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+import { useI18n, type Locale } from "@/i18n";
+
+const LANGUAGE_OPTIONS: { value: Locale; key: string }[] = [
+ // Keys are unprefixed: `common` is the default namespace (types.d.ts), so
+ // i18next v26 typing resolves them without the `common:` prefix.
+ { value: "en", key: "languages.en" },
+ { value: "zh-CN", key: "languages.zh-CN" },
+];
+
+/**
+ * EN / 中文 language toggle. Uses `useI18n().setLocale` which persists the
+ * choice to the unified `litellm.locale` key and applies it to the active
+ * i18next instance. The active option is the current locale.
+ */
+export function LanguageSwitcher() {
+ const { locale, setLocale } = useI18n();
+ const { t } = useTranslation();
+
+ return (
+
+ {LANGUAGE_OPTIONS.map((option) => {
+ const isActive = option.value === locale;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/i18n/I18nProvider.gate.integration.test.tsx b/ui/litellm-dashboard/src/i18n/I18nProvider.gate.integration.test.tsx
new file mode 100644
index 00000000000..2a7348ea805
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/I18nProvider.gate.integration.test.tsx
@@ -0,0 +1,75 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { i18n } from "i18next";
+
+// Control i18n initialisation resolves so we can assert the readiness gate
+// blocks the business subtree before the instance is ready.
+let releaseGate: (instance: i18n) => void = () => {};
+let pending: Promise | null = null;
+
+vi.mock("./i18n", () => {
+ return {
+ getI18n: () =>
+ (pending ??= new Promise((resolve) => {
+ releaseGate = resolve;
+ })),
+ };
+});
+
+import { I18nProvider, useI18n } from "@/i18n";
+
+function GateConsumer() {
+ const { locale } = useI18n();
+ return {locale};
+}
+
+const ORIGINAL_LANG = "en";
+
+beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.lang = ORIGINAL_LANG;
+ pending = null;
+});
+
+afterEach(() => {
+ vi.resetModules();
+ pending = null;
+ localStorage.clear();
+ document.documentElement.lang = ORIGINAL_LANG;
+});
+
+describe("I18nProvider readiness gate", () => {
+ it("does not render the business subtree before the instance is ready", () => {
+ localStorage.setItem("litellm.locale", "zh-CN");
+ render(
+
+
+ ,
+ );
+
+ // While i18n initialisation is pending, children must not appear (no key exposure).
+ expect(screen.queryByTestId("gate-child")).not.toBeInTheDocument();
+ });
+
+ it("renders children once initialisation resolves", async () => {
+ localStorage.setItem("litellm.locale", "zh-CN");
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId("gate-child")).not.toBeInTheDocument();
+
+ // Resolve the pending initialisation with a minimal fake instance.
+ releaseGate({
+ isInitialized: true,
+ language: "zh-CN",
+ changeLanguage: vi.fn(async () => {}),
+ t: vi.fn(),
+ } as unknown as i18n);
+
+ await waitFor(() => expect(screen.getByTestId("gate-child")).toBeInTheDocument());
+ });
+});
diff --git a/ui/litellm-dashboard/src/i18n/I18nProvider.integration.test.tsx b/ui/litellm-dashboard/src/i18n/I18nProvider.integration.test.tsx
new file mode 100644
index 00000000000..376503c7206
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/I18nProvider.integration.test.tsx
@@ -0,0 +1,111 @@
+import { render, screen, waitFor, fireEvent } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { useTranslation } from "react-i18next";
+import { useState } from "react";
+
+import { I18nProvider, useI18n } from "@/i18n";
+
+/** Reports the active locale and a translated value so tests can assert wiring. */
+function Consumer() {
+ const { locale, setLocale } = useI18n();
+ const { t } = useTranslation();
+ const [pressed, setPressed] = useState(0);
+
+ return (
+
+ {locale}
+ {t("common:languages.en")}
+
+ {pressed}
+
+ );
+}
+
+const ORIGINAL_LANG = "en";
+
+beforeEach(() => {
+ // Force a clean preference + language for each test.
+ localStorage.clear();
+ document.documentElement.lang = ORIGINAL_LANG;
+});
+
+afterEach(() => {
+ localStorage.clear();
+ document.documentElement.lang = ORIGINAL_LANG;
+});
+
+describe("I18nProvider readiness gate", () => {
+ it("renders the business subtree only once the instance is ready (no key flash)", async () => {
+ render(
+
+
+ ,
+ );
+
+ // Children must render (gate opened) with a real translated value, not a raw key.
+ const translated = await screen.findByTestId("translated");
+ expect(translated).toHaveTextContent("English");
+ });
+
+ it("syncs document.documentElement.lang to the resolved locale (en)", async () => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(document.documentElement.lang).toBe("en"));
+ });
+});
+
+describe("I18nProvider zh-CN preference", () => {
+ it("resolves a stored zh-CN preference, renders Chinese, and syncs ", async () => {
+ localStorage.setItem("litellm.locale", "zh-CN");
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"));
+ expect(document.documentElement.lang).toBe("zh-CN");
+ });
+});
+
+describe("useI18n setLocale", () => {
+ it("switches language, applies it, and updates the consumer", async () => {
+ render(
+
+
+ ,
+ );
+
+ await screen.findByTestId("translated");
+ fireEvent.click(screen.getByRole("button", { name: "to-zh" }));
+
+ await waitFor(() => expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"));
+ expect(document.documentElement.lang).toBe("zh-CN");
+ expect(screen.getByTestId("pressed")).toHaveTextContent("1");
+ });
+
+ it("persists the explicit choice to the unified preferences key", async () => {
+ render(
+
+
+ ,
+ );
+ await screen.findByTestId("translated");
+
+ fireEvent.click(screen.getByRole("button", { name: "to-zh" }));
+ await waitFor(() => expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"));
+
+ expect(localStorage.getItem("litellm.locale")).toBe("zh-CN");
+ });
+});
diff --git a/ui/litellm-dashboard/src/i18n/I18nProvider.tsx b/ui/litellm-dashboard/src/i18n/I18nProvider.tsx
new file mode 100644
index 00000000000..c0d2ce29684
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/I18nProvider.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
+import { I18nextProvider } from "react-i18next";
+import type { i18n } from "i18next";
+
+import { getI18n } from "./i18n";
+import { createBrowserLocaleEnv, resolveLocale, writeLocalePreference } from "./localePreferences";
+import type { Locale } from "./resources/registry";
+
+interface I18nContextValue {
+ locale: Locale;
+ setLocale: (locale: Locale) => Promise;
+}
+
+const I18nContext = createContext(null);
+
+/** Access the active locale and a `setLocale` that persists and applies it. */
+export function useI18n(): I18nContextValue {
+ const ctx = useContext(I18nContext);
+ if (!ctx) {
+ throw new Error("useI18n must be used within ");
+ }
+ return ctx;
+}
+
+/**
+ * Wraps the whole dashboard (root layout) as the outermost provider.
+ *
+ * First-screen strategy = language readiness gate (I18N_TECH_DESIGN §4 / ADR-03/04):
+ * - Resolve the target locale from the D5 preference chain.
+ * - Initialize the lazy i18next singleton and switch to the target language.
+ * - Only then render the business subtree, so no `t()`/`` content is ever
+ * rendered before resources are ready (no raw key flash).
+ * - Sync `document.documentElement.lang` with the resolved language post-mount.
+ * The English default is equally gated (sub-frame), since a non-initialised
+ * i18next would otherwise expose raw keys for `en` too.
+ */
+export function I18nProvider({ children }: { children: ReactNode }) {
+ const [i18n, setI18n] = useState(null);
+ const [locale, setLocaleState] = useState("en");
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ const env = createBrowserLocaleEnv();
+ const target = resolveLocale(env);
+
+ void getI18n()
+ .then(async (instance) => {
+ if (cancelled) return;
+ await instance.changeLanguage(target);
+ if (cancelled) return;
+ document.documentElement.lang = instance.language;
+ setI18n(instance);
+ setLocaleState(instance.language as Locale);
+ setReady(true);
+ })
+ .catch(() => {
+ // Pathological case: bundled static resources failed to initialise.
+ // Do not render business content without an instance (that would expose
+ // raw keys); surface the failure in the console.
+ if (!cancelled) {
+ // eslint-disable-next-line no-console
+ console.error("[i18n] failed to initialise i18next instance");
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const setLocale = useCallback(
+ async (next: Locale) => {
+ const env = createBrowserLocaleEnv();
+ writeLocalePreference(next, env);
+ if (i18n) {
+ await i18n.changeLanguage(next);
+ document.documentElement.lang = i18n.language;
+ }
+ setLocaleState(next);
+ },
+ [i18n],
+ );
+
+ if (!ready || !i18n) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/i18n/detectLocale.test.ts b/ui/litellm-dashboard/src/i18n/detectLocale.test.ts
new file mode 100644
index 00000000000..f2f330b06eb
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/detectLocale.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+
+import { detectLocale, detectLocaleFromList, isSupportedLocale } from "./detectLocale";
+
+describe("detectLocale", () => {
+ it("maps bare zh to zh-CN", () => {
+ expect(detectLocale("zh")).toBe("zh-CN");
+ });
+
+ it("maps zh-Hans to zh-CN", () => {
+ expect(detectLocale("zh-Hans")).toBe("zh-CN");
+ });
+
+ it("maps zh-CN to zh-CN", () => {
+ expect(detectLocale("zh-CN")).toBe("zh-CN");
+ });
+
+ it("maps lowercase zh variants to zh-CN", () => {
+ expect(detectLocale("zh-cn")).toBe("zh-CN");
+ expect(detectLocale("ZH")).toBe("zh-CN");
+ });
+
+ it("maps en to en", () => {
+ expect(detectLocale("en")).toBe("en");
+ expect(detectLocale("en-US")).toBe("en");
+ });
+
+ it("maps unsupported languages to en (fallback)", () => {
+ expect(detectLocale("fr")).toBe("en");
+ expect(detectLocale("de")).toBe("en");
+ });
+
+ it("falls back to en for empty/null/undefined input", () => {
+ expect(detectLocale("")).toBe("en");
+ expect(detectLocale(null)).toBe("en");
+ expect(detectLocale(undefined)).toBe("en");
+ });
+
+ it("trims surrounding whitespace", () => {
+ expect(detectLocale(" zh-Hans ")).toBe("zh-CN");
+ });
+});
+
+describe("detectLocaleFromList", () => {
+ it("returns the first supported locale honouring en priority", () => {
+ expect(detectLocaleFromList(["en", "zh-CN"])).toBe("en");
+ expect(detectLocaleFromList(["zh-CN", "en"])).toBe("zh-CN");
+ });
+
+ it("skips unsupported tags and finds the first supported one", () => {
+ expect(detectLocaleFromList(["fr", "zh"])).toBe("zh-CN");
+ expect(detectLocaleFromList(["fr-FR", "en-US"])).toBe("en");
+ });
+
+ it("falls back to en when nothing is supported", () => {
+ expect(detectLocaleFromList(["fr", "de"])).toBe("en");
+ });
+});
+
+describe("isSupportedLocale", () => {
+ it("accepts only exact supported locales", () => {
+ expect(isSupportedLocale("en")).toBe(true);
+ expect(isSupportedLocale("zh-CN")).toBe(true);
+ expect(isSupportedLocale("zh")).toBe(false);
+ expect(isSupportedLocale(null)).toBe(false);
+ });
+});
diff --git a/ui/litellm-dashboard/src/i18n/detectLocale.ts b/ui/litellm-dashboard/src/i18n/detectLocale.ts
new file mode 100644
index 00000000000..30d78b85c86
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/detectLocale.ts
@@ -0,0 +1,40 @@
+import { supportedLngs, type Locale } from "./resources/registry";
+
+/**
+ * Normalize a BCP-47 / browser language tag to one of the supported locales.
+ *
+ * Rules (I18N_TECH_DESIGN §3.1):
+ * - any Chinese tag (`zh`, `zh-Hans`, `zh-CN`, ...) -> `zh-CN`
+ * - an exact supported locale -> itself
+ * - anything else -> `en` (fallback)
+ *
+ * Pure function, safe to call in any environment.
+ */
+export function detectLocale(raw: string | null | undefined): Locale {
+ if (!raw) return "en";
+
+ const normalized = raw.trim().toLowerCase();
+
+ if (normalized.startsWith("zh")) return "zh-CN";
+
+ const exact = supportedLngs.find((lng) => lng.toLowerCase() === normalized);
+ if (exact) return exact as Locale;
+
+ return "en";
+}
+
+/** Accept a list of candidate browser languages and return the first supported one. */
+export function detectLocaleFromList(candidates: readonly string[]): Locale {
+ for (const candidate of candidates) {
+ const detected = detectLocale(candidate);
+ if (detected !== "en" || candidate.toLowerCase().startsWith("en")) {
+ // A non-en detection is authoritative; for `en` we stop at the first en tag.
+ return detected;
+ }
+ }
+ return "en";
+}
+
+export function isSupportedLocale(value: string | null | undefined): value is Locale {
+ return typeof value === "string" && (supportedLngs as readonly string[]).includes(value);
+}
diff --git a/ui/litellm-dashboard/src/i18n/i18n.test.ts b/ui/litellm-dashboard/src/i18n/i18n.test.ts
new file mode 100644
index 00000000000..7324386016c
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/i18n.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+
+import { getI18n } from "./i18n";
+
+describe("getI18n", () => {
+ it("returns a promise resolving to an initialised i18next instance", async () => {
+ const i18n = await getI18n();
+ expect(i18n.isInitialized).toBe(true);
+ });
+
+ it("returns the same singleton across calls", async () => {
+ const first = await getI18n();
+ const second = await getI18n();
+ expect(first).toBe(second);
+ });
+
+ it("resolves enabled namespaces from the registry", async () => {
+ const i18n = await getI18n();
+ expect(i18n.t("common:languages.en")).toBe("English");
+ expect(i18n.t("navigation:dashboard")).toBe("Dashboard");
+ });
+
+ it("switches to zh-CN and resolves Chinese resources", async () => {
+ const i18n = await getI18n();
+ await i18n.changeLanguage("zh-CN");
+ expect(i18n.t("common:languages.zh-CN")).toBe("简体中文");
+ expect(i18n.t("navigation:dashboard")).toBe("仪表盘");
+ await i18n.changeLanguage("en");
+ });
+});
diff --git a/ui/litellm-dashboard/src/i18n/i18n.ts b/ui/litellm-dashboard/src/i18n/i18n.ts
new file mode 100644
index 00000000000..ccc0c4e6ace
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/i18n.ts
@@ -0,0 +1,48 @@
+import { createInstance, type i18n } from "i18next";
+import { initReactI18next } from "react-i18next";
+
+import { RESOURCES, supportedLngs } from "./resources/registry";
+
+/**
+ * Lazy i18next singleton. Initialization happens once and is reused across
+ * HMR / re-mounts; callers `await getI18n()` rather than importing a module-top
+ * instance (I18N_TECH_DESIGN §2.2) so the static-export build never initializes
+ * i18next during compilation.
+ *
+ * Note (i18next v26): `.init()`'s promise resolves to the bound `t` function,
+ * not the instance, so we await it only to observe readiness and resolve the
+ * cached instance itself.
+ */
+let pending: Promise | null = null;
+
+export function getI18n(): Promise {
+ if (!pending) {
+ const instance = createInstance();
+
+ const initPromise = instance.use(initReactI18next).init({
+ resources: RESOURCES,
+ supportedLngs: [...supportedLngs],
+ fallbackLng: "en",
+ load: "currentOnly",
+ nonExplicitSupportedLngs: false,
+ defaultNS: "common",
+ ns: ["common"],
+ // Never render a raw English sentence as a "missing-key fallback"; a
+ // fully-missing key renders nothing (ADR-03 §5.1) and warns in dev so it
+ // surfaces early. English is the type source, so this is a last resort.
+ returnNull: true,
+ returnEmptyString: false,
+ interpolation: { escapeValue: false },
+ react: { useSuspense: false },
+ missingKeyHandler: (_lngs, _ns, key) => {
+ if (process.env.NODE_ENV === "development") {
+ // eslint-disable-next-line no-console
+ console.warn(`[i18n] missing key: ${key}`);
+ }
+ },
+ });
+
+ pending = initPromise.then(() => instance);
+ }
+ return pending;
+}
diff --git a/ui/litellm-dashboard/src/i18n/localePreferences.test.ts b/ui/litellm-dashboard/src/i18n/localePreferences.test.ts
new file mode 100644
index 00000000000..7b317fbac1a
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/localePreferences.test.ts
@@ -0,0 +1,172 @@
+import { describe, expect, it } from "vitest";
+
+import { LOCALE_STORAGE_KEY } from "./localePreferences";
+import {
+ clearLocalePreference,
+ readCookie,
+ readStoredLocale,
+ resolveLocale,
+ writeLocalePreference,
+ type LocaleEnv,
+} from "./localePreferences";
+
+interface MemoryState {
+ storage: Map;
+ /** Simulated Set-Cookie directives applied in order. */
+ cookieDirectives: string[];
+ languages: string[];
+ secure: boolean;
+}
+
+function makeEnv(state: MemoryState): LocaleEnv {
+ const cookieString = () => {
+ // Reconstruct a document.cookie string from the applied directives, keeping
+ // the most recently set value per name.
+ const values = new Map();
+ for (const directive of state.cookieDirectives) {
+ const [nameAndValue] = directive.split(";");
+ const idx = nameAndValue.indexOf("=");
+ if (idx === -1) continue;
+ const name = nameAndValue.slice(0, idx).trim();
+ const value = nameAndValue.slice(idx + 1).trim();
+ values.set(name, value);
+ }
+ return Array.from(values.entries())
+ .map(([name, value]) => `${name}=${value}`)
+ .join("; ");
+ };
+
+ return {
+ getCookieString: cookieString,
+ setCookie: (name, value, opts) => {
+ state.cookieDirectives.push(
+ `${name}=${encodeURIComponent(value)}; SameSite=Lax; path=/${opts.secure ? "; Secure" : ""}`,
+ );
+ },
+ removeCookie: (name) => {
+ state.cookieDirectives.push(`${name}=; Max-Age=0; SameSite=Lax; path=/`);
+ },
+ storageGet: (key) => state.storage.get(key) ?? null,
+ storageSet: (key, value) => {
+ state.storage.set(key, value);
+ },
+ storageRemove: (key) => {
+ state.storage.delete(key);
+ },
+ browserLanguages: () => state.languages,
+ isSecure: () => state.secure,
+ };
+}
+
+function freshState(overrides: Partial = {}): MemoryState {
+ return {
+ storage: new Map(),
+ cookieDirectives: [],
+ languages: ["en"],
+ secure: false,
+ ...overrides,
+ };
+}
+
+describe("readCookie", () => {
+ it("parses a simple cookie string", () => {
+ expect(readCookie("a=1; b=2", "b")).toBe("2");
+ });
+
+ it("decodes URI-encoded values", () => {
+ expect(readCookie("litellm.locale=zh-CN", LOCALE_STORAGE_KEY)).toBe("zh-CN");
+ });
+
+ it("returns null when the key is absent or the string is empty", () => {
+ expect(readCookie("", "x")).toBeNull();
+ expect(readCookie("a=1", "x")).toBeNull();
+ });
+});
+
+describe("writeLocalePreference", () => {
+ it("writes to both localStorage and cookie with SameSite=Lax and path=/", () => {
+ const state = freshState();
+ const env = makeEnv(state);
+
+ writeLocalePreference("zh-CN", env);
+
+ expect(state.storage.get(LOCALE_STORAGE_KEY)).toBe("zh-CN");
+ const directive = state.cookieDirectives.find((d) => d.startsWith("litellm.locale="));
+ expect(directive).toContain("SameSite=Lax");
+ expect(directive).toContain("path=/");
+ expect(directive).not.toContain("Secure");
+ });
+
+ it("attaches Secure when the environment reports it", () => {
+ const state = freshState({ secure: true });
+ const env = makeEnv(state);
+
+ writeLocalePreference("zh-CN", env);
+
+ const directive = state.cookieDirectives.find((d) => d.startsWith("litellm.locale="));
+ expect(directive).toContain("Secure");
+ });
+});
+
+describe("readStoredLocale", () => {
+ it("reads from localStorage first", () => {
+ const state = freshState({ storage: new Map([[LOCALE_STORAGE_KEY, "zh-CN"]]) });
+ state.cookieDirectives.push(`${LOCALE_STORAGE_KEY}=en; path=/`);
+ expect(readStoredLocale(makeEnv(state))).toBe("zh-CN");
+ });
+
+ it("falls back to the cookie when localStorage is empty", () => {
+ const state = freshState();
+ state.cookieDirectives.push(`litellm.locale=zh-CN; path=/`);
+ expect(readStoredLocale(makeEnv(state))).toBe("zh-CN");
+ });
+
+ it("returns null when no preference exists", () => {
+ expect(readStoredLocale(makeEnv(freshState()))).toBeNull();
+ });
+
+ it("treats an unsupported stored value as absent", () => {
+ const state = freshState({ storage: new Map([[LOCALE_STORAGE_KEY, "fr"]]) });
+ expect(readStoredLocale(makeEnv(state))).toBeNull();
+ });
+});
+
+describe("clearLocalePreference", () => {
+ it("removes the key from both layers", () => {
+ const state = freshState({
+ storage: new Map([[LOCALE_STORAGE_KEY, "zh-CN"]]),
+ cookieDirectives: [`litellm.locale=zh-CN; path=/`],
+ });
+ const env = makeEnv(state);
+
+ clearLocalePreference(env);
+
+ expect(state.storage.has(LOCALE_STORAGE_KEY)).toBe(false);
+ expect(state.cookieDirectives.at(-1)).toContain("Max-Age=0");
+ });
+});
+
+describe("resolveLocale (D5 priority)", () => {
+ it("prefers an explicit stored preference over browser language", () => {
+ const state = freshState({
+ storage: new Map([[LOCALE_STORAGE_KEY, "zh-CN"]]),
+ languages: ["en"],
+ });
+ expect(resolveLocale(makeEnv(state))).toBe("zh-CN");
+ });
+
+ it("uses browser language when no stored preference exists", () => {
+ expect(resolveLocale(makeEnv(freshState({ languages: ["zh-CN"] })))).toBe("zh-CN");
+ expect(resolveLocale(makeEnv(freshState({ languages: ["zh", "en"] })))).toBe("zh-CN");
+ });
+
+ it("honours browser language order with en priority", () => {
+ expect(resolveLocale(makeEnv(freshState({ languages: ["en", "zh-CN"] })))).toBe("en");
+ expect(resolveLocale(makeEnv(freshState({ languages: ["zh-CN", "en"] })))).toBe("zh-CN");
+ });
+
+ it("falls back to en for unsupported browser languages", () => {
+ expect(resolveLocale(makeEnv(freshState({ languages: ["fr", "de"] })))).toBe("en");
+ expect(resolveLocale(makeEnv(freshState({ languages: [] })))).toBe("en");
+ });
+});
diff --git a/ui/litellm-dashboard/src/i18n/localePreferences.ts b/ui/litellm-dashboard/src/i18n/localePreferences.ts
new file mode 100644
index 00000000000..cf0b0b161b3
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/localePreferences.ts
@@ -0,0 +1,139 @@
+import { detectLocale, isSupportedLocale } from "./detectLocale";
+import { type Locale } from "./resources/registry";
+
+/**
+ * Unified language preference storage key (DECISIONS.md P5).
+ * Always read/write this key; feature agents must not touch storage directly.
+ */
+export const LOCALE_STORAGE_KEY = "litellm.locale";
+
+export interface LocaleEnv {
+ /** Raw `document.cookie` string, e.g. `"a=1; b=2"`. */
+ getCookieString(): string;
+ /**
+ * Set a cookie. `secure` is decided by the environment so tests can assert the
+ * attribute independently of the global `window`.
+ */
+ setCookie(name: string, value: string, opts: { secure: boolean }): void;
+ removeCookie(name: string): void;
+ storageGet(key: string): string | null;
+ storageSet(key: string, value: string): void;
+ storageRemove(key: string): void;
+ browserLanguages(): readonly string[];
+ /** Whether `Secure` should be attached to cookies (true in production over HTTPS). */
+ isSecure(): boolean;
+}
+
+/** Convenience default for the browser environment. */
+export function createBrowserLocaleEnv(): LocaleEnv {
+ const inHttps =
+ typeof window !== "undefined" && window.location.protocol === "https:";
+ return {
+ getCookieString: () => (typeof document === "undefined" ? "" : document.cookie),
+ setCookie: (name, value, opts) => {
+ if (typeof document === "undefined") return;
+ document.cookie = `${name}=${encodeURIComponent(value)}; SameSite=Lax; path=/${opts.secure ? "; Secure" : ""}`;
+ },
+ removeCookie: (name) => {
+ if (typeof document === "undefined") return;
+ document.cookie = `${name}=; Max-Age=0; SameSite=Lax; path=/`;
+ },
+ storageGet: (key) => {
+ try {
+ return typeof localStorage === "undefined" ? null : localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+ },
+ storageSet: (key, value) => {
+ try {
+ localStorage?.setItem(key, value);
+ } catch {
+ /* storage may be unavailable (e.g. private mode); cookie still carries the value */
+ }
+ },
+ storageRemove: (key) => {
+ try {
+ localStorage?.removeItem(key);
+ } catch {
+ /* best-effort */
+ }
+ },
+ browserLanguages: () =>
+ typeof navigator === "undefined" ? [] : Array.from(navigator.languages ?? [navigator.language]).filter(Boolean),
+ isSecure: () => inHttps,
+ };
+}
+
+/** Parse a raw cookie string and return the value for `name` or null. */
+export function readCookie(rawCookies: string, name: string): string | null {
+ if (!rawCookies) return null;
+ for (const part of rawCookies.split(";")) {
+ const idx = part.indexOf("=");
+ if (idx === -1) continue;
+ const key = part.slice(0, idx).trim();
+ if (key === name) {
+ try {
+ return decodeURIComponent(part.slice(idx + 1).trim());
+ } catch {
+ return part.slice(idx + 1).trim();
+ }
+ }
+ }
+ return null;
+}
+
+/**
+ * Read the explicitly stored locale. Per L1, if the unified key exists anywhere
+ * (localStorage fast path first, then cookie) it is treated as the user's
+ * explicit choice. Returns null when no preference has been written.
+ */
+export function readStoredLocale(env: LocaleEnv): Locale | null {
+ const fromStorage = env.storageGet(LOCALE_STORAGE_KEY);
+ const value = fromStorage ?? readCookie(env.getCookieString(), LOCALE_STORAGE_KEY);
+ if (value === null || value === "") return null;
+ return isSupportedLocale(value) ? value : null;
+}
+
+/** Persist an explicit user choice to both cookie and localStorage. */
+export function writeLocalePreference(locale: Locale, env: LocaleEnv): void {
+ env.storageSet(LOCALE_STORAGE_KEY, locale);
+ env.setCookie(LOCALE_STORAGE_KEY, locale, { secure: env.isSecure() });
+}
+
+/** Remove the stored preference from both layers. */
+export function clearLocalePreference(env: LocaleEnv): void {
+ env.storageRemove(LOCALE_STORAGE_KEY);
+ env.removeCookie(LOCALE_STORAGE_KEY);
+}
+
+/**
+ * D5 priority resolution:
+ * 1. explicit stored preference (cookie/localStorage)
+ * 2. browser language (first supported, normalized via detectLocale)
+ * 3. `en` (fallback)
+ */
+export function resolveLocale(env: LocaleEnv): Locale {
+ const stored = readStoredLocale(env);
+ if (stored) return stored;
+ return detectLocaleFromBrowser(env) ?? "en";
+}
+
+function detectLocaleFromBrowser(env: LocaleEnv): Locale | null {
+ const languages = env.browserLanguages();
+ if (languages.length === 0) return null;
+
+ // Filter by supportedLngs and take the first supported tag (D5: browser
+ // languages are used only when no explicit preference exists).
+ for (const raw of languages) {
+ if (isEnglishTag(raw)) return "en";
+ const detected = detectLocale(raw);
+ if (detected !== "en") return detected; // e.g. zh -> zh-CN
+ }
+ return "en";
+}
+
+function isEnglishTag(raw: string): boolean {
+ const normalized = raw.trim().toLowerCase();
+ return normalized === "en" || normalized.startsWith("en-");
+}
diff --git a/ui/litellm-dashboard/src/i18n/resources.test.ts b/ui/litellm-dashboard/src/i18n/resources.test.ts
new file mode 100644
index 00000000000..b3c2b52e0e1
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/resources.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { RESOURCES, NAMESPACES } from "./resources/registry";
+
+// Resource-shape tests. With i18next v26's typed-qualified-key support being
+// fragile (and breaking `next build`), `types.d.ts` types keys loosely and key
+// correctness is delegated to runtime readiness + A7's `check-keys` CI gate.
+// These tests keep the registry honest at runtime: every locale exposes every
+// registered namespace, and the seed keys the platform actually uses exist in
+// both locales — so a removed/moved resource fails here, not silently at runtime.
+describe("i18n resource registry", () => {
+ it("exposes every registered namespace for every locale", () => {
+ for (const locale of Object.keys(RESOURCES) as (keyof typeof RESOURCES)[]) {
+ for (const name of NAMESPACES) {
+ expect(RESOURCES[locale], `${locale}.${name}`).toHaveProperty(name);
+ }
+ }
+ });
+
+ it("has the common seed keys used by the platform in en and zh-CN", () => {
+ for (const locale of ["en", "zh-CN"] as const) {
+ const common = RESOURCES[locale].common as Record;
+ expect(common).toHaveProperty("language.name");
+ expect(common).toHaveProperty("languages.en");
+ expect(common).toHaveProperty("languages.zh-CN");
+ }
+ });
+
+ it("has the navigation seed key in en and zh-CN", () => {
+ for (const locale of ["en", "zh-CN"] as const) {
+ const nav = RESOURCES[locale].navigation as Record;
+ expect(nav).toHaveProperty("dashboard");
+ }
+ });
+});
diff --git a/ui/litellm-dashboard/src/i18n/resources/registry.ts b/ui/litellm-dashboard/src/i18n/resources/registry.ts
new file mode 100644
index 00000000000..39ad677cbc1
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/resources/registry.ts
@@ -0,0 +1,86 @@
+import type { ResourceLanguage } from "i18next";
+
+import commonEn from "@/locales/en/common.json";
+import navigationEn from "@/locales/en/navigation.json";
+import authEn from "@/locales/en/auth.json";
+import modelsEn from "@/locales/en/models.json";
+import apiKeysEn from "@/locales/en/apiKeys.json";
+import usageEn from "@/locales/en/usage.json";
+import costEn from "@/locales/en/cost.json";
+import budgetsEn from "@/locales/en/budgets.json";
+
+import commonZh from "@/locales/zh-CN/common.json";
+import navigationZh from "@/locales/zh-CN/navigation.json";
+import authZh from "@/locales/zh-CN/auth.json";
+import modelsZh from "@/locales/zh-CN/models.json";
+import apiKeysZh from "@/locales/zh-CN/apiKeys.json";
+import usageZh from "@/locales/zh-CN/usage.json";
+import costZh from "@/locales/zh-CN/cost.json";
+import budgetsZh from "@/locales/zh-CN/budgets.json";
+
+export const supportedLngs = ["en", "zh-CN"] as const;
+export type Locale = (typeof supportedLngs)[number];
+
+/**
+ * Namespace registry — the single source of truth for namespaces and their
+ * static resources. Agent 4 owns this file permanently (FILE_OWNERSHIP rule 6).
+ * Function agents only add keys to their own JSON; any new namespace must be
+ * routed through Agent 4.
+ */
+// Namespace registry — the single source of truth for namespaces and their
+// static resources. Agent 4 owns this file permanently (FILE_OWNERSHIP rule 6).
+// Function agents only add keys to their own JSON; any new namespace must be
+// routed through Agent 4.
+//
+// KEY TYPING NOTE: use `satisfies Record` — NOT
+// `Record`. The broad `ResourceKey` target would erase
+// each namespace's concrete key shapes, so `CustomTypeOptions.resources`
+// (types.d.ts) could only type the defaultNS (common) keys and could not infer
+// qualified keys like `navigation:dashboard`. `satisfies object` only validates
+// the shape without widening, keeping the `as const` literal keys intact.
+export const NAMESPACES = [
+ "common",
+ "navigation",
+ "auth",
+ "models",
+ "apiKeys",
+ "usage",
+ "cost",
+ "budgets",
+] as const;
+export type Namespace = (typeof NAMESPACES)[number];
+
+const enResources = {
+ common: commonEn,
+ navigation: navigationEn,
+ auth: authEn,
+ models: modelsEn,
+ apiKeys: apiKeysEn,
+ usage: usageEn,
+ cost: costEn,
+ budgets: budgetsEn,
+} as const satisfies Record;
+
+const zhCNResources = {
+ common: commonZh,
+ navigation: navigationZh,
+ auth: authZh,
+ models: modelsZh,
+ apiKeys: apiKeysZh,
+ usage: usageZh,
+ cost: costZh,
+ budgets: budgetsZh,
+} as const satisfies Record;
+
+/**
+ * Resource loading map: locale -> namespace -> static JSON.
+ * English is the type source of truth (D2). Kept as a literal (`as const`) so
+ * `typeof RESOURCES["en"]` drives `CustomTypeOptions['resources']` with concrete
+ * key shapes for typed `t()` keys.
+ */
+export const RESOURCES = {
+ en: enResources,
+ "zh-CN": zhCNResources,
+} as const satisfies Record;
+
+export const defaultNS: Namespace = "common";
diff --git a/ui/litellm-dashboard/src/locales/en/apiKeys.json b/ui/litellm-dashboard/src/locales/en/apiKeys.json
new file mode 100644
index 00000000000..434c235cd98
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/en/apiKeys.json
@@ -0,0 +1,3 @@
+{
+ "apiKeys": "API Keys"
+}
diff --git a/ui/litellm-dashboard/src/locales/en/auth.json b/ui/litellm-dashboard/src/locales/en/auth.json
new file mode 100644
index 00000000000..bc763076c2d
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/en/auth.json
@@ -0,0 +1,3 @@
+{
+ "signIn": "Sign in"
+}
diff --git a/ui/litellm-dashboard/src/locales/en/budgets.json b/ui/litellm-dashboard/src/locales/en/budgets.json
new file mode 100644
index 00000000000..5e1853f7377
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/en/budgets.json
@@ -0,0 +1,3 @@
+{
+ "budgets": "Budgets"
+}
diff --git a/ui/litellm-dashboard/src/locales/en/common.json b/ui/litellm-dashboard/src/locales/en/common.json
new file mode 100644
index 00000000000..359bde9f7f4
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/en/common.json
@@ -0,0 +1,11 @@
+{
+ "language": {
+ "name": "Language",
+ "switchTo": "Switch language"
+ },
+ "languages": {
+ "en": "English",
+ "zh-CN": "简体中文"
+ },
+ "loadError": "An error occurred while loading the page."
+}
diff --git a/ui/litellm-dashboard/src/locales/en/cost.json b/ui/litellm-dashboard/src/locales/en/cost.json
new file mode 100644
index 00000000000..a1f2a41b26e
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/en/cost.json
@@ -0,0 +1,3 @@
+{
+ "cost": "Cost"
+}
diff --git a/ui/litellm-dashboard/src/locales/en/models.json b/ui/litellm-dashboard/src/locales/en/models.json
new file mode 100644
index 00000000000..ee2735fe19d
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/en/models.json
@@ -0,0 +1,3 @@
+{
+ "models": "Models"
+}
diff --git a/ui/litellm-dashboard/src/locales/en/navigation.json b/ui/litellm-dashboard/src/locales/en/navigation.json
new file mode 100644
index 00000000000..ac4a7159bb0
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/en/navigation.json
@@ -0,0 +1,3 @@
+{
+ "dashboard": "Dashboard"
+}
diff --git a/ui/litellm-dashboard/src/locales/en/usage.json b/ui/litellm-dashboard/src/locales/en/usage.json
new file mode 100644
index 00000000000..97956371445
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/en/usage.json
@@ -0,0 +1,3 @@
+{
+ "usage": "Usage"
+}
diff --git a/ui/litellm-dashboard/src/locales/zh-CN/apiKeys.json b/ui/litellm-dashboard/src/locales/zh-CN/apiKeys.json
new file mode 100644
index 00000000000..e5d49c4e033
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/zh-CN/apiKeys.json
@@ -0,0 +1,3 @@
+{
+ "apiKeys": "API 密钥"
+}
diff --git a/ui/litellm-dashboard/src/locales/zh-CN/auth.json b/ui/litellm-dashboard/src/locales/zh-CN/auth.json
new file mode 100644
index 00000000000..b565b09c92d
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/zh-CN/auth.json
@@ -0,0 +1,3 @@
+{
+ "signIn": "登录"
+}
diff --git a/ui/litellm-dashboard/src/locales/zh-CN/budgets.json b/ui/litellm-dashboard/src/locales/zh-CN/budgets.json
new file mode 100644
index 00000000000..0453d887828
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/zh-CN/budgets.json
@@ -0,0 +1,3 @@
+{
+ "budgets": "预算"
+}
diff --git a/ui/litellm-dashboard/src/locales/zh-CN/common.json b/ui/litellm-dashboard/src/locales/zh-CN/common.json
new file mode 100644
index 00000000000..d022c9860c9
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/zh-CN/common.json
@@ -0,0 +1,11 @@
+{
+ "language": {
+ "name": "语言",
+ "switchTo": "切换语言"
+ },
+ "languages": {
+ "en": "English",
+ "zh-CN": "简体中文"
+ },
+ "loadError": "加载页面时发生错误。"
+}
diff --git a/ui/litellm-dashboard/src/locales/zh-CN/cost.json b/ui/litellm-dashboard/src/locales/zh-CN/cost.json
new file mode 100644
index 00000000000..eceb5940175
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/zh-CN/cost.json
@@ -0,0 +1,3 @@
+{
+ "cost": "成本"
+}
diff --git a/ui/litellm-dashboard/src/locales/zh-CN/models.json b/ui/litellm-dashboard/src/locales/zh-CN/models.json
new file mode 100644
index 00000000000..0610c8695f1
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/zh-CN/models.json
@@ -0,0 +1,3 @@
+{
+ "models": "模型"
+}
diff --git a/ui/litellm-dashboard/src/locales/zh-CN/navigation.json b/ui/litellm-dashboard/src/locales/zh-CN/navigation.json
new file mode 100644
index 00000000000..712c51e959f
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/zh-CN/navigation.json
@@ -0,0 +1,3 @@
+{
+ "dashboard": "仪表盘"
+}
diff --git a/ui/litellm-dashboard/src/locales/zh-CN/usage.json b/ui/litellm-dashboard/src/locales/zh-CN/usage.json
new file mode 100644
index 00000000000..ded65168c54
--- /dev/null
+++ b/ui/litellm-dashboard/src/locales/zh-CN/usage.json
@@ -0,0 +1,3 @@
+{
+ "usage": "用量"
+}