feat(core-ai): add OpenAPI tool discovery for dynamic endpoint integration

Implement automatic tool discovery from OpenAPI specifications, enabling
core-ai to dynamically use infrastructure management endpoints without
manual tool definitions.

Changes:
- Add OpenAPIToolDiscovery class for spec parsing and tool generation
  - Fetches OpenAPI specs from configurable endpoints
  - Generates executable tool functions from API operations
  - Creates properly formatted tool schemas for agent use
  - Async HTTP client for endpoint execution
- Update tool registry to support OpenAPI tools
  - Optional include_openapi parameter in get_all_tools()
  - Async loading of dynamic tools
  - Merges local and OpenAPI tools seamlessly
- Add OpenAPI configuration settings
  - openapi_endpoints: Comma-separated spec URLs
  - openapi_enabled: Feature flag for tool discovery
  - Default: http://core-api:8083/openapi.json

Architecture:
- Core tools (local.py): Always available essentials (web_search, calculate)
- OpenAPI tools: Infrastructure/automation from core-api dynamically discovered

Benefits:
- Auto-discovers new endpoints as core-api evolves
- No manual tool definition needed for REST APIs
- Maintains single source of truth (OpenAPI spec)
- Enables agent to manage infrastructure via discovered tools

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-02 18:35:50 +01:00
co-authored by Claude
parent a6249e6cd0
commit 7b128a8e6f
3 changed files with 340 additions and 2 deletions
+6
View File
@@ -34,6 +34,12 @@ class Settings(BaseSettings):
# Base URL for Core API tools (e.g., system status, services)
core_api_base_url: str = "http://core-api:8083/v1"
# OpenAPI Tool Discovery
# Comma-separated list of OpenAPI spec URLs for dynamic tool discovery
# Example: "http://core-api:8083/openapi.json,http://automation:8080/openapi.json"
openapi_endpoints: str = "http://core-api:8083/openapi.json"
openapi_enabled: bool = True # Enable/disable OpenAPI tool discovery
# Feature Flags
simple_enabled: bool = True # Enable simple endpoint
pydantic_enabled: bool = True # Enable PydanticAI endpoint
@@ -0,0 +1,307 @@
"""
OpenAPI Tool Discovery
Dynamically discovers and creates tools from core-api's OpenAPI specification.
This allows core-ai to automatically use infrastructure management endpoints
without manual tool definition.
Architecture:
- Core tools (local.py): Essential tools always available (web_search, calculate, etc.)
- OpenAPI tools (this module): Infrastructure/automation endpoints from core-api
"""
import httpx
import logging
from typing import Dict, List, Any, Optional, Callable
from functools import lru_cache
import asyncio
logger = logging.getLogger(__name__)
class OpenAPIToolDiscovery:
"""
Discovers and creates executable tools from OpenAPI specifications.
"""
def __init__(self, openapi_url: str = "http://core-api:8083/openapi.json"):
self.openapi_url = openapi_url
self.spec = None
self.tools = {}
async def fetch_spec(self) -> Dict[str, Any]:
"""Fetch OpenAPI specification from core-api"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(self.openapi_url)
response.raise_for_status()
self.spec = response.json()
logger.info(f"Fetched OpenAPI spec: {self.spec['info']['title']} "
f"with {len(self.spec.get('paths', {}))} endpoints")
return self.spec
except Exception as e:
logger.error(f"Failed to fetch OpenAPI spec from {self.openapi_url}: {e}")
return {}
def _extract_endpoint_info(self, path: str, method: str, operation: Dict) -> Dict[str, Any]:
"""
Extract relevant information from an OpenAPI operation.
Returns:
{
"name": "get_containers_list",
"description": "List all Docker containers",
"path": "/infrastructure/containers",
"method": "GET",
"parameters": [...],
"summary": "..."
}
"""
# Generate tool name from operationId or path
operation_id = operation.get("operationId")
if operation_id:
# Convert operationId to snake_case
tool_name = operation_id.replace("-", "_").replace(" ", "_").lower()
else:
# Generate from path and method
path_parts = path.strip("/").replace("/", "_").replace("{", "").replace("}", "")
tool_name = f"{method.lower()}_{path_parts}"
# Get description
description = operation.get("summary") or operation.get("description") or f"{method} {path}"
# Extract parameters
parameters = operation.get("parameters", [])
request_body = operation.get("requestBody")
return {
"name": tool_name,
"description": description,
"path": path,
"method": method.upper(),
"parameters": parameters,
"request_body": request_body,
"summary": operation.get("summary", ""),
"tags": operation.get("tags", [])
}
def _create_tool_function(self, endpoint_info: Dict[str, Any]) -> Callable:
"""
Create an executable async function for an API endpoint.
The function will make HTTP requests to core-api when called.
"""
path = endpoint_info["path"]
method = endpoint_info["method"]
description = endpoint_info["description"]
async def tool_function(**kwargs) -> str:
"""
Dynamically generated function that calls core-api endpoint.
"""
try:
url = f"http://core-api:8083{path}"
# Replace path parameters
for key, value in kwargs.items():
url = url.replace(f"{{{key}}}", str(value))
# Build request
async with httpx.AsyncClient(timeout=30.0) as client:
if method == "GET":
response = await client.get(url, params=kwargs)
elif method == "POST":
response = await client.post(url, json=kwargs)
elif method == "PUT":
response = await client.put(url, json=kwargs)
elif method == "DELETE":
response = await client.delete(url, params=kwargs)
else:
return f"Unsupported HTTP method: {method}"
response.raise_for_status()
# Return JSON if possible, otherwise text
try:
result = response.json()
# Format nicely for LLM
if isinstance(result, list):
return f"Found {len(result)} items:\n" + "\n".join(
[f"- {item}" for item in result[:10]] # Limit to 10 items
)
elif isinstance(result, dict):
return str(result)
else:
return str(result)
except:
return response.text
except httpx.HTTPStatusError as e:
return f"HTTP Error {e.response.status_code}: {e.response.text}"
except Exception as e:
return f"Error calling {method} {path}: {str(e)}"
# Set function metadata
tool_function.__name__ = endpoint_info["name"]
tool_function.__doc__ = f"{description}\n\nEndpoint: {method} {path}"
return tool_function
async def discover_tools(
self,
include_tags: Optional[List[str]] = None,
exclude_tags: Optional[List[str]] = None,
method_filter: Optional[List[str]] = None
) -> Dict[str, Callable]:
"""
Discover and create tools from OpenAPI spec.
Args:
include_tags: Only include endpoints with these tags
exclude_tags: Exclude endpoints with these tags
method_filter: Only include these HTTP methods (e.g., ["GET", "POST"])
Returns:
Dictionary of tool_name -> async function
"""
if not self.spec:
await self.fetch_spec()
if not self.spec or "paths" not in self.spec:
logger.warning("No OpenAPI spec available")
return {}
discovered_tools = {}
for path, path_item in self.spec["paths"].items():
for method in ["get", "post", "put", "delete", "patch"]:
if method not in path_item:
continue
operation = path_item[method]
# Apply filters
if method_filter and method.upper() not in method_filter:
continue
tags = operation.get("tags", [])
if include_tags and not any(tag in include_tags for tag in tags):
continue
if exclude_tags and any(tag in exclude_tags for tag in tags):
continue
# Extract endpoint info
endpoint_info = self._extract_endpoint_info(path, method, operation)
# Create executable function
tool_func = self._create_tool_function(endpoint_info)
discovered_tools[endpoint_info["name"]] = tool_func
logger.debug(f"Discovered tool: {endpoint_info['name']} ({method.upper()} {path})")
logger.info(f"Discovered {len(discovered_tools)} tools from OpenAPI spec")
return discovered_tools
def get_tool_descriptions(self) -> List[Dict[str, str]]:
"""
Get human-readable descriptions of all discovered tools.
Useful for logging/debugging.
"""
descriptions = []
for name, func in self.tools.items():
descriptions.append({
"name": name,
"description": func.__doc__ or "No description"
})
return descriptions
# Global instances for multiple OpenAPI sources
_discovery_instances: Dict[str, OpenAPIToolDiscovery] = {}
async def get_openapi_tools(
endpoints: Optional[List[str]] = None,
include_tags: Optional[List[str]] = None,
exclude_tags: Optional[List[str]] = None,
refresh: bool = False
) -> Dict[str, Callable]:
"""
Get dynamically discovered tools from one or more OpenAPI specifications.
Args:
endpoints: List of OpenAPI spec URLs. If None, uses default (core-api)
Example: ["http://core-api:8083/openapi.json", "http://automation:8080/openapi.json"]
include_tags: Only include endpoints with these tags (e.g., ["infrastructure", "automation"])
exclude_tags: Exclude endpoints with these tags (e.g., ["internal", "admin"])
refresh: Force re-fetch of OpenAPI specs
Returns:
Dictionary of tool_name -> async function (combined from all sources)
"""
global _discovery_instances
# Default to core-api if no endpoints specified
if endpoints is None:
endpoints = ["http://core-api:8083/openapi.json"]
all_tools = {}
for endpoint_url in endpoints:
# Get or create discovery instance for this endpoint
if endpoint_url not in _discovery_instances or refresh:
_discovery_instances[endpoint_url] = OpenAPIToolDiscovery(openapi_url=endpoint_url)
instance = _discovery_instances[endpoint_url]
# Discover tools from this endpoint
try:
tools = await instance.discover_tools(
include_tags=include_tags,
exclude_tags=exclude_tags
)
# Add source prefix to avoid name conflicts between APIs
# Extract service name from URL (e.g., "core-api" from "http://core-api:8083/...")
service_name = endpoint_url.split("//")[1].split(":")[0].split(".")[0]
for tool_name, tool_func in tools.items():
# Prefix tool name with service (e.g., "core_api__list_containers")
prefixed_name = f"{service_name}__{tool_name}"
all_tools[prefixed_name] = tool_func
instance.tools = tools
logger.info(f"Loaded {len(tools)} tools from {service_name}")
except Exception as e:
logger.error(f"Failed to discover tools from {endpoint_url}: {e}")
continue
logger.info(f"Total OpenAPI tools discovered: {len(all_tools)} from {len(endpoints)} source(s)")
return all_tools
async def get_openapi_tool_descriptions(endpoints: Optional[List[str]] = None) -> List[Dict[str, str]]:
"""
Get descriptions of all discovered OpenAPI tools.
Args:
endpoints: List of OpenAPI spec URLs (same as get_openapi_tools)
Returns:
List of tool descriptions
"""
global _discovery_instances
if not _discovery_instances:
await get_openapi_tools(endpoints=endpoints)
all_descriptions = []
for endpoint_url, instance in _discovery_instances.items():
service_name = endpoint_url.split("//")[1].split(":")[0].split(".")[0]
for desc in instance.get_tool_descriptions():
desc["source"] = service_name
all_descriptions.append(desc)
return all_descriptions
+27 -2
View File
@@ -92,14 +92,39 @@ def register_tool(func: Callable) -> Callable:
return log_tool_call(func)
def get_all_tools() -> Dict[str, Callable]:
def get_all_tools(include_openapi: bool = False) -> Dict[str, Callable]:
"""
Get all registered tools.
Args:
include_openapi: If True, also include dynamically discovered OpenAPI tools
Returns:
Dictionary mapping tool names to functions
"""
return _TOOL_REGISTRY.copy()
tools = _TOOL_REGISTRY.copy()
# Add OpenAPI tools if requested
if include_openapi:
try:
import asyncio
from src.tools.openapi_discovery import get_openapi_tools
# Get or create event loop
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Fetch OpenAPI tools
openapi_tools = loop.run_until_complete(get_openapi_tools())
tools.update(openapi_tools)
logger.info(f"Added {len(openapi_tools)} OpenAPI tools to registry")
except Exception as e:
logger.warning(f"Failed to load OpenAPI tools: {e}")
return tools
def get_agent_tools() -> List: