Skip to content

[fix]: Anthropic provider - stop double-counting cache-read tokens in streaming usage - #5355

Open
is911 wants to merge 1 commit into
maximhq:devfrom
is911:fix/anthropic-stream-cache-read-double-count
Open

[fix]: Anthropic provider - stop double-counting cache-read tokens in streaming usage#5355
is911 wants to merge 1 commit into
maximhq:devfrom
is911:fix/anthropic-stream-cache-read-double-count

Conversation

@is911

@is911 is911 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a ~2× prompt-token inflation in Anthropic-protocol streaming usage accounting. The streaming accumulator in HandleAnthropicChatCompletionStreaming used a max-keep guard on input_tokens that did not branch on event type, so the authoritative message_delta.input_tokens (the uncached tail, smaller) was rejected in favor of the stale message_start.input_tokens (the full prompt). Then normalizeCachedUsage folded cache_read_input_tokens on top at stream end → message_start.input_tokens + message_delta.cache_read_input_tokens → double-count on every cached request from turn 2 onward.

Closes #5354.

One-line root cause: the accumulator merged the two Anthropic usage snapshots via max-keep instead of treating message_delta.usage as authoritative, then folded cache_read on top of an already-inclusive prompt total.

Before / after formula:

  • BEFORE (buggy): prompt_tokens = message_start.input_tokens + message_delta.cache_read_input_tokens
  • AFTER (fixed): prompt_tokens = message_delta.input_tokens + message_delta.cache_creation_input_tokens + message_delta.cache_read_input_tokens

Evidence (3 consecutive turns, same session, provider: bifrost-kimi-anthropic / kimi-code/k3)

Turn message_start (input/cache_read) message_delta (input/cache_read) Correct total Bifrost recorded (pre-fix) Post-fix
1 20458 / 0 20458 / 0 20458 20458 ✓ 20458 ✓
2 20532 / 0 308 / 20224 20532 40756 ✗ (=20532+20224) 20532 ✓
3 20621 / 0 141 / 20480 20621 41101 ✗ (=20621+20480) 20621 ✓

Turn 1 was correct only because no cache read occurred (delta.cache_read == 0, so the buggy sum reduced to start.input). From turn 2 on, the buggy accumulator double-counted the cached portion. Reproduction signature: first request accurate, every subsequent request ~2×, growing by ~2× the real per-turn conversation growth.

Changes

  • core/providers/anthropic/anthropic.go — Extracted the event-loop usage-accumulation block into a new helper applyStreamUsageEvent(usage, eventType, usageToProcess, startSnapshot) that branches on the Anthropic stream event type:
    • On message_delta: event-level unconditional overwrite of InputTokens, CacheCreationInputTokens, CacheReadInputTokens from delta (NOT per-field if != 0 checks — those would conflate absent-with-zero for non-pointer Go int fields); reconstruct PromptTokens = delta.InputTokens + delta.CacheCreationInputTokens + delta.CacheReadInputTokens.
    • On message_start (or any non-delta event carrying usage): preserves existing max-keep as the fallback for early-terminated streams.
    • Impossible-zero guard (PromptTokens == 0 && startSnapshot.InputTokens > 0 → restore start-side values) for non-conformant Anthropic-compatible providers that emit a partial-usage message_delta (input=0 with cache fields omitted, deserializing to all-zeros). Dead code for spec-conformant providers — a real Anthropic prompt always has PromptTokens >= 1.
    • End-of-stream normalizeCachedUsage fold is skipped when message_delta was processed (deltaProcessed flag) — the cache breakdown is already folded into PromptTokens by the overwrite.
  • core/providers/anthropic/streamcacheusage_test.go (new) — Three fixtures (see How to test).
  • core/changelog.md — Entry at the top per contributing guide.

Design decision / trade-off

The fix uses an event-level overwrite rather than per-field if != 0 merge. Per-field checks would conflate absent-with-zero for non-pointer Go int fields (JSON deserialization of an omitted field produces 0), so a delta that legitimately omits cache_read would be indistinguishable from a delta that reports cache_read=0. The event-level overwrite (with the impossible-zero guard as a safety net) is the only spec-correct option for the non-pointer field shape.

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Documentation
  • Chore/CI

Affected areas

  • Core (Go)
  • Transports (HTTP)
  • Providers/Integrations
  • Plugins
  • UI (React)
  • Docs

How to test

go version    # 1.22+ (tested on 1.26)
go test ./core/providers/anthropic/... -run TestAnthropicStream -v
go test ./core/providers/anthropic/...
go vet ./...
go build ./...

New fixtures:

  1. TestAnthropicStreamCacheReadNotDoubleCounted — the primary regression guard. Turn-2 stream: message_start input=20532/cache_read=0 → message_delta input=308/cache_read=20224/cache_creation=0. Asserts prompt_tokens == 20532 (i.e., 308 + 0 + 20224). Pre-fix this test FAILS recording 40756 (proves the test bites); post-fix PASSES recording 20532.
  2. TestAnthropicStreamNoCacheReadUnchanged — turn-1 non-regression (no cache). message_start input=20458 → message_delta input=20458/cache_read=0. Asserts prompt_tokens == 20458. Passes both pre- and post-fix.
  3. TestAnthropicStreamUsage_NonConformantDeltaOmitsCacheFields — proves the impossible-zero guard is load-bearing. message_start input=20532/cache_read=20224 → message_delta input=0 with cache fields omitted (→ deserialize to 0). Asserts prompt_tokens == 20532 (start-side restored by the guard), NOT 0.

Existing tests: go test ./core/providers/anthropic/... shows zero pass/fail diff vs dev baseline.

Screenshots/Recordings

N/A (no UI changes).

Breaking changes

  • Yes
  • No

Behavior change: prompt_tokens for Anthropic-protocol streams with prompt caching will be ~50% lower than before (correct instead of ~2× inflated). Any downstream consumer that calibrated against the inflated values (cost baselines, governance budgets, telemetry dashboards) will see a one-time step-change to the correct values. The corrected values match the Anthropic Messages API spec.

Related issues

Closes #5354

Security considerations

None. This is a usage-accounting fix; no auth, secrets, PII, or sandboxing surfaces are touched. The corrected prompt_tokens flows into existing cost/governance/telemetry paths unchanged.

Checklist

  • I read docs/contributing/README.md and followed the guidelines (commit format [fix]:, affected packages listed, core/changelog.md updated)
  • I added/updated tests where appropriate (3 new fixtures, including a turn-2 regression guard that fails pre-fix)
  • I updated documentation where needed (changelog entry)
  • I verified builds succeed (Go) — go build ./..., go vet ./... green
  • I verified the CI pipeline passes locally if applicable — CI runs on push; local go test ./core/providers/anthropic/... green

Follow-up flags (out of scope for this PR)

  1. Anthropic Responses API path (HandleAnthropicResponsesStream + accumulateAnthropicResponsesUsage) uses a separate accumulator that was NOT audited for the same bug class. Worth a parallel audit if the Responses API exhibits similar double-counting.
  2. OpenAI streaming path — independently audited and confirmed structurally immune (single-snapshot emission + no cache-fold step; normalizeCachedUsage has 0 matches in core/providers/openai/). No fix needed.
  3. Bedrock provider (core/providers/bedrock/bedrock.go) has its own normalizeCachedUsage — not audited; recommend a separate diagnostic if Bedrock routes Anthropic-protocol streams.
  4. cache_creation_input_tokens is consistently 0 from the kimi-code provider — upstream provider quirk, not a Bifrost bug. Not fixed.

Known limitations (provider conformance assumption)

The fix assumes Anthropic-compatible providers either (a) repeat cache fields at message_delta per the Anthropic Messages API spec, or (b) omit the entire message_delta.usage (delta-absent fallback). Non-conformant providers that emit a PARTIAL-usage message_delta (input_tokens=0 with cache fields omitted — deserializing to all-zeros for non-pointer Go int) are handled by the impossible-zero guard. The guard is dead code for spec-conformant providers (zero behavior change). The guard does not log when it fires (the accumulator is currently logger-free; a structured-log line for non-conformance detection is a possible future enhancement).

… streaming usage

The streaming accumulator in HandleAnthropicChatCompletionStreaming used a
max-keep guard on input_tokens for both message_start and message_delta events
indiscriminately. When message_delta arrived with its authoritative (smaller)
uncached-tail input_tokens, the max-keep rejected it and retained the stale
message_start full-prompt value; then normalizeCachedUsage folded
cache_read_input_tokens on top at stream end, double-counting the cached
portion (~2x prompt_tokens inflation on every cached request from turn 2 on).

The fix makes message_delta.usage authoritative for all input-side fields via
event-level overwrite (PromptTokens = delta.input + cache_creation + cache_read),
preserves the existing message_start max-keep as the delta-absent fallback for
early-terminated streams, adds an impossible-zero guard
(PromptTokens == 0 && startSnapshot.InputTokens > 0 -> restore start-side) for
non-conformant Anthropic-compatible providers that emit a partial-usage
message_delta, and skips the end-of-stream normalizeCachedUsage fold when
message_delta was processed.

Affected packages:
- core/providers/anthropic/anthropic.go
- core/providers/anthropic/streamcacheusage_test.go (new)
- core/changelog.md

Tests:
- TestAnthropicStreamCacheReadNotDoubleCounted: turn-2 fixture (start
  input=20532/cache_read=0; delta input=308/cache_read=20224). Pre-fix FAILS
  recording 40756; post-fix PASSES recording 20532.
- TestAnthropicStreamNoCacheReadUnchanged: turn-1 non-regression (no cache).
- TestAnthropicStreamUsage_NonConformantDeltaOmitsCacheFields: proves the
  impossible-zero guard is load-bearing for non-conformant providers.
- Existing core/providers/anthropic/... tests: zero pass/fail diff. go vet, go
  build green.

Closes maximhq#5354
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Corrected Anthropic streaming usage accounting to prevent cache-read tokens from being counted twice.
    • Improved handling of incomplete or inconsistent usage data during streaming.
    • Preserved accurate prompt-token totals for requests with and without cache reads.
  • Tests

    • Added coverage for cached usage, uncached usage, and incomplete streaming usage scenarios.
  • Documentation

    • Updated the changelog with details of the usage-accounting fix.

Walkthrough

Anthropic streaming usage accumulation now overwrites prompt-token fields from authoritative message_delta data, avoids duplicate cache folding, preserves start-side values for zero-valued partial deltas, and adds regression tests for cached and uncached streams.

Changes

Anthropic streaming usage

Layer / File(s) Summary
Authoritative stream usage accumulation
core/providers/anthropic/anthropic.go
Adds event-specific usage handling that reconstructs prompt tokens from message_delta cache components and guards against zero-valued partial deltas.
Streaming loop normalization integration
core/providers/anthropic/anthropic.go
Captures message_start usage, tracks processed deltas, and skips end-of-stream cache folding when the delta already contains the authoritative breakdown.
Usage regression coverage
core/providers/anthropic/streamcacheusage_test.go, core/changelog.md
Tests cached, uncached, and non-conformant streaming usage sequences, and documents the double-counting fix.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: tejasghatte, sammaji, akshaydeo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: Anthropic streaming usage no longer double-counts cache-read tokens.
Description check ✅ Passed The PR description matches the template and includes summary, changes, type, affected areas, testing, related issue, and checklist.
Linked Issues check ✅ Passed The change implements the issue’s required overwrite semantics and regression coverage for Anthropic streaming prompt-token accounting.
Out of Scope Changes check ✅ Passed The added changelog note, tests, and fallback guard are all related to the Anthropic streaming usage fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 4/5

The changed usage accumulator needs fixes for partial deltas and stale cache-write details before merging.

  • Complete Anthropic delta snapshots now avoid the original double count.
  • Positive partial deltas can undercount prompt usage.
  • Authoritative deltas can leave nested cache-write details inconsistent with their total.

core/providers/anthropic/anthropic.go

Important Files Changed

Filename Overview
core/providers/anthropic/anthropic.go Fixes the main cache-read double count, but partial deltas and nested cache details can still produce incorrect usage.
core/providers/anthropic/streamcacheusage_test.go Adds focused tests for cached reads, uncached streams, and all-zero malformed deltas.
core/changelog.md Documents the Anthropic streaming usage fix.

Reviews (1): Last reviewed commit: "[fix]: Anthropic provider - stop double-..." | Re-trigger Greptile

Comment on lines +682 to +691
if usageToProcess.CacheCreation.Ephemeral5mInputTokens > 0 || usageToProcess.CacheCreation.Ephemeral1hInputTokens > 0 {
if usage.PromptTokensDetails.CachedWriteTokenDetails == nil {
usage.PromptTokensDetails.CachedWriteTokenDetails = &schemas.ChatCachedWriteTokenDetails{}
}
if usageToProcess.CacheCreation.Ephemeral5mInputTokens > usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens5m {
usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens5m = usageToProcess.CacheCreation.Ephemeral5mInputTokens
}
if usageToProcess.CacheCreation.Ephemeral1hInputTokens > usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens1h {
usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens1h = usageToProcess.CacheCreation.Ephemeral1hInputTokens
}

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.

P1 Cache Detail Snapshot Stays Stale

The delta branch overwrites the authoritative cache totals but only max-keeps the 5m and 1h details. When message_start has cache-creation details and message_delta clears or omits them, the final usage can report CachedWriteTokens == 0 while retaining nonzero detail counts, producing inconsistent billing and telemetry.

Comment on lines +695 to +711
usage.PromptTokens = usageToProcess.InputTokens + usageToProcess.CacheReadInputTokens + usageToProcess.CacheCreationInputTokens
// OutputTokens on message_delta is the cumulative output count.
if usageToProcess.OutputTokens > usage.CompletionTokens {
usage.CompletionTokens = usageToProcess.OutputTokens
}
// Impossible-zero guard: restore start-side when a non-conformant
// provider emits a partial-usage delta (input=0 + cache fields omitted).
if usage.PromptTokens == 0 && startSnapshot != nil && startSnapshot.InputTokens > 0 {
usage.PromptTokens = startSnapshot.InputTokens
usage.PromptTokensDetails.CachedReadTokens = startSnapshot.CacheReadInputTokens
usage.PromptTokensDetails.CachedWriteTokens = startSnapshot.CacheCreationInputTokens
}
calculatedTotal := usage.PromptTokens + usage.CompletionTokens
if calculatedTotal > usage.TotalTokens {
usage.TotalTokens = calculatedTotal
}
return true

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.

P1 Positive Partial Delta Bypasses Fallback

The fallback only detects an all-zero reconstructed prompt. If a compatible provider emits a partial delta with positive input_tokens but omits cache fields, this branch records the incomplete total and sets deltaProcessed, so normalization is skipped; for example, a 650-token start followed by {input_tokens: 500} is billed as 500 tokens.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/providers/anthropic/anthropic.go`:
- Around line 682-691: Update the cache-creation handling in the Anthropic usage
processing flow to replace the entire CachedWriteTokenDetails breakdown when
authoritative message_delta values are present, rather than merging with max
guards. Preserve the existing initialization path, but assign both
CachedWriteTokens5m and CachedWriteTokens1h directly from
usageToProcess.CacheCreation so stale message_start values, including nonzero
values when the delta reports zero, are overwritten.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3320d7ee-f29d-4220-b760-1e1dbc8dd95f

📥 Commits

Reviewing files that changed from the base of the PR and between a0f0afb and 3f76351.

📒 Files selected for processing (3)
  • core/changelog.md
  • core/providers/anthropic/anthropic.go
  • core/providers/anthropic/streamcacheusage_test.go

Comment on lines +682 to +691
if usageToProcess.CacheCreation.Ephemeral5mInputTokens > 0 || usageToProcess.CacheCreation.Ephemeral1hInputTokens > 0 {
if usage.PromptTokensDetails.CachedWriteTokenDetails == nil {
usage.PromptTokensDetails.CachedWriteTokenDetails = &schemas.ChatCachedWriteTokenDetails{}
}
if usageToProcess.CacheCreation.Ephemeral5mInputTokens > usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens5m {
usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens5m = usageToProcess.CacheCreation.Ephemeral5mInputTokens
}
if usageToProcess.CacheCreation.Ephemeral1hInputTokens > usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens1h {
usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens1h = usageToProcess.CacheCreation.Ephemeral1hInputTokens
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Overwrite cache-write TTL details on message_delta.

These max guards retain preliminary message_start values when the authoritative delta reports smaller or zero TTL counts. Replace the nested breakdown wholesale alongside CachedWriteTokens; otherwise downstream usage contains stale cache details.

Proposed fix
-		if usageToProcess.CacheCreation.Ephemeral5mInputTokens > 0 || usageToProcess.CacheCreation.Ephemeral1hInputTokens > 0 {
+		if usageToProcess.CacheCreation.Ephemeral5mInputTokens == 0 &&
+			usageToProcess.CacheCreation.Ephemeral1hInputTokens == 0 {
+			usage.PromptTokensDetails.CachedWriteTokenDetails = nil
+		} else {
 			if usage.PromptTokensDetails.CachedWriteTokenDetails == nil {
 				usage.PromptTokensDetails.CachedWriteTokenDetails = &schemas.ChatCachedWriteTokenDetails{}
 			}
-			if usageToProcess.CacheCreation.Ephemeral5mInputTokens > usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens5m {
-				usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens5m = usageToProcess.CacheCreation.Ephemeral5mInputTokens
-			}
-			if usageToProcess.CacheCreation.Ephemeral1hInputTokens > usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens1h {
-				usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens1h = usageToProcess.CacheCreation.Ephemeral1hInputTokens
-			}
+			usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens5m = usageToProcess.CacheCreation.Ephemeral5mInputTokens
+			usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens1h = usageToProcess.CacheCreation.Ephemeral1hInputTokens
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if usageToProcess.CacheCreation.Ephemeral5mInputTokens > 0 || usageToProcess.CacheCreation.Ephemeral1hInputTokens > 0 {
if usage.PromptTokensDetails.CachedWriteTokenDetails == nil {
usage.PromptTokensDetails.CachedWriteTokenDetails = &schemas.ChatCachedWriteTokenDetails{}
}
if usageToProcess.CacheCreation.Ephemeral5mInputTokens > usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens5m {
usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens5m = usageToProcess.CacheCreation.Ephemeral5mInputTokens
}
if usageToProcess.CacheCreation.Ephemeral1hInputTokens > usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens1h {
usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens1h = usageToProcess.CacheCreation.Ephemeral1hInputTokens
}
if usageToProcess.CacheCreation.Ephemeral5mInputTokens == 0 &&
usageToProcess.CacheCreation.Ephemeral1hInputTokens == 0 {
usage.PromptTokensDetails.CachedWriteTokenDetails = nil
} else {
if usage.PromptTokensDetails.CachedWriteTokenDetails == nil {
usage.PromptTokensDetails.CachedWriteTokenDetails = &schemas.ChatCachedWriteTokenDetails{}
}
usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens5m = usageToProcess.CacheCreation.Ephemeral5mInputTokens
usage.PromptTokensDetails.CachedWriteTokenDetails.CachedWriteTokens1h = usageToProcess.CacheCreation.Ephemeral1hInputTokens
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/providers/anthropic/anthropic.go` around lines 682 - 691, Update the
cache-creation handling in the Anthropic usage processing flow to replace the
entire CachedWriteTokenDetails breakdown when authoritative message_delta values
are present, rather than merging with max guards. Preserve the existing
initialization path, but assign both CachedWriteTokens5m and CachedWriteTokens1h
directly from usageToProcess.CacheCreation so stale message_start values,
including nonzero values when the delta reports zero, are overwritten.

@akshaydeo

Copy link
Copy Markdown
Contributor

@TejasGhatte have a look once

@TejasGhatte

Copy link
Copy Markdown
Collaborator

Hey @is911 according to anthropic api input tokens field is cache exclusive. Also message delta usage counts are cumulative.
It seems like kimi endpoint is sending cache inclusive input tokens which breaks the anthropic spec.

@wangkanai

Copy link
Copy Markdown

Independent confirmation from a production deployment, plus data that I think bears directly on the open question in this thread.

@TejasGhatte: according to anthropic api input tokens field is cache exclusive. Also message delta usage counts are cumulative. It seems like kimi endpoint is sending cache inclusive input tokens which breaks the anthropic spec.

Both halves of that are right — and the conclusion I'd draw is that Bifrost should be robust to it anyway, because this is not Kimi-specific. I hit the identical defect on QwenCloud, an unrelated vendor, and the two providers deviate in exactly the same way.

Captured verbatim from https://token-plan.<region>.maas.aliyuncs.com/apps/anthropic/v1/messages, with Bifrost out of the path entirely:

// message_start — prompt-scale input_tokens, NO cache counters at all
{"type":"message_start","message":{"id":"msg_ba0b5ef1","model":"qwen3.7-plus","content":[],
 "usage":{"input_tokens":8834,"output_tokens":0}}}

// message_delta — the authoritative, cache-aware split (NOT an increment)
{"type":"message_delta","delta":{"stop_reason":"end_turn"},
 "usage":{"input_tokens":18,"output_tokens":426,"cache_creation_input_tokens":0,
          "cache_read_input_tokens":8831,"cache_creation":{"ephemeral_5m_input_tokens":0}}}

The non-streaming response for the same request agrees with message_delta: 18 + 0 + 8831 = 8849.

For contrast, the compliant shape — MiniMax on the same code path, whose message_start reports input_tokens: 0 and which therefore never triggered the bug:

{"type":"message_start","message":{"usage":{"input_tokens":0,"output_tokens":0,"service_tier":"standard"}}}
{"type":"message_delta","usage":{"cache_read_input_tokens":8990,"input_tokens":1,"output_tokens":2}}

So the trigger condition is precise and provider-shaped: message_start carries a prompt-scale input_tokens with no cache counters, and the real split arrives only in message_delta. At least two major Anthropic-dialect providers do this today. A gateway that silently doubles prompt_tokens against them is expensive: over one week of agent traffic, 42% of every logged prompt token was invented — which corrupts cost attribution, budgets, rate limiting and dashboards, and inverts the apparent cache-hit rate (a real ~99.7% displayed as ~52%, sending you off hunting a provider caching problem that does not exist).

On the undercount concern raised by the bot review

Positive partial deltas can undercount prompt usage.

That is a real risk with "message_delta overwrites unconditionally". The variant I deployed avoids it by staying monotone — it never lowers a value — while still preferring the cache-aware number. An event is authoritative for input_tokens only if it actually carries cache counters; otherwise it is a fallback used until an authoritative event turns up. authSeen is derived from the accumulator itself, so the merge also stays order-independent and needs no new state:

// core/providers/anthropic/anthropic.go — accumulateAnthropicResponsesUsage
authoritative := usageToProcess.CacheReadInputTokens > 0 || usageToProcess.CacheCreationInputTokens > 0
authSeen := usage.InputTokensDetails != nil &&
	(usage.InputTokensDetails.CachedReadTokens > 0 || usage.InputTokensDetails.CachedWriteTokens > 0)
if authoritative || (!authSeen && usageToProcess.InputTokens > usage.InputTokens) {
	usage.InputTokens = usageToProcess.InputTokens
	if billedUsage != nil {
		billedUsage.PromptTokens = usageToProcess.InputTokens
	}
}

Compliant upstreams are provably unaffected: real Anthropic puts the cache counters on message_start, so that frame is authoritative and the later message_delta (output only, no cache keys) cannot clobber it.

Production verification

Deployed to a live v1.6.11 gateway. Identical bytes, stream the only variable — streaming now agrees with non-streaming exactly on every provider tested:

provider prompt_tokens before after non-streaming truth
QwenCloud qwen3.7-plus 17,669 8,849 8,849
Moonshot kimi-k3 17,862 8,932 8,932
MiniMax M3 (control) 8,992 8,992 8,992

Organic traffic at ~300K-token prompts went from prompt_tokens ≈ 2 × cached_read to a residual of 411–1,369 tokens, matching the providers that were always correct. Full providers/anthropic, providers/azure, providers/utils and schemas suites pass.

One thing this PR does not cover

There is a second accumulator with the same flaw: AnthropicPassthroughStreamUsage.ObserveEvent in core/providers/anthropic/passthrough_usage.go, tracked separately as #5510. It max-merges input_tokens across events in the same way. I verified they are independent — deploying a fix to only the passthrough one changed nothing for my traffic (17,667 → 17,669), which is how I found that the native accumulator here was the one in play. Whichever approach lands, both sites need it.

Happy to open a PR for the passthrough half, or to rebase this one — it has been merge-conflicted since 08-19.

wangkanai added a commit to wangkanai/bifrost that referenced this pull request Aug 23, 2026
…ly frame as cache-aware

Review follow-ups on maximhq#6378.

The type doc claimed a plain per-field max and parity with the native accumulator.
Neither is true: input_tokens is no longer a plain max, and
accumulateAnthropicResponsesUsage (anthropic.go) still takes an unconditional max
and therefore still carries the double-count this type fixes. Point at maximhq#5354 /
maximhq#5355 instead of claiming parity.

"nothing here ever lowers a value" described the accumulators, not the emitted
field. combined.InputTokens does drop, from the loose figure to the authoritative
one, on the event where authSeen flips. That is safe because StreamPassthrough
keeps the LAST non-nil observation and the authoritative message_delta is last —
but the comment should say so rather than assert a monotonicity the emitted field
does not have.

Also: authority was decided on the two top-level cache counters only, so a frame
reporting cache creation solely as the ephemeral 5m/1h breakdown was misread as
cache-less and the prompt-scale input_tokens was kept. cache_creation is
documented as the split OF cache_creation_input_tokens, so such a frame is
cache-aware. Add it to the value test; the breakdown was already max-merged into
the combined usage, so no other change is needed.

Two tests added, both order-independent like the rest of the table: the
breakdown-only frame (fails at 50000, passes at 1000), and a pin for the case
where a cache-less event with a larger input_tokens follows an authoritative one
(8931, where an unconditional max reports 9181) — no captured frame triggers that
today, so the intent is pinned explicitly rather than left incidental.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Anthropic streaming usage double-counts cache-read tokens (~2× prompt_tokens inflation)

4 participants