From d0c07c83261e647b12d866736ee410260aba3caf Mon Sep 17 00:00:00 2001 From: Pranav Mishra Date: Tue, 14 Jul 2026 12:15:21 -0700 Subject: [PATCH 1/3] fix(lib): preserve custom tool calls in parse_chat_completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- src/openai/lib/_parsing/_completions.py | 14 ++-- .../lib/chat/test_parse_custom_tool_calls.py | 71 +++++++++++++++++++ 2 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 tests/lib/chat/test_parse_custom_tool_calls.py diff --git a/src/openai/lib/_parsing/_completions.py b/src/openai/lib/_parsing/_completions.py index 7a1bded1de..45428ec4e4 100644 --- a/src/openai/lib/_parsing/_completions.py +++ b/src/openai/lib/_parsing/_completions.py @@ -124,13 +124,13 @@ def parse_chat_completion( ) ) 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", - tool_call.id, - tool_call.custom.name, - stacklevel=2, - ) + # `.parse()` doesn't attach `parsed_arguments` to custom tool calls + # (there's no schema to parse their free-form input against), but the + # call must still be surfaced rather than dropped — the raw completion + # 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) elif TYPE_CHECKING: # type: ignore[unreachable] assert_never(tool_call) else: diff --git a/tests/lib/chat/test_parse_custom_tool_calls.py b/tests/lib/chat/test_parse_custom_tool_calls.py new file mode 100644 index 0000000000..007e53c636 --- /dev/null +++ b/tests/lib/chat/test_parse_custom_tool_calls.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Any, Dict, List, cast + +from openai._types import omit +from openai.types.chat import ChatCompletion +from openai.lib._parsing import parse_chat_completion + +_FUNCTION_CALL: Dict[str, Any] = { + "id": "call_fn", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, +} +_CUSTOM_CALL: Dict[str, Any] = { + "id": "call_custom", + "type": "custom", + "custom": {"name": "run_python", "input": "print(1)"}, +} + + +def _completion_with_tool_calls(tool_calls: List[Dict[str, Any]]) -> ChatCompletion: + return ChatCompletion.construct( + id="chatcmpl-test", + object="chat.completion", + created=0, + model="gpt-5", + choices=[ + { + "index": 0, + "finish_reason": "tool_calls", + "logprobs": None, + "message": {"role": "assistant", "content": None, "tool_calls": tool_calls}, + } + ], + ) + + +def _dump_tool_calls(completion: ChatCompletion) -> List[Dict[str, Any]]: + parsed = parse_chat_completion(chat_completion=completion, response_format=omit, input_tools=omit) + # `cast` avoids the generic `ResponseFormatT` (unbound here) leaking `Unknown` + # into attribute access under strict type checking. Dump each tool call + # individually so a custom call is serialized by its own type rather than the + # message field's declared `list[ParsedFunctionToolCall]`. + tool_calls = cast("Any", parsed).choices[0].message.tool_calls + assert tool_calls is not None + return [tc.model_dump() for tc in tool_calls] + + +def test_parse_preserves_custom_tool_call() -> None: + # Regression: a `custom` tool call used to be logged and discarded by + # `parse_chat_completion`, so `.parse()` returned `tool_calls=None` and the + # call the model made vanished from the parsed completion. + dumped = _dump_tool_calls(_completion_with_tool_calls([_CUSTOM_CALL])) + + assert len(dumped) == 1 + assert dumped[0]["type"] == "custom" + assert dumped[0]["id"] == "call_custom" + assert dumped[0]["custom"]["name"] == "run_python" + assert dumped[0]["custom"]["input"] == "print(1)" + + +def test_parse_preserves_custom_alongside_function_tool_call() -> None: + dumped = _dump_tool_calls(_completion_with_tool_calls([_FUNCTION_CALL, _CUSTOM_CALL])) + + assert [tc["type"] for tc in dumped] == ["function", "custom"] + # the function call is still parsed as before (gets `parsed_arguments`) + assert dumped[0]["function"]["name"] == "get_weather" + assert "parsed_arguments" in dumped[0]["function"] + # the custom call is surfaced unchanged rather than dropped + assert dumped[1]["id"] == "call_custom" + assert dumped[1]["custom"]["name"] == "run_python" From 27e8ae9a0dd82e09d05aba4fb77ba2b9d8654d80 Mon Sep 17 00:00:00 2001 From: Pranav Mishra Date: Mon, 3 Aug 2026 15:09:06 -0700 Subject: [PATCH 2/3] fix(lib): keep custom tool calls intact through serialization Appending the custom tool call was not enough. `ParsedChatCompletionMessage` narrowed `tool_calls` to `list[ParsedFunctionToolCall]`, and pydantic serializes by the declared type, so dumping a whole parsed completion emitted `{"id": ..., "type": "custom"}` with the `custom` payload silently gone. The result did not even validate back into an equivalent model, so a caller persisting and replaying a parsed completion lost the call. `tool_calls` is now `list[ParsedChatCompletionMessageToolCallUnion]`, an annotated discriminated union mirroring `ChatCompletionMessageToolCallUnion` on the base class with the function member swapped for its parsed subclass. That makes the annotation describe what the field actually holds, which is what pydantic needs, and it lines the parsed message up with the raw one instead of diverging from it. Consequences of the wider type, all handled here rather than left to callers: - The four `assert_never` exhaustiveness sites in the streaming accumulator now have an explicit `custom` branch. A custom call carries no `parsed_arguments` and the argument-delta/done events are function-tool specific, so each branch is a documented no-op, but being explicit keeps exhaustiveness real: a third tool-call type still trips `assert_never`. - examples/parsing_tools.py narrows on `type` before reaching for `.function`, which the raw completion has always required. This also removes the pyright/mypy error the previous commit introduced at _completions.py:133, where a custom call was appended to a list annotated `list[ParsedFunctionToolCall]`. Tests now assert on the whole-completion dump and on a validate round trip rather than dumping each tool call on its own, which was the hole that let the serialization loss through. All three fail without this change. --- examples/parsing_tools.py | 3 + src/openai/lib/_parsing/_completions.py | 3 +- src/openai/lib/streaming/chat/_completions.py | 14 +++ src/openai/types/chat/__init__.py | 1 + .../types/chat/parsed_chat_completion.py | 19 +++- .../lib/chat/test_parse_custom_tool_calls.py | 86 ++++++++++++++----- 6 files changed, 100 insertions(+), 26 deletions(-) diff --git a/examples/parsing_tools.py b/examples/parsing_tools.py index 26921b1df6..66a89a2624 100644 --- a/examples/parsing_tools.py +++ b/examples/parsing_tools.py @@ -75,6 +75,9 @@ class Query(BaseModel): ) tool_call = (completion.choices[0].message.tool_calls or [])[0] +# `tool_calls` can hold custom tool calls too, so narrow on `type` before reaching +# for `.function` (the raw, unparsed completion has always required this) +assert tool_call.type == "function" rich.print(tool_call.function) assert isinstance(tool_call.function.parsed_arguments, Query) print(tool_call.function.parsed_arguments.table_name) diff --git a/src/openai/lib/_parsing/_completions.py b/src/openai/lib/_parsing/_completions.py index 45428ec4e4..fca4eafdd9 100644 --- a/src/openai/lib/_parsing/_completions.py +++ b/src/openai/lib/_parsing/_completions.py @@ -23,6 +23,7 @@ ParsedChatCompletionMessage, ChatCompletionToolUnionParam, ChatCompletionFunctionToolParam, + ParsedChatCompletionMessageToolCallUnion, completion_create_params, ) from ..._exceptions import LengthFinishReasonError, ContentFilterFinishReasonError @@ -104,7 +105,7 @@ def parse_chat_completion( message = choice.message - tool_calls: list[ParsedFunctionToolCall] = [] + tool_calls: list[ParsedChatCompletionMessageToolCallUnion] = [] if message.tool_calls: for tool_call in message.tool_calls: if tool_call.type == "function": diff --git a/src/openai/lib/streaming/chat/_completions.py b/src/openai/lib/streaming/chat/_completions.py index 5f072cafbd..2f25657191 100644 --- a/src/openai/lib/streaming/chat/_completions.py +++ b/src/openai/lib/streaming/chat/_completions.py @@ -406,6 +406,10 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS if prev_tool.type == "function": assert new_tool.type == "function" new_tool.function.parsed_arguments = prev_tool.function.parsed_arguments + elif prev_tool.type == "custom": + # custom tool calls carry no `parsed_arguments`, so there is + # nothing to copy forward into the new snapshot + pass elif TYPE_CHECKING: # type: ignore[unreachable] assert_never(prev_tool) except IndexError: @@ -462,6 +466,9 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS bytes(tool_call_snapshot.function.arguments, "utf-8"), partial_mode=True, ) + elif tool_call_snapshot.type == "custom": + # no schema to partially parse a custom tool's free-form input against + pass elif TYPE_CHECKING: # type: ignore[unreachable] assert_never(tool_call_snapshot) @@ -547,6 +554,10 @@ def _build_events( arguments_delta=tool_call_delta.function.arguments or "", ) ) + elif tool_call.type == "custom": + # the streaming event types are function-tool specific, so a custom + # call produces no argument-delta events + pass elif TYPE_CHECKING: # type: ignore[unreachable] assert_never(tool_call) @@ -733,6 +744,9 @@ def _add_tool_done_event( parsed_arguments=parsed_arguments, ) ) + elif tool_call_snapshot.type == "custom": + # no arguments-done event for a custom call; there is nothing to parse + pass elif TYPE_CHECKING: # type: ignore[unreachable] assert_never(tool_call_snapshot) diff --git a/src/openai/types/chat/__init__.py b/src/openai/types/chat/__init__.py index 50bdac7c65..c417f4376c 100644 --- a/src/openai/types/chat/__init__.py +++ b/src/openai/types/chat/__init__.py @@ -11,6 +11,7 @@ ParsedChoice as ParsedChoice, ParsedChatCompletion as ParsedChatCompletion, ParsedChatCompletionMessage as ParsedChatCompletionMessage, + ParsedChatCompletionMessageToolCallUnion as ParsedChatCompletionMessageToolCallUnion, ) from .chat_completion_deleted import ChatCompletionDeleted as ChatCompletionDeleted from .chat_completion_message import ChatCompletionMessage as ChatCompletionMessage diff --git a/src/openai/types/chat/parsed_chat_completion.py b/src/openai/types/chat/parsed_chat_completion.py index 4b11dac5a0..622aa82530 100644 --- a/src/openai/types/chat/parsed_chat_completion.py +++ b/src/openai/types/chat/parsed_chat_completion.py @@ -1,13 +1,26 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Generic, TypeVar, Optional +from typing import List, Union, Generic, TypeVar, Optional +from typing_extensions import Annotated, TypeAlias +from ..._utils import PropertyInfo from ..._models import GenericModel from .chat_completion import Choice, ChatCompletion from .chat_completion_message import ChatCompletionMessage from .parsed_function_tool_call import ParsedFunctionToolCall +from .chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall -__all__ = ["ParsedChatCompletion", "ParsedChoice"] +__all__ = ["ParsedChatCompletion", "ParsedChoice", "ParsedChatCompletionMessageToolCallUnion"] + +# Mirrors `ChatCompletionMessageToolCallUnion` with the function member swapped for its +# parsed subclass. `parse_chat_completion()` surfaces custom tool calls unchanged, so the +# annotation has to admit them: pydantic serializes by the declared type, and narrowing +# this to `ParsedFunctionToolCall` alone silently dropped the `custom` payload whenever a +# whole completion was dumped. +ParsedChatCompletionMessageToolCallUnion: TypeAlias = Annotated[ + Union[ParsedFunctionToolCall, ChatCompletionMessageCustomToolCall], + PropertyInfo(discriminator="type"), +] ContentType = TypeVar("ContentType") @@ -23,7 +36,7 @@ class ParsedChatCompletionMessage(ChatCompletionMessage, GenericModel, Generic[C parsed: Optional[ContentType] = None """The auto-parsed message contents""" - tool_calls: Optional[List[ParsedFunctionToolCall]] = None # type: ignore[assignment] + tool_calls: Optional[List[ParsedChatCompletionMessageToolCallUnion]] = None # type: ignore[assignment] """The tool calls generated by the model, such as function calls.""" diff --git a/tests/lib/chat/test_parse_custom_tool_calls.py b/tests/lib/chat/test_parse_custom_tool_calls.py index 007e53c636..bb9a2cce2a 100644 --- a/tests/lib/chat/test_parse_custom_tool_calls.py +++ b/tests/lib/chat/test_parse_custom_tool_calls.py @@ -3,8 +3,9 @@ from typing import Any, Dict, List, cast from openai._types import omit -from openai.types.chat import ChatCompletion +from openai.types.chat import ChatCompletion, ParsedFunctionToolCall from openai.lib._parsing import parse_chat_completion +from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall _FUNCTION_CALL: Dict[str, Any] = { "id": "call_fn", @@ -35,37 +36,78 @@ def _completion_with_tool_calls(tool_calls: List[Dict[str, Any]]) -> ChatComplet ) -def _dump_tool_calls(completion: ChatCompletion) -> List[Dict[str, Any]]: - parsed = parse_chat_completion(chat_completion=completion, response_format=omit, input_tools=omit) +def _parse(tool_calls: List[Dict[str, Any]]) -> Any: # `cast` avoids the generic `ResponseFormatT` (unbound here) leaking `Unknown` - # into attribute access under strict type checking. Dump each tool call - # individually so a custom call is serialized by its own type rather than the - # message field's declared `list[ParsedFunctionToolCall]`. - tool_calls = cast("Any", parsed).choices[0].message.tool_calls - assert tool_calls is not None - return [tc.model_dump() for tc in tool_calls] + # into attribute access under strict type checking. + return cast( + "Any", + parse_chat_completion( + chat_completion=_completion_with_tool_calls(tool_calls), + response_format=omit, + input_tools=omit, + ), + ) + + +def _dumped_tool_calls(parsed: Any) -> List[Dict[str, Any]]: + # Dumps the whole completion rather than each tool call on its own: pydantic + # serializes by the declared field type, so this is the path that used to + # silently drop the `custom` payload. + return cast("List[Dict[str, Any]]", parsed.model_dump()["choices"][0]["message"]["tool_calls"]) def test_parse_preserves_custom_tool_call() -> None: # Regression: a `custom` tool call used to be logged and discarded by # `parse_chat_completion`, so `.parse()` returned `tool_calls=None` and the # call the model made vanished from the parsed completion. - dumped = _dump_tool_calls(_completion_with_tool_calls([_CUSTOM_CALL])) + parsed = _parse([_CUSTOM_CALL]) - assert len(dumped) == 1 - assert dumped[0]["type"] == "custom" - assert dumped[0]["id"] == "call_custom" - assert dumped[0]["custom"]["name"] == "run_python" - assert dumped[0]["custom"]["input"] == "print(1)" + tool_calls = parsed.choices[0].message.tool_calls + assert tool_calls is not None + assert len(tool_calls) == 1 + assert isinstance(tool_calls[0], ChatCompletionMessageCustomToolCall) + assert tool_calls[0].custom.name == "run_python" + assert tool_calls[0].custom.input == "print(1)" + + # ...and it survives serialization of the whole completion, which the narrowed + # `list[ParsedFunctionToolCall]` annotation used to defeat: the dump came back + # as `{"id": ..., "type": "custom"}` with `custom` missing entirely. + assert _dumped_tool_calls(parsed) == [ + {"id": "call_custom", "type": "custom", "custom": {"name": "run_python", "input": "print(1)"}} + ] def test_parse_preserves_custom_alongside_function_tool_call() -> None: - dumped = _dump_tool_calls(_completion_with_tool_calls([_FUNCTION_CALL, _CUSTOM_CALL])) + parsed = _parse([_FUNCTION_CALL, _CUSTOM_CALL]) + tool_calls = parsed.choices[0].message.tool_calls + assert tool_calls is not None + # the function member still resolves to the parsed subclass, which is what + # widening the annotation to a union had to preserve. `parsed_arguments` stays + # None here because no `input_tools` were passed, so there is no schema to parse + # the arguments against; the point is that the field exists at all. + assert isinstance(tool_calls[0], ParsedFunctionToolCall) + assert tool_calls[0].function.name == "get_weather" + assert tool_calls[0].function.parsed_arguments is None + assert isinstance(tool_calls[1], ChatCompletionMessageCustomToolCall) + + dumped = _dumped_tool_calls(parsed) assert [tc["type"] for tc in dumped] == ["function", "custom"] - # the function call is still parsed as before (gets `parsed_arguments`) - assert dumped[0]["function"]["name"] == "get_weather" - assert "parsed_arguments" in dumped[0]["function"] - # the custom call is surfaced unchanged rather than dropped - assert dumped[1]["id"] == "call_custom" - assert dumped[1]["custom"]["name"] == "run_python" + assert dumped[0]["function"] == {"name": "get_weather", "arguments": "{}", "parsed_arguments": None} + assert dumped[1]["custom"] == {"name": "run_python", "input": "print(1)"} + + +def test_parse_round_trips_a_custom_tool_call() -> None: + # A dumped completion has to validate back into an equivalent model, which is + # what a caller persisting and replaying a parsed completion depends on. + parsed = _parse([_FUNCTION_CALL, _CUSTOM_CALL]) + + reloaded = ChatCompletion.model_validate(parsed.model_dump()) + + tool_calls = reloaded.choices[0].message.tool_calls + assert tool_calls is not None + assert [tc.type for tc in tool_calls] == ["function", "custom"] + custom = tool_calls[1] + assert isinstance(custom, ChatCompletionMessageCustomToolCall) + assert custom.custom.name == "run_python" + assert custom.custom.input == "print(1)" From c80482a2f2f7f567c69903da25fbdf4654ffe2de Mon Sep 17 00:00:00 2001 From: Pranav Mishra Date: Mon, 3 Aug 2026 15:12:22 -0700 Subject: [PATCH 3/3] Scope custom tool-call support to non-streaming parse() and pin why The streaming half of the earlier claim was wrong. `ChoiceDeltaToolCall.type` is `Optional[Literal["function"]]`, so a streamed custom tool call fails chunk validation before `get_final_completion()` reaches `parse_chat_completion` at all: Input should be 'function' [type=literal_error, input_value='custom'] Extending that path means widening the generated chunk delta types plus the streaming accumulator, which is a separate change and depends on the wire shape the API actually emits for streamed custom calls. Records the boundary in code rather than only in prose: test_streaming_deltas_cannot_carry_a_custom_tool_call_yet asserts the validation error, so when the generated types gain a custom member the test fails and points at the streaming half instead of it being found in the field. The branch comment in _completions.py now says non-streaming only and references that test. --- src/openai/lib/_parsing/_completions.py | 17 ++++++---- .../lib/chat/test_parse_custom_tool_calls.py | 34 ++++++++++++++++++- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/openai/lib/_parsing/_completions.py b/src/openai/lib/_parsing/_completions.py index fca4eafdd9..fde3bfc574 100644 --- a/src/openai/lib/_parsing/_completions.py +++ b/src/openai/lib/_parsing/_completions.py @@ -125,12 +125,17 @@ def parse_chat_completion( ) ) elif tool_call.type == "custom": - # `.parse()` doesn't attach `parsed_arguments` to custom tool calls - # (there's no schema to parse their free-form input against), but the - # call must still be surfaced rather than dropped — the raw completion - # 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. + # No `parsed_arguments` for a custom call: there's no schema to parse + # its free-form input against. The call still has to be surfaced + # rather than dropped, because the raw completion 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. + # + # Non-streaming only. A streamed custom call cannot reach here at all: + # `ChoiceDeltaToolCall.type` is `Literal["function"]`, so the chunk + # fails validation before `get_final_completion()` gets this far. See + # test_streaming_deltas_cannot_carry_a_custom_tool_call_yet. tool_calls.append(tool_call) elif TYPE_CHECKING: # type: ignore[unreachable] assert_never(tool_call) diff --git a/tests/lib/chat/test_parse_custom_tool_calls.py b/tests/lib/chat/test_parse_custom_tool_calls.py index bb9a2cce2a..facf11ca41 100644 --- a/tests/lib/chat/test_parse_custom_tool_calls.py +++ b/tests/lib/chat/test_parse_custom_tool_calls.py @@ -2,8 +2,11 @@ from typing import Any, Dict, List, cast +import pytest +import pydantic + from openai._types import omit -from openai.types.chat import ChatCompletion, ParsedFunctionToolCall +from openai.types.chat import ChatCompletion, ChatCompletionChunk, ParsedFunctionToolCall from openai.lib._parsing import parse_chat_completion from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall @@ -111,3 +114,32 @@ def test_parse_round_trips_a_custom_tool_call() -> None: assert isinstance(custom, ChatCompletionMessageCustomToolCall) assert custom.custom.name == "run_python" assert custom.custom.input == "print(1)" + + +def test_streaming_deltas_cannot_carry_a_custom_tool_call_yet() -> None: + """Pins why this fix is scoped to non-streaming `.parse()`. + + `ChoiceDeltaToolCall.type` is `Optional[Literal["function"]]`, so a custom + tool-call delta fails validation well before `get_final_completion()` reaches + `parse_chat_completion`. Extending the streaming path means widening the + generated chunk delta types and the accumulator, which is a separate change. + + When those types do gain a custom member this test starts failing, which is the + signal to revisit the streaming half rather than discovering it in the field. + """ + chunk: Dict[str, Any] = { + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-5", + "choices": [ + { + "index": 0, + "finish_reason": None, + "delta": {"role": "assistant", "tool_calls": [{"index": 0, **_CUSTOM_CALL}]}, + } + ], + } + + with pytest.raises(pydantic.ValidationError, match="Input should be 'function'"): + ChatCompletionChunk.model_validate(chunk)