From df82a3bc694a1b2c9511fb8066f9bc9fcbb23b1c Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sun, 17 May 2026 11:20:05 +0800 Subject: [PATCH] fix(openai): avoid null content when converting chat-completions to responses When a chat-completions message has no usable content parts (empty array, empty text part, or filtered-out image part), marshalChatInputContent marshalled a nil slice to JSON null. The upstream Responses API rejects a null content field with HTTP 400. Fall back to an empty string instead. Fixes #2515 --- .../chatcompletions_responses_test.go | 35 +++++++++++++++++++ .../apicompat/chatcompletions_to_responses.go | 9 ++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index bf5c23d5..484d4246 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -225,6 +225,41 @@ func TestChatCompletionsToResponses_WhitespaceOnlyBase64ImageURLSkipped(t *testi assert.Equal(t, "Describe this", parts[0].Text) } +func TestChatCompletionsToResponses_EmptyContentNeverNull(t *testing.T) { + // Regression for #2515: the upstream Responses API rejects an input item + // whose content field is JSON null. Any chat-completions message that + // yields no usable content parts must serialize content as a string. + cases := []struct { + name string + content json.RawMessage + }{ + {"null content", json.RawMessage(`null`)}, + {"empty array content", json.RawMessage(`[]`)}, + {"only empty text part", json.RawMessage(`[{"type":"text","text":""}]`)}, + {"only empty base64 image part", json.RawMessage(`[{"type":"image_url","image_url":{"url":"data:image/png;base64,"}}]`)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-5.5", + Messages: []ChatMessage{ + {Role: "user", Content: tc.content}, + }, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + assert.NotContains(t, string(resp.Input), `"content":null`, + "converted input must not contain a null content field") + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + assert.Equal(t, `""`, string(items[0].Content), + "content must be an empty string, not null") + }) + } +} + func TestChatCompletionsToResponses_SystemArrayContent(t *testing.T) { req := &ChatCompletionsRequest{ Model: "gpt-4o", diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 64ef5781..8f00a1ad 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -325,7 +325,14 @@ func marshalChatInputContent(content chatMessageContent) (json.RawMessage, error if content.Text != nil { return json.Marshal(*content.Text) } - return json.Marshal(convertChatContentPartsToResponses(content.Parts)) + parts := convertChatContentPartsToResponses(content.Parts) + if len(parts) == 0 { + // A nil slice marshals to JSON null, which the upstream Responses API + // rejects ("expected an array of objects or string, but got null"). + // Fall back to an empty string when no usable parts remain. + return json.Marshal("") + } + return json.Marshal(parts) } func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesContentPart {