From 77b4644523c20e8338f1986d88d24a5c6ed22b3a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 6 Nov 2026 14:59:15 -0800 Subject: [PATCH] feat(ui): format dashboard numbers, money and dates through i18next (#50443) * feat(ui): format dashboard values through i18next formatters Register named formats on i18next's formatter (usd, compact, duration, relativetime, number and five date presets) with a cache keyed on the language and options, add format.* keys to en.json, and move the usage stat strip onto them. * fix(ui): keep number formats in USD and the format cache bounded Force USD on the number and compact formats, clean underscore language codes like i18next does, fall back when options cannot be serialized, keep formatted placeholders alone in format keys, and pin the remaining stat strip values in the German test. * refactor(ui): format durations with Intl.DurationFormat and load its polyfill on demand Browsers without Intl.DurationFormat (Chrome and Edge before 129, Firefox before 136) load the pinned FormatJS polyfill through a dynamic import and re-render translations once it is installed. The milliseconds-to-units split and the 0s fallback stay, so the output matches the previous hand-built formatter. * test(ui): cover duration edge cases on the polyfill path * refactor(ui): drop the unused duration format and its polyfill Nothing outside tests called format.duration, and its 1m 15s style doesn't match the decimal-second latencies the dashboard shows today. A duration format will come with the pages that need it, in the style they already use. --- .../UsageStatStrip.integration.test.tsx | 67 ++++++++- .../components/overview/UsageStatStrip.tsx | 57 +++++--- .../components/overview/overviewData.ts | 2 - ui/litellm-dashboard/src/i18n/formats.test.ts | 131 ++++++++++++++++++ ui/litellm-dashboard/src/i18n/formats.ts | 80 +++++++++++ ui/litellm-dashboard/src/i18n/i18n.ts | 3 + ui/litellm-dashboard/src/i18n/locales.test.ts | 4 +- ui/litellm-dashboard/src/i18n/locales/en.json | 11 ++ 8 files changed, 333 insertions(+), 22 deletions(-) create mode 100644 ui/litellm-dashboard/src/i18n/formats.test.ts create mode 100644 ui/litellm-dashboard/src/i18n/formats.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.integration.test.tsx index 2e2c42cb218..c6b41cb9e31 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.integration.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import i18n from "@/i18n/i18n"; import type { OverviewTotals } from "./overviewData"; import UsageStatStrip from "./UsageStatStrip"; @@ -19,6 +20,15 @@ const totals: OverviewTotals = { successRate: 80, }; +const asRendered = (text: string) => text.replace(/\s+/g, " "); +const SECONDS = { + style: "unit", + unit: "second", + unitDisplay: "narrow", + minimumFractionDigits: 1, + maximumFractionDigits: 1, +} as const; + const defaultProps = { results: [], totals, @@ -58,3 +68,58 @@ describe("UsageStatStrip token breakdown", () => { expect(screen.queryByText("failed")).not.toBeInTheDocument(); }); }); + +describe("UsageStatStrip in another UI language", () => { + afterEach(async () => { + await i18n.changeLanguage("en"); + i18n.removeResourceBundle("de", "translation"); + }); + + it("should format every value in the selected language and keep money in US dollars", async () => { + i18n.addResourceBundle("de", "translation", {}); + await i18n.changeLanguage("de"); + const german = new Intl.NumberFormat("de"); + const dollars = new Intl.NumberFormat("de", { style: "currency", currency: "USD" }); + const euros = new Intl.NumberFormat("de", { style: "currency", currency: "EUR" }); + const dollarsToFourPlaces = new Intl.NumberFormat("de", { + style: "currency", + currency: "USD", + minimumFractionDigits: 4, + }); + const oneDecimal = new Intl.NumberFormat("de", { minimumFractionDigits: 1 }); + const milliseconds = new Intl.NumberFormat("de", { style: "unit", unit: "millisecond", unitDisplay: "narrow" }); + + render(); + + expect(screen.getByText(asRendered(dollars.format(42.5)))).toBeInTheDocument(); + expect(screen.getByText(asRendered(`of ${dollars.format(1500)} budget`))).toBeInTheDocument(); + expect(screen.getByText(asRendered(dollarsToFourPlaces.format(4.25)))).toBeInTheDocument(); + expect(screen.getByText(asRendered(milliseconds.format(250)))).toBeInTheDocument(); + expect(screen.getByText(`${oneDecimal.format(80)}% success rate`)).toBeInTheDocument(); + expect(screen.getByTitle(`${german.format(9123456)} tokens`)).toBeInTheDocument(); + expect(screen.queryByText(asRendered(euros.format(42.5)))).not.toBeInTheDocument(); + }); + + it("should format large counts, compact tokens, seconds and the token breakdown in the selected language", async () => { + const user = userEvent.setup(); + i18n.addResourceBundle("de", "translation", {}); + await i18n.changeLanguage("de"); + const german = new Intl.NumberFormat("de"); + const compact = new Intl.NumberFormat("de", { notation: "compact", maximumFractionDigits: 1 }); + const large = { ...totals, requests: 8123456, successful: 7654321, failed: 469135, avgLatencyMs: 1234 }; + + render(); + + expect(screen.getByText(german.format(8123456))).toBeInTheDocument(); + expect(screen.getByText(german.format(7654321))).toBeInTheDocument(); + expect(screen.getByText(german.format(469135))).toBeInTheDocument(); + expect(screen.getByText(asRendered(compact.format(9123456)))).toBeInTheDocument(); + expect(screen.getByText(asRendered(new Intl.NumberFormat("de", SECONDS).format(1.234)))).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "View token breakdown" })); + const breakdown = await screen.findByRole("dialog", { name: "What counts toward total tokens?" }); + [8123456, 5123456, 2000000, 1000000, 9123456].forEach((count) => + expect(breakdown).toHaveTextContent(german.format(count)), + ); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx index 15c33d8653c..48eb1e96691 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx @@ -1,14 +1,19 @@ "use client"; import React, { useMemo } from "react"; +import type { TFunction } from "i18next"; import { Trans, useTranslation } from "react-i18next"; import type { DailyData } from "@/components/UsagePage/types"; import { cn } from "@/lib/cva.config"; import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; -import { dailyTotals, formatCompact, formatLatency, formatUsd, type OverviewTotals } from "./overviewData"; +import { dailyTotals, type OverviewTotals } from "./overviewData"; import { PANEL_INSET_X, Sparkline, Stat } from "./Primitives"; const BRAND = "#2b3fd6"; +const ONE_DECIMAL = { minimumFractionDigits: 1, maximumFractionDigits: 1 } as const; +const FOUR_DECIMALS = { minimumFractionDigits: 4, maximumFractionDigits: 4 } as const; +const MILLISECONDS = { style: "unit", unit: "millisecond", unitDisplay: "narrow", maximumFractionDigits: 0 } as const; +const SECONDS = { ...ONE_DECIMAL, style: "unit", unit: "second", unitDisplay: "narrow" } as const; interface UsageStatStripProps { results: readonly DailyData[]; @@ -33,15 +38,19 @@ export default function UsageStatStrip({ }> }> {!requestCountsPending && ( @@ -50,7 +59,9 @@ export default function UsageStatStrip({ {totals.successful.toLocaleString()}, + count: ( + {t("format.number", { val: totals.successful })} + ), }} /> @@ -60,7 +71,7 @@ export default function UsageStatStrip({ components={{ count: ( 0 ? "text-destructive tabular-nums" : "tabular-nums"}> - {totals.failed.toLocaleString()} + {t("format.number", { val: totals.failed })} ), }} @@ -72,11 +83,15 @@ export default function UsageStatStrip({ }> {!loading && ( @@ -89,23 +104,23 @@ export default function UsageStatStrip({
{t("usage.statStrip.tokenPopover.inputTokens")}
-
{totals.inputTokens.toLocaleString()}
+
{t("format.number", { val: totals.inputTokens })}
{t("usage.statStrip.tokenPopover.cacheReads")}
-
{totals.cacheReadTokens.toLocaleString()}
+
{t("format.number", { val: totals.cacheReadTokens })}
{t("usage.statStrip.tokenPopover.cacheWrites")}
-
{totals.cacheWriteTokens.toLocaleString()}
+
{t("format.number", { val: totals.cacheWriteTokens })}
{t("usage.statStrip.tokenPopover.outputTokens")}
-
{totals.outputTokens.toLocaleString()}
+
{t("format.number", { val: totals.outputTokens })}
{t("usage.statStrip.tokenPopover.totalTokens")}
-
{totals.tokens.toLocaleString()}
+
{t("format.number", { val: totals.tokens })}

{t("usage.statStrip.tokenPopover.pricingNote")}

@@ -118,17 +133,19 @@ export default function UsageStatStrip({
@@ -137,6 +154,12 @@ export default function UsageStatStrip({ ); } +function latency(ms: number, t: TFunction): string { + return ms < 1000 + ? t("format.number", { val: ms, ...MILLISECONDS }) + : t("format.number", { val: ms / 1000, ...SECONDS }); +} + function StatCell({ children, trend }: { children: React.ReactNode; trend?: React.ReactNode }) { return (
{ export const formatCompact = (value: number): string => Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value); -export const formatLatency = (ms: number): string => (ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`); - export interface OverviewTotals { spend: number; requests: number; diff --git a/ui/litellm-dashboard/src/i18n/formats.test.ts b/ui/litellm-dashboard/src/i18n/formats.test.ts new file mode 100644 index 00000000000..4785b46cf62 --- /dev/null +++ b/ui/litellm-dashboard/src/i18n/formats.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { cachedFormat } from "./formats"; +import i18n from "./i18n"; +import en from "./locales/en.json"; + +type Messages = { readonly [key: string]: string | Messages }; + +const entries = (node: Messages, prefix: string): ReadonlyArray => + Object.entries(node).flatMap(([key, value]) => + typeof value === "string" ? [[`${prefix}${key}`, value] as const] : entries(value, `${prefix}${key}.`), + ); + +const strings = (node: Messages): readonly string[] => entries(node, "").map(([, text]) => text); + +const formatsInEnglish = [ + ...new Set(strings(en).flatMap((text) => [...text.matchAll(/\{\{\s*\w+\s*,\s*([^}]+?)\s*\}\}/g)].map(([, f]) => f))), +]; + +const useLanguage = async (language: string) => { + i18n.addResourceBundle(language, "translation", {}); + await i18n.changeLanguage(language); +}; + +afterEach(async () => { + await i18n.changeLanguage("en"); + ["de", "zh-CN", "ja", "fr", "en-GB"].forEach((language) => i18n.removeResourceBundle(language, "translation")); +}); + +describe("format keys in English", () => { + it("formats numbers and USD the way the dashboard shows them today", () => { + expect(i18n.t("format.number", { val: 9123456 })).toBe("9,123,456"); + expect(i18n.t("format.number", { val: 80, minimumFractionDigits: 1, maximumFractionDigits: 1 })).toBe("80.0"); + expect(i18n.t("format.compactNumber", { val: 75000 })).toBe("75K"); + expect(i18n.t("format.compactNumber", { val: 41_730_000 })).toBe("41.7M"); + expect(i18n.t("format.compactNumber", { val: 75000, notation: "standard" })).toBe("75K"); + expect(i18n.t("format.usd", { val: 1234.5 })).toBe("$1,234.50"); + expect(i18n.t("format.usd", { val: 0.0838, minimumFractionDigits: 4, maximumFractionDigits: 4 })).toBe("$0.0838"); + }); + + it("formats dates in local time with a 24-hour clock that starts at 00", () => { + const morning = new Date(2026, 8, 5, 4, 3, 2); + expect(i18n.t("format.date", { val: morning })).toBe("Sep 5, 2026"); + expect(i18n.t("format.date", { val: morning, month: "long" })).toBe("Sep 5, 2026"); + expect(i18n.t("format.monthDay", { val: morning })).toBe("Sep 5"); + expect(i18n.t("format.monthDayTime", { val: morning })).toBe("Sep 5, 04:03:02"); + expect(i18n.t("format.timestamp", { val: morning })).toBe("Sep 5, 2026, 04:03:02"); + expect(i18n.t("format.numericDateTime", { val: morning })).toBe("9/5/2026, 4:03:02 AM"); + expect(i18n.t("format.monthDayTime", { val: new Date(2026, 0, 2, 0, 0, 5) })).toBe("Jan 2, 00:00:05"); + }); + + it("accepts epoch milliseconds and ISO strings as dates, and a time zone from the caller", () => { + const iso = "2026-09-05T12:30:00Z"; + const expected = i18n.t("format.timestamp", { val: new Date(iso) }); + expect(i18n.t("format.timestamp", { val: iso })).toBe(expected); + expect(i18n.t("format.timestamp", { val: Date.parse(iso) })).toBe(expected); + expect(i18n.t("format.timestamp", { val: iso, timeZone: "UTC" })).toBe("Sep 5, 2026, 12:30:00"); + }); + + it("formats relative time in the unit the caller names", () => { + expect(i18n.t("format.relativeTime", { val: -3 })).toBe("3 days ago"); + expect(i18n.t("format.relativeTime", { val: 2, range: "hour" })).toBe("in 2 hours"); + }); + + it("only names formatters that are registered", () => { + expect(formatsInEnglish.length).toBeGreaterThan(0); + const unformatted = formatsInEnglish.filter( + (format) => typeof i18n.services.formatter?.format(0, format, "en", {}) !== "string", + ); + expect(unformatted).toEqual([]); + }); + + it("keeps every formatted placeholder alone in a format key", () => { + const formatted = entries(en, "").filter(([, text]) => /\{\{[^}]*,[^}]*\}\}/.test(text)); + expect(formatted.length).toBeGreaterThan(0); + const mixed = formatted.filter(([key, text]) => !key.startsWith("format.") || !/^\{\{val, [a-z]+\}\}$/.test(text)); + expect(mixed).toEqual([]); + }); + + it("keeps a currency asked of the number formats in US dollars", () => { + expect(i18n.t("format.number", { val: 1234.5, style: "currency", currency: "EUR" })).toBe("$1,234.50"); + expect(i18n.t("format.compactNumber", { val: 1_234_567, style: "currency", currency: "EUR" })).toBe("$1.2M"); + }); + + it("reads underscore language codes the way i18next does", () => { + expect(i18n.t("format.number", { val: 1234.5, lng: "de_DE" })).toBe(new Intl.NumberFormat("de-DE").format(1234.5)); + }); + + it("still formats when the options cannot be serialized", () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + expect(i18n.t("format.number", { val: 1234.5, circular })).toBe("1,234.5"); + }); + + it("builds one formatter per language and options, not one per value", () => { + const build = vi.fn((lng: string) => (value: number) => `${lng} ${value}`); + i18n.services.formatter?.add("probe", cachedFormat(build)); + const render = (val: number, lng: string) => + i18n.services.interpolator.interpolate("{{val, probe}}", { val }, lng, {}); + expect([1, 2, 3].map((val) => render(val, "en"))).toEqual(["en 1", "en 2", "en 3"]); + expect(build).toHaveBeenCalledTimes(1); + expect(render(4, "de")).toBe("de 4"); + expect(build).toHaveBeenCalledTimes(2); + }); +}); + +describe("format keys in another UI language", () => { + it("follows the selected UI language even when its bundle has no format keys", async () => { + const english = i18n.t("format.number", { val: 9123456.5 }); + await useLanguage("de"); + const german = i18n.t("format.number", { val: 9123456.5 }); + expect(german).toBe(new Intl.NumberFormat("de").format(9123456.5)); + expect(german).not.toBe(english); + expect(i18n.t("format.relativeTime", { val: -3 })).toBe(new Intl.RelativeTimeFormat("de").format(-3, "day")); + }); + + it.each([ + ["de", "EUR"], + ["zh-CN", "CNY"], + ["ja", "JPY"], + ["fr", "EUR"], + ["en-GB", "GBP"], + ])("keeps money in US dollars in %s", async (language, localCurrency) => { + await useLanguage(language); + const shown = i18n.t("format.usd", { val: 1234.5, currency: localCurrency }); + expect(shown).toBe(new Intl.NumberFormat(language, { style: "currency", currency: "USD" }).format(1234.5)); + expect(shown).not.toBe( + new Intl.NumberFormat(language, { style: "currency", currency: localCurrency }).format(1234.5), + ); + }); +}); diff --git a/ui/litellm-dashboard/src/i18n/formats.ts b/ui/litellm-dashboard/src/i18n/formats.ts new file mode 100644 index 00000000000..413f686854a --- /dev/null +++ b/ui/litellm-dashboard/src/i18n/formats.ts @@ -0,0 +1,80 @@ +import type { Formatter } from "i18next"; + +import { DEFAULT_LANGUAGE } from "./languages"; + +type DateInput = Date | number | string; +type InterpolationOptions = { readonly interpolationkey?: string }; +type Build = (lng: string, options: Options) => (value: Value) => string; + +const cacheKey = (language: string, options: InterpolationOptions): string | null => { + try { + return ( + language + JSON.stringify(options, (name, option) => (name === options.interpolationkey ? undefined : option)) + ); + } catch { + return null; + } +}; + +export const cachedFormat = (build: Build) => { + const renders = new Map string>(); + return (value: Value, lng: string | undefined, options: Options & InterpolationOptions): string => { + const language = (lng ?? DEFAULT_LANGUAGE).replaceAll("_", "-"); + const key = cacheKey(language, options); + if (key === null) return build(language, options)(value); + const existing = renders.get(key); + if (existing) return existing(value); + const render = build(language, options); + renders.set(key, render); + return render(value); + }; +}; + +const dateFormat = (preset: Intl.DateTimeFormatOptions) => + cachedFormat((lng, options: Intl.DateTimeFormatOptions) => { + const format = new Intl.DateTimeFormat(lng, { ...options, ...preset }); + return (value: DateInput) => format.format(new Date(value)); + }); + +const USD = { style: "currency", currency: "USD", currencyDisplay: "symbol" } as const; +const COMPACT = { notation: "compact", maximumFractionDigits: 1 } as const; +const MONTH_DAY = { month: "short", day: "numeric" } as const; +const DATE = { ...MONTH_DAY, year: "numeric" } as const; +const CLOCK = { hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" } as const; +const NUMERIC_DATE_TIME = { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", +} as const; + +const FORMATS = { + number: cachedFormat((lng, options: Intl.NumberFormatOptions) => { + const format = new Intl.NumberFormat(lng, { ...options, currency: USD.currency }); + return (value: number) => format.format(value); + }), + compact: cachedFormat((lng, options: Intl.NumberFormatOptions) => { + const format = new Intl.NumberFormat(lng, { ...options, ...COMPACT, currency: USD.currency }); + return (value: number) => format.format(value); + }), + usd: cachedFormat((lng, options: Intl.NumberFormatOptions) => { + const format = new Intl.NumberFormat(lng, { ...options, ...USD }); + return (value: number) => format.format(value); + }), + relativetime: cachedFormat( + (lng, options: Intl.RelativeTimeFormatOptions & { range?: Intl.RelativeTimeFormatUnit }) => { + const format = new Intl.RelativeTimeFormat(lng, options); + return (value: number) => format.format(value, options.range ?? "day"); + }, + ), + date: dateFormat(DATE), + monthday: dateFormat(MONTH_DAY), + monthdaytime: dateFormat({ ...MONTH_DAY, ...CLOCK }), + timestamp: dateFormat({ ...DATE, ...CLOCK }), + numericdatetime: dateFormat(NUMERIC_DATE_TIME), +}; + +export const registerFormats = (formatter: Formatter): void => + Object.entries(FORMATS).forEach(([name, format]) => formatter.add(name, format)); diff --git a/ui/litellm-dashboard/src/i18n/i18n.ts b/ui/litellm-dashboard/src/i18n/i18n.ts index 0e9c364a0b3..970b12ccda1 100644 --- a/ui/litellm-dashboard/src/i18n/i18n.ts +++ b/ui/litellm-dashboard/src/i18n/i18n.ts @@ -1,6 +1,7 @@ import i18n, { type InitOptions } from "i18next"; import { initReactI18next } from "react-i18next"; +import { registerFormats } from "./formats"; import en from "./locales/en.json"; declare module "i18next" { @@ -20,4 +21,6 @@ const options: InitOptions = { void i18n.use(initReactI18next).init(options); +if (i18n.services.formatter) registerFormats(i18n.services.formatter); + export default i18n; diff --git a/ui/litellm-dashboard/src/i18n/locales.test.ts b/ui/litellm-dashboard/src/i18n/locales.test.ts index fd0d0b2f125..f38920f075c 100644 --- a/ui/litellm-dashboard/src/i18n/locales.test.ts +++ b/ui/litellm-dashboard/src/i18n/locales.test.ts @@ -29,11 +29,11 @@ describe("en.json", () => { expect(entries.filter(([, value]) => typeof value !== "string" || value === "").map(([key]) => key)).toEqual([]); }); - it("only uses placeholders named like identifiers", () => { + it("only uses placeholders named like identifiers, with an optional lowercase format", () => { expect(entries.some(([, value]) => String(value).includes("{{"))).toBe(true); const malformed = entries.filter(([, value]) => { const text = String(value); - const wellFormed = text.match(/\{\{[A-Za-z_][A-Za-z0-9_]*\}\}/g)?.length ?? 0; + const wellFormed = text.match(/\{\{[A-Za-z_][A-Za-z0-9_]*(?:, [a-z]+)?\}\}/g)?.length ?? 0; return text.split("{{").length - 1 !== wellFormed || text.split("}}").length - 1 !== wellFormed; }); expect(malformed.map(([key]) => key)).toEqual([]); diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index 4bbfbc469ff..a2801d401a5 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -1,4 +1,15 @@ { + "format": { + "number": "{{val, number}}", + "compactNumber": "{{val, compact}}", + "usd": "{{val, usd}}", + "relativeTime": "{{val, relativetime}}", + "date": "{{val, date}}", + "monthDay": "{{val, monthday}}", + "monthDayTime": "{{val, monthdaytime}}", + "timestamp": "{{val, timestamp}}", + "numericDateTime": "{{val, numericdatetime}}" + }, "agents": { "killSwitchConfig": { "authTypes": {