Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1492,6 +1492,9 @@ def _prepare_message_for_openai(
# marker, since the server-stored items would otherwise duplicate the inline ones. Without
# storage, standalone reasoning items are invalid per the API ("reasoning was provided
# without its required following item"), so the reasoning branch always drops.
drops_reasoning_without_storage = not request_uses_service_side_storage and any(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flag is message-scoped, so it only fires when reasoning and the mcp_call share one Message. For responses parsed by this client that always holds (streaming updates coalesce into one assistant Message, non-streaming builds one), so live traffic is fine. But if a replayed or checkpointed history ever splits a turn so reasoning sits in a separate message, this is False for the mcp_call's message and the bare call survives, the exact state the API rejects. Worth a no-storage test with reasoning and the call in separate messages to pin the contract, or a note that the split can't happen?

content.type == "text_reasoning" for content in message.contents
)
Comment on lines +1495 to +1497
for content in message.contents:
match content.type:
case "text_reasoning":
Expand Down Expand Up @@ -1546,7 +1549,10 @@ def _prepare_message_for_openai(
# server-side `id`, so under continuation it would duplicate
# the prior response's items (#3295). Drop the call here; the
# orphan result is dropped by the coalesce step that follows.
if request_uses_service_side_storage:
#
# Without storage, a reasoning + hosted-MCP pair cannot be replayed
# partially: reasoning is stripped above, and a bare mcp_call is rejected.
if request_uses_service_side_storage or drops_reasoning_without_storage:
Comment on lines +1552 to +1555
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the same drop apply to function_call? Under no-storage this branch keeps the call but reasoning is stripped above, the exact orphan we're fixing for mcp_call. The docstring on test_prepare_message_for_openai_includes_reasoning_with_function_call says that combo 400s: "function_call was provided without its required reasoning item". So either function_call needs the same drops_reasoning_without_storage guard and this PR fixes only half the problem, or function_call and mcp_call genuinely differ at the API and it's worth a note on why only mcp_call is all-or-nothing. Reasoning-model + intermittent, so the new test wouldn't surface it either way.

continue
prepared_mcp = self._prepare_content_for_openai(
message.role,
Expand Down
35 changes: 35 additions & 0 deletions python/packages/openai/tests/openai/test_openai_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5648,6 +5648,41 @@ def test_prepare_messages_for_openai_coalesces_mcp_call_and_result_into_single_i
assert fco_items == [], f"unexpected orphan function_call_output items: {fco_items}"


def test_prepare_messages_for_openai_drops_mcp_call_when_paired_reasoning_is_stripped() -> None:
client = OpenAIChatClient(model="test-model", api_key="test-key")

messages = [
Message(
role="assistant",
contents=[
Content.from_text_reasoning(id="rs_abc123", text="Need the MCP server."),
Content.from_mcp_server_tool_call(
call_id="mcp_abc123",
tool_name="search",
server_name="api_specs",
arguments='{"q": "cats"}',
),
],
),
Message(
role="tool",
contents=[
Content.from_mcp_server_tool_result(
call_id="mcp_abc123",
output=[Content.from_text(text="found 10 cats")],
)
],
),
]

result = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)

types = [item.get("type") for item in result if isinstance(item, dict)]
assert "reasoning" not in types
assert "mcp_call" not in types
assert "function_call_output" not in types


def test_prepare_messages_for_openai_drops_orphan_mcp_server_tool_result() -> None:
"""When an mcp_server_tool_result has no matching mcp_server_tool_call in
the message list, it must be dropped, NOT serialized as a
Expand Down
Loading