Skip to content

fix(models): reject unpaired function calls in Chat Completions - #4702

Closed
ayaangazali wants to merge 1 commit into
openai:mainfrom
ayaangazali:fix/chatcmpl-unpaired-function-call
Closed

fix(models): reject unpaired function calls in Chat Completions#4702
ayaangazali wants to merge 1 commit into
openai:mainfrom
ayaangazali:fix/chatcmpl-unpaired-function-call

Conversation

@ayaangazali

Copy link
Copy Markdown
Contributor

Summary

#4699 made an unpaired function_call_output raise an actionable UserError, because Chat Completions has no way to represent it. The branch immediately above it has the same shape and was not covered:

elif func_call := cls.maybe_function_tool_call(item):
    ...
    new_tool_call = ChatCompletionMessageFunctionToolCallParam(
        id=func_call["call_id"],      # direct subscript, no guard

call_id is optional on both ResponseFunctionToolCallParam and FunctionCallOutput, so a function call without one is exactly as constructible as the output that #4699 now rejects. Today the two halves of the same pair fail differently:

output without call_id   UserError: Unpaired function outputs are supported by Responses ...
call   without call_id   KeyError: 'call_id'

This is reachable from an ordinary hand-built input list, and it fails in conversion before anything is sent:

await Runner.run(agent, [
    {"role": "user", "content": "hi"},
    {"type": "function_call", "name": "f", "arguments": "{}"},
])
# Chat Completions model -> KeyError: 'call_id'

It is also the same capability difference #4699 described, not invalid input. Running that identical input against a Responses model converts cleanly and reaches the transport, failing only on the connection. So Responses preserves the item and Chat Completions cannot, which is exactly the case the existing message is worded for.

The change raises the parallel UserError so both halves of the pair fail the same actionable way. Provenance for the two sides: the output guard landed one commit ago in #4699, while the call-side subscript has been unguarded since #522 extracted this converter, so the asymmetry is an uncovered branch rather than a deliberate split.

Test plan

tests/models/test_openai_chatcompletions.py::test_unpaired_function_call_rejected_before_chat_request, written to mirror the #4699 test directly: same MockTransport handler that fails if a request is ever issued, and the same parametrization over an omitted versus explicitly null call_id, streaming versus non streaming, and default versus strict feature validation. Eight cases, and it asserts no request reached the wire.

Verified it fails without the source change by reverting src/: all eight fail, each with KeyError: 'call_id'.

.agents/skills/code-change-verification/scripts/run.sh passes end to end: format, lint, typecheck and the full suite.

Issue number

None. Found while sibling-checking #4699.

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

This is just the other half of #4699, so the wording and the test structure are deliberately copied from it rather than reinvented. If you would rather the two branches shared one guard instead of two parallel messages, say so and I will fold them together. I'm a freshman in college and I found this by checking whether the fix you just merged had a sibling one branch up.

openai#4699 made an unpaired function_call_output raise an actionable UserError
because Chat Completions cannot represent it. A function_call has the same
optional call_id and the branch above it still subscripts func_call["call_id"]
directly, so the same shape raises KeyError: 'call_id' instead.

The input reaches the converter through an ordinary hand-built input list, and
a Responses model converts it and reaches the transport, so this is the same
capability difference openai#4699 documented rather than an invalid input.

Raise the parallel UserError so both halves of the pair fail the same way.
Copilot AI lite review requested due to automatic review settings August 27, 2026 01:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@seratch

seratch commented Aug 27, 2026

Copy link
Copy Markdown
Member

ResponseFunctionToolCallParam.call_id is a required string, and the function-calling contract pairs each function call and output through that ID. An omitted or null call_id is therefore malformed hand-built input rather than a supported Responses input shape.

The proposed error also states that Responses supports unpaired function calls, which is not part of the documented contract. Improving the exception type for an invalid synthetic value is not enough reason to add another provider-conversion branch, so I am closing this PR.

@seratch seratch closed this Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants