mirror of
https://github.com/deepset-ai/haystack.git
synced 2026-01-05 03:28:09 +00:00
* feat: add reasoning support for HuggingFaceAPIChatGenerator Add reasoning field extraction for both streaming and non-streaming modes in HuggingFaceAPIChatGenerator, aligning with the parent issue #9700 initiative to standardize reasoning support across all ChatGenerators. Changes: - Extract reasoning from choice.message.reasoning in non-streaming mode - Extract reasoning from choice.delta.reasoning in streaming mode - Accumulate reasoning chunks in streaming and create final ReasoningContent - Add backward compatibility check using hasattr() for API responses - Add 11 comprehensive tests covering all reasoning scenarios The reasoning content is stored in ChatMessage.reasoning field (not meta), following the pattern established in PR #9696. Closes #9701 Ref #9700 * fix: skip ReasoningContent in convert_message_to_hf_format for multi-turn Address PR review feedback: - Update convert_message_to_hf_format to explicitly skip ReasoningContent during conversion (HF API doesn't support reasoning in input messages) - Add multi-turn integration test with reasoning model - Add unit tests for ReasoningContent handling in conversion * fix: use reStructuredText formatting in release note Convert Markdown to reStructuredText: - Use double backticks for inline code - Use .. code:: python directive for code blocks * refactor: extract common reasoning extraction logic into helper function Address review feedback from @mpangrazzi: - Extract duplicated reasoning extraction code into _extract_reasoning_content() - Use helper function in streaming, sync, and async methods - Shorten release note to be more concise * test: increase max_tokens to 300 in multi-turn HuggingFaceAPIChatGenerator integration test for stability --------- Co-authored-by: Michele Pangrazzi <xmikex83@gmail.com>
152 lines
5.9 KiB
Python
152 lines
5.9 KiB
Python
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from haystack.dataclasses import ChatMessage, ChatRole, ImageContent, ReasoningContent, TextContent, ToolCall
|
|
from haystack.dataclasses.chat_message import ToolCallResult
|
|
from haystack.utils.device import ComponentDevice
|
|
from haystack.utils.hf import convert_message_to_hf_format, resolve_hf_device_map
|
|
|
|
|
|
def test_resolve_hf_device_map_only_device():
|
|
model_kwargs = resolve_hf_device_map(device=None, model_kwargs={})
|
|
assert model_kwargs["device_map"] == ComponentDevice.resolve_device(None).to_hf()
|
|
|
|
|
|
def test_resolve_hf_device_map_only_device_map():
|
|
model_kwargs = resolve_hf_device_map(device=None, model_kwargs={"device_map": "cpu"})
|
|
assert model_kwargs["device_map"] == "cpu"
|
|
|
|
|
|
def test_resolve_hf_device_map_device_and_device_map(caplog):
|
|
with caplog.at_level(logging.WARNING):
|
|
model_kwargs = resolve_hf_device_map(
|
|
device=ComponentDevice.from_str("cpu"), model_kwargs={"device_map": "cuda:0"}
|
|
)
|
|
assert "The parameters `device` and `device_map` from `model_kwargs` are both provided." in caplog.text
|
|
assert model_kwargs["device_map"] == "cuda:0"
|
|
|
|
|
|
def test_convert_message_to_hf_format():
|
|
message = ChatMessage.from_system("You are good assistant")
|
|
assert convert_message_to_hf_format(message) == {"role": "system", "content": "You are good assistant"}
|
|
|
|
message = ChatMessage.from_user("I have a question")
|
|
assert convert_message_to_hf_format(message) == {"role": "user", "content": "I have a question"}
|
|
|
|
message = ChatMessage.from_assistant(text="I have an answer", meta={"finish_reason": "stop"})
|
|
assert convert_message_to_hf_format(message) == {"role": "assistant", "content": "I have an answer"}
|
|
|
|
message = ChatMessage.from_assistant(
|
|
tool_calls=[ToolCall(id="123", tool_name="weather", arguments={"city": "Paris"})]
|
|
)
|
|
assert convert_message_to_hf_format(message) == {
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{"id": "123", "type": "function", "function": {"name": "weather", "arguments": {"city": "Paris"}}}
|
|
],
|
|
}
|
|
|
|
message = ChatMessage.from_assistant(tool_calls=[ToolCall(tool_name="weather", arguments={"city": "Paris"})])
|
|
assert convert_message_to_hf_format(message) == {
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [{"type": "function", "function": {"name": "weather", "arguments": {"city": "Paris"}}}],
|
|
}
|
|
|
|
tool_result = {"weather": "sunny", "temperature": "25"}
|
|
message = ChatMessage.from_tool(
|
|
tool_result=tool_result, origin=ToolCall(id="123", tool_name="weather", arguments={"city": "Paris"})
|
|
)
|
|
assert convert_message_to_hf_format(message) == {"role": "tool", "content": tool_result, "tool_call_id": "123"}
|
|
|
|
message = ChatMessage.from_tool(
|
|
tool_result=tool_result, origin=ToolCall(tool_name="weather", arguments={"city": "Paris"})
|
|
)
|
|
assert convert_message_to_hf_format(message) == {"role": "tool", "content": tool_result}
|
|
|
|
|
|
def test_convert_message_to_hf_invalid():
|
|
message = ChatMessage(_role=ChatRole.ASSISTANT, _content=[])
|
|
with pytest.raises(ValueError):
|
|
convert_message_to_hf_format(message)
|
|
|
|
message = ChatMessage(
|
|
_role=ChatRole.USER,
|
|
_content=[
|
|
TextContent(text="I have an answer"),
|
|
ToolCallResult(
|
|
result="result!",
|
|
origin=ToolCall(id="123", tool_name="weather", arguments={"city": "Paris"}),
|
|
error=None,
|
|
),
|
|
],
|
|
)
|
|
with pytest.raises(ValueError):
|
|
convert_message_to_hf_format(message)
|
|
|
|
|
|
def test_convert_message_to_hf_format_with_multiple_images(base64_image_string):
|
|
image1 = ImageContent(base64_image=base64_image_string)
|
|
image2 = ImageContent(base64_image=base64_image_string)
|
|
message = ChatMessage.from_user(content_parts=["Compare these images", image1, image2])
|
|
|
|
result = convert_message_to_hf_format(message)
|
|
expected = {
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "Compare these images"},
|
|
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image_string}"}},
|
|
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image_string}"}},
|
|
],
|
|
}
|
|
assert result == expected
|
|
|
|
|
|
def test_convert_message_to_hf_format_skips_reasoning_content():
|
|
"""
|
|
Test that ReasoningContent is properly skipped during conversion.
|
|
|
|
ReasoningContent is for human transparency only and is not sent to the HuggingFace API
|
|
because the API (which follows OpenAI-compatible format) does not support reasoning
|
|
in input messages.
|
|
"""
|
|
# Assistant message with text and reasoning
|
|
message = ChatMessage(
|
|
_role=ChatRole.ASSISTANT,
|
|
_content=[
|
|
TextContent(text="The answer is 42."),
|
|
ReasoningContent(reasoning_text="Let me think step by step..."),
|
|
],
|
|
)
|
|
result = convert_message_to_hf_format(message)
|
|
# ReasoningContent should be skipped, only text should be in the output
|
|
assert result == {"role": "assistant", "content": "The answer is 42."}
|
|
|
|
|
|
def test_convert_message_to_hf_format_with_tool_call_and_reasoning():
|
|
"""
|
|
Test that ReasoningContent is skipped when message contains tool calls.
|
|
"""
|
|
message = ChatMessage(
|
|
_role=ChatRole.ASSISTANT,
|
|
_content=[
|
|
ReasoningContent(reasoning_text="I need to check the weather."),
|
|
ToolCall(id="123", tool_name="weather", arguments={"city": "Paris"}),
|
|
],
|
|
)
|
|
result = convert_message_to_hf_format(message)
|
|
# ReasoningContent should be skipped
|
|
assert result == {
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{"id": "123", "type": "function", "function": {"name": "weather", "arguments": {"city": "Paris"}}}
|
|
],
|
|
}
|