Skip to content

Do not double-count Anthropic streaming output tokens#558

Open
PratikDhanave wants to merge 1 commit into
microsoft:mainfrom
PratikDhanave:fix-anthropic-streaming-output-token-double-count
Open

Do not double-count Anthropic streaming output tokens#558
PratikDhanave wants to merge 1 commit into
microsoft:mainfrom
PratikDhanave:fix-anthropic-streaming-output-token-double-count

Conversation

@PratikDhanave

Copy link
Copy Markdown
Contributor

The streaming path accumulates usage by summing every event:

case anthropic.MessageStartEvent:
    ...
    usage.Add(toUsageDetails(event.Message.Usage))
case anthropic.MessageDeltaEvent:
    usage.Add(toUsageDetailsDelta(event.Usage))

But Anthropic splits streaming usage across those two events: message_start carries the final input/cache counts but only a small placeholder output count (typically output_tokens: 1), while message_delta carries the final cumulative output count. The SDK’s own accumulator reflects this — for message_delta it sets acc.Usage.OutputTokens = event.Usage.OutputTokens rather than adding.

Summing them double-counts the message_start placeholder, so every streamed response over-reports output tokens (and the derived total) by that amount. With message_start output_tokens: 1 and message_delta output_tokens: 5, the emitted usage is output=6, total=16 instead of output=5, total=15.

This is the same cumulative-vs-incremental trap as the merged Gemini streaming-usage fix (#509).

Fix

Overwrite the output-derived counts from message_delta instead of adding them:

delta := toUsageDetailsDelta(event.Usage)
usage.OutputTokenCount = delta.OutputTokenCount
usage.ReasoningTokenCount = delta.ReasoningTokenCount
usage.TotalTokenCount = usage.InputTokenCount + usage.OutputTokenCount

Input and cache counts still come from message_start. This is also correct if the stream ever sends multiple message_delta events (each carries the cumulative count).

Test

TestStreamingUsage_DoesNotDoubleCountOutputTokens streams message_start output_tokens: 1 then message_delta output_tokens: 5 and asserts the final usage is output=5, total=15. It fails on the old code (6/16) and passes with the fix.

@PratikDhanave
PratikDhanave requested a review from a team as a code owner July 19, 2026 12:21
Copilot AI review requested due to automatic review settings July 19, 2026 12:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses token-usage accounting in the Anthropic streaming path to avoid double-counting output tokens by treating message_delta usage as cumulative (authoritative) rather than incremental. It also introduces a separate change to functool to substitute a zero-value object when a typed tool handler returns a typed-nil pointer output.

Changes:

  • Fix Anthropic streaming usage accumulation by overwriting (not summing) output token counts from message_delta.
  • Add a regression test ensuring streamed Anthropic usage reports the final cumulative output token count.
  • Add functool support (and tests) for typed-nil pointer outputs by substituting a non-nil zero value for schema validation.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
provider/anthropicprovider/agent.go Overwrites output-derived usage from message_delta to avoid double-counting cumulative output tokens.
provider/anthropicprovider/agent_test.go Adds a streaming regression test asserting correct output/total token counts.
tool/functool/func.go Adds typed-nil pointer output substitution to avoid null failing output schema validation.
tool/functool/func_test.go Adds a test verifying typed-nil pointer output returns a non-nil zero-value pointer.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tool/functool/func.go Outdated
Comment on lines +60 to +64
// When Out is a pointer type, a handler may legitimately return a typed-nil
// pointer to mean "no result". A typed nil marshals to JSON null, which fails
// validation against the (non-nullable, object-rooted) output schema derived
// from the pointed-to type. Substitute the zero value of the element type in
// that case, mirroring the MCP go-sdk's HandlerFor behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the functool change was included here by accident (this branch was cut from my functool PR #557 rather than from main). I've rebased this PR to contain only the Anthropic streaming-usage fix; the functool typed-nil handling lives in #557 where it belongs.

Comment thread tool/functool/func.go Outdated
Comment on lines +68 to +70
if hasPointerOut {
elemZero = reflect.New(outType.Elem()).Interface().(Out)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real bug, thank you — a named pointer output type (type P *T) would panic because reflect.New(outType.Elem()) yields an unnamed *T. Fixed in #557 by converting to the output type before the assertion (reflect.New(outType.Elem()).Convert(outType).Interface().(Out)), with a named-pointer regression test. This PR no longer touches functool.

The streaming path accumulated usage by summing message_start and
message_delta with usage.Add. But Anthropic reports the final cumulative
output token count on message_delta, while message_start carries only a
small placeholder output count (typically 1). Summing them over-counted
output tokens (and the derived total) by the placeholder amount on every
streamed response.

Overwrite the output-derived counts from message_delta instead of adding
them, matching the SDK accumulator (which sets, not adds, OutputTokens on
message_delta) and mirroring the equivalent Gemini streaming-usage fix.
@PratikDhanave
PratikDhanave force-pushed the fix-anthropic-streaming-output-token-double-count branch from e21a9dd to 68f4fd8 Compare July 19, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants