Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,33 @@
|
||||
"""Replaceable ContextForge gateway adapter boundary."""
|
||||
|
||||
from .client import ContextForgeClient
|
||||
from .errors import ContextForgeError
|
||||
from .fake import FakeContextForge
|
||||
from .models import (
|
||||
ContextForgeSettings,
|
||||
GatewayCapabilities,
|
||||
GatewayDesiredState,
|
||||
GatewayPrompt,
|
||||
GatewaySnapshot,
|
||||
GatewayTestResult,
|
||||
GatewayTool,
|
||||
SanitizedGatewayInventory,
|
||||
VirtualServerDesiredState,
|
||||
VirtualServerHandle,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ContextForgeClient",
|
||||
"ContextForgeError",
|
||||
"ContextForgeSettings",
|
||||
"FakeContextForge",
|
||||
"GatewayCapabilities",
|
||||
"GatewayDesiredState",
|
||||
"GatewayPrompt",
|
||||
"GatewaySnapshot",
|
||||
"GatewayTestResult",
|
||||
"GatewayTool",
|
||||
"SanitizedGatewayInventory",
|
||||
"VirtualServerDesiredState",
|
||||
"VirtualServerHandle",
|
||||
]
|
||||
@@ -0,0 +1,625 @@
|
||||
"""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)
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Stable, sanitized gateway adapter errors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ContextForgeError(Exception):
|
||||
"""An adapter error safe for logs and public service error mapping."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
retryable: bool = False,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
self.retryable = retryable
|
||||
|
||||
@classmethod
|
||||
def from_status(cls, status_code: int) -> ContextForgeError:
|
||||
mappings = {
|
||||
401: ("gateway_unauthorized", "Gateway authorization failed.", False),
|
||||
403: ("gateway_forbidden", "Gateway authorization was denied.", False),
|
||||
404: ("gateway_not_found", "Gateway resource was not found.", False),
|
||||
400: ("gateway_invalid_request", "Gateway request was invalid.", False),
|
||||
422: ("gateway_invalid_request", "Gateway request was invalid.", False),
|
||||
409: ("gateway_conflict", "Gateway resource conflicts with existing state.", False),
|
||||
429: ("gateway_rate_limited", "Gateway rate limit reached.", True),
|
||||
}
|
||||
code, message, retryable = mappings.get(
|
||||
status_code,
|
||||
(
|
||||
"gateway_unavailable" if status_code >= 500 else "gateway_request_failed",
|
||||
"Gateway request failed.",
|
||||
status_code >= 500,
|
||||
),
|
||||
)
|
||||
return cls(code, message, status_code=status_code, retryable=retryable)
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Deterministic in-memory ContextForge-compatible target."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class FakeContextForge:
|
||||
"""Fake REST gateway for offline adapter and reconciliation tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.gateways: dict[str, dict[str, Any]] = {}
|
||||
self.tools: dict[str, list[dict[str, Any]]] = {}
|
||||
self.prompts: dict[str, list[dict[str, Any]]] = {}
|
||||
self.servers: dict[str, dict[str, Any]] = {}
|
||||
self.requests: list[tuple[str, str, dict[str, str]]] = []
|
||||
self.fail_next: int | None = None
|
||||
|
||||
_gateway_tools = {
|
||||
"itworx-stitch": (
|
||||
"stitch.project.list",
|
||||
"stitch.project.get",
|
||||
"stitch.project.create",
|
||||
"stitch.screen.list",
|
||||
"stitch.screen.get",
|
||||
"stitch.screen.generate",
|
||||
"stitch.screen.edit",
|
||||
"stitch.screen.variants",
|
||||
),
|
||||
"itworx-knowledge": (
|
||||
"knowledge.document.search",
|
||||
"knowledge.document.get",
|
||||
"knowledge.source.list",
|
||||
"knowledge.index.status",
|
||||
"knowledge.adr.list",
|
||||
"knowledge.project.summary",
|
||||
),
|
||||
"itworx-gitea": (
|
||||
"gitea.capabilities.get",
|
||||
"gitea.connection.check",
|
||||
"gitea.selftest.run",
|
||||
"gitea.repository.list",
|
||||
"gitea.repository.get",
|
||||
"gitea.repository.context",
|
||||
"gitea.repository.health",
|
||||
"gitea.branchprotection.get",
|
||||
"gitea.branchprotection.preview",
|
||||
"gitea.branch.list",
|
||||
"gitea.commit.list",
|
||||
"gitea.commit.get",
|
||||
"gitea.ref.compare",
|
||||
"gitea.release.list",
|
||||
"gitea.issue.list",
|
||||
"gitea.issue.search",
|
||||
"gitea.code.search",
|
||||
"search",
|
||||
"fetch",
|
||||
"gitea.file.read",
|
||||
"gitea.tree.list",
|
||||
"gitea.changeset.preview",
|
||||
"gitea.validation.get",
|
||||
"gitea.validation.failure.get",
|
||||
"gitea.validation.list",
|
||||
"gitea.change.status",
|
||||
"gitea.change.review",
|
||||
"gitea.change.reconcile",
|
||||
"gitea.workspace.status",
|
||||
"gitea.portfolio.health",
|
||||
"gitea.portfolio.governance.preview",
|
||||
"gitea.portfolio.governance.verify",
|
||||
"gitea.orchestration.plan",
|
||||
"gitea.pullrequest.list",
|
||||
"gitea.pullrequest.get",
|
||||
"gitea.pullrequest.feedback",
|
||||
"gitea.commitstatus.list",
|
||||
"gitea.repository.create",
|
||||
"gitea.branchprotection.ensure",
|
||||
"gitea.portfolio.governance.apply",
|
||||
"gitea.branch.create",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.update",
|
||||
"gitea.issue.comment.create",
|
||||
"gitea.file.create",
|
||||
"gitea.file.update",
|
||||
"gitea.file.delete",
|
||||
"gitea.pullrequest.create",
|
||||
"gitea.pullrequest.update",
|
||||
"gitea.pullrequest.close",
|
||||
"gitea.pullrequest.validation.ensure",
|
||||
"gitea.pullrequest.sync",
|
||||
"gitea.pullrequest.merge",
|
||||
"gitea.branch.delete",
|
||||
"gitea.changeset.apply",
|
||||
"gitea.validation.run",
|
||||
"gitea.workspace.run",
|
||||
"gitea.orchestration.start",
|
||||
),
|
||||
"itworx-unraid": (
|
||||
"unraid.host.status",
|
||||
"unraid.storage.list",
|
||||
"unraid.network.list",
|
||||
"unraid.container.list",
|
||||
"unraid.container.inspect",
|
||||
"unraid.container.health",
|
||||
"unraid.container.logs",
|
||||
"unraid.project.summary",
|
||||
),
|
||||
"itworx-mobilityops": (
|
||||
"mobilityops.operations.summary",
|
||||
"mobilityops.attention.list",
|
||||
"mobilityops.vehicle.get",
|
||||
"mobilityops.knowledge.search",
|
||||
),
|
||||
"itworx-projectbrain": (
|
||||
"projectbrain.project.list",
|
||||
"projectbrain.repository.list",
|
||||
"projectbrain.portfolio.get",
|
||||
"projectbrain.portfolio.repository.list",
|
||||
"projectbrain.portfolio.repository.reconcile",
|
||||
"projectbrain.portfolio.repository.exclusion.set",
|
||||
"projectbrain.repository.search",
|
||||
"projectbrain.repository.fetch",
|
||||
"projectbrain.context.get",
|
||||
"projectbrain.workspace.render",
|
||||
"projectbrain.modules.get",
|
||||
"projectbrain.brainstorm.save",
|
||||
"projectbrain.knowledge.save",
|
||||
"projectbrain.documentation.save",
|
||||
"projectbrain.memory.save",
|
||||
"projectbrain.contextpack.create",
|
||||
"projectbrain.feedback.record",
|
||||
"projectbrain.delivery.list",
|
||||
"projectbrain.delivery.get",
|
||||
"projectbrain.delivery.create",
|
||||
"projectbrain.delivery.update",
|
||||
"projectbrain.repository.tree",
|
||||
"projectbrain.repository.file",
|
||||
"projectbrain.repository.branches",
|
||||
"projectbrain.repository.pullrequests",
|
||||
"projectbrain.delivery.start",
|
||||
"projectbrain.delivery.file.write",
|
||||
"projectbrain.delivery.file.delete",
|
||||
"projectbrain.delivery.changeset.apply",
|
||||
"projectbrain.delivery.validation.record",
|
||||
"projectbrain.delivery.validation.runs",
|
||||
"projectbrain.delivery.validation.sync",
|
||||
"projectbrain.delivery.complete",
|
||||
"projectbrain.delivery.merge",
|
||||
"projectbrain.analysis.start",
|
||||
"projectbrain.analysis.list",
|
||||
"projectbrain.analysis.status",
|
||||
"projectbrain.analysis.results",
|
||||
"projectbrain.analysis.cancel",
|
||||
"projectbrain.analysis.render",
|
||||
"projectbrain.finding.list",
|
||||
"projectbrain.finding.get",
|
||||
"projectbrain.finding.issue.sync",
|
||||
"projectbrain.finding.investigation.start",
|
||||
"projectbrain.finding.investigation.get",
|
||||
"projectbrain.finding.investigation.save",
|
||||
"projectbrain.finding.suppress",
|
||||
"projectbrain.finding.delivery.create",
|
||||
"projectbrain.finding.verify",
|
||||
"projectbrain.autonomy.projects.list",
|
||||
"projectbrain.autonomy.executor.heartbeat",
|
||||
"projectbrain.autonomy.state.get",
|
||||
"projectbrain.autonomy.configure",
|
||||
"projectbrain.autonomy.roadmap.import",
|
||||
"projectbrain.autonomy.resume",
|
||||
"projectbrain.autonomy.work.claim",
|
||||
"projectbrain.autonomy.work.report",
|
||||
),
|
||||
"itworx-appops": (
|
||||
"server.summary.get",
|
||||
"server.application.list",
|
||||
"server.container.list",
|
||||
"server.safety.findings",
|
||||
"server.action.list",
|
||||
"server.operation.preview",
|
||||
"server.audit.search",
|
||||
"server.knowledge.query",
|
||||
"server.operation.request",
|
||||
"server.operation.status",
|
||||
"server.operation.execute",
|
||||
"server.job.status",
|
||||
"server.job.cancel",
|
||||
"server.ci.runners.status",
|
||||
"server.project.list",
|
||||
"server.project.reconcile",
|
||||
"server.deployment.preflight",
|
||||
"server.deployment.capabilities",
|
||||
"server.deployment.enrollment.preflight",
|
||||
"server.deployment.target.enroll",
|
||||
"server.deployment.enrollment.status",
|
||||
"server.deployment.apply",
|
||||
"server.deployment.status",
|
||||
"server.deployment.verify",
|
||||
"server.deployment.rollback",
|
||||
),
|
||||
"itworx-opencode": (
|
||||
"opencode.capabilities.get",
|
||||
"opencode.models.list",
|
||||
"opencode.workspace.ensure",
|
||||
"opencode.job.preview",
|
||||
"opencode.job.start",
|
||||
"opencode.jobs.list",
|
||||
"opencode.job.status",
|
||||
"opencode.job.result",
|
||||
"opencode.job.cancel",
|
||||
"opencode.delivery.control.repositories.list",
|
||||
"opencode.delivery.control.plan",
|
||||
"opencode.delivery.control.start",
|
||||
"opencode.delivery.control.jobs.list",
|
||||
"opencode.delivery.control.status",
|
||||
"opencode.delivery.control.result",
|
||||
"opencode.delivery.control.retry",
|
||||
"opencode.delivery.control.cancel",
|
||||
),
|
||||
"itworx-opencode-readonly": (
|
||||
"opencode.analysis.capabilities.get",
|
||||
"opencode.analysis.models.list",
|
||||
"opencode.analysis.start",
|
||||
"opencode.analysis.jobs.list",
|
||||
"opencode.analysis.status",
|
||||
"opencode.analysis.result",
|
||||
"opencode.delivery.repositories.list",
|
||||
"opencode.delivery.jobs.list",
|
||||
"opencode.delivery.status",
|
||||
"opencode.delivery.result",
|
||||
),
|
||||
}
|
||||
|
||||
_gateway_prompts = {
|
||||
"itworx-knowledge": ("project-context-pack", "pre-change-impact-review"),
|
||||
"itworx-gitea": (
|
||||
"repository-engineer",
|
||||
"repository-change-review",
|
||||
"repository-maintenance",
|
||||
"portfolio-engineer",
|
||||
"release-readiness",
|
||||
),
|
||||
"itworx-unraid": ("production-incident-triage", "deployment-health-review"),
|
||||
"itworx-mobilityops": ("fleet-operations-morning-brief",),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _tool_payload(tool_name: str) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"id": str(uuid4()), "name": tool_name}
|
||||
if tool_name == "gitea.repository.create":
|
||||
payload["inputSchema"] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
field: {"type": field_type}
|
||||
for field, field_type in (
|
||||
("name", "string"),
|
||||
("description", "string"),
|
||||
("private", "boolean"),
|
||||
("autoInit", "boolean"),
|
||||
("defaultBranch", "string"),
|
||||
)
|
||||
},
|
||||
"required": ["name", "description", "private", "autoInit", "defaultBranch"],
|
||||
}
|
||||
payload["inputSchema"]["properties"]["autoInit"]["const"] = True
|
||||
return payload
|
||||
|
||||
def transport(self) -> httpx.MockTransport:
|
||||
return httpx.MockTransport(self._handle)
|
||||
|
||||
def _response(self, request: httpx.Request, status: int, payload: Any) -> httpx.Response:
|
||||
return httpx.Response(status, json=payload, request=request)
|
||||
|
||||
def _handle(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(
|
||||
(
|
||||
request.method,
|
||||
request.url.path,
|
||||
{
|
||||
key: "<redacted>" if key.lower() == "authorization" else value
|
||||
for key, value in request.headers.items()
|
||||
},
|
||||
)
|
||||
)
|
||||
if self.fail_next is not None:
|
||||
status = self.fail_next
|
||||
self.fail_next = None
|
||||
return self._response(request, status, {"error": "simulated"})
|
||||
path = request.url.path
|
||||
if path.startswith("/v1/"):
|
||||
path = path[3:]
|
||||
if request.method == "GET" and path == "/health":
|
||||
return self._response(request, 200, {"status": "healthy", "version": "1.0.6"})
|
||||
if request.method == "GET" and path == "/ready":
|
||||
return self._response(request, 200, {"status": "ready"})
|
||||
if request.method == "GET" and path == "/version":
|
||||
return self._response(request, 200, {"version": "1.0.6"})
|
||||
if path == "/gateways":
|
||||
if request.method == "GET":
|
||||
return self._response(
|
||||
request, 200, {"gateways": list(self.gateways.values()), "nextCursor": None}
|
||||
)
|
||||
if request.method == "POST":
|
||||
payload = json.loads(request.content)
|
||||
name = str(payload["name"])
|
||||
if any(item["name"] == name for item in self.gateways.values()):
|
||||
return self._response(request, 409, {"error": "conflict"})
|
||||
gateway = {
|
||||
"id": f"gateway-{uuid4().hex}",
|
||||
"name": name,
|
||||
"url": payload["url"],
|
||||
"description": payload.get("description", ""),
|
||||
"transport": payload.get("transport", "STREAMABLEHTTP"),
|
||||
"enabled": payload.get("enabled", True),
|
||||
"status": "active",
|
||||
"passthrough_headers": payload.get("passthrough_headers", []),
|
||||
}
|
||||
self.gateways[gateway["id"]] = gateway
|
||||
self.tools[gateway["id"]] = [
|
||||
self._tool_payload(tool_name) for tool_name in self._gateway_tools.get(name, ())
|
||||
]
|
||||
self.prompts[gateway["id"]] = [
|
||||
{"id": str(uuid4()), "name": prompt_name}
|
||||
for prompt_name in self._gateway_prompts.get(name, ())
|
||||
]
|
||||
return self._response(request, 201, deepcopy(gateway))
|
||||
if path == "/tools" and request.method == "GET":
|
||||
gateway_id = request.url.params.get("gateway_id", "")
|
||||
return self._response(request, 200, deepcopy(self.tools.get(gateway_id, [])))
|
||||
if path == "/prompts" and request.method == "GET":
|
||||
gateway_id = request.url.params.get("gateway_id", "")
|
||||
return self._response(request, 200, deepcopy(self.prompts.get(gateway_id, [])))
|
||||
if path == "/servers":
|
||||
if request.method == "GET":
|
||||
return self._response(request, 200, deepcopy(list(self.servers.values())))
|
||||
if request.method == "POST":
|
||||
payload = json.loads(request.content)
|
||||
payload = payload.get("server", payload)
|
||||
external_id = UUID(str(payload["id"])).hex if payload.get("id") else uuid4().hex
|
||||
server = {
|
||||
"id": external_id,
|
||||
"name": payload["name"],
|
||||
"associated_tool_ids": payload.get("associated_tools", []),
|
||||
"associated_prompt_ids": payload.get("associated_prompts", []),
|
||||
"enabled": True,
|
||||
"visibility": payload.get("visibility", "private"),
|
||||
}
|
||||
self.servers[external_id] = server
|
||||
return self._response(request, 201, deepcopy(server))
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) == 2 and parts[0] == "tools" and request.method == "PUT":
|
||||
payload = json.loads(request.content)
|
||||
for gateway_tools in self.tools.values():
|
||||
tool = next((item for item in gateway_tools if item.get("id") == parts[1]), None)
|
||||
if tool is not None:
|
||||
if "customName" in payload:
|
||||
tool["customName"] = payload["customName"]
|
||||
return self._response(request, 200, deepcopy(tool))
|
||||
return self._response(request, 404, {"error": "not_found"})
|
||||
if len(parts) >= 2 and parts[0] == "gateways":
|
||||
stored = self.gateways.get(parts[1])
|
||||
if stored is None:
|
||||
return self._response(request, 404, {"error": "not_found"})
|
||||
if len(parts) == 3 and parts[2] == "test" and request.method == "GET":
|
||||
return self._response(request, 200, {"reachable": True, "status": "healthy"})
|
||||
if len(parts) == 4 and parts[2:] == ["tools", "refresh"] and request.method == "POST":
|
||||
old_tools = len(self.tools.get(parts[1], []))
|
||||
old_prompts = len(self.prompts.get(parts[1], []))
|
||||
self.tools[parts[1]] = [
|
||||
self._tool_payload(tool_name)
|
||||
for tool_name in self._gateway_tools.get(str(stored["name"]), ())
|
||||
]
|
||||
self.prompts[parts[1]] = [
|
||||
{"id": str(uuid4()), "name": prompt_name}
|
||||
for prompt_name in self._gateway_prompts.get(str(stored["name"]), ())
|
||||
]
|
||||
return self._response(
|
||||
request,
|
||||
200,
|
||||
{
|
||||
"success": True,
|
||||
"tools_added": max(0, len(self.tools[parts[1]]) - old_tools),
|
||||
"tools_removed": 0,
|
||||
"prompts_added": max(0, len(self.prompts[parts[1]]) - old_prompts),
|
||||
"prompts_removed": 0,
|
||||
},
|
||||
)
|
||||
if request.method == "GET":
|
||||
return self._response(request, 200, deepcopy(stored))
|
||||
if request.method == "PUT":
|
||||
payload = json.loads(request.content)
|
||||
stored.update(
|
||||
{
|
||||
"name": payload["name"],
|
||||
"url": payload["url"],
|
||||
"description": payload.get("description", ""),
|
||||
"transport": payload.get("transport", "STREAMABLEHTTP"),
|
||||
"enabled": payload.get("enabled", True),
|
||||
"status": "active" if payload.get("enabled", True) else "inactive",
|
||||
"passthrough_headers": payload.get("passthrough_headers", []),
|
||||
}
|
||||
)
|
||||
return self._response(request, 200, deepcopy(stored))
|
||||
if request.method == "DELETE":
|
||||
del self.gateways[parts[1]]
|
||||
return self._response(request, 204, {})
|
||||
if len(parts) == 2 and parts[0] == "servers":
|
||||
stored_server = self.servers.get(parts[1])
|
||||
if stored_server is None:
|
||||
return self._response(request, 404, {"error": "not_found"})
|
||||
if request.method == "PUT":
|
||||
payload = json.loads(request.content)
|
||||
stored_server.update(
|
||||
{
|
||||
"name": payload["name"],
|
||||
"associated_tool_ids": payload.get("associated_tools", []),
|
||||
"associated_prompt_ids": payload.get("associated_prompts", []),
|
||||
"visibility": payload.get("visibility", "private"),
|
||||
}
|
||||
)
|
||||
return self._response(request, 200, deepcopy(stored_server))
|
||||
return self._response(request, 404, {"error": "not_found"})
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Hub-owned gateway adapter contracts without public gateway IDs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import (
|
||||
AnyHttpUrl,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
TypeAdapter,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from packages.hub_runtime import SecretFileError, read_secret_file
|
||||
|
||||
from .errors import ContextForgeError
|
||||
|
||||
|
||||
class ContextForgeSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
base_url: AnyHttpUrl
|
||||
auth_token_env: str | None = Field(default=None, min_length=1, max_length=256)
|
||||
auth_token_file: Path | None = None
|
||||
auth_scheme: Literal["Bearer", "Basic"] = "Bearer"
|
||||
basic_username: str = Field(default="itworx-contextforge-admin", min_length=1, max_length=256)
|
||||
timeout_seconds: float = Field(default=5.0, gt=0, le=30)
|
||||
retries: int = Field(default=2, ge=0, le=5)
|
||||
max_response_bytes: int = Field(default=4 * 1024 * 1024, ge=1024, le=4 * 1024 * 1024)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, environ: Mapping[str, str] | None = None) -> ContextForgeSettings:
|
||||
values = environ if environ is not None else os.environ
|
||||
base_url = values.get("HUB_CONTEXTFORGE_URL")
|
||||
if not base_url:
|
||||
raise ContextForgeError("gateway_configuration_missing", "Gateway URL is unavailable.")
|
||||
try:
|
||||
return cls(
|
||||
base_url=TypeAdapter(AnyHttpUrl).validate_python(base_url),
|
||||
auth_token_env=(
|
||||
values.get("HUB_CONTEXTFORGE_TOKEN_ENV")
|
||||
if values.get("HUB_CONTEXTFORGE_TOKEN_FILE")
|
||||
else values.get("HUB_CONTEXTFORGE_TOKEN_ENV", "HUB_CONTEXTFORGE_TOKEN")
|
||||
),
|
||||
auth_token_file=(
|
||||
Path(values["HUB_CONTEXTFORGE_TOKEN_FILE"])
|
||||
if values.get("HUB_CONTEXTFORGE_TOKEN_FILE")
|
||||
else None
|
||||
),
|
||||
auth_scheme=cast(
|
||||
Literal["Bearer", "Basic"],
|
||||
values.get("HUB_CONTEXTFORGE_AUTH_SCHEME", "Bearer"),
|
||||
),
|
||||
basic_username=values.get(
|
||||
"HUB_CONTEXTFORGE_BASIC_USERNAME", "itworx-contextforge-admin"
|
||||
),
|
||||
timeout_seconds=float(values.get("HUB_CONTEXTFORGE_TIMEOUT_SECONDS", "5")),
|
||||
retries=int(values.get("HUB_CONTEXTFORGE_RETRIES", "2")),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ContextForgeError(
|
||||
"gateway_configuration_invalid", "Gateway configuration is invalid."
|
||||
) from exc
|
||||
|
||||
@model_validator(mode="after")
|
||||
def exactly_one_credential_source(self) -> ContextForgeSettings:
|
||||
if bool(self.auth_token_env) == bool(self.auth_token_file):
|
||||
raise ValueError("configure exactly one ContextForge credential source")
|
||||
return self
|
||||
|
||||
def credential(self, environ: Mapping[str, str] | None = None) -> str:
|
||||
if self.auth_token_file:
|
||||
try:
|
||||
return read_secret_file(self.auth_token_file)
|
||||
except SecretFileError as exc:
|
||||
raise ContextForgeError(
|
||||
"gateway_credentials_missing", "Gateway authorization is unavailable."
|
||||
) from exc
|
||||
value = (environ or os.environ).get(self.auth_token_env or "")
|
||||
if not value:
|
||||
raise ContextForgeError(
|
||||
"gateway_credentials_missing", "Gateway authorization is unavailable."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class GatewayDesiredState(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
slug: str = Field(
|
||||
min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
|
||||
)
|
||||
url: AnyHttpUrl
|
||||
description: str = Field(default="", max_length=1000)
|
||||
transport: Literal["STREAMABLEHTTP", "SSE", "STDIO", "WEBSOCKET"] = "STREAMABLEHTTP"
|
||||
enabled: bool = True
|
||||
passthrough_headers: tuple[str, ...] = ()
|
||||
|
||||
@field_validator("passthrough_headers")
|
||||
@classmethod
|
||||
def validate_passthrough_headers(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
allowed = {
|
||||
"x-itworx-project",
|
||||
"x-itworx-environment",
|
||||
"x-itworx-client-id",
|
||||
"x-itworx-trace-id",
|
||||
"x-itworx-identity-assertion",
|
||||
}
|
||||
normalized = tuple(dict.fromkeys(item.strip().lower() for item in value))
|
||||
if any(not re.fullmatch(r"[a-z0-9-]{1,64}", item) for item in normalized):
|
||||
raise ValueError("invalid passthrough header")
|
||||
if not set(normalized).issubset(allowed):
|
||||
raise ValueError("gateway passthrough header is not approved")
|
||||
return normalized
|
||||
|
||||
|
||||
class GatewaySnapshot(BaseModel):
|
||||
"""Publicly safe gateway state; `external_id` is intentionally absent."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
slug: str
|
||||
url: str
|
||||
description: str
|
||||
transport: str
|
||||
enabled: bool
|
||||
status: str
|
||||
passthrough_headers: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class GatewayHandle(BaseModel):
|
||||
"""Adapter-internal handle; do not serialize into public Hub contracts."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
external_id: str = Field(min_length=1, max_length=256)
|
||||
snapshot: GatewaySnapshot
|
||||
|
||||
|
||||
class GatewayTool(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
external_id: str = Field(min_length=1, max_length=256)
|
||||
name: str = Field(min_length=1, max_length=256)
|
||||
published_name: str = Field(min_length=1, max_length=256)
|
||||
input_schema: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class GatewayPrompt(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
external_id: str = Field(min_length=1, max_length=256)
|
||||
name: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class VirtualServerDesiredState(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
external_id: UUID
|
||||
name: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
description: str = Field(max_length=1000)
|
||||
associated_tool_ids: tuple[str, ...] = Field(min_length=1)
|
||||
associated_prompt_ids: tuple[str, ...] = ()
|
||||
# ContextForge's service token is intentionally public-only. The virtual
|
||||
# server is reachable solely on the private Docker network; the public Hub
|
||||
# dataplane remains the OAuth, binding, policy and rate-limit boundary.
|
||||
visibility: Literal["public"] = "public"
|
||||
|
||||
@field_validator("associated_tool_ids")
|
||||
@classmethod
|
||||
def unique_tool_ids(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if len(set(value)) != len(value) or any(not item or len(item) > 256 for item in value):
|
||||
raise ValueError("virtual server tool IDs must be unique bounded values")
|
||||
return tuple(sorted(value))
|
||||
|
||||
@field_validator("associated_prompt_ids")
|
||||
@classmethod
|
||||
def unique_prompt_ids(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if len(set(value)) != len(value) or any(not item or len(item) > 256 for item in value):
|
||||
raise ValueError("virtual server prompt IDs must be unique bounded values")
|
||||
return tuple(sorted(value))
|
||||
|
||||
|
||||
class VirtualServerHandle(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
external_id: str = Field(min_length=1, max_length=256)
|
||||
name: str = Field(min_length=1, max_length=63)
|
||||
associated_tool_ids: tuple[str, ...]
|
||||
associated_prompt_ids: tuple[str, ...] = ()
|
||||
enabled: bool
|
||||
visibility: Literal["public", "private", "team"]
|
||||
|
||||
|
||||
class GatewayTestResult(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
reachable: bool
|
||||
status: str
|
||||
detail: str = Field(default="", max_length=256)
|
||||
|
||||
|
||||
class GatewayCapabilities(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
health: bool
|
||||
readiness: bool
|
||||
version: str | None
|
||||
gateway_crud: bool
|
||||
gateway_test: bool
|
||||
|
||||
|
||||
class SanitizedGatewayInventory(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
generated_at: datetime
|
||||
capabilities: GatewayCapabilities
|
||||
gateways: tuple[dict[str, Any], ...]
|
||||
|
||||
@field_validator("generated_at")
|
||||
@classmethod
|
||||
def require_aware_timestamp(cls, value: datetime) -> datetime:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError("inventory timestamp must be timezone-aware")
|
||||
return value.astimezone(UTC)
|
||||
Reference in new issue
Block a user