diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 04c1fe2ebc..d6eaaeb93e 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -6,6 +6,7 @@ import { Database } from "../../storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "../../project/project" import { Instance } from "../../project/instance" +import { Locale } from "../../util/locale" interface SessionStats { totalSessions: number @@ -333,8 +334,8 @@ export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit const cost = isNaN(stats.totalCost) ? 0 : stats.totalCost const costPerDay = isNaN(stats.costPerDay) ? 0 : stats.costPerDay const tokensPerSession = isNaN(stats.tokensPerSession) ? 0 : stats.tokensPerSession - console.log(renderRow("Total Cost", `$${cost.toFixed(2)}`)) - console.log(renderRow("Avg Cost/Day", `$${costPerDay.toFixed(2)}`)) + console.log(renderRow("Total Cost", Locale.cost(cost))) + console.log(renderRow("Avg Cost/Day", Locale.cost(costPerDay))) console.log(renderRow("Avg Tokens/Session", formatNumber(Math.round(tokensPerSession)))) const medianTokensPerSession = isNaN(stats.medianTokensPerSession) ? 0 : stats.medianTokensPerSession console.log(renderRow("Median Tokens/Session", formatNumber(Math.round(medianTokensPerSession)))) @@ -361,7 +362,7 @@ export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit console.log(renderRow(" Output Tokens", formatNumber(usage.tokens.output))) console.log(renderRow(" Cache Read", formatNumber(usage.tokens.cache.read))) console.log(renderRow(" Cache Write", formatNumber(usage.tokens.cache.write))) - console.log(renderRow(" Cost", `$${usage.cost.toFixed(4)}`)) + console.log(renderRow(" Cost", Locale.cost(usage.cost))) console.log("├────────────────────────────────────────────────────────┤") } // Remove last separator and add bottom border diff --git a/packages/opencode/src/cli/cmd/trace.ts b/packages/opencode/src/cli/cmd/trace.ts index 3c9a0b1b9b..e31a45e6e8 100644 --- a/packages/opencode/src/cli/cmd/trace.ts +++ b/packages/opencode/src/cli/cmd/trace.ts @@ -8,6 +8,7 @@ import { renderTraceViewer } from "../../altimate/observability/viewer" import { Config } from "../../config/config" import fs from "fs/promises" import path from "path" +import { Locale } from "../../util/locale" function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms` @@ -18,8 +19,7 @@ function formatDuration(ms: number): string { } function formatCost(cost: number): string { - if (cost < 0.01) return `$${cost.toFixed(4)}` - return `$${cost.toFixed(2)}` + return Locale.cost(cost) } function formatTimestamp(iso: string): string { diff --git a/packages/opencode/src/cli/cmd/trajectory.ts b/packages/opencode/src/cli/cmd/trajectory.ts index 7b81d18993..2dd6b6f208 100644 --- a/packages/opencode/src/cli/cmd/trajectory.ts +++ b/packages/opencode/src/cli/cmd/trajectory.ts @@ -208,7 +208,7 @@ function printTrajectoryTable(summaries: SessionSummary[]) { s.id.slice(-idW).padEnd(idW), Locale.truncate(s.title, titleW).padEnd(titleW), (s.agent || "-").slice(0, agentW).padEnd(agentW), - `$${s.cost.toFixed(2)}`.padStart(costW), + Locale.cost(s.cost).padStart(costW), String(s.tool_calls).padStart(toolsW), String(s.generations).padStart(gensW), formatDuration(s.duration_ms).padStart(durW), diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index f64dbe533a..f3669701f5 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -8,6 +8,7 @@ import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" import { Flag } from "@/flag/flag" +import { Locale } from "@/util/locale" import { useTerminalDimensions } from "@opentui/solid" const Title = (props: { session: Accessor }) => { @@ -52,10 +53,7 @@ export function Header() { messages(), sumBy((x) => (x.role === "assistant" ? x.cost : 0)), ) - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(total) + return Locale.cost(total) }) const context = createMemo(() => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index e97c1797bc..bc5b7848d5 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -44,10 +44,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const cost = createMemo(() => { const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(total) + return Locale.cost(total) }) const context = createMemo(() => { diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 487b8b7faf..8ab412721b 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -80,4 +80,38 @@ export namespace Locale { const template = count === 1 ? singular : plural return template.replace("{}", count.toString()) } + + /** + * Format a USD cost value with appropriate precision. + * + * The standard Intl.NumberFormat currency formatter rounds to 2 decimal + * places, which causes any cost below $0.005 to display as "$0.00". + * For LLM usage this is misleading — a single message with 1K input + * tokens on Claude Sonnet costs ~$0.003, which would round to "$0.00" + * even though the user is being charged. + * + * This function uses tiered precision: + * $0 → "$0.00" + * < $0.01 → "$0.0012" (4 decimal places so sub-cent costs are visible) + * < $0.10 → "$0.0123" (4 decimal places for precision) + * >= $0.10 → "$0.12" (standard 2 decimal places) + */ + export function cost(amount: number): string { + if (amount === 0) return "$0.00" + if (amount > 0 && amount < 0.10) { + // Use 4 decimal places so sub-cent costs are visible. + // Strip trailing zeros but keep at least 2 decimal places. + const raw = amount.toFixed(4) + const trimmed = raw.replace(/0+$/, "") + // Ensure at least 2 decimal places after the dot + const dot = trimmed.indexOf(".") + const decimals = dot === -1 ? 0 : trimmed.length - dot - 1 + const padded = decimals < 2 ? trimmed + "0".repeat(2 - decimals) : trimmed + return "$" + padded + } + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount) + } } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 011cec2cc6..e39b184f3c 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -372,6 +372,54 @@ describe("session.getUsage", () => { expect(result.cost).toBe(3 + 1.5) }) + test("calculates non-zero cost for small token counts (single message)", () => { + // Simulates a typical single LLM response: + // 5K input tokens, 1K output tokens on Claude Sonnet ($3/$15 per M) + const model = createModel({ + context: 200_000, + output: 64_000, + cost: { + input: 3, + output: 15, + cache: { read: 0.3, write: 3.75 }, + }, + }) + const result = Session.getUsage({ + model, + usage: { + inputTokens: 5000, + outputTokens: 1000, + totalTokens: 6000, + }, + }) + + // 3 * 5000/1M + 15 * 1000/1M = 0.015 + 0.015 = 0.03 + expect(result.cost).toBeCloseTo(0.03, 6) + expect(result.cost).toBeGreaterThan(0) + }) + + test("returns zero cost for free models", () => { + const model = createModel({ + context: 200_000, + output: 64_000, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + }) + const result = Session.getUsage({ + model, + usage: { + inputTokens: 10000, + outputTokens: 2000, + totalTokens: 12000, + }, + }) + + expect(result.cost).toBe(0) + }) + test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])( "computes total from components for %s models", (npm) => { diff --git a/packages/opencode/test/util/locale.test.ts b/packages/opencode/test/util/locale.test.ts index 502b85b6aa..6a3c1ace53 100644 --- a/packages/opencode/test/util/locale.test.ts +++ b/packages/opencode/test/util/locale.test.ts @@ -66,6 +66,47 @@ describe("Locale.truncateMiddle", () => { }) }) +describe("Locale.cost", () => { + test("shows $0.00 for zero cost", () => { + expect(Locale.cost(0)).toBe("$0.00") + }) + + test("shows 4 decimal places for sub-cent costs", () => { + // 1K input tokens on Claude Sonnet ($3/M) = $0.003 + expect(Locale.cost(0.003)).toBe("$0.003") + // Tiny cost that would round to $0.00 with 2 decimals + expect(Locale.cost(0.001)).toBe("$0.001") + expect(Locale.cost(0.0001)).toBe("$0.0001") + }) + + test("shows 4 decimal places for costs under 10 cents", () => { + expect(Locale.cost(0.015)).toBe("$0.015") + expect(Locale.cost(0.0567)).toBe("$0.0567") + expect(Locale.cost(0.09)).toBe("$0.09") + }) + + test("shows standard 2 decimal places for costs >= 10 cents", () => { + expect(Locale.cost(0.10)).toBe("$0.10") + expect(Locale.cost(0.50)).toBe("$0.50") + expect(Locale.cost(1.23)).toBe("$1.23") + expect(Locale.cost(42.00)).toBe("$42.00") + }) + + test("negative amounts use standard Intl formatting", () => { + // Negative costs should not go through the sub-cent branch + expect(Locale.cost(-0.003)).toBe("-$0.00") + expect(Locale.cost(-1.50)).toBe("-$1.50") + }) + + test("handles typical session costs", () => { + // Single message: 5K input + 1K output on Claude Sonnet + // $3 * 5000/1M + $15 * 1000/1M = $0.015 + $0.015 = $0.03 + expect(Locale.cost(0.03)).toBe("$0.03") + // Multi-message session: accumulated ~$0.25 + expect(Locale.cost(0.25)).toBe("$0.25") + }) +}) + describe("Locale.pluralize", () => { test("uses singular for count=1", () => { expect(Locale.pluralize(1, "{} item", "{} items")).toBe("1 item")