mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Fix 35 ruff errors (E722, F841, B904, B905, B007, E741, S202, S310, S306, S110, TID251, UP042, F401) and run black on 526 files. Co-authored-by: Hanzo Dev <[email protected]>
1020 lines
34 KiB
Python
1020 lines
34 KiB
Python
"""Main API client with explicit typed methods.
|
|
|
|
Provides a clean, agent-friendly interface for API operations:
|
|
|
|
from hanzo_tools.api import APIClient
|
|
|
|
client = APIClient()
|
|
|
|
# Discover providers
|
|
providers = await client.list_providers()
|
|
|
|
# Configure credentials
|
|
await client.config("cloudflare", api_key="...")
|
|
|
|
# Load spec and discover operations
|
|
await client.spec("cloudflare")
|
|
ops = await client.ops("cloudflare", search="zones")
|
|
|
|
# Call operations
|
|
result = await client.call("cloudflare", "listZones", params={"page": 1})
|
|
|
|
# Raw requests
|
|
result = await client.raw("github", "GET", "/user/repos")
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .credentials import CredentialManager, get_credential_manager
|
|
from .errors import (
|
|
ProviderNotFoundError,
|
|
)
|
|
from .models import (
|
|
APICallResult,
|
|
EffectiveCredential,
|
|
Operation,
|
|
OperationListResult,
|
|
ProviderListResult,
|
|
ProviderStatus,
|
|
ToolParameter,
|
|
ToolSchema,
|
|
)
|
|
from .openapi_client import OpenAPIClient, SpecCache
|
|
from .providers import PROVIDER_CONFIGS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class APIClient:
|
|
"""Main API client with explicit typed methods.
|
|
|
|
This is the primary interface for agent integration. Each method
|
|
has explicit parameters with type hints for easy auto-completion
|
|
and validation.
|
|
|
|
Example:
|
|
client = APIClient()
|
|
|
|
# List and configure providers
|
|
providers = await client.list_providers()
|
|
await client.config("cloudflare", api_key="my-token")
|
|
|
|
# Load spec and call operations
|
|
await client.spec("cloudflare")
|
|
result = await client.call("cloudflare", "listZones")
|
|
|
|
# Or make raw requests
|
|
result = await client.raw("github", "GET", "/user/repos")
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config_dir: Path | None = None,
|
|
credential_manager: CredentialManager | None = None,
|
|
):
|
|
"""Initialize API client.
|
|
|
|
Args:
|
|
config_dir: Directory for credentials and specs
|
|
credential_manager: Custom credential manager
|
|
"""
|
|
self._config_dir = config_dir or Path.home() / ".hanzo" / "api"
|
|
self._cred_manager = credential_manager or get_credential_manager()
|
|
self._spec_cache = SpecCache(self._config_dir / "specs")
|
|
self._clients: dict[str, OpenAPIClient] = {}
|
|
|
|
async def _get_openapi_client(self, provider: str) -> OpenAPIClient:
|
|
"""Get or create OpenAPI client for provider."""
|
|
if provider not in self._clients:
|
|
self._clients[provider] = OpenAPIClient(
|
|
provider=provider,
|
|
credential_manager=self._cred_manager,
|
|
spec_cache=self._spec_cache,
|
|
)
|
|
return self._clients[provider]
|
|
|
|
# =========================================================================
|
|
# Provider Discovery
|
|
# =========================================================================
|
|
|
|
async def list_providers(self, configured_only: bool = False) -> ProviderListResult:
|
|
"""List all available providers and their status.
|
|
|
|
Args:
|
|
configured_only: Only return providers with credentials configured
|
|
|
|
Returns:
|
|
ProviderListResult with provider statuses
|
|
|
|
Example:
|
|
result = await client.list_providers()
|
|
for p in result.configured:
|
|
print(f"{p.name}: {p.source}")
|
|
"""
|
|
statuses = await self._cred_manager.list_providers()
|
|
|
|
if configured_only:
|
|
statuses = [s for s in statuses if s.configured]
|
|
|
|
return ProviderListResult(
|
|
providers=statuses,
|
|
configured_count=sum(1 for s in statuses if s.configured),
|
|
total_count=len(statuses),
|
|
)
|
|
|
|
async def get_provider(self, provider: str) -> ProviderStatus:
|
|
"""Get detailed status for a specific provider.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
|
|
Returns:
|
|
ProviderStatus with configuration details
|
|
|
|
Raises:
|
|
ProviderNotFoundError: If provider is unknown
|
|
"""
|
|
providers = await self.list_providers()
|
|
|
|
for p in providers.providers:
|
|
if p.name == provider:
|
|
return p
|
|
|
|
raise ProviderNotFoundError(
|
|
provider=provider,
|
|
available_providers=[p.name for p in providers.providers],
|
|
)
|
|
|
|
# =========================================================================
|
|
# Credential Management
|
|
# =========================================================================
|
|
|
|
async def config(
|
|
self,
|
|
provider: str,
|
|
api_key: str | None = None,
|
|
api_secret: str | None = None,
|
|
account_id: str | None = None,
|
|
base_url: str | None = None,
|
|
**extra,
|
|
) -> None:
|
|
"""Configure credentials for a provider.
|
|
|
|
Args:
|
|
provider: Provider name (e.g., 'cloudflare', 'github')
|
|
api_key: API key or token
|
|
api_secret: API secret (for providers that need it)
|
|
account_id: Account/organization ID
|
|
base_url: Custom base URL override
|
|
**extra: Additional provider-specific fields
|
|
|
|
Example:
|
|
await client.config("cloudflare", api_key="my-token")
|
|
await client.config("stripe", api_key="sk_xxx", api_secret="whsec_xxx")
|
|
"""
|
|
await self._cred_manager.set_credential(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
api_secret=api_secret,
|
|
account_id=account_id,
|
|
base_url=base_url,
|
|
**extra,
|
|
)
|
|
logger.info(f"Configured credentials for {provider}")
|
|
|
|
async def delete_config(self, provider: str) -> bool:
|
|
"""Delete stored credentials for a provider.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
|
|
Returns:
|
|
True if deleted, False if not found
|
|
"""
|
|
return await self._cred_manager.delete_credential(provider)
|
|
|
|
async def get_effective_credentials(self, provider: str) -> EffectiveCredential:
|
|
"""Get credential with full resolution info.
|
|
|
|
Shows exactly where the credential came from, useful for
|
|
debugging authentication issues.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
|
|
Returns:
|
|
EffectiveCredential with source information
|
|
|
|
Example:
|
|
effective = await client.get_effective_credentials("cloudflare")
|
|
print(f"Source: {effective.source}") # e.g., "environment"
|
|
print(f"Env var: {effective.env_var_used}") # e.g., "CLOUDFLARE_API_TOKEN"
|
|
"""
|
|
return await self._cred_manager.get_effective_credentials(provider)
|
|
|
|
# =========================================================================
|
|
# OpenAPI Spec Management
|
|
# =========================================================================
|
|
|
|
async def spec(
|
|
self,
|
|
provider: str,
|
|
spec_url: str | None = None,
|
|
force_refresh: bool = False,
|
|
) -> int:
|
|
"""Load or refresh OpenAPI spec for a provider.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
spec_url: Custom URL to OpenAPI spec
|
|
force_refresh: Force refresh even if cached
|
|
|
|
Returns:
|
|
Number of operations discovered
|
|
|
|
Example:
|
|
count = await client.spec("cloudflare")
|
|
print(f"Loaded {count} operations")
|
|
|
|
# Load custom spec
|
|
await client.spec("custom-api", spec_url="https://api.example.com/openapi.json")
|
|
"""
|
|
openapi_client = await self._get_openapi_client(provider)
|
|
|
|
if spec_url:
|
|
await openapi_client.load_spec(spec_url)
|
|
elif force_refresh:
|
|
await openapi_client.refresh_spec(force=True)
|
|
else:
|
|
await openapi_client.load_spec()
|
|
|
|
return len(openapi_client._operations)
|
|
|
|
async def has_spec(self, provider: str) -> bool:
|
|
"""Check if spec is available for provider.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
|
|
Returns:
|
|
True if spec is loaded or cached
|
|
"""
|
|
openapi_client = await self._get_openapi_client(provider)
|
|
return openapi_client.has_spec()
|
|
|
|
async def spec_age(self, provider: str) -> float | None:
|
|
"""Get age of cached spec in seconds.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
|
|
Returns:
|
|
Age in seconds, or None if no spec
|
|
"""
|
|
openapi_client = await self._get_openapi_client(provider)
|
|
return openapi_client.spec_age()
|
|
|
|
async def refresh_spec(self, provider: str, force: bool = False) -> bool:
|
|
"""Refresh the OpenAPI spec.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
force: Force refresh even if not stale
|
|
|
|
Returns:
|
|
True if spec was refreshed, False if using cache
|
|
"""
|
|
openapi_client = await self._get_openapi_client(provider)
|
|
return await openapi_client.refresh_spec(force=force)
|
|
|
|
# =========================================================================
|
|
# Operation Discovery
|
|
# =========================================================================
|
|
|
|
async def ops(
|
|
self,
|
|
provider: str,
|
|
search: str | None = None,
|
|
tag: str | None = None,
|
|
method: str | None = None,
|
|
path_contains: str | None = None,
|
|
operation_id_prefix: str | None = None,
|
|
include_deprecated: bool = False,
|
|
) -> OperationListResult:
|
|
"""List available operations for a provider.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
search: Search in operation ID, summary, description
|
|
tag: Filter by tag
|
|
method: Filter by HTTP method (GET, POST, etc.)
|
|
path_contains: Filter by path substring
|
|
operation_id_prefix: Filter by operation ID prefix
|
|
include_deprecated: Include deprecated operations
|
|
|
|
Returns:
|
|
OperationListResult with matching operations
|
|
|
|
Example:
|
|
# List all operations
|
|
result = await client.ops("cloudflare")
|
|
|
|
# Search for zone operations
|
|
result = await client.ops("cloudflare", search="zone")
|
|
|
|
# Filter by tag
|
|
result = await client.ops("cloudflare", tag="Zones")
|
|
|
|
# Filter by method
|
|
result = await client.ops("cloudflare", method="POST")
|
|
"""
|
|
openapi_client = await self._get_openapi_client(provider)
|
|
|
|
if not openapi_client.spec_loaded:
|
|
await openapi_client.load_spec()
|
|
|
|
return openapi_client.list_operations(
|
|
search=search,
|
|
tag=tag,
|
|
method=method,
|
|
path_contains=path_contains,
|
|
operation_id_prefix=operation_id_prefix,
|
|
include_deprecated=include_deprecated,
|
|
)
|
|
|
|
async def get_operation(self, provider: str, operation_id: str) -> Operation:
|
|
"""Get detailed information about a specific operation.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
operation_id: Operation ID
|
|
|
|
Returns:
|
|
Operation with full details including parameter schemas
|
|
|
|
Example:
|
|
op = await client.get_operation("cloudflare", "listZones")
|
|
print(op.params_schema) # JSON schema for parameters
|
|
print(op.request_body_schema) # JSON schema for body
|
|
"""
|
|
openapi_client = await self._get_openapi_client(provider)
|
|
|
|
if not openapi_client.spec_loaded:
|
|
await openapi_client.load_spec()
|
|
|
|
return openapi_client.get_operation(operation_id)
|
|
|
|
# =========================================================================
|
|
# API Calls
|
|
# =========================================================================
|
|
|
|
async def call(
|
|
self,
|
|
provider: str,
|
|
operation_id: str,
|
|
params: dict[str, Any] | None = None,
|
|
body: dict[str, Any] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
dry_run: bool = False,
|
|
) -> APICallResult:
|
|
"""Call an API operation by ID.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
operation_id: Operation ID from the OpenAPI spec
|
|
params: Path, query, and header parameters
|
|
body: Request body (for POST/PUT/PATCH)
|
|
headers: Additional headers
|
|
dry_run: If True, return request details without making call
|
|
|
|
Returns:
|
|
APICallResult with response data
|
|
|
|
Example:
|
|
# Simple call
|
|
result = await client.call("cloudflare", "listZones")
|
|
|
|
# With parameters
|
|
result = await client.call(
|
|
"cloudflare",
|
|
"getZone",
|
|
params={"zone_id": "abc123"}
|
|
)
|
|
|
|
# With body
|
|
result = await client.call(
|
|
"cloudflare",
|
|
"createDNSRecord",
|
|
params={"zone_id": "abc123"},
|
|
body={"type": "A", "name": "test", "content": "1.2.3.4"}
|
|
)
|
|
"""
|
|
openapi_client = await self._get_openapi_client(provider)
|
|
|
|
if not openapi_client.spec_loaded:
|
|
await openapi_client.load_spec()
|
|
|
|
return await openapi_client.call(
|
|
operation_id=operation_id,
|
|
params=params,
|
|
body=body,
|
|
headers=headers,
|
|
dry_run=dry_run,
|
|
)
|
|
|
|
async def raw(
|
|
self,
|
|
provider: str,
|
|
method: str,
|
|
path: str,
|
|
params: dict[str, Any] | None = None,
|
|
body: dict[str, Any] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
dry_run: bool = False,
|
|
) -> APICallResult:
|
|
"""Make a raw HTTP request.
|
|
|
|
Useful for endpoints not in the spec or custom calls.
|
|
|
|
Args:
|
|
provider: Provider name (for authentication)
|
|
method: HTTP method (GET, POST, PUT, PATCH, DELETE)
|
|
path: URL path
|
|
params: Query parameters
|
|
body: Request body
|
|
headers: Additional headers
|
|
dry_run: If True, return request details without making call
|
|
|
|
Returns:
|
|
APICallResult with response data
|
|
|
|
Example:
|
|
# GET request
|
|
result = await client.raw("github", "GET", "/user")
|
|
|
|
# POST with body
|
|
result = await client.raw(
|
|
"github",
|
|
"POST",
|
|
"/repos/owner/repo/issues",
|
|
body={"title": "Bug report", "body": "..."}
|
|
)
|
|
"""
|
|
openapi_client = await self._get_openapi_client(provider)
|
|
|
|
return await openapi_client.call_raw(
|
|
method=method,
|
|
path=path,
|
|
params=params,
|
|
body=body,
|
|
headers=headers,
|
|
dry_run=dry_run,
|
|
)
|
|
|
|
# =========================================================================
|
|
# Tool Schemas (for agent integration)
|
|
# =========================================================================
|
|
|
|
def get_tool_schemas(self) -> list[ToolSchema]:
|
|
"""Get machine-readable schemas for all client methods.
|
|
|
|
Returns tool schemas that can be used by agents for
|
|
function calling with strict argument validation.
|
|
|
|
Returns:
|
|
List of ToolSchema objects for each method
|
|
"""
|
|
return [
|
|
ToolSchema(
|
|
name="list_providers",
|
|
description="List all available API providers and their configuration status",
|
|
parameters=[
|
|
ToolParameter(
|
|
name="configured_only",
|
|
type="boolean",
|
|
description="Only return providers with credentials configured",
|
|
default=False,
|
|
),
|
|
],
|
|
returns="ProviderListResult with provider statuses",
|
|
examples=[
|
|
"await client.list_providers()",
|
|
"await client.list_providers(configured_only=True)",
|
|
],
|
|
),
|
|
ToolSchema(
|
|
name="config",
|
|
description="Configure credentials for a provider",
|
|
parameters=[
|
|
ToolParameter(
|
|
name="provider",
|
|
type="string",
|
|
description="Provider name (e.g., 'cloudflare', 'github')",
|
|
required=True,
|
|
),
|
|
ToolParameter(
|
|
name="api_key",
|
|
type="string",
|
|
description="API key or token",
|
|
),
|
|
ToolParameter(
|
|
name="api_secret",
|
|
type="string",
|
|
description="API secret (for providers that need it)",
|
|
),
|
|
ToolParameter(
|
|
name="base_url",
|
|
type="string",
|
|
description="Custom base URL override",
|
|
),
|
|
],
|
|
returns="None",
|
|
examples=["await client.config('cloudflare', api_key='my-token')"],
|
|
),
|
|
ToolSchema(
|
|
name="spec",
|
|
description="Load or refresh OpenAPI spec for a provider",
|
|
parameters=[
|
|
ToolParameter(
|
|
name="provider",
|
|
type="string",
|
|
description="Provider name",
|
|
required=True,
|
|
),
|
|
ToolParameter(
|
|
name="spec_url",
|
|
type="string",
|
|
description="Custom URL to OpenAPI spec",
|
|
),
|
|
ToolParameter(
|
|
name="force_refresh",
|
|
type="boolean",
|
|
description="Force refresh even if cached",
|
|
default=False,
|
|
),
|
|
],
|
|
returns="Number of operations discovered",
|
|
examples=[
|
|
"await client.spec('cloudflare')",
|
|
"await client.spec('custom', spec_url='...')",
|
|
],
|
|
),
|
|
ToolSchema(
|
|
name="ops",
|
|
description="List available operations for a provider",
|
|
parameters=[
|
|
ToolParameter(
|
|
name="provider",
|
|
type="string",
|
|
description="Provider name",
|
|
required=True,
|
|
),
|
|
ToolParameter(
|
|
name="search",
|
|
type="string",
|
|
description="Search in operation ID, summary, description",
|
|
),
|
|
ToolParameter(
|
|
name="tag",
|
|
type="string",
|
|
description="Filter by tag",
|
|
),
|
|
ToolParameter(
|
|
name="method",
|
|
type="string",
|
|
description="Filter by HTTP method",
|
|
enum=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
),
|
|
],
|
|
returns="OperationListResult with matching operations",
|
|
examples=["await client.ops('cloudflare', search='zone')"],
|
|
),
|
|
ToolSchema(
|
|
name="call",
|
|
description="Call an API operation by ID",
|
|
parameters=[
|
|
ToolParameter(
|
|
name="provider",
|
|
type="string",
|
|
description="Provider name",
|
|
required=True,
|
|
),
|
|
ToolParameter(
|
|
name="operation_id",
|
|
type="string",
|
|
description="Operation ID from the OpenAPI spec",
|
|
required=True,
|
|
),
|
|
ToolParameter(
|
|
name="params",
|
|
type="object",
|
|
description="Path, query, and header parameters",
|
|
),
|
|
ToolParameter(
|
|
name="body",
|
|
type="object",
|
|
description="Request body (for POST/PUT/PATCH)",
|
|
),
|
|
ToolParameter(
|
|
name="dry_run",
|
|
type="boolean",
|
|
description="Return request details without making call",
|
|
default=False,
|
|
),
|
|
],
|
|
returns="APICallResult with response data",
|
|
examples=["await client.call('cloudflare', 'listZones')"],
|
|
),
|
|
ToolSchema(
|
|
name="raw",
|
|
description="Make a raw HTTP request to any endpoint",
|
|
parameters=[
|
|
ToolParameter(
|
|
name="provider",
|
|
type="string",
|
|
description="Provider name (for authentication)",
|
|
required=True,
|
|
),
|
|
ToolParameter(
|
|
name="method",
|
|
type="string",
|
|
description="HTTP method",
|
|
required=True,
|
|
enum=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
),
|
|
ToolParameter(
|
|
name="path",
|
|
type="string",
|
|
description="URL path",
|
|
required=True,
|
|
),
|
|
ToolParameter(
|
|
name="params",
|
|
type="object",
|
|
description="Query parameters",
|
|
),
|
|
ToolParameter(
|
|
name="body",
|
|
type="object",
|
|
description="Request body",
|
|
),
|
|
],
|
|
returns="APICallResult with response data",
|
|
examples=["await client.raw('github', 'GET', '/user')"],
|
|
),
|
|
]
|
|
|
|
# =========================================================================
|
|
# API Discovery & Registration
|
|
# =========================================================================
|
|
|
|
async def search(self, query: str) -> list[dict[str, Any]]:
|
|
"""Search for APIs in public registries.
|
|
|
|
Searches openapisearch.com for publicly available APIs.
|
|
|
|
Args:
|
|
query: Search query (e.g., 'weather', 'spotify', 'notion')
|
|
|
|
Returns:
|
|
List of API results with id, name, description, spec_url
|
|
|
|
Example:
|
|
results = await client.search("weather")
|
|
for api in results:
|
|
print(f"{api['id']}: {api['name']}")
|
|
"""
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=30) as http:
|
|
# Search openapisearch.com
|
|
try:
|
|
response = await http.get(
|
|
"https://openapisearch.com/api/search",
|
|
params={"q": query},
|
|
)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
return data.get("results", [])
|
|
except Exception as e:
|
|
logger.warning(f"openapisearch.com search failed: {e}")
|
|
|
|
# Fallback: search handmade OpenAPIs on GitHub
|
|
try:
|
|
response = await http.get(
|
|
"https://api.github.com/repos/janwilmake/handmade-openapis/contents"
|
|
)
|
|
if response.status_code == 200:
|
|
files = response.json()
|
|
results = []
|
|
for f in files:
|
|
if f["name"].endswith(".json"):
|
|
name = f["name"].replace(".json", "")
|
|
if query.lower() in name.lower():
|
|
results.append(
|
|
{
|
|
"id": name,
|
|
"name": name.replace("-", " ").title(),
|
|
"spec_url": f["download_url"],
|
|
}
|
|
)
|
|
return results
|
|
except Exception as e:
|
|
logger.warning(f"GitHub fallback search failed: {e}")
|
|
|
|
return []
|
|
|
|
async def register(
|
|
self,
|
|
name: str,
|
|
spec_url: str | None = None,
|
|
base_url: str | None = None,
|
|
auth_type: str = "bearer",
|
|
auth_header: str = "Authorization",
|
|
auth_prefix: str = "Bearer",
|
|
api_key: str | None = None,
|
|
) -> int:
|
|
"""Register a custom API provider dynamically.
|
|
|
|
Allows using any OpenAPI spec without pre-configuration.
|
|
|
|
Args:
|
|
name: Unique name for this API (e.g., 'my-api')
|
|
spec_url: URL to OpenAPI spec (JSON or YAML)
|
|
base_url: Base URL for API calls (extracted from spec if not provided)
|
|
auth_type: Authentication type ('bearer', 'header', 'basic', 'api_key', 'query')
|
|
auth_header: Header name for auth (default: 'Authorization')
|
|
auth_prefix: Prefix for auth value (default: 'Bearer')
|
|
api_key: API key (optional, can also use config() later)
|
|
|
|
Returns:
|
|
Number of operations discovered
|
|
|
|
Example:
|
|
# Register from OpenAPI search
|
|
results = await client.search("notion")
|
|
count = await client.register("notion", spec_url=results[0]["spec_url"])
|
|
|
|
# Register custom API
|
|
await client.register(
|
|
"my-api",
|
|
spec_url="https://api.example.com/openapi.json",
|
|
api_key="secret"
|
|
)
|
|
"""
|
|
from .models import AuthType, ProviderConfig
|
|
|
|
# Create dynamic provider config
|
|
auth_type_enum = (
|
|
AuthType(auth_type)
|
|
if auth_type in [e.value for e in AuthType]
|
|
else AuthType.BEARER
|
|
)
|
|
|
|
config = ProviderConfig(
|
|
name=name,
|
|
display_name=name.replace("-", " ").replace("_", " ").title(),
|
|
base_url=base_url or "",
|
|
auth_type=auth_type_enum,
|
|
auth_header=auth_header,
|
|
auth_prefix=auth_prefix,
|
|
spec_url=spec_url,
|
|
env_vars=[],
|
|
)
|
|
|
|
# Add to provider configs
|
|
PROVIDER_CONFIGS[name] = config
|
|
|
|
# Set credentials if provided
|
|
if api_key:
|
|
await self.config(name, api_key=api_key, base_url=base_url)
|
|
|
|
# Load spec
|
|
if spec_url:
|
|
return await self.spec(name, spec_url=spec_url)
|
|
|
|
return 0
|
|
|
|
async def overview(self, provider: str, compact: bool = True) -> str:
|
|
"""Get a human-readable overview of an API.
|
|
|
|
Generates agent-friendly, token-efficient summaries. For large APIs,
|
|
automatically minifies output to reduce token usage.
|
|
|
|
Args:
|
|
provider: Provider name (must have spec loaded)
|
|
compact: Use compact format (default True for token efficiency)
|
|
|
|
Returns:
|
|
Formatted overview of all endpoints
|
|
|
|
Example:
|
|
await client.spec("cloudflare")
|
|
print(await client.overview("cloudflare"))
|
|
"""
|
|
openapi_client = await self._get_openapi_client(provider)
|
|
|
|
if not openapi_client.spec_loaded:
|
|
await openapi_client.load_spec()
|
|
|
|
result = openapi_client.list_operations()
|
|
base_url = openapi_client.base_url or ""
|
|
|
|
# Build operation items
|
|
items = []
|
|
for op in result.operations:
|
|
# Get full operation for parameters
|
|
try:
|
|
full_op = openapi_client.get_operation(op.operation_id)
|
|
# Build query params string
|
|
query_params = [
|
|
f"{p.name}={p.schema_type}"
|
|
for p in full_op.parameters
|
|
if p.location.value == "query"
|
|
]
|
|
query_string = f"?{'&'.join(query_params)}" if query_params else ""
|
|
except Exception:
|
|
query_string = ""
|
|
|
|
items.append(
|
|
{
|
|
"operation_id": op.operation_id,
|
|
"method": op.method,
|
|
"path": op.path,
|
|
"query_string": query_string,
|
|
"summary": op.summary or "",
|
|
}
|
|
)
|
|
|
|
# Check if we need minified output (>10k chars ≈ 2500 tokens)
|
|
is_large = len(str(items)) > 10000
|
|
|
|
lines = []
|
|
|
|
# Header
|
|
lines.append(f"# {provider.upper()} API")
|
|
if base_url:
|
|
lines.append(f"Base: {base_url}")
|
|
lines.append(f"Endpoints: {result.total_count}")
|
|
lines.append("")
|
|
|
|
if compact or is_large:
|
|
# Minified format for agents
|
|
for item in items:
|
|
if is_large:
|
|
# Super compact: just operation_id and summary
|
|
summary_part = f" - {item['summary']}" if item["summary"] else ""
|
|
lines.append(f"- {item['operation_id']}{summary_part}")
|
|
else:
|
|
# Compact: include method and path
|
|
summary_part = f" - {item['summary']}" if item["summary"] else ""
|
|
lines.append(
|
|
f"- {item['operation_id']}: {item['method']} {item['path']}{item['query_string']}{summary_part}"
|
|
)
|
|
else:
|
|
# Full format grouped by tag
|
|
tags: dict[str, list] = {}
|
|
for op in result.operations:
|
|
for tag in op.tags or ["Other"]:
|
|
if tag not in tags:
|
|
tags[tag] = []
|
|
tags[tag].append(op)
|
|
|
|
for tag_name in sorted(tags.keys()):
|
|
tag_ops = tags[tag_name]
|
|
lines.append(f"## {tag_name}")
|
|
lines.append("")
|
|
for op in tag_ops:
|
|
lines.append(
|
|
f"- **{op.method}** `{op.path}` - {op.summary or op.operation_id}"
|
|
)
|
|
lines.append("")
|
|
|
|
# Footer with help
|
|
lines.append("")
|
|
lines.append(f"Use 'api ops {provider} --search <term>' for filtered list")
|
|
lines.append(f"Use 'api call {provider} <operation_id>' to call an endpoint")
|
|
|
|
return "\n".join(lines)
|
|
|
|
# =========================================================================
|
|
# Preloading
|
|
# =========================================================================
|
|
|
|
async def preload_specs(
|
|
self,
|
|
providers: list[str] | None = None,
|
|
concurrent: int = 5,
|
|
) -> dict[str, int]:
|
|
"""Preload and cache OpenAPI specs for faster first use.
|
|
|
|
Args:
|
|
providers: List of provider names to preload. If None, preloads
|
|
all providers with spec_url configured.
|
|
concurrent: Max concurrent downloads (default 5)
|
|
|
|
Returns:
|
|
Dict mapping provider name to operation count (or -1 if failed)
|
|
|
|
Example:
|
|
# Preload all configured specs
|
|
results = await client.preload_specs()
|
|
print(f"Preloaded {len(results)} specs")
|
|
|
|
# Preload specific providers
|
|
await client.preload_specs(["cloudflare", "github", "openai"])
|
|
"""
|
|
import asyncio
|
|
|
|
# Determine which providers to preload
|
|
if providers is None:
|
|
providers = [
|
|
name for name, config in PROVIDER_CONFIGS.items() if config.spec_url
|
|
]
|
|
|
|
results: dict[str, int] = {}
|
|
semaphore = asyncio.Semaphore(concurrent)
|
|
|
|
async def preload_one(provider: str) -> tuple[str, int]:
|
|
async with semaphore:
|
|
try:
|
|
count = await self.spec(provider)
|
|
logger.info(f"Preloaded {provider}: {count} operations")
|
|
return (provider, count)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to preload {provider}: {e}")
|
|
return (provider, -1)
|
|
|
|
tasks = [preload_one(p) for p in providers]
|
|
completed = await asyncio.gather(*tasks)
|
|
|
|
for name, count in completed:
|
|
results[name] = count
|
|
|
|
return results
|
|
|
|
@classmethod
|
|
async def preload_common(cls) -> APIClient:
|
|
"""Create a client with common specs preloaded.
|
|
|
|
Returns a client with cloudflare, github, stripe, openai, notion
|
|
specs already cached.
|
|
|
|
Example:
|
|
client = await APIClient.preload_common()
|
|
# Now all common specs are cached
|
|
"""
|
|
client = cls()
|
|
await client.preload_specs(
|
|
[
|
|
"cloudflare",
|
|
"github",
|
|
"stripe",
|
|
"openai",
|
|
"notion",
|
|
"groq",
|
|
]
|
|
)
|
|
return client
|
|
|
|
# =========================================================================
|
|
# Cleanup
|
|
# =========================================================================
|
|
|
|
async def close(self) -> None:
|
|
"""Close all HTTP clients."""
|
|
for client in self._clients.values():
|
|
await client.close()
|
|
self._clients.clear()
|
|
|
|
async def __aenter__(self) -> APIClient:
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
await self.close()
|
|
|
|
|
|
# =============================================================================
|
|
# Module-level singleton
|
|
# =============================================================================
|
|
|
|
_default_client: APIClient | None = None
|
|
|
|
|
|
def get_api_client() -> APIClient:
|
|
"""Get the default API client instance."""
|
|
global _default_client
|
|
if _default_client is None:
|
|
_default_client = APIClient()
|
|
return _default_client
|
|
|
|
|
|
def reset_api_client() -> None:
|
|
"""Reset the default API client (for testing)."""
|
|
global _default_client
|
|
_default_client = None
|