Skip to content

Commit ac60e49

Browse files
anakin87kacperlukawski
authored andcommitted
feat: support FileContent in OpenAIResponsesChatGenerator (#10555)
* FileContent first draft * progress + tests * reduce to_openai_dict complexity * more tests * schema tests + relnote * better err msg * refinement * draft * feat: support FileContent in OpenAIResponsesChatGenerator * lint
1 parent b54f1eb commit ac60e49

4 files changed

Lines changed: 61 additions & 6 deletions

File tree

haystack/components/generators/chat/openai_responses.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
AsyncStreamingCallbackT,
1919
ChatMessage,
2020
ComponentInfo,
21+
FileContent,
2122
ImageContent,
2223
ReasoningContent,
2324
StreamingCallbackT,
@@ -804,7 +805,7 @@ def _convert_chat_message_to_responses_api_format(message: ChatMessage) -> list[
804805
If the message format is invalid.
805806
"""
806807

807-
def serialize_part(part) -> dict[str, str]:
808+
def convert_part(part) -> dict[str, str | None]:
808809
if isinstance(part, TextContent):
809810
return {"type": "input_text", "text": part.text}
810811
elif isinstance(part, ImageContent):
@@ -813,18 +814,25 @@ def serialize_part(part) -> dict[str, str]:
813814
# If no MIME type is provided, default to JPEG. OpenAI API appears to tolerate MIME type mismatches.
814815
"image_url": f"data:{part.mime_type or 'image/jpeg'};base64,{part.base64_image}",
815816
}
817+
elif isinstance(part, FileContent):
818+
return {
819+
"type": "input_file",
820+
"filename": part.filename,
821+
"file_data": f"data:{part.mime_type or 'application/pdf'};base64,{part.base64_data}",
822+
}
816823
raise ValueError(f"Unsupported content type: {type(part)}")
817824

818825
text_contents = message.texts
819826
tool_calls = message.tool_calls
820827
tool_call_results = message.tool_call_results
821828
images = message.images
822829
reasonings = message.reasonings
830+
files = message.files
823831

824-
if not text_contents and not tool_calls and not tool_call_results and not images and not reasonings:
832+
if not any([text_contents, tool_calls, tool_call_results, images, reasonings, files]):
825833
raise ValueError(
826834
"""A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, `ToolCallResult`,
827-
`ImageContent`, or `ReasoningContent`."""
835+
`ImageContent`, `FileContent`, or `ReasoningContent`."""
828836
)
829837
if len(tool_call_results) > 0 and len(message._content) > 1:
830838
raise ValueError(
@@ -838,7 +846,7 @@ def serialize_part(part) -> dict[str, str]:
838846

839847
# user message
840848
if message._role.value == "user":
841-
content = [serialize_part(part) for part in message._content]
849+
content = [convert_part(part) for part in message._content]
842850
openai_msg["content"] = content
843851
return [openai_msg]
844852

@@ -849,7 +857,7 @@ def serialize_part(part) -> dict[str, str]:
849857
if result.origin.id is not None:
850858
# Handle multimodal tool results (list of TextContent/ImageContent)
851859
if isinstance(result.result, list):
852-
output_content = [serialize_part(part) for part in result.result]
860+
output_content = [convert_part(part) for part in result.result]
853861
elif isinstance(result.result, str):
854862
output_content = [{"type": "input_text", "text": result.result}]
855863
else:
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
features:
3+
- |
4+
Added support for Chat Messages that include files using the ``FileContent`` dataclass
5+
to ``OpenAIResponsesChatGenerator`` and ``AzureOpenAIResponsesChatGenerator``.
6+
7+
Users can now pass files such as PDFs when using Haystack Chat Generators based on the Responses API.

test/components/generators/chat/test_openai_responses.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from haystack.components.agents import Agent
1616
from haystack.components.generators.chat.openai_responses import OpenAIResponsesChatGenerator
1717
from haystack.components.generators.utils import print_streaming_chunk
18-
from haystack.dataclasses import ChatMessage, ChatRole, ImageContent, StreamingChunk, TextContent, ToolCall
18+
from haystack.dataclasses import ChatMessage, ChatRole, FileContent, ImageContent, StreamingChunk, TextContent, ToolCall
1919
from haystack.tools import ComponentTool, Tool, Toolset, create_tool_from_function
2020
from haystack.utils import Secret
2121

@@ -768,6 +768,28 @@ def test_live_run_multimodal(self, test_files_path):
768768
assert not message.tool_calls
769769
assert not message.tool_call_results
770770

771+
def test_live_run_with_file_content(self, test_files_path):
772+
pdf_path = test_files_path / "pdf" / "sample_pdf_3.pdf"
773+
774+
file_content = FileContent.from_file_path(file_path=pdf_path)
775+
776+
chat_messages = [
777+
ChatMessage.from_user(
778+
content_parts=[file_content, "Is this document a paper about LLMs? Respond with 'yes' or 'no' only."]
779+
)
780+
]
781+
782+
generator = OpenAIResponsesChatGenerator(model="gpt-4.1-nano")
783+
results = generator.run(chat_messages)
784+
785+
assert len(results["replies"]) == 1
786+
message: ChatMessage = results["replies"][0]
787+
788+
assert message.is_from(ChatRole.ASSISTANT)
789+
790+
assert message.text
791+
assert "no" in message.text.lower()
792+
771793
@pytest.mark.skip(reason="The tool calls time out resulting in failing")
772794
def test_live_run_with_openai_tools(self):
773795
"""

test/components/generators/chat/test_openai_responses_conversion.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from haystack.dataclasses import (
3838
ChatMessage,
3939
ChatRole,
40+
FileContent,
4041
ImageContent,
4142
ReasoningContent,
4243
StreamingChunk,
@@ -1266,6 +1267,23 @@ def test_convert_multimodal_user_message(self, base64_image_string):
12661267
],
12671268
}
12681269

1270+
def test_convert_user_message_with_file_content(self, base64_pdf_string):
1271+
message = ChatMessage.from_user(
1272+
content_parts=[FileContent(base64_data=base64_pdf_string, mime_type="application/pdf", filename="test.pdf")]
1273+
)
1274+
assert _convert_chat_message_to_responses_api_format(message) == [
1275+
{
1276+
"role": "user",
1277+
"content": [
1278+
{
1279+
"type": "input_file",
1280+
"filename": "test.pdf",
1281+
"file_data": f"data:application/pdf;base64,{base64_pdf_string}",
1282+
}
1283+
],
1284+
}
1285+
]
1286+
12691287
def test_convert_assistant_message(self):
12701288
message = ChatMessage.from_assistant(text="I have an answer", meta={"finish_reason": "stop"})
12711289
assert _convert_chat_message_to_responses_api_format(message) == [

0 commit comments

Comments
 (0)