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:
theCyberTech
2026-07-11 18:34:42 +08:00
parent cb78402898
commit 37a8267355
2 changed files with 145 additions and 2 deletions

View File

@@ -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] = []

View File

@@ -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."""