From d1b33aa38b9fe6861c184d3a1eab858ffd11eeb2 Mon Sep 17 00:00:00 2001 From: Mohammad Hijjawi Date: Thu, 17 Sep 2026 10:55:46 +0000 Subject: [PATCH 1/2] fix: accept dict tool arguments in ChatMessage.from_openai_dict_format Some OpenAI-compatible servers send parsed argument objects. json.loads then raised TypeError. Invalid JSON strings are treated as an empty mapping. --- haystack/dataclasses/chat_message.py | 22 +++++++++++++++++- ...-dict-tool-arguments-c8e1a4b27f90d3a1.yaml | 6 +++++ test/dataclasses/test_chat_message.py | 23 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/fix-openai-dict-tool-arguments-c8e1a4b27f90d3a1.yaml diff --git a/haystack/dataclasses/chat_message.py b/haystack/dataclasses/chat_message.py index a305e4187c1..2263cc8a356 100644 --- a/haystack/dataclasses/chat_message.py +++ b/haystack/dataclasses/chat_message.py @@ -16,6 +16,25 @@ logger = logging.getLogger(__name__) +def _parse_openai_tool_call_arguments(raw_arguments: Any) -> dict[str, Any]: + """Parse OpenAI-style tool call arguments into a dictionary. + + OpenAI sends a JSON string. Some OpenAI-compatible servers already send a dict, omit the field, or send + null or an empty string for a zero-argument call. + """ + if isinstance(raw_arguments, dict): + return raw_arguments + if not raw_arguments: + return {} + if not isinstance(raw_arguments, str): + return {} + try: + parsed = json.loads(raw_arguments) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + + class ChatRole(str, Enum): """ Enumeration representing the roles within a chat. @@ -815,11 +834,12 @@ def from_openai_dict_format(cls, message: dict[str, Any]) -> "ChatMessage": for tc in tool_calls: # Zero-argument tool calls from OpenAI-compatible servers may send an # empty string, null, or omit `arguments` entirely; treat all as {}. + # Some servers also send a parsed dict instead of a JSON string. raw_arguments = tc["function"].get("arguments") haystack_tc = ToolCall( id=tc.get("id"), tool_name=tc["function"]["name"], - arguments=json.loads(raw_arguments) if raw_arguments else {}, + arguments=_parse_openai_tool_call_arguments(raw_arguments), ) haystack_tool_calls.append(haystack_tc) return cls.from_assistant(text=content, name=name, tool_calls=haystack_tool_calls) diff --git a/releasenotes/notes/fix-openai-dict-tool-arguments-c8e1a4b27f90d3a1.yaml b/releasenotes/notes/fix-openai-dict-tool-arguments-c8e1a4b27f90d3a1.yaml new file mode 100644 index 00000000000..83d3ad5a564 --- /dev/null +++ b/releasenotes/notes/fix-openai-dict-tool-arguments-c8e1a4b27f90d3a1.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + ``ChatMessage.from_openai_dict_format`` now accepts tool-call ``arguments`` that are already a dictionary, + and treats invalid JSON strings as an empty mapping, instead of raising ``TypeError`` or ``JSONDecodeError``. + Some OpenAI-compatible servers send a parsed object rather than a JSON string. diff --git a/test/dataclasses/test_chat_message.py b/test/dataclasses/test_chat_message.py index b476b59c80c..4fcb67ccfd4 100644 --- a/test/dataclasses/test_chat_message.py +++ b/test/dataclasses/test_chat_message.py @@ -1082,6 +1082,29 @@ def test_from_openai_dict_format_tool_call_with_missing_arguments(self): assert message.tool_call is not None assert message.tool_call.arguments == {} + def test_from_openai_dict_format_tool_call_with_dict_arguments(self): + # Some OpenAI-compatible servers already parse arguments into a dict. + openai_msg = { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_123", "function": {"name": "get_weather", "arguments": {"location": "Berlin"}}} + ], + } + message = ChatMessage.from_openai_dict_format(openai_msg) + assert message.tool_call is not None + assert message.tool_call.arguments == {"location": "Berlin"} + + def test_from_openai_dict_format_tool_call_with_invalid_json_arguments(self): + openai_msg = { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call_1", "function": {"name": "now", "arguments": "{not-json"}}], + } + message = ChatMessage.from_openai_dict_format(openai_msg) + assert message.tool_call is not None + assert message.tool_call.arguments == {} + def test_from_openai_dict_format_tool_message(self): openai_msg = {"role": "tool", "content": "The weather is sunny", "tool_call_id": "call_123"} message = ChatMessage.from_openai_dict_format(openai_msg) From 7dcac5969dc3519d0f7d72bff19b01a16fed8147 Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee Date: Mon, 28 Sep 2026 08:54:16 +0200 Subject: [PATCH 2/2] fix: keep raising on invalid JSON tool call arguments --- haystack/dataclasses/chat_message.py | 19 +++++++------------ ...-dict-tool-arguments-c8e1a4b27f90d3a1.yaml | 5 ++--- test/dataclasses/test_chat_message.py | 5 ++--- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/haystack/dataclasses/chat_message.py b/haystack/dataclasses/chat_message.py index 2263cc8a356..e18a6221db7 100644 --- a/haystack/dataclasses/chat_message.py +++ b/haystack/dataclasses/chat_message.py @@ -17,22 +17,17 @@ def _parse_openai_tool_call_arguments(raw_arguments: Any) -> dict[str, Any]: - """Parse OpenAI-style tool call arguments into a dictionary. + """ + Parse OpenAI-style tool call arguments into a dictionary. + + A dict is returned as is, a missing, null, or empty value becomes `{}`, and anything else is parsed as a + JSON string. - OpenAI sends a JSON string. Some OpenAI-compatible servers already send a dict, omit the field, or send - null or an empty string for a zero-argument call. + :raises json.JSONDecodeError: If the arguments are not valid JSON. """ if isinstance(raw_arguments, dict): return raw_arguments - if not raw_arguments: - return {} - if not isinstance(raw_arguments, str): - return {} - try: - parsed = json.loads(raw_arguments) - except json.JSONDecodeError: - return {} - return parsed if isinstance(parsed, dict) else {} + return json.loads(raw_arguments) if raw_arguments else {} class ChatRole(str, Enum): diff --git a/releasenotes/notes/fix-openai-dict-tool-arguments-c8e1a4b27f90d3a1.yaml b/releasenotes/notes/fix-openai-dict-tool-arguments-c8e1a4b27f90d3a1.yaml index 83d3ad5a564..4fe034a59c8 100644 --- a/releasenotes/notes/fix-openai-dict-tool-arguments-c8e1a4b27f90d3a1.yaml +++ b/releasenotes/notes/fix-openai-dict-tool-arguments-c8e1a4b27f90d3a1.yaml @@ -1,6 +1,5 @@ --- fixes: - | - ``ChatMessage.from_openai_dict_format`` now accepts tool-call ``arguments`` that are already a dictionary, - and treats invalid JSON strings as an empty mapping, instead of raising ``TypeError`` or ``JSONDecodeError``. - Some OpenAI-compatible servers send a parsed object rather than a JSON string. + ``ChatMessage.from_openai_dict_format`` now accepts tool-call ``arguments`` that are already a dictionary + instead of raising a ``TypeError``. Some OpenAI-compatible servers send a parsed object rather than a JSON string. diff --git a/test/dataclasses/test_chat_message.py b/test/dataclasses/test_chat_message.py index 4fcb67ccfd4..5b4fd1e042e 100644 --- a/test/dataclasses/test_chat_message.py +++ b/test/dataclasses/test_chat_message.py @@ -1101,9 +1101,8 @@ def test_from_openai_dict_format_tool_call_with_invalid_json_arguments(self): "content": None, "tool_calls": [{"id": "call_1", "function": {"name": "now", "arguments": "{not-json"}}], } - message = ChatMessage.from_openai_dict_format(openai_msg) - assert message.tool_call is not None - assert message.tool_call.arguments == {} + with pytest.raises(json.JSONDecodeError): + ChatMessage.from_openai_dict_format(openai_msg) def test_from_openai_dict_format_tool_message(self): openai_msg = {"role": "tool", "content": "The weather is sunny", "tool_call_id": "call_123"}