Skip to content

fix(lib): preserve custom tool calls in parse_chat_completion#3505

Open
PranavMishra28 wants to merge 1 commit into
openai:nextfrom
PranavMishra28:fix/parse-preserve-custom-tool-calls
Open

fix(lib): preserve custom tool calls in parse_chat_completion#3505
PranavMishra28 wants to merge 1 commit into
openai:nextfrom
PranavMishra28:fix/parse-preserve-custom-tool-calls

Conversation

@PranavMishra28

Copy link
Copy Markdown

What

parse_chat_completion() — the helper behind client.chat.completions.parse() and streaming get_final_completion() — logs a warning and then drops every custom-type tool call:

elif tool_call.type == "custom":
    # warn user that custom tool calls are not callable here
    log.warning("Custom tool calls are not callable. Ignoring tool call: %s - %s", ...)
    # <-- never appended, so the call is discarded

So a supported GPT-5 custom tool call that the model emits disappears from the parsed result — message.tool_calls omits it, and for a custom-only turn it comes back None. The raw (unparsed) ChatCompletion carries the call correctly; only .parse() loses it.

The handling is also internally inconsistent: the trailing else branch already preserves any non-function tool call by appending it unchanged — only custom is special-cased to discard.

Fix

Append the custom tool call instead of dropping it, mirroring the else branch. Custom calls legitimately don't get parsed_arguments (there's no schema to parse their free-form input against), so they're surfaced as-is. The now-inaccurate "Ignoring tool call" warning — which fired on every custom call in normal use — is removed.

Tests

tests/lib/chat/test_parse_custom_tool_calls.py (pure unit tests, no mock server / live API): a custom-only message and a mixed function+custom message. Both fail on next before this change (the custom call is dropped / tool_calls is None) and pass after. The existing tests/lib/chat/test_completions.py suite still passes; ruff and pyright --strict are clean on the changed files.

Scope / honest limitation

ParsedChatCompletionMessage.tool_calls is a generated type narrowed to list[ParsedFunctionToolCall]. With this fix a custom call round-trips through attribute access and its own model_dump(), but its custom payload is still dropped when the entire completion is serialized (pydantic serializes the list by its declared element type) — the same limitation the existing else branch already has for non-function calls. Fully fixing serialization would require widening the generated tool_calls type, which is out of scope for a lib/-only change; happy to coordinate if you'd prefer that path.

Targeted at next to match where external fixes merge.

`parse_chat_completion` (behind `client.chat.completions.parse()` and the
streaming `get_final_completion()`) logged a warning and then *dropped* every
`custom`-type tool call, so a supported GPT-5 tool call the model made vanished
from the parsed result (`tool_calls` could even come back `None`).

The handling was also inconsistent: the trailing `else` branch already preserves
any non-function tool call by appending it unchanged; only `custom` was
special-cased to discard. Append the custom call the same way (it has no schema
to parse `parsed_arguments` against, so it's surfaced as-is) instead of dropping
it, and remove the now-inaccurate "Ignoring tool call" warning that fired on
every custom call in normal use.

Adds tests/lib/chat/test_parse_custom_tool_calls.py covering a custom-only and a
mixed function+custom message; both fail before this change.

Note: `ParsedChatCompletionMessage.tool_calls` is a generated type narrowed to
`list[ParsedFunctionToolCall]`, so a custom call round-trips through attribute
access and its own `model_dump()` but its `custom` payload is still dropped when
the whole completion is serialized — same limitation the existing `else` branch
already has for non-function calls. Fully fixing serialization needs the
generated type widened, which is out of scope for a lib-only change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@PranavMishra28 PranavMishra28 requested a review from a team as a code owner July 14, 2026 19:15
# includes it and callers rely on `tool_calls` reflecting every call
# the model made. This mirrors the `else` branch below, which already
# preserves any non-function tool call unchanged.
tool_calls.append(tool_call)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

leaving a note since dropping custom calls looks intentional at a glance — but the else branch just below already appends any non-function tool call as-is, so custom was actually the only tool-call type being discarded here. this just makes it consistent. custom calls have no schema to parse args against, so they pass through unchanged (no parsed_arguments), same as the else path.

@PranavMishra28

Copy link
Copy Markdown
Author

@seratch would appreciate your eyes on this when you get a chance. small one: parse_chat_completion() drops GPT-5 custom tool calls on a custom-only turn (the raw completion keeps them, only .parse() loses them). the fix just appends the custom call instead of discarding it, mirroring how the else branch already preserves non-function calls. tests included, targeted at next. happy to adjust if you'd rather handle it a different way.

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.

1 participant