Skip to content

Commit 8f2363f

Browse files
committed
Python: Enhance BinaryContent for OpenAI Responses API file handling
- Add can_read property to indicate data availability - Add from_file() class method to create BinaryContent from file paths - Set data_format='base64' in from_file() to prevent Unicode decode errors - Update responses agent to handle BinaryContent with proper OpenAI file format - Use correct OpenAI API structure: filename and file_data with data URI - Keep BinaryContent completely provider-agnostic using generic properties - Generate UUID-based filenames with appropriate mime-type extensions - Add comprehensive tests for new BinaryContent functionality This implementation enhances BinaryContent instead of introducing FileContent, maintaining clean separation between generic content handling and provider-specific logic. Fixes file upload support for OpenAI Responses API.
1 parent 3e771ec commit 8f2363f

File tree

3 files changed

+157
-0
lines changed

3 files changed

+157
-0
lines changed

python/semantic_kernel/agents/open_ai/responses_agent_thread_actions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import logging
5+
import uuid
56
from collections.abc import AsyncIterable, Sequence
67
from functools import reduce
78
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast
@@ -31,6 +32,7 @@
3132
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
3233
from semantic_kernel.connectors.ai.open_ai.exceptions.content_filter_ai_exception import ContentFilterAIException
3334
from semantic_kernel.contents.annotation_content import AnnotationContent
35+
from semantic_kernel.contents.binary_content import BinaryContent
3436
from semantic_kernel.contents.chat_history import ChatHistory
3537
from semantic_kernel.contents.chat_message_content import CMC_ITEM_TYPES, ChatMessageContent
3638
from semantic_kernel.contents.function_call_content import FunctionCallContent
@@ -719,6 +721,25 @@ def _prepare_chat_history_for_request(
719721
"call_id": content.call_id,
720722
}
721723
response_inputs.append(rfrc_dict)
724+
case BinaryContent() if content.can_read:
725+
# Generate filename with appropriate extension based on mime type
726+
extension = ""
727+
if content.mime_type == "application/pdf":
728+
extension = ".pdf"
729+
elif content.mime_type.startswith("image/"):
730+
extension = f".{content.mime_type.split('/')[-1]}"
731+
elif content.mime_type.startswith("text/"):
732+
extension = ".txt"
733+
filename = f"{uuid.uuid4()}{extension}"
734+
735+
# Format according to OpenAI Responses API specification
736+
file_data_uri = f"data:{content.mime_type};base64,{content.data_string}"
737+
contents.append({
738+
"type": "input_file",
739+
"filename": filename,
740+
"file_data": file_data_uri,
741+
})
742+
response_inputs.append({"role": original_role, "content": contents})
722743

723744
return response_inputs
724745

@@ -975,4 +996,5 @@ def _get_tools(
975996

976997
return tools
977998

999+
9781000
# endregion

python/semantic_kernel/contents/binary_content.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,48 @@ def mime_type(self, value: str):
165165
if self._data_uri:
166166
self._data_uri.mime_type = value
167167

168+
@property
169+
def can_read(self) -> bool:
170+
"""Get whether the content can be read.
171+
172+
Returns True if the content has data available for reading.
173+
"""
174+
return self._data_uri is not None
175+
176+
@classmethod
177+
def from_file(
178+
cls: type[_T],
179+
file_path: str | Path,
180+
mime_type: str | None = None,
181+
) -> _T:
182+
"""Create a BinaryContent from a file.
183+
184+
Args:
185+
file_path: Path to the file to read
186+
mime_type: The mime type of the file content
187+
188+
Returns:
189+
A BinaryContent instance with the file data
190+
191+
Raises:
192+
FileNotFoundError: If the file does not exist
193+
ContentInitializationError: If the file cannot be read
194+
"""
195+
path = Path(file_path)
196+
if not path.exists():
197+
raise FileNotFoundError(f"File not found: {file_path}")
198+
if not path.is_file():
199+
raise ContentInitializationError(f"Path is not a file: {file_path}")
200+
201+
try:
202+
with open(path, "rb") as file:
203+
data = file.read()
204+
except Exception as e:
205+
raise ContentInitializationError(f"Failed to read file: {e}") from e
206+
207+
return cls(data=data, mime_type=mime_type, data_format="base64", uri=str(path))
208+
209+
168210
def __str__(self) -> str:
169211
"""Return the string representation of the content."""
170212
return self.data_uri if self._data_uri else str(self.uri)

python/tests/unit/contents/test_binary_content.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33

4+
import tempfile
5+
from pathlib import Path
6+
47
import pytest
58
from numpy import array
69

710
from semantic_kernel.contents.binary_content import BinaryContent
11+
from semantic_kernel.exceptions.content_exceptions import ContentInitializationError
812

913
test_cases = [
1014
pytest.param(BinaryContent(uri="http://test_uri"), id="uri"),
@@ -111,3 +115,92 @@ def test_element_roundtrip(binary):
111115
@pytest.mark.parametrize("binary", test_cases)
112116
def test_to_dict(binary):
113117
assert binary.to_dict() == {"type": "binary", "binary": {"uri": str(binary)}}
118+
119+
120+
def test_can_read_with_data():
121+
"""Test can_read property returns True when data is available."""
122+
binary = BinaryContent(data=b"test_data", mime_type="application/pdf")
123+
assert binary.can_read is True
124+
125+
126+
def test_can_read_without_data():
127+
"""Test can_read property returns False when no data is available."""
128+
binary = BinaryContent(uri="http://example.com/file.pdf")
129+
assert binary.can_read is False
130+
131+
132+
def test_can_read_empty():
133+
"""Test can_read property returns False for empty BinaryContent."""
134+
binary = BinaryContent()
135+
assert binary.can_read is False
136+
137+
138+
def test_from_file_success():
139+
"""Test from_file class method successfully creates BinaryContent from a file."""
140+
test_data = b"This is test file content"
141+
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
142+
temp_file.write(test_data)
143+
temp_file_path = temp_file.name
144+
145+
try:
146+
binary = BinaryContent.from_file(temp_file_path, mime_type="application/pdf")
147+
assert binary.data == test_data
148+
assert binary.mime_type == "application/pdf"
149+
assert binary.uri == temp_file_path
150+
assert binary.can_read is True
151+
# Verify data_string works (should be base64 encoded)
152+
assert binary.data_string == "VGhpcyBpcyB0ZXN0IGZpbGUgY29udGVudA=="
153+
finally:
154+
Path(temp_file_path).unlink()
155+
156+
157+
def test_from_file_with_path_object():
158+
"""Test from_file class method works with Path objects."""
159+
test_data = b"Path object test content"
160+
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
161+
temp_file.write(test_data)
162+
temp_path = Path(temp_file.name)
163+
164+
try:
165+
binary = BinaryContent.from_file(temp_path, mime_type="text/plain")
166+
assert binary.data == test_data
167+
assert binary.mime_type == "text/plain"
168+
assert binary.uri == str(temp_path)
169+
# Verify data_string works (should be base64 encoded)
170+
assert binary.data_string == "UGF0aCBvYmplY3QgdGVzdCBjb250ZW50"
171+
finally:
172+
temp_path.unlink()
173+
174+
175+
def test_from_file_binary_data():
176+
"""Test from_file handles binary data correctly without encoding errors."""
177+
# Test with actual binary PDF-like data
178+
test_data = b'%PDF-1.4\n%\xf6\xe4\xfc\xdf\n1 0 obj\n<<\n/Type /Catalog'
179+
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file:
180+
temp_file.write(test_data)
181+
temp_file_path = temp_file.name
182+
183+
try:
184+
binary = BinaryContent.from_file(temp_file_path, mime_type="application/pdf")
185+
assert binary.data == test_data
186+
assert binary.mime_type == "application/pdf"
187+
assert binary.can_read is True
188+
# Should not raise Unicode decode error
189+
data_string = binary.data_string
190+
assert isinstance(data_string, str)
191+
assert len(data_string) > 0
192+
finally:
193+
Path(temp_file_path).unlink()
194+
195+
196+
def test_from_file_nonexistent():
197+
"""Test from_file raises FileNotFoundError for nonexistent files."""
198+
with pytest.raises(FileNotFoundError, match="File not found"):
199+
BinaryContent.from_file("/nonexistent/file.pdf")
200+
201+
202+
def test_from_file_directory():
203+
"""Test from_file raises ContentInitializationError for directories."""
204+
with tempfile.TemporaryDirectory() as temp_dir:
205+
with pytest.raises(ContentInitializationError, match="Path is not a file"):
206+
BinaryContent.from_file(temp_dir)

0 commit comments

Comments
 (0)