Skip to content

[Bug]: toNormalizedUsage() discards accumulated input/cache tokens — cost tracking underreports ~80% of actual billed usage #53734

Description

@Ponera-Syl

Summary

toNormalizedUsage() in src/agents/pi-embedded-runner/run.ts (line ~192) intentionally returns only the last API call's input, cacheRead, and cacheWrite tokens instead of the accumulated values from the UsageAccumulator. While output tokens are correctly accumulated, input/cache tokens are taken from lastInput/lastCacheRead/lastCacheWrite. This causes session transcripts and cost tracking to miss ~80% of actual billed tokens in tool-heavy workflows.

This was introduced to fix the context-size display issue (#13698), but it inadvertently broke cost/accounting accuracy.

Impact

On a real deployment (572 sessions, ~7,900 assistant messages, ~7,300 tool calls over March 2026):

Metric Anthropic Console (actual) Transcript (logged) Captured
Input tokens 3,106,387,899 413,926,649 13%
Output tokens 12,523,367 1,858,613 15%
Cost $2,148.66 $430.25 20%

Verified independently by Claude Code and OpenAI Codex analyzing the source code — both confirmed no alternative persistence path exists for the full accumulated usage.

Root Cause

In run.ts:

const toNormalizedUsage = (usage: UsageAccumulator) => {
  const lastPromptTokens = usage.lastInput + usage.lastCacheRead + usage.lastCacheWrite;
  return {
    input:      usage.lastInput || undefined,       // ❌ last call only
    output:     usage.output || undefined,           // ✅ accumulated
    cacheRead:  usage.lastCacheRead || undefined,    // ❌ last call only
    cacheWrite: usage.lastCacheWrite || undefined,   // ❌ last call only
    total: lastPromptTokens + usage.output || undefined,
  };
};

The UsageAccumulator correctly accumulates all values via mergeUsageIntoAccumulator() (including input, cacheRead, cacheWrite), but toNormalizedUsage() discards the accumulated prompt-side fields. For a turn with N tool-call round-trips, this loses (N-1)/N of input and cache tokens.

Downstream consumers all read this lossy object:

  • Session store (session-store.ts:47) → inputTokens, cacheRead, cacheWrite, estimatedCostUsd
  • Transcript JSONL entries → usage block per assistant message
  • Gateway transcript fallback (session-utils.fs.ts)
  • Diagnostics emitter (agent-runner.ts:585-619)
  • Cost calculation (session-cost-usage.ts)

No alternative persistence path captures the full accumulated totals.

Steps to Reproduce

  1. Configure OpenClaw with Anthropic provider (prompt caching enabled by default)
  2. Start a session with tool-using agent (e.g., exec tool)
  3. Send a message that triggers multiple tool calls in one turn:
    Run these 3 commands: echo "hello", echo "world", echo "done"
    
  4. Check the transcript JSONL for the assistant message's usage.cacheRead
  5. Compare with Anthropic Console usage for the same time window

Expected: usage.cacheRead reflects sum of all API calls in the turn (≈ context_size × number_of_calls)
Actual: usage.cacheRead reflects only the last API call

Suggested Fix

The types already have the separation needed (EmbeddedPiAgentMeta.lastCallUsage exists for context-size display). The fix is:

  1. Make toNormalizedUsage() return accumulated values for all fields (restore usage.input, usage.cacheRead, usage.cacheWrite from the accumulator)
  2. Continue using lastCallUsage / promptTokens for context-size display (already wired correctly via deriveSessionTotalTokens)
  3. Cost/accounting consumers already use agentMeta.usage — they will automatically get correct values

This separates the two conflated concepts:

  • Context-size display: needs last call's prompt tokens → lastCallUsage / promptTokens
  • Cost tracking: needs accumulated billed tokens → agentMeta.usage

Related Issues

Environment

  • OpenClaw 2026.3.23-2
  • Anthropic provider with prompt caching
  • Heavy tool-use workflows (avg ~1 tool call per assistant message)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions