From ba2b0044e360a9f625ad275242ab59de76e2378a Mon Sep 17 00:00:00 2001 From: huangjunsen <951434130@qq.com> Date: Sun, 19 Jul 2026 13:05:24 +0800 Subject: [PATCH] fix(apicompat): emit null stop_reason on Anthropic message_start Use *string for AnthropicResponse.StopReason so streaming message_start serializes as JSON null instead of "", matching official Anthropic wire format and avoiding strict client turn-finalization failures. --- .../pkg/apicompat/anthropic_responses_test.go | 36 +++++++++++++++---- .../anthropic_to_responses_response.go | 2 +- .../chatcompletions_anthropic_bridge.go | 21 +++++------ .../chatcompletions_anthropic_bridge_test.go | 18 +++++----- ...responses_anthropic_cache_creation_test.go | 2 +- .../pkg/apicompat/responses_to_anthropic.go | 17 +++++---- backend/internal/pkg/apicompat/types.go | 19 +++++++++- .../gateway_forward_as_chat_completions.go | 2 +- .../service/gateway_forward_as_responses.go | 2 +- .../gemini_chat_completions_compat_service.go | 13 +++---- 10 files changed, 89 insertions(+), 43 deletions(-) diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index db6b49aa9b..c13e9985a6 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -208,7 +208,7 @@ func TestResponsesToAnthropic_TextOnly(t *testing.T) { anth := ResponsesToAnthropic(resp, "claude-opus-4-6") assert.Equal(t, "resp_123", anth.ID) assert.Equal(t, "claude-opus-4-6", anth.Model) - assert.Equal(t, "end_turn", anth.StopReason) + assert.Equal(t, "end_turn", AnthropicStopReasonString(anth.StopReason)) require.Len(t, anth.Content, 1) assert.Equal(t, "text", anth.Content[0].Type) assert.Equal(t, "Hello there!", anth.Content[0].Text) @@ -287,7 +287,7 @@ func TestResponsesToAnthropic_ToolUse(t *testing.T) { } anth := ResponsesToAnthropic(resp, "claude-opus-4-6") - assert.Equal(t, "tool_use", anth.StopReason) + assert.Equal(t, "tool_use", AnthropicStopReasonString(anth.StopReason)) require.Len(t, anth.Content, 2) assert.Equal(t, "text", anth.Content[0].Type) assert.Equal(t, "tool_use", anth.Content[1].Type) @@ -318,7 +318,7 @@ func TestResponsesToAnthropic_ToolUseStopReasonDoesNotDependOnLastBlock(t *testi } anth := ResponsesToAnthropic(resp, "claude-opus-4-6") - assert.Equal(t, "tool_use", anth.StopReason) + assert.Equal(t, "tool_use", AnthropicStopReasonString(anth.StopReason)) require.Len(t, anth.Content, 2) assert.Equal(t, "tool_use", anth.Content[0].Type) assert.Equal(t, "text", anth.Content[1].Type) @@ -411,7 +411,7 @@ func TestResponsesToAnthropic_Incomplete(t *testing.T) { } anth := ResponsesToAnthropic(resp, "claude-opus-4-6") - assert.Equal(t, "max_tokens", anth.StopReason) + assert.Equal(t, "max_tokens", AnthropicStopReasonString(anth.StopReason)) } func TestResponsesToAnthropic_EmptyOutput(t *testing.T) { @@ -995,7 +995,7 @@ func TestResponsesToAnthropic_Failed(t *testing.T) { anth := ResponsesToAnthropic(resp, "claude-opus-4-6") // Failed status defaults to "end_turn" stop reason - assert.Equal(t, "end_turn", anth.StopReason) + assert.Equal(t, "end_turn", AnthropicStopReasonString(anth.StopReason)) // Should have at least an empty text block require.Len(t, anth.Content, 1) assert.Equal(t, "text", anth.Content[0].Type) @@ -1609,7 +1609,7 @@ func TestAnthropicToResponsesResponse_CacheTokensUseOpenAIInputSemantics(t *test Content: []AnthropicContentBlock{ {Type: "text", Text: "ok"}, }, - StopReason: "end_turn", + StopReason: AnthropicStopReasonPtr("end_turn"), Usage: AnthropicUsage{ InputTokens: 3318, OutputTokens: 123, @@ -1635,7 +1635,7 @@ func TestAnthropicToResponsesResponse_NoCacheTokens(t *testing.T) { Content: []AnthropicContentBlock{ {Type: "text", Text: "ok"}, }, - StopReason: "end_turn", + StopReason: AnthropicStopReasonPtr("end_turn"), Usage: AnthropicUsage{ InputTokens: 100, OutputTokens: 50, @@ -1732,3 +1732,25 @@ func TestAnthropicEventToResponses_CacheTokensFromMessageDelta(t *testing.T) { require.NotNil(t, completed.Response.Usage.InputTokensDetails) assert.Equal(t, 11, completed.Response.Usage.InputTokensDetails.CachedTokens) } + +func TestMessageStartSSE_StopReasonIsJSONNull(t *testing.T) { + state := NewResponsesEventToAnthropicState() + state.Model = "grok-4.5" + events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.created", + Response: &ResponsesResponse{ + ID: "resp_test", + Model: "grok-4.5", + }, + }, state) + require.Len(t, events, 1) + require.Equal(t, "message_start", events[0].Type) + require.NotNil(t, events[0].Message) + require.Nil(t, events[0].Message.StopReason, "message_start must use null stop_reason, not empty string") + + sse, err := ResponsesAnthropicEventToSSE(events[0]) + require.NoError(t, err) + // Official Anthropic wire: "stop_reason":null + require.Contains(t, sse, `"stop_reason":null`) + require.NotContains(t, sse, `"stop_reason":""`) +} diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index b222158363..db0343cd73 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -89,7 +89,7 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { out.Output = outputs // Map stop_reason → status - out.Status = anthropicStopReasonToResponsesStatus(resp.StopReason, resp.Content) + out.Status = anthropicStopReasonToResponsesStatus(AnthropicStopReasonString(resp.StopReason), resp.Content) if out.Status == "incomplete" { out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"} } diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index 3713ccb782..e4fa5541c1 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -390,7 +390,7 @@ func ChatCompletionsResponseToAnthropic(resp *ChatCompletionsResponse, model str if len(resp.Choices) > 0 { choice := resp.Choices[0] out.Content = chatMessageToAnthropicBlocks(choice.Message) - out.StopReason = chatFinishReasonToAnthropicStopReason(choice.FinishReason, out.Content) + out.StopReason = AnthropicStopReasonPtr(chatFinishReasonToAnthropicStopReason(choice.FinishReason, out.Content)) // "length" → "max_tokens" is handled by chatFinishReasonToAnthropicStopReason; // Anthropic conveys max-tokens via stop_reason only, no incomplete_details field. } @@ -404,9 +404,9 @@ func ChatCompletionsResponseToAnthropic(resp *ChatCompletionsResponse, model str } // Empty choices / nil response never enter the choices branch above; the // double-conversion path still reports a completed turn ("end_turn"), and - // stop_reason "" is invalid for strict Anthropic clients. - if out.StopReason == "" { - out.StopReason = chatFinishReasonToAnthropicStopReason("", out.Content) + // stop_reason null/"" is invalid for completed non-stream responses. + if AnthropicStopReasonString(out.StopReason) == "" { + out.StopReason = AnthropicStopReasonPtr(chatFinishReasonToAnthropicStopReason("", out.Content)) } // The double-conversion path generates a response id when the upstream // omits one (ChatCompletionsResponseToResponses); clients treat it as required. @@ -701,12 +701,13 @@ func ensureCCAnthropicMessageStart(state *ChatCompletionsToAnthropicStreamState) return []AnthropicStreamEvent{{ Type: "message_start", Message: &AnthropicResponse{ - ID: state.ResponseID, - Type: "message", - Role: "assistant", - Content: []AnthropicContentBlock{}, - Model: state.Model, - Usage: AnthropicUsage{InputTokens: 0, OutputTokens: 0}, + ID: state.ResponseID, + Type: "message", + Role: "assistant", + Content: []AnthropicContentBlock{}, + Model: state.Model, + StopReason: nil, // JSON null; never "" + Usage: AnthropicUsage{InputTokens: 0, OutputTokens: 0}, }, }} } diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index 7d769d0bb5..00726f5ef7 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -314,7 +314,7 @@ func TestChatCompletionsResponseToAnthropic_TextOnly(t *testing.T) { require.Len(t, out.Content, 1) require.Equal(t, "text", out.Content[0].Type) require.Equal(t, "hello world", out.Content[0].Text) - require.Equal(t, "end_turn", out.StopReason) + require.Equal(t, "end_turn", AnthropicStopReasonString(out.StopReason)) require.Equal(t, 5, out.Usage.InputTokens) require.Equal(t, 2, out.Usage.OutputTokens) } @@ -346,7 +346,7 @@ func TestChatCompletionsResponseToAnthropic_ToolUse(t *testing.T) { require.Equal(t, "call_1", out.Content[0].ID) require.Equal(t, "get_weather", out.Content[0].Name) require.Equal(t, `{"city":"SF"}`, string(out.Content[0].Input)) - require.Equal(t, "tool_use", out.StopReason) + require.Equal(t, "tool_use", AnthropicStopReasonString(out.StopReason)) } func TestChatCompletionsResponseToAnthropic_ReasoningOnlyFallback(t *testing.T) { @@ -384,7 +384,7 @@ func TestChatCompletionsResponseToAnthropic_FinishReasonLength(t *testing.T) { } out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") - require.Equal(t, "max_tokens", out.StopReason) + require.Equal(t, "max_tokens", AnthropicStopReasonString(out.StopReason)) } func TestChatCompletionsResponseToAnthropic_EmptyChoices(t *testing.T) { @@ -398,7 +398,7 @@ func TestChatCompletionsResponseToAnthropic_EmptyChoices(t *testing.T) { require.Len(t, out.Content, 1) require.Equal(t, "text", out.Content[0].Type) require.Equal(t, "", out.Content[0].Text) - require.Equal(t, "end_turn", out.StopReason, "empty choices must not produce an empty stop_reason") + require.Equal(t, "end_turn", AnthropicStopReasonString(out.StopReason), "empty choices must not produce an empty stop_reason") } func TestChatCompletionsResponseToAnthropic_CacheTokens(t *testing.T) { @@ -433,7 +433,7 @@ func TestChatCompletionsResponseToAnthropic_NilResponse(t *testing.T) { out := ChatCompletionsResponseToAnthropic(nil, "claude-sonnet-4-20250514") require.Len(t, out.Content, 1) require.Equal(t, "text", out.Content[0].Type) - require.Equal(t, "end_turn", out.StopReason, "nil response must not produce an empty stop_reason") + require.Equal(t, "end_turn", AnthropicStopReasonString(out.StopReason), "nil response must not produce an empty stop_reason") require.NotEmpty(t, out.ID) } @@ -678,7 +678,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion(t *testing.T) { double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") // Compare key fields - require.Equal(t, direct.StopReason, double.StopReason) + require.Equal(t, AnthropicStopReasonString(direct.StopReason), AnthropicStopReasonString(double.StopReason)) require.Equal(t, direct.Model, double.Model) require.Len(t, direct.Content, len(double.Content)) for i := range direct.Content { @@ -1061,8 +1061,8 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion_EmptyChoices(t *testin responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") - require.Equal(t, double.StopReason, direct.StopReason) - require.Equal(t, "end_turn", direct.StopReason) + require.Equal(t, AnthropicStopReasonString(double.StopReason), AnthropicStopReasonString(direct.StopReason)) + require.Equal(t, "end_turn", AnthropicStopReasonString(direct.StopReason)) } func TestChatCompletionsResponseToAnthropic_ContentFilterWithToolUse(t *testing.T) { @@ -1086,5 +1086,5 @@ func TestChatCompletionsResponseToAnthropic_ContentFilterWithToolUse(t *testing. } out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") - require.Equal(t, "tool_use", out.StopReason) + require.Equal(t, "tool_use", AnthropicStopReasonString(out.StopReason)) } diff --git a/backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go b/backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go index b8c7916d2d..aa5ecbe5ba 100644 --- a/backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go +++ b/backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go @@ -88,7 +88,7 @@ func TestAnthropicToResponsesResponse_CacheCreation(t *testing.T) { CacheReadInputTokens: 4, CacheCreationInputTokens: 6, }, - StopReason: "end_turn", + StopReason: AnthropicStopReasonPtr("end_turn"), } out := AnthropicToResponsesResponse(&resp) diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic.go b/backend/internal/pkg/apicompat/responses_to_anthropic.go index 455dd1a444..20b8ac0292 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic.go @@ -81,7 +81,7 @@ func ResponsesToAnthropic(resp *ResponsesResponse, model string) *AnthropicRespo } out.Content = blocks - out.StopReason = responsesStatusToAnthropicStopReason(resp.Status, resp.IncompleteDetails, blocks) + out.StopReason = AnthropicStopReasonPtr(responsesStatusToAnthropicStopReason(resp.Status, resp.IncompleteDetails, blocks)) if resp.Usage != nil { out.Usage = anthropicUsageFromResponsesUsage(resp.Usage) @@ -297,14 +297,19 @@ func resToAnthHandleCreated(evt *ResponsesStreamEvent, state *ResponsesEventToAn } state.MessageStartSent = true + // Official Anthropic message_start uses stop_reason: null and usage with + // input_tokens when known. We leave StopReason nil (JSON null) and usage + // zeros until response.completed; never emit stop_reason:"" which breaks + // strict clients' turn-finalization / session usage accounting. return []AnthropicStreamEvent{{ Type: "message_start", Message: &AnthropicResponse{ - ID: state.ResponseID, - Type: "message", - Role: "assistant", - Content: []AnthropicContentBlock{}, - Model: state.Model, + ID: state.ResponseID, + Type: "message", + Role: "assistant", + Content: []AnthropicContentBlock{}, + Model: state.Model, + StopReason: nil, Usage: AnthropicUsage{ InputTokens: 0, OutputTokens: 0, diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 6cf9a2be31..54b2adf3de 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -121,17 +121,34 @@ type AnthropicCacheControl struct { } // AnthropicResponse is the non-streaming response from POST /v1/messages. +// +// StopReason is a pointer so streaming message_start can emit JSON null +// (official Anthropic wire format). A plain string zero-value would marshal as +// "" which strict clients treat as invalid mid-stream state. type AnthropicResponse struct { ID string `json:"id"` Type string `json:"type"` // "message" Role string `json:"role"` // "assistant" Content []AnthropicContentBlock `json:"content"` Model string `json:"model"` - StopReason string `json:"stop_reason"` + StopReason *string `json:"stop_reason"` StopSequence *string `json:"stop_sequence,omitempty"` Usage AnthropicUsage `json:"usage"` } +// AnthropicStopReasonPtr returns a non-nil pointer to s for final stop reasons. +func AnthropicStopReasonPtr(s string) *string { + return &s +} + +// AnthropicStopReasonString returns the stop reason value, or "" when unset/null. +func AnthropicStopReasonString(p *string) string { + if p == nil { + return "" + } + return *p +} + // AnthropicUsage holds token counts in Anthropic format. type AnthropicUsage struct { InputTokens int `json:"input_tokens"` diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 5e6d562768..5a542ae3a1 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -270,7 +270,7 @@ func (s *GatewayService) handleCCBufferedFromAnthropic( mergeAnthropicUsage(&usage, *event.Usage) } if event.Delta != nil && event.Delta.StopReason != "" && finalResp != nil { - finalResp.StopReason = event.Delta.StopReason + finalResp.StopReason = apicompat.AnthropicStopReasonPtr(event.Delta.StopReason) } } if event.Type == "content_block_start" && event.ContentBlock != nil && finalResp != nil { diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 4b0314493e..774c903a5f 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -285,7 +285,7 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( mergeAnthropicUsage(&usage, *event.Usage) } if event.Delta != nil && event.Delta.StopReason != "" && finalResp != nil { - finalResp.StopReason = event.Delta.StopReason + finalResp.StopReason = apicompat.AnthropicStopReasonPtr(event.Delta.StopReason) } } diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index 7db000d265..de996649b8 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -556,12 +556,13 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom if emitAnthropicEvent(&apicompat.AnthropicStreamEvent{ Type: "message_start", Message: &apicompat.AnthropicResponse{ - ID: messageID, - Type: "message", - Role: "assistant", - Model: originalModel, - Content: []apicompat.AnthropicContentBlock{}, - Usage: apicompat.AnthropicUsage{}, + ID: messageID, + Type: "message", + Role: "assistant", + Model: originalModel, + Content: []apicompat.AnthropicContentBlock{}, + StopReason: nil, // JSON null + Usage: apicompat.AnthropicUsage{}, }, }) { return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil