fix(apicompat): default stop_reason for empty-choices responses

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.
This commit is contained in:
shaw
2026-07-15 11:44:47 +08:00
parent 430fd8f20a
commit 7b51271a84
2 changed files with 25 additions and 0 deletions
@@ -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 == "" {
@@ -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.