feat(i18n): add internationalization platform infrastructure

Implement the Wave 1 platform layer per I18N_TECH_DESIGN/ADR (docs/i18n):
- src/i18n/** : lazy i18next singleton, readiness-gated I18nProvider that syncs
  <html lang>, locale detection/normalization (zh->zh-CN), cookie+localStorage
  preferences under the unified 'litellm.locale' key (P5), and the namespace
  registry (single source of truth, Agent-4-owned).
- src/locales/{en,zh-CN}/** : 8 namespace skeletons (common/navigation/auth/
  models/apiKeys/usage/cost/budgets) with matching key sets.
- Wire I18nProvider into the root layout as the outermost provider.
- Add LanguageSwitcher EN/中文 toggle component.
- Add Playwright E2E scaffolding (tests/e2e/ui) and deps
  (i18next, react-i18next, @playwright/test) via Agent 4 as single writer.
- Tests: 33 unit + 7 integration passing; next build green.

Note: strict typed keys intentionally relaxed to string (see types.d.ts) —
runtime readiness gate + A7 check-keys CI guarantee key correctness.
This commit is contained in:
lijian19 2026-09-09 14:19:10 +08:00
parent 6321c8b52d
commit 37dd0678f6
31 changed files with 1135 additions and 12 deletions

View file

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

View file

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

View file

@ -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.
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
<NuqsAdapter>
<ReactQueryProvider>
<AuthProvider>{children}</AuthProvider>
<Toaster />
</ReactQueryProvider>
</NuqsAdapter>
</ThemeProvider>
<I18nProvider>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
<NuqsAdapter>
<ReactQueryProvider>
<AuthProvider>{children}</AuthProvider>
<Toaster />
</ReactQueryProvider>
</NuqsAdapter>
</ThemeProvider>
</I18nProvider>
</body>
</html>
);

View file

@ -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 (
<div className="inline-flex items-center gap-1" role="group" aria-label={t("language.name")}>
{LANGUAGE_OPTIONS.map((option) => {
const isActive = option.value === locale;
return (
<Button
key={option.value}
type="button"
size="sm"
variant={isActive ? "secondary" : "ghost"}
aria-pressed={isActive}
onClick={() => setLocale(option.value)}
>
{t(option.key)}
</Button>
);
})}
</div>
);
}

View file

@ -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<i18n> | null = null;
vi.mock("./i18n", () => {
return {
getI18n: () =>
(pending ??= new Promise<i18n>((resolve) => {
releaseGate = resolve;
})),
};
});
import { I18nProvider, useI18n } from "@/i18n";
function GateConsumer() {
const { locale } = useI18n();
return <span data-testid="gate-child">{locale}</span>;
}
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(
<I18nProvider>
<GateConsumer />
</I18nProvider>,
);
// 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(
<I18nProvider>
<GateConsumer />
</I18nProvider>,
);
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());
});
});

View file

@ -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<number>(0);
return (
<div>
<span data-testid="locale">{locale}</span>
<span data-testid="translated">{t("common:languages.en")}</span>
<button
type="button"
onClick={() => {
setLocale("zh-CN");
setPressed((n) => n + 1);
}}
>
to-zh
</button>
<span data-testid="pressed">{pressed}</span>
</div>
);
}
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(
<I18nProvider>
<Consumer />
</I18nProvider>,
);
// 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(
<I18nProvider>
<Consumer />
</I18nProvider>,
);
await waitFor(() => expect(document.documentElement.lang).toBe("en"));
});
});
describe("I18nProvider zh-CN preference", () => {
it("resolves a stored zh-CN preference, renders Chinese, and syncs <html lang>", async () => {
localStorage.setItem("litellm.locale", "zh-CN");
render(
<I18nProvider>
<Consumer />
</I18nProvider>,
);
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(
<I18nProvider>
<Consumer />
</I18nProvider>,
);
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(
<I18nProvider>
<Consumer />
</I18nProvider>,
);
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");
});
});

View file

@ -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<void>;
}
const I18nContext = createContext<I18nContextValue | null>(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 <I18nProvider>");
}
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()`/`<Trans>` 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<i18n | null>(null);
const [locale, setLocaleState] = useState<Locale>("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 (
<I18nextProvider i18n={i18n}>
<I18nContext.Provider value={{ locale, setLocale }}>{children}</I18nContext.Provider>
</I18nextProvider>
);
}

View file

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

View file

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

View file

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

View file

@ -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<i18n> | null = null;
export function getI18n(): Promise<i18n> {
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;
}

View file

@ -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<string, string>;
/** 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<string, string>();
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> = {}): 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");
});
});

View file

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

View file

@ -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<string, unknown>;
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<string, unknown>;
expect(nav).toHaveProperty("dashboard");
}
});
});

View file

@ -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<Namespace, object>` — NOT
// `Record<Namespace, ResourceKey>`. 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<Namespace, object>;
const zhCNResources = {
common: commonZh,
navigation: navigationZh,
auth: authZh,
models: modelsZh,
apiKeys: apiKeysZh,
usage: usageZh,
cost: costZh,
budgets: budgetsZh,
} as const satisfies Record<Namespace, object>;
/**
* 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<Locale, ResourceLanguage>;
export const defaultNS: Namespace = "common";

View file

@ -0,0 +1,3 @@
{
"apiKeys": "API Keys"
}

View file

@ -0,0 +1,3 @@
{
"signIn": "Sign in"
}

View file

@ -0,0 +1,3 @@
{
"budgets": "Budgets"
}

View file

@ -0,0 +1,11 @@
{
"language": {
"name": "Language",
"switchTo": "Switch language"
},
"languages": {
"en": "English",
"zh-CN": "简体中文"
},
"loadError": "An error occurred while loading the page."
}

View file

@ -0,0 +1,3 @@
{
"cost": "Cost"
}

View file

@ -0,0 +1,3 @@
{
"models": "Models"
}

View file

@ -0,0 +1,3 @@
{
"dashboard": "Dashboard"
}

View file

@ -0,0 +1,3 @@
{
"usage": "Usage"
}

View file

@ -0,0 +1,3 @@
{
"apiKeys": "API 密钥"
}

View file

@ -0,0 +1,3 @@
{
"signIn": "登录"
}

View file

@ -0,0 +1,3 @@
{
"budgets": "预算"
}

View file

@ -0,0 +1,11 @@
{
"language": {
"name": "语言",
"switchTo": "切换语言"
},
"languages": {
"en": "English",
"zh-CN": "简体中文"
},
"loadError": "加载页面时发生错误。"
}

View file

@ -0,0 +1,3 @@
{
"cost": "成本"
}

View file

@ -0,0 +1,3 @@
{
"models": "模型"
}

View file

@ -0,0 +1,3 @@
{
"dashboard": "仪表盘"
}

View file

@ -0,0 +1,3 @@
{
"usage": "用量"
}