Brahim Bousnguar

Notes · Article

Counting Claude Code tokens from the logs? You're probably 2.5× too high

By · · 3 min read

Listen · 3:35 · read by an AI voice

Listen to this article0:00 / 3:35

Read by an AI voice · MP3

If you add up token usage from Claude Code's session logs line by line, your total is wrong. On my machine it was 2.5× too high, and output tokens were 4.4× too high.

The logs aren't lying. Every line just repeats the usage of the whole reply it belongs to. Count replies, not lines: key by the message id.

I hit this building Meterlex, a tool that prices my AI coding usage at API rates. Here's what's going on and the fix.

One reply, many lines

Claude Code keeps transcripts in ~/.claude/projects/**/*.jsonl. A single assistant reply isn't one line. Each part of it is logged on its own line: the thinking, the text, each tool call.

And each of those lines carries the same usage block, for the whole reply. Simplified, with made-up numbers, one reply with a text part and a tool call looks like this:

{"type": "assistant", "uuid": "a1…", "message": {"id": "msg_01X…", "usage": {"input_tokens": 12, "output_tokens": 480}}}
{"type": "assistant", "uuid": "b2…", "message": {"id": "msg_01X…", "usage": {"input_tokens": 12, "output_tokens": 480}}}

Two lines, two different uuids, one message.id. Sum both and you've counted that reply twice. A reply with five tool calls gets counted six or seven times.

One reply

msg_01X…

  1. text
  2. tool call

The log, one line per part

  1. uuid a1…id msg_01X…in 12 · out 480
  2. uuid b2…id msg_01X…in 12 · out 480

Sum the linesout 960

Count the replyout 480

Five tool callsA reply with five tool calls gets counted six or seven times.

The note's example with its made-up numbers: two lines, two uuids, one message id, and the usage of the whole reply on both.

How big the error is

On my Mac's transcripts: 44,158 assistant lines, but only 18,701 actual replies.

Counting lines gave 2.5× the real tokens. Output tokens were 4.4× too high.

That matters if you're doing what Meterlex does: comparing a flat subscription with what the same usage would cost on the API. Overcount the usage and the subscription looks like a way better deal than it is.

My Mac's transcripts

Assistant lines

44,158

Actual replies

18,701

Counted by line, against the real total

All tokens

2.5×

Output tokens

4.4×

Why it mattersOvercount the usage and the subscription looks like a way better deal than it is.

Real counts from my Mac, each bar to scale. In the bottom pair, the cobalt part is the real total and the dashed part is what counting lines added.

The fix: key by message id

Use message.id as the key for a reply, and when you see it again, merge instead of adding. The one subtle bit: a reply's numbers can grow while it streams, so keep each count at its largest value, not the first or the sum.

This is the core of it, from the Meterlex collector:

def _merge_max(a, b):
    """Two records of the same reply: each token count at its largest
    (a reply's numbers only grow as it streams), the earliest time."""
    m = dict(a)
    for f in TOKEN_FIELDS:
        m[f] = max(a[f], b[f])
    m["ts"] = min(a["ts"], b["ts"])
    return m

key = (session_id, msg.get("id") or uuid)
replies[key] = _merge_max(replies[key], t) if key in replies else t

Lines without a message.id fall back to their uuid, so nothing gets dropped.

The key for a reply(session_id, message.id)

No message.id? The line's uuid, so nothing gets dropped.

One reply, three records, as it streams

  1. msg_01X…

  2. msg_01X…

  3. msg_01X…

  • Take the first

    too low
  • Add them up

    too high
  • Keep the largest

    right, with the earliest time
How the Meterlex collector merges records that share a key. A schematic: the bar lengths are illustrative.

Migrating the rows you already stored

If you've been storing per-line rows, re-sending the same transcript with the new key would just add a third copy. So each reply also carries its old per-line uuids as alt_keys, and the hub folds any stored row with one of those keys into the new one. Old data cleans itself up the next time a file is read.

Re-send with the new key only

Three copies

  • a1…stored, per line
  • b2…stored, per line
  • msg_01X…a third copy

The new row carries alt_keys

One row

  • a1…folded in
  • b2…folded in
  • msg_01X…alt_keys: a1…, b2…

On the next readOld data cleans itself up the next time a file is read.

The example reply after a re-send. Carrying the old keys is what lets the hub fold the copies into one row.

Gemini CLI does it too

Different tool, same trap. Gemini CLI appends a message again every time it updates it. One project's file had 56,651 lines for 908 messages. Same fix: key by message id.

Codex has a milder version: it can repeat a token_count event with an unchanged running total. Meterlex skips those.

One Gemini CLI project's file

Lines

56,651

Messages

908

  • Claude CodeOne line per part of a reply, each with the whole reply's usagekey by message id
  • Gemini CLIA message appended again every time it's updatedkey by message id
  • Codex, milderA token_count event repeated with an unchanged running totalskip the repeat
Different tools, same trap. The Gemini counts are from one project's file, with the bars to scale.

Try it

If you track AI usage from local logs, check your keys. Count distinct message ids against lines on one of your own transcripts. The gap is the overcount.

Lines

11

Distinct message ids

5

6extra lines. The gap is the overcount.

Where to lookClaude Code keeps transcripts in ~/.claude/projects/**/*.jsonl.

Try it on one of your own transcripts. This one is made up: 11 lines, 5 replies.

Meterlex does all of this for Claude Code, Codex, Gemini CLI, Antigravity, Ollama, Copilot CLI and OpenClaw, and it's MIT on GitHub. If your numbers look off in a different way, open an issue.

Plain text: claude-code-token-overcount.md · Atom feed · All notes

Contact

Say hello.

b.bousnguar@gmail.com

SAP Commerce Cloud, MuleSoft and Salesforce integration · Nantes · EN / FR