Skip to content

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

Open
PranavMishra28 wants to merge 1 commit into
openai:mainfrom
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:mainfrom
PranavMishra28:fix/parse-preserve-custom-tool-calls

Conversation

@PranavMishra28

@PranavMishra28 PranavMishra28 commented Jul 14, 2026

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.

Rebased onto main and retargeted there (this PR originally targeted next; main is where external fixes land — 36 of the last 40 merges, and next currently points at the same commit).

Developed with Claude Code; reviewed and tested by Pranav before marking ready for review.

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

@stainless-app
stainless-app Bot force-pushed the next branch 3 times, most recently from 317260c to e67afa8 Compare July 22, 2026 17:46
`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 force-pushed the fix/parse-preserve-custom-tool-calls branch from c11f3c9 to d0c07c8 Compare August 3, 2026 05:48
@PranavMishra28
PranavMishra28 changed the base branch from next to main August 3, 2026 05:48
@PranavMishra28

Copy link
Copy Markdown
Author

@apcha-oai @jbeckwith-oai small correction on my end: this originally targeted next, which was wrong. 36 of the last 40 merges go to main, and next currently points at the same commit (cbdc98b). I have retargeted it and rebased onto main, so it is now a single commit, +78/-7 across one source file and one new test file.

The bug: parse_chat_completion logs a warning and then drops every custom-type tool call, so a custom tool call the model made disappears from .parse() output (tool_calls comes back None for a custom-only turn). The else branch immediately below already preserves non-function tool calls unchanged, so custom was the only type being discarded. This appends it the same way.

One thing worth deciding before you spend review time: ParsedChatCompletionMessage.tool_calls is a generated type narrowed to list[ParsedFunctionToolCall], so with this change a custom call survives attribute access and its own model_dump(), but its custom payload is still dropped when the whole completion is serialized. Fixing that properly means widening the generated type, which I cannot do from here.

So: do you want this lib-only fix as-is, or should it be handled upstream in the Stainless spec instead? Happy to close this if it is the latter.

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