mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
6321c8b52d
commit
37dd0678f6
31 changed files with 1135 additions and 12 deletions
120
ui/litellm-dashboard/package-lock.json
generated
120
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
111
ui/litellm-dashboard/src/i18n/I18nProvider.integration.test.tsx
Normal file
111
ui/litellm-dashboard/src/i18n/I18nProvider.integration.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
96
ui/litellm-dashboard/src/i18n/I18nProvider.tsx
Normal file
96
ui/litellm-dashboard/src/i18n/I18nProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
ui/litellm-dashboard/src/i18n/detectLocale.test.ts
Normal file
67
ui/litellm-dashboard/src/i18n/detectLocale.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
ui/litellm-dashboard/src/i18n/detectLocale.ts
Normal file
40
ui/litellm-dashboard/src/i18n/detectLocale.ts
Normal 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);
|
||||
}
|
||||
30
ui/litellm-dashboard/src/i18n/i18n.test.ts
Normal file
30
ui/litellm-dashboard/src/i18n/i18n.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
48
ui/litellm-dashboard/src/i18n/i18n.ts
Normal file
48
ui/litellm-dashboard/src/i18n/i18n.ts
Normal 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;
|
||||
}
|
||||
172
ui/litellm-dashboard/src/i18n/localePreferences.test.ts
Normal file
172
ui/litellm-dashboard/src/i18n/localePreferences.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
139
ui/litellm-dashboard/src/i18n/localePreferences.ts
Normal file
139
ui/litellm-dashboard/src/i18n/localePreferences.ts
Normal 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-");
|
||||
}
|
||||
34
ui/litellm-dashboard/src/i18n/resources.test.ts
Normal file
34
ui/litellm-dashboard/src/i18n/resources.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
86
ui/litellm-dashboard/src/i18n/resources/registry.ts
Normal file
86
ui/litellm-dashboard/src/i18n/resources/registry.ts
Normal 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";
|
||||
3
ui/litellm-dashboard/src/locales/en/apiKeys.json
Normal file
3
ui/litellm-dashboard/src/locales/en/apiKeys.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"apiKeys": "API Keys"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/auth.json
Normal file
3
ui/litellm-dashboard/src/locales/en/auth.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"signIn": "Sign in"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/budgets.json
Normal file
3
ui/litellm-dashboard/src/locales/en/budgets.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"budgets": "Budgets"
|
||||
}
|
||||
11
ui/litellm-dashboard/src/locales/en/common.json
Normal file
11
ui/litellm-dashboard/src/locales/en/common.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"language": {
|
||||
"name": "Language",
|
||||
"switchTo": "Switch language"
|
||||
},
|
||||
"languages": {
|
||||
"en": "English",
|
||||
"zh-CN": "简体中文"
|
||||
},
|
||||
"loadError": "An error occurred while loading the page."
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/cost.json
Normal file
3
ui/litellm-dashboard/src/locales/en/cost.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"cost": "Cost"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/models.json
Normal file
3
ui/litellm-dashboard/src/locales/en/models.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"models": "Models"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/navigation.json
Normal file
3
ui/litellm-dashboard/src/locales/en/navigation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"dashboard": "Dashboard"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/usage.json
Normal file
3
ui/litellm-dashboard/src/locales/en/usage.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"usage": "Usage"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/apiKeys.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/apiKeys.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"apiKeys": "API 密钥"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/auth.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/auth.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"signIn": "登录"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/budgets.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/budgets.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"budgets": "预算"
|
||||
}
|
||||
11
ui/litellm-dashboard/src/locales/zh-CN/common.json
Normal file
11
ui/litellm-dashboard/src/locales/zh-CN/common.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"language": {
|
||||
"name": "语言",
|
||||
"switchTo": "切换语言"
|
||||
},
|
||||
"languages": {
|
||||
"en": "English",
|
||||
"zh-CN": "简体中文"
|
||||
},
|
||||
"loadError": "加载页面时发生错误。"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/cost.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/cost.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"cost": "成本"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/models.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/models.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"models": "模型"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/navigation.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/navigation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"dashboard": "仪表盘"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/usage.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/usage.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"usage": "用量"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue