From e3fe8a20665818e1da798822dbc7188fc29442af Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Tue, 28 Jul 2026 22:47:58 -0700 Subject: [PATCH 1/2] fix(codex): stop double-billing cached input and reasoning tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex usage fields are subsets, not disjoint categories: input_tokens INCLUDES cached_input_tokens (upstream codex-rs: non_cached_input = input - cached) and output_tokens includes reasoning tokens (OpenAI output_tokens_details). ccx stored the raw values and ComputeCost billed every field separately, so cached tokens were billed twice (once at full input rate) and reasoning twice at output rate. On a real 36-minute session: displayed $101.99, honest $38.54 — 2.6x overstated. The header also read "6.6m in" for ~206k of uncached input, while Claude sessions' "in" excludes cache — the same line meant different things per provider. The Codex backend now normalizes to the Anthropic-style exclusive semantics the rest of ccx assumes (input excludes cache reads), for both the session aggregate and per-message deltas; ComputeCost drops the reasoning term (subset of output, same rate); the contract is documented on MessageUsage. CacheFormatVersion bumped so upgraded binaries reparse instead of serving stale cached numbers. Closes #27 Co-Authored-By: Claude Fable 5 --- internal/parser/pricing.go | 10 +++---- internal/parser/pricing_test.go | 13 +++++----- internal/parser/types.go | 8 +++++- internal/provider/codex/backend.go | 26 ++++++++++++++----- internal/provider/codex/backend_test.go | 4 ++- .../provider/codex/parse_branches_test.go | 18 ++++++++----- 6 files changed, 53 insertions(+), 26 deletions(-) diff --git a/internal/parser/pricing.go b/internal/parser/pricing.go index 2064646..401ff69 100644 --- a/internal/parser/pricing.go +++ b/internal/parser/pricing.go @@ -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 @@ -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 } diff --git a/internal/parser/pricing_test.go b/internal/parser/pricing_test.go index 4db5f93..81680f0 100644 --- a/internal/parser/pricing_test.go +++ b/internal/parser/pricing_test.go @@ -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) } diff --git a/internal/parser/types.go b/internal/parser/types.go index a75b900..e0f9d33 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -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 @@ -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 diff --git a/internal/provider/codex/backend.go b/internal/provider/codex/backend.go index b5344b4..8aa3519 100644 --- a/internal/provider/codex/backend.go +++ b/internal/provider/codex/backend.go @@ -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": @@ -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 @@ -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) { diff --git a/internal/provider/codex/backend_test.go b/internal/provider/codex/backend_test.go index 96daef0..aa3e3fe 100644 --- a/internal/provider/codex/backend_test.go +++ b/internal/provider/codex/backend_test.go @@ -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 { diff --git a/internal/provider/codex/parse_branches_test.go b/internal/provider/codex/parse_branches_test.go index d2ad5d7..aed380d 100644 --- a/internal/provider/codex/parse_branches_test.go +++ b/internal/provider/codex/parse_branches_test.go @@ -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) @@ -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) @@ -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) From 0c06a38e0bf2462a886ee2c22c7c4990c87f7d1c Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Tue, 28 Jul 2026 22:48:28 -0700 Subject: [PATCH 2/2] docs(changelog): record codex cost double-billing fix (#27) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d1b8dc..24d15da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.