diff --git a/haystack/components/generators/chat/openai.py b/haystack/components/generators/chat/openai.py index e649e2d2e27..90f324e1c11 100644 --- a/haystack/components/generators/chat/openai.py +++ b/haystack/components/generators/chat/openai.py @@ -39,6 +39,7 @@ ToolCallDelta, select_streaming_callback, ) +from haystack.dataclasses.chat_message import _parse_openai_tool_call_arguments from haystack.dataclasses.streaming_chunk import _invoke_streaming_callback from haystack.tools import ( ToolsType, @@ -668,7 +669,7 @@ def _convert_chat_completion_to_chat_message( for openai_tc in openai_tool_calls: arguments_str = openai_tc.function.arguments try: - arguments = json.loads(arguments_str) + arguments = _parse_openai_tool_call_arguments(arguments_str) tool_calls.append(ToolCall(id=openai_tc.id, tool_name=openai_tc.function.name, arguments=arguments)) except json.JSONDecodeError: logger.warning( diff --git a/haystack/components/generators/chat/openai_responses.py b/haystack/components/generators/chat/openai_responses.py index 94b91538326..7f50e35512b 100644 --- a/haystack/components/generators/chat/openai_responses.py +++ b/haystack/components/generators/chat/openai_responses.py @@ -29,6 +29,7 @@ ToolCallDelta, select_streaming_callback, ) +from haystack.dataclasses.chat_message import _parse_openai_tool_call_arguments from haystack.dataclasses.streaming_chunk import FinishReason, _invoke_streaming_callback from haystack.tools import ( ToolsType, @@ -692,7 +693,7 @@ def _convert_response_to_chat_message(responses: Response | ParsedResponse) -> C elif output.type == "function_call": try: - arguments = json.loads(output.arguments) + arguments = _parse_openai_tool_call_arguments(output.arguments) tool_calls.append( ToolCall( id=output.id, tool_name=output.name, arguments=arguments, extra={"call_id": output.call_id} diff --git a/releasenotes/notes/fix-openai-zero-argument-tool-call-e616b87f179be88b.yaml b/releasenotes/notes/fix-openai-zero-argument-tool-call-e616b87f179be88b.yaml new file mode 100644 index 00000000000..f5ae4702f44 --- /dev/null +++ b/releasenotes/notes/fix-openai-zero-argument-tool-call-e616b87f179be88b.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + ``OpenAIChatGenerator`` and ``OpenAIResponsesChatGenerator`` no longer drop a tool call whose arguments are an + empty string, and no longer raise ``TypeError`` when they are ``null``, in non-streaming responses. + OpenAI-compatible servers such as vLLM send these for a tool with no parameters. Both now become empty + arguments, as they already did in streaming responses and in ``ChatMessage.from_openai_dict_format``. diff --git a/test/components/generators/chat/test_openai.py b/test/components/generators/chat/test_openai.py index df3ca86f322..26f67fee2c8 100644 --- a/test/components/generators/chat/test_openai.py +++ b/test/components/generators/chat/test_openai.py @@ -37,6 +37,7 @@ OpenAIChatGenerator, _check_finish_reason, _convert_chat_completion_chunk_to_streaming_chunk, + _convert_chat_completion_to_chat_message, _make_schema_strict, ) from haystack.components.generators.utils import print_streaming_chunk @@ -2031,6 +2032,34 @@ def test_convert_usage_chunk_to_streaming_chunk(self) -> None: assert result.meta["model"] == "gpt-5-mini" assert result.meta["received_at"] is not None + @pytest.mark.parametrize("arguments", ["", None]) + def test_convert_chat_completion_with_zero_argument_tool_call(self, arguments: str | None) -> None: + # OpenAI-compatible servers such as vLLM send an empty string or null for a tool with no parameters + completion = ChatCompletion( + id="1", + model="gpt-5-mini", + object="chat.completion", + created=1234567890, + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + role="assistant", + tool_calls=[ + ChatCompletionMessageFunctionToolCall( + id="1", + type="function", + function=Function.model_construct(name="get_time", arguments=arguments), + ) + ], + ), + ) + ], + ) + message = _convert_chat_completion_to_chat_message(completion, completion.choices[0]) + assert message.tool_calls == [ToolCall(id="1", tool_name="get_time", arguments={})] + class TestMakeSchemaStrict: def test_flat_object(self) -> None: diff --git a/test/components/generators/chat/test_openai_responses_conversion.py b/test/components/generators/chat/test_openai_responses_conversion.py index 4e5e3cb192c..aa61a43fcf1 100644 --- a/test/components/generators/chat/test_openai_responses_conversion.py +++ b/test/components/generators/chat/test_openai_responses_conversion.py @@ -1296,6 +1296,20 @@ def test_convert_incomplete_response_with_finish_reason( assert message.meta["finish_reason"] == finish_reason + @pytest.mark.parametrize("arguments", ["", None]) + def test_convert_zero_argument_function_call(self, arguments: str | None) -> None: + # OpenAI-compatible servers such as vLLM send an empty string or null for a tool with no parameters + function_call = ResponseFunctionToolCall.model_construct( + arguments=arguments, call_id="call_1", name="get_time", type="function_call", id="fc_1", status="completed" + ) + response = Response.model_construct(output=[function_call], output_text=None, status="completed") + + message = _convert_response_to_chat_message(response) + + assert message.tool_calls == [ + ToolCall(id="fc_1", tool_name="get_time", arguments={}, extra={"call_id": "call_1"}) + ] + def test_convert_system_message(self) -> None: message = ChatMessage.from_system("You are good assistant")