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
This commit is contained in:
Sebastian Husch Lee 2025-07-03 08:50:29 +02:00 committed by GitHub
parent 9fd552f906
commit 16fc41cd95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 147 additions and 11 deletions

View File

@ -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:

View File

@ -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.

View File

@ -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"))

View File

@ -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!",

View File

@ -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")