Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
dddeebd
update the usage-report
hustxiayang Jun 16, 2026
8c758f4
docs: update compatibility matrix EG -> V1.8.1 (#2215)
aabchoo Jun 9, 2026
8073ccd
fix: set SchemeHeaderTransformation.MatchUpstream on AI Gateway liste…
kanurag94 Jun 10, 2026
699c72c
fix: ensure that plaintext API key secret ref is not exposed in MCP b…
aishwaryaraimule21 Jun 10, 2026
91499c8
chore(deps): bump the go group across 1 directory with 8 updates (#2200)
dependabot[bot] Jun 10, 2026
7f630d3
fix: status of routes when Gateway is not found (#2180)
Hritik003 Jun 10, 2026
c304288
fix: promote role:system messages from messages array to system param…
Killusions Jun 10, 2026
5d691db
translator: handle numeric OpenAI `error.code` in Anthropic→OpenAI er…
Copilot Jun 10, 2026
c5b3f0d
site: fix vars for 0.7 and main (#2217)
nacx Jun 10, 2026
855df94
feat: add completion tokens details for anthropic models (#2199)
hustxiayang Jun 10, 2026
48f759f
chore(deps): bump the go group across 1 directory with 12 updates (#2…
dependabot[bot] Jun 11, 2026
8fc95ea
chore(deps): bump docker/setup-qemu-action from 4.0.0 to 4.1.0 in the…
dependabot[bot] Jun 11, 2026
2857986
docs(site): adds Stacklok to Adopters Page (#2224)
ChrisJBurns Jun 11, 2026
7c254d4
fix: use camelCase JSON tags for GCP Vertex AI request fields (#2225)
ChrisJBurns Jun 11, 2026
54e73be
fix: prevent empty namespace from breaking RBAC name rendering in hel…
ChrisJBurns Jun 12, 2026
8e4b6d8
fix: skip empty-content assistant messages in Bedrock translator (#2191)
Killusions Jun 12, 2026
3326c0a
extproc: skip CONTINUE_AND_REPLACE when no body change is needed (#2170)
ChrisJBurns Jun 12, 2026
0e03257
fix: insert AI Gateway extproc after the buffer filter (#2227)
ChrisJBurns Jun 12, 2026
a200229
fix: allow strict function definition in anthropic (#2232)
hustxiayang Jun 12, 2026
8f98b4b
docs: update release information to v0.7 (#2233)
mathetake Jun 14, 2026
d34a29d
Merge commit from fork
nacx Jun 16, 2026
fbdde20
mcp: limit request body size by default (#2238)
nacx Jun 16, 2026
9719b8f
cached-tokens
hustxiayang Jun 16, 2026
e0ae47e
fix-gemini-review-comment
hustxiayang Jun 16, 2026
fc79240
Merge branch 'main' into fix-usage-report
hustxiayang Jun 16, 2026
7a7e107
update-tests
hustxiayang Jun 16, 2026
a02e6d7
fix-the-tests
hustxiayang Jun 16, 2026
bbdb144
update-tests
hustxiayang Jun 16, 2026
224ea22
Update internal/translator/anthropic_helper.go
hustxiayang Jun 16, 2026
da8daf9
update-comments
hustxiayang Jun 17, 2026
d0d9441
Merge branch 'main' into fix-usage-report
hustxiayang Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 2 additions & 13 deletions internal/translator/anthropic_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -966,8 +966,8 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat
&u.CacheReadInputTokens,
&u.CacheCreationInputTokens,
)
// For message_start, we store the initial usage but don't add to the accumulated
// The message_delta event will contain the final totals
// Set all input token counts (input, cache read, cache creation) from message_start.
// message_delta may also contain these fields but only output_tokens is used from it.
if input, ok := usage.InputTokens(); ok {
p.tokenUsage.SetInputTokens(input)
}
Expand Down Expand Up @@ -1061,17 +1061,6 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat
if output, ok := usage.OutputTokens(); ok {
p.tokenUsage.AddOutputTokens(output)
}
// Update input tokens to include any cache tokens from delta

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.

I missed this PR when I was working on #2241.

After discussing offline with @hustxiayang and @yuzisun, we agreed to remove the extra cache token counting from message_start and instead rely on message_delta, which is the provider's final source of truth in streaming mode.

This keeps the logic cleaner and avoids edge cases like the one here: https://github.com/envoyproxy/ai-gateway/pull/2241/changes#diff-aa8ea9f59fc3acd18cfd4a4c571d683817274a213b6a5fad0ab6085cfe0912dfL974-L980.

if cached, ok := usage.CachedInputTokens(); ok {
p.tokenUsage.AddInputTokens(cached)
// Accumulate any additional cache tokens from delta
p.tokenUsage.AddCachedInputTokens(cached)
}
if cacheCreation, ok := usage.CacheCreationInputTokens(); ok {
p.tokenUsage.AddInputTokens(cacheCreation)
// Accumulate cache creation tokens
p.tokenUsage.AddCacheCreationInputTokens(cacheCreation)
}
p.tokenUsage.SetReasoningTokens(uint32(u.OutputTokensDetails.ThinkingTokens)) //nolint:gosec
if event.Delta.StopReason != "" {
p.stopReason = event.Delta.StopReason
Expand Down
148 changes: 148 additions & 0 deletions internal/translator/anthropic_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ package translator

import (
"fmt"
"strings"
"testing"

"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/shared/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/utils/ptr"

"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
"github.com/envoyproxy/ai-gateway/internal/internalapi"
"github.com/envoyproxy/ai-gateway/internal/metrics"
)

// mockErrorReader is a helper for testing io.Reader failures.
Expand Down Expand Up @@ -1157,3 +1160,148 @@ func TestBuildAnthropicParamsWithReasoningEffort(t *testing.T) {
require.Equal(t, anthropic.OutputConfigEffort(""), params.OutputConfig.Effort)
})
}

func TestAnthropicStreamParser_StreamingTokenUsage(t *testing.T) {
tests := []struct {
name string
events string
expectedInputTokens uint32
expectedOutputTokens uint32
expectedTotalTokens uint32
expectedCachedTokens uint32
expectedCacheCreationTokens uint32
}{
{
name: "with cache tokens",
events: `event: message_start
data: {"type": "message_start", "message": {"id": "msg_abc123", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-6", "usage": {"input_tokens": 678, "cache_read_input_tokens": 13363, "cache_creation_input_tokens": 0, "output_tokens": 1}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hi"}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 678, "cache_read_input_tokens": 13363, "cache_creation_input_tokens": 0, "output_tokens": 5}}

event: message_stop
data: {"type": "message_stop"}

`,
expectedInputTokens: 14041, // 678 + 13363 + 0
expectedOutputTokens: 5,
expectedTotalTokens: 14046, // 14041 + 5
expectedCachedTokens: 13363,
expectedCacheCreationTokens: 0,
},
{
name: "without cache tokens",
events: `event: message_start
data: {"type": "message_start", "message": {"id": "msg_abc456", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-6", "usage": {"input_tokens": 100, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "output_tokens": 1}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 100, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "output_tokens": 10}}

event: message_stop
data: {"type": "message_stop"}

`,
expectedInputTokens: 100,
expectedOutputTokens: 10,
expectedTotalTokens: 110,
expectedCachedTokens: 0,
expectedCacheCreationTokens: 0,
},
{
name: "with cache creation tokens",
events: `event: message_start
data: {"type": "message_start", "message": {"id": "msg_abc789", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-6", "usage": {"input_tokens": 200, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 5000, "output_tokens": 1}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Response"}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 200, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 5000, "output_tokens": 8}}

event: message_stop
data: {"type": "message_stop"}

`,
expectedInputTokens: 5200, // 200 + 5000 + 0
expectedOutputTokens: 8,
expectedTotalTokens: 5208, // 5200 + 8
expectedCachedTokens: 0,
expectedCacheCreationTokens: 5000,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := newAnthropicStreamParser("claude-sonnet-4-6")

// Feed each event block separately (simulating chunked SSE delivery),
// with the last chunk marked as endOfStream.
chunks := splitSSEEvents(tt.events)
var tokenUsage metrics.TokenUsage
for i, chunk := range chunks {
endOfStream := i == len(chunks)-1
_, _, usage, _, err := parser.Process(strings.NewReader(chunk), endOfStream, nil)
require.NoError(t, err)
if endOfStream {
tokenUsage = usage
}
}

inputTokens, inputSet := tokenUsage.InputTokens()
assert.True(t, inputSet, "InputTokens should be set")
assert.Equal(t, tt.expectedInputTokens, inputTokens, "InputTokens mismatch")

outputTokens, outputSet := tokenUsage.OutputTokens()
assert.True(t, outputSet, "OutputTokens should be set")
assert.Equal(t, tt.expectedOutputTokens, outputTokens, "OutputTokens mismatch")

totalTokens, totalSet := tokenUsage.TotalTokens()
assert.True(t, totalSet, "TotalTokens should be set")
assert.Equal(t, tt.expectedTotalTokens, totalTokens, "TotalTokens mismatch")

cachedTokens, cachedSet := tokenUsage.CachedInputTokens()
assert.True(t, cachedSet, "CachedInputTokens should be set")
assert.Equal(t, tt.expectedCachedTokens, cachedTokens, "CachedInputTokens mismatch")

cacheCreation, cacheCreationSet := tokenUsage.CacheCreationInputTokens()
assert.True(t, cacheCreationSet, "CacheCreationInputTokens should be set")
assert.Equal(t, tt.expectedCacheCreationTokens, cacheCreation, "CacheCreationInputTokens mismatch")
})
}
}

func splitSSEEvents(data string) []string {
parts := strings.Split(data, "\n\n")
var events []string
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
events = append(events, p+"\n\n")
}
}
return events
}
22 changes: 11 additions & 11 deletions tests/data-plane/testupstream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ data: [DONE]
expRequestHeaders: map[string]string{"Authorization": "Bearer " + fakeGCPAuthToken},
responseStatus: strconv.Itoa(http.StatusOK),
responseBody: `event: message_start
data: {"type": "message_start", "message": {"id": "msg_123", "usage": {"input_tokens": 15}}}
data: {"type": "message_start", "message": {"id": "msg_123", "usage": {"input_tokens": 15, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 10, "output_tokens": 1}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
Expand All @@ -566,7 +566,7 @@ event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 12, "cache_read_input_tokens":10}}
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 15, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 10, "output_tokens": 12}}

event: message_stop
data: {"type": "message_stop"}
Expand Down Expand Up @@ -614,7 +614,7 @@ data: [DONE]
expRequestHeaders: map[string]string{"Authorization": "Bearer " + fakeGCPAuthToken},
responseStatus: strconv.Itoa(http.StatusOK),
responseBody: `event: message_start
data: {"type": "message_start", "message": {"id": "msg_123", "usage": {"input_tokens": 50}}}
data: {"type": "message_start", "message": {"id": "msg_123", "usage": {"input_tokens": 50, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 1}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_abc123", "name": "get_weather", "input": {}}}
Expand All @@ -629,7 +629,7 @@ event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {"output_tokens": 20}}
data: {"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {"input_tokens": 50, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 20}}

event: message_stop
data: {"type": "message_stop"}`,
Expand Down Expand Up @@ -910,7 +910,7 @@ data: [DONE]
expRequestHeaders: map[string]string{"Authorization": "Bearer " + fakeGCPAuthToken},
responseStatus: strconv.Itoa(http.StatusOK),
responseBody: `event: message_start
data: {"type": "message_start", "message": {"id": "msg_789", "usage": {"input_tokens": 8}}}
data: {"type": "message_start", "message": {"id": "msg_789", "usage": {"input_tokens": 8, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 1}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
Expand All @@ -922,15 +922,15 @@ event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 15}}
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 8, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 15}}

event: message_stop
data: {"type": "message_stop"}

`,
expStatus: http.StatusOK,
expResponseBody: `event: message_start
data: {"type": "message_start", "message": {"id": "msg_789", "usage": {"input_tokens": 8}}}
data: {"type": "message_start", "message": {"id": "msg_789", "usage": {"input_tokens": 8, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 1}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
Expand All @@ -942,7 +942,7 @@ event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 15}}
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 8, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 15}}

event: message_stop
data: {"type": "message_stop"}
Expand Down Expand Up @@ -1052,7 +1052,7 @@ event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":9,"output_tokens":10}}
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":9,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":10}}

event: message_stop
data: {"type":"message_stop"}
Expand Down Expand Up @@ -1090,7 +1090,7 @@ data: {"type":"message_stop"}
{"bytes":"eyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiLwn5GLIEhvdyJ9fQ==","p":"abcdefghijklmnopqrstuvwxyzABCDEFG"}
{"bytes":"eyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiIgYXJlIHlvdSBkb2luZyB0b2RheT8ifX0=","p":"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234"}
{"bytes":"eyJ0eXBlIjoiY29udGVudF9ibG9ja19zdG9wIiwiaW5kZXgiOjB9","p":"abcdefghijklmnopqrstuvwxyz"}
{"bytes":"eyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsfSwidXNhZ2UiOnsib3V0cHV0X3Rva2VucyI6MTV9fQ==","p":"abcdefghijklmnopqrstu"}
{"bytes":"eyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsfSwidXNhZ2UiOnsiaW5wdXRfdG9rZW5zIjoxMCwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MCwib3V0cHV0X3Rva2VucyI6MTV9fQ==","p":"abcdefghijklmnopqrstu"}
{"bytes":"eyJ0eXBlIjoibWVzc2FnZV9zdG9wIiwiYW1hem9uLWJlZHJvY2staW52b2NhdGlvbk1ldHJpY3MiOnsiaW5wdXRUb2tlbkNvdW50IjoxMCwib3V0cHV0VG9rZW5Db3VudCI6MTUsImludm9jYXRpb25MYXRlbmN5IjoxNzk4LCJmaXJzdEJ5dGVMYXRlbmN5IjoxNTA3fX0=","p":"ab"}
`,
expStatus: http.StatusOK,
Expand Down Expand Up @@ -1119,7 +1119,7 @@ event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":15}}
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":15}}

event: message_stop
data: {"type":"message_stop","amazon-bedrock-invocationMetrics":{"inputTokenCount":10,"outputTokenCount":15,"invocationLatency":1798,"firstByteLatency":1507}}
Expand Down
Loading