Skip to content
Closed
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
7 changes: 4 additions & 3 deletions packages/opencode/src/cli/cmd/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))))
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/cli/cmd/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/trajectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
6 changes: 2 additions & 4 deletions packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Session> }) => {
Expand Down Expand Up @@ -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(() => {
Expand Down
5 changes: 1 addition & 4 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
34 changes: 34 additions & 0 deletions packages/opencode/src/util/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
48 changes: 48 additions & 0 deletions packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
41 changes: 41 additions & 0 deletions packages/opencode/test/util/locale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading