From 21a2be09189aba231843d5523901251a2d0a5694 Mon Sep 17 00:00:00 2001 From: Aditya Pandey Date: Tue, 10 Mar 2026 13:29:15 -0600 Subject: [PATCH 1/7] fix: Use Responses API native content types in openai_responses adapter Content blocks now use input_text, input_image, input_file instead of Chat Completions types (text, image_url, file) which OpenAI rejects. --- CHANGELOG.md | 1 + src/freeplay/resources/adapters.py | 61 ++++++++++++++++++++++++++++-- tests/test_adapters.py | 47 ++++++++++++++++++++--- 3 files changed, 101 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0b55c..dedd22a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Notable additions, fixes, or breaking changes to the Freeplay SDK. ### 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. - **`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/src/freeplay/resources/adapters.py b/src/freeplay/resources/adapters.py index 5f2ecb1..6c59f89 100644 --- a/src/freeplay/resources/adapters.py +++ b/src/freeplay/resources/adapters.py @@ -336,9 +336,64 @@ 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) + content = msg.get("content") + # Pass through assistant string content or already-formatted + # Responses API content blocks (dicts from history) as-is. + if isinstance(content, list) and all( + isinstance(item, dict) for item in content + ): + result.append({"type": "message", **msg}) + else: + 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 isinstance(content, dict): + return content + 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} + if isinstance(content, MediaContentBase64): + 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}", + } + raise ValueError(f"Unexpected content type {type(content)}") 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", }, ], }, From ed18b0c375fad06173b90eddb6252649657719ed Mon Sep 17 00:00:00 2001 From: Aditya Pandey Date: Tue, 10 Mar 2026 13:37:00 -0600 Subject: [PATCH 2/7] fix: Resolve type-check errors in Responses adapter --- src/freeplay/resources/adapters.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/freeplay/resources/adapters.py b/src/freeplay/resources/adapters.py index 6c59f89..2ec4569 100644 --- a/src/freeplay/resources/adapters.py +++ b/src/freeplay/resources/adapters.py @@ -353,15 +353,7 @@ def to_llm_syntax( ) else: msg = copy.deepcopy(message) - content = msg.get("content") - # Pass through assistant string content or already-formatted - # Responses API content blocks (dicts from history) as-is. - if isinstance(content, list) and all( - isinstance(item, dict) for item in content - ): - result.append({"type": "message", **msg}) - else: - result.append({"type": "message", **msg}) + result.append({"type": "message", **msg}) return result @staticmethod @@ -382,16 +374,16 @@ def _map_responses_content( "Message contains a non-image URL, but the Responses API only supports image URLs." ) return {"type": "input_image", "image_url": content.url} - if isinstance(content, MediaContentBase64): - 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}", - } + # Must be MediaContentBase64 at this point + if content.type == "file": return { - "type": "input_image", - "image_url": f"data:{content.content_type};base64,{content.data}", + "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}", } raise ValueError(f"Unexpected content type {type(content)}") From 84426c010658d8a7925cce0aa0c04d8f42a59c82 Mon Sep 17 00:00:00 2001 From: Aditya Pandey Date: Tue, 10 Mar 2026 15:15:45 -0600 Subject: [PATCH 3/7] fix: Remove unreachable code, fix indentation, add dict to type hint --- src/freeplay/resources/adapters.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/freeplay/resources/adapters.py b/src/freeplay/resources/adapters.py index 2ec4569..55ed2a7 100644 --- a/src/freeplay/resources/adapters.py +++ b/src/freeplay/resources/adapters.py @@ -358,12 +358,12 @@ def to_llm_syntax( @staticmethod def _map_responses_content( - content: Union[TextContent, MediaContentBase64, MediaContentUrl], + content: Union[TextContent, MediaContentBase64, MediaContentUrl, Dict[str, Any]], ) -> Dict[str, Any]: - if isinstance(content, TextContent): - return {"type": "input_text", "text": content.text} if isinstance(content, dict): return content + 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" @@ -384,8 +384,7 @@ def _map_responses_content( return { "type": "input_image", "image_url": f"data:{content.content_type};base64,{content.data}", - } - raise ValueError(f"Unexpected content type {type(content)}") + } class BedrockConverseAdapter(LLMAdapter): From 4edac8fa3f85c0e54f5a5771620dd19e55e5ccb2 Mon Sep 17 00:00:00 2001 From: Aditya Pandey Date: Tue, 10 Mar 2026 16:21:36 -0600 Subject: [PATCH 4/7] Lint fix --- src/freeplay/resources/adapters.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/freeplay/resources/adapters.py b/src/freeplay/resources/adapters.py index 55ed2a7..2badfef 100644 --- a/src/freeplay/resources/adapters.py +++ b/src/freeplay/resources/adapters.py @@ -358,16 +358,16 @@ def to_llm_syntax( @staticmethod def _map_responses_content( - content: Union[TextContent, MediaContentBase64, MediaContentUrl, Dict[str, Any]], + content: Union[ + TextContent, MediaContentBase64, MediaContentUrl, Dict[str, Any] + ], ) -> Dict[str, Any]: if isinstance(content, dict): return content 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" - ) + raise ValueError("Audio content is not yet supported by the Responses API") if isinstance(content, MediaContentUrl): if content.type != "image": raise ValueError( From c08269162ce6b811c914d455acc43d0924eec21b Mon Sep 17 00:00:00 2001 From: Aditya Pandey Date: Wed, 11 Mar 2026 15:55:37 -0600 Subject: [PATCH 5/7] fix: Remove dead dict guard from _map_responses_content --- src/freeplay/resources/adapters.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/freeplay/resources/adapters.py b/src/freeplay/resources/adapters.py index 2badfef..e34b605 100644 --- a/src/freeplay/resources/adapters.py +++ b/src/freeplay/resources/adapters.py @@ -358,12 +358,8 @@ def to_llm_syntax( @staticmethod def _map_responses_content( - content: Union[ - TextContent, MediaContentBase64, MediaContentUrl, Dict[str, Any] - ], + content: Union[TextContent, MediaContentBase64, MediaContentUrl], ) -> Dict[str, Any]: - if isinstance(content, dict): - return content if isinstance(content, TextContent): return {"type": "input_text", "text": content.text} if content.type == "audio": From 3485440b975313092ec4c386d85493545f378d31 Mon Sep 17 00:00:00 2001 From: Aditya Pandey Date: Wed, 11 Mar 2026 15:57:02 -0600 Subject: [PATCH 6/7] refactor: OpenAIResponsesAdapter no longer extends OpenAIAdapter --- src/freeplay/resources/adapters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/freeplay/resources/adapters.py b/src/freeplay/resources/adapters.py index e34b605..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={}, From 38b76d5686f890eb5cdb462bb713238957352ad4 Mon Sep 17 00:00:00 2001 From: Aditya Pandey Date: Wed, 11 Mar 2026 16:02:51 -0600 Subject: [PATCH 7/7] Bump version --- CHANGELOG.md | 6 +++++- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dedd22a..637a028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +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/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'" },