From 7b51271a849028c739f3fc918dd8e2ed866b6b75 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 15 Jul 2026 11:44:47 +0800 Subject: [PATCH] fix(apicompat): default stop_reason for empty-choices responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial re-verification found one more divergence from the double-conversion chain: an upstream 200 with empty choices (or a nil response) left stop_reason as an empty string, while the old chain reports end_turn. Derive the fallback from the content blocks — the guard never fires when choices exist, since every finish_reason maps to a non-empty stop_reason. Also strengthen the equivalence tests: compare tool_use Content[].Input and tool_call Function.Arguments across bridges, and add an empty-choices stop_reason parity case. --- .../chatcompletions_anthropic_bridge.go | 6 ++++++ .../chatcompletions_anthropic_bridge_test.go | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index 1890cccf44..3713ccb782 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -402,6 +402,12 @@ func ChatCompletionsResponseToAnthropic(resp *ChatCompletionsResponse, model str if len(out.Content) == 0 { out.Content = []AnthropicContentBlock{{Type: "text", Text: ""}} } + // 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) + } // The double-conversion path generates a response id when the upstream // omits one (ChatCompletionsResponseToResponses); clients treat it as required. if out.ID == "" { diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index bd850aa3ab..7d769d0bb5 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -398,6 +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") } func TestChatCompletionsResponseToAnthropic_CacheTokens(t *testing.T) { @@ -432,6 +433,8 @@ 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.NotEmpty(t, out.ID) } // --------------------------------------------------------------------------- @@ -684,6 +687,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion(t *testing.T) { require.Equal(t, double.Content[i].Thinking, direct.Content[i].Thinking, "block %d thinking mismatch", i) require.Equal(t, double.Content[i].Name, direct.Content[i].Name, "block %d name mismatch", i) require.Equal(t, double.Content[i].ID, direct.Content[i].ID, "block %d id mismatch", i) + require.Equal(t, string(double.Content[i].Input), string(direct.Content[i].Input), "block %d input mismatch", i) } require.Equal(t, double.Usage.InputTokens, direct.Usage.InputTokens) require.Equal(t, double.Usage.OutputTokens, direct.Usage.OutputTokens) @@ -744,6 +748,7 @@ func TestDirectBridge_RequestMatchesDoubleConversion(t *testing.T) { for j := range direct.Messages[i].ToolCalls { require.Equal(t, double.Messages[i].ToolCalls[j].ID, direct.Messages[i].ToolCalls[j].ID, "msg %d tool %d id mismatch", i, j) require.Equal(t, double.Messages[i].ToolCalls[j].Function.Name, direct.Messages[i].ToolCalls[j].Function.Name, "msg %d tool %d name mismatch", i, j) + require.Equal(t, double.Messages[i].ToolCalls[j].Function.Arguments, direct.Messages[i].ToolCalls[j].Function.Arguments, "msg %d tool %d arguments mismatch", i, j) } } } @@ -1046,6 +1051,20 @@ func TestAnthropicToChatCompletionsRequest_ToolChoiceUndeclaredDropped(t *testin require.JSONEq(t, `{"type":"function","function":{"name":"get_weather"}}`, string(out.ToolChoice)) } +func TestDirectBridge_NonStreamingMatchesDoubleConversion_EmptyChoices(t *testing.T) { + // An upstream 200 with empty choices must still report a valid stop_reason, + // matching the double-conversion chain ("end_turn"). + resp := &ChatCompletionsResponse{ID: "chatcmpl-empty", Model: "deepseek-v4-pro"} + + direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") + + 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) +} + func TestChatCompletionsResponseToAnthropic_ContentFilterWithToolUse(t *testing.T) { // content_filter (and unknown finish reasons) derive stop_reason from the // blocks, like the double-conversion path.