From 37a8267355c8c89269b57ba3a467b9efa341d134 Mon Sep 17 00:00:00 2001 From: theCyberTech <84775494+theCyberTech@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:34:42 +0800 Subject: [PATCH] fix(llms/openai): convert Chat-Completions tool messages to Responses API input items _prepare_responses_params() passed non-system messages straight through as the Responses API "input" array without converting them. That's fine for plain user/assistant text (matches the API's lenient "easy input message" shape), but Chat-Completions-style assistant messages carrying "tool_calls" and "tool"-role messages have no equivalent shape in the Responses API - it expects standalone "function_call" and "function_call_output" input items instead. Sending the raw Chat-Completions shapes gets rejected with a 400 (union-type validation failure against every Responses API input item variant). This broke every multi-turn tool-calling conversation over api="responses" that doesn't rely on auto_chain/previous_response_id (i.e. the common case: resending full history each turn instead of referencing server-side state). Added _convert_message_to_responses_input_items() to translate: - assistant + tool_calls -> one function_call item per call - tool role -> function_call_output item - everything else -> passed through unchanged Verified against a real multi-turn tool-calling run: the agent now completes the full conversation and returns the actual extracted answer instead of erroring on the second turn. Co-Authored-By: Claude Sonnet 5 --- .../llms/providers/openai/completion.py | 44 +++++++- lib/crewai/tests/llms/openai/test_openai.py | 103 ++++++++++++++++++ 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py index 77d2bbbdd..4b872d1d2 100644 --- a/lib/crewai/src/crewai/llms/providers/openai/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py @@ -643,6 +643,44 @@ class OpenAICompletion(BaseLLM): response_model=response_model, ) + def _convert_message_to_responses_input_items( + self, message: LLMMessage + ) -> list[dict[str, Any]]: + """Convert a Chat-Completions-style message into Responses API input items. + + The Responses API has no message shape for an assistant turn carrying + ``tool_calls`` or for a ``tool`` role reply - those become standalone + ``function_call`` / ``function_call_output`` input items instead. Plain + user/assistant text messages pass through unchanged (accepted as-is by + the Responses API's lenient "easy input message" shape). + """ + role = message.get("role") + + if role == "assistant" and message.get("tool_calls"): + items: list[dict[str, Any]] = [] + for tool_call in message["tool_calls"]: + function = tool_call.get("function", {}) + items.append( + { + "type": "function_call", + "call_id": tool_call.get("id", ""), + "name": function.get("name", ""), + "arguments": function.get("arguments", ""), + } + ) + return items + + if role == "tool": + return [ + { + "type": "function_call_output", + "call_id": message.get("tool_call_id", ""), + "output": message.get("content") or "", + } + ] + + return [message] + def _prepare_responses_params( self, messages: list[LLMMessage], @@ -658,7 +696,7 @@ class OpenAICompletion(BaseLLM): - Internally-tagged tool format (flat structure) """ instructions: str | None = self.instructions - input_messages: list[LLMMessage] = [] + input_messages: list[Any] = [] for message in messages: if message.get("role") == "system": @@ -669,7 +707,9 @@ class OpenAICompletion(BaseLLM): else: instructions = content_str else: - input_messages.append(message) + input_messages.extend( + self._convert_message_to_responses_input_items(message) + ) # Prepend reasoning items for ZDR (zero-data-retention) chaining when configured final_input: list[Any] = [] diff --git a/lib/crewai/tests/llms/openai/test_openai.py b/lib/crewai/tests/llms/openai/test_openai.py index 836abe838..fdb77504b 100644 --- a/lib/crewai/tests/llms/openai/test_openai.py +++ b/lib/crewai/tests/llms/openai/test_openai.py @@ -812,6 +812,109 @@ def test_openai_responses_api_with_system_message_extraction(): assert result.isupper() or "HELLO" in result.upper() +def test_openai_responses_api_converts_assistant_tool_calls_message(): + """Regression: assistant messages carrying tool_calls (Chat-Completions + shape) must become standalone function_call input items, since the + Responses API has no message shape for an assistant tool-call turn. + """ + llm = OpenAICompletion(model="gpt-4o-mini", api="responses") + + messages = [ + {"role": "user", "content": "Fetch https://example.com"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "fetch_page", + "arguments": '{"url": "https://example.com"}', + }, + } + ], + }, + ] + + params = llm._prepare_responses_params(messages) + + assert params["input"][0] == {"role": "user", "content": "Fetch https://example.com"} + assert params["input"][1] == { + "type": "function_call", + "call_id": "call_abc123", + "name": "fetch_page", + "arguments": '{"url": "https://example.com"}', + } + + +def test_openai_responses_api_converts_tool_result_message(): + """Regression: tool-role messages (Chat-Completions shape) must become + function_call_output input items for the Responses API. + """ + llm = OpenAICompletion(model="gpt-4o-mini", api="responses") + + messages = [ + { + "role": "tool", + "tool_call_id": "call_abc123", + "name": "fetch_page", + "content": "page text", + }, + ] + + params = llm._prepare_responses_params(messages) + + assert params["input"] == [ + { + "type": "function_call_output", + "call_id": "call_abc123", + "output": "page text", + } + ] + + +def test_openai_responses_api_multi_turn_tool_conversation_shape(): + """Regression: a full multi-turn tool-calling conversation (user -> + assistant tool_calls -> tool result) must convert entirely into valid + Responses API input items, with no leftover Chat-Completions-only keys + ("tool_calls", "tool_call_id") that the Responses API would reject. + """ + llm = OpenAICompletion(model="gpt-4o-mini", api="responses") + + messages = [ + {"role": "user", "content": "Fetch https://example.com"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "fetch_page", + "arguments": '{"url": "https://example.com"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_abc123", + "name": "fetch_page", + "content": "page text", + }, + ] + + params = llm._prepare_responses_params(messages) + + for item in params["input"]: + assert "tool_calls" not in item + assert "tool_call_id" not in item + assert params["input"][1]["type"] == "function_call" + assert params["input"][2]["type"] == "function_call_output" + + @pytest.mark.vcr() def test_openai_responses_api_streaming(): """Test Responses API with streaming enabled."""