From f412eb5c95d4248bd3503606ee6a791e2cceea4f Mon Sep 17 00:00:00 2001 From: Ding Guanghua Date: Tue, 16 Jun 2026 07:34:25 +0800 Subject: [PATCH] feat: configure cost display currency --- .../src/components/session-context-usage.tsx | 12 ++--- .../session/session-context-format.test.ts | 24 ++++++++++ .../session/session-context-format.ts | 6 ++- .../session/session-context-tab.tsx | 12 +---- packages/core/src/cost-display.test.ts | 28 +++++++++++ packages/core/src/cost-display.ts | 48 +++++++++++++++++++ .../src/plugin/skill/customize-opencode.md | 7 +++ packages/core/src/v1/config/config.ts | 16 +++++++ packages/sdk/js/src/gen/types.gen.ts | 19 ++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 5 ++ packages/sdk/openapi.json | 22 +++++++++ packages/tui/src/component/prompt/index.tsx | 8 +--- .../src/feature-plugins/sidebar/context.tsx | 8 +--- .../src/routes/session/subagent-footer.tsx | 8 +--- packages/tui/src/util/cost-display.ts | 9 ++++ 15 files changed, 194 insertions(+), 38 deletions(-) create mode 100644 packages/app/src/components/session/session-context-format.test.ts create mode 100644 packages/core/src/cost-display.test.ts create mode 100644 packages/core/src/cost-display.ts create mode 100644 packages/tui/src/util/cost-display.ts diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c61afb262c0a..6bb75239dfeb 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -11,6 +11,7 @@ import { useProviders } from "@/hooks/use-providers" import { getSessionContextMetrics } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" +import { createSessionContextFormatter } from "@/components/session/session-context-format" interface SessionContextUsageProps { variant?: "button" | "indicator" @@ -44,18 +45,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }) const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) - const usd = createMemo( - () => - new Intl.NumberFormat(language.intl(), { - style: "currency", - currency: "USD", - }), - ) - const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) + const formatter = createMemo(() => createSessionContextFormatter(language.intl(), sync().data.config.display)) const context = createMemo(() => metrics().context) const cost = createMemo(() => { - return usd().format(metrics().totalCost) + return formatter().cost(metrics().totalCost) }) const openContext = () => { diff --git a/packages/app/src/components/session/session-context-format.test.ts b/packages/app/src/components/session/session-context-format.test.ts new file mode 100644 index 000000000000..ea5feb3aef30 --- /dev/null +++ b/packages/app/src/components/session/session-context-format.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { createSessionContextFormatter } from "./session-context-format" + +describe("createSessionContextFormatter", () => { + test("keeps compact USD symbols for English cost", () => { + expect(createSessionContextFormatter("en").cost(1.25)).toBe("$1.25") + }) + + test("formats configured CNY cost with the yuan symbol", () => { + expect(createSessionContextFormatter("zh-Hans", { currency: "CNY" }).cost(1.25)).toBe("¥9.00") + }) + + test("uses configured display currency rate", () => { + expect(createSessionContextFormatter("zh-Hans", { currency: "CNY", currency_rate: 7 }).cost(1.25)).toBe("¥8.75") + }) + + test("ignores display currency rate when currency is not configured", () => { + expect(createSessionContextFormatter("en", { currency_rate: 7 }).cost(1.25)).toBe("$1.25") + }) + + test("formats configured CNY source cost without converting again", () => { + expect(createSessionContextFormatter("zh-Hans", { cost_currency: "CNY", currency: "CNY" }).cost(1.25)).toBe("¥1.25") + }) +}) diff --git a/packages/app/src/components/session/session-context-format.ts b/packages/app/src/components/session/session-context-format.ts index e7c536d58411..173a43cb2ec7 100644 --- a/packages/app/src/components/session/session-context-format.ts +++ b/packages/app/src/components/session/session-context-format.ts @@ -1,7 +1,11 @@ import { DateTime } from "luxon" +import { CostDisplay } from "@opencode-ai/core/cost-display" -export function createSessionContextFormatter(locale: string) { +export function createSessionContextFormatter(locale: string, config?: CostDisplay.Config) { return { + cost(value: number) { + return CostDisplay.format(locale, value, config) + }, number(value: number | null | undefined) { if (value === undefined) return "—" if (value === null) return "—" diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index a601928de488..30509b32ed2d 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -124,20 +124,12 @@ export function SessionContextTab() { { equals: same }, ) - const usd = createMemo( - () => - new Intl.NumberFormat(language.intl(), { - style: "currency", - currency: "USD", - }), - ) - const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) const ctx = createMemo(() => metrics().context) - const formatter = createMemo(() => createSessionContextFormatter(language.intl())) + const formatter = createMemo(() => createSessionContextFormatter(language.intl(), sync().data.config.display)) const cost = createMemo(() => { - return usd().format(metrics().totalCost) + return formatter().cost(metrics().totalCost) }) const counts = createMemo(() => { diff --git a/packages/core/src/cost-display.test.ts b/packages/core/src/cost-display.test.ts new file mode 100644 index 000000000000..3b239f853d25 --- /dev/null +++ b/packages/core/src/cost-display.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { CostDisplay } from "./cost-display" + +describe("CostDisplay", () => { + test("formats USD by default", () => { + expect(CostDisplay.format("en", 1.25)).toBe("$1.25") + }) + + test("formats configured CNY cost with the yuan symbol", () => { + expect(CostDisplay.format("zh-Hans", 1.25, { currency: "CNY" })).toBe("¥9.00") + }) + + test("uses configured display currency rate", () => { + expect(CostDisplay.format("zh-Hans", 1.25, { currency: "CNY", currency_rate: 7 })).toBe("¥8.75") + }) + + test("ignores display currency rate when currency is not configured", () => { + expect(CostDisplay.format("en", 1.25, { currency_rate: 7 })).toBe("$1.25") + }) + + test("formats configured CNY source cost without converting again", () => { + expect(CostDisplay.format("zh-Hans", 1.25, { cost_currency: "CNY", currency: "CNY" })).toBe("¥1.25") + }) + + test("converts configured CNY source cost to USD", () => { + expect(CostDisplay.format("en", 7.2, { cost_currency: "CNY", currency: "USD" })).toBe("$1.00") + }) +}) diff --git a/packages/core/src/cost-display.ts b/packages/core/src/cost-display.ts new file mode 100644 index 000000000000..cec994cd209c --- /dev/null +++ b/packages/core/src/cost-display.ts @@ -0,0 +1,48 @@ +export namespace CostDisplay { + export type Config = { + currency?: string + cost_currency?: string + currency_rate?: number + } + + const USD_RATE: Record = { + USD: 1, + CNY: 7.2, + EUR: 0.92, + GBP: 0.79, + JPY: 155, + KRW: 1_370, + } + + export function format(locale: string, cost: number, config?: Config) { + const from = currency(config?.cost_currency) + const next = currency(config?.currency) + return money(locale, cost * rate(config, from, next), next) ?? money(locale, cost, "USD") ?? `$${cost.toFixed(2)}` + } + + function currency(input: string | undefined) { + const value = input?.trim().toUpperCase() + if (!value) return "USD" + if (/^[A-Z]{3}$/.test(value)) return value + return "USD" + } + + function rate(config: Config | undefined, from: string, next: string) { + if (from === next) return 1 + if (config?.currency_rate && Number.isFinite(config.currency_rate) && config.currency_rate > 0) + return config.currency_rate + return (USD_RATE[next] ?? 1) / (USD_RATE[from] ?? 1) + } + + function money(locale: string, value: number, next: string) { + try { + return new Intl.NumberFormat(locale, { + style: "currency", + currency: next, + currencyDisplay: "symbol", + }).format(value) + } catch { + return + } + } +} diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 1c1cbdf3c296..e79cb467bd90 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -68,6 +68,12 @@ Every field is optional. "snapshot": true, "instructions": ["AGENTS.md", "docs/style.md"], + "display": { + "currency": "CNY", + "cost_currency": "USD", + "currency_rate": 7.2 + }, + "skills": { "paths": [".opencode/skills", "/abs/path/to/skills"], "urls": ["https://example.com/.well-known/skills/"] @@ -154,6 +160,7 @@ Shape notes worth being explicit about: - `plugin` is an array of strings or `[name, options]` tuples, not an object. - `mcp[name].command` is an array of strings, never a single string. `type` is required. - `permission` is either a string action or an object keyed by tool name. +- `display.cost_currency` is the currency used by configured model prices. If configured costs are already RMB, use `{ "currency": "CNY", "cost_currency": "CNY" }`. ## Skills diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 2e773f71e256..24293693498b 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -84,6 +84,22 @@ export const Info = Schema.Struct({ username: Schema.optional(Schema.String).annotate({ description: "Custom username to display in conversations instead of system username", }), + display: Schema.optional( + Schema.Struct({ + currency: Schema.optional(Schema.String).annotate({ + description: + "ISO 4217 currency code used to display usage costs, for example USD or CNY.", + }), + cost_currency: Schema.optional(Schema.String).annotate({ + description: + "ISO 4217 currency code of the stored usage costs. Defaults to USD. Set to CNY if configured model prices are already RMB.", + }), + currency_rate: Schema.optional(Schema.Finite.check(Schema.isGreaterThan(0))).annotate({ + description: + "Optional multiplier from cost_currency to currency. When omitted, built-in approximate rates are used for supported currencies.", + }), + }), + ).annotate({ description: "Display preferences" }), mode: Schema.optional( Schema.StructWithRest( Schema.Struct({ build: Schema.optional(ConfigAgentV1.Info), plan: Schema.optional(ConfigAgentV1.Info) }), diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 5e4fd8906155..691d9c76fc38 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1172,6 +1172,21 @@ export type McpRemoteConfig = { */ export type LayoutConfig = "auto" | "stretch" +export type DisplayConfig = { + /** + * ISO 4217 currency code used to display usage costs, for example USD or CNY. + */ + currency?: string + /** + * ISO 4217 currency code of the stored usage costs. Defaults to USD. Set to CNY if configured model prices are already RMB. + */ + cost_currency?: string + /** + * Optional multiplier from cost_currency to currency. When omitted, built-in approximate rates are used for supported currencies. + */ + currency_rate?: number +} + export type Config = { /** * JSON schema reference for configuration validation @@ -1257,6 +1272,10 @@ export type Config = { * Custom username to display in conversations instead of system username */ username?: string + /** + * Display preferences + */ + display?: DisplayConfig /** * @deprecated Use `agent` field instead. */ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 093c1894a8ab..e73e8b6f4dfc 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1952,6 +1952,11 @@ export type Config = { small_model?: string default_agent?: string username?: string + display?: { + currency?: string + cost_currency?: string + currency_rate?: number + } mode?: { build?: AgentConfig plan?: AgentConfig diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 48350fe13df7..1576d06d231a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -20282,6 +20282,25 @@ }, "additionalProperties": false }, + "DisplayConfig": { + "type": "object", + "properties": { + "currency": { + "type": "string", + "description": "ISO 4217 currency code used to display usage costs, for example USD or CNY." + }, + "cost_currency": { + "type": "string", + "description": "ISO 4217 currency code of the stored usage costs. Defaults to USD. Set to CNY if configured model prices are already RMB." + }, + "currency_rate": { + "type": "number", + "description": "Optional multiplier from cost_currency to currency. When omitted, built-in approximate rates are used for supported currencies.", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false + }, "Config": { "type": "object", "properties": { @@ -20456,6 +20475,9 @@ "username": { "type": "string" }, + "display": { + "$ref": "#/components/schemas/DisplayConfig" + }, "mode": { "type": "object", "properties": { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index aa002080b1dd..e7ad42eb3b09 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -56,6 +56,7 @@ import { useTuiConfig } from "../../config" import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" +import { formatCost } from "../../util/cost-display" export type PromptProps = { sessionID?: string @@ -93,11 +94,6 @@ export type PromptRef = { submit(): void } -const money = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}) - const DRAFT_RETENTION_MIN_CHARS = 20 function randomIndex(count: number) { @@ -272,7 +268,7 @@ export function Prompt(props: PromptProps) { const cost = session?.cost ?? 0 return { context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), - cost: cost > 0 ? money.format(cost) : undefined, + cost: cost > 0 ? formatCost(cost, sync.data.config.display) : undefined, } }) diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index f1c99d9679ce..9d73db9c0636 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -2,14 +2,10 @@ import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" import { createMemo } from "solid-js" +import { formatCost } from "../../util/cost-display" const id = "internal:sidebar-context" -const money = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}) - function View(props: { api: TuiPluginApi; session_id: string }) { const theme = () => props.api.theme.current const msg = createMemo(() => props.api.state.session.messages(props.session_id)) @@ -41,7 +37,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) { {state().tokens.toLocaleString()} tokens {state().percent ?? 0}% used - {money.format(cost())} spent + {formatCost(cost(), props.api.state.config.display)} spent ) } diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index 0aadb4f43c96..f7df40bd7696 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -7,6 +7,7 @@ import type { AssistantMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { useTerminalDimensions } from "@opentui/solid" import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" +import { formatCost } from "../../util/cost-display" export function SubagentFooter() { const route = useRouteData("session") @@ -43,14 +44,9 @@ export function SubagentFooter() { const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const cost = session()?.cost ?? 0 - const money = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }) - return { context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), - cost: cost > 0 ? money.format(cost) : undefined, + cost: cost > 0 ? formatCost(cost, sync.data.config.display) : undefined, } }) diff --git a/packages/tui/src/util/cost-display.ts b/packages/tui/src/util/cost-display.ts new file mode 100644 index 000000000000..e20dd2f397d4 --- /dev/null +++ b/packages/tui/src/util/cost-display.ts @@ -0,0 +1,9 @@ +import { CostDisplay } from "@opencode-ai/core/cost-display" + +export function formatCost(usd: number, config?: CostDisplay.Config) { + return CostDisplay.format(locale(config), usd, config) +} + +function locale(config: CostDisplay.Config | undefined) { + return config?.currency?.trim().toUpperCase() === "CNY" ? "zh-Hans" : "en-US" +}