fix(apicompat): chat/completions file part 不再被静默丢弃,转换为 Responses input_file

/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(因混入无关改动未合并)。
This commit is contained in:
Pengap
2026-08-10 13:59:20 +08:00
parent 10a4c6e3ad
commit 4d4a0be1ad
3 changed files with 97 additions and 2 deletions
@@ -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
@@ -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
+15 -2
View File
@@ -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"