diff --git a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx
index d17c01334b..cdc5a138ae 100644
--- a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx
+++ b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx
@@ -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')}
- {formatCost(view.branchCost)}
+
+ {formatCost(view.branchCost, currency)}
+
{showTotal && (
{localize('com_ui_context_cost_total')}
- {formatCost(view.totalCost)}
+ {formatCost(view.totalCost, currency)}
)}
diff --git a/client/src/components/Chat/Input/TokenUsage/index.tsx b/client/src/components/Chat/Input/TokenUsage/index.tsx
index d4ba946d31..cb9f4894a5 100644
--- a/client/src/components/Chat/Input/TokenUsage/index.tsx
+++ b/client/src/components/Chat/Input/TokenUsage/index.tsx
@@ -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 (
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ }
+ />
+
+
+
+ >
);
}
@@ -79,7 +112,11 @@ const TokenUsage = memo(function TokenUsage(props: TokenUsageProps) {
return null;
}
return (
-
+
);
});
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 89049a4d1b..03f924373a 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -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",
diff --git a/client/src/utils/tokens.spec.ts b/client/src/utils/tokens.spec.ts
index 5ce386b73c..a80b725f96 100644
--- a/client/src/utils/tokens.spec.ts
+++ b/client/src/utils/tokens.spec.ts
@@ -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', () => {
diff --git a/client/src/utils/tokens.ts b/client/src/utils/tokens.ts
index c5abff26c0..9bb129c6c9 100644
--- a/client/src/utils/tokens.ts
+++ b/client/src/utils/tokens.ts
@@ -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();
+const currencyDigits = new Map();
+let supportedCurrencies: Set | 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);
}
diff --git a/e2e/specs/mock/usage.spec.ts b/e2e/specs/mock/usage.spec.ts
index ab7e5bc4c8..1d85487094 100644
--- a/e2e/specs/mock/usage.spec.ts
+++ b/e2e/specs/mock/usage.spec.ts
@@ -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 });
+ });
});
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index 64bc73e291..bd92072fa2 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -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,
diff --git a/packages/data-schemas/src/app/interface.spec.ts b/packages/data-schemas/src/app/interface.spec.ts
index f59bc676ec..e4af198690 100644
--- a/packages/data-schemas/src/app/interface.spec.ts
+++ b/packages/data-schemas/src/app/interface.spec.ts
@@ -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 = {
+ 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 = {
+ 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 = {
interface: {
diff --git a/packages/data-schemas/src/app/interface.ts b/packages/data-schemas/src/app/interface.ts
index de74aad9c0..ba6c6bed96 100644
--- a/packages/data-schemas/src/app/interface.ts
+++ b/packages/data-schemas/src/app/interface.ts
@@ -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,