Skip to content

Commit 21ae837

Browse files
pjdurdennacx
andauthored
fix(translator): merge input/cache token usage reported on Anthropic message_delta (#2292)
**Description** Anthropic Messages streaming usage extraction reads input and cache token counts from `message_start`, and on `message_delta` it only updates `output_tokens`. Some Anthropic-compatable backends report the final `input_tokens`, `cache_creation_input_tokens` and `cache_read_input_tokens` on the `message_delta` event instead. In that case the input and cache counts get dropped and recorded as zero, which corrupts usage metrics and any downstream cost or quota accounting that depends on them. This runs the `message_delta` usage through the same `ExtractTokenUsageFromExplicitCaching` path already used for `message_start` and merges it with `Override`. The fields are only merged when non-zero, so a standard delta that carries `output_tokens` alone doesnt overwrite the `message_start` baseline with zeros. **Related Issues/PRs (if applicable)** Fixes #2290 **Special notes for reviewers (if applicable)** Added two streaming subtests (cache-creation and cache-read variants) using the values from the issue: `input_tokens` 4522 + cache 4511 gives 9033 input total and 9038 total. They fail on main and pass with this change. The existing streaming and non-streaming usage tests are untouched. --------- Signed-off-by: pjdurden <prajjwalchittori1@gmail.com> Co-authored-by: Ignasi Barrera <ignasi@tetrate.io>
1 parent 6749acb commit 21ae837

2 files changed

Lines changed: 104 additions & 2 deletions

File tree

internal/translator/anthropic_anthropic.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,44 @@ func (a *anthropicToAnthropicTranslator) reflectStreamingEvent(eventUnion *anthr
178178
}
179179
case eventUnion.MessageDelta != nil:
180180
u := eventUnion.MessageDelta.Usage
181-
// message_delta events provide final counts for specific token types
182-
// Update output tokens from message_delta (final count)
181+
// message_delta carries the final counts. Standard Anthropic only reports output_tokens
182+
// here, but some Anthropic-compatible backends report the final input/cache counts on
183+
// message_delta instead of message_start. See https://github.com/envoyproxy/ai-gateway/issues/2290.
184+
//
185+
// output_tokens is always the final value on message_delta, so take it unconditionally.
183186
if u.OutputTokens >= 0 {
184187
a.streamingTokenUsage.SetOutputTokens(uint32(u.OutputTokens)) //nolint:gosec
185188
}
189+
// Merge the input/cache counts per field rather than replacing the whole usage snapshot:
190+
// a delta may report only the fields that apply (the rest arrive as zero), so overwriting
191+
// every field would clobber values already set on message_start. We can only treat a field
192+
// as "present" when it is non-zero, since the upstream usage fields are not pointers.
193+
if u.InputTokens > 0 || u.CacheReadInputTokens > 0 || u.CacheCreationInputTokens > 0 {
194+
// The unified input_tokens is the sum of raw input + cache-read + cache-creation, so
195+
// recover the latest known value of each component and overwrite only the ones present
196+
// on this delta before recomputing the sum.
197+
cacheRead, _ := a.streamingTokenUsage.CachedInputTokens()
198+
cacheCreation, _ := a.streamingTokenUsage.CacheCreationInputTokens()
199+
unifiedInput, _ := a.streamingTokenUsage.InputTokens()
200+
var rawInput uint32
201+
if unifiedInput >= cacheRead+cacheCreation {
202+
rawInput = unifiedInput - cacheRead - cacheCreation
203+
}
204+
205+
if u.InputTokens > 0 {
206+
rawInput = uint32(u.InputTokens) //nolint:gosec
207+
}
208+
if u.CacheReadInputTokens > 0 {
209+
cacheRead = uint32(u.CacheReadInputTokens) //nolint:gosec
210+
}
211+
if u.CacheCreationInputTokens > 0 {
212+
cacheCreation = uint32(u.CacheCreationInputTokens) //nolint:gosec
213+
}
214+
215+
a.streamingTokenUsage.SetCachedInputTokens(cacheRead)
216+
a.streamingTokenUsage.SetCacheCreationInputTokens(cacheCreation)
217+
a.streamingTokenUsage.SetInputTokens(rawInput + cacheRead + cacheCreation)
218+
}
186219
}
187220
}
188221

internal/translator/anthropic_anthropic_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,75 @@ data: {"type":"message_stop" }`
195195
require.Equal(t, "claude-sonnet-4-5-20250929", responseModel)
196196
}
197197

198+
func TestAnthropicToAnthropic_ResponseBody_streaming_usageOnMessageDelta(t *testing.T) {
199+
// Some Anthropic-compatible streaming backends report the final input/cache usage only on the
200+
// message_delta event rather than message_start. The translator must merge those fields instead
201+
// of dropping everything but output_tokens. See https://github.com/envoyproxy/ai-gateway/issues/2290.
202+
t.Run("cache creation tokens", func(t *testing.T) {
203+
translator := NewAnthropicToAnthropicTranslator("", "")
204+
require.NotNil(t, translator)
205+
translator.(*anthropicToAnthropicTranslator).stream = true
206+
207+
const responseBody = `event: message_start
208+
data: {"type":"message_start","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_x","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0}}}
209+
210+
event: message_delta
211+
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":4522,"output_tokens":5,"cache_creation_input_tokens":4511}}
212+
213+
event: message_stop
214+
data: {"type":"message_stop"}`
215+
216+
_, _, tokenUsage, _, err := translator.ResponseBody(nil, strings.NewReader(responseBody), false, nil)
217+
require.NoError(t, err)
218+
// Total input = input_tokens(4522) + cache_creation_input_tokens(4511) = 9033; total = 9033 + 5 = 9038.
219+
require.Equal(t, tokenUsageFrom(9033, 0, 4511, 5, 9038, -1), tokenUsage)
220+
})
221+
222+
t.Run("cache read tokens", func(t *testing.T) {
223+
translator := NewAnthropicToAnthropicTranslator("", "")
224+
require.NotNil(t, translator)
225+
translator.(*anthropicToAnthropicTranslator).stream = true
226+
227+
const responseBody = `event: message_start
228+
data: {"type":"message_start","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_x","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0}}}
229+
230+
event: message_delta
231+
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":4522,"output_tokens":5,"cache_read_input_tokens":4511}}
232+
233+
event: message_stop
234+
data: {"type":"message_stop"}`
235+
236+
_, _, tokenUsage, _, err := translator.ResponseBody(nil, strings.NewReader(responseBody), false, nil)
237+
require.NoError(t, err)
238+
// Total input = input_tokens(4522) + cache_read_input_tokens(4511) = 9033; total = 9033 + 5 = 9038.
239+
require.Equal(t, tokenUsageFrom(9033, 4511, 0, 5, 9038, -1), tokenUsage)
240+
})
241+
242+
t.Run("partial fields on message_delta do not clobber message_start", func(t *testing.T) {
243+
// A backend may set input_tokens on message_start and only report the final cache_read on
244+
// message_delta. Merging must be per field: the delta omits input_tokens (reported as 0),
245+
// which must not zero out the value already recorded from message_start.
246+
translator := NewAnthropicToAnthropicTranslator("", "")
247+
require.NotNil(t, translator)
248+
translator.(*anthropicToAnthropicTranslator).stream = true
249+
250+
const responseBody = `event: message_start
251+
data: {"type":"message_start","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_x","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0}}}
252+
253+
event: message_delta
254+
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":10,"cache_read_input_tokens":500}}
255+
256+
event: message_stop
257+
data: {"type":"message_stop"}`
258+
259+
_, _, tokenUsage, _, err := translator.ResponseBody(nil, strings.NewReader(responseBody), false, nil)
260+
require.NoError(t, err)
261+
// message_start raw input(1000) is preserved; delta adds cache_read(500).
262+
// Total input = 1000 + 500 = 1500; total = 1500 + 10 = 1510.
263+
require.Equal(t, tokenUsageFrom(1500, 500, 0, 10, 1510, -1), tokenUsage)
264+
})
265+
}
266+
198267
func TestAnthropicToAnthropic_ResponseError(t *testing.T) {
199268
t.Run("json error", func(t *testing.T) {
200269
translator := NewAnthropicToAnthropicTranslator("", "")

0 commit comments

Comments
 (0)