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
10 changes: 8 additions & 2 deletions src/openai/lib/_parsing/_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,22 @@ def parse_function_tool_arguments(
if not input_tool:
return None

# The API sends empty-string arguments (rather than `{}`) for strict tools that take no
# arguments or whose parameters are all optional, so normalize those to an empty object.
args = function.arguments
if not args or not args.strip():
args = "{}"

input_fn = cast(object, input_tool.get("function"))
if isinstance(input_fn, PydanticFunctionTool):
return model_parse_json(input_fn.model, function.arguments)
return model_parse_json(input_fn.model, args)

input_fn = cast(FunctionDefinition, input_fn)

if not input_fn.get("strict"):
return None

return json.loads(function.arguments) # type: ignore[no-any-return]
return json.loads(args) # type: ignore[no-any-return]


def maybe_parse_content(
Expand Down
10 changes: 8 additions & 2 deletions src/openai/lib/_parsing/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,17 @@ def parse_function_tool_arguments(
if not input_tool:
return None

# The API sends empty-string arguments (rather than `{}`) for strict tools that take no
# arguments or whose parameters are all optional, so normalize those to an empty object.
args = function_call.arguments
if not args or not args.strip():
args = "{}"

tool = cast(object, input_tool)
if isinstance(tool, ResponsesPydanticFunctionTool):
return model_parse_json(tool.model, function_call.arguments)
return model_parse_json(tool.model, args)

if not input_tool.get("strict"):
return None

return json.loads(function_call.arguments)
return json.loads(args)
87 changes: 87 additions & 0 deletions tests/lib/chat/test_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,18 @@

import openai
from openai import OpenAI, AsyncOpenAI
from openai._types import omit
from openai._utils import assert_signatures_in_sync
from openai._compat import PYDANTIC_V1
from openai.types.chat import (
ChatCompletion,
ChatCompletionMessage,
ChatCompletionToolParam,
ChatCompletionMessageFunctionToolCall,
)
from openai.lib._parsing._completions import parse_chat_completion, parse_function_tool_arguments
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_message_function_tool_call import Function

from ..utils import print_obj
from ...conftest import base_url
Expand Down Expand Up @@ -999,3 +1009,80 @@ def test_parse_method_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpe
checking_client.chat.completions.parse,
exclude_params={"response_format", "stream"},
)


# a strict function tool with no parameters; the API returns empty-string arguments for these
STRICT_NOOP_TOOL: ChatCompletionToolParam = {
"type": "function",
"function": {
"name": "noop",
"strict": True,
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
},
}


@pytest.mark.parametrize("arguments", ["", " \n ", "\t"])
def test_parse_function_tool_empty_arguments(arguments: str) -> None:
# the API sends empty-string arguments (not `{}`) for strict tools with no/all-optional
# params, which used to raise json.JSONDecodeError instead of returning an empty object
assert (
parse_function_tool_arguments(
input_tools=[STRICT_NOOP_TOOL],
function=Function(name="noop", arguments=arguments),
)
== {}
)


def test_parse_function_tool_non_empty_arguments_still_parsed() -> None:
# regression guard: non-empty arguments must be unaffected by the empty-string handling
assert parse_function_tool_arguments(
input_tools=[STRICT_NOOP_TOOL],
function=Function(name="noop", arguments='{"city": "SF"}'),
) == {"city": "SF"}


def test_parse_pydantic_function_tool_empty_arguments() -> None:
class NoArgs(BaseModel):
pass

result = parse_function_tool_arguments(
input_tools=[openai.pydantic_function_tool(NoArgs)],
function=Function(name="NoArgs", arguments=""),
)
assert isinstance(result, NoArgs)


def test_parse_chat_completion_empty_function_tool_arguments() -> None:
completion = parse_chat_completion(
response_format=omit,
input_tools=[STRICT_NOOP_TOOL],
chat_completion=ChatCompletion.construct(
id="chatcmpl-1",
created=0,
model="gpt-4o-2024-08-06",
object="chat.completion",
choices=[
Choice.construct(
index=0,
finish_reason="tool_calls",
message=ChatCompletionMessage.construct(
role="assistant",
content=None,
tool_calls=[
ChatCompletionMessageFunctionToolCall.construct(
id="call_1",
type="function",
function=Function(name="noop", arguments=""),
)
],
),
)
],
),
)

tool_calls = completion.choices[0].message.tool_calls
assert tool_calls is not None
assert tool_calls[0].function.parsed_arguments == {}
62 changes: 61 additions & 1 deletion tests/lib/chat/test_completions_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
from openai import OpenAI, AsyncOpenAI
from openai._utils import consume_sync_iterator, assert_signatures_in_sync
from openai._compat import model_copy
from openai.types.chat import ChatCompletionChunk
from openai._models import construct_type_unchecked
from openai.types.chat import ChatCompletionChunk, ChatCompletionToolParam
from openai.lib.streaming.chat import (
ContentDoneEvent,
ChatCompletionStream,
Expand Down Expand Up @@ -1069,6 +1070,65 @@ def streamer(client: OpenAI) -> Iterator[ChatCompletionChunk]:
)


def test_stream_state_handles_empty_function_tool_arguments() -> None:
# the API sends empty-string arguments (not `{}`) for strict tools with no/all-optional
# params; the done/final path used to leak a json.JSONDecodeError out of handle_chunk()
strict_noop_tool: ChatCompletionToolParam = {
"type": "function",
"function": {
"name": "noop",
"strict": True,
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
},
}
state = ChatCompletionStreamState(input_tools=[strict_noop_tool])

tool_call_chunk: dict[str, object] = {
"id": "chatcmpl-1",
"created": 0,
"model": "gpt-4o-2024-08-06",
"object": "chat.completion.chunk",
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"tool_calls": [
{
"index": 0,
"id": "call_1",
"type": "function",
"function": {"name": "noop", "arguments": ""},
}
],
},
"finish_reason": None,
}
],
}
finish_chunk: dict[str, object] = {
"id": "chatcmpl-1",
"created": 0,
"model": "gpt-4o-2024-08-06",
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
}

events: list[ChatCompletionStreamEvent[None]] = []
for value in (tool_call_chunk, finish_chunk):
chunk = construct_type_unchecked(type_=ChatCompletionChunk, value=value)
events.extend(state.handle_chunk(chunk))

done_events = [event for event in events if event.type == "tool_calls.function.arguments.done"]
assert len(done_events) == 1
assert done_events[0].parsed_arguments == {}

completion = state.get_final_completion()
tool_calls = completion.choices[0].message.tool_calls
assert tool_calls is not None
assert tool_calls[0].function.parsed_arguments == {}


@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])
def test_stream_method_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None:
checking_client: OpenAI | AsyncOpenAI = client if sync else async_client
Expand Down
44 changes: 43 additions & 1 deletion tests/lib/responses/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from openai._utils import assert_signatures_in_sync
from openai._models import construct_type_unchecked
from openai.types.responses import Response
from openai.lib._parsing._responses import parse_response
from openai.lib._parsing._responses import parse_response, parse_function_tool_arguments
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall

from ...conftest import base_url
from ..snapshots import make_snapshot_request
Expand Down Expand Up @@ -72,6 +73,47 @@ def test_parse_response_preserves_program_items(item: dict[str, object]) -> None
assert parsed.output[0].to_dict() == item


@pytest.mark.parametrize("arguments", ["", " \n ", "\t"])
def test_parse_function_tool_empty_arguments(arguments: str) -> None:
# the API sends empty-string arguments (not `{}`) for strict tools with no/all-optional
# params, which used to raise json.JSONDecodeError instead of returning an empty object
function_call = ResponseFunctionToolCall.construct(
name="noop", arguments=arguments, call_id="call_1", type="function_call"
)
assert (
parse_function_tool_arguments(
input_tools=[
{
"type": "function",
"name": "noop",
"strict": True,
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
}
],
function_call=function_call,
)
== {}
)


def test_parse_function_tool_non_empty_arguments_still_parsed() -> None:
# regression guard: non-empty arguments must be unaffected by the empty-string handling
function_call = ResponseFunctionToolCall.construct(
name="noop", arguments='{"city": "SF"}', call_id="call_1", type="function_call"
)
assert parse_function_tool_arguments(
input_tools=[
{
"type": "function",
"name": "noop",
"strict": True,
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
}
],
function_call=function_call,
) == {"city": "SF"}


@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])
def test_stream_method_definition_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None:
checking_client: OpenAI | AsyncOpenAI = client if sync else async_client
Expand Down