mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 01:22:54 +00:00
🪙 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:
parent
4ee68d5240
commit
7cf2877b45
9 changed files with 317 additions and 49 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue