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 7a1bded1de..fde3bfc574 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": @@ -124,13 +125,18 @@ 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, - ) + # 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) else: 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 new file mode 100644 index 0000000000..facf11ca41 --- /dev/null +++ b/tests/lib/chat/test_parse_custom_tool_calls.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from typing import Any, Dict, List, cast + +import pytest +import pydantic + +from openai._types import omit +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 + +_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 _parse(tool_calls: List[Dict[str, Any]]) -> Any: + # `cast` avoids the generic `ResponseFormatT` (unbound here) leaking `Unknown` + # 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. + parsed = _parse([_CUSTOM_CALL]) + + 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: + 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"] + 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)" + + +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)