Skip to content

transcript adapter: reasoning_output_tokens always zero — extended thinking models invisible #4

Description

@fjgbue

⚠️ This was automatically generated by Javi OpenClaw BugFinder Bot. Not reviewed by a human. Verify independently. Author: 12122J. Co-author: fjbgu OpenClaw Assistant.

Severity: Medium

File & Line

src/adapters/transcript.mjsextractFromTranscript() function, return statement (approx. line 120)

Root Cause

The usage object returned by extractFromTranscript() hardcodes reasoning_output_tokens to 0:

usage: hasUsage ? {
  input_tokens: rawUsage.input_tokens,
  cache_creation_tokens: rawUsage.cache_creation_input_tokens,
  cache_read_tokens: rawUsage.cache_read_input_tokens,
  output_tokens: rawUsage.output_tokens,
  reasoning_output_tokens: 0,  // <-- ALWAYS ZERO, never extracted
  total_tokens: total
} : null,

The function parses Claude Code transcript JSON lines and aggregates input_tokens, cache_creation_input_tokens, cache_read_input_tokens, and output_tokens from assistant messages — but never reads reasoning_output_tokens (also known as thinking_tokens or reasoning_tokens in the API response).

Claude Code with extended thinking models (Opus 4 Thinking, etc.) DOES include this field in the transcript. For example, an assistant message from a thinking model contains:

{
  "type": "assistant",
  "message": {
    "usage": {
      "input_tokens": 1234,
      "cache_creation_input_tokens": 0,
      "cache_read_input_tokens": 500,
      "output_tokens": 800,
      "reasoning_output_tokens": 12000
    }
  }
}

The 12,000 reasoning tokens are silently dropped.

Impact

  • Hook-recorded sessions (automatic recording via Claude Code Stop hook) show zero reasoning tokens even when the session used an extended thinking model.
  • Cost estimates are wrong — reasoning tokens are billed (though often at a lower rate), making the recorded cost lower than actual.
  • Dashboard and summaries show incomplete token breakdowns for thinking-enabled sessions.

Suggested Fix

In the assistant message processing loop, extract reasoning_output_tokens from the usage block:

if (entry.type === "assistant") {
  const msg = entry.message ?? {};
  const u = msg.usage ?? {};

  if (msg.model && !model && !msg.model.startsWith("<")) model = msg.model;

  if (u.input_tokens != null || u.output_tokens != null) {
    hasUsage = true;
    rawUsage.input_tokens += u.input_tokens ?? 0;
    rawUsage.cache_creation_input_tokens += u.cache_creation_input_tokens ?? 0;
    rawUsage.cache_read_input_tokens += u.cache_read_input_tokens ?? 0;
    rawUsage.output_tokens += u.output_tokens ?? 0;
    // FIX: capture reasoning tokens
    rawUsage.reasoning_output_tokens += u.reasoning_output_tokens ?? 0;
    turns.push({
      input: u.input_tokens ?? 0,
      cacheWrite: u.cache_creation_input_tokens ?? 0,
      cacheRead: u.cache_read_input_tokens ?? 0,
      output: u.output_tokens ?? 0,
      reasoning: u.reasoning_output_tokens ?? 0,  // add to turns too
    });
  }
  // ...
}

Then in the return statement, use the tracked value instead of the hardcoded 0:

usage: hasUsage ? {
  input_tokens: rawUsage.input_tokens,
  cache_creation_tokens: rawUsage.cache_creation_input_tokens,
  cache_read_tokens: rawUsage.cache_read_input_tokens,
  output_tokens: rawUsage.output_tokens,
  reasoning_output_tokens: rawUsage.reasoning_output_tokens,
  total_tokens: total
} : null,

The total_tokens computation on the line above may also need updating to include reasoning tokens (currently it is input_tokens + cache_creation_input_tokens + output_tokens), depending on whether reasoning tokens are meant to be counted as new work tokens.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions