235 lines
8.4 KiB
Python
235 lines
8.4 KiB
Python
"""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)
|