mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-22 07:15:10 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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] = []
|
||||
|
||||
@@ -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": "<html>page text</html>",
|
||||
},
|
||||
]
|
||||
|
||||
params = llm._prepare_responses_params(messages)
|
||||
|
||||
assert params["input"] == [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_abc123",
|
||||
"output": "<html>page text</html>",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
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": "<html>page text</html>",
|
||||
},
|
||||
]
|
||||
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user