diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index cf39f4aaa7..821a7d42e2 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -47,6 +47,7 @@ from ..exceptions import AgentsException, UserError from ..handoffs import Handoff from ..items import TResponseInputItem, TResponseOutputItem +from ..logger import logger from ..model_settings import MCPToolChoice from ..tool import ( FunctionTool, @@ -66,6 +67,8 @@ ResponseInputContentParam | ResponseInputAudioParam | dict[str, Any] ) +_OMITTED_TOOL_OUTPUT_PLACEHOLDER = "[tool output omitted]" + class Converter: @classmethod @@ -468,6 +471,7 @@ def items_to_messages( preserve_tool_output_all_content: bool = False, base_url: str | None = None, should_replay_reasoning_content: ShouldReplayReasoningContent | None = None, + strict_feature_validation: bool = False, ) -> list[ChatCompletionMessageParam]: """ Convert a sequence of 'Item' objects into a list of ChatCompletionMessageParam. @@ -493,6 +497,8 @@ def items_to_messages( should_replay_reasoning_content: Optional hook that decides whether a reasoning item should be replayed into the next assistant message as `reasoning_content`. + strict_feature_validation: Whether to raise a UserError for Responses-only + features that Chat Completions cannot faithfully represent. Rules: - EasyInputMessage or InputMessage (role=user) => ChatCompletionUserMessageParam @@ -748,6 +754,19 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: for c in all_output_content if c.get("type") == "text" ] + if not tool_result_content: + message = ( + "Chat Completions tool outputs cannot be empty or contain only " + "non-text content unless preserve_tool_output_all_content=True." + ) + if strict_feature_validation: + raise UserError(message) + logger.warning( + "%s Replacing the tool output with a placeholder; enable strict " + "feature validation to raise an error instead.", + message, + ) + tool_result_content = _OMITTED_TOOL_OUTPUT_PLACEHOLDER msg: ChatCompletionToolMessageParam = { "role": "tool", "tool_call_id": func_output["call_id"], diff --git a/src/agents/models/multi_provider.py b/src/agents/models/multi_provider.py index fb6097787c..2dd5af2013 100644 --- a/src/agents/models/multi_provider.py +++ b/src/agents/models/multi_provider.py @@ -109,8 +109,8 @@ def __init__( responses API. openai_strict_feature_validation: Whether OpenAI Chat Completions models should raise a UserError when callers pass Responses-only features such as previous_response_id, - conversation_id, or prompt. Defaults to False, which preserves the previous - ignore-and-warn behavior. + conversation_id, prompt, or non-text-only tool outputs. Defaults to False, which + preserves the default compatibility behavior. openai_websocket_base_url: The websocket base URL to use for the OpenAI provider. If not provided, the provider will use `OPENAI_WEBSOCKET_BASE_URL` when set. openai_prefix_mode: Controls how ``openai/...`` model strings are interpreted. diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 95b9b64deb..dbf4045b53 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -412,6 +412,7 @@ async def _fetch_response( model=self.model, base_url=str(self._client.base_url), should_replay_reasoning_content=self.should_replay_reasoning_content, + strict_feature_validation=self._strict_feature_validation, ) if system_instructions: diff --git a/src/agents/models/openai_provider.py b/src/agents/models/openai_provider.py index 45b3acbc53..4153e659f5 100644 --- a/src/agents/models/openai_provider.py +++ b/src/agents/models/openai_provider.py @@ -74,8 +74,8 @@ def __init__( API. strict_feature_validation: Whether Chat Completions models should raise a UserError when callers pass Responses-only features such as previous_response_id, - conversation_id, or prompt. Defaults to False, which preserves the previous - ignore-and-warn behavior. + conversation_id, prompt, or non-text-only tool outputs. Defaults to False, which + preserves the default compatibility behavior. agent_registration: Optional agent registration configuration. responses_websocket_options: Optional low-level websocket keepalive options for the OpenAI Responses websocket transport. diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index 399acf2291..0f8066b2e6 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -320,6 +320,110 @@ async def patched_fetch_response(self, *args, **kwargs): ) +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_rejects_non_text_tool_output_in_strict_mode() -> None: + class DummyCompletions: + async def create(self, **kwargs: Any) -> Any: + raise AssertionError("chat.completions.create should not run") + + class DummyClient: + def __init__(self) -> None: + self.chat = type("_Chat", (), {"completions": DummyCompletions()})() + self.base_url = httpx.URL("http://fake") + + model = OpenAIChatCompletionsModel( + model="gpt-4", + openai_client=DummyClient(), # type: ignore[arg-type] + strict_feature_validation=True, + ) + + with pytest.raises(UserError, match="cannot be empty or contain only non-text content"): + await model.get_response( + system_instructions=None, + input=[ + { + "type": "function_call_output", + "call_id": "call_image", + "output": [ + { + "type": "input_image", + "image_url": "https://example.com/image.png", + } + ], + } + ], + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_warns_and_sends_placeholder_for_non_text_tool_output( + caplog: pytest.LogCaptureFixture, +) -> None: + class DummyCompletions: + def __init__(self) -> None: + self.kwargs: dict[str, Any] = {} + + async def create(self, **kwargs: Any) -> Any: + self.kwargs = kwargs + return _minimal_chat_completion() + + class DummyClient: + def __init__(self) -> None: + self.completions = DummyCompletions() + self.chat = type("_Chat", (), {"completions": self.completions})() + self.base_url = httpx.URL("http://fake") + + client = DummyClient() + model = OpenAIChatCompletionsModel( + model="gpt-4", + openai_client=client, # type: ignore[arg-type] + ) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + await model.get_response( + system_instructions=None, + input=[ + { + "type": "function_call_output", + "call_id": "call_image", + "output": [ + { + "type": "input_image", + "image_url": "https://example.com/image.png", + } + ], + } + ], + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + assert client.completions.kwargs["messages"] == [ + { + "role": "tool", + "tool_call_id": "call_image", + "content": "[tool output omitted]", + } + ] + assert "Replacing the tool output with a placeholder" in caplog.text + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_get_response_attaches_logprobs(monkeypatch) -> None: diff --git a/tests/models/test_openai_chatcompletions_converter.py b/tests/models/test_openai_chatcompletions_converter.py index c0bb95c4b9..14174c7771 100644 --- a/tests/models/test_openai_chatcompletions_converter.py +++ b/tests/models/test_openai_chatcompletions_converter.py @@ -23,6 +23,7 @@ from __future__ import annotations +import logging from typing import Literal, cast import pytest @@ -356,6 +357,139 @@ def test_items_to_messages_with_function_output_item(): assert tool_msg["content"] == func_output_item["output"] +def test_items_to_messages_with_non_text_only_function_output_uses_placeholder_by_default( + caplog: pytest.LogCaptureFixture, +): + """Default conversion should keep running without sending an empty tool message.""" + func_output_item: FunctionCallOutput = { + "type": "function_call_output", + "call_id": "somecall", + "output": [ + { + "type": "input_image", + "image_url": "https://example.com/image.png", + } + ], + } + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + messages = Converter.items_to_messages([func_output_item]) + + assert len(messages) == 1 + tool_msg = messages[0] + assert tool_msg["role"] == "tool" + assert tool_msg["tool_call_id"] == func_output_item["call_id"] + assert tool_msg["content"] == "[tool output omitted]" + assert "Replacing the tool output with a placeholder" in caplog.text + + +def test_items_to_messages_with_non_text_only_function_output_raises_in_strict_mode(): + """Strict validation should fail explicitly instead of silently losing the output.""" + func_output_item: FunctionCallOutput = { + "type": "function_call_output", + "call_id": "somecall", + "output": [ + { + "type": "input_image", + "image_url": "https://example.com/image.png", + } + ], + } + + with pytest.raises(UserError, match="cannot be empty or contain only non-text content"): + Converter.items_to_messages([func_output_item], strict_feature_validation=True) + + +def test_items_to_messages_with_empty_function_output_uses_placeholder_by_default( + caplog: pytest.LogCaptureFixture, +): + """Default conversion should not send an empty tool message.""" + func_output_item: FunctionCallOutput = { + "type": "function_call_output", + "call_id": "somecall", + "output": [], + } + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + messages = Converter.items_to_messages([func_output_item]) + + assert len(messages) == 1 + tool_msg = messages[0] + assert tool_msg["role"] == "tool" + assert tool_msg["tool_call_id"] == func_output_item["call_id"] + assert tool_msg["content"] == "[tool output omitted]" + assert "Replacing the tool output with a placeholder" in caplog.text + + +def test_items_to_messages_with_empty_function_output_raises_in_strict_mode(): + """Strict validation should fail explicitly instead of sending empty output.""" + func_output_item: FunctionCallOutput = { + "type": "function_call_output", + "call_id": "somecall", + "output": [], + } + + with pytest.raises(UserError, match="cannot be empty or contain only non-text content"): + Converter.items_to_messages([func_output_item], strict_feature_validation=True) + + +def test_items_to_messages_with_mixed_function_output_keeps_text_by_default( + caplog: pytest.LogCaptureFixture, +): + """Default conversion should preserve text parts and omit unsupported non-text parts.""" + func_output_item: FunctionCallOutput = { + "type": "function_call_output", + "call_id": "somecall", + "output": [ + {"type": "input_text", "text": "visible text"}, + { + "type": "input_image", + "image_url": "https://example.com/image.png", + }, + ], + } + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + messages = Converter.items_to_messages([func_output_item]) + + assert len(messages) == 1 + tool_msg = messages[0] + assert tool_msg["role"] == "tool" + assert tool_msg["tool_call_id"] == func_output_item["call_id"] + assert tool_msg["content"] == [{"type": "text", "text": "visible text"}] + assert "tool output omitted" not in caplog.text + + +def test_items_to_messages_can_preserve_non_text_function_output() -> None: + """Compatible providers can opt in to preserving non-text tool output.""" + func_output_item: FunctionCallOutput = { + "type": "function_call_output", + "call_id": "somecall", + "output": [ + { + "type": "input_image", + "image_url": "https://example.com/image.png", + } + ], + } + + messages = Converter.items_to_messages( + [func_output_item], + preserve_tool_output_all_content=True, + ) + + assert len(messages) == 1 + tool_msg = messages[0] + assert tool_msg["role"] == "tool" + assert tool_msg["tool_call_id"] == func_output_item["call_id"] + assert tool_msg["content"] == [ + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png", "detail": "auto"}, + } + ] + + def test_extract_all_and_text_content_for_strings_and_lists(): """ The converter provides helpers for extracting user-supplied message content