Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Tool openapi integration #151

Closed
wants to merge 15 commits into from
20 changes: 19 additions & 1 deletion haystack_experimental/dataclasses/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import inspect
from dataclasses import asdict, dataclass
from typing import Any, Callable, Dict, Optional
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Union

from haystack.lazy_imports import LazyImport
from haystack.utils import deserialize_callable, serialize_callable
Expand Down Expand Up @@ -198,6 +199,23 @@ def get_weather(

return Tool(name=name or function.__name__, description=tool_description, parameters=schema, function=function)

@classmethod
def from_openapi(cls, spec: Union[str, Path], operation_name: str, **kwargs) -> "Tool":
"""
Create a Tool instance from an OpenAPI specification and a specific operation name.

:param spec: OpenAPI specification as URL, file path, or string content
:param operation_name: Name of the operation to create a tool for
:param kwargs: Additional configuration options for the OpenAPI client:
- credentials: API credentials (e.g., API key, auth token)
- request_sender: Custom callable to send HTTP requests
:returns: Tool instance for the specified operation
:raises ValueError: If the OpenAPI specification is invalid or cannot be loaded
"""
from haystack_experimental.tools.openapi import _create_tool_from_openapi_spec
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circular dep between these two otherwise


return _create_tool_from_openapi_spec(spec=spec, operation_name=operation_name, **kwargs)


def _remove_title_from_schema(schema: Dict[str, Any]):
"""
Expand Down
3 changes: 3 additions & 0 deletions haystack_experimental/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <[email protected]>
#
# SPDX-License-Identifier: Apache-2.0
185 changes: 185 additions & 0 deletions haystack_experimental/tools/openapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <[email protected]>
#
# SPDX-License-Identifier: Apache-2.0

from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, TypedDict, Union

from haystack.lazy_imports import LazyImport
from haystack.logging import logging

from haystack_experimental.dataclasses import Tool

with LazyImport(message="Run 'pip install openapi-llm'") as openapi_llm_import:
from openapi_llm.client.config import ClientConfig
from openapi_llm.client.openapi import OpenAPIClient
from openapi_llm.core.spec import OpenAPISpecification


logger = logging.getLogger(__name__)


class OpenAPIKwargs(TypedDict, total=False):
"""
TypedDict for OpenAPI configuration kwargs.

Contains all supported configuration options for Tool.from_openapi_spec()
"""

credentials: Any # API credentials (e.g., API key, auth token)
request_sender: Callable[[Dict[str, Any]], Dict[str, Any]] # Custom HTTPrequest sender function
allowed_operations: List[str] # A list of operations to include in the OpenAPI client.


def _create_tool_from_openapi_spec(spec: Union[str, Path], operation_name: str, **kwargs) -> "Tool":
"""
Create a Tool instance from an OpenAPI specification and a specific operation name.

:param spec: OpenAPI specification as URL, file path, or string content.
:param operation_name: Name of the operation to create a tool for.
:param kwargs: Additional configuration options for the OpenAPI client:
- credentials: API credentials (e.g., API key, auth token).
- request_sender: Custom callable to send HTTP requests.
:returns: Tool instance for the specified operation.
:raises ValueError: If the OpenAPI specification is invalid or cannot be loaded.
"""
# Create a config dictionary with the necessary parameters
config: Dict[str, Any] = {
"allowed_operations": [operation_name],
"request_sender": kwargs.get("request_sender"),
"credentials": kwargs.get("credentials"),
}
# Remove None values from the dictionary
config = {k: v for k, v in config.items() if v is not None}

tools = _create_tools_from_openapi_spec(spec=spec, **config)

# Ensure we have only one tool
if len(tools) != 1:
msg = (
f"Couldn't create a tool from OpenAPI spec '{spec}' for operation '{operation_name}'. "
"Please check that the operation name is correct and that the OpenAPI spec is valid."
)
raise ValueError(msg)

return tools[0]


def _create_tools_from_openapi_spec(spec: Union[str, Path], **kwargs: OpenAPIKwargs) -> List["Tool"]:
"""
Create Tool instances from an OpenAPI specification.

The specification can be provided as:
- A URL pointing to an OpenAPI spec
- A local file path to an OpenAPI spec (JSON or YAML)
- A string containing the OpenAPI spec content (JSON or YAML)

:param spec: OpenAPI specification as URL, file path, or string content
:param kwargs: Additional configuration options for the OpenAPI client:
- credentials: API credentials (e.g., API key, auth token)
- request_sender: Custom callable to send HTTP requests
- allowed_operations: List of operations from the OpenAPI spec to include
:returns: List of Tool instances configured to invoke the OpenAPI service endpoints
:raises ValueError: If the OpenAPI specification is not valid or the operation name is not found
"""
openapi_llm_import.check()
# Load the OpenAPI specification
if isinstance(spec, str):
if spec.startswith(("http://", "https://")):
openapi_spec = OpenAPISpecification.from_url(spec)
elif Path(spec).exists():
openapi_spec = OpenAPISpecification.from_file(spec)
else:
openapi_spec = OpenAPISpecification.from_str(spec)
elif isinstance(spec, Path):
openapi_spec = OpenAPISpecification.from_file(str(spec))
else:
raise ValueError("OpenAPI spec must be a string (URL, file path, or content) or a Path object")

# Create client configuration
config = ClientConfig(openapi_spec=openapi_spec, **kwargs)

# Create an OpenAPI client for invocations
client = OpenAPIClient(config)

# Get all tool definitions from the config
tools = []
for llm_specific_tool_def in config.get_tool_definitions():
# Extract normalized tool definition
standardized_tool_def = _standardize_tool_definition(llm_specific_tool_def)
if not standardized_tool_def:
logger.warning(f"Skipping {llm_specific_tool_def}, as required parameters not found")
continue

# Create a closure that captures the current value of standardized_tool_def
def create_invoke_function(tool_def: Dict[str, Any]) -> Callable:
"""
Create an invoke function with the tool definition bound to its scope.

:param tool_def: The tool definition to bind to the invoke function.
:returns: Function that invokes the OpenAPI endpoint.
"""

def invoke_openapi(**kwargs):
"""
Invoke the OpenAPI endpoint with the provided arguments.

:param kwargs: Arguments to pass to the OpenAPI endpoint.
:returns: Response from the OpenAPI endpoint.
"""
return client.invoke({"name": tool_def["name"], "arguments": kwargs})

return invoke_openapi

tools.append(
Tool(
name=standardized_tool_def["name"],
description=standardized_tool_def["description"],
parameters=standardized_tool_def["parameters"],
function=create_invoke_function(standardized_tool_def),
)
)

return tools


def _standardize_tool_definition(llm_specific_tool_def: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Recursively extract tool parameters from different LLM provider formats.

Supports various LLM provider formats including OpenAI, Anthropic, and Cohere.

:param llm_specific_tool_def: Dictionary containing tool definition in provider-specific format
:returns: Dictionary with normalized tool parameters or None if required fields not found
"""
# Mapping of provider-specific schema field names to our Tool "parameters" field
SCHEMA_FIELD_NAMES = [
"parameters", # Cohere/OpenAI
"input_schema", # Anthropic
# any other field names that might contain a schema in other providers
]

def _find_in_dict(d: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if all(k in d for k in ["name", "description"]):
schema = None
for field_name in SCHEMA_FIELD_NAMES:
if field_name in d:
schema = d[field_name]
break

if schema is not None:
return {
"name": d["name"],
"description": d["description"],
"parameters": schema,
}

# Recurse into nested dictionaries
for v in d.values():
if isinstance(v, dict):
result = _find_in_dict(v)
if result:
return result
return None

return _find_in_dict(llm_specific_tool_def)
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ extra-dependencies = [
# LLMMetadataExtractor dependencies
"amazon-bedrock-haystack>=1.1.1",
"google-vertex-haystack>=2.0.0",
# OpenAPI as a Tool
"openapi-llm",
]

[tool.hatch.envs.test.scripts]
Expand Down
Loading
Loading