= ({ onLogout, colla
/>
)}
+
diff --git a/ui/litellm-dashboard/src/i18n/I18nProvider.test.tsx b/ui/litellm-dashboard/src/i18n/I18nProvider.test.tsx
new file mode 100644
index 00000000000..1cf0c8971a0
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/I18nProvider.test.tsx
@@ -0,0 +1,82 @@
+import { render, screen } from "@testing-library/react";
+import i18next, { type InitOptions } from "i18next";
+import { useState } from "react";
+import { I18nextProvider, initReactI18next, useTranslation } from "react-i18next";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { LanguageGate } from "./I18nProvider";
+import { Locales, Messages } from "./languages";
+import en from "./locales/en.json";
+
+const JAPANESE_LOGOUT = "ログアウト";
+const japanese: Messages = { shell: { sidebarAccountMenu: { logout: JAPANESE_LOGOUT } } };
+
+const options: InitOptions = {
+ lng: "en",
+ fallbackLng: "en",
+ interpolation: { escapeValue: false },
+ resources: { en: { translation: en } },
+ initAsync: false,
+};
+
+const createI18n = () => {
+ const instance = i18next.createInstance();
+ void instance.use(initReactI18next).init(options);
+ return instance;
+};
+
+function AccountMenuLabels() {
+ const { t, i18n } = useTranslation();
+ const [labelReadOnce] = useState(() => i18n.t("shell.sidebarAccountMenu.logout"));
+ return (
+ <>
+ {t("shell.sidebarAccountMenu.logout")}
+ {labelReadOnce}
+ {t("shell.sidebarAccountMenu.changePassword")}
+ >
+ );
+}
+
+const renderGate = (stored: string | null, locales: Locales) => {
+ const instance = createI18n();
+ render(
+
+ stored, write: vi.fn() }}>
+
+
+ ,
+ );
+};
+
+afterEach(() => {
+ document.documentElement.lang = "en";
+ vi.restoreAllMocks();
+});
+
+describe("LanguageGate", () => {
+ it("shows the stored language, with English for strings it hasn't translated", async () => {
+ renderGate("ja", new Map([["ja", async () => japanese]]));
+
+ expect(await screen.findByTestId("logout")).toHaveTextContent(JAPANESE_LOGOUT);
+ expect(screen.getByTestId("logout-read-once")).toHaveTextContent(JAPANESE_LOGOUT);
+ expect(screen.getByTestId("change-password")).toHaveTextContent(en.shell.sidebarAccountMenu.changePassword);
+ expect(document.documentElement.lang).toBe("ja");
+ });
+
+ it.each([null, "en", "fr"])("shows English without loading anything when the stored value is %j", (stored) => {
+ const load = vi.fn(async () => japanese);
+ renderGate(stored, new Map([["ja", load]]));
+
+ expect(screen.getByTestId("logout")).toHaveTextContent(en.shell.sidebarAccountMenu.logout);
+ expect(load).not.toHaveBeenCalled();
+ expect(document.documentElement.lang).toBe("en");
+ });
+
+ it("falls back to English when the translations fail to load", async () => {
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ renderGate("ja", new Map([["ja", () => Promise.reject(new Error("offline"))]]));
+
+ expect(await screen.findByTestId("logout")).toHaveTextContent(en.shell.sidebarAccountMenu.logout);
+ expect(document.documentElement.lang).toBe("en");
+ });
+});
diff --git a/ui/litellm-dashboard/src/i18n/I18nProvider.tsx b/ui/litellm-dashboard/src/i18n/I18nProvider.tsx
index f0ecdfe2568..f96658fbbb3 100644
--- a/ui/litellm-dashboard/src/i18n/I18nProvider.tsx
+++ b/ui/litellm-dashboard/src/i18n/I18nProvider.tsx
@@ -1,9 +1,55 @@
"use client";
+import type { i18n as I18n } from "i18next";
+import { useEffect, useState, useSyncExternalStore } from "react";
import { I18nextProvider } from "react-i18next";
+import LoadingScreen from "@/components/common_components/LoadingScreen";
+
import i18n from "./i18n";
+import { browserLanguageStore, DEFAULT_LANGUAGE, LanguageStore, Locales, LOCALES, resolveLanguage } from "./languages";
+
+type LanguageGateProps = {
+ i18n: I18n;
+ locales: Locales;
+ store: LanguageStore;
+ children: React.ReactNode;
+};
+
+const neverChanges = () => () => undefined;
+
+export function LanguageGate({ i18n, locales, store, children }: LanguageGateProps) {
+ const language = useSyncExternalStore(
+ neverChanges,
+ () => resolveLanguage(store.read(), locales),
+ () => DEFAULT_LANGUAGE,
+ );
+ const [settled, setSettled] = useState(DEFAULT_LANGUAGE);
+
+ useEffect(() => {
+ const load = locales.get(language);
+ if (!load) return;
+ load()
+ .then((messages) => {
+ i18n.addResourceBundle(language, "translation", messages);
+ return i18n.changeLanguage(language);
+ })
+ .then(() => {
+ document.documentElement.lang = language;
+ })
+ .catch((error: unknown) => console.warn(`Couldn't load the "${language}" translations`, error))
+ .finally(() => setSettled(language));
+ }, [i18n, language, locales]);
+
+ return language === settled ? children : ;
+}
export default function I18nProvider({ children }: { children: React.ReactNode }) {
- return {children};
+ return (
+
+
+ {children}
+
+
+ );
}
diff --git a/ui/litellm-dashboard/src/i18n/languages.test.ts b/ui/litellm-dashboard/src/i18n/languages.test.ts
new file mode 100644
index 00000000000..5a462d7a52d
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/languages.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import { englishName, Locales, nativeName, resolveLanguage, supportedLanguages } from "./languages";
+
+const locales: Locales = new Map([["ja", async () => ({})]]);
+
+describe("resolveLanguage", () => {
+ it("uses the stored language when it has translations", () => {
+ expect(resolveLanguage("ja", locales)).toBe("ja");
+ });
+
+ it.each([null, "", "en", "fr", "JA", "ja-JP", "__proto__"])("shows English when the stored value is %j", (stored) => {
+ expect(resolveLanguage(stored, locales)).toBe("en");
+ });
+
+ it("shows English when no language besides English has translations", () => {
+ expect(resolveLanguage("ja", new Map())).toBe("en");
+ });
+});
+
+describe("supportedLanguages", () => {
+ it("puts English first, then every language with translations", () => {
+ expect(supportedLanguages(locales)).toEqual(["en", "ja"]);
+ expect(supportedLanguages(new Map())).toEqual(["en"]);
+ });
+});
+
+describe("language names", () => {
+ it("names a language in itself, starting with a capital letter", () => {
+ const spanish = nativeName("es");
+
+ expect(spanish.toLocaleLowerCase("es")).toBe(
+ new Intl.DisplayNames(["es"], { type: "language" }).of("es")?.toLocaleLowerCase("es"),
+ );
+ expect(spanish.charAt(0)).toBe(spanish.charAt(0).toLocaleUpperCase("es"));
+ });
+
+ it("names a language in English for the secondary label", () => {
+ expect(englishName("ja")).toBe(new Intl.DisplayNames(["en"], { type: "language" }).of("ja"));
+ expect(englishName("ja")).not.toBe(nativeName("ja"));
+ });
+});
diff --git a/ui/litellm-dashboard/src/i18n/languages.ts b/ui/litellm-dashboard/src/i18n/languages.ts
new file mode 100644
index 00000000000..616523a7e54
--- /dev/null
+++ b/ui/litellm-dashboard/src/i18n/languages.ts
@@ -0,0 +1,30 @@
+import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
+
+export type Messages = { readonly [key: string]: string | Messages };
+export type Locales = ReadonlyMap Promise>;
+export type LanguageStore = { readonly read: () => string | null; readonly write: (language: string) => void };
+
+export const DEFAULT_LANGUAGE = "en";
+export const LANGUAGE_STORAGE_KEY = "litellm_language";
+
+export const LOCALES: Locales = new Map();
+
+export const browserLanguageStore: LanguageStore = {
+ read: () => getLocalStorageItem(LANGUAGE_STORAGE_KEY),
+ write: (language) => setLocalStorageItem(LANGUAGE_STORAGE_KEY, language),
+};
+
+export const resolveLanguage = (stored: string | null, locales: Locales): string =>
+ stored !== null && locales.has(stored) ? stored : DEFAULT_LANGUAGE;
+
+export const supportedLanguages = (locales: Locales): readonly string[] => [DEFAULT_LANGUAGE, ...locales.keys()];
+
+const displayName = (language: string, displayLanguage: string): string =>
+ new Intl.DisplayNames([displayLanguage], { type: "language" }).of(language) ?? language;
+
+export const nativeName = (language: string): string => {
+ const name = displayName(language, language);
+ return name.charAt(0).toLocaleUpperCase(language) + name.slice(1);
+};
+
+export const englishName = (language: string): string => displayName(language, DEFAULT_LANGUAGE);
diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json
index f8886610e01..c72b9b91ca7 100644
--- a/ui/litellm-dashboard/src/i18n/locales/en.json
+++ b/ui/litellm-dashboard/src/i18n/locales/en.json
@@ -8085,6 +8085,10 @@
"copyUserId": "Copy user ID",
"hideAria": "Hide ",
"toggleHideAria": "Toggle hide {{name}}",
+ "language": {
+ "label": "Language",
+ "fallbackNote": "Missing translations show in English. Help translate"
+ },
"changePassword": "Change Password",
"logout": "Logout"
},