From d0c07c83261e647b12d866736ee410260aba3caf Mon Sep 17 00:00:00 2001 From: Pranav Mishra Date: Tue, 14 Jul 2026 12:15:21 -0700 Subject: [PATCH] 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"