From 7cf2877b456f7bff257accb729d48289550f951e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 14 Jun 2026 13:38:27 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=99=20feat:=20Context=20Gauge=20UX,=20?= =?UTF-8?q?Hover=20Snapshot,=20Click=20Breakdown,=20Currency,=20Cost-On-By?= =?UTF-8?q?-Default=20(#13739)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🪙 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. --- .../Chat/Input/TokenUsage/Breakdown.tsx | 10 +- .../Chat/Input/TokenUsage/index.tsx | 97 +++++++++++----- client/src/locales/en/translation.json | 2 + client/src/utils/tokens.spec.ts | 52 ++++++++- client/src/utils/tokens.ts | 107 ++++++++++++++++-- e2e/specs/mock/usage.spec.ts | 41 ++++++- packages/data-provider/src/config.ts | 8 +- .../data-schemas/src/app/interface.spec.ts | 48 ++++++++ packages/data-schemas/src/app/interface.ts | 1 + 9 files changed, 317 insertions(+), 49 deletions(-) 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,