diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py index 87b55a9e26..86a7d9e841 100644 --- a/src/agents/models/chatcmpl_converter.py +++ b/src/agents/models/chatcmpl_converter.py @@ -790,10 +790,17 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: elif func_call := cls.maybe_function_tool_call(item): asst = ensure_assistant_message() + call_id = func_call.get("call_id") + if call_id is None: + raise UserError( + "Unpaired function calls are supported by Responses but cannot be " + "converted to Chat Completions tool calls. " + "Use a Responses model to preserve this input." + ) tool_calls = list(asst.get("tool_calls", [])) arguments = func_call["arguments"] if func_call["arguments"] else "{}" new_tool_call = ChatCompletionMessageFunctionToolCallParam( - id=func_call["call_id"], + id=call_id, type="function", function={ "name": func_call["name"], diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index cd3c34d32d..43917f8dc4 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -795,6 +795,58 @@ async def handler(request: httpx2.Request) -> httpx2.Response: assert requests == [] +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize("call_id_fields", [{}, {"call_id": None}], ids=["omitted", "null"]) +@pytest.mark.parametrize("stream", [False, True], ids=["non_streaming", "streaming"]) +@pytest.mark.parametrize("strict_feature_validation", [False, True], ids=["default", "strict"]) +async def test_unpaired_function_call_rejected_before_chat_request( + call_id_fields: dict[str, None], stream: bool, strict_feature_validation: bool +) -> None: + """A function call without a call ID cannot become a Chat Completions tool call.""" + requests: list[httpx2.Request] = [] + + async def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + raise AssertionError("Unpaired function calls must not reach Chat Completions") + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http_client: + model = OpenAIChatCompletionsModel( + model="gpt-4", + openai_client=AsyncOpenAI(api_key="test-key", http_client=http_client), + strict_feature_validation=strict_feature_validation, + ) + request_kwargs: dict[str, Any] = { + "system_instructions": None, + "input": [ + { + "type": "function_call", + "name": "notifications", + "namespace": "slack", + "arguments": "{}", + **call_id_fields, + } + ], + "model_settings": ModelSettings(), + "tools": [], + "output_schema": None, + "handoffs": [], + "tracing": ModelTracing.DISABLED, + } + + with pytest.raises( + UserError, + match="Unpaired function calls.*Chat Completions.*Use a Responses model", + ): + if stream: + async for _ in model.stream_response(**request_kwargs): + pass + else: + await model.get_response(**request_kwargs) + + assert requests == [] + + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_get_response_rejects_non_text_tool_output_in_strict_mode() -> None: