mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user