atlas-iac/services/hermes/scripts/claude_oauth_broker.py

236 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""Translate an internal relay key into the owner's Claude OAuth credential."""
from __future__ import annotations
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Final
import httpx
from routing_catalog import resolve_route
HOST: Final = os.environ.get("HERMES_CLAUDE_BROKER_HOST", "0.0.0.0")
PORT: Final = int(os.environ.get("HERMES_CLAUDE_BROKER_PORT", "9006"))
UPSTREAM: Final = os.environ.get(
"HERMES_CLAUDE_BROKER_UPSTREAM", "https://api.anthropic.com"
).rstrip("/")
MAX_BODY_BYTES: Final = int(
os.environ.get("HERMES_CLAUDE_BROKER_MAX_BODY", str(64 << 20))
)
READ_TIMEOUT_SECONDS: Final = float(
os.environ.get("HERMES_CLAUDE_BROKER_READ_TIMEOUT", "900")
)
ALLOWED_PATHS: Final = {
"/v1/messages",
"/v1/messages/count_tokens",
"/v1/models",
}
REQUIRED_BETAS: Final = (
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
"claude-code-20250219",
"oauth-2025-04-20",
)
ROUTED_MODEL_PREFIX: Final = "route/claude/"
def _translate_model(body: bytes) -> bytes:
"""Translate a Switchyard effort-qualified target into a Claude model."""
if not body:
return body
try:
payload = json.loads(body)
except (TypeError, ValueError, json.JSONDecodeError):
return body
if not isinstance(payload, dict):
return body
model = payload.get("model")
if isinstance(model, str) and model.startswith(ROUTED_MODEL_PREFIX):
payload["model"] = resolve_route(model)
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
return body
def _read_secret(env_name: str, file_env_name: str) -> str:
"""Read a secret from an environment value or a mounted file."""
value = os.environ.get(env_name, "").strip()
if value:
return value
path = os.environ.get(file_env_name, "").strip()
if not path:
return ""
try:
return Path(path).read_text(encoding="utf-8").strip()
except OSError:
return ""
def _relay_key() -> str:
"""Return the shared internal key without caching rotated file contents."""
return _read_secret(
"HERMES_CLAUDE_BROKER_KEY", "HERMES_CLAUDE_BROKER_KEY_FILE"
)
def _oauth_token() -> str:
"""Return the current owner OAuth token or fail closed."""
token = _read_secret(
"CLAUDE_CODE_OAUTH_TOKEN", "HERMES_CLAUDE_OAUTH_TOKEN_FILE"
)
if not token:
raise RuntimeError("owner Claude authentication is unavailable")
return token
def _authorized(authorization: str | None, api_key: str | None) -> bool:
"""Accept Switchyard's x-api-key or an internal Bearer relay key."""
expected = _relay_key()
if not expected:
return False
candidates = [api_key or ""]
if authorization and authorization.startswith("Bearer "):
candidates.append(authorization[7:].strip())
return any(candidate and hmac.compare_digest(candidate, expected) for candidate in candidates)
def _merge_betas(incoming: str | None) -> str:
"""Preserve requested Anthropic betas while adding Claude Code OAuth betas."""
values: list[str] = []
for value in (*((incoming or "").split(",")), *REQUIRED_BETAS):
value = value.strip()
if value and value not in values:
values.append(value)
return ",".join(values)
class Handler(BaseHTTPRequestHandler):
"""Stream Anthropic responses while keeping the OAuth token server-side."""
server_version = "HermesClaudeOAuthBroker/1"
def log_message(self, format: str, *args: object) -> None:
"""Avoid logging paths or headers that could contain sensitive metadata."""
return
def _json(self, status: int, value: dict[str, object]) -> None:
body = json.dumps(value, separators=(",", ":")).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _check_auth(self) -> bool:
if _authorized(
self.headers.get("Authorization"), self.headers.get("x-api-key")
):
return True
self._json(401, {"error": {"type": "authentication_error", "message": "unauthorized"}})
return False
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path == "/health":
try:
_oauth_token()
except RuntimeError as exc:
self._json(503, {"ok": False, "error": str(exc)})
return
self._json(200, {"ok": True, "provider": "anthropic-oauth"})
return
if self.path not in ALLOWED_PATHS:
self._json(404, {"error": {"type": "not_found", "message": "not found"}})
return
if not self._check_auth():
return
self._proxy(b"")
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path not in ALLOWED_PATHS:
self._json(404, {"error": {"type": "not_found", "message": "not found"}})
return
if not self._check_auth():
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = -1
if length < 0 or length > MAX_BODY_BYTES:
self._json(413, {"error": {"type": "request_too_large", "message": "request too large"}})
return
self._proxy(_translate_model(self.rfile.read(length)))
def _proxy(self, body: bytes) -> None:
"""Forward one bounded request and stream its response unchanged."""
response_started = False
try:
token = _oauth_token()
headers = {
"Accept": self.headers.get("Accept", "application/json"),
"Authorization": f"Bearer {token}",
"anthropic-version": self.headers.get(
"anthropic-version", "2023-06-01"
),
"anthropic-beta": _merge_betas(self.headers.get("anthropic-beta")),
"Content-Type": self.headers.get("Content-Type", "application/json"),
"User-Agent": "claude-code/2.1.226 (external, cli)",
"x-app": "cli",
}
timeout = httpx.Timeout(30.0, read=READ_TIMEOUT_SECONDS)
with httpx.Client(timeout=timeout) as client:
with client.stream(
self.command,
f"{UPSTREAM}{self.path}",
headers=headers,
content=body or None,
) as response:
self.send_response(response.status_code)
for name, value in response.headers.items():
if name.lower() in {
"content-type",
"cache-control",
"request-id",
"retry-after",
"anthropic-ratelimit-requests-limit",
"anthropic-ratelimit-requests-remaining",
"anthropic-ratelimit-requests-reset",
"anthropic-ratelimit-tokens-limit",
"anthropic-ratelimit-tokens-remaining",
"anthropic-ratelimit-tokens-reset",
}:
self.send_header(name, value)
self.send_header("Connection", "close")
self.end_headers()
response_started = True
for chunk in response.iter_bytes():
if chunk:
self.wfile.write(chunk)
self.wfile.flush()
except (RuntimeError, httpx.HTTPError, OSError) as exc:
# Once streaming headers have crossed the wire, an upstream failure
# can only terminate the stream. Sending a second HTTP response
# would corrupt the Anthropic event stream seen by Switchyard.
if not response_started and not self.wfile.closed:
self._json(
503,
{"error": {"type": "provider_unavailable", "message": str(exc)}},
)
finally:
self.close_connection = True
class Server(ThreadingHTTPServer):
"""Threaded server whose request workers do not block shutdown."""
daemon_threads = True
if __name__ == "__main__":
Server((HOST, PORT), Handler).serve_forever()