🪙 feat: Context Gauge UX, Hover Snapshot, Click Breakdown, Currency, Cost-On-By-Default (#13739)

* 🪙 feat: Default Context Cost On + Configurable Display Currency

Flip interface.contextCost to default-on (schema default true, resolved per-field
in loadDefaultInterface so it applies unless an admin explicitly sets false).

Add interface.currency { code, rate }: an ISO-4217 code and a static USD→local
multiplier so non-USD communities (EUR, JPY, CNY, BRL, ZAR, …) can show costs in
their currency. Inner fields are required (no nested defaults) to keep zod
input/output identical; loadDefaultInterface passes it through. Display-only —
model prices stay USD server-side.

* 🪙 feat: Currency-Aware Context Cost Formatting

formatCost(usd, currency?) applies the static rate (usd × rate) and formats via
a cached Intl.NumberFormat keyed by currency code — locale-correct symbol and
per-currency decimals, falling back to USD on a malformed code. The USD default
(code USD, rate 1) is byte-identical to the prior output.

* 💄 feat: Gauge Hover Snapshot, Click-to-Open Breakdown, Hide Until Data

Replace the hover-only HoverCard with: a compact hover snapshot tooltip
("Context 341.7k / 1.0M (34%)" + cost when enabled) via the existing Tooltip
primitive, and a click-opened Ariakit popover for the full breakdown that
dismisses on outside-click/Escape/blur. Gate visibility on usedTokens > 0 so a
fresh, message-less chat shows nothing, with an animate-in fade as the first
tokens land. Thread the display currency into the breakdown + snapshot.

* 🧪 test: Gauge Interaction + Visibility E2E

Switch the breakdown specs from hover to click, and add a test that the gauge is
absent on a new chat, surfaces the snapshot tooltip on hover, opens the breakdown
on click, and dismisses on Escape and outside-click.

* 🪙 fix: Harden Currency Resolution + Layer Breakdown Above Tooltip

Address Codex review on the currency display:
- Unsupported currency code now falls back to USD AND rate 1, so a typo like
  { code: 'EURO', rate: 0.92 } no longer shows a converted amount under a $
  symbol (was $9.20 for a $10 cost; now $10.00).
- A non-finite/negative rate (e.g. a partial admin override that set code before
  rate) falls back to rate 1, so a cost never renders as NaN.
- Fraction digits derive from the currency's own defaults, so zero-decimal
  currencies (JPY) render ¥5, not ¥5.00, and extra sub-unit precision applies
  only to currencies that have minor units. USD output is unchanged.
- Raise the click breakdown popover to z-[200] so it always sits above the
  z-150 hover tooltip when both briefly coexist.

* 🪙 fix: Validate ISO-4217 Codes + Derive Tiny Threshold from Minor Unit

Address Codex review on currency formatting:
- Intl.NumberFormat accepts any well-formed 3-letter code (EUU, RMB) without
  throwing, so the previous construct-based check missed typos/non-ISO codes and
  applied the rate under a bogus label. Validate against Intl.supportedValuesOf
  ('currency') (the ISO-4217 set); unsupported codes fall back to USD + rate 1.
  Codes are normalized to upper-case; graceful fallback if the runtime lacks
  supportedValuesOf.
- The tiny-amount threshold now derives from the currency's minor unit
  (10^-fractionDigits): 0.01 for 2-decimal, 0.001 for 3-decimal (KWD/BHD/JOD),
  1 for zero-decimal — instead of a hard-coded 0.01. Sub-unit precision trims to
  each currency's own scale. USD output unchanged.
This commit is contained in:
Danny Avila 2026-06-14 13:38:27 -04:00 • committed by GitHub
parent 4ee68d5240
commit 7cf2877b45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 317 additions and 49 deletions

View file

@ -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';
@ -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 —
@ -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>

View file

@ -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';
@ -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;
}
@ -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>
</>
);
}
@ -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}
/>
);
});

View file

@ -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",

View file

@ -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', () => {

View file

@ -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';
}
if (usd < 0.01) {
return '<$0.01';
}
if (usd < 1) {
return `$${usd.toFixed(4)}`;
}
return `$${usd.toFixed(2)}`;
/** 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 (amount < smallest) {
return `<${currencyFormatter(code, base, base).format(smallest)}`;
}
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 currencyFormatter(code, base, base).format(amount);
}

View file

@ -18,10 +18,10 @@ async function expectGaugeAboveZero(page: Page) {
await expect(gaugeMeter(page)).toHaveAttribute('aria-valuenow', /[1-9]/, { timeout: 20000 });
}
/** Opens the gauge hover popover and returns its region locator. */
/** Opens the gauge breakdown popover (click, not hover) and returns its region. */
async function openBreakdown(page: Page) {
await expectGaugeAboveZero(page);
await gauge(page).hover();
await gauge(page).click();
const popover = page.getByRole('region', { name: 'Context usage' });
await expect(popover).toBeVisible({ timeout: 10000 });
return popover;
@ -64,7 +64,7 @@ test.describe('context usage gauge', () => {
/** Breakdown popover: context section always; the usage section is
* scoped by testid since the pre-snapshot fallback renders its own
* Input/Output rows when the lib predates on_context_usage */
await gauge(page).hover();
await gauge(page).click();
const popover = page.getByRole('region', { name: 'Context usage' });
await expect(popover).toBeVisible({ timeout: 10000 });
await expect(popover.getByText('Context window')).toBeVisible();
@ -192,4 +192,39 @@ test.describe('context usage gauge', () => {
* total render — assert at least one cost value is present. */
await expect(costSection.getByText(/\$\d|<\$0\.01/).first()).toBeVisible();
});
test('hides on a new chat, then reveals snapshot on hover and breakdown on click', async ({
page,
}) => {
test.setTimeout(120000);
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
/** A fresh, message-less chat shows no gauge (it is not mounted). */
await expect(gauge(page)).toHaveCount(0);
await sendAndAwaitReply(page, 'hello');
await expectGaugeAboveZero(page);
/** Hover surfaces the compact snapshot tooltip — not the full breakdown. */
await gauge(page).hover();
const tooltip = page.getByRole('tooltip');
await expect(tooltip).toBeVisible({ timeout: 10000 });
await expect(tooltip).toContainText('Context');
await expect(page.getByRole('region', { name: 'Context usage' })).toHaveCount(0);
/** Click opens the breakdown popover; Escape (focus-away) closes it. */
await gauge(page).click();
const popover = page.getByRole('region', { name: 'Context usage' });
await expect(popover).toBeVisible({ timeout: 10000 });
await expect(popover.getByText('Context window')).toBeVisible();
await page.keyboard.press('Escape');
await expect(popover).toBeHidden({ timeout: 10000 });
/** Reopen, then dismiss by clicking outside (blur). */
await gauge(page).click();
await expect(popover).toBeVisible({ timeout: 10000 });
await messagesView(page).click({ position: { x: 5, y: 5 } });
await expect(popover).toBeHidden({ timeout: 10000 });
});
});

View file

@ -1181,6 +1181,12 @@ export const interfaceSchema = z
webSearch: z.boolean().optional(),
contextUsage: z.boolean().optional(),
contextCost: z.boolean().optional(),
currency: z
.object({
code: z.string(),
rate: z.number().positive(),
})
.optional(),
peoplePicker: z
.object({
users: z.boolean().optional(),
@ -1251,7 +1257,7 @@ export const interfaceSchema = z
runCode: true,
webSearch: true,
contextUsage: true,
contextCost: false,
contextCost: true,
peoplePicker: {
users: true,
groups: true,

View file

@ -66,6 +66,54 @@ describe('loadDefaultInterface', () => {
expect(interfaceConfig?.buildInfo).toBe(true);
});
it('enables context cost by default', async () => {
const interfaceConfig = await loadDefaultInterface({
config: {},
configDefaults: getConfigDefaults(),
});
expect(interfaceConfig?.contextCost).toBe(true);
});
it('preserves a disabled context cost flag', async () => {
const config: Partial<TCustomConfig> = {
interface: {
contextCost: false,
},
};
const interfaceConfig = await loadDefaultInterface({
config,
configDefaults: getConfigDefaults(),
});
expect(interfaceConfig?.contextCost).toBe(false);
});
it('passes through a configured display currency', async () => {
const config: Partial<TCustomConfig> = {
interface: {
currency: { code: 'EUR', rate: 0.92 },
},
};
const interfaceConfig = await loadDefaultInterface({
config,
configDefaults: getConfigDefaults(),
});
expect(interfaceConfig?.currency).toEqual({ code: 'EUR', rate: 0.92 });
});
it('omits currency when it is not configured', async () => {
const interfaceConfig = await loadDefaultInterface({
config: {},
configDefaults: getConfigDefaults(),
});
expect(interfaceConfig?.currency).toBeUndefined();
});
it('preserves enabled URL auto-submit config', async () => {
const config: Partial<TCustomConfig> = {
interface: {

View file

@ -42,6 +42,7 @@ export async function loadDefaultInterface({
buildInfo: interfaceConfig?.buildInfo ?? defaults.buildInfo,
contextUsage: interfaceConfig?.contextUsage ?? defaults.contextUsage,
contextCost: interfaceConfig?.contextCost ?? defaults.contextCost,
currency: interfaceConfig?.currency ?? defaults.currency,
// Permissions and related settings - only include if explicitly configured
bookmarks: interfaceConfig?.bookmarks,