mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-27 01:35:12 +00:00
fix(openai): make tool calling work on the Responses API path (#6657)
* fix(openai): make tool calling work on the Responses API path
An agent with tools on api="responses" never produced an answer. It returned the
raw tool-call list instead:
[{'id': 'call_...', 'name': 'multiply', 'arguments': '{"a":17,"b":23}'}]
Three defects in the chain, all on the Responses side only:
1. `is_tool_call_list()` knew the OpenAI-nested, Anthropic, Bedrock and Gemini
shapes but not the Responses one ({"id", "name", "arguments"} -- no nested
"function", no "input"). The list wasn't recognized as tool calls, so the
executor handed it back verbatim as the final answer.
2. `extract_tool_call_info()` read "arguments" only from a nested "function"
object, falling back to "input". For the Responses shape both missed and the
arguments silently became {}, so the tool would have run with no input.
3. With those fixed the tool ran, then the follow-up request 400'd:
Invalid type for 'input[1].content': expected one of an array of objects
or string, but got null instead.
Tool calling is expressed differently by the two APIs. Chat Completions uses an
assistant message carrying `tool_calls` with content: None, then role: "tool"
results. The Responses API uses flat function_call / function_call_output items
keyed by call_id. Those messages were passed through untranslated.
`_to_responses_input()` now converts them. Messages without tool calls pass
through unchanged, so nothing else moves.
Verified end to end against the live API:
api="responses" + tools -> 391 (was raw tool-call JSON)
chained multi-step tool calls -> 400 (17*23, then +9)
completions path (control) -> 391 (unchanged)
The generated `input` payload was also posted to /v1/responses directly and
accepted, and the pre-fix chat-shaped payload confirmed as a 400.
This is why api="responses" never worked for agents: the provider side has had a
full Responses implementation since #4258/c4c9208, but the executor never learned
the shape it emits. Fixing it also unblocks routing gpt-5.4+ tool calls to the
Responses API instead of dropping reasoning_effort.
Tests: 12 cases covering recognition, extraction (including that the Chat
Completions and Bedrock shapes are unaffected), translation of assistant/tool
messages, parallel calls, assistant text alongside tool calls, non-string tool
output, and the full prepared `input` list.
* fix(openai): prefer Responses "call_id" over the item's own "id"
Per CodeRabbit review. A raw Responses function_call item carries both keys with
different values, confirmed against the live API:
keys ['arguments', 'call_id', 'id', 'name', 'status', 'type']
id fc_0adeb715c5d740c7006a65ccb72b948199872ad8b5a5c53108
call_id call_dEoHFrYnOgWYvk17FymdcDZ5
function_call_output must reference call_id. Reading the item's own "id" would
produce a tool result the model can't correlate back to its invocation.
Our own _extract_function_calls_from_response already maps item.call_id into "id",
so the normal path was correct and the existing tests passed. But
extract_tool_call_info is a shared helper reached from every provider's tool loop,
and a raw Responses item is a plausible thing to hand it -- silently picking the
wrong identifier is a bad trap to leave in place for one line of guard.
Tests: raw item extraction asserting call_id is chosen over id, and a round-trip
check that the id extracted from a call is the one sent back with its result.
997 passed across tests/llms, test_agent_utils and tests/agents (1 pre-existing
unrelated failure from a local OLLAMA_API_KEY env leak). Real two-agent chained-tool
run still returns the correct answer.
---------
Co-authored-by: João Moura <joaomdmoura@gmail.com>
This commit is contained in:
@@ -719,6 +719,52 @@ class OpenAICompletion(BaseLLM):
|
||||
response_model=response_model,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_responses_input(message: LLMMessage) -> list[Any]:
|
||||
"""Translate a chat-format message into Responses ``input`` items.
|
||||
|
||||
Tool calling is expressed differently by the two APIs. Chat Completions
|
||||
uses an assistant message carrying ``tool_calls`` (with ``content: None``)
|
||||
followed by ``role: "tool"`` results; the Responses API uses flat
|
||||
``function_call`` / ``function_call_output`` items keyed by ``call_id``.
|
||||
|
||||
Passing the chat shape straight through is rejected:
|
||||
|
||||
Invalid type for 'input[1].content': expected one of an array of
|
||||
objects or string, but got null instead.
|
||||
|
||||
Anything without tool calls is already valid and passes through unchanged.
|
||||
"""
|
||||
role = message.get("role")
|
||||
|
||||
if role == "assistant" and message.get("tool_calls"):
|
||||
items: list[Any] = []
|
||||
content = message.get("content")
|
||||
if content:
|
||||
items.append({"role": "assistant", "content": content})
|
||||
for call in message["tool_calls"]:
|
||||
function = call.get("function", {})
|
||||
items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": 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": str(message.get("content", "")),
|
||||
}
|
||||
]
|
||||
|
||||
return [message]
|
||||
|
||||
def _prepare_responses_params(
|
||||
self,
|
||||
messages: list[LLMMessage],
|
||||
@@ -745,7 +791,7 @@ class OpenAICompletion(BaseLLM):
|
||||
else:
|
||||
instructions = content_str
|
||||
else:
|
||||
input_messages.append(message)
|
||||
input_messages.extend(self._to_responses_input(message))
|
||||
|
||||
# Prepend reasoning items for ZDR (zero-data-retention) chaining when configured
|
||||
final_input: list[Any] = []
|
||||
|
||||
@@ -1246,13 +1246,28 @@ def extract_tool_call_info(
|
||||
call_id = getattr(tool_call, "id", f"call_{id(tool_call)}")
|
||||
return call_id, sanitize_tool_name(tool_call.name), tool_call.input
|
||||
if isinstance(tool_call, dict):
|
||||
# Support OpenAI "id", Bedrock "toolUseId", or generate one
|
||||
# Prefer the Responses API "call_id", then OpenAI "id", then Bedrock
|
||||
# "toolUseId", else generate one. A raw Responses function_call item carries
|
||||
# both "id" (fc_...) and "call_id" (call_...) with different values, and the
|
||||
# matching function_call_output must reference "call_id" -- reading "id"
|
||||
# would produce a tool result that can't be correlated to its invocation.
|
||||
call_id = (
|
||||
tool_call.get("id") or tool_call.get("toolUseId") or f"call_{id(tool_call)}"
|
||||
tool_call.get("call_id")
|
||||
or tool_call.get("id")
|
||||
or tool_call.get("toolUseId")
|
||||
or f"call_{id(tool_call)}"
|
||||
)
|
||||
func_info = tool_call.get("function", {})
|
||||
func_name = func_info.get("name", "") or tool_call.get("name", "")
|
||||
func_args = func_info.get("arguments") or tool_call.get("input") or {}
|
||||
# "arguments" is also read from the top level for the OpenAI Responses API,
|
||||
# which emits {"id", "name", "arguments"} with no nested "function" object.
|
||||
# Without it the args silently resolved to {} and the tool ran with no input.
|
||||
func_args = (
|
||||
func_info.get("arguments")
|
||||
or tool_call.get("arguments")
|
||||
or tool_call.get("input")
|
||||
or {}
|
||||
)
|
||||
return call_id, sanitize_tool_name(func_name), func_args
|
||||
return None
|
||||
|
||||
@@ -1284,6 +1299,16 @@ def is_tool_call_list(response: list[Any]) -> bool:
|
||||
# Bedrock-style
|
||||
if isinstance(first_item, dict) and "name" in first_item and "input" in first_item:
|
||||
return True
|
||||
# OpenAI Responses API style: {"id", "name", "arguments"}, with no nested
|
||||
# "function" object and no "input". Without this the list isn't recognized as
|
||||
# tool calls, so the executor hands it back verbatim and the agent returns raw
|
||||
# tool-call JSON instead of running the tool and producing a final answer.
|
||||
if (
|
||||
isinstance(first_item, dict)
|
||||
and "name" in first_item
|
||||
and "arguments" in first_item
|
||||
):
|
||||
return True
|
||||
# Gemini-style
|
||||
if hasattr(first_item, "function_call") and first_item.function_call:
|
||||
return True
|
||||
|
||||
226
lib/crewai/tests/llms/openai/test_responses_tool_loop.py
Normal file
226
lib/crewai/tests/llms/openai/test_responses_tool_loop.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Tests for tool calling on the OpenAI Responses API path.
|
||||
|
||||
The two APIs express tool calling differently:
|
||||
|
||||
Chat Completions assistant message with `tool_calls` (content: None),
|
||||
then {"role": "tool", "tool_call_id": ...}
|
||||
Responses flat {"type": "function_call", "call_id", ...} and
|
||||
{"type": "function_call_output", "call_id", "output"} items
|
||||
|
||||
Sending the chat shape to /v1/responses is rejected outright:
|
||||
|
||||
Invalid type for 'input[1].content': expected one of an array of objects or
|
||||
string, but got null instead.
|
||||
|
||||
Verified against the live endpoint: the chat shape 400s, the native items complete.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.llms.providers.openai.completion import OpenAICompletion
|
||||
from crewai.utilities.agent_utils import extract_tool_call_info, is_tool_call_list
|
||||
|
||||
|
||||
# The shape OpenAICompletion._extract_function_calls_from_response builds from a
|
||||
# Responses payload: no nested "function" object, no "input".
|
||||
RESPONSES_TOOL_CALL = {
|
||||
"id": "call_abc",
|
||||
"name": "multiply",
|
||||
"arguments": '{"a": 17, "b": 23}',
|
||||
}
|
||||
|
||||
# A raw Responses `function_call` output item, as returned by the API. Note that
|
||||
# "id" and "call_id" are different values -- the matching function_call_output must
|
||||
# reference "call_id".
|
||||
RAW_RESPONSES_ITEM = {
|
||||
"type": "function_call",
|
||||
"id": "fc_0adeb715c5d740c7006a65ccb7",
|
||||
"call_id": "call_dEoHFrYnOgWYvk17FymdcDZ5",
|
||||
"name": "multiply",
|
||||
"arguments": '{"a": 17, "b": 23}',
|
||||
"status": "completed",
|
||||
}
|
||||
|
||||
|
||||
def build(model: str = "gpt-5.5", **kwargs) -> OpenAICompletion:
|
||||
return OpenAICompletion(model=model, api_key="sk-test", api="responses", **kwargs)
|
||||
|
||||
|
||||
class TestToolCallRecognition:
|
||||
"""The executor must recognize Responses-shaped tool calls."""
|
||||
|
||||
def test_recognizes_responses_shape(self):
|
||||
assert is_tool_call_list([RESPONSES_TOOL_CALL])
|
||||
|
||||
def test_extracts_arguments_from_top_level(self):
|
||||
"""Previously fell through to `input` and silently yielded {}."""
|
||||
call_id, name, args = extract_tool_call_info(RESPONSES_TOOL_CALL)
|
||||
|
||||
assert call_id == "call_abc"
|
||||
assert name == "multiply"
|
||||
assert args == '{"a": 17, "b": 23}'
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_call", "expected_args"),
|
||||
[
|
||||
(
|
||||
{"id": "c", "function": {"name": "f", "arguments": '{"x":1}'}},
|
||||
'{"x":1}',
|
||||
),
|
||||
({"toolUseId": "c", "name": "f", "input": {"x": 1}}, {"x": 1}),
|
||||
],
|
||||
)
|
||||
def test_other_provider_shapes_still_work(self, tool_call, expected_args):
|
||||
"""Chat Completions and Bedrock shapes must be unaffected."""
|
||||
assert is_tool_call_list([tool_call])
|
||||
assert extract_tool_call_info(tool_call)[2] == expected_args
|
||||
|
||||
def test_raw_responses_item_uses_call_id_not_item_id(self):
|
||||
"""A raw function_call item carries both; only call_id can be correlated.
|
||||
|
||||
function_call_output must reference call_id, so picking up the item's own
|
||||
"id" (fc_...) would produce a tool result the model can't match to its
|
||||
invocation.
|
||||
"""
|
||||
call_id, name, args = extract_tool_call_info(RAW_RESPONSES_ITEM)
|
||||
|
||||
assert call_id == "call_dEoHFrYnOgWYvk17FymdcDZ5"
|
||||
assert call_id != RAW_RESPONSES_ITEM["id"]
|
||||
assert name == "multiply"
|
||||
assert args == '{"a": 17, "b": 23}'
|
||||
|
||||
|
||||
class TestResponsesInputTranslation:
|
||||
"""Chat-format tool messages must become native Responses items."""
|
||||
|
||||
def test_assistant_tool_calls_become_function_call_items(self):
|
||||
message = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "multiply", "arguments": '{"a":17,"b":23}'},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
assert OpenAICompletion._to_responses_input(message) == [
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "multiply",
|
||||
"arguments": '{"a":17,"b":23}',
|
||||
}
|
||||
]
|
||||
|
||||
def test_tool_result_becomes_function_call_output(self):
|
||||
message = {"role": "tool", "tool_call_id": "call_1", "content": "391"}
|
||||
|
||||
assert OpenAICompletion._to_responses_input(message) == [
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": "391"}
|
||||
]
|
||||
|
||||
def test_assistant_text_alongside_tool_calls_is_preserved(self):
|
||||
message = {
|
||||
"role": "assistant",
|
||||
"content": "Let me calculate.",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "function": {"name": "multiply", "arguments": "{}"}}
|
||||
],
|
||||
}
|
||||
|
||||
items = OpenAICompletion._to_responses_input(message)
|
||||
|
||||
assert items[0] == {"role": "assistant", "content": "Let me calculate."}
|
||||
assert items[1]["type"] == "function_call"
|
||||
|
||||
def test_parallel_tool_calls_become_separate_items(self):
|
||||
message = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": "c1", "function": {"name": "multiply", "arguments": "{}"}},
|
||||
{"id": "c2", "function": {"name": "add", "arguments": "{}"}},
|
||||
],
|
||||
}
|
||||
|
||||
items = OpenAICompletion._to_responses_input(message)
|
||||
|
||||
assert [i["call_id"] for i in items] == ["c1", "c2"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
],
|
||||
)
|
||||
def test_messages_without_tool_calls_pass_through(self, message):
|
||||
assert OpenAICompletion._to_responses_input(message) == [message]
|
||||
|
||||
def test_non_string_tool_output_is_coerced(self):
|
||||
"""Tool results arrive as ints, dicts, etc. The API requires a string."""
|
||||
message = {"role": "tool", "tool_call_id": "c1", "content": 391}
|
||||
|
||||
assert OpenAICompletion._to_responses_input(message)[0]["output"] == "391"
|
||||
|
||||
def test_call_and_output_ids_round_trip(self):
|
||||
"""The id extracted from a call must be the one sent back with its result.
|
||||
|
||||
This is the correlation the API relies on: a function_call_output whose
|
||||
call_id doesn't match an emitted function_call is rejected or ignored.
|
||||
"""
|
||||
call_id, name, args = extract_tool_call_info(RAW_RESPONSES_ITEM)
|
||||
|
||||
assistant = OpenAICompletion._to_responses_input(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": call_id, "function": {"name": name, "arguments": args}}
|
||||
],
|
||||
}
|
||||
)
|
||||
result = OpenAICompletion._to_responses_input(
|
||||
{"role": "tool", "tool_call_id": call_id, "content": "391"}
|
||||
)
|
||||
|
||||
assert assistant[0]["call_id"] == result[0]["call_id"] == call_id
|
||||
|
||||
|
||||
class TestPreparedParams:
|
||||
"""End-to-end shape of the `input` list handed to the Responses API."""
|
||||
|
||||
def test_tool_conversation_produces_valid_input(self):
|
||||
llm = build()
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "multiply 17 and 23"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"function": {"name": "multiply", "arguments": '{"a":17,"b":23}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "391"},
|
||||
]
|
||||
|
||||
params = llm._prepare_responses_params(messages)
|
||||
|
||||
assert params["instructions"] == "You are helpful."
|
||||
assert [item.get("type") or item["role"] for item in params["input"]] == [
|
||||
"user",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
]
|
||||
# The rejected shape was an item carrying an explicit content: None.
|
||||
# function_call items have no content key at all, which is valid.
|
||||
assert not any(
|
||||
"content" in item and item["content"] is None for item in params["input"]
|
||||
)
|
||||
Reference in New Issue
Block a user