From 4d4a0be1ad2a994bcf8ca175444a9eb2facb28b4 Mon Sep 17 00:00:00 2001 From: Pengap Date: Mon, 10 Aug 2026 11:29:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(apicompat):=20chat/completions=20file=20par?= =?UTF-8?q?t=20=E4=B8=8D=E5=86=8D=E8=A2=AB=E9=9D=99=E9=BB=98=E4=B8=A2?= =?UTF-8?q?=E5=BC=83=EF=BC=8C=E8=BD=AC=E6=8D=A2=E4=B8=BA=20Responses=20inp?= =?UTF-8?q?ut=5Ffile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /v1/chat/completions → Responses 转换层此前只处理 text 和 image_url 两种 content part,type:"file"(PDF 附件)被静默丢弃:请求返回 200、模型正常 回答,但 prompt 里没有文件(prompt_tokens 只剩纯文字)。 现将 file part 映射为 Responses API 的 input_file(filename/file_data/ file_id 透传),与同网关 /v1/responses + input_file 实测可用的格式一致。 无 file_data 且无 file_id 的空 file part 跳过,与空 image URL 行为一致。 参考上游 PR #2497(因混入无关改动未合并)。 --- .../chatcompletions_responses_test.go | 73 +++++++++++++++++++ .../apicompat/chatcompletions_to_responses.go | 9 +++ backend/internal/pkg/apicompat/types.go | 17 ++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 4e319f9751..7a1584b529 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -407,6 +407,79 @@ func TestChatCompletionsToResponses_WhitespaceOnlyBase64ImageURLSkipped(t *testi assert.Equal(t, "Describe this", parts[0].Text) } +func TestChatCompletionsToResponses_FilePartFileData(t *testing.T) { + content := `[{"type":"text","text":"Summarize the attached document"},{"type":"file","file":{"filename":"document.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQ="}}]` + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(content)}, + }, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 2) + assert.Equal(t, "input_text", parts[0].Type) + assert.Equal(t, "Summarize the attached document", parts[0].Text) + assert.Equal(t, "input_file", parts[1].Type) + assert.Equal(t, "document.pdf", parts[1].Filename) + assert.Equal(t, "data:application/pdf;base64,JVBERi0xLjQ=", parts[1].FileData) + assert.Empty(t, parts[1].FileID) +} + +func TestChatCompletionsToResponses_FilePartFileID(t *testing.T) { + content := `[{"type":"file","file":{"file_id":"file-abc123"}}]` + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(content)}, + }, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "input_file", parts[0].Type) + assert.Equal(t, "file-abc123", parts[0].FileID) + assert.Empty(t, parts[0].FileData) +} + +func TestChatCompletionsToResponses_EmptyFilePartSkipped(t *testing.T) { + // A file part with neither file_data nor file_id carries nothing the + // Responses API can use; dropping it (like empty image URLs) avoids an + // upstream 400 on an empty input_file part. + content := `[{"type":"text","text":"Describe this"},{"type":"file","file":{"filename":"empty.pdf"}}]` + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(content)}, + }, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "input_text", parts[0].Type) +} + 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 diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 0f65d217e8..de908c86a1 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -378,6 +378,15 @@ func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesCont ImageURL: p.ImageURL.URL, }) } + case "file": + if p.File != nil && (p.File.FileData != "" || p.File.FileID != "") { + responseParts = append(responseParts, ResponsesContentPart{ + Type: "input_file", + Filename: p.File.Filename, + FileData: p.File.FileData, + FileID: p.File.FileID, + }) + } } } return responseParts diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index c4cde405e1..52d03876cb 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -295,9 +295,14 @@ func (i *ResponsesInputItem) UnmarshalJSON(data []byte) error { // ResponsesContentPart is a typed content part in a Responses message. type ResponsesContentPart struct { - Type string `json:"type"` // "input_text" | "output_text" | "input_image" + Type string `json:"type"` // "input_text" | "output_text" | "input_image" | "input_file" Text string `json:"text,omitempty"` ImageURL string `json:"image_url,omitempty"` // data URI for input_image + + // input_file fields. + Filename string `json:"filename,omitempty"` + FileData string `json:"file_data,omitempty"` // data URI + FileID string `json:"file_id,omitempty"` } // ResponsesTool describes a tool in the Responses API. @@ -661,9 +666,10 @@ type ChatMessage struct { // ChatContentPart is a typed content part in a multi-modal message. type ChatContentPart struct { - Type string `json:"type"` // "text" | "image_url" + Type string `json:"type"` // "text" | "image_url" | "file" Text string `json:"text,omitempty"` ImageURL *ChatImageURL `json:"image_url,omitempty"` + File *ChatFile `json:"file,omitempty"` } // ChatImageURL contains the URL for an image content part. @@ -672,6 +678,13 @@ type ChatImageURL struct { Detail string `json:"detail,omitempty"` // "auto" | "low" | "high" } +// ChatFile contains the payload of a "file" content part (e.g. PDF input). +type ChatFile struct { + Filename string `json:"filename,omitempty"` + FileData string `json:"file_data,omitempty"` // data URI + FileID string `json:"file_id,omitempty"` +} + // ChatTool describes a tool available to the model. type ChatTool struct { Type string `json:"type"` // "function"