mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-03 07:23:33 +00:00
feat: add dashboard currency display tools
Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
parent
4e1257881d
commit
72a3a5a8b9
11 changed files with 291 additions and 0 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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/**"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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<typeof useUISettings>);
|
||||
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<typeof useUISettings>);
|
||||
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<typeof useUISettings>);
|
||||
expect(renderHook(() => useCurrency()).result.current).toEqual({ code: "EUR", usd_rate: 0.92 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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<number | null>(null);
|
||||
return (
|
||||
<MoneyInput
|
||||
aria-label="Budget"
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
onChange(next);
|
||||
setValue(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<Form />);
|
||||
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(<MoneyInput aria-label="Budget" value={1} onChange={onChange} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
58
ui/litellm-dashboard/src/components/shared/MoneyInput.tsx
Normal file
58
ui/litellm-dashboard/src/components/shared/MoneyInput.tsx
Normal file
|
|
@ -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<ComponentProps<typeof Input>, "type" | "value" | "onChange"> & {
|
||||
value: number | null | undefined;
|
||||
onChange: (value: number | null) => void;
|
||||
};
|
||||
|
||||
export const MoneyInput = forwardRef<HTMLInputElement, MoneyInputProps>(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<HTMLInputElement>) {
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden="true">{symbol}</span>
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onBlur={(event) => {
|
||||
setEdit(null);
|
||||
props.onBlur?.(event);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
30
ui/litellm-dashboard/src/lib/money.test.ts
Normal file
30
ui/litellm-dashboard/src/lib/money.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
48
ui/litellm-dashboard/src/lib/money.ts
Normal file
48
ui/litellm-dashboard/src/lib/money.ts
Normal file
|
|
@ -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);
|
||||
|
|
@ -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 = <span>Price in USD</span>;",
|
||||
],
|
||||
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 = <span>${value}</span>;", errors: [{ messageId: "hardcoded" }] },
|
||||
],
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue