626 lines
24 KiB
Python
626 lines
24 KiB
Python
"""Typed, retry-bounded ContextForge HTTP adapter."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import time
|
|
from base64 import b64encode
|
|
from collections.abc import Mapping
|
|
from datetime import UTC, datetime
|
|
from typing import Any, Literal, Protocol, cast
|
|
from uuid import UUID
|
|
|
|
import httpx
|
|
|
|
from .errors import ContextForgeError
|
|
from .models import (
|
|
ContextForgeSettings,
|
|
GatewayCapabilities,
|
|
GatewayDesiredState,
|
|
GatewayHandle,
|
|
GatewayPrompt,
|
|
GatewaySnapshot,
|
|
GatewayTestResult,
|
|
GatewayTool,
|
|
SanitizedGatewayInventory,
|
|
VirtualServerDesiredState,
|
|
VirtualServerHandle,
|
|
)
|
|
|
|
|
|
class GatewayAdapter(Protocol):
|
|
def health(self) -> dict[str, Any]: ...
|
|
|
|
def capabilities(self) -> GatewayCapabilities: ...
|
|
|
|
def list_gateways(
|
|
self, *, cursor: str | None = None, limit: int = 50
|
|
) -> tuple[GatewaySnapshot, ...]: ...
|
|
|
|
def ensure_gateway(self, desired: GatewayDesiredState) -> GatewayHandle: ...
|
|
|
|
def update_gateway(
|
|
self, handle: GatewayHandle, desired: GatewayDesiredState
|
|
) -> GatewayHandle: ...
|
|
|
|
def delete_gateway(self, handle: GatewayHandle) -> None: ...
|
|
|
|
def refresh_gateway(self, handle: GatewayHandle) -> None: ...
|
|
|
|
def list_gateway_tools(self, handle: GatewayHandle) -> tuple[GatewayTool, ...]: ...
|
|
|
|
def ensure_canonical_tool_names(
|
|
self, tools: tuple[GatewayTool, ...]
|
|
) -> tuple[GatewayTool, ...]: ...
|
|
|
|
def list_gateway_prompts(self, handle: GatewayHandle) -> tuple[GatewayPrompt, ...]: ...
|
|
|
|
def test_gateway(self, handle: GatewayHandle) -> GatewayTestResult: ...
|
|
|
|
|
|
class ContextForgeClient:
|
|
"""ContextForge API client; raw gateway IDs stay inside this adapter."""
|
|
|
|
def __init__(
|
|
self,
|
|
settings: ContextForgeSettings,
|
|
*,
|
|
transport: httpx.BaseTransport | None = None,
|
|
environ: Mapping[str, str] | None = None,
|
|
) -> None:
|
|
self.settings = settings
|
|
self._environ = environ if environ is not None else os.environ
|
|
self._http = httpx.Client(
|
|
base_url=str(settings.base_url).rstrip("/"),
|
|
timeout=settings.timeout_seconds,
|
|
transport=transport,
|
|
)
|
|
self._capabilities: GatewayCapabilities | None = None
|
|
|
|
def close(self) -> None:
|
|
self._http.close()
|
|
|
|
def _auth_headers(self) -> dict[str, str]:
|
|
token = self.settings.credential(self._environ)
|
|
scheme = self.settings.auth_scheme
|
|
if scheme == "Basic":
|
|
encoded = b64encode(f"{self.settings.basic_username}:{token}".encode()).decode("ascii")
|
|
credential = f"Basic {encoded}"
|
|
else:
|
|
credential = f"Bearer {token}"
|
|
return {"Accept": "application/json", "Authorization": credential}
|
|
|
|
def authorization_header(self) -> str:
|
|
"""Build the outbound credential only at the request boundary."""
|
|
|
|
return self._auth_headers()["Authorization"]
|
|
|
|
def _request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
params: Mapping[str, str | int] | None = None,
|
|
json_body: Mapping[str, Any] | None = None,
|
|
retryable: bool = False,
|
|
) -> Any:
|
|
attempts = self.settings.retries if retryable else 0
|
|
for attempt in range(attempts + 1):
|
|
try:
|
|
response = self._http.request(
|
|
method,
|
|
path,
|
|
headers=self._auth_headers(),
|
|
params=params,
|
|
json=json_body,
|
|
)
|
|
except httpx.TimeoutException as exc:
|
|
if attempt < attempts:
|
|
time.sleep(min(0.25 * (attempt + 1), 1.0))
|
|
continue
|
|
raise ContextForgeError(
|
|
"gateway_timeout", "Gateway request timed out.", retryable=True
|
|
) from exc
|
|
except httpx.HTTPError as exc:
|
|
if attempt < attempts:
|
|
time.sleep(min(0.25 * (attempt + 1), 1.0))
|
|
continue
|
|
raise ContextForgeError(
|
|
"gateway_unavailable", "Gateway could not be reached.", retryable=True
|
|
) from exc
|
|
if response.status_code >= 400:
|
|
error = ContextForgeError.from_status(response.status_code)
|
|
if error.retryable and attempt < attempts:
|
|
time.sleep(min(0.25 * (attempt + 1), 1.0))
|
|
continue
|
|
raise error
|
|
if len(response.content) > self.settings.max_response_bytes:
|
|
raise ContextForgeError(
|
|
"gateway_response_too_large", "Gateway response exceeded the output budget."
|
|
)
|
|
if not response.content:
|
|
return {}
|
|
try:
|
|
return response.json()
|
|
except (ValueError, json.JSONDecodeError) as exc:
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Gateway returned an invalid response."
|
|
) from exc
|
|
raise ContextForgeError("gateway_unavailable", "Gateway request failed.", retryable=True)
|
|
|
|
def health(self) -> dict[str, Any]:
|
|
value = self._request("GET", "/health", retryable=True)
|
|
if not isinstance(value, dict):
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Gateway health response was invalid."
|
|
)
|
|
return {str(key): value[key] for key in value if key in {"status", "version"}}
|
|
|
|
def readiness(self) -> dict[str, Any]:
|
|
value = self._request("GET", "/ready", retryable=True)
|
|
if not isinstance(value, dict):
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Gateway readiness response was invalid."
|
|
)
|
|
return {str(key): value[key] for key in value if key in {"status"}}
|
|
|
|
def version(self) -> str | None:
|
|
value = self._request("GET", "/version", retryable=True)
|
|
if not isinstance(value, dict):
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Gateway version response was invalid."
|
|
)
|
|
raw = value.get("version") or value.get("release")
|
|
if raw is None and isinstance(value.get("app"), dict):
|
|
raw = value["app"].get("version")
|
|
return str(raw) if raw is not None else None
|
|
|
|
def capabilities(self) -> GatewayCapabilities:
|
|
if self._capabilities is not None:
|
|
return self._capabilities
|
|
health_ok = False
|
|
readiness_ok = False
|
|
version_value: str | None = None
|
|
try:
|
|
self.health()
|
|
health_ok = True
|
|
except ContextForgeError:
|
|
pass
|
|
try:
|
|
self.readiness()
|
|
readiness_ok = True
|
|
except ContextForgeError:
|
|
pass
|
|
try:
|
|
version_value = self.version()
|
|
except ContextForgeError:
|
|
pass
|
|
crud_ok = False
|
|
if health_ok:
|
|
try:
|
|
self.list_gateways(limit=1)
|
|
crud_ok = True
|
|
except ContextForgeError:
|
|
pass
|
|
self._capabilities = GatewayCapabilities(
|
|
health=health_ok,
|
|
readiness=readiness_ok,
|
|
version=version_value,
|
|
gateway_crud=crud_ok,
|
|
gateway_test=health_ok,
|
|
)
|
|
return self._capabilities
|
|
|
|
@staticmethod
|
|
def _snapshot(payload: Mapping[str, Any]) -> tuple[str, GatewaySnapshot]:
|
|
external_id = payload.get("id")
|
|
name = payload.get("name") or payload.get("slug")
|
|
if (
|
|
not isinstance(external_id, str)
|
|
or not external_id
|
|
or not isinstance(name, str)
|
|
or not name
|
|
):
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Gateway resource identity was invalid."
|
|
)
|
|
snapshot = GatewaySnapshot(
|
|
slug=name,
|
|
url=str(payload.get("url", "")),
|
|
description=str(payload.get("description", ""))[:1000],
|
|
transport=str(payload.get("transport", "STREAMABLEHTTP")),
|
|
enabled=bool(payload.get("enabled", True)),
|
|
status=str(payload.get("status", "unknown")),
|
|
passthrough_headers=tuple(
|
|
str(item).lower()
|
|
for item in payload.get(
|
|
"passthroughHeaders", payload.get("passthrough_headers", [])
|
|
)
|
|
),
|
|
)
|
|
return external_id, snapshot
|
|
|
|
def list_gateways(
|
|
self, *, cursor: str | None = None, limit: int = 50
|
|
) -> tuple[GatewaySnapshot, ...]:
|
|
if not 1 <= limit <= 100:
|
|
raise ValueError("gateway list limit must be between 1 and 100")
|
|
params: dict[str, str | int] = {"limit": limit, "include_pagination": "false"}
|
|
if cursor:
|
|
params["cursor"] = cursor
|
|
value = self._request("GET", "/gateways", params=params, retryable=True)
|
|
entities = (
|
|
value
|
|
if isinstance(value, list)
|
|
else value.get("gateways", [])
|
|
if isinstance(value, dict)
|
|
else []
|
|
)
|
|
if not isinstance(entities, list):
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Gateway inventory response was invalid."
|
|
)
|
|
return tuple(self._snapshot(item)[1] for item in entities if isinstance(item, dict))
|
|
|
|
def _find(self, slug: str) -> GatewayHandle | None:
|
|
value = self._request(
|
|
"GET",
|
|
"/gateways",
|
|
params={"limit": 100, "include_pagination": "false"},
|
|
retryable=True,
|
|
)
|
|
entities = (
|
|
value
|
|
if isinstance(value, list)
|
|
else value.get("gateways", [])
|
|
if isinstance(value, dict)
|
|
else []
|
|
)
|
|
for item in entities if isinstance(entities, list) else []:
|
|
if isinstance(item, dict) and (item.get("name") or item.get("slug")) == slug:
|
|
external_id, snapshot = self._snapshot(item)
|
|
return GatewayHandle(external_id=external_id, snapshot=snapshot)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _desired_payload(desired: GatewayDesiredState) -> dict[str, Any]:
|
|
return {
|
|
"name": desired.slug,
|
|
"url": str(desired.url),
|
|
"description": desired.description,
|
|
"transport": desired.transport,
|
|
"enabled": desired.enabled,
|
|
"passthrough_headers": list(desired.passthrough_headers),
|
|
}
|
|
|
|
def create_gateway(self, desired: GatewayDesiredState) -> GatewayHandle:
|
|
value = self._request("POST", "/gateways", json_body=self._desired_payload(desired))
|
|
if not isinstance(value, dict):
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Gateway create response was invalid."
|
|
)
|
|
external_id, snapshot = self._snapshot(value)
|
|
return GatewayHandle(external_id=external_id, snapshot=snapshot)
|
|
|
|
def ensure_gateway(self, desired: GatewayDesiredState) -> GatewayHandle:
|
|
existing = self._find(desired.slug)
|
|
if existing is None:
|
|
return self.create_gateway(desired)
|
|
# ContextForge can deactivate a gateway after repeated upstream failures
|
|
# while leaving its configured ``enabled`` flag set. Treat runtime status
|
|
# as part of the desired state so a recovered connector is explicitly
|
|
# reactivated instead of retaining a stale, offline tool inventory.
|
|
runtime_status = existing.snapshot.status.strip().lower()
|
|
if (
|
|
existing.snapshot.url != str(desired.url)
|
|
or existing.snapshot.description != desired.description
|
|
or existing.snapshot.transport != desired.transport
|
|
or existing.snapshot.enabled != desired.enabled
|
|
or existing.snapshot.passthrough_headers != desired.passthrough_headers
|
|
or runtime_status not in {"active", "healthy", "online", "ready"}
|
|
):
|
|
return self.update_gateway(existing, desired)
|
|
return existing
|
|
|
|
def update_gateway(self, handle: GatewayHandle, desired: GatewayDesiredState) -> GatewayHandle:
|
|
value = self._request(
|
|
"PUT",
|
|
f"/gateways/{handle.external_id}",
|
|
json_body=self._desired_payload(desired),
|
|
)
|
|
if not isinstance(value, dict):
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Gateway update response was invalid."
|
|
)
|
|
external_id, snapshot = self._snapshot(value)
|
|
return GatewayHandle(external_id=external_id, snapshot=snapshot)
|
|
|
|
def delete_gateway(self, handle: GatewayHandle) -> None:
|
|
self._request("DELETE", f"/gateways/{handle.external_id}")
|
|
|
|
def list_gateway_tools(self, handle: GatewayHandle) -> tuple[GatewayTool, ...]:
|
|
value = self._request(
|
|
"GET",
|
|
"/tools",
|
|
params={
|
|
"gateway_id": handle.external_id,
|
|
"limit": 0,
|
|
"include_pagination": "false",
|
|
},
|
|
retryable=True,
|
|
)
|
|
entities = (
|
|
value
|
|
if isinstance(value, list)
|
|
else value.get("tools", [])
|
|
if isinstance(value, dict)
|
|
else []
|
|
)
|
|
tools: list[GatewayTool] = []
|
|
for item in entities if isinstance(entities, list) else []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
external_id = item.get("id")
|
|
name = (
|
|
item.get("originalName")
|
|
or item.get("original_name")
|
|
or item.get("customName")
|
|
or item.get("name")
|
|
)
|
|
published_name = item.get("customName") or item.get("custom_name") or item.get("name")
|
|
if isinstance(external_id, str) and isinstance(name, str):
|
|
if not isinstance(published_name, str):
|
|
published_name = name
|
|
raw_input_schema = item.get("inputSchema", item.get("input_schema"))
|
|
tools.append(
|
|
GatewayTool(
|
|
external_id=external_id,
|
|
name=name,
|
|
published_name=published_name,
|
|
input_schema=(
|
|
raw_input_schema if isinstance(raw_input_schema, dict) else None
|
|
),
|
|
)
|
|
)
|
|
return tuple(sorted(tools, key=lambda item: item.name))
|
|
|
|
def ensure_canonical_tool_names(
|
|
self, tools: tuple[GatewayTool, ...]
|
|
) -> tuple[GatewayTool, ...]:
|
|
"""Publish connector names verbatim instead of ContextForge prefixes.
|
|
|
|
ContextForge makes discovered tool names globally unique by prefixing the
|
|
gateway slug. The Hub catalog and ChatGPT app expose the connector's
|
|
canonical MCP names, so leaving that prefix in place makes every routed
|
|
``tools/call`` miss even though discovery itself is healthy.
|
|
"""
|
|
|
|
canonical: list[GatewayTool] = []
|
|
for tool in tools:
|
|
if tool.published_name == tool.name:
|
|
canonical.append(tool)
|
|
continue
|
|
value = self._request(
|
|
"PUT", f"/tools/{tool.external_id}", json_body={"customName": tool.name}
|
|
)
|
|
if not isinstance(value, dict) or value.get("customName") != tool.name:
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Gateway tool rename was not applied."
|
|
)
|
|
canonical.append(tool.model_copy(update={"published_name": tool.name}))
|
|
return tuple(canonical)
|
|
|
|
def refresh_gateway(self, handle: GatewayHandle) -> None:
|
|
value = self._request(
|
|
"POST",
|
|
f"/gateways/{handle.external_id}/tools/refresh",
|
|
params={"include_resources": "true", "include_prompts": "true"},
|
|
)
|
|
if not isinstance(value, dict) or value.get("success") is not True:
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Gateway refresh did not complete successfully."
|
|
)
|
|
|
|
def list_gateway_prompts(self, handle: GatewayHandle) -> tuple[GatewayPrompt, ...]:
|
|
value = self._request(
|
|
"GET",
|
|
"/prompts",
|
|
params={
|
|
"gateway_id": handle.external_id,
|
|
"limit": 0,
|
|
"include_pagination": "false",
|
|
},
|
|
retryable=True,
|
|
)
|
|
entities = (
|
|
value
|
|
if isinstance(value, list)
|
|
else value.get("prompts", [])
|
|
if isinstance(value, dict)
|
|
else []
|
|
)
|
|
prompts: list[GatewayPrompt] = []
|
|
for item in entities if isinstance(entities, list) else []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
external_id = item.get("id")
|
|
name = (
|
|
item.get("originalName")
|
|
or item.get("original_name")
|
|
or item.get("customName")
|
|
or item.get("name")
|
|
)
|
|
if isinstance(external_id, str) and isinstance(name, str):
|
|
prompts.append(GatewayPrompt(external_id=external_id, name=name))
|
|
return tuple(sorted(prompts, key=lambda item: item.name))
|
|
|
|
@staticmethod
|
|
def _server_handle(payload: Mapping[str, Any]) -> VirtualServerHandle:
|
|
external_id = payload.get("id")
|
|
name = payload.get("name")
|
|
tool_ids = payload.get(
|
|
"associated_tool_ids",
|
|
payload.get("associatedToolIds", payload.get("associated_tools", [])),
|
|
)
|
|
prompt_ids = payload.get(
|
|
"associated_prompt_ids",
|
|
payload.get("associatedPromptIds", payload.get("associated_prompts", [])),
|
|
)
|
|
if (
|
|
not isinstance(external_id, str)
|
|
or not isinstance(name, str)
|
|
or not isinstance(tool_ids, list)
|
|
or not isinstance(prompt_ids, list)
|
|
):
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Virtual server response was invalid."
|
|
)
|
|
normalized = tuple(
|
|
str(item.get("id")) if isinstance(item, dict) else str(item) for item in tool_ids
|
|
)
|
|
normalized_prompts = tuple(
|
|
str(item.get("id")) if isinstance(item, dict) else str(item) for item in prompt_ids
|
|
)
|
|
return VirtualServerHandle(
|
|
external_id=external_id,
|
|
name=name,
|
|
associated_tool_ids=tuple(sorted(normalized)),
|
|
associated_prompt_ids=tuple(sorted(normalized_prompts)),
|
|
enabled=bool(payload.get("enabled", True)),
|
|
visibility=cast(
|
|
Literal["public", "private", "team"],
|
|
str(payload.get("visibility", "private")),
|
|
),
|
|
)
|
|
|
|
def _find_server(self, desired: VirtualServerDesiredState) -> VirtualServerHandle | None:
|
|
value = self._request(
|
|
"GET",
|
|
"/servers",
|
|
params={"limit": 0, "include_pagination": "false", "include_inactive": "true"},
|
|
retryable=True,
|
|
)
|
|
entities = (
|
|
value
|
|
if isinstance(value, list)
|
|
else value.get("servers", [])
|
|
if isinstance(value, dict)
|
|
else []
|
|
)
|
|
for item in entities if isinstance(entities, list) else []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
candidate = self._server_handle(item)
|
|
try:
|
|
same_id = UUID(candidate.external_id) == desired.external_id
|
|
except ValueError:
|
|
same_id = False
|
|
if same_id or candidate.name == desired.name:
|
|
return candidate
|
|
return None
|
|
|
|
@staticmethod
|
|
def _server_payload(desired: VirtualServerDesiredState, *, include_id: bool) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"name": desired.name,
|
|
"description": desired.description,
|
|
"associated_tools": list(desired.associated_tool_ids),
|
|
"associated_prompts": list(desired.associated_prompt_ids),
|
|
"visibility": desired.visibility,
|
|
}
|
|
if include_id:
|
|
payload["id"] = str(desired.external_id)
|
|
return payload
|
|
|
|
def ensure_virtual_server(self, desired: VirtualServerDesiredState) -> VirtualServerHandle:
|
|
existing = self._find_server(desired)
|
|
if existing is None:
|
|
server_payload = self._server_payload(desired, include_id=True)
|
|
value = self._request("POST", "/servers", json_body={"server": server_payload})
|
|
elif (
|
|
existing.name != desired.name
|
|
or existing.associated_tool_ids != desired.associated_tool_ids
|
|
or existing.associated_prompt_ids != desired.associated_prompt_ids
|
|
or existing.visibility != desired.visibility
|
|
):
|
|
value = self._request(
|
|
"PUT",
|
|
f"/servers/{existing.external_id}",
|
|
json_body=self._server_payload(desired, include_id=False),
|
|
)
|
|
else:
|
|
return existing
|
|
if not isinstance(value, dict):
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Virtual server response was invalid."
|
|
)
|
|
return self._server_handle(value)
|
|
|
|
def test_gateway(self, handle: GatewayHandle) -> GatewayTestResult:
|
|
try:
|
|
value = self._request("GET", f"/gateways/{handle.external_id}/test", retryable=True)
|
|
except ContextForgeError as exc:
|
|
if exc.code == "gateway_not_found":
|
|
return GatewayTestResult(
|
|
reachable=False,
|
|
status="unsupported",
|
|
detail="Gateway test endpoint unavailable.",
|
|
)
|
|
raise
|
|
if not isinstance(value, dict):
|
|
raise ContextForgeError(
|
|
"gateway_invalid_response", "Gateway test response was invalid."
|
|
)
|
|
return GatewayTestResult(
|
|
reachable=bool(value.get("reachable", value.get("status") in {"ok", "healthy"})),
|
|
status=str(value.get("status", "unknown")),
|
|
detail=str(value.get("detail", ""))[:256],
|
|
)
|
|
|
|
def test_gateway_by_slug(self, slug: str) -> GatewayTestResult:
|
|
"""Probe one registered gateway without exposing its external ID."""
|
|
|
|
handle = self._find(slug)
|
|
if handle is None:
|
|
return GatewayTestResult(
|
|
reachable=False,
|
|
status="missing",
|
|
detail="Connector gateway is not registered.",
|
|
)
|
|
return self.test_gateway(handle)
|
|
|
|
def inventory(self) -> SanitizedGatewayInventory:
|
|
capabilities = self.capabilities()
|
|
value = self._request(
|
|
"GET",
|
|
"/gateways",
|
|
params={"limit": 100, "include_pagination": "false"},
|
|
retryable=True,
|
|
)
|
|
entities = (
|
|
value
|
|
if isinstance(value, list)
|
|
else value.get("gateways", [])
|
|
if isinstance(value, dict)
|
|
else []
|
|
)
|
|
inventory: list[dict[str, Any]] = []
|
|
for item in entities if isinstance(entities, list) else []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
external_id = str(item.get("id", ""))
|
|
inventory.append(
|
|
{
|
|
"slug": str(item.get("name") or item.get("slug") or ""),
|
|
"external_id_digest": hashlib.sha256(external_id.encode()).hexdigest(),
|
|
"url": str(item.get("url", "")),
|
|
"status": str(item.get("status", "unknown")),
|
|
"enabled": bool(item.get("enabled", True)),
|
|
}
|
|
)
|
|
return SanitizedGatewayInventory(
|
|
generated_at=datetime.now(UTC), capabilities=capabilities, gateways=tuple(inventory)
|
|
)
|