Files
wehub-resource-sync c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

370 lines
16 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from unittest.mock import MagicMock
import pytest
from haystack import Document, Pipeline, component
from haystack.components.agents.agent import Agent
from haystack.components.generators.chat import LLM
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.joiners.branch import BranchJoiner
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.routers.conditional_router import ConditionalRouter
from haystack.core.component.types import InputSocket, OutputSocket
from haystack.dataclasses import ChatMessage
from haystack.dataclasses.chat_message import ChatRole
from haystack.dataclasses.streaming_chunk import StreamingChunk
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.tools import Tool
from haystack.tools.toolset import Toolset
def sync_streaming_callback(chunk: StreamingChunk) -> None:
pass
@component
class MockChatGeneratorWithTools:
"""A mock chat generator that accepts a tools parameter."""
def to_dict(self) -> dict[str, Any]:
return {"type": "test_llm.MockChatGeneratorWithTools", "data": {}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MockChatGeneratorWithTools":
return cls()
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Reply with tools support")]}
@component.output_types(replies=list[ChatMessage])
async def run_async(
self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs
) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Async reply with tools support")]}
@component
class MockChatGenerator:
"""A mock chat generator that does NOT accept a tools parameter."""
def to_dict(self) -> dict[str, Any]:
return {"type": "test_llm.MockChatGenerator", "data": {}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MockChatGenerator":
return cls()
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Sync reply")]}
@component.output_types(replies=list[ChatMessage])
async def run_async(self, messages: list[ChatMessage], **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Async reply")]}
class TestLLM:
class TestInit:
USER_PROMPT = '{% message role="user" %}{{ query }}{% endmessage %}'
def test_is_subclass_of_agent(self):
assert issubclass(LLM, Agent)
def test_defaults(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert llm.chat_generator is not None
assert llm.tools == []
assert llm.system_prompt is None
assert llm.user_prompt == self.USER_PROMPT
assert llm.required_variables == "*"
assert llm.streaming_callback is None
def test_output_sockets(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert llm.__haystack_output__._sockets_dict == {
"messages": OutputSocket(name="messages", type=list[ChatMessage], receivers=[]),
"last_message": OutputSocket(name="last_message", type=ChatMessage, receivers=[]),
"token_usage": OutputSocket(name="token_usage", type=dict[str, Any], receivers=[]),
}
def test_detects_no_tools_support(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert llm._chat_generator_supports_tools is False
def test_detects_tools_support(self):
llm = LLM(chat_generator=MockChatGeneratorWithTools(), user_prompt=self.USER_PROMPT)
assert llm._chat_generator_supports_tools is True
def test_messages_required_when_no_prompt_variables(self):
llm = LLM(
chat_generator=MockChatGenerator(), user_prompt='{% message role="user" %}Hello world{% endmessage %}'
)
messages_socket = llm.__haystack_input__._sockets_dict["messages"]
assert isinstance(messages_socket, InputSocket)
assert messages_socket.is_mandatory
def test_messages_optional_when_prompt_has_variables(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
messages_socket = llm.__haystack_input__._sockets_dict["messages"]
assert isinstance(messages_socket, InputSocket)
assert not messages_socket.is_mandatory
def test_messages_optional_when_plain_prompt_has_variables(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt="Question: {{ query }}")
messages_socket = llm.__haystack_input__._sockets_dict["messages"]
assert isinstance(messages_socket, InputSocket)
assert not messages_socket.is_mandatory
assert "query" in llm.__haystack_input__._sockets_dict
def test_runtime_prompt_overrides_not_component_inputs(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert "system_prompt" not in llm.__haystack_input__._sockets_dict
assert "user_prompt" not in llm.__haystack_input__._sockets_dict
def test_raises_if_required_variables_empty(self):
with pytest.raises(ValueError, match="required_variables must not be empty"):
LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT, required_variables=[])
class TestSerialization:
def test_to_dict_excludes_agent_only_params(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
user_prompt = '{% message role="user" %}{{ query }}{% endmessage %}'
llm = LLM(chat_generator=OpenAIChatGenerator(), system_prompt="You are helpful.", user_prompt=user_prompt)
serialized = llm.to_dict()
assert serialized["type"] == "haystack.components.generators.chat.llm.LLM"
assert "chat_generator" in serialized["init_parameters"]
assert serialized["init_parameters"]["system_prompt"] == "You are helpful."
agent_only_params = [
"tools",
"exit_conditions",
"max_agent_steps",
"raise_on_tool_invocation_failure",
"tool_concurrency_limit",
"tool_streaming_callback_passthrough",
"confirmation_strategies",
"state_schema",
]
for param in agent_only_params:
assert param not in serialized["init_parameters"], (
f"Agent-only param '{param}' should not be serialized"
)
def test_to_dict_includes_llm_params(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
llm = LLM(
chat_generator=OpenAIChatGenerator(),
system_prompt="Be concise.",
user_prompt='{% message role="user" %}{{ query }}{% endmessage %}',
required_variables=["query"],
)
serialized = llm.to_dict()
assert serialized["init_parameters"]["system_prompt"] == "Be concise."
assert "{{ query }}" in serialized["init_parameters"]["user_prompt"]
assert serialized["init_parameters"]["required_variables"] == ["query"]
assert serialized["init_parameters"]["streaming_callback"] is None
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
data = {
"type": "haystack.components.generators.chat.llm.LLM",
"init_parameters": {
"chat_generator": {
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
"init_parameters": {
"model": "gpt-4o-mini",
"streaming_callback": None,
"api_base_url": None,
"organization": None,
"generation_kwargs": {},
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
},
"system_prompt": "You are helpful.",
"user_prompt": '{% message role="user" %}{{ query }}{% endmessage %}',
"required_variables": "*",
"streaming_callback": None,
},
}
llm = LLM.from_dict(data)
assert isinstance(llm, LLM)
assert isinstance(llm.chat_generator, OpenAIChatGenerator)
assert llm.system_prompt == "You are helpful."
assert llm.tools == []
def test_roundtrip(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
user_prompt = '{% message role="user" %}{{ query }}{% endmessage %}'
original = LLM(
chat_generator=OpenAIChatGenerator(), system_prompt="You are a poet.", user_prompt=user_prompt
)
restored = LLM.from_dict(original.to_dict())
assert isinstance(restored, LLM)
assert isinstance(restored.chat_generator, OpenAIChatGenerator)
assert restored.system_prompt == original.system_prompt
assert restored.tools == []
class TestRun:
USER_PROMPT = '{% message role="user" %}{{ query }}{% endmessage %}'
def test_run_accepts_messages_via_kwargs(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
prior_message = ChatMessage.from_user("Some prior context")
result = llm.run(query="What is 2+2?", messages=[prior_message])
assert result["last_message"].text == "Sync reply"
assert prior_message in result["messages"]
def test_run_without_messages(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
result = llm.run(query="What is 2+2?")
assert result["last_message"].text == "Sync reply"
user_messages = [m for m in result["messages"] if m.is_from(ChatRole.USER)]
assert any("What is 2+2?" in m.text for m in user_messages)
def test_run_with_plain_user_prompt(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt="Question: {{ query }}")
result = llm.run(query="What is 2+2?")
assert result["last_message"].text == "Sync reply"
user_messages = [m for m in result["messages"] if m.is_from(ChatRole.USER)]
assert any("Question: What is 2+2?" in m.text for m in user_messages)
@pytest.mark.asyncio
async def test_run_async_accepts_messages_via_kwargs(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
prior_message = ChatMessage.from_user("Some prior context")
result = await llm.run_async(query="What is 2+2?", messages=[prior_message])
assert result["last_message"].text == "Async reply"
assert prior_message in result["messages"]
class TestPipelineIntegration:
@pytest.fixture()
def document_store_with_docs(self):
store = InMemoryDocumentStore()
store.write_documents(
[
Document(content="The Eiffel Tower is located in Paris."),
Document(content="The Brandenburg Gate is in Berlin."),
Document(content="The Colosseum is in Rome."),
]
)
return store
def test_rag_pipeline(self, document_store_with_docs):
user_prompt = (
'{% message role="user" %}'
"Use the following documents to answer the question.\n"
"Documents:\n{% for doc in documents %}{{ doc.content }}\n{% endfor %}"
"Question: {{ query }}"
"{% endmessage %}"
)
llm = LLM(
chat_generator=MockChatGenerator(),
system_prompt="You are a knowledgeable assistant.",
user_prompt=user_prompt,
required_variables=["query", "documents"],
)
pipe = Pipeline()
pipe.add_component("retriever", InMemoryBM25Retriever(document_store=document_store_with_docs))
pipe.add_component("llm", llm)
pipe.connect("retriever.documents", "llm.documents")
query = "Where is the Colosseum?"
result = pipe.run(data={"retriever": {"query": query}, "llm": {"query": query}})
assert "llm" in result
llm_output = result["llm"]
assert "messages" in llm_output
assert "last_message" in llm_output
messages = llm_output["messages"]
assert messages[0].is_from(ChatRole.SYSTEM)
assert messages[0].text == "You are a knowledgeable assistant."
user_messages = [m for m in messages if m.is_from(ChatRole.USER)]
assert len(user_messages) == 1
rendered = user_messages[0].text
assert "Question: Where is the Colosseum?" in rendered
assert "Documents:" in rendered
assert "Colosseum" in rendered
assert llm_output["last_message"].is_from(ChatRole.ASSISTANT)
assert llm_output["last_message"].text == "Sync reply"
class TestLLMNotTriggeredByInjectedInput:
"""
Regression guard for the optional-messages scheduling hazard described in
https://github.com/deepset-ai/haystack/issues/11109.
When `user_prompt` contains template variables, `messages` is optional on the LLM.
An optional input with `sender=None` (i.e., injected directly via `pipeline.run`)
would flip `has_user_input()` to True and incorrectly trigger the component even
when its required inputs (e.g. `query`) never arrive.
"""
def test_llm_not_triggered_by_injected_streaming_callback(self):
@component
class Planner:
@component.output_types(messages=list[ChatMessage], last_role=str)
def run(self) -> dict:
return {"messages": [ChatMessage.from_user("hello")], "last_role": "assistant"}
chat_generator = MockChatGenerator()
llm = LLM(chat_generator=chat_generator)
chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("x")]})
router = ConditionalRouter(
routes=[
{
"condition": "{{ last_role == 'tool' }}",
"output": "{{ messages }}",
"output_name": "processing",
"output_type": list[ChatMessage],
},
{
"condition": "{{ True }}",
"output": "{{ messages }}",
"output_name": "planning",
"output_type": list[ChatMessage],
},
],
unsafe=True,
)
pipeline = Pipeline()
pipeline.add_component("planner", Planner())
pipeline.add_component("router", router)
pipeline.add_component("branch_joiner", BranchJoiner(type_=list[ChatMessage]))
pipeline.add_component("llm", llm)
pipeline.connect("planner.messages", "router.messages")
pipeline.connect("planner.last_role", "router.last_role")
pipeline.connect("router.processing", "branch_joiner.value")
pipeline.connect("branch_joiner.value", "llm.messages")
result = pipeline.run(data={"llm": {"streaming_callback": sync_streaming_callback}})
assert "llm" not in result
chat_generator.run.assert_not_called()