mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
Merge pull request #4589 from huangjunsen0406/fix/anthropic-message-start-stop-reason-null
fix(apicompat): emit null stop_reason on Anthropic message_start
This commit is contained in:
@@ -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":""`)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestAnthropicToResponsesResponse_CacheCreation(t *testing.T) {
|
||||
CacheReadInputTokens: 4,
|
||||
CacheCreationInputTokens: 6,
|
||||
},
|
||||
StopReason: "end_turn",
|
||||
StopReason: AnthropicStopReasonPtr("end_turn"),
|
||||
}
|
||||
|
||||
out := AnthropicToResponsesResponse(&resp)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user