Skip to content

Commit 5b35eff

Browse files
committed
feat: trust JSONL cache tiers, externalize all pricing to JSON
Claude Code now correctly reports cache write tiers per model: - Main conversation (Opus/Sonnet) → ephemeral_1h_input_tokens - Subagent requests (any model) → ephemeral_5m_input_tokens Remove the cacheWriteAs1h override and -cache-5m flag — trust the JSONL data as-is. Fallback for old logs without cache_creation sub-object defaults to 1h. Externalize all remaining hardcoded pricing to pricing.json: - Per-model: cache_read, cache_write_5m, cache_write_1h (and long_ctx variants) — with fillCacheDefaults() fallback that derives from input price using standard multipliers when absent - Global: long_context_threshold (200K), web_search_cost ($0.01) Known regression: old JSONL logs (Dec 2025 – Feb 2026) where Claude Code reported all cache writes as ephemeral_5m will now price Opus/Sonnet cache writes at 1.25x instead of 2x. Accepted trade-off — affects only historical data and the old override was wrong for Haiku subagents (overpriced at 2x instead of correct 1.25x).
1 parent 67735a4 commit 5b35eff

File tree

10 files changed

+151
-123
lines changed

10 files changed

+151
-123
lines changed

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ One API call produces multiple JSONL entries sharing the same `requestId`. `inpu
9595

9696
- **Flat package structure** — all code in `package main`, one concern per file
9797
- **Dedup by requestId** — streaming duplicates collapsed by keeping the last entry per `requestId` in a map
98-
- **Externalized pricing** — all model pricing, family prefixes, display names, and default model live in `pricing.json`. Embedded via `//go:embed`, with a remote-cached copy fetched from the repo every 24h. `initPricing()` prefers cached over embedded. Adding a new model requires only editing `pricing.json` — no code changes or binary release needed
98+
- **Externalized pricing** — all model pricing (input, output, cache read/write tiers, long context), global settings (long context threshold, web search cost), family prefixes, display names, and default model live in `pricing.json`. Embedded via `//go:embed`, with a remote-cached copy fetched from the repo every 24h. `initPricing()` prefers cached over embedded. Adding a new model or adjusting pricing requires only editing `pricing.json` — no code changes or binary release needed. Cache fields in JSON are optional — `fillCacheDefaults()` derives them from input price using standard multipliers (0.1x read, 1.25x write-5m, 2x write-1h) when absent
9999
- **Pricing resolution** — exact model ID → longest family prefix match → `defaultPricing`
100-
- **Cache write pricing defaults to 1h** — Claude Code JSONL logs report all cache writes as `ephemeral_5m`, but Anthropic billing matches 1-hour tier pricing (2x input). Override with `-cache-5m`. `CacheWrite5m`/`CacheWrite1h` remain separate fields (different pricing multipliers)
100+
- **Cache write tiers are trusted from JSONL** — Claude Code now correctly reports `ephemeral_5m_input_tokens` and `ephemeral_1h_input_tokens` per model (e.g. Haiku → 5m, Opus/Sonnet → 1h). Fallback for old logs without `cache_creation` sub-object defaults to 1h. `CacheWrite5m`/`CacheWrite1h` remain separate fields (different pricing multipliers)
101101
- **Shared file parsing**`parseFile()` in parser.go is used by both `parseLogs` (directory walk) and `parseSession` (statusline single-session)
102102
- **Local timezone everywhere** — local midnight for cutoffs, `parsed.Local()` for date bucketing. Never use `UTC()` for user-facing date logic
103103
- **MCP detection is best-effort** — all MCP detection functions return nil/empty on error; statusline never fails due to missing config
@@ -107,7 +107,7 @@ One API call produces multiple JSONL entries sharing the same `requestId`. `inpu
107107

108108
## Don't
109109

110-
- Don't add new model pricing by editing Go code — update `pricing.json` instead (models, families, and display_names sections)
110+
- Don't add or change pricing by editing Go code — update `pricing.json` instead (models, families, display_names, long_context_threshold, web_search_cost)
111111
- Don't use `log.Fatal` or `panic` — use `fmt.Fprintf(os.Stderr, ...)` + `os.Exit(1)`
112112
- Don't use UTC for day boundaries — use `time.Date(...)` with `now.Location()` for local midnight
113113
- Don't add JSON tags to `Bucket` — it's never directly marshalled; `printJSON` defines its own output structs

README.md

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ Parses JSONL logs from `~/.claude/projects/`, deduplicates streaming responses,
1818
- [Session Exit Hook](#session-exit-hook)
1919
- [Configuration](#configuration)
2020
- [Flags](#flags)
21-
- [How It Works](#how-it-works)
2221
- [Preserving Log History](#preserving-log-history)
2322

2423
## Installation
@@ -58,7 +57,6 @@ goccc -projects # Project breakdown only
5857
goccc -project webapp -daily # Filter by project name (substring match)
5958
goccc -days 1 # Today's usage
6059
goccc -projects -top 5 # Top 5 most expensive projects
61-
goccc -cache-5m # Use 5-minute cache pricing instead of 1-hour
6260
goccc -days 30 -all -json # JSON output for scripting
6361
goccc -json | jq '.summary.total_cost' # Pipe to jq for custom analysis
6462
goccc -currency-symbol "" -currency-rate 0.92 # One-off currency override
@@ -187,7 +185,6 @@ Thresholds are in USD (before currency conversion). They apply to the terminal o
187185
| `-projects` | | `false` | Show per-project breakdown |
188186
| `-all` | | `false` | Show all breakdowns (daily + projects) |
189187
| `-top` | `-n` | `0` | Max entries in breakdowns (0 = all) |
190-
| `-cache-5m` | | `false` | Price cache writes at 5-minute tier (1.25x input) instead of 1-hour (2x) |
191188
| `-json` | | `false` | Output as JSON |
192189
| `-no-color` | | `false` | Disable colored output (also respects `NO_COLOR` env) |
193190
| `-base-dir` | | `~/.claude` | Base directory for Claude Code data |
@@ -198,14 +195,6 @@ Thresholds are in USD (before currency conversion). They apply to the terminal o
198195
| `-currency-rate` | | `0` | Override exchange rate from USD (requires `-currency-symbol`) |
199196
| `-version` | `-V` | | Print version and exit |
200197

201-
## How It Works
202-
203-
goccc parses Claude Code's JSONL conversation logs from `~/.claude/projects/`, deduplicates streaming responses (by `requestId`), and calculates costs using [Anthropic's published pricing](https://platform.claude.com/docs/en/about-claude/pricing) — including cache write tiers, long-context premiums (>200K input), and web search costs.
204-
205-
### Cache Write Pricing
206-
207-
Claude Code JSONL logs report all cache writes as `ephemeral_5m`, but Anthropic billing matches 1-hour tier pricing (2x input price). goccc defaults to 1-hour pricing to align with actual bills. Use `-cache-5m` to switch to 5-minute pricing (1.25x input) if your billing differs.
208-
209198
## Preserving Log History
210199

211200
Claude Code periodically deletes old log files. To keep more history for cost tracking, increase the cleanup period in `~/.claude/settings.json`:

fixture_test.go

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func TestFixture_RealisticConversation(t *testing.T) {
4646
// req_main_001: in=15000 out=2500 cr=50000 cw5m=8000 cw1h=3000
4747
// req_main_002: in=22000 out=4800 cr=62000 cw5m=0 cw1h=5000
4848
// req_main_003: in=30000 out=6200 cr=70000 cw5m=2000 cw1h=0
49-
// req_main_004: in=35000 out=3500 cr=80000 cw5m=4000 cw1h=0 (flat fallback)
49+
// req_main_004: in=35000 out=3500 cr=80000 cw5m=0 cw1h=4000 (flat fallback → 1h)
5050

5151
opus := data.ModelUsage["claude-opus-4-6"]
5252
if opus == nil {
@@ -56,17 +56,17 @@ func TestFixture_RealisticConversation(t *testing.T) {
5656
assertInt(t, "opus.InputTokens", opus.InputTokens, 102000)
5757
assertInt(t, "opus.OutputTokens", opus.OutputTokens, 17000)
5858
assertInt(t, "opus.CacheRead", opus.CacheRead, 262000)
59-
assertInt(t, "opus.CacheWrite5m", opus.CacheWrite5m, 0)
60-
assertInt(t, "opus.CacheWrite1h", opus.CacheWrite1h, 22000)
59+
assertInt(t, "opus.CacheWrite5m", opus.CacheWrite5m, 10000)
60+
assertInt(t, "opus.CacheWrite1h", opus.CacheWrite1h, 12000)
6161

6262
// Opus cost per request (pricing: Input=$5/M, Output=$25/M,
63-
// CacheWrite1h=$10/M, CacheRead=$0.50/M — all writes treated as 1h):
64-
// req_main_001: 0.075 + 0.0625 + 0.11 + 0.025 = 0.2725
65-
// req_main_002: 0.11 + 0.12 + 0.05 + 0.031 = 0.311
66-
// req_main_003: 0.15 + 0.155 + 0.02 + 0.035 = 0.36
67-
// req_main_004: 0.175 + 0.0875 + 0.04 + 0.04 = 0.3425
68-
// Total: 1.286
69-
assertCost(t, "opus.Cost", opus.Cost, 1.286)
63+
// CacheWrite5m=$6.25/M, CacheWrite1h=$10/M, CacheRead=$0.50/M):
64+
// req_main_001: 0.075 + 0.0625 + 0.05 + 0.03 + 0.025 = 0.2425
65+
// req_main_002: 0.11 + 0.12 + 0 + 0.05 + 0.031 = 0.311
66+
// req_main_003: 0.15 + 0.155 + 0.0125 + 0 + 0.035 = 0.3525
67+
// req_main_004: 0.175 + 0.0875 + 0 + 0.04 + 0.04 = 0.3425 (flat fallback → 1h)
68+
// Total: 1.2485
69+
assertCost(t, "opus.Cost", opus.Cost, 1.2485)
7070

7171
// --- Haiku 4.5 model bucket ---
7272
// Final values after dedup:
@@ -82,16 +82,16 @@ func TestFixture_RealisticConversation(t *testing.T) {
8282
assertInt(t, "haiku.InputTokens", haiku.InputTokens, 19000)
8383
assertInt(t, "haiku.OutputTokens", haiku.OutputTokens, 4900)
8484
assertInt(t, "haiku.CacheRead", haiku.CacheRead, 45000)
85-
assertInt(t, "haiku.CacheWrite5m", haiku.CacheWrite5m, 0)
86-
assertInt(t, "haiku.CacheWrite1h", haiku.CacheWrite1h, 6500)
85+
assertInt(t, "haiku.CacheWrite5m", haiku.CacheWrite5m, 4500)
86+
assertInt(t, "haiku.CacheWrite1h", haiku.CacheWrite1h, 2000)
8787

8888
// Haiku cost per request (pricing: Input=$1/M, Output=$5/M,
89-
// CacheWrite1h=$2/M, CacheRead=$0.10/M — all writes treated as 1h):
90-
// req_sub_001: 0.005 + 0.006 + 0.006 + 0.0012 = 0.0182
91-
// req_sub_002: 0.008 + 0.014 + 0.004 + 0.0015 = 0.0275
92-
// req_sub_003: 0.006 + 0.0045 + 0.003 + 0.0018 = 0.0153
93-
// Total: 0.061
94-
assertCost(t, "haiku.Cost", haiku.Cost, 0.061)
89+
// CacheWrite5m=$1.25/M, CacheWrite1h=$2/M, CacheRead=$0.10/M):
90+
// req_sub_001: 0.005 + 0.006 + 0.00375 + 0 + 0.0012 = 0.01595
91+
// req_sub_002: 0.008 + 0.014 + 0 + 0.004 + 0.0015 = 0.0275
92+
// req_sub_003: 0.006 + 0.0045+ 0.001875+ 0 + 0.0018 = 0.014175
93+
// Total: 0.057625
94+
assertCost(t, "haiku.Cost", haiku.Cost, 0.057625)
9595

9696
// --- No other models should exist ---
9797
if len(data.ModelUsage) != 2 {
@@ -108,8 +108,8 @@ func TestFixture_RealisticConversation(t *testing.T) {
108108
if day18opus := day18["claude-opus-4-6"]; day18opus != nil {
109109
assertInt(t, "day18.opus.Requests", day18opus.Requests, 2)
110110
assertInt(t, "day18.opus.InputTokens", day18opus.InputTokens, 37000)
111-
// req_main_001 (0.2725) + req_main_002 (0.311)
112-
assertCost(t, "day18.opus.Cost", day18opus.Cost, 0.5835)
111+
// req_main_001 (0.2425) + req_main_002 (0.311)
112+
assertCost(t, "day18.opus.Cost", day18opus.Cost, 0.5535)
113113
} else {
114114
t.Error("missing opus bucket for 2026-02-18")
115115
}
@@ -121,14 +121,14 @@ func TestFixture_RealisticConversation(t *testing.T) {
121121
}
122122
if day19opus := day19["claude-opus-4-6"]; day19opus != nil {
123123
assertInt(t, "day19.opus.Requests", day19opus.Requests, 2)
124-
// req_main_003 (0.36) + req_main_004 (0.3425)
125-
assertCost(t, "day19.opus.Cost", day19opus.Cost, 0.7025)
124+
// req_main_003 (0.3525) + req_main_004 (0.3425)
125+
assertCost(t, "day19.opus.Cost", day19opus.Cost, 0.695)
126126
} else {
127127
t.Error("missing opus bucket for 2026-02-19")
128128
}
129129
if day19haiku := day19["claude-haiku-4-5-20251001"]; day19haiku != nil {
130130
assertInt(t, "day19.haiku.Requests", day19haiku.Requests, 3)
131-
assertCost(t, "day19.haiku.Cost", day19haiku.Cost, 0.061)
131+
assertCost(t, "day19.haiku.Cost", day19haiku.Cost, 0.057625)
132132
} else {
133133
t.Error("missing haiku bucket for 2026-02-19")
134134
}
@@ -142,8 +142,8 @@ func TestFixture_RealisticConversation(t *testing.T) {
142142
if len(proj) != 2 {
143143
t.Errorf("project has %d models, want 2", len(proj))
144144
}
145-
assertCost(t, "project.opus.Cost", proj["claude-opus-4-6"].Cost, 1.286)
146-
assertCost(t, "project.haiku.Cost", proj["claude-haiku-4-5-20251001"].Cost, 0.061)
145+
assertCost(t, "project.opus.Cost", proj["claude-opus-4-6"].Cost, 1.2485)
146+
assertCost(t, "project.haiku.Cost", proj["claude-haiku-4-5-20251001"].Cost, 0.057625)
147147

148148
// --- Branch aggregation ---
149149
// All fixture entries have gitBranch:"main"
@@ -158,8 +158,8 @@ func TestFixture_RealisticConversation(t *testing.T) {
158158
if len(mainBranch) != 2 {
159159
t.Errorf("expected 2 models in main branch, got %d", len(mainBranch))
160160
}
161-
assertCost(t, "branch.main.opus.Cost", mainBranch["claude-opus-4-6"].Cost, 1.286)
162-
assertCost(t, "branch.main.haiku.Cost", mainBranch["claude-haiku-4-5-20251001"].Cost, 0.061)
161+
assertCost(t, "branch.main.opus.Cost", mainBranch["claude-opus-4-6"].Cost, 1.2485)
162+
assertCost(t, "branch.main.haiku.Cost", mainBranch["claude-haiku-4-5-20251001"].Cost, 0.057625)
163163
}
164164

165165
func TestFixture_SyntheticEntriesSkipped(t *testing.T) {

format.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -302,9 +302,6 @@ func printSummary(data *ParseResult, opts OutputOptions) {
302302
if data.ParseErrors > 0 {
303303
dim.Printf(" (%d parse errors skipped)\n", data.ParseErrors)
304304
}
305-
if cacheWriteAs1h {
306-
dim.Println(" Cache writes priced at 1h tier (2x input); use -cache-5m for 1.25x")
307-
}
308305
if activeCurrency.Rate > 0 {
309306
if activeCurrency.Code != "" {
310307
dim.Printf(" Costs in %s (1 USD = %.4f %s)\n", activeCurrency.Code, activeCurrency.Rate, activeCurrency.Code)
@@ -349,7 +346,7 @@ func printSummary(data *ParseResult, opts OutputOptions) {
349346
dim.Printf(" Web searches: %d (%s)\n", totals.WebSearches, fmtCost(float64(totals.WebSearches)*webSearchCostPerSearch))
350347
}
351348
if totals.LongCtxRequests > 0 {
352-
dim.Printf(" Long-context requests (>200K): %d (premium pricing applied)\n", totals.LongCtxRequests)
349+
dim.Printf(" Long-context requests (>%dK): %d (premium pricing applied)\n", longCtxThreshold/1000, totals.LongCtxRequests)
353350
}
354351
fmt.Println()
355352

main.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ func main() {
5656
statusline := flag.Bool("statusline", false, "Statusline mode: read session JSON from stdin, output formatted cost line")
5757
sessionEnd := flag.Bool("session-end", false, "Session end hook mode: read SessionEnd JSON from stdin, print cost summary")
5858
noMCP := flag.Bool("no-mcp", false, "Hide MCP servers from statusline output")
59-
cache5m := flag.Bool("cache-5m", false, "Price cache writes at 5-minute tier (1.25x) instead of 1-hour (2x)")
6059
currencySymbolFlag := flag.String("currency-symbol", "", "Override currency symbol (requires -currency-rate)")
6160
currencyRateFlag := flag.Float64("currency-rate", 0, "Override exchange rate from USD (requires -currency-symbol)")
6261

@@ -93,10 +92,6 @@ func main() {
9392
noColorFlag = true
9493
}
9594

96-
if *cache5m {
97-
cacheWriteAs1h = false
98-
}
99-
10095
if err := initConfig(*currencySymbolFlag, *currencyRateFlag); err != nil {
10196
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
10297
os.Exit(1)

parser_test.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -443,20 +443,20 @@ func TestCacheTokenAggregation(t *testing.T) {
443443
if opus.CacheRead != 800 {
444444
t.Errorf("expected cache_read=800, got %d", opus.CacheRead)
445445
}
446-
// All cache writes treated as 1h (default): 5m(200+0)→1h, plus original 1h(100+150)
447-
if opus.CacheWrite5m != 0 {
448-
t.Errorf("expected cache_write_5m=0 (all promoted to 1h), got %d", opus.CacheWrite5m)
446+
// req_1: 5m=200, 1h=100; req_2: 5m=0, 1h=150
447+
if opus.CacheWrite5m != 200 {
448+
t.Errorf("expected cache_write_5m=200, got %d", opus.CacheWrite5m)
449449
}
450-
if opus.CacheWrite1h != 450 {
451-
t.Errorf("expected cache_write_1h=450, got %d", opus.CacheWrite1h)
450+
if opus.CacheWrite1h != 250 {
451+
t.Errorf("expected cache_write_1h=250, got %d", opus.CacheWrite1h)
452452
}
453453
if opus.TotalCacheWrite() != 450 {
454454
t.Errorf("expected total_cache_write=450, got %d", opus.TotalCacheWrite())
455455
}
456456
}
457457

458458
func TestCacheTokenFallback_FlatField(t *testing.T) {
459-
// When cache_creation is nil, cache_creation_input_tokens falls back to 5m
459+
// When cache_creation is nil, cache_creation_input_tokens defaults to 1h
460460
line := `{"type":"assistant","requestId":"req_flat","timestamp":` +
461461
fmt.Sprintf("%q", ts(0, 10)) +
462462
`,"message":{"model":"claude-opus-4-6","role":"assistant","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":0,"cache_creation_input_tokens":500}}}`
@@ -467,9 +467,8 @@ func TestCacheTokenFallback_FlatField(t *testing.T) {
467467
t.Fatal(err)
468468
}
469469
opus := data.ModelUsage["claude-opus-4-6"]
470-
// Flat fallback → 5m → promoted to 1h (default)
471470
if opus.CacheWrite5m != 0 {
472-
t.Errorf("expected cache_write_5m=0 (promoted to 1h), got %d", opus.CacheWrite5m)
471+
t.Errorf("expected cache_write_5m=0, got %d", opus.CacheWrite5m)
473472
}
474473
if opus.CacheWrite1h != 500 {
475474
t.Errorf("expected cache_write_1h=500, got %d", opus.CacheWrite1h)

0 commit comments

Comments
 (0)