diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0b55c..637a028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,15 @@ Notable additions, fixes, or breaking changes to the Freeplay SDK. -## [0.5.11] +## [0.5.12] - 2026-03-11 ### Fixed +- **`openai_responses` adapter**: Content blocks now use Responses API native types (`input_text`, `input_image`, `input_file`) instead of Chat Completions types (`text`, `image_url`, `file`) which OpenAI rejects. + +## [0.5.11] + +### Fixed - **`tool` role support for OpenAI adapters**: `OpenAIAdapter` and `OpenAIResponsesAdapter` now accept `tool` role messages in history. Previously, tool-use conversation history would crash with `ValueError: role 'tool' is not supported`. ## [0.5.10] diff --git a/pyproject.toml b/pyproject.toml index f8752e0..a5bae49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "freeplay" -version = "0.5.11" +version = "0.5.12" description = "" authors = [ {name = "Freeplay Engineering", email = "support@freeplay.ai"}, diff --git a/src/freeplay/resources/adapters.py b/src/freeplay/resources/adapters.py index 5f2ecb1..4c4d3a0 100644 --- a/src/freeplay/resources/adapters.py +++ b/src/freeplay/resources/adapters.py @@ -327,7 +327,7 @@ def __translate_role(role: str) -> str: raise ValueError(f"Gemini formatting found unexpected role {role}") -class OpenAIResponsesAdapter(OpenAIAdapter): +class OpenAIResponsesAdapter(LLMAdapter): role_support = RoleSupport( supported=frozenset({"system", "user", "assistant", "developer", "tool"}), coerce_map={}, @@ -336,9 +336,51 @@ class OpenAIResponsesAdapter(OpenAIAdapter): def to_llm_syntax( self, messages: List[Dict[str, Any]] ) -> Union[str, List[Dict[str, Any]]]: - formatted = super().to_llm_syntax(messages) - assert isinstance(formatted, list) - return [{"type": "message", **m} for m in formatted if m["role"] != "system"] + result: List[Dict[str, Any]] = [] + for message in messages: + if message["role"] == "system": + continue + if "has_media" in message and message["has_media"]: + result.append( + { + "type": "message", + "role": message["role"], + "content": [ + OpenAIResponsesAdapter._map_responses_content(content) + for content in message["content"] + ], + } + ) + else: + msg = copy.deepcopy(message) + result.append({"type": "message", **msg}) + return result + + @staticmethod + def _map_responses_content( + content: Union[TextContent, MediaContentBase64, MediaContentUrl], + ) -> Dict[str, Any]: + if isinstance(content, TextContent): + return {"type": "input_text", "text": content.text} + if content.type == "audio": + raise ValueError("Audio content is not yet supported by the Responses API") + if isinstance(content, MediaContentUrl): + if content.type != "image": + raise ValueError( + "Message contains a non-image URL, but the Responses API only supports image URLs." + ) + return {"type": "input_image", "image_url": content.url} + # Must be MediaContentBase64 at this point + if content.type == "file": + return { + "type": "input_file", + "filename": f"{content.slot_name}.{content.content_type.split('/')[-1]}", + "file_data": f"data:{content.content_type};base64,{content.data}", + } + return { + "type": "input_image", + "image_url": f"data:{content.content_type};base64,{content.data}", + } class BedrockConverseAdapter(LLMAdapter): diff --git a/tests/test_adapters.py b/tests/test_adapters.py index d40cb2b..302a8cf 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -577,14 +577,51 @@ def test_openai_responses_media(self) -> None: "type": "message", "role": "user", "content": [ - {"type": "text", "text": "Take a look at these images!"}, + {"type": "input_text", "text": "Take a look at these images!"}, { - "type": "image_url", - "image_url": {"url": "https://localhost/image.png"}, + "type": "input_image", + "image_url": "https://localhost/image.png", }, { - "type": "image_url", - "image_url": {"url": "data:image/png;base64,some-data"}, + "type": "input_image", + "image_url": "data:image/png;base64,some-data", + }, + ], + }, + ], + ) + + def test_openai_responses_file_media(self) -> None: + messages: List[Dict[str, Any]] = [ + { + "role": "user", + "has_media": True, + "content": [ + TextContent("Check this file"), + MediaContentBase64( + type="file", + content_type="application/pdf", + data="pdf-data", + slot_name="report", + ), + ], + }, + ] + + formatted = OpenAIResponsesAdapter().to_llm_syntax(messages) + + self.assertEqual( + formatted, + [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Check this file"}, + { + "type": "input_file", + "filename": "report.pdf", + "file_data": "data:application/pdf;base64,pdf-data", }, ], }, diff --git a/uv.lock b/uv.lock index 8b3a95c..088781a 100644 --- a/uv.lock +++ b/uv.lock @@ -353,7 +353,7 @@ wheels = [ [[package]] name = "freeplay" -version = "0.5.11" +version = "0.5.12" source = { editable = "." } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },