Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/openai/lib/_parsing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
validate_input_tools as validate_input_tools,
parse_chat_completion as parse_chat_completion,
get_input_tool_by_name as get_input_tool_by_name,
materialize_input_tools as materialize_input_tools,
parse_function_tool_arguments as parse_function_tool_arguments,
type_to_response_format_param as type_to_response_format_param,
)
18 changes: 14 additions & 4 deletions src/openai/lib/_parsing/_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,23 @@ def select_strict_chat_completion_tools(
return [t for t in tools if is_strict_chat_completion_tool_param(t)]


def validate_input_tools(
def materialize_input_tools(
tools: Iterable[ChatCompletionToolUnionParam] | Omit = omit,
) -> Iterable[ChatCompletionFunctionToolParam] | Omit:
) -> list[ChatCompletionToolUnionParam] | Omit:
if not is_given(tools):
return omit

for tool in tools:
return list(tools)


def validate_input_tools(
tools: Iterable[ChatCompletionToolUnionParam] | Omit = omit,
) -> list[ChatCompletionFunctionToolParam] | Omit:
input_tools = materialize_input_tools(tools)
if not is_given(input_tools):
return omit

for tool in input_tools:
if tool["type"] != "function":
raise ValueError(
f"Currently only `function` tool types support auto-parsing; Received `{tool['type']}`",
Expand All @@ -80,7 +90,7 @@ def validate_input_tools(
f"`{tool['function']['name']}` is not strict. Only `strict` function tools can be auto-parsed"
)

return cast(Iterable[ChatCompletionFunctionToolParam], tools)
return cast(list[ChatCompletionFunctionToolParam], input_tools)


def parse_chat_completion(
Expand Down
21 changes: 12 additions & 9 deletions src/openai/resources/chat/completions/completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
ResponseFormatT,
validate_input_tools as _validate_input_tools,
parse_chat_completion as _parse_chat_completion,
materialize_input_tools as _materialize_input_tools,
type_to_response_format_param as _type_to_response_format,
)
from ....lib.streaming.chat import ChatCompletionStreamManager, AsyncChatCompletionStreamManager
Expand Down Expand Up @@ -225,7 +226,7 @@ def parser(raw_completion: ChatCompletion) -> ParsedChatCompletion[ResponseForma
"stream_options": stream_options,
"temperature": temperature,
"tool_choice": tool_choice,
"tools": tools,
"tools": chat_completion_tools,
"top_logprobs": top_logprobs,
"top_p": top_p,
"user": user,
Expand Down Expand Up @@ -1633,6 +1634,8 @@ def stream(
When the context manager exits, the response will be closed, however the `stream` instance is still available outside
the context manager.
"""
chat_completion_tools = _materialize_input_tools(tools)

extra_headers = {
"X-Stainless-Helper-Method": "chat.completions.stream",
**(extra_headers or {}),
Expand Down Expand Up @@ -1671,7 +1674,7 @@ def stream(
stream_options=stream_options,
temperature=temperature,
tool_choice=tool_choice,
tools=tools,
tools=chat_completion_tools,
top_logprobs=top_logprobs,
top_p=top_p,
user=user,
Expand All @@ -1685,7 +1688,7 @@ def stream(
return ChatCompletionStreamManager(
api_request,
response_format=response_format,
input_tools=tools,
input_tools=chat_completion_tools,
)


Expand Down Expand Up @@ -1808,7 +1811,7 @@ class MathResponse(BaseModel):
print("answer: ", message.parsed.final_answer)
```
"""
_validate_input_tools(tools)
chat_completion_tools = _validate_input_tools(tools)

extra_headers = {
"X-Stainless-Helper-Method": "chat.completions.parse",
Expand All @@ -1819,7 +1822,7 @@ def parser(raw_completion: ChatCompletion) -> ParsedChatCompletion[ResponseForma
return _parse_chat_completion(
response_format=response_format,
chat_completion=raw_completion,
input_tools=tools,
input_tools=chat_completion_tools,
)

return await self._post(
Expand Down Expand Up @@ -1857,7 +1860,7 @@ def parser(raw_completion: ChatCompletion) -> ParsedChatCompletion[ResponseForma
"stream_options": stream_options,
"temperature": temperature,
"tool_choice": tool_choice,
"tools": tools,
"tools": chat_completion_tools,
"top_logprobs": top_logprobs,
"top_p": top_p,
"user": user,
Expand Down Expand Up @@ -3265,7 +3268,7 @@ def stream(
When the context manager exits, the response will be closed, however the `stream` instance is still available outside
the context manager.
"""
_validate_input_tools(tools)
chat_completion_tools = _materialize_input_tools(tools)

extra_headers = {
"X-Stainless-Helper-Method": "chat.completions.stream",
Expand Down Expand Up @@ -3304,7 +3307,7 @@ def stream(
stream_options=stream_options,
temperature=temperature,
tool_choice=tool_choice,
tools=tools,
tools=chat_completion_tools,
top_logprobs=top_logprobs,
top_p=top_p,
user=user,
Expand All @@ -3318,7 +3321,7 @@ def stream(
return AsyncChatCompletionStreamManager(
api_request,
response_format=response_format,
input_tools=tools,
input_tools=chat_completion_tools,
)


Expand Down
95 changes: 95 additions & 0 deletions tests/lib/chat/test_single_pass_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from __future__ import annotations

import json

import httpx2
import pytest

from openai import OpenAI, AsyncOpenAI
from tests.respx2 import MockRouter
from openai.types.chat import ChatCompletionToolUnionParam

from ...conftest import base_url


def mock_tool_call(
respx2_mock: MockRouter, *, streaming: bool = False, strict: bool = True
) -> ChatCompletionToolUnionParam:
tool: ChatCompletionToolUnionParam = {
"type": "function",
"function": {
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
},
"strict": strict,
},
}
tool_call: dict[str, object] = {
"id": "call-test",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city":"San Francisco"}'},
}
message = {"role": "assistant", "content": None, "tool_calls": [tool_call]}
response: dict[str, object] = {
"id": "chatcmpl-test",
"object": "chat.completion.chunk" if streaming else "chat.completion",
"created": 0,
"model": "gpt-test",
"choices": [{"index": 0, "delta" if streaming else "message": message, "finish_reason": "tool_calls"}],
}
if streaming:
tool_call["index"] = 0

def handle_request(request: httpx2.Request) -> httpx2.Response:
assert json.loads(request.content)["tools"] == [tool]
if streaming:
return httpx2.Response(
200,
text=f"data: {json.dumps(response)}\n\ndata: [DONE]\n\n",
headers={"content-type": "text/event-stream"},
)
return httpx2.Response(200, json=response)

respx2_mock.post("/chat/completions").mock(side_effect=handle_request)
return tool


@pytest.mark.respx2(base_url=base_url)
@pytest.mark.parametrize("use_async", [False, True])
@pytest.mark.asyncio
async def test_parse_preserves_single_pass_tools(
client: OpenAI, async_client: AsyncOpenAI, respx2_mock: MockRouter, use_async: bool
) -> None:
tools = iter([mock_tool_call(respx2_mock)])
if use_async:
completion = await async_client.chat.completions.parse(model="gpt-test", messages=[], tools=tools)
else:
completion = client.chat.completions.parse(model="gpt-test", messages=[], tools=tools)

tool_calls = completion.choices[0].message.tool_calls
assert tool_calls is not None
assert tool_calls[0].function.parsed_arguments == {"city": "San Francisco"}


@pytest.mark.respx2(base_url=base_url)
@pytest.mark.parametrize("use_async", [False, True])
@pytest.mark.parametrize("strict", [False, True])
@pytest.mark.asyncio
async def test_stream_preserves_single_pass_tools(
client: OpenAI, async_client: AsyncOpenAI, respx2_mock: MockRouter, use_async: bool, strict: bool
) -> None:
tools = iter([mock_tool_call(respx2_mock, streaming=True, strict=strict)])
if use_async:
async with async_client.chat.completions.stream(model="gpt-test", messages=[], tools=tools) as async_stream:
completion = await async_stream.get_final_completion()
else:
with client.chat.completions.stream(model="gpt-test", messages=[], tools=tools) as stream:
completion = stream.get_final_completion()

tool_calls = completion.choices[0].message.tool_calls
assert tool_calls is not None
assert tool_calls[0].function.parsed_arguments == ({"city": "San Francisco"} if strict else None)
Loading