[BUG] /usage Stats tab counts message.usage per transcript row instead of per message.id, inflating token totals ~2x
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
/usage → Stats tab sums message.usage once per transcript row instead of once per API response, so every token figure it shows is roughly double.
One API response is written to the transcript as several assistant rows — one per content block (thinking, text, each tool_use) — and every one of those rows carries a byte-identical copy of the whole response's usage. The documented rule for any consumer summing that data is to count each message.id once:
When Claude uses tools in parallel, multiple messages share the same id with identical usage data. Track which IDs you've already counted and skip duplicates to avoid inflated totals. — Track cost and usage
That rule was reaffirmed when #87303 was closed as documented behaviour ("Consumers are expected to count each message.id once"). The Stats tab is a consumer and does not follow it.
Measured against the rendered screen
/usage → Stats → Overview → Last 7 days, against the same transcripts and window summed by a reimplementation of the scanner:
| field | Stats tab shows | naive (per row) | with documented dedup |
|---|---|---|---|
| Total tokens | 706.5m | 707.2m | 278.1m |
| Input | 731.4k | 731.4k | 135.9k |
| Output | 5.4m | 5.4m | 1.6m |
| Cache read | 670.2m | 670.8m | 267.4m |
| Cache write | 30.2m | 30.2m | 9.0m |
Input and Cache write match the per-row sum exactly at displayed precision; the other two differ by <0.1%, which is the session continuing to run between the screenshot and the replica run. The deduplicated column matches nothing on screen. Screenshot in Additional Information.
The adjacent tab in the same screen already does it correctly
Over the same 7-day window the Usage tab reports Last 7d · 1647 requests. Counting the same transcripts two ways:
Last 7d: rows = 4,430 distinct message.id = 1,647
1,647 reported = 1,647 distinct message.ids. Exact.
So within one screen: Usage counts 1,647 API responses by deduplicating on message.id, while Stats sums token usage across all 4,430 rows those same responses were written as. Both cannot be right.
cost-state — Claude Code's own per-response record, written into the same transcript file — agrees with the deduplicated total (exactly, on 6 of 14 sessions tested) and sits ~2x below the per-row sum.
Scope
This is not a request to change the transcript format. #87303 and #6805 asked for that and the answer was no; the shape is intentional. This is a report that the first-party consumer doesn't follow the contract the docs define.
Impact
- Total tokens ~2x on every date range (1.87x all-time, 2.54x on 7d for this machine).
- The error is not a uniform factor — it scales with blocks-per-turn, so the Models tab mis-weights models against each other (a model used for tool-heavy agentic work is inflated relative to one used for chat, at identical real usage) and the daily graph is distorted unevenly. Users can't correct for it with a mental divide-by-two.
- Output tokens are hit hardest: 2.51x vs 1.95x overall.
- The inflated history is permanent.
stats-cache.jsonis cumulative —Pl()merges new days forward and past days are never recomputed, gated by thelastComputedDatewatermark. Fixing the scanner alone leaves every already-recorded day wrong. - It undercuts the docs' own guidance: #87303 was closed by pointing third parties at a rule the first-party UI doesn't follow.
What Should Happen?
The Stats tab should count each API response once — the same rule the docs prescribe, the adjacent Usage tab already applies, and cost-state already records.
Concretely, for Last 7 days on the machine in this report, Overview should read approximately:
Total tokens: 278.1m
Input 135.9k · Output 1.6m · Cache read 267.4m · Cache write 9.0m
rather than the 706.5m currently shown. The Models tab percentages should shift too, since the inflation is not uniform across models — it scales with how many content blocks each turn produced.
More generally: /usage → Stats, /usage → Usage, and the cost-state record all read the same transcripts and should agree on how many API responses occurred. Today two of the three do, and the Stats tab doesn't.
Error Messages/Logs
$ python3 repro.py # see "Steps to Reproduce" for the script
transcript files scanned: 168
Rows per distinct message.id:
1 row(s): 9,048 responses
2 row(s): 6,844 responses
3 row(s): 5,228 responses
4 row(s): 671 responses
5 row(s): 130 responses
6 row(s): 64 responses
7 row(s): 23 responses
8 row(s): 24 responses
9 row(s): 14 responses
10 row(s): 8 responses
11 row(s): 7 responses
12 row(s): 3 responses
13 row(s): 7 responses
15 row(s): 2 responses
19 row(s): 1 responses
22,074 API responses written as 42,950 rows -> inflation factor 1.95x
== 7d ==
what /usage shows 711,020,734
with documented dedup applied 279,763,707
over-count 2.54x
== 30d ==
what /usage shows 11,009,034,957
with documented dedup applied 5,917,413,702
over-count 1.86x
== all time (local files) ==
what /usage shows 11,657,599,469
with documented dedup applied 6,240,231,408
over-count 1.87x
--- and the request count the Usage tab reports, for the same window ---
$ claude -p "/usage"
Last 24h · 123 requests · 4 sessions
Last 7d · 1647 requests · 17 sessions
# 1647 == the "distinct message.id" count above for the same window.
# The Usage tab deduplicates. The Stats tab, one tab over, does not.
Steps to Reproduce
- Use Claude Code normally for a few days, so
~/.claude/projects/**/*.jsonlholds sessions with tool use (i.e. responses with several content blocks). Any single tool-using session reproduces it; more data just makes the ratio steadier.
- Open
/usage→ Stats tab → Overview. Pressruntil the range reads Last 7 days. NoteTotal tokensand theInput · Output · Cache read · Cache writeline.
- Switch to the Usage tab in that same screen. Note the
Last 7d · N requestsfigure.
- Save this as
repro.pyand runpython3 repro.py. It reimplements the Stats scanner over the same files and window, then re-runs it applying the documentedmessage.iddedup. Read-only; prints aggregates only.
#!/usr/bin/env python3
"""
Repro: /usage sums message.usage per transcript ROW instead of per message.id,
so its token stats are inflated. Reimplements Claude Code's stats scanner
(`ks()`), then re-runs it with the documented dedup applied.
"""
import json, os, glob, datetime
from collections import defaultdict
ROOT = os.path.expanduser("~/.claude/projects")
SEP = os.sep
def stats_files():
"""Mirror of Om(): project *.jsonl + <session>/subagents/agent-*.jsonl"""
out = []
for d in sorted(glob.glob(os.path.join(ROOT, "*"))):
if not os.path.isdir(d):
continue
out += sorted(glob.glob(os.path.join(d, "*.jsonl")))
out += sorted(glob.glob(os.path.join(d, "*", "subagents", "agent-*.jsonl")))
return out
def entries(path):
for line in open(path, errors="ignore"):
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
except ValueError:
continue
if isinstance(r, dict) and r.get("timestamp") and r.get("type"):
yield r
def scan(from_date=None, dedupe=False):
"""Mirror of ks(). dedupe=True counts each message.id once, per the docs."""
by_model, seen = defaultdict(lambda: defaultdict(int)), set()
for path in stats_files():
is_sub = (SEP + "subagents" + SEP) in path
rows = list(entries(path))
if not is_sub:
rows = [r for r in rows if not r.get("isSidechain")]
for r in rows:
if from_date and r["timestamp"][:10] < from_date:
continue
if r.get("type") != "assistant":
continue
msg = r.get("message") or {}
u = msg.get("usage")
if not isinstance(u, dict):
continue
model = msg.get("model") or "unknown"
if model == "<synthetic>":
continue
if dedupe:
mid = msg.get("id")
if mid:
if (path, mid) in seen:
continue
seen.add((path, mid))
b = by_model[model]
b["in"] += u.get("input_tokens", 0) or 0
b["out"] += u.get("output_tokens", 0) or 0
b["cr"] += u.get("cache_read_input_tokens", 0) or 0
b["cw"] += u.get("cache_creation_input_tokens", 0) or 0
return by_model
def total(by_model):
return sum(v for b in by_model.values() for v in b.values())
def fan_out_histogram():
hist, per_file = defaultdict(int), defaultdict(lambda: defaultdict(int))
for path in stats_files():
for r in entries(path):
if r.get("type") != "assistant":
continue
msg = r.get("message") or {}
if isinstance(msg.get("usage"), dict) and msg.get("id"):
per_file[path][msg["id"]] += 1
for _, ids in per_file.items():
for _, n in ids.items():
hist[n] += 1
return hist
print(f"transcript files scanned: {len(stats_files())}\n")
print("Rows per distinct message.id:")
hist = fan_out_histogram()
ids = sum(hist.values())
rows = sum(n * c for n, c in hist.items())
for n in sorted(hist):
print(f" {n} row(s): {hist[n]:>7,} responses")
print(f" {ids:,} API responses written as {rows:,} rows"
f" -> inflation factor {rows/ids:.2f}x\n")
today = datetime.date.today()
for days in (7, 30, None):
frm = (today - datetime.timedelta(days=days - 1)).isoformat() if days else None
print(f"== {days}d ==" if days else "== all time (local files) ==")
a = total(scan(frm, dedupe=False)); print(f" {'what /usage shows':<34} {a:>16,}")
b = total(scan(frm, dedupe=True)); print(f" {'with documented dedup applied':<34} {b:>16,}")
if b:
print(f" {'over-count':<34} {a/b:>15.2f}x")
print()
- Compare the three:
- Step 4's "what /usage shows" for
7d≈ step 2's Total tokens → the Stats tab is showing the per-row sum. (In my run: 711.0m computed vs 706.5m on screen, the gap being tokens spent between the two.) - Step 4's distinct
message.idcount for7d== step 3's request count → the Usage tab deduplicates. (In my run: 1,647 == 1,647, exact.) - The two differ by ~2x.
One-line version, for a single transcript — prints total rows carrying usage vs. distinct message.ids:
python3 -c '
import json,sys
rows=set();n=0
for l in open(sys.argv[1]):
try: r=json.loads(l)
except: continue
m=r.get("message") or {}
if r.get("type")=="assistant" and isinstance(m.get("usage"),dict) and m.get("id"):
n+=1; rows.add(m["id"])
print(f"rows={n} distinct message.id={len(rows)} inflation={n/len(rows):.2f}x")
' ~/.claude/projects/<project>/<sessionId>.jsonl
Any output with inflation > 1.00x means the Stats tab is over-counting that session by that factor.
Claude Model
Not sure / Multiple models
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.259 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Other
Additional Information
Screenshot
/usage → Stats → Overview, Last 7 days. Total tokens: 706.5m, Input 731.4k · Output 5.4m · Cache read 670.2m · Cache write 30.2m — every field matching the per-row sum in the table above. Note the Usage tab sitting immediately to the left, which counts the same responses correctly.
<img width="983" height="741" alt="/usage Stats tab, Overview, Last 7 days: Total tokens 706.5m; Input 731.4k, Output 5.4m, Cache read 670.2m, Cache write 30.2m" src="https://github.com/user-attachments/assets/15f07d4f-1b56-416d-8f82-06bc7c22ee78" />
Where it happens
Symbol names below are the minified ones from the 2.1.259 bundle; annotations are mine. Pointers only — everything above was measured from Claude Code's own outputs and needs no reference to this to verify.
The scanner behind the Stats tab, accumulation block:
if (Re.type === "assistant") {
/* ... */
if (Re.message?.usage) {
let Fe = Re.message.usage,
Ye = Re.message.model || "unknown";
if (Ye === ru || _i(Ye)) continue; // ru === "<synthetic>"
if (!A[Ye]) A[Ye] = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0,
cacheCreationInputTokens: 0, webSearchRequests: 0,
costUSD: 0, contextWindow: 0, maxOutputTokens: 0 };
let ct = Ee(Fe.input_tokens) ?? 0,
et = Ee(Fe.output_tokens) ?? 0,
Gt = Ee(Fe.cache_read_input_tokens) ?? 0,
Bs = Ee(Fe.cache_creation_input_tokens) ?? 0,
_o = A[Ye];
_o.inputTokens += ct; // unconditional, per ROW
_o.outputTokens += et;
_o.cacheReadInputTokens += Gt;
_o.cacheCreationInputTokens += Bs;
let Jo = ct + et + Gt + Bs;
if (Jo > 0) {
let Po = R.get(lt) || {};
Po[Ye] = (Po[Ye] || 0) + Jo; // daily bucket, also per ROW
R.set(lt, Po);
}
}
}
Re.message.id is never read — no Set, no guard, nothing equivalent to the documented dedup.
The 7d/30d ranges rescan the files through that same function, so this is not a stale-cache artifact:
async function Bi(i, c) {
if (i === "all") return Iy(c); // cumulative stats-cache path
let { files: m, mtimeMsByFile: D } = await Om(c);
let E = new Date(), T = i === "7d" ? 7 : 30;
let x = new Date(E);
x.setDate(E.getDate() - T + 1);
let R = await ks(m, { fromDate: wo(x) }, c, D); // same scanner, no dedup
return _y(R);
}
The correct accumulator already exists — cost-state, called once per API response:
function MZr(e, n, r) { // (costUSD, usage, model)
let o = ELn(r) ?? { inputTokens: 0, outputTokens: 0, /* ... */ };
return o.inputTokens += n.input_tokens,
o.outputTokens += n.output_tokens,
o.thinkingTokens = (o.thinkingTokens ?? 0) + (n.output_tokens_details?.thinking_tokens ?? 0),
o.cacheReadInputTokens += n.cache_read_input_tokens ?? 0,
o.cacheCreationInputTokens += n.cache_creation_input_tokens ?? 0,
o.costUSD += e,
/* ... */ o;
}
It writes the cost-state record's modelUsage — the CLI's analogue of the SDK result message's modelUsage, which the docs point to as authoritative. It lands in the same transcript file the Stats tab reads, and the Stats tab ignores it.
Supporting measurements
cost-state vs. both summations, per session
Only sessions whose entire transcript lies inside a single cost-state run (startTime unique, first record ≥ startTime), with each session's subagents/agent-*.jsonl included since cost-state covers subagent calls too:
sub rows dedup cost-state dedup/truth
0 11,601,839 2,897,389 2,897,389 1.000x
0 10,778,979 1,989,666 1,989,666 1.000x
0 12,221,314 2,324,120 2,324,120 1.000x
0 13,362,186 2,237,106 2,237,106 1.000x
7 203,967,142 72,576,243 86,580,760 0.838x
0 9,673,344 1,984,224 1,984,224 1.000x
0 24,453,963 11,644,952 13,318,243 0.874x
0 21,109,391 10,673,649 12,110,235 0.881x
0 4,406,003 2,560,611 2,647,980 0.967x
0 150,039,126 62,701,339 66,478,011 0.943x
0 117,566,151 64,167,780 67,801,253 0.946x
0 2,642,596 1,381,637 1,519,099 0.910x
0 133,430,467 57,934,454 107,710,942 0.538x
0 36,807,432 13,428,659 15,432,187 0.870x
TOTAL rows/cost-state = 1.95x dedup/cost-state = 0.80x
OUTPUT rows/cost-state = 2.51x dedup/cost-state = 0.77x
Six sessions land on cost-state to the token once deduplicated, while row-summing runs 4.0x–6.0x high on those same sessions.
The rows below 1.000x are dedup under-counting, for a separate and expected reason: cost-state also covers API calls never written to any transcript (auto-titler, compaction summarizer, quota probes, retries). Transcript-derived stats structurally can't see those. Much smaller than the 2x, and an argument for fix option 2.
The persisted cache matches a non-deduplicated replica on every intact day
Diffing my scanner reimplementation against ~/.claude/stats-cache.json:
date model replica stats-cache verdict
2026-08-15 claude-opus-5 550,634,995 550,634,995 EXACT
2026-08-18 claude-fable-5 163,604,740 163,604,740 EXACT
2026-08-19 claude-opus-4-8 34,760,251 34,760,251 EXACT
2026-08-20 claude-fable-5 293,964,168 293,964,168 EXACT
2026-08-24 claude-fable-5 770,637,214 770,637,214 EXACT
2026-08-28 claude-opus-5 150,039,126 150,039,126 EXACT
2026-09-01 claude-opus-5 174,377,234 174,377,234 EXACT
2026-09-02 claude-fable-5-1 187,366,448 187,366,448 EXACT
...
exact matches: 47 mismatches: 25 (all days whose transcripts have since been deleted by retention)
47/47 exact on every day whose transcripts still exist.
One response, four rows
{"type":"assistant","uuid":"cef9c465…","message":{"id":"msg_011CeALkiJn3uTavAHtzQYKr","model":"claude-fable-5","content":"[1 block: thinking]","usage":{"input_tokens":2,"output_tokens":10611,"cache_creation_input_tokens":21999,"cache_read_input_tokens":24363}}}
{"type":"assistant","uuid":"2a218497…","message":{"id":"msg_011CeALkiJn3uTavAHtzQYKr","model":"claude-fable-5","content":"[1 block: text]","usage":{"input_tokens":2,"output_tokens":10611,"cache_creation_input_tokens":21999,"cache_read_input_tokens":24363}}}
{"type":"assistant","uuid":"2dcdbfb0…","message":{"id":"msg_011CeALkiJn3uTavAHtzQYKr","model":"claude-fable-5","content":"[1 block: tool_use]","usage":{"input_tokens":2,"output_tokens":10611,"cache_creation_input_tokens":21999,"cache_read_input_tokens":24363}}}
{"type":"assistant","uuid":"a9f46e3b…","message":{"id":"msg_011CeALkiJn3uTavAHtzQYKr","model":"claude-fable-5","content":"[1 block: tool_use]","usage":{"input_tokens":2,"output_tokens":10611,"cache_creation_input_tokens":21999,"cache_read_input_tokens":24363}}}
Real cost 56,975 tokens; the Stats tab adds up 227,900.
Suggested fix
1. Apply the documented dedup. A per-file Set is enough and keeps memory bounded:
const seenMessageIds = new Set(); // per transcript file
for (let Re of ze) {
/* ... */
if (Re.type === "assistant" && Re.message?.usage) {
const mid = Re.message.id;
if (mid) {
if (seenMessageIds.has(mid)) continue; // same API response, another content block
seenMessageIds.add(mid);
}
/* ...existing accumulation... */
}
}
Pair it with a dailyModelTokensVersion bump so recorded history is rebuilt rather than merged forward — the rebuild path already exists and is gated on that version. Note modelUsage and totalMessages are merged by Pl() and are not covered by that rebuild; they need a reset or their own version gate, or the Overview total stays wrong even after dailyModelTokens is rebuilt.
2. Better — read cost-state where it exists. The docs already tell SDK consumers to prefer the result message's modelUsage over per-step summation:
The deduplicated per-step values are accurate for input and cache tokens. Per-step output_tokens is a placeholder, so read output tokens from the result message.
The CLI's equivalent of that result message is the cost-state record, already in the transcript. Using it fixes the 2x and closes the ~20% gap from calls that never reach the transcript. A hybrid — cost-state where present, deduplicated summation for older transcripts written before it — would cover the whole history.
An open question worth confirming
In my measurements the deduplicated per-step output_tokens matched cost-state exactly in the six clean sessions above, which suggests the persisted transcript carries final output counts rather than the message_start placeholder the docs describe for the live SDK stream. There is a backfill in the normalizer that looks related:
if (p.type === "assistant" && I.normalized[0]?.type === "assistant" &&
I.normalized[0].message.stop_reason !== p.message.stop_reason) {
for (let F of I.normalized)
if (F.type === "assistant")
F.message.stop_reason = p.message.stop_reason,
F.message.stop_details = p.message.stop_details,
F.message.usage = p.message.usage; // final usage backfilled
}
I'd rather have that confirmed than assume it. If on-disk output_tokens really is authoritative, fix 1 is sufficient for output totals; if it isn't, fix 2 is required — and either way the docs' placeholder warning could usefully say which surface it applies to.
Secondary observation
The All time range reads the cumulative stats-cache.json, which retains days whose transcripts have since been deleted by retention, while 7d/30d rescan the files live. On this machine that's 48,838,567,442 all-time tokens across 153 days of dailyActivity versus 11,657,599,469 across the 63 days of transcripts still on disk.
Defensible as a design choice, but all-time and the ranged views then come from different sources and can't be reconciled by a user — and it's why the version bump in fix 1 matters: without it, today's inflated all-time figure is permanent.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Independent reproduction, Claude Code 2.1.260, macOS, Max subscriber.
I can confirm the mechanism and the magnitude on my own machine, and add one measurement that does not depend on the Stats tab at all.
1. Stats tab vs. per-request count, same files.
/usage→ Stats → Overview, screenshots taken 2026-09-06 11:53–11:56 CEST, against~/.claude/projects/**/*.jsonlcounted (a) once per line withusage, (b) once perrequestId(fallbackmessage.id) keeping the record with the largest total:| window | Stats tab shows | per line | per request |
|---|---|---|---|
| Last 7 days | 5.80B | 5.80B | 2.92B |
| Last 30 days | 18.4B | 20.3B | 11.0B |
| All time | 53.2B | 55.4B | 29.9B |
7 days matches the per-line sum exactly; the longer windows sit slightly below it because
dailyModelTokensinstats-cache.jsonis merged forward and not recomputed. Per field, 7 days (shown / per request): input 3.9M / 1.1M (×3.5), cache write 217.6M / 90.6M (×2.4), cache read 5.60B / 2.83B (×2.0), output 12.3M / 6.8M (×1.8). Over 65 fully covered days (June–August) the per-line/per-request ratio is 1.85.2. Example of the duplicated rows.
req_011Ccuez6RjiGqVoj1v4CJcj/msg_011Ccuez9Mqtj1ZiWESjXQhZ, four assistant lines (blocks: text, tool_use, tool_use, tool_use) at 04:41:07, 04:41:31, 04:41:48, 04:42:08Z, each withinput_tokens: 131, cache_read_input_tokens: 497971, output_tokens: 4810. Across 11 July alone: 16,773 usage lines for 8,139 requests (1 line: 1,729 requests; 2 lines: 4,609; 3: 1,483; 4+: 318).3. Claude Code's own result message agrees with the per-request count, not with the Stats tab.
claude -p "<read three files, summarise>" --output-format json --max-turns 8 --allowedTools Read --model sonnet(sessionffe680fe-e4b1-4c95-be78-19ef2373a936). Resultusage: input 8, output 1,139, cache_creation 59,753, cache_read 160,497. The session's transcript counted once per request: identical in all four fields. Counted once per line: 10 / 1,250 / 111,577 / 160,497 (total 273,334 vs 221,397). So the product already contains the correct count; the Stats scanner just doesn't use the documented rule (cost-tracking: "Track which IDs you've already counted and skip duplicates to avoid inflated totals").Note for anyone relying on this screen: the per-field inflation is uneven (input worst, output least, since output grows across the rows of one response and only the last row is complete), so model shares and input/output ratios shift as well, not just totals.
Happy to share the counting script. Fix on the reader side is a
Setkeyed onmessage.id(orrequestId), keeping the max record per key; the historicaldailyModelTokenswould need a one-time recompute.Third independent reproduction, Claude Code 2.1.270, Windows 11, Max subscription — the
first Windows data point on this issue, and it confirms both the mechanism and the
stats-cache.jsonside.
Counting
~/.claude/projects/**/*.jsonl(includingsubagents/) two ways, once per line withusageand once per(message.id, requestId):| field | per line | per request | factor |
|---|---:|---:|---:|
| input | 99,238 | 54,302 | 1.83x |
| output | 8,992,890 | 3,048,300 | 2.95x |
| cache write | 61,211,244 | 28,000,695 | 2.19x |
| cache read | 3,254,112,518 | 1,938,816,526 | 1.68x |
| total | 3,324,415,890 | 1,969,919,823 | 1.69x |
18,474 usage lines for 10,297 requests — 44.3% of lines are repeats.
message.idandrequestIdare present on every line here (0 missing of 18,474), so the two-part key needs no fallback.
dailyModelTokensequals the per-line sum exactly, per day and per model. All 15 day/model cellswhere transcripts and
stats-cache.jsonoverlap:Digit for digit, not approximately — so the cache is not merely stale here, it is storing the
per-line sum.
modelUsagesummed over all five models gives 13,049,215,353, the same figure as theper-line sum over the same scope, which matches your point that fixing
dailyModelTokensaloneleaves the Overview total wrong.
lastComputedDateis2026-09-12while today is2026-09-13,so the lazy recompute also lags by a day.
Per-model inflation is uneven in the same direction you described:
cache/dedupranges from1.53x (opus, 09-09) to 3.10x (sonnet, 09-09), and haiku lands on exactly 2.00x on two separate days.
A user cannot correct the Models tab with one divide.
On your open question about
output_tokens. Of the request groups on this machine, 7,838 havebyte-identical
usageon every line, but 2,463 do not — earlier lines of a group carry smalleroutput counts than the last. So the persisted transcript does not always end up fully backfilled,
and a fix that keeps the first line per key would under-count output. Keeping the record with the
largest token total per key (as suggested above) is the safe rule, and it is what
ccusagedoes.Worst case here: one response written as 10 lines, all identical,
input 32 · output 1,459 ·— 2,525,240 tokens charged to Stats for 252,584 real ones.cache_creation 1,967 · cache_read 249,126
Group sizes: 1→4,273 · 2→4,256 · 3→1,505 · 4→204 · 5→35 · 6→18 · 7→2 · 8→6 · 10→2.
Read-only script that produces the above, if useful for other platforms — Node ≥18, no dependencies,
prints aggregates only (no paths, ids or content):
<details>
<summary>check-usage-duplicates.mjs</summary>
</details>
One consequence of the
dailyModelTokensVersionbump suggested in the issue, from the 2.1.273 bundle, and a way to repair days the retention sweep has already pruned.The rebuild drops pruned days instead of correcting them. On the all-time path, 2.1.273 rebuilds
dailyModelTokenswhenever the cache'sdailyModelTokensVersionis below the current one (ir = 5). Minified, with my comments:Any day whose transcripts are gone drops out of the saved series, while
modelUsagekeeps its tokens, so the lifetime totals and the daily series stop matching. With the defaultcleanupPeriodDays, that means every day older than about a month. A cache last written by an older build already takes this path the first time a current CLI computes all-time stats: a version 4 file has nodailyModelTokensVersion. #94479 asks whether a rebuild keeps pruned history, and on this path it doesn't. A rebuild that recounted only the days that still have transcripts, and kept the stored rows for the rest, would keep it. Until then, a copy of~/.claude/stats-cache.jsontaken before upgrading is the only record of those days.Repairing the days whose transcripts are gone. I wrote a read-only scanner, token-usage-graph (standard-library Python), that uses the cache for those days instead of dropping them. For each UTC day and model it compares
dailyModelTokenswith the per-line sum of the transcripts that survive, and treats the remainder as pruned. It then divides that remainder by the model's ratio of per-line to per-request tokens, measured on the surviving transcripts. A model with no transcripts left gets an assumed ratio, and the output names it. Repaired rows are labelled as estimates. As the comment above shows, the ratio moves a lot between models and days, so a single repaired day is rough.On my machine the measured ratio runs from 2.0x (Opus 4.8) to 3.15x (Fable 5.1). About a fifth of the requests written as more than one line have lines whose
output_tokensdiffer, so dividing by each request's first line rather than its largest overstates the ratio by up to 1% (Sonnet 5).To reproduce, from a clone with nothing installed:
token-usage-graph/census-summary.jsonhas the measured ratios (claude_line_duplication_factor_measured), how many cache days were repaired (claude_stats_caches), andundated_lifetime_tokens: tokens inmodelUsagethat the daily series no longer places on a date. It holds counts only, and nothing is sent anywhere.