Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions client/src/components/Chat/Input/TokenUsage/Breakdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TokenUsageView } from '~/hooks/Chat/useTokenUsage';
import type { CurrencyConfig } from '~/utils';
import { groupToolTokens, formatTokens, formatCost } from '~/utils';
import { useLocalize } from '~/hooks';

Expand Down Expand Up @@ -28,9 +29,10 @@ function Row({ label, value, max }: RowProps) {
interface BreakdownProps {
view: TokenUsageView;
showCost: boolean;
currency?: CurrencyConfig;
}

export default function Breakdown({ view, showCost }: BreakdownProps) {
export default function Breakdown({ view, showCost, currency }: BreakdownProps) {
const localize = useLocalize();
const { usedTokens, maxTokens, percent, snapshot, snapshotActive, branchUsage, hasUsage } = view;
/** Show the all-branches total only when it (a) exceeds the active branch —
Expand Down Expand Up @@ -170,12 +172,14 @@ export default function Breakdown({ view, showCost }: BreakdownProps) {
? localize('com_ui_context_cost_branch')
: localize('com_ui_context_cost')}
</span>
<span className="font-medium text-text-primary">{formatCost(view.branchCost)}</span>
<span className="font-medium text-text-primary">
{formatCost(view.branchCost, currency)}
</span>
</div>
{showTotal && (
<div className="flex items-center justify-between text-xs">
<span className="text-text-secondary">{localize('com_ui_context_cost_total')}</span>
<span className="text-text-secondary">{formatCost(view.totalCost)}</span>
<span className="text-text-secondary">{formatCost(view.totalCost, currency)}</span>
</div>
)}
</div>
Expand Down
97 changes: 67 additions & 30 deletions client/src/components/Chat/Input/TokenUsage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { memo } from 'react';
import { HoverCard, HoverCardTrigger, HoverCardContent, HoverCardPortal } from '@librechat/client';
import * as Ariakit from '@ariakit/react';
import { TooltipAnchor } from '@librechat/client';
import type { TConversation } from 'librechat-data-provider';
import type { CurrencyConfig } from '~/utils';
import { formatTokens, formatCost, cn } from '~/utils';
import useTokenUsage from '~/hooks/Chat/useTokenUsage';
import { useGetStartupConfig } from '~/data-provider';
import { formatTokens } from '~/utils';
import { useLocalize } from '~/hooks';
import Breakdown from './Breakdown';
import Gauge from './Gauge';
Expand All @@ -19,13 +21,18 @@ function TokenUsageIndicator({
conversation,
isSubmitting,
showCost,
currency,
}: TokenUsageProps & {
showCost: boolean;
currency?: CurrencyConfig;
}) {
const localize = useLocalize();
const view = useTokenUsage({ index, conversation, isSubmitting });
const popover = Ariakit.usePopoverStore({ placement: 'top' });

if (view.usedTokens <= 0 && view.maxTokens == null) {
/** Hide until the branch has data — keeps a fresh, message-less chat clean and
* lets the indicator animate into view once the first tokens land. */
if (view.usedTokens <= 0) {
return null;
}

Expand All @@ -38,34 +45,60 @@ function TokenUsageIndicator({
})
: localize('com_ui_context_usage_label_unknown', { 0: formatTokens(view.usedTokens) });

const snapshotSummary = hasMax
? localize('com_ui_context_usage_snapshot', {
0: formatTokens(view.usedTokens),
1: formatTokens(view.maxTokens ?? 0),
2: String(Math.round(view.percent)),
})
: localize('com_ui_context_usage_snapshot_unknown', { 0: formatTokens(view.usedTokens) });
const snapshot =
showCost && view.hasUsage && view.branchUsage.costKnown
? `${snapshotSummary} · ${formatCost(view.branchCost, currency)}`
: snapshotSummary;

return (
<HoverCard openDelay={150} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
data-testid="token-usage"
className="flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={ariaLabel}
aria-haspopup="dialog"
>
<span
role="meter"
aria-valuemin={0}
aria-valuemax={hasMax ? view.maxTokens : undefined}
aria-valuenow={view.usedTokens}
aria-label={localize('com_ui_context_usage')}
className="flex items-center justify-center"
<>
<TooltipAnchor
description={snapshot}
side="top"
render={
<Ariakit.PopoverDisclosure
store={popover}
type="button"
data-testid="token-usage"
aria-label={ariaLabel}
aria-haspopup="dialog"
className={cn(
'flex size-9 items-center justify-center rounded-full p-1 transition-colors',
'hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'duration-300 animate-in fade-in zoom-in-95',
)}
>
<Gauge percent={view.percent} indeterminate={!hasMax} />
</span>
</button>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side="top" align="end" className="w-auto p-3">
<Breakdown view={view} showCost={showCost} />
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
<span
role="meter"
aria-valuemin={0}
aria-valuemax={hasMax ? view.maxTokens : undefined}
aria-valuenow={view.usedTokens}
aria-label={localize('com_ui_context_usage')}
className="flex items-center justify-center"
>
<Gauge percent={view.percent} indeterminate={!hasMax} />
</span>
</Ariakit.PopoverDisclosure>
}
/>
<Ariakit.Popover
store={popover}
gutter={8}
portal
unmountOnHide
aria-label={localize('com_ui_context_usage')}
className="z-[200] rounded-xl border border-border-medium bg-surface-secondary p-3 shadow-lg focus:outline-none"
>
<Breakdown view={view} showCost={showCost} currency={currency} />
</Ariakit.Popover>
</>
);
}

Expand All @@ -79,7 +112,11 @@ const TokenUsage = memo(function TokenUsage(props: TokenUsageProps) {
return null;
}
return (
<TokenUsageIndicator {...props} showCost={startupConfig.interface?.contextCost === true} />
<TokenUsageIndicator
{...props}
showCost={startupConfig.interface?.contextCost === true}
currency={startupConfig.interface?.currency}
/>
);
});

Expand Down
2 changes: 2 additions & 0 deletions client/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,8 @@
"com_ui_context_usage": "Context usage",
"com_ui_context_usage_label": "Context window: {{0}} of {{1}} tokens used ({{2}}%)",
"com_ui_context_usage_label_unknown": "Context usage: {{0}} tokens used",
"com_ui_context_usage_snapshot": "Context {{0}} / {{1}} ({{2}}%)",
"com_ui_context_usage_snapshot_unknown": "Context {{0}}",
"com_ui_context_window": "Context window",
"com_ui_continue": "Continue",
"com_ui_continue_oauth": "Continue with OAuth",
Expand Down
52 changes: 51 additions & 1 deletion client/src/utils/tokens.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,62 @@ describe('normalizeUsageUnits', () => {
});

describe('formatCost', () => {
it('formats across magnitude bands', () => {
it('formats across magnitude bands (USD default, unchanged)', () => {
expect(formatCost(0)).toBe('$0.00');
expect(formatCost(0.004)).toBe('<$0.01');
expect(formatCost(0.0523)).toBe('$0.0523');
expect(formatCost(1.234)).toBe('$1.23');
});

it('applies the static USD→local rate', () => {
expect(formatCost(2, { code: 'USD', rate: 0.5 })).toBe('$1.00');
expect(formatCost(10, { code: 'USD', rate: 0.1 })).toBe('$1.00');
});

it('formats in the configured currency', () => {
const eur = formatCost(5, { code: 'EUR', rate: 1 });
expect(eur).toContain('€');
expect(eur).toContain('5');
});

it('respects zero-decimal currencies', () => {
const jpy = formatCost(5, { code: 'JPY', rate: 1 });
expect(jpy).toContain('Â¥');
expect(jpy).not.toContain('.');
});

it('falls back to USD on a malformed currency code', () => {
expect(formatCost(5, { code: 'invalid', rate: 1 })).toBe('$5.00');
});

it('drops the rate too when the currency code is unsupported', () => {
/** A typo in `code` must not leave a converted amount under the $ symbol. */
expect(formatCost(10, { code: 'EURO', rate: 0.92 })).toBe('$10.00');
});

it('rejects well-formed but non-ISO codes that Intl would accept', () => {
/** `EUU`/`RMB` are 3 letters so Intl does not throw; the ISO-4217 set must
* still reject them and fall back to USD (no converted amount, no rate). */
expect(formatCost(10, { code: 'EUU', rate: 0.92 })).toBe('$10.00');
expect(formatCost(10, { code: 'RMB', rate: 0.5 })).toBe('$10.00');
});

it('uses the currency minor unit for three-decimal currencies', () => {
/** KWD has 3 fractional digits, so the tiny threshold is 0.001, not 0.01. */
const small = formatCost(0.005, { code: 'KWD', rate: 1 });
expect(small).toContain('0.005');
expect(small).not.toContain('<');
expect(formatCost(0.0005, { code: 'KWD', rate: 1 })).toContain('0.001');
});

it('falls back to rate 1 when rate is not a finite positive number', () => {
/** Partial admin override (code set before rate) must never render NaN. */
const eur = formatCost(10, { code: 'EUR', rate: undefined as unknown as number });
expect(eur).toContain('€');
expect(eur).toContain('10');
expect(eur).not.toContain('NaN');
expect(formatCost(10, { code: 'USD', rate: Number.NaN })).toBe('$10.00');
});
});

describe('groupToolTokens', () => {
Expand Down
101 changes: 93 additions & 8 deletions client/src/utils/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,15 +472,100 @@ export function formatTokens(count: number): string {
return formatted.replace(/\.0(?=[A-Za-z]|$)/, '');
}

export function formatCost(usd: number): string {
if (usd <= 0) {
return '$0.00';
/** Display currency for the context cost. `rate` is a static USD→local
* multiplier (no live FX); `code` is an ISO-4217 currency for Intl formatting. */
export interface CurrencyConfig {
code: string;
rate: number;
}

const DEFAULT_CURRENCY: CurrencyConfig = { code: 'USD', rate: 1 };
const currencyFormatters = new Map<string, Intl.NumberFormat>();
const currencyDigits = new Map<string, number>();
let supportedCurrencies: Set<string> | null | undefined;

/** True only for codes recognized as ISO-4217. `Intl.NumberFormat` accepts any
* well-formed three-letter code (e.g. `EUU`, `RMB`) without throwing, so the
* ISO-4217 set is the real guard against typo'd / non-ISO codes. */
function isSupportedCurrency(code: unknown): code is string {
if (typeof code !== 'string' || code.length === 0) {
return false;
}
const upper = code.toUpperCase();
if (supportedCurrencies === undefined) {
const supportedValuesOf = (
Intl as typeof Intl & { supportedValuesOf?: (key: string) => string[] }
).supportedValuesOf;
supportedCurrencies =
typeof supportedValuesOf === 'function' ? new Set(supportedValuesOf('currency')) : null;
}
if (supportedCurrencies != null) {
return supportedCurrencies.has(upper);
}
/** Older runtime without `supportedValuesOf`: accept any code Intl can build. */
try {
new Intl.NumberFormat(undefined, { style: 'currency', currency: upper });
return true;
} catch {
return false;
}
}

/** Default fraction digits for a validated currency: USD→2, JPY→0, KWD→3. */
function maxFractionDigits(code: string): number {
const cached = currencyDigits.get(code);
if (cached !== undefined) {
return cached;
}
const digits =
new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).resolvedOptions()
.maximumFractionDigits ?? 2;
currencyDigits.set(code, digits);
return digits;
}

function currencyFormatter(code: string, minDigits: number, maxDigits: number): Intl.NumberFormat {
const key = `${code}:${minDigits}:${maxDigits}`;
let formatter = currencyFormatters.get(key);
if (formatter == null) {
formatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: code,
minimumFractionDigits: minDigits,
maximumFractionDigits: maxDigits,
});
currencyFormatters.set(key, formatter);
}
return formatter;
}

export function formatCost(usd: number, currency: CurrencyConfig = DEFAULT_CURRENCY): string {
/** Resolve to a safe (code, rate): an unsupported code falls back to USD AND
* rate 1 — never present a converted amount under the wrong symbol. A
* non-finite/negative rate (e.g. a partial admin override that set `code`
* before `rate`) falls back to 1 so a cost never renders as `NaN`. */
let code = DEFAULT_CURRENCY.code;
let rate = 1;
if (isSupportedCurrency(currency?.code)) {
code = currency.code.toUpperCase();
rate = Number.isFinite(currency?.rate) && (currency.rate as number) > 0 ? currency.rate : 1;
}
const base = maxFractionDigits(code);

const amount = usd * rate;
/** The currency's own minor unit — USD/EUR→0.01, JPY→1, KWD→0.001. */
const smallest = Math.pow(10, -base);

if (amount <= 0) {
return currencyFormatter(code, base, base).format(0);
}
if (usd < 0.01) {
return '<$0.01';
if (amount < smallest) {
return `<${currencyFormatter(code, base, base).format(smallest)}`;
}
if (usd < 1) {
return `$${usd.toFixed(4)}`;
if (amount < 1 && base > 0) {
/** Extra precision for sub-unit costs; trailing zeros trim to the currency's
* own scale (min = base). */
return currencyFormatter(code, base, base + 2).format(amount);
}
return `$${usd.toFixed(2)}`;
return currencyFormatter(code, base, base).format(amount);
}
Loading
Loading