mirror of
https://github.com/deepset-ai/haystack.git
synced 2025-06-26 22:00:13 +00:00

* Starting to refactor type util tests to be more systematic * refactoring * Expand tests * Update to type utils * Add missing subclass check * Expand and refactor tests, introduce type_validation Literal * More test refactoring * Test refactoring, adding type validation variable to pipeline base * Update relaxed version of type checking to pass all newly added tests * trim whitespace * Add tests * cleanup * Updates docstrings * Add reno * docs * Fix mypy and add docstrings * Changes based on advice from Tobi * Remove unused imports * Doc strings * Add connection type validation to to_dict and from_dict * Update tests * Fix test * Also save connection_type_validation at global pipeline level * Fix tests * Remove connection type validation from the connect level, only keep at pipeline level * Formatting * Fix tests * formatting
290 lines
14 KiB
Python
290 lines
14 KiB
Python
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
import json
|
|
import os
|
|
from typing import Any, Dict, List
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import requests
|
|
|
|
from haystack import Pipeline
|
|
import pytest
|
|
from haystack.components.converters.openapi_functions import OpenAPIServiceToFunctions
|
|
from haystack.components.converters.output_adapter import OutputAdapter
|
|
from haystack.components.generators.chat.openai import OpenAIChatGenerator
|
|
from haystack.components.generators.utils import print_streaming_chunk
|
|
from haystack.dataclasses.byte_stream import ByteStream
|
|
from openapi3 import OpenAPI
|
|
|
|
from haystack.components.connectors import OpenAPIServiceConnector
|
|
from haystack.components.connectors.openapi_service import patch_request
|
|
from haystack.dataclasses import ChatMessage, ToolCall
|
|
|
|
|
|
@pytest.fixture
|
|
def openapi_service_mock():
|
|
return MagicMock(spec=OpenAPI)
|
|
|
|
|
|
class TestOpenAPIServiceConnector:
|
|
@pytest.fixture
|
|
def connector(self):
|
|
return OpenAPIServiceConnector()
|
|
|
|
def test_run_without_tool_calls(self, connector):
|
|
message = ChatMessage.from_assistant(text="Just a regular message")
|
|
with pytest.raises(ValueError, match="has no tool calls"):
|
|
connector.run(messages=[message], service_openapi_spec={})
|
|
|
|
def test_run_with_non_assistant_message(self, connector):
|
|
message = ChatMessage.from_user(text="User message")
|
|
with pytest.raises(ValueError, match="is not from the assistant"):
|
|
connector.run(messages=[message], service_openapi_spec={})
|
|
|
|
def test_authenticate_service_missing_authentication_token(self, connector, openapi_service_mock):
|
|
security_schemes_dict = {
|
|
"components": {"securitySchemes": {"apiKey": {"in": "header", "name": "x-api-key", "type": "apiKey"}}}
|
|
}
|
|
openapi_service_mock.raw_element = security_schemes_dict
|
|
|
|
with pytest.raises(ValueError, match="requires authentication but no credentials were provided"):
|
|
connector._authenticate_service(openapi_service_mock)
|
|
|
|
def test_authenticate_service_having_authentication_token(self, connector, openapi_service_mock):
|
|
security_schemes_dict = {
|
|
"components": {"securitySchemes": {"apiKey": {"in": "header", "name": "x-api-key", "type": "apiKey"}}}
|
|
}
|
|
openapi_service_mock.raw_element = security_schemes_dict
|
|
openapi_service_mock.components.securitySchemes.raw_element = {
|
|
"apiKey": {"in": "header", "name": "x-api-key", "type": "apiKey"}
|
|
}
|
|
connector._authenticate_service(openapi_service_mock, "some_fake_token")
|
|
openapi_service_mock.authenticate.assert_called_once_with("apiKey", "some_fake_token")
|
|
|
|
def test_authenticate_service_having_authentication_dict(self, connector, openapi_service_mock):
|
|
security_schemes_dict = {
|
|
"components": {"securitySchemes": {"apiKey": {"in": "header", "name": "x-api-key", "type": "apiKey"}}}
|
|
}
|
|
openapi_service_mock.raw_element = security_schemes_dict
|
|
openapi_service_mock.components.securitySchemes.raw_element = {
|
|
"apiKey": {"in": "header", "name": "x-api-key", "type": "apiKey"}
|
|
}
|
|
connector._authenticate_service(openapi_service_mock, {"apiKey": "some_fake_token"})
|
|
openapi_service_mock.authenticate.assert_called_once_with("apiKey", "some_fake_token")
|
|
|
|
def test_authenticate_service_having_unsupported_auth(self, connector, openapi_service_mock):
|
|
security_schemes_dict = {"components": {"securitySchemes": {"oauth2": {"type": "oauth2"}}}}
|
|
openapi_service_mock.raw_element = security_schemes_dict
|
|
openapi_service_mock.components.securitySchemes.raw_element = {"oauth2": {"type": "oauth2"}}
|
|
with pytest.raises(ValueError, match="Check the service configuration and credentials"):
|
|
connector._authenticate_service(openapi_service_mock, {"apiKey": "some_fake_token"})
|
|
|
|
@patch("haystack.components.connectors.openapi_service.OpenAPI")
|
|
def test_run_with_parameters(self, openapi_mock):
|
|
connector = OpenAPIServiceConnector()
|
|
tool_call = ToolCall(
|
|
tool_name="compare_branches",
|
|
arguments={"basehead": "main...some_branch", "owner": "deepset-ai", "repo": "haystack"},
|
|
)
|
|
message = ChatMessage.from_assistant(tool_calls=[tool_call])
|
|
|
|
# Mock the OpenAPI service
|
|
call_compare_branches = Mock(return_value={"status": "success"})
|
|
call_compare_branches.operation.__self__ = Mock()
|
|
call_compare_branches.operation.__self__.raw_element = {
|
|
"parameters": [{"name": "basehead"}, {"name": "owner"}, {"name": "repo"}]
|
|
}
|
|
mock_service = Mock(call_compare_branches=call_compare_branches, raw_element={})
|
|
openapi_mock.return_value = mock_service
|
|
|
|
result = connector.run(messages=[message], service_openapi_spec={})
|
|
|
|
# Verify the service call
|
|
mock_service.call_compare_branches.assert_called_once_with(
|
|
parameters={"basehead": "main...some_branch", "owner": "deepset-ai", "repo": "haystack"}, raw_response=True
|
|
)
|
|
assert json.loads(result["service_response"][0].text) == {"status": "success"}
|
|
|
|
@patch("haystack.components.connectors.openapi_service.OpenAPI")
|
|
def test_run_with_request_body(self, openapi_mock):
|
|
connector = OpenAPIServiceConnector()
|
|
tool_call = ToolCall(tool_name="greet", arguments={"message": "Hello", "name": "John"})
|
|
message = ChatMessage.from_assistant(tool_calls=[tool_call])
|
|
|
|
# Mock the OpenAPI service
|
|
call_greet = Mock(return_value="Hello, John")
|
|
call_greet.operation.__self__ = Mock()
|
|
call_greet.operation.__self__.raw_element = {
|
|
"parameters": [{"name": "name"}],
|
|
"requestBody": {
|
|
"content": {"application/json": {"schema": {"properties": {"message": {"type": "string"}}}}}
|
|
},
|
|
}
|
|
mock_service = Mock(call_greet=call_greet, raw_element={})
|
|
openapi_mock.return_value = mock_service
|
|
|
|
result = connector.run(messages=[message], service_openapi_spec={})
|
|
|
|
# Verify the service call
|
|
mock_service.call_greet.assert_called_once_with(
|
|
parameters={"name": "John"}, data={"message": "Hello"}, raw_response=True
|
|
)
|
|
assert json.loads(result["service_response"][0].text) == "Hello, John"
|
|
|
|
@patch("haystack.components.connectors.openapi_service.OpenAPI")
|
|
def test_run_with_missing_required_parameter(self, openapi_mock):
|
|
connector = OpenAPIServiceConnector()
|
|
tool_call = ToolCall(
|
|
tool_name="greet",
|
|
arguments={"message": "Hello"}, # missing required 'name' parameter
|
|
)
|
|
message = ChatMessage.from_assistant(tool_calls=[tool_call])
|
|
|
|
# Mock the OpenAPI service
|
|
call_greet = Mock()
|
|
call_greet.operation.__self__ = Mock()
|
|
call_greet.operation.__self__.raw_element = {
|
|
"parameters": [{"name": "name", "required": True}],
|
|
"requestBody": {
|
|
"content": {"application/json": {"schema": {"properties": {"message": {"type": "string"}}}}}
|
|
},
|
|
}
|
|
mock_service = Mock(call_greet=call_greet, raw_element={})
|
|
openapi_mock.return_value = mock_service
|
|
|
|
with pytest.raises(ValueError, match="Missing parameter: 'name' required for the 'greet' operation"):
|
|
connector.run(messages=[message], service_openapi_spec={})
|
|
|
|
@patch("haystack.components.connectors.openapi_service.OpenAPI")
|
|
def test_run_with_missing_required_parameters_in_request_body(self, openapi_mock):
|
|
"""
|
|
Test that the connector raises a ValueError when the request body is missing required parameters.
|
|
"""
|
|
connector = OpenAPIServiceConnector()
|
|
tool_call = ToolCall(
|
|
tool_name="post_message",
|
|
arguments={"recipient": "John"}, # only providing URL parameter, no request body data
|
|
)
|
|
message = ChatMessage.from_assistant(tool_calls=[tool_call])
|
|
|
|
# Mock the OpenAPI service
|
|
call_post_message = Mock()
|
|
call_post_message.operation.__self__ = Mock()
|
|
call_post_message.operation.__self__.raw_element = {
|
|
"parameters": [{"name": "recipient"}],
|
|
"requestBody": {
|
|
"required": True,
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {
|
|
"required": ["message"], # Mark message as required in schema
|
|
"properties": {"message": {"type": "string"}},
|
|
}
|
|
}
|
|
},
|
|
},
|
|
}
|
|
mock_service = Mock(call_post_message=call_post_message, raw_element={})
|
|
openapi_mock.return_value = mock_service
|
|
|
|
with pytest.raises(
|
|
ValueError, match="Missing requestBody parameter: 'message' required for the 'post_message' operation"
|
|
):
|
|
connector.run(messages=[message], service_openapi_spec={})
|
|
|
|
# Verify that the service was never called since validation failed
|
|
call_post_message.assert_not_called()
|
|
|
|
def test_serialization(self):
|
|
for test_val in ("myvalue", True, None):
|
|
connector = OpenAPIServiceConnector(test_val)
|
|
serialized = connector.to_dict()
|
|
assert serialized["init_parameters"]["ssl_verify"] == test_val
|
|
deserialized = OpenAPIServiceConnector.from_dict(serialized)
|
|
assert deserialized.ssl_verify == test_val
|
|
|
|
def test_serde_in_pipeline(self):
|
|
"""
|
|
Test serialization/deserialization of OpenAPIServiceConnector in a Pipeline,
|
|
including YAML conversion and detailed dictionary validation
|
|
"""
|
|
connector = OpenAPIServiceConnector(ssl_verify=True)
|
|
|
|
pipeline = Pipeline()
|
|
pipeline.add_component("connector", connector)
|
|
|
|
pipeline_dict = pipeline.to_dict()
|
|
assert pipeline_dict == {
|
|
"metadata": {},
|
|
"max_runs_per_component": 100,
|
|
"connection_type_validation": True,
|
|
"components": {
|
|
"connector": {
|
|
"type": "haystack.components.connectors.openapi_service.OpenAPIServiceConnector",
|
|
"init_parameters": {"ssl_verify": True},
|
|
}
|
|
},
|
|
"connections": [],
|
|
}
|
|
|
|
pipeline_yaml = pipeline.dumps()
|
|
new_pipeline = Pipeline.loads(pipeline_yaml)
|
|
assert new_pipeline == pipeline
|
|
|
|
@pytest.mark.skipif(not os.getenv("SERPERDEV_API_KEY"), reason="SERPERDEV_API_KEY is not set")
|
|
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="OPENAI_API_KEY is not set")
|
|
@pytest.mark.integration
|
|
def test_run_live(self):
|
|
# An OutputAdapter filter we'll use to setup function calling
|
|
def prepare_fc_params(openai_functions_schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
return {
|
|
"tools": [{"type": "function", "function": openai_functions_schema}],
|
|
"tool_choice": {"type": "function", "function": {"name": openai_functions_schema["name"]}},
|
|
}
|
|
|
|
pipe = Pipeline()
|
|
pipe.add_component("spec_to_functions", OpenAPIServiceToFunctions())
|
|
pipe.add_component("functions_llm", OpenAIChatGenerator(model="gpt-4o-mini"))
|
|
|
|
pipe.add_component("openapi_container", OpenAPIServiceConnector())
|
|
pipe.add_component(
|
|
"prepare_fc_adapter",
|
|
OutputAdapter("{{functions[0] | prepare_fc}}", Dict[str, Any], {"prepare_fc": prepare_fc_params}),
|
|
)
|
|
pipe.add_component("openapi_spec_adapter", OutputAdapter("{{specs[0]}}", Dict[str, Any], unsafe=True))
|
|
pipe.add_component(
|
|
"final_prompt_adapter",
|
|
OutputAdapter("{{system_message + service_response}}", List[ChatMessage], unsafe=True),
|
|
)
|
|
pipe.add_component("llm", OpenAIChatGenerator(model="gpt-4o-mini", streaming_callback=print_streaming_chunk))
|
|
|
|
pipe.connect("spec_to_functions.functions", "prepare_fc_adapter.functions")
|
|
pipe.connect("spec_to_functions.openapi_specs", "openapi_spec_adapter.specs")
|
|
pipe.connect("prepare_fc_adapter", "functions_llm.generation_kwargs")
|
|
pipe.connect("functions_llm.replies", "openapi_container.messages")
|
|
pipe.connect("openapi_spec_adapter", "openapi_container.service_openapi_spec")
|
|
pipe.connect("openapi_container.service_response", "final_prompt_adapter.service_response")
|
|
pipe.connect("final_prompt_adapter", "llm.messages")
|
|
|
|
serperdev_spec = requests.get(
|
|
"https://gist.githubusercontent.com/vblagoje/241a000f2a77c76be6efba71d49e2856/raw/722ccc7fe6170a744afce3e3fb3a30fdd095c184/serper.json"
|
|
).json()
|
|
system_prompt = requests.get("https://bit.ly/serper_dev_system").text
|
|
|
|
query = "Why did Elon Musk sue OpenAI?"
|
|
|
|
result = pipe.run(
|
|
data={
|
|
"functions_llm": {
|
|
"messages": [ChatMessage.from_system("Only do tool/function calling"), ChatMessage.from_user(query)]
|
|
},
|
|
"openapi_container": {"service_credentials": os.getenv("SERPERDEV_API_KEY")},
|
|
"spec_to_functions": {"sources": [ByteStream.from_string(json.dumps(serperdev_spec))]},
|
|
"final_prompt_adapter": {"system_message": [ChatMessage.from_system(system_prompt)]},
|
|
}
|
|
)
|
|
assert isinstance(result["llm"]["replies"][0], ChatMessage)
|
|
assert "Elon" in result["llm"]["replies"][0].text
|
|
assert "OpenAI" in result["llm"]["replies"][0].text
|