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 6a3c751f13..384b58495b 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 9f2e8388e7..b96d16b911 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. @@ -670,9 +675,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. @@ -681,6 +687,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" | "x_search"