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": "用量" +}