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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to ccx are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

### Fixed
- **Codex cost no longer double-bills cached input and reasoning tokens.** Codex usage fields are subsets, not disjoint categories: `input_tokens` includes `cached_input_tokens` (upstream: `non_cached_input = input - cached`) and `output_tokens` includes reasoning (OpenAI `output_tokens_details`) — ccx billed every field separately, overstating a real 36-minute session 2.6x ($101.99 shown, $38.54 honest) and printing "6.6m in" for ~206k of uncached input while Claude's `in` excludes cache. The Codex backend now normalizes to the exclusive semantics the rest of ccx assumes; `ComputeCost` drops the reasoning term; cache format bumped so upgrades reparse. Found by the first cross-provider field eval. (#27)

## [0.12.0] - 2026-07-24

Both changes answer the second field report — the first one produced by ccx tracing itself: a user audited a live session's trace and caught its two headline numbers lying. Findings 3-5 from the same report are tracked in issues #21-#23.
Expand Down
10 changes: 5 additions & 5 deletions internal/parser/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,11 @@ func hasVariantToken(name, variant string) bool {
// ComputeCost returns USD for the given token usage at the given pricing.
// Returns 0 when pricing is nil.
//
// Cached reads and cached writes are billed at their discounted/
// surcharged rates respectively. Reasoning tokens (Codex-only) are
// billed at the output rate — OpenAI treats extended-thinking output
// as regular output for pricing purposes.
// The four billed categories are disjoint by the MessageUsage contract
// (input excludes cache reads). ReasoningTokens are NOT billed here:
// they are a subset of OutputTokens (OpenAI output_tokens_details) at
// the same rate, so the output term already covers them — adding a
// reasoning term would bill them twice.
func ComputeCost(u *MessageUsage, p *ModelPricing) float64 {
if u == nil || p == nil {
return 0
Expand All @@ -270,7 +271,6 @@ func ComputeCost(u *MessageUsage, p *ModelPricing) float64 {
cost += float64(u.OutputTokens) * p.OutputPer1M / perMillion
cost += float64(u.CacheReadTokens) * p.CacheReadPer1M / perMillion
cost += float64(u.CacheCreateTokens) * p.CacheWritePer1M / perMillion
cost += float64(u.ReasoningTokens) * p.OutputPer1M / perMillion
return cost
}

Expand Down
13 changes: 7 additions & 6 deletions internal/parser/pricing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,17 +144,18 @@ func TestHasVariantToken_DelimitedBoundaries(t *testing.T) {
}
}

func TestComputeCost_ReasoningTokensBilledAsOutput(t *testing.T) {
// Codex / GPT-5 reasoning tokens should be billed at the same
// rate as regular output tokens.
func TestComputeCost_ReasoningTokensNotBilledSeparately(t *testing.T) {
// Codex reasoning tokens are a SUBSET of output tokens (OpenAI
// output_tokens_details) at the same rate — the output term already
// bills them. A separate reasoning term would double-bill.
pricing := &ModelPricing{InputPer1M: 10, OutputPer1M: 80}
usage := &MessageUsage{
InputTokens: 1_000_000, // $10
OutputTokens: 1_000_000, // $80
ReasoningTokens: 500_000, // $40
OutputTokens: 1_000_000, // $80, of which 500k is reasoning
ReasoningTokens: 500_000, // informational only
}
got := ComputeCost(usage, pricing)
want := 10.0 + 80.0 + 40.0 // 130 USD
want := 10.0 + 80.0 // 90 USD
if got != want {
t.Errorf("ComputeCost = %v, want %v", got, want)
}
Expand Down
8 changes: 7 additions & 1 deletion internal/parser/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ type Project struct {
// struct versions silently, so without this stamp an upgraded binary
// keeps serving parses produced by the old code until the source
// session file itself happens to change.
const CacheFormatVersion = 2
const CacheFormatVersion = 3

type Session struct {
ID string
Expand Down Expand Up @@ -103,6 +103,12 @@ type SessionStats struct {
// ReasoningTokens carries reasoning_output_tokens. CacheCreateTokens
// stays zero for Codex (OpenAI doesn't expose 5m/1h cache creation
// the way Anthropic does).
//
// CONTRACT (Anthropic-style exclusive semantics, both providers):
// InputTokens excludes cache reads — the Codex backend subtracts
// cached_input_tokens, which upstream includes in input_tokens.
// OutputTokens includes reasoning; ReasoningTokens is the informational
// subset and is never billed separately (see ComputeCost).
type MessageUsage struct {
InputTokens int
OutputTokens int
Expand Down
26 changes: 19 additions & 7 deletions internal/provider/codex/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -672,9 +672,14 @@ func (b *Backend) quickParseSession(filePath string) (*parser.Session, error) {
case "token_count":
var payload tokenCountPayload
if err := json.Unmarshal(rollout.Payload, &payload); err == nil && payload.Info != nil {
stats.InputTokens = payload.Info.TotalTokenUsage.InputTokens
stats.CacheReadTokens = payload.Info.TotalTokenUsage.CachedInputTokens
stats.OutputTokens = payload.Info.TotalTokenUsage.OutputTokens
// Codex input_tokens INCLUDES cached_input_tokens
// (upstream: non_cached_input = input - cached); ccx
// carries input exclusive of cache reads, matching
// Anthropic semantics.
total := payload.Info.TotalTokenUsage
stats.InputTokens = clampNonNegative(total.InputTokens - total.CachedInputTokens)
stats.CacheReadTokens = total.CachedInputTokens
stats.OutputTokens = total.OutputTokens
}

case "thread_name_updated":
Expand Down Expand Up @@ -1175,8 +1180,13 @@ func (b *Backend) parseSession(filePath string, threadNames map[string]string) (
}
}
// Session-level aggregate (latest snapshot wins — Codex
// always emits running totals, not deltas).
stats.InputTokens = total.InputTokens
// always emits running totals, not deltas). Codex
// input_tokens INCLUDES cached_input_tokens (upstream:
// non_cached_input = input - cached); ccx carries input
// exclusive of cache reads, matching Anthropic
// semantics, so cost and display never bill a cached
// token twice.
stats.InputTokens = clampNonNegative(total.InputTokens - total.CachedInputTokens)
stats.CacheReadTokens = total.CachedInputTokens
stats.OutputTokens = total.OutputTokens

Expand All @@ -1192,10 +1202,12 @@ func (b *Backend) parseSession(filePath string, threadNames map[string]string) (
// turn sums all its messages. Without this fix, only
// the latest assistant got tokens and all earlier
// ones in the burst showed $0.00.
inputDelta := clampNonNegative(total.InputTokens - previousTotals.InputTokens)
cachedDelta := clampNonNegative(total.CachedInputTokens - previousTotals.CachedInputTokens)
delta := parser.MessageUsage{
InputTokens: clampNonNegative(total.InputTokens - previousTotals.InputTokens),
InputTokens: clampNonNegative(inputDelta - cachedDelta),
OutputTokens: clampNonNegative(total.OutputTokens - previousTotals.OutputTokens),
CacheReadTokens: clampNonNegative(total.CachedInputTokens - previousTotals.CachedInputTokens),
CacheReadTokens: cachedDelta,
ReasoningTokens: clampNonNegative(total.ReasoningOutputTokens - previousTotals.ReasoningOutputTokens),
}
if delta.Total() > 0 && usageWatermark <= len(messages) {
Expand Down
4 changes: 3 additions & 1 deletion internal/provider/codex/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ func TestParseSessionBuildsUsableTranscript(t *testing.T) {
if session.Stats.ToolCalls != 1 {
t.Fatalf("session.Stats.ToolCalls = %d, want 1", session.Stats.ToolCalls)
}
if session.Stats.InputTokens != 10 || session.Stats.CacheReadTokens != 2 || session.Stats.OutputTokens != 5 {
// input_tokens=10 includes cached_input_tokens=2; ccx carries the
// non-cached 8 (Anthropic-style exclusive semantics).
if session.Stats.InputTokens != 8 || session.Stats.CacheReadTokens != 2 || session.Stats.OutputTokens != 5 {
t.Fatalf("unexpected token stats: %+v", session.Stats)
}
if session.Stats.CostUSD <= 0 {
Expand Down
18 changes: 12 additions & 6 deletions internal/provider/codex/parse_branches_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,10 @@ func TestParseSessionTokenCountFromEventMsg(t *testing.T) {
t.Fatalf("ParseSession() error: %v", err)
}

if session.Stats.InputTokens != 500 {
t.Fatalf("InputTokens = %d, want 500", session.Stats.InputTokens)
// input_tokens=500 includes cached_input_tokens=100; ccx carries
// the non-cached 400 (Anthropic-style exclusive semantics).
if session.Stats.InputTokens != 400 {
t.Fatalf("InputTokens = %d, want 400", session.Stats.InputTokens)
}
if session.Stats.CacheReadTokens != 100 {
t.Fatalf("CacheReadTokens = %d, want 100", session.Stats.CacheReadTokens)
Expand All @@ -328,8 +330,8 @@ func TestParseSessionTokenCountFromEventMsg(t *testing.T) {
if assistant.Usage == nil {
t.Fatal("assistant.Usage is nil, want attributed token delta")
}
if assistant.Usage.InputTokens != 500 {
t.Fatalf("assistant.Usage.InputTokens = %d, want 500", assistant.Usage.InputTokens)
if assistant.Usage.InputTokens != 400 {
t.Fatalf("assistant.Usage.InputTokens = %d, want 400", assistant.Usage.InputTokens)
}
if assistant.Usage.CacheReadTokens != 100 {
t.Fatalf("assistant.Usage.CacheReadTokens = %d, want 100", assistant.Usage.CacheReadTokens)
Expand All @@ -340,8 +342,12 @@ func TestParseSessionTokenCountFromEventMsg(t *testing.T) {
if assistant.Usage.ReasoningTokens != 50 {
t.Fatalf("assistant.Usage.ReasoningTokens = %d, want 50", assistant.Usage.ReasoningTokens)
}
if assistant.Usage.CostUSD <= 0 {
t.Fatalf("assistant.Usage.CostUSD = %v, want > 0", assistant.Usage.CostUSD)
// gpt-5 tier 10/80, cache read 5: 400*10 + 200*80 + 100*5 per 1M.
// Cached input billed once (at the cache rate) and reasoning not
// billed on top of output — the double-billing regression guard.
wantCost := 0.0205
if diff := assistant.Usage.CostUSD - wantCost; diff < -1e-9 || diff > 1e-9 {
t.Fatalf("assistant.Usage.CostUSD = %v, want %v", assistant.Usage.CostUSD, wantCost)
}
if session.Stats.CostUSD != assistant.Usage.CostUSD {
t.Fatalf("session.Stats.CostUSD = %v, want %v", session.Stats.CostUSD, assistant.Usage.CostUSD)
Expand Down
Loading