diff --git a/src/openai/lib/_parsing/_completions.py b/src/openai/lib/_parsing/_completions.py index 7a1bded1de..d8ca446e27 100644 --- a/src/openai/lib/_parsing/_completions.py +++ b/src/openai/lib/_parsing/_completions.py @@ -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( diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index c607587ec1..123a5c0891 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -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) diff --git a/tests/lib/chat/test_completions.py b/tests/lib/chat/test_completions.py index 741f5eaa75..c569cf0ae0 100644 --- a/tests/lib/chat/test_completions.py +++ b/tests/lib/chat/test_completions.py @@ -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 @@ -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 == {} diff --git a/tests/lib/chat/test_completions_streaming.py b/tests/lib/chat/test_completions_streaming.py index 598a41ee2b..6442a3110b 100644 --- a/tests/lib/chat/test_completions_streaming.py +++ b/tests/lib/chat/test_completions_streaming.py @@ -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, @@ -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 diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 4ed6dff47d..1ac1440d37 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -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 @@ -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