diff --git a/ui/litellm-dashboard/eslint-budgets.json b/ui/litellm-dashboard/eslint-budgets.json index 83b7190b355..0beda34ec77 100644 --- a/ui/litellm-dashboard/eslint-budgets.json +++ b/ui/litellm-dashboard/eslint-budgets.json @@ -5,6 +5,7 @@ "max-depth": { "max": 70, "target": 30 }, "local/no-large-inline-object-arg": { "max": 551, "target": 300 }, "local/no-long-condition-chain": { "max": 194, "target": 120 }, + "local/no-hardcoded-currency": { "max": 179, "target": 0 }, "testing-library/no-container": { "max": 133, "target": 50 }, "testing-library/no-node-access": { "max": 707, "target": 500 }, "testing-library/prefer-screen-queries": { "max": 18, "target": 18 } diff --git a/ui/litellm-dashboard/eslint.config.mjs b/ui/litellm-dashboard/eslint.config.mjs index acf6f624f96..8d81a89f2e3 100644 --- a/ui/litellm-dashboard/eslint.config.mjs +++ b/ui/litellm-dashboard/eslint.config.mjs @@ -67,6 +67,16 @@ const eslintConfig = [ ], }, }, + { + files: ["src/**/*.{ts,tsx}"], + ignores: [ + "src/lib/money.ts", + "src/**/*.test.{ts,tsx}", + "src/**/*.spec.{ts,tsx}", + "src/**/*.integration.test.{ts,tsx}", + ], + rules: { "local/no-hardcoded-currency": "warn" }, + }, { files: ["src/**/*.tsx"], ignores: ["src/components/ui/**"], diff --git a/ui/litellm-dashboard/scripts/eslint-rules/index.mjs b/ui/litellm-dashboard/scripts/eslint-rules/index.mjs index 3988a810fca..457e2ac3964 100644 --- a/ui/litellm-dashboard/scripts/eslint-rules/index.mjs +++ b/ui/litellm-dashboard/scripts/eslint-rules/index.mjs @@ -5,6 +5,7 @@ import filenamePascalCase from "./filename-pascal-case.mjs"; import noNoopHoverVariant from "./no-noop-hover-variant.mjs"; import noAdHocZIndex from "./no-ad-hoc-z-index.mjs"; import noArbitraryDesignValue from "./no-arbitrary-design-value.mjs"; +import noHardcodedCurrency from "./no-hardcoded-currency.mjs"; const plugin = { rules: { @@ -15,6 +16,7 @@ const plugin = { "no-noop-hover-variant": noNoopHoverVariant, "no-ad-hoc-z-index": noAdHocZIndex, "no-arbitrary-design-value": noArbitraryDesignValue, + "no-hardcoded-currency": noHardcodedCurrency, }, }; diff --git a/ui/litellm-dashboard/scripts/eslint-rules/no-hardcoded-currency.mjs b/ui/litellm-dashboard/scripts/eslint-rules/no-hardcoded-currency.mjs new file mode 100644 index 00000000000..ad1794938f0 --- /dev/null +++ b/ui/litellm-dashboard/scripts/eslint-rules/no-hardcoded-currency.mjs @@ -0,0 +1,30 @@ +const currencySymbol = /(?:^|[\s<>=(:])\$(?=\s|\d|$)/; + +const rule = { + meta: { + type: "problem", + docs: { description: "Use the shared money formatter instead of hard-coded dollar currency symbols" }, + schema: [], + messages: { hardcoded: "Use the shared money formatter instead of a hard-coded dollar symbol." }, + }, + create(context) { + const check = (node, value) => { + if (typeof value === "string" && currencySymbol.test(value)) { + context.report({ node, messageId: "hardcoded" }); + } + }; + return { + Literal(node) { + check(node, node.value); + }, + TemplateElement(node) { + check(node, node.value.cooked); + }, + JSXText(node) { + check(node, node.value); + }, + }; + }, +}; + +export default rule; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useCurrency.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useCurrency.test.ts new file mode 100644 index 00000000000..498dc92b252 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useCurrency.test.ts @@ -0,0 +1,26 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useCurrency } from "./useCurrency"; +import { useUISettings } from "./useUISettings"; + +vi.mock("./useUISettings", () => ({ useUISettings: vi.fn() })); + +describe("useCurrency", () => { + it("defaults to USD while loading or with invalid settings", () => { + vi.mocked(useUISettings).mockReturnValue({ data: undefined } as ReturnType); + const { result, rerender } = renderHook(() => useCurrency()); + expect(result.current).toEqual({ code: "USD", usd_rate: 1 }); + vi.mocked(useUISettings).mockReturnValue({ + data: { currency: { code: "EUR", usd_rate: 0 } }, + } as unknown as ReturnType); + rerender(); + expect(result.current).toEqual({ code: "USD", usd_rate: 1 }); + }); + + it("reads configured currency", () => { + vi.mocked(useUISettings).mockReturnValue({ + data: { currency: { code: "EUR", usd_rate: 0.92 } }, + } as unknown as ReturnType); + expect(renderHook(() => useCurrency()).result.current).toEqual({ code: "EUR", usd_rate: 0.92 }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useCurrency.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useCurrency.ts new file mode 100644 index 00000000000..4b23305174f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useCurrency.ts @@ -0,0 +1,12 @@ +import { USD, type Currency } from "@/lib/money"; +import { useUISettings } from "./useUISettings"; + +export function useCurrency(): Currency { + const { data } = useUISettings(); + const currency: unknown = data?.currency; + if (typeof currency !== "object" || currency === null) return USD; + if (!("code" in currency) || typeof currency.code !== "string" || !/^[A-Z]{3}$/.test(currency.code)) return USD; + if (!("usd_rate" in currency) || typeof currency.usd_rate !== "number") return USD; + if (!Number.isFinite(currency.usd_rate) || currency.usd_rate <= 0) return USD; + return { code: currency.code, usd_rate: currency.usd_rate }; +} diff --git a/ui/litellm-dashboard/src/components/shared/MoneyInput.test.tsx b/ui/litellm-dashboard/src/components/shared/MoneyInput.test.tsx new file mode 100644 index 00000000000..79328f42a75 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/MoneyInput.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { MoneyInput } from "./MoneyInput"; + +vi.mock("@/app/(dashboard)/hooks/uiSettings/useCurrency", () => ({ + useCurrency: () => ({ code: "EUR", usd_rate: 0.92 }), +})); + +describe("MoneyInput", () => { + it("shows EUR, emits USD and keeps entered values on round trip", () => { + const onChange = vi.fn(); + function Form() { + const [value, setValue] = useState(null); + return ( + { + onChange(next); + setValue(next); + }} + /> + ); + } + render(
); + const input = screen.getByRole("textbox", { name: "Budget" }); + expect(screen.getByText("€")).toBeInTheDocument(); + expect(input).toHaveValue(""); + fireEvent.change(input, { target: { value: "100" } }); + expect(onChange).toHaveBeenLastCalledWith(100 / 0.92); + expect(input).toHaveValue("100"); + fireEvent.blur(input); + expect(input).toHaveValue("100"); + fireEvent.change(input, { target: { value: "" } }); + expect(onChange).toHaveBeenLastCalledWith(null); + expect(input).toHaveValue(""); + }); + + it("keeps an incomplete input without emitting an invalid amount", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole("textbox", { name: "Budget" }); + fireEvent.change(input, { target: { value: "-" } }); + expect(input).toHaveValue("-"); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.blur(input); + expect(input).toHaveValue("0.92"); + fireEvent.change(input, { target: { value: `17${"0".repeat(307)}` } }); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/MoneyInput.tsx b/ui/litellm-dashboard/src/components/shared/MoneyInput.tsx new file mode 100644 index 00000000000..73b13d55cf6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/MoneyInput.tsx @@ -0,0 +1,58 @@ +import { forwardRef, useState, type ChangeEvent, type ComponentProps } from "react"; +import { Input } from "@/components/ui/input"; +import { useCurrency } from "@/app/(dashboard)/hooks/uiSettings/useCurrency"; +import { fromUsd, toUsd } from "@/lib/money"; + +type MoneyInputProps = Omit, "type" | "value" | "onChange"> & { + value: number | null | undefined; + onChange: (value: number | null) => void; +}; + +export const MoneyInput = forwardRef(function MoneyInput( + { value, onChange, ...props }, + ref, +) { + const currency = useCurrency(); + const [edit, setEdit] = useState<{ + text: string; + value: number | null | undefined; + code: string; + rate: number; + } | null>(null); + const symbol = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.code }) + .formatToParts(0) + .find((part) => part.type === "currency")?.value; + const display = value == null ? "" : String(Number(fromUsd(value, currency).toPrecision(15))); + const sameCurrency = edit?.code === currency.code && edit?.rate === currency.usd_rate; + const text = edit && edit.value === value && sameCurrency ? edit.text : display; + + function handleChange(event: ChangeEvent) { + const next = event.target.value; + const converted = toUsd(Number(next), currency); + const valid = /^-?(?:\d+\.?\d*|\.\d+)$/.test(next) && Number.isFinite(converted); + const retained = valid ? converted : value; + const emitted = next === "" ? null : retained; + const draft = { text: next, value: emitted, code: currency.code, rate: currency.usd_rate }; + setEdit(draft); + if (next === "") onChange(null); + else if (valid && emitted != null) onChange(emitted); + } + + return ( +
+ + { + setEdit(null); + props.onBlur?.(event); + }} + /> +
+ ); +}); diff --git a/ui/litellm-dashboard/src/lib/money.test.ts b/ui/litellm-dashboard/src/lib/money.test.ts new file mode 100644 index 00000000000..81bfed77c67 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/money.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { formatMoney, formatUsd, fromUsd, toUsd, USD } from "./money"; + +const eur = { code: "EUR", usd_rate: 0.92 }; + +describe("money", () => { + it("converts in both directions while retaining USD values", () => { + expect(fromUsd(100, eur)).toBe(92); + expect(toUsd(92, eur)).toBe(100); + expect(fromUsd(toUsd(100, eur), eur)).toBeCloseTo(100); + expect(toUsd(10, USD)).toBe(10); + }); + + it("formats currencies, precision, abbreviation and small values", () => { + expect(formatMoney(100, eur)).toBe("€92.00"); + expect(formatUsd(1234.5, { decimals: 1 })).toBe("$1,234.5"); + expect(formatMoney(2000, eur, { abbreviate: true, decimals: 1 })).toBe("€1.8K"); + expect(formatUsd(1_200_000, { abbreviate: true, decimals: 0 })).toBe("$1M"); + expect(formatUsd(0.0000004, { decimals: 6, showSmallAmount: true })).toBe("< $0.000001"); + expect(formatUsd(-3)).toBe("-$3.00"); + }); + + it("handles null, non-finite and optionally hidden zero", () => { + expect(formatMoney(null, eur)).toBe("-"); + expect(formatMoney(undefined, eur)).toBe("-"); + expect(formatMoney(NaN, eur)).toBe("-"); + expect(formatMoney(0, eur, { showZero: false })).toBe("-"); + expect(formatMoney(0, eur)).toBe("€0.00"); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/money.ts b/ui/litellm-dashboard/src/lib/money.ts new file mode 100644 index 00000000000..e93ab4c435c --- /dev/null +++ b/ui/litellm-dashboard/src/lib/money.ts @@ -0,0 +1,48 @@ +import type { components } from "@/lib/http/schema"; + +export type Currency = components["schemas"]["Currency"]; + +export const USD: Currency = { code: "USD", usd_rate: 1 }; + +export const fromUsd = (usd: number, currency: Currency): number => usd * currency.usd_rate; + +export const toUsd = (amount: number, currency: Currency): number => amount / currency.usd_rate; + +export type MoneyFormatOptions = { + decimals?: number; + abbreviate?: boolean; + showZero?: boolean; + showSmallAmount?: boolean; +}; + +function abbreviation(amount: number, enabled: boolean): { scale: number; suffix: string } { + if (!enabled) return { scale: 1, suffix: "" }; + if (amount >= 1_000_000) return { scale: 1_000_000, suffix: "M" }; + if (amount >= 1_000) return { scale: 1_000, suffix: "K" }; + return { scale: 1, suffix: "" }; +} + +export function formatMoney( + usd: number | null | undefined, + currency: Currency, + { decimals = 2, abbreviate = false, showZero = true, showSmallAmount = false }: MoneyFormatOptions = {}, +): string { + if (usd == null || !Number.isFinite(usd)) return "-"; + if (usd === 0 && !showZero) return "-"; + const amount = fromUsd(usd, currency); + const { scale, suffix } = abbreviation(Math.abs(amount), abbreviate); + const formatOptions: Intl.NumberFormatOptions = { + style: "currency", + currency: currency.code, + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }; + const formatter = new Intl.NumberFormat("en-US", formatOptions); + if (showSmallAmount && amount !== 0 && Number((amount / scale).toFixed(decimals)) === 0) { + return `< ${formatter.format(1 / 10 ** decimals)}`; + } + return `${formatter.format(amount / scale)}${suffix}`; +} + +export const formatUsd = (usd: number | null | undefined, options?: MoneyFormatOptions): string => + formatMoney(usd, USD, options); diff --git a/ui/litellm-dashboard/tests/eslint-rules/no-hardcoded-currency.test.ts b/ui/litellm-dashboard/tests/eslint-rules/no-hardcoded-currency.test.ts new file mode 100644 index 00000000000..ba296aba201 --- /dev/null +++ b/ui/litellm-dashboard/tests/eslint-rules/no-hardcoded-currency.test.ts @@ -0,0 +1,22 @@ +import { RuleTester } from "eslint"; +import rule from "../../scripts/eslint-rules/no-hardcoded-currency.mjs"; + +const ruleTester = new RuleTester({ + languageOptions: { ecmaVersion: "latest", sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true } } }, +}); + +ruleTester.run("no-hardcoded-currency", rule as never, { + valid: [ + 'const regex = "^name$";', + 'const variable = "$HOME";', + "const text = `${value} tokens`;", + "const el = Price in USD;", + ], + invalid: [ + { code: 'const symbol = "$";', errors: [{ messageId: "hardcoded" }] }, + { code: 'const price = "$0.00";', errors: [{ messageId: "hardcoded" }] }, + { code: 'const small = "< $";', errors: [{ messageId: "hardcoded" }] }, + { code: "const price = `$${value}`;", errors: [{ messageId: "hardcoded" }] }, + { code: "const el = ${value};", errors: [{ messageId: "hardcoded" }] }, + ], +});