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": {
{t("usage.statStrip.tokenPopover.pricingNote")}