Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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.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
35 changes: 35 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,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")
Expand Down
Loading