[BUG] Local /cost doubles usage when parsing session JSONL (cache write & I/O tokens; ~2× total)

Resolved 💬 11 comments Opened Aug 16, 2025 by Cur150 Closed Aug 28, 2025

Environment

  • Platform (select one):
  • [ ] Anthropic API
  • [ ] AWS Bedrock
  • [ ] Google Vertex AI
  • [x] Other: Custom local CLI (with a /cost command) 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 write is reported at \~2× the actual cache_creation_input_tokens.
  • input and output are also inflated (input grows by 2× per call; output shows +1 token per response vs. server usage.output_tokens).

This makes the total cost appear roughly double the true amount.

Steps to Reproduce

  1. Make several short requests to Claude Sonnet 4 with prompt caching enabled (ephemeral 5-minute cache), e.g., send “hi” three times.
  2. After each response, run the local /cost command.
  3. Compare /cost output vs. the raw usage objects persisted in session JSONL logs.

Expected Behavior

  • cache write should equal either usage.cache_creation_input_tokens or the sum of typed fields (e.g., usage.cache_creation.ephemeral_5m_input_tokens + ..._1h_...), but not both.
  • input should match the sum of usage.input_tokens across final messages.
  • output should match the sum of usage.output_tokens (no spurious +1 per message).

Actual Behavior

  • cache write is \~2×: e.g., 15,079 becomes \~30.2k in /cost.
  • input increments by 6 per call even though usage.input_tokens is 3 each time.
  • output increments by 13 per call even though usage.output_tokens is 12.

Total shown by /cost is about 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.

View original on GitHub ↗

This issue has 11 comments on GitHub. Read the full discussion on GitHub ↗