Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "freeplay"
version = "0.5.11"
version = "0.5.12"
description = ""
authors = [
{name = "Freeplay Engineering", email = "support@freeplay.ai"},
Expand Down
50 changes: 46 additions & 4 deletions src/freeplay/resources/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={},
Expand All @@ -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
Comment thread
callingmedic911 marked this conversation as resolved.
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})
Comment on lines +355 to +356

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

wc -l src/freeplay/resources/adapters.py

Repository: freeplayai/freeplay-python

Length of output: 108


🏁 Script executed:

cat -n src/freeplay/resources/adapters.py | sed -n '340,370p'

Repository: freeplayai/freeplay-python

Length of output: 1475


🏁 Script executed:

cat -n src/freeplay/resources/adapters.py | sed -n '1,100p'

Repository: freeplayai/freeplay-python

Length of output: 3623


🏁 Script executed:

cat -n src/freeplay/resources/adapters.py | sed -n '320,360p'

Repository: freeplayai/freeplay-python

Length of output: 1725


🏁 Script executed:

cat -n src/freeplay/resources/adapters.py | sed -n '200,320p'

Repository: freeplayai/freeplay-python

Length of output: 5279


🏁 Script executed:

rg "tool" src/freeplay/resources/adapters.py -i -B 3 -A 3

Repository: freeplayai/freeplay-python

Length of output: 410


🏁 Script executed:

rg "type.*message" src/freeplay/resources/ -i -B 2 -A 2

Repository: freeplayai/freeplay-python

Length of output: 748


🏁 Script executed:

git ls-files | grep -E "(test|spec)" | head -20

Repository: freeplayai/freeplay-python

Length of output: 770


🏁 Script executed:

find tests -name "*.py" -type f | xargs rg "OpenAIResponsesAdapter|tool" -B 3 -A 3

Repository: freeplayai/freeplay-python

Length of output: 35639


🏁 Script executed:

rg "class OpenAI" src/freeplay/resources/adapters.py -A 50 | head -80

Repository: freeplayai/freeplay-python

Length of output: 3023


🏁 Script executed:

cat -n tests/test_adapters.py | grep -A 30 "openai_responses"

Repository: freeplayai/freeplay-python

Length of output: 5007


🏁 Script executed:

cat -n tests/test_adapters.py | grep -B 5 -A 20 "test_openai_responses"

Repository: freeplayai/freeplay-python

Length of output: 4048


🏁 Script executed:

web_search
OpenAI Conversations API Responses create-item schema allowed roles message type

Repository: freeplayai/freeplay-python

Length of output: 165


🌐 Web query:

OpenAI Conversations API Responses message schema roles allowed types

💡 Result:

For the Responses API / Conversations-compat message items, the schema is:

  • Item shape: {"type":"message","role":..., "content":...} (with optional phase on assistant messages). Roles are used for instruction hierarchy. Allowed role values: user, assistant, system, developer. [1]
  • content: either a string or an array of content parts. [1]
  • Allowed input content-part types (in content[]):
    • input_text [2]
    • input_image [2]
    • input_file [3]
  • Common assistant output content-part type:
    • output_text [4]

Sources: [1] [2] [3] [4]


Remove tool from supported roles or implement separate handling for tool responses.

The OpenAIResponsesAdapter declares "tool" as a supported role (line 332), but the OpenAI Conversations API only accepts user, assistant, system, and developer roles in message items. Tool messages must be sent as separate conversation item types. If a tool message reaches lines 355-356, it will serialize as {"type": "message", "role": "tool", ...}, producing an invalid API payload.

Either remove "tool" from role_support or add logic to handle tool messages separately (e.g., skip them, raise an error, or transform them to the appropriate conversation item type for the API).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/freeplay/resources/adapters.py` around lines 355 - 356,
OpenAIResponsesAdapter currently lists "tool" in role_support but later
serializes messages as {"type": "message", **msg} (see the msg =
copy.deepcopy(message) / result.append(...) block), which yields invalid OpenAI
Conversations API payloads; fix by removing "tool" from the adapter's
role_support OR add explicit handling for messages with role == "tool" in the
message serialization path (e.g., skip them, raise a clear error, or convert
them to the proper conversation item type) so that tool messages never get
emitted as {"type":"message","role":"tool",...}; update
OpenAIResponsesAdapter.role_support and/or the serialization before
result.append to handle tool messages accordingly.

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):
Expand Down
47 changes: 42 additions & 5 deletions tests/test_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
},
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading