Skip to content

[Bug] calibrationActualInputTokens accumulates usage across stream events, producing values up to 58,043,860 where one request's input is expected #617

Description

@lijia838-source

Summary

The per-request token calibration records a value that can exceed the model's entire context
window by hundreds of times. In the worst observed snapshot the field holds 58,043,860
while the very same snapshot reports an actual usage of 86,650 tokens and a context limit
of 95,232 — i.e. roughly 670× the real figure.

The value is not merely cosmetic: it is emitted as part of a live budget snapshot with
"source": "calibrated" and drives the compaction decision ("state": "blocking"), and the
validator that guards it only checks that the number is finite and positive.

Environment

Item Value
Build v2026.923.0, commit 5a0efbc5106e7c80c3df8e20184c439ed3d0603b
Provider / model deepseek (protocol: openai), deepseek-flash
Context config maxContextTokens: 95232, warningRatio: 0.8, blockingRatio: 0.9

Observed evidence

runtime.log:771, an auto-compaction trigger — note the internal inconsistency: the snapshot
reports 86,650 tokens of real usage and, in the same object, a 58-million-token "actual input":

2026-09-23T19:43:39.258Z [gateway] [context:auto-compact] policy_trigger {
  "sessionId": "web-s_b412fb22-467c-4de7-ba2b-5408173ccdbc",
  "reason": "blocking_threshold",
  "snapshot": {
    "tokens": 86650,
    "estimateSource": "usage",
    "usageTokens": 86650,
    "localEstimateTokens": 57767,
    "calibrationActualInputTokens": 58043860,
    "calibrationEstimatedInputTokens": 56472,
    "totalContextTokens": 128000,
    "maxContextTokens": 95232,
    "state": "blocking",
    "ratio": 0.9098832325268817,
    "source": "calibrated"
  }
}

Distribution across 3,000 logged values:

Statistic Value
Minimum 3,726
Median 61,078
Maximum 58,043,860
Values above 1,000,000 38

Other observed outliers include 55,012,552 (repeated). Typical healthy rows look correct,
e.g. "tokens":80153,"usageTokens":80153,"localEstimateTokens":74574, "calibrationActualInputTokens":76465 — so the field is right most of the time and wrong
catastrophically in a minority of cases.

The same field also appears in the persisted session transcript
(.../always-on/worktrees/Users-Administrator-.pilotdeck/chats/web-s_fd5c62fa-....jsonl), so
the bad value is written to disk as well as logged.

Root cause

The value is a sum of three usage fields

// dist/src/context/budget/TokenAccountingRuntime.js:185
export function actualInputTokensFromUsage(usage) {
    if (!usage) return undefined;
    let total = 0;
    for (const tokens of [usage.inputTokens, usage.cacheReadTokens, usage.cacheWriteTokens]) {
        if (typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) {
            total += tokens;
        }
    }
    return total > 0 ? Math.ceil(total) : undefined;
}

That summation is correct for a single response's usage. The problem is the usage object it
is handed.

The usage object accumulates instead of being replaced

The assembler that produces the usage object merges every usage event it sees:

// dist/src/model/streaming/assembleModelMessage.js
// :8
    usage: {},
// :58
        case "usage":
            state.usage = mergeUsage(state.usage, event.usage);
// :81
        usage: hasUsage(state.usage) ? state.usage : undefined,

and mergeUsage adds every numeric field rather than taking the latest value:

// dist/src/agent/loop/AgentLoop.js:2736
function mergeUsage(first, second) {
    if (!second) {
        return first;
    }
    return {
        inputTokens: add(first.inputTokens, second.inputTokens),
        outputTokens: add(first.outputTokens, second.outputTokens),
        cacheReadTokens: add(first.cacheReadTokens, second.cacheReadTokens),
        cacheWriteTokens: add(first.cacheWriteTokens, second.cacheWriteTokens),
        totalTokens: add(first.totalTokens, second.totalTokens),
    };
}
// :2748
function add(first, second) {
    if (first === undefined && second === undefined) {
        return undefined;
    }
    return (first ?? 0) + (second ?? 0);
}

The OpenAI-compatible streaming path emits a usage event for every chunk that carries a
usage object rather than only once per response:

// dist/src/model/providers/openai/stream.js:41
const usage = normalizeOpenAIUsage(chunk.usage);
if (usage) {
    events.push({ type: "usage", usage, raw });
}

So for a response whose usage is reported on more than one chunk (or replayed across a
retry/continuation that reuses the same assembler state), the individual counts are
summed, and the total grows without relation to the real single-request input size. The
result is then read back as the "actual input tokens" for that request:

// dist/src/agent/loop/AgentLoop.js:621
this.recordTokenCalibration(calibrationRequest, assembled.usage, requestInputEstimate);

// :1813
recordTokenCalibration(request, usage, estimatedInputTokens) {
    const actualInputTokens = actualInputTokensFromUsage(usage);
    ...
    // :1817-1822 — stored per route; overwrite, not accumulated
    this.tokenCalibrationByRoute.set(route, { provider, model, actualInputTokens,
                                              estimatedInputTokens });

Because the entry is stored per route and overwritten, a single inflated value is then
reused for that provider/model route on subsequent turns, which is consistent with the
repeated occurrences of the identical value 55,012,552 and 58,043,860 in the logs.

Nothing rejects an implausible value

The only validation applied to a stored calibration is finiteness and positivity:

// dist/src/context/budget/TokenAccountingRuntime.js:198 (matchingCalibration)
if (!Number.isFinite(calibration.actualInputTokens) || calibration.actualInputTokens <= 0) {
    return undefined;
}
if (!Number.isFinite(calibration.estimatedInputTokens) || calibration.estimatedInputTokens <= 0) {
    return undefined;
}
return calibration;

A value of 58,043,860 passes this check. Paired with the accompanying
calibrationEstimatedInputTokens: 56,472, the implied actual/estimate ratio is about
1028×, and the snapshot is still published with "source": "calibrated".

What I could and could not establish

I verified: the field's computation, the accumulating merge, the emission of multiple usage
events per response, the per-route overwrite, the absence of any plausibility check, and the
real observed values.

I could not conclusively determine how much of the budget calculation is corrupted by the
bad value in the captured case. In the log line above, tokens, usageTokens and the
ratio are all internally consistent from the real usage (86,650 / 95,232 = 0.9099), which
means that particular decision was driven by the true usage while the inflated calibration was
carried alongside it. A distorted value could still poison estimates on turns where the
provider reports no usage and the calibration ratio is the only available scaling factor. The
report therefore treats the corrupted field as a confirmed defect whose downstream blast radius
is only partially characterised.

Impact

  • A corrupted per-request "ground truth" that is stored per route and reused, so the error
    persists for the lifetime of the route.
  • A budget snapshot published with source: "calibrated" that carries a value two to three
    orders of magnitude wrong.
  • The bad value is persisted into session transcripts on disk.
  • Any calibration-driven estimate that does use the ratio is off by a factor of roughly 10³,
    which would trip the blocking threshold immediately and force unnecessary compaction.

Severity: moderate (no crash observed from this path, but it corrupts the input to context
budgeting and is silently persisted).

Suggested fix

  1. Do not merge usage events by addition for a single response. Either treat the last
    usage event as authoritative (providers that split usage across chunks generally send the
    cumulative totals in the final chunk), or accumulate per stream and reset the assembler's
    usage when a response completes. assembleModelMessage.js:59 and the mergeUsage call
    sites in AgentLoop.js are the places to change; mergeUsage is also used at
    AgentLoop.js:614 to combine per-turn usage, so the fix must distinguish
    "merge across turns" (addition is correct) from "merge within one response" (addition is
    wrong).
  2. Sanity-check the calibration before storing it. Reject or discard a calibration whose
    actualInputTokens exceeds the model's context window, or whose implied ratio against
    estimatedInputTokens is implausible, and fall back to undefined (which
    matchingCalibration already handles as "no calibration").
  3. Consider not persisting the raw calibration values into session transcripts, or persisting
    them only after the sanity check, so a fluke is not written to disk indefinitely.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions