From 6c6473f8998d3c8d3e79ea138bb431a3ca842027 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 30 Aug 2026 13:47:18 +0800 Subject: [PATCH] fix(openai): ignore empty tool calls array in responses translation - Ensure the `tool_calls` array is non-empty before stopping reasoning and terminating output items. Closes: #5333 --- .../openai_openai-responses_response.go | 2 +- .../openai_openai-responses_response_test.go | 76 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index 540d0892..e1ccfe12 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -725,7 +725,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } // tool calls - if tcs := delta.Get("tool_calls"); tcs.Exists() && tcs.IsArray() { + if tcs := delta.Get("tool_calls"); tcs.Exists() && tcs.IsArray() && len(tcs.Array()) > 0 { if st.ReasoningID != "" { stopReasoning(st.ReasoningBuf.String()) st.ReasoningBuf.Reset() diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go index 68c74e9a..b1008c37 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go @@ -1349,3 +1349,79 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_Reasonin }) } } + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_EmptyToolCallsArrayDoesNotTerminateItems(t *testing.T) { + t.Parallel() + + request := []byte(`{"model":"codebuddy-hy4"}`) + chunks := []string{ + `data: {"id":"chatcmpl_empty_tc","object":"chat.completion.chunk","created":1773896263,"model":"codebuddy-hy4","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"Thinking part 1, ","function_call":null,"refusal":"","tool_calls":[]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_empty_tc","object":"chat.completion.chunk","created":1773896263,"model":"codebuddy-hy4","choices":[{"index":0,"delta":{"content":"","reasoning_content":"thinking part 2.","function_call":null,"refusal":"","tool_calls":[]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_empty_tc","object":"chat.completion.chunk","created":1773896263,"model":"codebuddy-hy4","choices":[{"index":0,"delta":{"content":"Hello ","reasoning_content":"","function_call":null,"refusal":"","tool_calls":[]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_empty_tc","object":"chat.completion.chunk","created":1773896263,"model":"codebuddy-hy4","choices":[{"index":0,"delta":{"content":"world!","reasoning_content":"","function_call":null,"refusal":"","tool_calls":[]},"finish_reason":"stop"}]}`, + `data: [DONE]`, + } + + var param any + var reasoningAddedCount, reasoningDoneCount int + var messageAddedCount, messageDoneCount int + var completedCount int + var lastReasoningSummaryText string + var lastMessageContentText string + var completedData gjson.Result + + for _, line := range chunks { + for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "codebuddy-hy4", request, request, []byte(line), ¶m) { + event, data := parseOpenAIResponsesSSEEvent(t, chunk) + switch event { + case "response.output_item.added": + itemType := data.Get("item.type").String() + if itemType == "reasoning" { + reasoningAddedCount++ + } else if itemType == "message" { + messageAddedCount++ + } + case "response.output_item.done": + itemType := data.Get("item.type").String() + if itemType == "reasoning" { + reasoningDoneCount++ + lastReasoningSummaryText = data.Get("item.summary.0.text").String() + } else if itemType == "message" { + messageDoneCount++ + lastMessageContentText = data.Get("item.content.0.text").String() + } + case "response.completed": + completedCount++ + completedData = data + } + } + } + + if reasoningAddedCount != 1 { + t.Fatalf("expected exactly 1 reasoning output_item.added, got %d", reasoningAddedCount) + } + if reasoningDoneCount != 1 { + t.Fatalf("expected exactly 1 reasoning output_item.done, got %d", reasoningDoneCount) + } + if lastReasoningSummaryText != "Thinking part 1, thinking part 2." { + t.Fatalf("unexpected reasoning summary text: got %q, want %q", lastReasoningSummaryText, "Thinking part 1, thinking part 2.") + } + if messageAddedCount != 1 { + t.Fatalf("expected exactly 1 message output_item.added, got %d", messageAddedCount) + } + if messageDoneCount != 1 { + t.Fatalf("expected exactly 1 message output_item.done, got %d", messageDoneCount) + } + if lastMessageContentText != "Hello world!" { + t.Fatalf("unexpected message content text: got %q, want %q", lastMessageContentText, "Hello world!") + } + if completedCount != 1 { + t.Fatalf("expected exactly 1 response.completed, got %d", completedCount) + } + if got := completedData.Get("response.output.0.summary.0.text").String(); got != "Thinking part 1, thinking part 2." { + t.Fatalf("unexpected completed response reasoning summary: got %q, want %q", got, "Thinking part 1, thinking part 2.") + } + if got := completedData.Get("response.output.1.content.0.text").String(); got != "Hello world!" { + t.Fatalf("unexpected completed response message text: got %q, want %q", got, "Hello world!") + } +} -- 2.54.0