Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions packages/app/src/components/session-context-usage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
})
})
Original file line number Diff line number Diff line change
@@ -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 "—"
Expand Down
12 changes: 2 additions & 10 deletions packages/app/src/components/session/session-context-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/cost-display.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
48 changes: 48 additions & 0 deletions packages/core/src/cost-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export namespace CostDisplay {
export type Config = {
currency?: string
cost_currency?: string
currency_rate?: number
}

const USD_RATE: Record<string, number> = {
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
}
}
}
7 changes: 7 additions & 0 deletions packages/core/src/plugin/skill/customize-opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/"]
Expand Down Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }),
Expand Down
19 changes: 19 additions & 0 deletions packages/sdk/js/src/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -20456,6 +20475,9 @@
"username": {
"type": "string"
},
"display": {
"$ref": "#/components/schemas/DisplayConfig"
},
"mode": {
"type": "object",
"properties": {
Expand Down
8 changes: 2 additions & 6 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
}
})

Expand Down
8 changes: 2 additions & 6 deletions packages/tui/src/feature-plugins/sidebar/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -41,7 +37,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
</text>
<text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>
<text fg={theme().textMuted}>{state().percent ?? 0}% used</text>
<text fg={theme().textMuted}>{money.format(cost())} spent</text>
<text fg={theme().textMuted}>{formatCost(cost(), props.api.state.config.display)} spent</text>
</box>
)
}
Expand Down
8 changes: 2 additions & 6 deletions packages/tui/src/routes/session/subagent-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
}
})

Expand Down
9 changes: 9 additions & 0 deletions packages/tui/src/util/cost-display.ts
Original file line number Diff line number Diff line change
@@ -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"
}
Loading