Repository: deepset-ai/haystack

Issue:
ValueError when using streaming with `qwen-plus-latest` and `dashscope`
**Describe the bug**
Created based on this PR https://github.com/deepset-ai/haystack/pull/9565

> This pull request addresses a `ValueError` that occurs in `OpenAIChatGenerator` when processing a streaming response that includes tool calls. The issue arises because some LLM providers, like qwen-plus, can occasionally send a `tool_calls` delta chunk where the `function` object has both `name` and `arguments` as `None`.
>
> This empty delta causes a `ValueError` during the instantiation of the `ToolCallDelta` dataclass, as its `__post_init__` validation requires at least `tool_name` or `arguments` to be present. The application crashes with the following error:

https://github.com/deepset-ai/haystack/blob/c54a68ab631ba9af23b37a5cda2478eca3353bf3/haystack/dataclasses/streaming_chunk.py#L33-L37

```
ValueError: At least one of tool_name or arguments must be provided.
```

**Expected behavior**
For streaming to work with LLMs that return ChatCompletionChunks with empty ChoiceDeltaToolCalls.

**Additional context**
This was identified to occur when using the provider https://dashscope.aliyuncs.com/compatible-mode/v1 and the model `qwen-plus-latest`.

**To Reproduce**
Reproducing from the original PR
You can use the simplest Agent Tools sample to test.

Please customize the api_base_url of OpenAIChatGenerator to https://dashscope.aliyuncs.com/compatible-mode/v1. This is the compatible endpoint of OpenAI.

```python
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack.tools.tool import Tool
from haystack.components.agents import Agent
from typing import List
from haystack.components.generators.utils import print_streaming_chunk
from haystack.utils import Secret
from haystack import component
from haystack.tools import ComponentTool

@component
class rephraser:

    def __init__(self):
        self.result = ""

    @component.output_types(result=str)
    def run(self, query: str) -> dict:
        """
        Rephrase the query.

        Args:
            query: The query to rephrase.

        Returns:
            The rephrased query.
        """
        try:
            result = "Json had lunch?"
            return {"result": result}
        except Exception as e:
            return {"error": str(e)}

# Tool Definition
rephraser_tool = ComponentTool(
    component=rephraser(),
    name="rephraser",
    description="Rephrase the query.",
    parameters={
        "type": "object",
        "properties": {
            "query": {"type": "string", "description": "Query to rephrase"}
        },
        "required": ["query"]
    },
)

# Agent Setup
agent = Agent(
    chat_generator=OpenAIChatGenerator(
        api_base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
        api_key=Secret.from_token("xxxxxxxxxx"),
        model="qwen-plus-latest",
        streaming_callback=print_streaming_chunk
    ),
    system_prompt="You are a helpful assistant. You can chat with the user and use tools to answer questions. Always start by using the 'rephraser' tool to rephrase the user's question. You can use the 'rephraser' tool to rephrase the user's question as many times as you want.",
    tools=[rephraser_tool],
)

# Run the Agent
agent.warm_up()
response = agent.run(messages=[ChatMessage.from_user("He had lunch?")])

# Output
print(response["messages"])
```

You will most likely see this error message. When you switch to deepseek or other advanced models, this problem is unlikely to occur.

**System:**
 - Haystack version (commit or version number): 2.15



Discussion:

Patch:
diff --git a/haystack/dataclasses/streaming_chunk.py b/haystack/dataclasses/streaming_chunk.py
index 7cd30e466f..107a0d55b2 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 0000000000..4789e696fe
--- /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.


Test patch:
diff --git a/test/components/generators/chat/test_openai.py b/test/components/generators/chat/test_openai.py
index dfbb2a39d5..9d051749fd 100644
--- a/test/components/generators/chat/test_openai.py
+++ b/test/components/generators/chat/test_openai.py
@@ -1177,6 +1177,32 @@ def test_convert_chat_completion_chunk_to_streaming_chunk(self, chat_completion_
             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 80d5372908..b428067224 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 1d53633026..af9fd8011f 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")