Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
24 changes: 22 additions & 2 deletions internal/parser/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down
18 changes: 18 additions & 0 deletions internal/parser/pricing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
70 changes: 19 additions & 51 deletions internal/parser/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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"`
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -787,6 +754,7 @@ func parseSidechainFile(filePath string) []*Message {
messages = append(messages, msg)
}
}
collapseResponseUsage(messages)
return messages
}

Expand Down
35 changes: 29 additions & 6 deletions internal/parser/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading