diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb7d97..c791b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - **`ccx doctor` checks that the record is whole.** Two records of the same work have different lifetimes: Claude Code deletes transcripts older than `cleanupPeriodDays` (default 30, by file mtime) and never touches `history.jsonl`, so a store can lose months without one error — every command just returns fewer sessions. `doctor` now reports the retention setting (warning when it is the default, or 0) and compares the sessions named in prompt history against the transcripts on disk, per provider and month, listing each month where more than a tenth of the prompts have no transcript (`2026-01 1953 prompts, 332 on disk; 77 sessions, 7 on disk`). Codex is checked too, including `archived_sessions/`. 0.2 s; no transcript is read. (dogfood 2026-09-30: a five-month-old hole nobody could see from inside ccx) ### Fixed +- Claude Code usage is counted once per API response. Claude Code writes one response as several records that repeat its usage; 0.17.0 summed every record and overstated Claude Code cost by about 2.2x (1.3x to 3.1x across 17 sessions). The rule applies to the main transcript and every sub-agent file in the full parse and the quick parse; cached parses are refreshed. Session-list cost still covers the main transcript only, while `trace` totals include sub-agent transcripts. +- 1-hour cache writes are priced at twice the input rate instead of the 5-minute rate. - **`log --kind user_prompt` counted subagent briefs as human prompts.** A subagent's task prompt is user-role text written by the parent agent; inline (`isSidechain`) or in a `subagents/` file it was classified `user_prompt`, so "the humans in the loop" and `metrics.user_prompts` / `days[].user_prompts` ran a fifth to a half high (3,079 of 16,829 on one store; in a month whose parent transcripts were gone, the surviving subagent files made up most of the "prompts"). They are now kind `agent_prompt`. `insight` already excluded them and is unchanged. - `log --since X` without `--until` was rejected ("both required"); "since X" now means until now, in `log` and `insight`, and the reconstructed `insight` command no longer prints an empty `--until`. diff --git a/internal/parser/pricing.go b/internal/parser/pricing.go index af80c9f..f943463 100644 --- a/internal/parser/pricing.go +++ b/internal/parser/pricing.go @@ -19,7 +19,11 @@ type ModelPricing struct { InputPer1M float64 // USD per 1,000,000 input tokens OutputPer1M float64 // USD per 1,000,000 output tokens CacheReadPer1M float64 // USD per 1,000,000 cache-read input tokens - CacheWritePer1M float64 // USD per 1,000,000 cache-creation input tokens + CacheWritePer1M float64 // USD per 1,000,000 cache-creation input tokens (5-minute TTL) + // CacheWrite1hPer1M prices the 1-hour TTL cache write, 2x input on + // every Anthropic tier. Zero means the vendor has one write rate, + // and every cache-write token is billed at CacheWritePer1M. + CacheWrite1hPer1M float64 } // Named Claude cost tiers — mirror the constants in Claude Code's @@ -30,22 +34,27 @@ var ( costTier_3_15 = ModelPricing{ InputPer1M: 3.00, OutputPer1M: 15.00, CacheReadPer1M: 0.30, CacheWritePer1M: 3.75, + CacheWrite1hPer1M: 6.00, } costTier_15_75 = ModelPricing{ InputPer1M: 15.00, OutputPer1M: 75.00, CacheReadPer1M: 1.50, CacheWritePer1M: 18.75, + CacheWrite1hPer1M: 30.00, } costTier_5_25 = ModelPricing{ InputPer1M: 5.00, OutputPer1M: 25.00, CacheReadPer1M: 0.50, CacheWritePer1M: 6.25, + CacheWrite1hPer1M: 10.00, } costHaiku_35 = ModelPricing{ InputPer1M: 0.80, OutputPer1M: 4.00, CacheReadPer1M: 0.08, CacheWritePer1M: 1.00, + CacheWrite1hPer1M: 1.60, } costHaiku_45 = ModelPricing{ InputPer1M: 1.00, OutputPer1M: 5.00, CacheReadPer1M: 0.10, CacheWritePer1M: 1.25, + CacheWrite1hPer1M: 2.00, } // Fable 5 tier. Input/output ($10/$50 per MTok) are VERIFIED against the @@ -57,6 +66,7 @@ var ( costTier_10_50 = ModelPricing{ InputPer1M: 10.00, OutputPer1M: 50.00, CacheReadPer1M: 1.00, CacheWritePer1M: 12.50, // 0.1x / 1.25x of input (standard ratio) + CacheWrite1hPer1M: 20.00, } // Sonnet 5 tier: $2/$10 per MTok, VERIFIED against the claude-api @@ -66,6 +76,7 @@ var ( costTier_2_10 = ModelPricing{ InputPer1M: 2.00, OutputPer1M: 10.00, CacheReadPer1M: 0.20, CacheWritePer1M: 2.50, + CacheWrite1hPer1M: 4.00, } // Codex / GPT-5.x tiers. @@ -334,7 +345,16 @@ func ComputeCost(u *MessageUsage, p *ModelPricing) float64 { cost += float64(u.InputTokens) * p.InputPer1M / perMillion cost += float64(u.OutputTokens) * p.OutputPer1M / perMillion cost += float64(u.CacheReadTokens) * p.CacheReadPer1M / perMillion - cost += float64(u.CacheCreateTokens) * p.CacheWritePer1M / perMillion + write5m, write1h := u.CacheCreateTokens, 0 + if p.CacheWrite1hPer1M > 0 && u.CacheCreate1hTokens > 0 { + write1h = u.CacheCreate1hTokens + if write1h > write5m { + write1h = write5m + } + write5m -= write1h + } + cost += float64(write5m) * p.CacheWritePer1M / perMillion + cost += float64(write1h) * p.CacheWrite1hPer1M / perMillion return cost } diff --git a/internal/parser/pricing_test.go b/internal/parser/pricing_test.go index a3d396b..f07bc3d 100644 --- a/internal/parser/pricing_test.go +++ b/internal/parser/pricing_test.go @@ -341,3 +341,21 @@ func TestSessionStats_CostStatusSeparatesUnpricedFromFree(t *testing.T) { } } } + +func TestComputeCost_SplitsCacheWriteByTTL(t *testing.T) { + pricing := &ModelPricing{InputPer1M: 5, OutputPer1M: 25, CacheReadPer1M: 0.5, CacheWritePer1M: 6.25, CacheWrite1hPer1M: 10} + usage := &MessageUsage{CacheCreateTokens: 1_000_000, CacheCreate1hTokens: 250_000} + if got, want := ComputeCost(usage, pricing), 750_000*6.25/1e6+250_000*10/1e6; got != want { + t.Errorf("ComputeCost = %v, want %v", got, want) + } + // A vendor with one write rate bills every write token at it. + flat := &ModelPricing{CacheWritePer1M: 6.25} + if got, want := ComputeCost(usage, flat), 6.25; got != want { + t.Errorf("ComputeCost (no 1h rate) = %v, want %v", got, want) + } + // The 1h subset can never exceed the total. + over := &MessageUsage{CacheCreateTokens: 100, CacheCreate1hTokens: 500} + if got, want := ComputeCost(over, pricing), 100*10/1e6; got != want { + t.Errorf("ComputeCost (1h > total) = %v, want %v", got, want) + } +} diff --git a/internal/parser/session.go b/internal/parser/session.go index 476dfbb..d0a9adf 100644 --- a/internal/parser/session.go +++ b/internal/parser/session.go @@ -18,7 +18,6 @@ func ParseSession(filePath string) (*Session, error) { var messages []*Message var summaryFromFile string - var totalInputTokens, totalOutputTokens, totalCacheRead, totalCacheCreate int var sessionSlug, sessionTitle, sessionVersion, sessionBranch, sessionCWD string logicalParents := make(map[string]string) // compact_boundary UUID -> logical parent UUID scanner := bufio.NewScanner(file) @@ -40,18 +39,6 @@ func ParseSession(filePath string) (*Session, error) { continue } - // Accumulate usage stats (check both top-level and message.usage) - usage := raw.Usage - if usage == nil && raw.Message.Usage != nil { - usage = raw.Message.Usage - } - if usage != nil { - totalInputTokens += usage.InputTokens - totalOutputTokens += usage.OutputTokens - totalCacheRead += usage.CacheReadInputTokens - totalCacheCreate += usage.CacheCreationInputTokens - } - // Extract session metadata from first message that has it if sessionSlug == "" && raw.Slug != "" { sessionSlug = raw.Slug @@ -102,21 +89,21 @@ func ParseSession(filePath string) (*Session, error) { for _, msg := range messages { msg.ParentUUID = resolveLogicalParent(msg.ParentUUID, logicalParents) } + collapseResponseUsage(messages) rootMessages := buildMessageTree(messages) stats := computeStats(messages) - // Add token usage stats - stats.InputTokens = totalInputTokens - stats.OutputTokens = totalOutputTokens - stats.CacheReadTokens = totalCacheRead - stats.CacheCreateTokens = totalCacheCreate - - // Session cost is the sum of per-message costs. We compute it post-parse - // so the session total is guaranteed equal to the sum of per-turn costs - // the UI renders (single source of truth). + // Session totals are the sum of per-message usage, which after the + // collapse is one figure per API response. Computed here so the + // session total is guaranteed equal to the sum of per-turn costs + // every surface renders (single source of truth). for _, msg := range messages { if msg.Usage != nil { + stats.InputTokens += msg.Usage.InputTokens + stats.OutputTokens += msg.Usage.OutputTokens + stats.CacheReadTokens += msg.Usage.CacheReadTokens + stats.CacheCreateTokens += msg.Usage.CacheCreateTokens stats.CostUSD += msg.Usage.CostUSD if !msg.Usage.Priced { stats.UnpricedTokens += msg.Usage.Total() @@ -210,17 +197,9 @@ func parseMessage(raw rawMessage) *Message { usage = raw.Usage } if usage != nil { - mu := &MessageUsage{ - InputTokens: usage.InputTokens, - OutputTokens: usage.OutputTokens, - CacheReadTokens: usage.CacheReadInputTokens, - CacheCreateTokens: usage.CacheCreationInputTokens, - } - pricing := LookupPricing(msg.Model) - mu.CostUSD = ComputeCost(mu, pricing) - mu.Priced = pricing != nil - msg.Usage = mu + msg.Usage = priceUsage(usage.toUsage(), msg.Model) } + msg.ResponseID = raw.Message.ID msg.Content = parseContent(raw.Message.Content) msg.Kind = classifyMessage(msg, raw) @@ -545,6 +524,7 @@ func quickParseSession(filePath string) (summary string, startTime, endTime time var firstTime, lastTime time.Time foundSummary := false + var usageByResponse responseUsage for scanner.Scan() { line := scanner.Text() @@ -565,6 +545,7 @@ func quickParseSession(filePath string) (summary string, startTime, endTime time CWD string `json:"cwd"` Usage *usageData `json:"usage"` Message struct { + ID string `json:"id"` Content any `json:"content"` Model string `json:"model"` Usage *usageData `json:"usage"` @@ -575,30 +556,15 @@ func quickParseSession(filePath string) (summary string, startTime, endTime time continue } - // Accumulate token usage (check both top-level and message.usage) + // Usage is one figure per API response, and the last record of + // a response carries the final one (see collapseResponseUsage). + // Same rule as the full parse, so list totals equal view totals. usage := raw.Usage if usage == nil && raw.Message.Usage != nil { usage = raw.Message.Usage } if usage != nil { - stats.InputTokens += usage.InputTokens - stats.OutputTokens += usage.OutputTokens - stats.CacheReadTokens += usage.CacheReadInputTokens - stats.CacheCreateTokens += usage.CacheCreationInputTokens - - // Accumulate cost using the same pricing path the full parser uses, - // so session-list totals match the session-view total. - mu := &MessageUsage{ - InputTokens: usage.InputTokens, - OutputTokens: usage.OutputTokens, - CacheReadTokens: usage.CacheReadInputTokens, - CacheCreateTokens: usage.CacheCreationInputTokens, - } - if pricing := LookupPricing(raw.Message.Model); pricing != nil { - stats.CostUSD += ComputeCost(mu, pricing) - } else { - stats.UnpricedTokens += mu.Total() - } + usageByResponse.set(raw.Message.ID, usage.toUsage(), raw.Message.Model) } // Extract metadata from first message that has it @@ -681,6 +647,7 @@ func quickParseSession(filePath string) (summary string, startTime, endTime time if summary == "" { summary = "(no summary)" } + usageByResponse.addTo(&stats) return summary, firstTime, lastTime, stats, meta } @@ -787,6 +754,7 @@ func parseSidechainFile(filePath string) []*Message { messages = append(messages, msg) } } + collapseResponseUsage(messages) return messages } diff --git a/internal/parser/types.go b/internal/parser/types.go index f8017bc..4f262ad 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -38,7 +38,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 = 6 // 5: pricing rows for Claude 5 / GPT-5.6, SessionStats.UnpricedTokens; 6: Codex quick parse prices token_count totals +const CacheFormatVersion = 10 // 5: pricing rows for Claude 5 / GPT-5.6, SessionStats.UnpricedTokens; 6: Codex quick parse prices token_count totals; 7-9: skipped, stamped by unreleased development builds with other shapes; 10: usage counted once per API response (Message.ResponseID), 5m/1h cache-write split type Session struct { ID string @@ -144,10 +144,15 @@ type MessageUsage struct { InputTokens int OutputTokens int CacheReadTokens int - CacheCreateTokens int - ReasoningTokens int // Codex-only (GPT-5 reasoning); 0 for Claude - CostUSD float64 // 0 if model is unknown to the pricing table - Priced bool // false when the model had no pricing row (CostUSD is then "unknown", not 0) + CacheCreateTokens int // every cache-write token, both TTLs + // CacheCreate1hTokens is the part of CacheCreateTokens written with + // the 1-hour TTL, which Anthropic bills at twice the 5-minute rate. + // Zero when the producer did not split the figure; the whole write + // is then priced at the 5-minute rate, the cheaper reading. + CacheCreate1hTokens int + ReasoningTokens int // Codex-only (GPT-5 reasoning); 0 for Claude + CostUSD float64 // 0 if model is unknown to the pricing table + Priced bool // false when the model had no pricing row (CostUSD is then "unknown", not 0) } // Total returns the sum of all token categories. @@ -177,7 +182,15 @@ type Message struct { Model string // Model ID (e.g., claude-sonnet-4-5-20250929) Subtype string // For system messages: compact_boundary, local_command - Usage *MessageUsage // Per-message API token usage + cost. nil if absent. + // ResponseID is the producer's id for the API response this record + // was cut from. Claude Code writes one response as several records + // (one per content block) that share it; usage is counted once per + // ResponseID, on the last record, and the earlier records carry + // Usage == nil. Empty for producers that write one record per + // response or report no id. + ResponseID string `json:",omitempty"` + + Usage *MessageUsage // Token usage + cost, once per API response. nil on the other records of the same response, and when absent. SubAgentResult *SubAgentResultData // Sub-agent summary from toolUseResult. nil for non-agent tool results. RawJSON string // Original JSONL line verbatim @@ -255,9 +268,19 @@ type usageData struct { OutputTokens int `json:"output_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + // CacheCreation splits the write by TTL. Absent on older records; + // then the whole write is priced at the 5-minute rate. + CacheCreation *struct { + Ephemeral5m int `json:"ephemeral_5m_input_tokens"` + Ephemeral1h int `json:"ephemeral_1h_input_tokens"` + } `json:"cache_creation"` } type messagePayload struct { + // ID is the API's message id. Claude Code writes one API response + // as several records (one per content block) sharing it; that is + // how ccx knows to count the usage once. + ID string `json:"id"` Role string `json:"role"` Content any `json:"content"` // string or []contentBlock Model string `json:"model"` // Model ID for assistant messages diff --git a/internal/parser/usage.go b/internal/parser/usage.go new file mode 100644 index 0000000..f823af7 --- /dev/null +++ b/internal/parser/usage.go @@ -0,0 +1,111 @@ +package parser + +// Claude Code writes one API response as several transcript records, +// one per content block (thinking, text, tool_use), and every record +// repeats the response's `usage`. Fable records repeat the final +// figure; Opus 5 records carry the figure as it stood when the block +// was cut, so the first record of a response can say output_tokens 1 +// while the last says 342. Summing records therefore overcounts cache +// reads by the block count and can over- or undercount output. +// +// The rule that matches the wire (verified against a cctrace capture +// of 2,607 requests): one usage per response id, the last record wins. + +// collapseResponseUsage leaves Usage on the last record of every +// response and clears it on the earlier ones, in place. Records with +// no ResponseID are left alone: they are their own response. +func collapseResponseUsage(messages []*Message) { + last := make(map[string]*Message) + for _, msg := range messages { + if msg == nil || msg.ResponseID == "" || msg.Usage == nil { + continue + } + if prev, ok := last[msg.ResponseID]; ok { + prev.Usage = nil + } + last[msg.ResponseID] = msg + } +} + +// toUsage converts one wire usage into MessageUsage, unpriced. +func (u *usageData) toUsage() *MessageUsage { + if u == nil { + return nil + } + mu := &MessageUsage{ + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + CacheReadTokens: u.CacheReadInputTokens, + CacheCreateTokens: u.CacheCreationInputTokens, + } + if u.CacheCreation != nil { + mu.CacheCreate1hTokens = u.CacheCreation.Ephemeral1h + } + return mu +} + +// priceUsage fills in cost under the model's rate card. Priced is +// false when ccx has no row for the model, and the figure is then +// "unknown", never 0. +func priceUsage(mu *MessageUsage, model string) *MessageUsage { + if mu == nil { + return nil + } + pricing := LookupPricing(model) + mu.CostUSD = ComputeCost(mu, pricing) + mu.Priced = pricing != nil + return mu +} + +// responseUsage is the quick-parse counterpart of collapseResponseUsage: +// a single pass keeps the latest usage seen per response id, and the +// roll-up prices each response once. Records without an id are +// counted individually. +type responseUsage struct { + byID map[string]responseUsageEntry + order []string + loose []responseUsageEntry +} + +type responseUsageEntry struct { + usage *MessageUsage + model string +} + +func (r *responseUsage) set(id string, usage *MessageUsage, model string) { + if usage == nil { + return + } + if id == "" { + r.loose = append(r.loose, responseUsageEntry{usage: usage, model: model}) + return + } + if r.byID == nil { + r.byID = make(map[string]responseUsageEntry) + } + if _, seen := r.byID[id]; !seen { + r.order = append(r.order, id) + } + r.byID[id] = responseUsageEntry{usage: usage, model: model} +} + +func (r *responseUsage) addTo(stats *SessionStats) { + add := func(e responseUsageEntry) { + mu := e.usage + stats.InputTokens += mu.InputTokens + stats.OutputTokens += mu.OutputTokens + stats.CacheReadTokens += mu.CacheReadTokens + stats.CacheCreateTokens += mu.CacheCreateTokens + if pricing := LookupPricing(e.model); pricing != nil { + stats.CostUSD += ComputeCost(mu, pricing) + } else { + stats.UnpricedTokens += mu.Total() + } + } + for _, id := range r.order { + add(r.byID[id]) + } + for _, e := range r.loose { + add(e) + } +} diff --git a/internal/parser/usage_test.go b/internal/parser/usage_test.go new file mode 100644 index 0000000..e13ed85 --- /dev/null +++ b/internal/parser/usage_test.go @@ -0,0 +1,122 @@ +package parser + +import ( + "os" + "path/filepath" + "testing" +) + +// One Opus 5 response written as three records: thinking (usage as it +// stood at message_start), text, tool_use (final usage). Plus one +// Fable-style response whose two records repeat the final figure, and +// one record with no response id at all. +const responseUsageFixture = `{"type":"user","timestamp":"2026-09-08T19:00:00.000Z","uuid":"u1","message":{"role":"user","content":"go"}} +{"type":"assistant","timestamp":"2026-09-08T19:00:01.000Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_A","model":"claude-opus-5","role":"assistant","content":[{"type":"thinking","thinking":"t"}],"usage":{"input_tokens":3,"output_tokens":1,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}} +{"type":"assistant","timestamp":"2026-09-08T19:00:02.000Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_A","model":"claude-opus-5","role":"assistant","content":[{"type":"text","text":"x"}],"usage":{"input_tokens":3,"output_tokens":1,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}} +{"type":"assistant","timestamp":"2026-09-08T19:00:03.000Z","uuid":"a3","parentUuid":"a2","message":{"id":"msg_A","model":"claude-opus-5","role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}],"usage":{"input_tokens":3,"output_tokens":342,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}} +{"type":"user","timestamp":"2026-09-08T19:00:04.000Z","uuid":"u2","parentUuid":"a3","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}} +{"type":"assistant","timestamp":"2026-09-08T19:00:05.000Z","uuid":"a4","parentUuid":"u2","message":{"id":"msg_B","model":"claude-fable-5-1","role":"assistant","content":[{"type":"thinking","thinking":"t"}],"usage":{"input_tokens":2,"output_tokens":874,"cache_read_input_tokens":500,"cache_creation_input_tokens":100}}} +{"type":"assistant","timestamp":"2026-09-08T19:00:06.000Z","uuid":"a5","parentUuid":"a4","message":{"id":"msg_B","model":"claude-fable-5-1","role":"assistant","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":2,"output_tokens":874,"cache_read_input_tokens":500,"cache_creation_input_tokens":100}}} +{"type":"assistant","timestamp":"2026-09-08T19:00:07.000Z","uuid":"a6","parentUuid":"a5","message":{"model":"claude-fable-5-1","role":"assistant","content":[{"type":"text","text":"legacy record, no id"}],"usage":{"input_tokens":7,"output_tokens":9,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +` + +func writeResponseUsageFixture(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "s.jsonl") + if err := os.WriteFile(path, []byte(responseUsageFixture), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestCollapseResponseUsage_LastRecordWins(t *testing.T) { + session, err := ParseSession(writeResponseUsageFixture(t)) + if err != nil { + t.Fatal(err) + } + var flat []*Message + var walk func(*Message) + walk = func(m *Message) { + flat = append(flat, m) + for _, c := range m.Children { + walk(c) + } + } + for _, r := range session.RootMessages { + walk(r) + } + byUUID := map[string]*Message{} + for _, m := range flat { + byUUID[m.UUID] = m + } + for _, uuid := range []string{"a1", "a2", "a4"} { + if byUUID[uuid].Usage != nil { + t.Errorf("%s: earlier record of a response still carries usage %+v", uuid, *byUUID[uuid].Usage) + } + } + if got := byUUID["a3"].Usage; got == nil || got.OutputTokens != 342 || got.CacheCreate1hTokens != 200 { + t.Fatalf("a3 usage = %+v, want the final figure (342 out, 200 1h cache write)", got) + } + if got := byUUID["a5"].Usage; got == nil || got.OutputTokens != 874 { + t.Fatalf("a5 usage = %+v, want 874 out", got) + } + if got := byUUID["a6"].Usage; got == nil || got.OutputTokens != 9 { + t.Fatalf("a6 (no response id) usage = %+v, want its own 9 out", got) + } + if byUUID["a3"].ResponseID != "msg_A" || byUUID["a6"].ResponseID != "" { + t.Errorf("ResponseID not carried: a3=%q a6=%q", byUUID["a3"].ResponseID, byUUID["a6"].ResponseID) + } + + // Session totals are one figure per response, not per record. + s := session.Stats + if s.InputTokens != 3+2+7 || s.OutputTokens != 342+874+9 || s.CacheReadTokens != 1000+500 || s.CacheCreateTokens != 200+100 { + t.Fatalf("stats = in %d out %d cr %d cw %d; want 12 / 1225 / 1500 / 300", + s.InputTokens, s.OutputTokens, s.CacheReadTokens, s.CacheCreateTokens) + } +} + +func TestQuickParseMatchesFullParseAfterCollapse(t *testing.T) { + path := writeResponseUsageFixture(t) + full, err := ParseSession(path) + if err != nil { + t.Fatal(err) + } + _, _, _, quick, _ := quickParseSession(path) + f, q := full.Stats, quick + if f.InputTokens != q.InputTokens || f.OutputTokens != q.OutputTokens || + f.CacheReadTokens != q.CacheReadTokens || f.CacheCreateTokens != q.CacheCreateTokens { + t.Fatalf("token totals differ: full %+v quick %+v", f, q) + } + if diff := f.CostUSD - q.CostUSD; diff > 1e-9 || diff < -1e-9 { + t.Fatalf("cost differs: full %.6f quick %.6f", f.CostUSD, q.CostUSD) + } + if f.CostUSD <= 0 { + t.Fatalf("cost should be priced, got %.6f", f.CostUSD) + } +} + +func TestOneHourCacheWriteIsPricedAtTheOneHourRate(t *testing.T) { + session, err := ParseSession(writeResponseUsageFixture(t)) + if err != nil { + t.Fatal(err) + } + // msg_A: opus-5, 3 in, 342 out, 1000 cache read, 200 cache write all 1h. + // Rates: 5/25, read 0.5, 1h write 10 (= 2x input). + want := (3*5.0 + 342*25.0 + 1000*0.5 + 200*10.0) / 1e6 + var got float64 + var walk func(*Message) + walk = func(m *Message) { + if m.ResponseID == "msg_A" && m.Usage != nil { + got = m.Usage.CostUSD + } + for _, c := range m.Children { + walk(c) + } + } + for _, r := range session.RootMessages { + walk(r) + } + if diff := got - want; diff > 1e-12 || diff < -1e-12 { + t.Fatalf("msg_A cost = %.9f, want %.9f (1h cache write at 2x input)", got, want) + } +}