From b17704d6effc717e5644ad09f61abe9aa2296775 Mon Sep 17 00:00:00 2001 From: deqiying Date: Sun, 26 Apr 2026 01:14:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(anthropic):=20=E4=BF=AE=E6=AD=A3=E7=BC=93?= =?UTF-8?q?=E5=AD=98=20token=20=E7=9A=84=20Anthropic=20=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pkg/apicompat/anthropic_responses_test.go | 79 +++++++++++++++++++ .../pkg/apicompat/responses_to_anthropic.go | 39 ++++++--- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 095305c2..c35b51b6 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -181,6 +181,55 @@ func TestResponsesToAnthropic_TextOnly(t *testing.T) { assert.Equal(t, 5, anth.Usage.OutputTokens) } +func TestResponsesToAnthropic_CachedTokensUseAnthropicInputSemantics(t *testing.T) { + resp := &ResponsesResponse{ + ID: "resp_cached", + Model: "gpt-5.2", + Status: "completed", + Output: []ResponsesOutput{ + { + Type: "message", + Content: []ResponsesContentPart{ + {Type: "output_text", Text: "Cached response"}, + }, + }, + }, + Usage: &ResponsesUsage{ + InputTokens: 54006, + OutputTokens: 123, + TotalTokens: 54129, + InputTokensDetails: &ResponsesInputTokensDetails{ + CachedTokens: 50688, + }, + }, + } + + anth := ResponsesToAnthropic(resp, "claude-sonnet-4-5-20250929") + assert.Equal(t, 3318, anth.Usage.InputTokens) + assert.Equal(t, 50688, anth.Usage.CacheReadInputTokens) + assert.Equal(t, 123, anth.Usage.OutputTokens) +} + +func TestResponsesToAnthropic_CachedTokensClampInputTokens(t *testing.T) { + resp := &ResponsesResponse{ + ID: "resp_cached_clamp", + Model: "gpt-5.2", + Status: "completed", + Usage: &ResponsesUsage{ + InputTokens: 100, + OutputTokens: 5, + InputTokensDetails: &ResponsesInputTokensDetails{ + CachedTokens: 150, + }, + }, + } + + anth := ResponsesToAnthropic(resp, "claude-sonnet-4-5-20250929") + assert.Equal(t, 0, anth.Usage.InputTokens) + assert.Equal(t, 150, anth.Usage.CacheReadInputTokens) + assert.Equal(t, 5, anth.Usage.OutputTokens) +} + func TestResponsesToAnthropic_ToolUse(t *testing.T) { resp := &ResponsesResponse{ ID: "resp_456", @@ -343,6 +392,36 @@ func TestStreamingTextOnly(t *testing.T) { assert.Equal(t, "message_stop", events[1].Type) } +func TestStreamingCachedTokensUseAnthropicInputSemantics(t *testing.T) { + state := NewResponsesEventToAnthropicState() + ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.created", + Response: &ResponsesResponse{ID: "resp_cached_stream", Model: "gpt-5.2"}, + }, state) + + events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.completed", + Response: &ResponsesResponse{ + Status: "completed", + Usage: &ResponsesUsage{ + InputTokens: 54006, + OutputTokens: 123, + TotalTokens: 54129, + InputTokensDetails: &ResponsesInputTokensDetails{ + CachedTokens: 50688, + }, + }, + }, + }, state) + + require.Len(t, events, 2) + assert.Equal(t, "message_delta", events[0].Type) + assert.Equal(t, 3318, events[0].Usage.InputTokens) + assert.Equal(t, 50688, events[0].Usage.CacheReadInputTokens) + assert.Equal(t, 123, events[0].Usage.OutputTokens) + assert.Equal(t, "message_stop", events[1].Type) +} + func TestStreamingToolCall(t *testing.T) { state := NewResponsesEventToAnthropicState() diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic.go b/backend/internal/pkg/apicompat/responses_to_anthropic.go index 5409a0f4..40bed302 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic.go @@ -84,18 +84,34 @@ func ResponsesToAnthropic(resp *ResponsesResponse, model string) *AnthropicRespo out.StopReason = responsesStatusToAnthropicStopReason(resp.Status, resp.IncompleteDetails, blocks) if resp.Usage != nil { - out.Usage = AnthropicUsage{ - InputTokens: resp.Usage.InputTokens, - OutputTokens: resp.Usage.OutputTokens, - } - if resp.Usage.InputTokensDetails != nil { - out.Usage.CacheReadInputTokens = resp.Usage.InputTokensDetails.CachedTokens - } + out.Usage = anthropicUsageFromResponsesUsage(resp.Usage) } return out } +func anthropicUsageFromResponsesUsage(usage *ResponsesUsage) AnthropicUsage { + if usage == nil { + return AnthropicUsage{} + } + + cachedTokens := 0 + if usage.InputTokensDetails != nil { + cachedTokens = usage.InputTokensDetails.CachedTokens + } + + inputTokens := usage.InputTokens - cachedTokens + if inputTokens < 0 { + inputTokens = 0 + } + + return AnthropicUsage{ + InputTokens: inputTokens, + OutputTokens: usage.OutputTokens, + CacheReadInputTokens: cachedTokens, + } +} + func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncompleteDetails, blocks []AnthropicContentBlock) string { switch status { case "incomplete": @@ -466,11 +482,10 @@ func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo stopReason := "end_turn" if evt.Response != nil { if evt.Response.Usage != nil { - state.InputTokens = evt.Response.Usage.InputTokens - state.OutputTokens = evt.Response.Usage.OutputTokens - if evt.Response.Usage.InputTokensDetails != nil { - state.CacheReadInputTokens = evt.Response.Usage.InputTokensDetails.CachedTokens - } + usage := anthropicUsageFromResponsesUsage(evt.Response.Usage) + state.InputTokens = usage.InputTokens + state.OutputTokens = usage.OutputTokens + state.CacheReadInputTokens = usage.CacheReadInputTokens } switch evt.Response.Status { case "incomplete":