Skip to content

fix: prevent double counting of Anthropic streaming cache tokens - #2236

Merged
nacx merged 18 commits into
envoyproxy:mainfrom
Aias00:worktree-fix-anthropic-token-double-count
Jul 11, 2026
Merged

fix: prevent double counting of Anthropic streaming cache tokens#2236
nacx merged 18 commits into
envoyproxy:mainfrom
Aias00:worktree-fix-anthropic-token-double-count

Conversation

@Aias00

@Aias00 Aias00 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Description

The message_delta event handler in anthropicStreamParser was using Add* methods for input tokens, cached tokens, and cache creation tokens. However, the Anthropic API reports cumulative (not incremental) token counts in message_delta, and message_start already sets these values correctly via ExtractTokenUsageFromExplicitCaching. Using Add on top of the already-set cumulative values caused cache tokens to be counted twice.

Bug Example

With input_tokens=9 and cache_read_input_tokens=1:

Step Action InputTokens CachedInputTokens
message_start SetInputTokens(10), SetCachedInputTokens(1) 10 ✅ 1 ✅
message_delta (before fix) AddInputTokens(1), AddCachedInputTokens(1) 11 ❌ 2 ❌
message_delta (after fix) Only SetOutputTokens(16) 10 ✅ 1 ✅

Root Cause

ExtractTokenUsageFromExplicitCaching computes InputTokens = input_tokens + cache_read + cache_creation. In message_start, this is correctly set via Set* methods. In message_delta, the same cumulative values were being added via Add* methods, causing double counting.

Fix

The message_delta handler now only updates output tokens using SetOutputTokens, since message_start typically reports output_tokens=0 and message_delta provides the final count. This matches the pattern already used in anthropic_anthropic.go's reflectStreamingEvent. The MessageDeltaUsage struct uses non-pointer int64 fields that default to 0 when absent from JSON, making it impossible to distinguish "not provided" from "actually zero" — so we cannot safely Set override from delta either, as it would erase correct message_start values with zeros.

Changes

  • internal/translator/anthropic_helper.go: Removed Add* calls for input/cache tokens from message_delta handler; replaced AddOutputTokens with SetOutputTokens; removed unused ExtractTokenUsageFromExplicitCaching call; added >= 0 guard for ThinkingTokens
  • internal/translator/anthropic_helper_test.go: Added TestAnthropicStreamParserTokenUsage_NoDoubleCounting with 6 test cases

Test Coverage

  • Cache tokens in both message_start and message_delta (core double-count scenario)
  • Cache creation tokens in both events
  • Both cache_read and cache_creation in both events
  • No cache tokens (baseline correctness)
  • Cache tokens only in message_start, not in message_delta
  • Cache tokens only in message_delta — verifies they are ignored (non-pointer int64 fields cannot distinguish absent from zero)

@Aias00
Aias00 requested a review from a team as a code owner June 16, 2026 10:20
Copilot AI review requested due to automatic review settings June 16, 2026 10:20
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Jun 16, 2026

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

Note

Copilot was unable to run its full agentic suite in this review.

This PR updates the Anthropic streaming token usage parsing to prevent double-counting cache-related tokens when both message_start and message_delta report usage, and adds a regression test to validate the behavior.

Changes:

  • Adjust message_delta handling to treat usage values as cumulative and avoid accumulating cache/input tokens twice.
  • Add a new table-driven test covering several cache token combinations across message_start and message_delta.
  • Introduce testify/assert usage in the new test.

Reviewed changes

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

File Description
internal/translator/anthropic_helper.go Changes message_delta token accounting to avoid double-counting and sets output tokens as cumulative.
internal/translator/anthropic_helper_test.go Adds a regression test ensuring cache tokens aren’t double-counted across streaming events.

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

Comment thread internal/translator/anthropic_helper.go Outdated
Comment thread internal/translator/anthropic_helper.go Outdated
Comment thread internal/translator/anthropic_helper_test.go
@codecov-commenter

codecov-commenter commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.89%. Comparing base (f5f83b2) to head (b41080e).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2236      +/-   ##
==========================================
+ Coverage   84.87%   84.89%   +0.01%     
==========================================
  Files         144      144              
  Lines       21317    21345      +28     
==========================================
+ Hits        18093    18121      +28     
  Misses       2138     2138              
  Partials     1086     1086              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Aias00
Aias00 force-pushed the worktree-fix-anthropic-token-double-count branch from e951b39 to 33f745d Compare June 16, 2026 10:27
Aias00 and others added 3 commits June 19, 2026 21:14
The message_delta event handler in anthropicStreamParser was using Add*
methods for input tokens, cached tokens, and cache creation tokens.
However, the Anthropic API reports cumulative (not incremental) token
counts in message_delta, and message_start already sets these values
correctly via ExtractTokenUsageFromExplicitCaching. Using Add on top of
the already-set cumulative values caused cache tokens to be counted
twice.

For example, with input_tokens=9 and cache_read_input_tokens=1:
- message_start correctly set InputTokens=10 (9+1), CachedInputTokens=1
- message_delta then AddInputTokens(1), AddCachedInputTokens(1)
  resulting in InputTokens=11, CachedInputTokens=2 (double counted)

Fix: Only update output tokens from message_delta using SetOutputTokens,
since message_start typically reports output_tokens=0 and message_delta
provides the final count. This matches the pattern already used in
anthropic_anthropic.go's reflectStreamingEvent.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: liuhy <liuhongyu@apache.org>
Signed-off-by: liuhy <liuhongyu@apache.org>
Signed-off-by: liuhy <liuhongyu@apache.org>
@Aias00
Aias00 force-pushed the worktree-fix-anthropic-token-double-count branch from c62a63c to f867645 Compare June 19, 2026 13:20
@dosubot dosubot Bot added size:S This PR changes 10-29 lines, ignoring generated files. size:M This PR changes 30-99 lines, ignoring generated files. and removed size:M This PR changes 30-99 lines, ignoring generated files. size:S This PR changes 10-29 lines, ignoring generated files. labels Jun 19, 2026
The previous stream parser avoided double-counting by ignoring message_delta input and cache fields. That left delta-only or corrected cumulative usage undercounted. The parser now inspects raw JSON field presence so absent fields do not erase message_start values, while present message_delta fields can override and recompute derived input totals.

Constraint: Anthropic SDK MessageDeltaUsage uses non-pointer numeric fields, so normal unmarshalling cannot distinguish absent fields from explicit zero values

Rejected: Always set from MessageDeltaUsage | absent fields would overwrite message_start usage with zero defaults

Confidence: high

Scope-risk: narrow

Tested: go test ./internal/translator

Tested: make lint
Signed-off-by: liuhy <liuhongyu@apache.org>
@Aias00
Aias00 force-pushed the worktree-fix-anthropic-token-double-count branch from 00d6d0e to 291f8a8 Compare June 19, 2026 14:20
@Aias00

Aias00 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

close: #2279

Aias00 and others added 5 commits June 23, 2026 17:23
- Add detailed comments explaining message_delta token handling logic
- Clarify why Set (not Add) is used for cumulative token counts
- Document handling of delta-only cache tokens and corrected values
- Add test cases for message_delta with partial cache fields
- Add test for message_delta without usage field
- Improve coverage from 73.3% to 96.7% for updateInputUsageFromMessageDelta

Addresses PR review comments:
- Copilot comment on lines 1054-1064 about ignoring message_delta tokens
- Copilot comment on lines 833-846 about missing test coverage
- Codecov patch coverage requirement (now 87.4% > 80% target)

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: liuhy <liuhongyu@apache.org>
- Add test for message_delta with cache tokens when message_start already has cache
- Add test for invalid JSON in message_delta usage fields (error handling)
- Achieve 100% coverage for updateInputUsageFromMessageDelta function
- Improve overall coverage to 88.0%

Addresses Codecov patch coverage requirement (89.19% -> 100%)

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: liuhy <liuhongyu@apache.org>
@Aias00
Aias00 force-pushed the worktree-fix-anthropic-token-double-count branch from 06aea8e to 404019f Compare June 25, 2026 03:27
Run make precommit to fix comment alignment issue

Signed-off-by: liuhy <liuhongyu@apache.org>
@Aias00

Aias00 commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

/retest

Inference Extension conformance can create multiple HTTPRoutes for the same InferencePool in quick succession. A reconcile may observe only one referenced Gateway through the cache and write a partial Parents status, then receive no later event to recompute the full parent set.

Return a short requeue only when the InferencePool status actually changes. Status equality ignores LastTransitionTime so the follow-up reconcile converges and stops instead of looping forever.

Constraint: controller-runtime cache-backed route and gateway lists can lag related object events.

Rejected: Increasing conformance timeouts, because stale status can persist indefinitely without another reconcile.

Confidence: high

Scope-risk: moderate; changes InferencePool reconcile scheduling after status updates.

Tested: go test ./internal/controller -run TestInferencePoolController

Tested: go test ./internal/controller

Tested: go test ./internal/translator

Tested: make lint

Not-tested: full inference extension e2e locally.
Signed-off-by: liuhy <liuhongyu@apache.org>
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. size:M This PR changes 30-99 lines, ignoring generated files. and removed size:M This PR changes 30-99 lines, ignoring generated files. size:L This PR changes 100-499 lines, ignoring generated files. labels Jul 7, 2026
This reverts commit 6fcf39b.

Signed-off-by: liuhy <liuhongyu@apache.org>
@Aias00
Aias00 force-pushed the worktree-fix-anthropic-token-double-count branch from a99202d to 1b6ef2e Compare July 7, 2026 11:37
@Aias00

Aias00 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

/retest

@nacx nacx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall LGTM.
@aabchoo mind taking a look?

…resence

The message_delta handler set output and reasoning tokens from the SDK's
anthropic.MessageDeltaEvent.Usage fields guarded only by `>= 0`. Those
fields are non-pointer int64s that decode to 0 when usage (or a subfield)
is omitted, and 0 >= 0 is always true — so a later message_delta that
omits usage overwrote an already-set non-zero output/reasoning count with
zero. Anthropic's streaming protocol reports cumulative usage and a stream
may emit multiple message_delta events, so this case is real, not
theoretical.

Replace the no-op `>= 0` guards with the SDK's JSON presence fields
(u.JSON.OutputTokens.Valid() and u.OutputTokensDetails.JSON.ThinkingTokens.Valid(),
via respjson.Field.Valid()), matching the presence-based approach already
used for input/cache tokens in updateInputUsageFromMessageDelta. A
message_delta that omits these fields now leaves prior values untouched.

Also fix the misleading test TestAnthropicStreamParserTokenUsage_MessageDeltaNoUsage,
which encoded the bug as expected behavior (asserting output=0 when usage is
absent). Renamed to ...NoUsagePreservesPrior: it emits a message_delta with
output_tokens=16 and thinking_tokens=4, then a second message_delta without
usage, and asserts both counts survive.

Tested: go test ./internal/translator/... -run AnthropicStreamParserTokenUsage
Tested: go test ./internal/translator/...
Tested: go vet ./internal/translator/... && go build ./...

Signed-off-by: liuhy <liuhongyu@apache.org>
@nacx

nacx commented Jul 11, 2026

Copy link
Copy Markdown
Member

/retest

@nacx
nacx merged commit e6610d1 into envoyproxy:main Jul 11, 2026
55 of 57 checks passed
DanielChrn pushed a commit to DanielChrn/ai-gateway that referenced this pull request Jul 16, 2026
…oyproxy#2236)

**Description**

The `message_delta` event handler in `anthropicStreamParser` was using
`Add*` methods for input tokens, cached tokens, and cache creation
tokens. However, the Anthropic API reports cumulative (not incremental)
token counts in `message_delta`, and `message_start` already sets these
values correctly via `ExtractTokenUsageFromExplicitCaching`. Using `Add`
on top of the already-set cumulative values caused cache tokens to be
counted twice.

### Bug Example

With `input_tokens=9` and `cache_read_input_tokens=1`:

| Step | Action | InputTokens | CachedInputTokens |
|------|--------|-------------|-------------------|
| `message_start` | `SetInputTokens(10)`, `SetCachedInputTokens(1)` | 10
✅ | 1 ✅ |
| `message_delta` (before fix) | `AddInputTokens(1)`,
`AddCachedInputTokens(1)` | 11 ❌ | 2 ❌ |
| `message_delta` (after fix) | Only `SetOutputTokens(16)` | 10 ✅ | 1 ✅
|

### Root Cause

`ExtractTokenUsageFromExplicitCaching` computes `InputTokens =
input_tokens + cache_read + cache_creation`. In `message_start`, this is
correctly set via `Set*` methods. In `message_delta`, the same
cumulative values were being added via `Add*` methods, causing double
counting.

### Fix

The `message_delta` handler now only updates output tokens using
`SetOutputTokens`, since `message_start` typically reports
`output_tokens=0` and `message_delta` provides the final count. This
matches the pattern already used in `anthropic_anthropic.go`'s
`reflectStreamingEvent`. The `MessageDeltaUsage` struct uses non-pointer
`int64` fields that default to 0 when absent from JSON, making it
impossible to distinguish "not provided" from "actually zero" — so we
cannot safely `Set` override from delta either, as it would erase
correct `message_start` values with zeros.

### Changes

- `internal/translator/anthropic_helper.go`: Removed `Add*` calls for
input/cache tokens from `message_delta` handler; replaced
`AddOutputTokens` with `SetOutputTokens`; removed unused
`ExtractTokenUsageFromExplicitCaching` call; added `>= 0` guard for
`ThinkingTokens`
- `internal/translator/anthropic_helper_test.go`: Added
`TestAnthropicStreamParserTokenUsage_NoDoubleCounting` with 6 test cases

### Test Coverage

- Cache tokens in both `message_start` and `message_delta` (core
double-count scenario)
- Cache creation tokens in both events
- Both cache_read and cache_creation in both events
- No cache tokens (baseline correctness)
- Cache tokens only in `message_start`, not in `message_delta`
- Cache tokens only in `message_delta` — verifies they are ignored
(non-pointer int64 fields cannot distinguish absent from zero)

---------

Signed-off-by: liuhy <liuhongyu@apache.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ignasi Barrera <ignasi@tetrate.io>
Signed-off-by: Daniel Chernovsky <daniel.chernovsky@doubleverify.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants