Skip to content

Commit eb15a0e

Browse files
committed
fix(translator): stop double-counting OpenAI cache tokens in Anthropic streaming usage
OpenAI's prompt_tokens_details.cached_tokens and cache_creation_input_tokens are a breakdown *within* prompt_tokens (analogous to text_tokens/audio_tokens in the same struct), not additive extras. Anthropic's native usage schema is the opposite: input_tokens excludes cache tokens, which are reported as separate, additive counters (cache_read_input_tokens, cache_creation_input_tokens). The previous commit forwarded OpenAI's cache token breakdown directly into Anthropic's additive cache usage fields without accounting for the semantic mismatch. Since Anthropic-consuming clients compute total context usage as input_tokens + cache_read_input_tokens + cache_creation_input_tokens, this caused the cached portion of every prompt to be counted twice: once inside input_tokens (via OpenAI's prompt_tokens) and again via the cache fields. Depending on the backend's exact reporting of cache_creation_input_tokens (a field with no documented meaning in OpenAI's real Chat Completions API, so its semantics are backend-defined), this could inflate the reported input token count far beyond the real prompt size, up to the point of clients rejecting requests as exceeding the model's context window. Revert to not forwarding OpenAI's cache token breakdown into Anthropic's cache usage fields, for both the client-facing message_delta.usage payload and the gateway's internal TokenUsage metrics. input_tokens/output_tokens continue to be backfilled correctly, which was the original motivating fix. The pre-existing ptr.To(int64(0)) cache args to ExtractTokenUsageFromExplicitCaching are left untouched to keep this change minimal. Signed-off-by: ajac-zero <ajcardoza2000@gmail.com>
1 parent 90394b5 commit eb15a0e

4 files changed

Lines changed: 45 additions & 45 deletions

File tree

internal/translator/anthropic_openai_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ func TestAnthropicToOpenAITranslator_ResponseBody_Streaming(t *testing.T) {
385385

386386
// Spot-check specific event data.
387387
require.JSONEq(t, `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello!"}}`, events[2].data)
388-
require.JSONEq(t, `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`, events[4].data)
388+
require.JSONEq(t, `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":5}}`, events[4].data)
389389
require.JSONEq(t, `{"type":"message_stop"}`, events[5].data)
390390
}
391391

internal/translator/openai_helper.go

Lines changed: 28 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -630,10 +630,8 @@ type sseMessageDeltaBody struct {
630630
}
631631

632632
type sseOutputUsage struct {
633-
InputTokens int `json:"input_tokens"`
634-
OutputTokens int `json:"output_tokens"`
635-
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
636-
CacheReadInputTokens int `json:"cache_read_input_tokens"`
633+
InputTokens int `json:"input_tokens"`
634+
OutputTokens int `json:"output_tokens"`
637635
}
638636

639637
type sseMessageStop struct {
@@ -642,22 +640,20 @@ type sseMessageStop struct {
642640

643641
// openAIStreamToAnthropicState tracks the state for converting OpenAI SSE chunks to Anthropic SSE events.
644642
type openAIStreamToAnthropicState struct {
645-
buffer bytes.Buffer
646-
messageStarted bool // flag indicating emitted message_start
647-
hasOpenBlock bool // flag indicating emitted content_block_start but not content_block_stop
648-
hasThinkingBlock bool // flag indicating the open block is a thinking block
649-
closingEmitted bool // flag indicating emitted content_block_stop + message_delta + message_stop
650-
messageID string
651-
model string
652-
stopReason string // Anthropic stop_reason, mapped from OpenAI finish_reason
653-
inputTokens int
654-
outputTokens int
655-
cacheReadTokens int // prompt tokens served from OpenAI's prompt cache (prompt_tokens_details.cached_tokens)
656-
cacheCreationTokens int // tokens written to OpenAI's prompt cache (prompt_tokens_details.cache_creation_input_tokens)
657-
tokenUsage metrics.TokenUsage
658-
blockIndex int // current Anthropic content block index
659-
activeTools map[int64]*streamToolCall // keyed by OpenAI tool_call index
660-
requestModel string
643+
buffer bytes.Buffer
644+
messageStarted bool // flag indicating emitted message_start
645+
hasOpenBlock bool // flag indicating emitted content_block_start but not content_block_stop
646+
hasThinkingBlock bool // flag indicating the open block is a thinking block
647+
closingEmitted bool // flag indicating emitted content_block_stop + message_delta + message_stop
648+
messageID string
649+
model string
650+
stopReason string // Anthropic stop_reason, mapped from OpenAI finish_reason
651+
inputTokens int
652+
outputTokens int
653+
tokenUsage metrics.TokenUsage
654+
blockIndex int // current Anthropic content block index
655+
activeTools map[int64]*streamToolCall // keyed by OpenAI tool_call index
656+
requestModel string
661657
}
662658

663659
type streamToolCall struct {
@@ -743,15 +739,17 @@ func (s *openAIStreamToAnthropicState) handleChunk(chunk *openai.ChatCompletionR
743739
if len(chunk.Choices) == 0 && chunk.Usage != nil {
744740
s.inputTokens = chunk.Usage.PromptTokens
745741
s.outputTokens = chunk.Usage.CompletionTokens
746-
if details := chunk.Usage.PromptTokensDetails; details != nil {
747-
s.cacheReadTokens = details.CachedTokens
748-
s.cacheCreationTokens = details.CacheCreationTokens
749-
}
742+
// We don't forward OpenAI's prompt_tokens_details.cached_tokens/cache_creation_input_tokens
743+
// here: they're a breakdown *within* prompt_tokens (like text_tokens/audio_tokens), not
744+
// additive extras as in Anthropic's native usage schema, where input_tokens excludes
745+
// cache tokens and they're reported as separate additive fields. Forwarding OpenAI's
746+
// values as-is would cause Anthropic clients to double-count the cached portion on top
747+
// of input_tokens.
750748
s.tokenUsage = metrics.ExtractTokenUsageFromExplicitCaching(
751749
int64(s.inputTokens),
752750
int64(s.outputTokens),
753-
ptr.To(int64(s.cacheReadTokens)),
754-
ptr.To(int64(s.cacheCreationTokens)),
751+
ptr.To(int64(0)),
752+
ptr.To(int64(0)),
755753
)
756754
return s.emitClosingEvents(out)
757755
}
@@ -1059,17 +1057,14 @@ func (s *openAIStreamToAnthropicState) emitClosingEvents(out *[]byte) error {
10591057
stopReason = string(anthropic.StopReasonEndTurn)
10601058
}
10611059

1062-
// message_delta.usage is cumulative per the Anthropic API spec, so we
1063-
// backfill input_tokens and cache counters here rather than in
1064-
// message_start, since OpenAI doesn't report prompt token usage until now.
1060+
// message_delta.usage is cumulative per the Anthropic API spec, so we backfill input_tokens
1061+
// here rather than in message_start, since OpenAI doesn't report prompt token usage until now.
10651062
msgDeltaPayload := sseMessageDelta{
10661063
Type: "message_delta",
10671064
Delta: sseMessageDeltaBody{StopReason: stopReason, StopSequence: nil},
10681065
Usage: sseOutputUsage{
1069-
InputTokens: s.inputTokens,
1070-
OutputTokens: s.outputTokens,
1071-
CacheCreationInputTokens: s.cacheCreationTokens,
1072-
CacheReadInputTokens: s.cacheReadTokens,
1066+
InputTokens: s.inputTokens,
1067+
OutputTokens: s.outputTokens,
10731068
},
10741069
}
10751070
data, err := json.Marshal(msgDeltaPayload)

internal/translator/openai_helper_test.go

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -612,15 +612,16 @@ func TestOpenAIStreamToAnthropicState_ProcessBuffer_TextStreaming(t *testing.T)
612612
require.JSONEq(t, `{"type":"content_block_stop","index":0}`, events[4].data)
613613

614614
assert.Equal(t, "message_delta", events[5].eventType)
615-
require.JSONEq(t, `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`, events[5].data)
615+
require.JSONEq(t, `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":5}}`, events[5].data)
616616

617617
assert.Equal(t, "message_stop", events[6].eventType)
618618
require.JSONEq(t, `{"type":"message_stop"}`, events[6].data)
619619
}
620620

621+
// TestOpenAIStreamToAnthropicState_ProcessBuffer_CachedTokens verifies that OpenAI's
622+
// prompt_tokens_details breakdown is not forwarded into Anthropic's additive cache usage
623+
// fields (see the comment in handleChunk for why).
621624
func TestOpenAIStreamToAnthropicState_ProcessBuffer_CachedTokens(t *testing.T) {
622-
// Verify that OpenAI's prompt_tokens_details (cached_tokens / cache_creation_input_tokens)
623-
// is forwarded into the Anthropic message_delta.usage cache fields, not hardcoded to zero.
624625
state := &openAIStreamToAnthropicState{
625626
activeTools: make(map[int64]*streamToolCall),
626627
requestModel: "claude-3",
@@ -647,15 +648,19 @@ func TestOpenAIStreamToAnthropicState_ProcessBuffer_CachedTokens(t *testing.T) {
647648
}
648649
}
649650
require.NotEmpty(t, msgDeltaData)
650-
require.JSONEq(t, `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":100,"output_tokens":5,"cache_creation_input_tokens":20,"cache_read_input_tokens":80}}`, msgDeltaData)
651+
require.JSONEq(t, `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":100,"output_tokens":5}}`, msgDeltaData)
651652

652-
// Also verify the gateway's internal cost-tracking TokenUsage picks up the real cache counts.
653+
// The gateway's internal cost-tracking TokenUsage must likewise report the plain
654+
// prompt_tokens total without adding the cached breakdown on top of it.
655+
total, ok := state.tokenUsage.InputTokens()
656+
require.True(t, ok)
657+
assert.Equal(t, uint32(100), total)
653658
cached, ok := state.tokenUsage.CachedInputTokens()
654659
require.True(t, ok)
655-
assert.Equal(t, uint32(80), cached)
660+
assert.Equal(t, uint32(0), cached)
656661
cacheCreation, ok := state.tokenUsage.CacheCreationInputTokens()
657662
require.True(t, ok)
658-
assert.Equal(t, uint32(20), cacheCreation)
663+
assert.Equal(t, uint32(0), cacheCreation)
659664
}
660665

661666
func TestOpenAIStreamToAnthropicState_ProcessBuffer_ToolCallStreaming(t *testing.T) {
@@ -696,7 +701,7 @@ func TestOpenAIStreamToAnthropicState_ProcessBuffer_ToolCallStreaming(t *testing
696701
require.JSONEq(t, `{"type":"content_block_stop","index":0}`, events[3].data)
697702

698703
assert.Equal(t, "message_delta", events[4].eventType)
699-
require.JSONEq(t, `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":15,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`, events[4].data)
704+
require.JSONEq(t, `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":15,"output_tokens":10}}`, events[4].data)
700705

701706
assert.Equal(t, "message_stop", events[5].eventType)
702707
require.JSONEq(t, `{"type":"message_stop"}`, events[5].data)
@@ -733,7 +738,7 @@ func TestOpenAIStreamToAnthropicState_ProcessBuffer_EndOfStreamClosing(t *testin
733738
}
734739
}
735740
require.NotEmpty(t, msgDeltaData)
736-
require.JSONEq(t, `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`, msgDeltaData)
741+
require.JSONEq(t, `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`, msgDeltaData)
737742
}
738743

739744
func TestOpenAIStreamToAnthropicState_ProcessBuffer_EmptyInput(t *testing.T) {

tests/data-plane/testupstream_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,7 +1342,7 @@ event: content_block_stop
13421342
data: {"type":"content_block_stop","index":0}
13431343
13441344
event: message_delta
1345-
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}
1345+
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":3}}
13461346
13471347
event: message_stop
13481348
data: {"type":"message_stop"}`,
@@ -1379,7 +1379,7 @@ event: content_block_stop
13791379
data: {"type":"content_block_stop","index":0}
13801380
13811381
event: message_delta
1382-
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":50,"output_tokens":15,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}
1382+
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":50,"output_tokens":15}}
13831383
13841384
event: message_stop
13851385
data: {"type":"message_stop"}`,

0 commit comments

Comments
 (0)