From a14c9f8a6e114ef04028eb0cb559f1cb027a6676 Mon Sep 17 00:00:00 2001 From: Vijay Yadav Date: Mon, 30 Mar 2026 19:45:57 -0400 Subject: [PATCH 1/2] fix: show sub-cent costs in context panel instead of rounding to $0.00 The cost display in the TUI context panel (sidebar and header) used Intl.NumberFormat with USD currency formatting, which rounds to 2 decimal places. This caused any cost below $0.005 to display as "$0.00", making it appear that cost never increases even as tokens accumulate. For typical LLM usage, a single message with 1K input tokens on Claude Sonnet costs ~$0.003, which was invisibly rounded away. After several messages the cost would eventually cross the $0.01 threshold, but for the first many interactions it misleadingly showed $0.00. Add Locale.cost() formatter with tiered precision: - $0 exactly -> "$0.00" - < $0.10 -> "$0.003" (up to 4 decimals, trailing zeros stripped) - >= $0.10 -> "$0.12" (standard 2 decimal places) Apply the formatter consistently across all cost displays: TUI sidebar, TUI header, stats command, trace list, and trajectory table. Fixes #585 Co-Authored-By: Vijay Yadav --- packages/opencode/src/cli/cmd/stats.ts | 7 +-- packages/opencode/src/cli/cmd/trace.ts | 4 +- packages/opencode/src/cli/cmd/trajectory.ts | 2 +- .../src/cli/cmd/tui/routes/session/header.tsx | 6 +-- .../cli/cmd/tui/routes/session/sidebar.tsx | 5 +- packages/opencode/src/util/locale.ts | 34 +++++++++++++ .../opencode/test/session/compaction.test.ts | 48 +++++++++++++++++++ packages/opencode/test/util/locale.test.ts | 35 ++++++++++++++ 8 files changed, 127 insertions(+), 14 deletions(-) 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..e423152e2e 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.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..ddf58f8ee4 100644 --- a/packages/opencode/test/util/locale.test.ts +++ b/packages/opencode/test/util/locale.test.ts @@ -66,6 +66,41 @@ 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("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") From 3d9d7670110329c5198436a03bea6931e66b10a4 Mon Sep 17 00:00:00 2001 From: Vijay Yadav Date: Mon, 30 Mar 2026 19:55:07 -0400 Subject: [PATCH 2/2] fix: guard sub-cent formatting to positive amounts only Address CodeRabbit review: negative amounts were routed through the sub-cent branch instead of Intl.NumberFormat. Changed condition from `amount < 0.10` to `amount > 0 && amount < 0.10`. Added test for negative amounts. Co-Authored-By: Vijay Yadav --- packages/opencode/src/util/locale.ts | 2 +- packages/opencode/test/util/locale.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index e423152e2e..8ab412721b 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -98,7 +98,7 @@ export namespace Locale { */ export function cost(amount: number): string { if (amount === 0) return "$0.00" - if (amount < 0.10) { + 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) diff --git a/packages/opencode/test/util/locale.test.ts b/packages/opencode/test/util/locale.test.ts index ddf58f8ee4..6a3c1ace53 100644 --- a/packages/opencode/test/util/locale.test.ts +++ b/packages/opencode/test/util/locale.test.ts @@ -92,6 +92,12 @@ describe("Locale.cost", () => { 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