From 16fc41cd95ef6bbb32679d96bed4837b96e4d357 Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:50:29 +0200 Subject: [PATCH] feat: Relax requirement for creating a ToolCallDelta dataclass (#9582) * Relax our requirement for ToolCallDelta to better match ChoiceDeltaToolCall and ChoiceDeltaToolCallFunction from OpenAI * Add reno * Update tests --- haystack/dataclasses/streaming_chunk.py | 6 - ...elax-tool-call-delta-a9e1f3e9c753cdf4.yaml | 4 + .../components/generators/chat/test_openai.py | 26 ++++ test/components/generators/test_utils.py | 117 ++++++++++++++++++ test/dataclasses/test_streaming_chunk.py | 5 - 5 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 releasenotes/notes/relax-tool-call-delta-a9e1f3e9c753cdf4.yaml diff --git a/haystack/dataclasses/streaming_chunk.py b/haystack/dataclasses/streaming_chunk.py index 7cd30e466..107a0d55b 100644 --- a/haystack/dataclasses/streaming_chunk.py +++ b/haystack/dataclasses/streaming_chunk.py @@ -30,12 +30,6 @@ class ToolCallDelta: arguments: Optional[str] = field(default=None) id: Optional[str] = field(default=None) # noqa: A003 - def __post_init__(self): - # NOTE: We allow for name and arguments to both be present because some providers like Mistral provide the - # name and full arguments in one chunk - if self.tool_name is None and self.arguments is None: - raise ValueError("At least one of tool_name or arguments must be provided.") - @dataclass class ComponentInfo: diff --git a/releasenotes/notes/relax-tool-call-delta-a9e1f3e9c753cdf4.yaml b/releasenotes/notes/relax-tool-call-delta-a9e1f3e9c753cdf4.yaml new file mode 100644 index 000000000..4789e696f --- /dev/null +++ b/releasenotes/notes/relax-tool-call-delta-a9e1f3e9c753cdf4.yaml @@ -0,0 +1,4 @@ +--- +enhancements: + - | + We relaxed the requirement that in ToolCallDelta (introduced in Haystack 2.15) which required the parameters arguments or name to be populated to be able to create a ToolCallDelta dataclass. We remove this requirement to be more in line with OpenAI's SDK and since this was causing errors for some hosted versions of open source models following OpenAI's SDK specification. diff --git a/test/components/generators/chat/test_openai.py b/test/components/generators/chat/test_openai.py index dfbb2a39d..9d051749f 100644 --- a/test/components/generators/chat/test_openai.py +++ b/test/components/generators/chat/test_openai.py @@ -1177,6 +1177,32 @@ class TestChatCompletionChunkConversion: assert stream_chunk == haystack_chunk previous_chunks.append(stream_chunk) + def test_convert_chat_completion_chunk_with_empty_tool_calls(self): + # This can happen with some LLM providers where tool calls are not present but the pydantic models are still + # initialized. + chunk = ChatCompletionChunk( + id="chatcmpl-BC1y4wqIhe17R8sv3lgLcWlB4tXCw", + choices=[ + chat_completion_chunk.Choice( + delta=chat_completion_chunk.ChoiceDelta( + tool_calls=[ChoiceDeltaToolCall(index=0, function=ChoiceDeltaToolCallFunction())] + ), + index=0, + ) + ], + created=1742207200, + model="gpt-4o-mini-2024-07-18", + object="chat.completion.chunk", + ) + result = _convert_chat_completion_chunk_to_streaming_chunk(chunk=chunk, previous_chunks=[]) + assert result.content == "" + assert result.start is False + assert result.tool_calls == [ToolCallDelta(index=0)] + assert result.tool_call_result is None + assert result.index == 0 + assert result.meta["model"] == "gpt-4o-mini-2024-07-18" + assert result.meta["received_at"] is not None + def test_handle_stream_response(self, chat_completion_chunks): openai_chunks = chat_completion_chunks comp = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key")) diff --git a/test/components/generators/test_utils.py b/test/components/generators/test_utils.py index 80d537290..b42806722 100644 --- a/test/components/generators/test_utils.py +++ b/test/components/generators/test_utils.py @@ -388,6 +388,123 @@ def test_convert_streaming_chunk_to_chat_message_two_tool_calls_in_same_chunk(): assert result.tool_calls[1].arguments == {"city": "Berlin"} +def test_convert_streaming_chunk_to_chat_message_empty_tool_call_delta(): + chunks = [ + StreamingChunk( + content="", + meta={ + "model": "gpt-4o-mini-2024-07-18", + "index": 0, + "tool_calls": None, + "finish_reason": None, + "received_at": "2025-02-19T16:02:55.910076", + }, + component_info=ComponentInfo(name="test", type="test"), + ), + StreamingChunk( + content="", + meta={ + "model": "gpt-4o-mini-2024-07-18", + "index": 0, + "tool_calls": [ + chat_completion_chunk.ChoiceDeltaToolCall( + index=0, + id="call_ZOj5l67zhZOx6jqjg7ATQwb6", + function=chat_completion_chunk.ChoiceDeltaToolCallFunction( + arguments='{"query":', name="rag_pipeline_tool" + ), + type="function", + ) + ], + "finish_reason": None, + "received_at": "2025-02-19T16:02:55.913919", + }, + component_info=ComponentInfo(name="test", type="test"), + index=0, + start=True, + tool_calls=[ + ToolCallDelta( + id="call_ZOj5l67zhZOx6jqjg7ATQwb6", tool_name="rag_pipeline_tool", arguments='{"query":', index=0 + ) + ], + ), + StreamingChunk( + content="", + meta={ + "model": "gpt-4o-mini-2024-07-18", + "index": 0, + "tool_calls": [ + chat_completion_chunk.ChoiceDeltaToolCall( + index=0, + function=chat_completion_chunk.ChoiceDeltaToolCallFunction( + arguments=' "Where does Mark live?"}' + ), + ) + ], + "finish_reason": None, + "received_at": "2025-02-19T16:02:55.924420", + }, + component_info=ComponentInfo(name="test", type="test"), + index=0, + tool_calls=[ToolCallDelta(arguments=' "Where does Mark live?"}', index=0)], + ), + StreamingChunk( + content="", + meta={ + "model": "gpt-4o-mini-2024-07-18", + "index": 0, + "tool_calls": [ + chat_completion_chunk.ChoiceDeltaToolCall( + index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction() + ) + ], + "finish_reason": "tool_calls", + "received_at": "2025-02-19T16:02:55.948772", + }, + tool_calls=[ToolCallDelta(index=0)], + component_info=ComponentInfo(name="test", type="test"), + finish_reason="tool_calls", + index=0, + ), + StreamingChunk( + content="", + meta={ + "model": "gpt-4o-mini-2024-07-18", + "index": 0, + "tool_calls": None, + "finish_reason": None, + "received_at": "2025-02-19T16:02:55.948772", + "usage": { + "completion_tokens": 42, + "prompt_tokens": 282, + "total_tokens": 324, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 0, + "rejected_prediction_tokens": 0, + }, + "prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 0}, + }, + }, + component_info=ComponentInfo(name="test", type="test"), + ), + ] + + # Convert chunks to a chat message + result = _convert_streaming_chunks_to_chat_message(chunks=chunks) + + assert not result.texts + assert not result.text + + # Verify both tool calls were found and processed + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].id == "call_ZOj5l67zhZOx6jqjg7ATQwb6" + assert result.tool_calls[0].tool_name == "rag_pipeline_tool" + assert result.tool_calls[0].arguments == {"query": "Where does Mark live?"} + assert result.meta["finish_reason"] == "tool_calls" + + def test_print_streaming_chunk_content_only(): chunk = StreamingChunk( content="Hello, world!", diff --git a/test/dataclasses/test_streaming_chunk.py b/test/dataclasses/test_streaming_chunk.py index 1d5363302..af9fd8011 100644 --- a/test/dataclasses/test_streaming_chunk.py +++ b/test/dataclasses/test_streaming_chunk.py @@ -99,11 +99,6 @@ def test_tool_call_delta(): assert tool_call.index == 0 -def test_tool_call_delta_with_missing_fields(): - with pytest.raises(ValueError): - _ = ToolCallDelta(id="123", index=0) - - def test_create_chunk_with_finish_reason(): """Test creating a chunk with the new finish_reason field.""" chunk = StreamingChunk(content="Test content", finish_reason="stop")