[BUG] Local /cost doubles usage when parsing session JSONL (cache write & I/O tokens; ~2× total)
Environment
- Platform (select one):
- [ ] Anthropic API
- [ ] AWS Bedrock
- [ ] Google Vertex AI
- [x] Other: Custom local CLI (with a
/costcommand) calling Anthropic models - Claude CLI version: 1.0.77
- Operating System: macOS
- Terminal: Warp.app
Bug Description
As issue #5902
<img width="750" height="344" alt="Image" src="https://github.com/user-attachments/assets/9dcdbd53-ba90-435a-8b1f-91ff932f5ccb" />
and https://x.com/badlogicgames/status/1957221028603535617 on X
The local /cost command double-counts usage:
cache writeis reported at \~2× the actualcache_creation_input_tokens.inputandoutputare also inflated (input grows by 2× per call; output shows +1 token per response vs. serverusage.output_tokens).
This makes the total cost appear roughly double the true amount.
Steps to Reproduce
- Make several short requests to Claude Sonnet 4 with prompt caching enabled (ephemeral 5-minute cache), e.g., send “hi” three times.
- After each response, run the local
/costcommand. - Compare
/costoutput vs. the rawusageobjects persisted in session JSONL logs.
Expected Behavior
cache writeshould equal eitherusage.cache_creation_input_tokensor the sum of typed fields (e.g.,usage.cache_creation.ephemeral_5m_input_tokens+..._1h_...), but not both.inputshould match the sum ofusage.input_tokensacross final messages.outputshould match the sum ofusage.output_tokens(no spurious +1 per message).
Actual Behavior
cache writeis \~2×: e.g., 15,079 becomes \~30.2k in/cost.inputincrements by 6 per call even thoughusage.input_tokensis 3 each time.outputincrements by 13 per call even thoughusage.output_tokensis 12.
Total shown by /cost is about 2× the true cost (e.g., $0.349 vs. actual ≈ $0.176 for the 3 calls).
Additional Context
Where local records come from:
Use the session JSONL logs stored under your project directory, e.g.:~/.claude/project/<YOUR_PROJECT>/<SESSION_ID>/*.jsonl
(Example session id from our logs: 0205ebf6-1fa8-4ac7-9716-27dd911a7e9d.)
Local token test code (Python, JSONL-only, local-sum view)
Reads one JSON object per line from the session .jsonl files and sums every snapshot as-is (no dedup), so you can compare the CLI’s /cost with raw local totals.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
JSONL local usage summarizer (no dedup).
- Input: one or more .jsonl files (or stdin with '-')
- Output: per-model totals and a grand total
"""
import argparse
import json
import glob
import re
import sys
from collections import defaultdict
from typing import Dict, Any, Iterable, Tuple
USAGE_KEYS = (
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
)
MODEL_SUFFIX_RE = re.compile(r"^(.*?)-(\d{8})$")
def normalize_model(model: str) -> str:
if not model:
return "(unknown-model)"
m = MODEL_SUFFIX_RE.match(model)
return m.group(1) if m else model
def coerce_usage(u: Dict[str, Any]) -> Dict[str, int]:
out = {}
for k in USAGE_KEYS:
v = u.get(k, 0)
try:
out[k] = int(v)
except Exception:
try:
out[k] = int(float(v))
except Exception:
out[k] = 0
return out
def extract_usage(obj: Dict[str, Any]) -> Iterable[Tuple[str, Dict[str, int]]]:
"""
Yields (model, usage_dict) for common shapes:
A) { "message": { "model": "...", "usage": {...} } }
B) { "model": "...", "usage": {...} }
"""
msg = obj.get("message")
if isinstance(msg, dict):
model = normalize_model(msg.get("model") or obj.get("model") or "")
usage = msg.get("usage") or obj.get("usage")
if isinstance(usage, dict) and model:
yield (model, coerce_usage(usage))
return
model = normalize_model(obj.get("model") or "")
usage = obj.get("usage")
if isinstance(usage, dict) and model:
yield (model, coerce_usage(usage))
def iter_jsonl(paths: Iterable[str]) -> Iterable[Dict[str, Any]]:
"""
Reads JSON objects from .jsonl files or stdin ('-').
Skips malformed lines.
"""
expanded = []
for p in (paths or []):
if p == "-":
expanded.append("-")
else:
hits = glob.glob(p)
expanded.extend(hits if hits else [p])
if not expanded:
expanded = ["-"]
def lines_from(path):
if path == "-":
for line in sys.stdin:
yield line
else:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
yield line
for path in expanded:
for line in lines_from(path):
s = line.strip()
if not s:
continue
try:
obj = json.loads(s)
except Exception:
continue
else:
yield obj
def main():
ap = argparse.ArgumentParser(description="Sum usage from session JSONL logs (no dedup).")
ap.add_argument("paths", nargs="*", help="JSONL paths/globs; use '-' or none for stdin")
args = ap.parse_args()
totals = defaultdict(lambda: {"input":0, "output":0, "cache_read":0, "cache_write":0})
count = 0
for obj in iter_jsonl(args.paths):
for model, usage in extract_usage(obj):
t = totals[model]
t["input"] += usage.get("input_tokens", 0)
t["output"] += usage.get("output_tokens", 0)
t["cache_read"] += usage.get("cache_read_input_tokens", 0)
t["cache_write"]+= usage.get("cache_creation_input_tokens", 0)
count += 1
if not totals:
print("No usage records found.")
return
print("\nUsage by model (LOCAL sum of all snapshots):")
for model in sorted(totals.keys()):
t = totals[model]
print(f" {model:20s} {t['input']:8d} in, {t['output']:6d} out, "
f"{t['cache_read']:8d} read, {t['cache_write']:8d} write")
g = {"input":0, "output":0, "cache_read":0, "cache_write":0}
for t in totals.values():
g["input"] += t["input"]
g["output"] += t["output"]
g["cache_read"] += t["cache_read"]
g["cache_write"]+= t["cache_write"]
print("\nGrand total (LOCAL):")
print(f" {g['input']:8d} in, {g['output']:6d} out, "
f"{g['cache_read']:8d} read, {g['cache_write']:8d} write")
print(f"\nSnapshots processed: {count}")
if __name__ == "__main__":
main()
How to run against session logs
# Example: first move the script to ~/.claude/project/<YOUR_PROJECT>
cd ~/.claude/project/<YOUR_PROJECT>
python local_jsonl_sum.py 0205ebf6-1fa8-4ac7-9716-27dd911a7e9d.jsonl
This makes it explicit that the “local” numbers come directly from the JSONL session records under your project directory.
This issue has 11 comments on GitHub. Read the full discussion on GitHub ↗