Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/parsing_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
22 changes: 14 additions & 8 deletions src/openai/lib/_parsing/_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ParsedChatCompletionMessage,
ChatCompletionToolUnionParam,
ChatCompletionFunctionToolParam,
ParsedChatCompletionMessageToolCallUnion,
completion_create_params,
)
from ..._exceptions import LengthFinishReasonError, ContentFilterFinishReasonError
Expand Down Expand Up @@ -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":
Expand All @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions src/openai/lib/streaming/chat/_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/openai/types/chat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions src/openai/types/chat/parsed_chat_completion.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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."""


Expand Down
145 changes: 145 additions & 0 deletions tests/lib/chat/test_parse_custom_tool_calls.py
Original file line number Diff line number Diff line change
@@ -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)