atlas-iac/services/hermes/scripts/claude_oauth_broker.py
2026-08-12 23:08:21 -03:00

573 lines
20 KiB
Python

#!/usr/bin/env python3
"""Expose the owner's native Claude Code subscription as an Anthropic lane."""
from __future__ import annotations
import hmac
import json
import os
import re
import shutil
import subprocess
import threading
import time
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Final
from routing_catalog import load_catalog, 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"))
CLAUDE_BIN: Final = os.environ.get("HERMES_CLAUDE_BIN", "/opt/coordinator/claude")
MAX_BODY_BYTES: Final = int(
os.environ.get("HERMES_CLAUDE_BROKER_MAX_BODY", str(64 << 20))
)
TIMEOUT_SECONDS: Final = float(
os.environ.get("HERMES_CLAUDE_BROKER_READ_TIMEOUT", "1800")
)
MAX_CONCURRENCY: Final = int(
os.environ.get("HERMES_CLAUDE_BROKER_CONCURRENCY", "4")
)
HEALTH_PATH: Final = Path(
os.environ.get(
"HERMES_CLAUDE_HEALTH_PATH", "/opt/data/provider-health/claude.json"
)
)
ROUTED_MODEL_PREFIX: Final = "route/claude/"
EFFORTS: Final = {"low", "medium", "high", "xhigh"}
CAPACITY_PATTERN: Final = re.compile(
r"(?:rate.?limit|capacity|overload|usage.?limit|quota|credit|exhaust|429|529)",
re.I,
)
STRUCTURED_SCHEMA: Final = {
"type": "object",
"additionalProperties": False,
"required": ["type", "text", "tool_calls"],
"properties": {
"type": {"type": "string", "enum": ["final", "tool_calls"]},
"text": {"type": "string"},
"tool_calls": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["name", "input"],
"properties": {
"name": {"type": "string"},
"input": {"type": "object"},
},
},
},
},
}
_slots = threading.BoundedSemaphore(MAX_CONCURRENCY)
_auth_probe_lock = threading.Lock()
_auth_probe_at = 0.0
_auth_probe_value: dict[str, Any] = {}
def _read_secret(env_name: str, file_env_name: str) -> str:
"""Read a secret from an environment value or 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 internal relay key shared with Switchyard."""
return (
_read_secret("HERMES_CLAUDE_BROKER_KEY", "HERMES_CLAUDE_BROKER_KEY_FILE")
or os.environ.get("HERMES_IMAGE_BROKER_KEY", "").strip()
)
def _authorized(authorization: str | None, api_key: str | None) -> bool:
"""Accept Switchyard's API-key or bearer-key header."""
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 _route(model: str, payload: dict[str, Any]) -> tuple[str, str]:
"""Resolve one Switchyard model and effort into native Claude CLI values."""
effort = "medium"
if model.startswith(ROUTED_MODEL_PREFIX):
parts = model.split("/")
if parts[-1] in EFFORTS:
effort = parts[-1]
model = resolve_route(model)
output_config = payload.get("output_config")
if isinstance(output_config, dict) and output_config.get("effort") in EFFORTS:
effort = str(output_config["effort"])
if not model.startswith("claude-"):
raise ValueError("unsupported Claude model")
return model, effort
def _prompt(payload: dict[str, Any]) -> str:
"""Describe one Anthropic boundary without letting Claude run local tools."""
tools = payload.get("tools")
tools = tools if isinstance(tools, list) else []
contract = {
"system": payload.get("system") or "",
"messages": payload.get("messages") or [],
"tools": tools,
"tool_choice": payload.get("tool_choice") or {"type": "auto"},
}
return (
"You are serving one model boundary for Hermes. The JSON below is the "
"complete conversation and the only source of task context. Do not run "
"Claude Code tools or modify files yourself. If a listed external tool "
"is needed, return type=tool_calls with its exact name and a valid input "
"object. Otherwise return type=final and place the complete user-facing "
"answer in text. Do not describe this envelope.\n\n"
+ json.dumps(contract, ensure_ascii=False, separators=(",", ":"))
)
def _usage(event: dict[str, Any]) -> dict[str, int]:
"""Translate Claude Code's result accounting into Anthropic token fields."""
raw = event.get("usage")
raw = raw if isinstance(raw, dict) else {}
return {
"input_tokens": max(0, int(raw.get("input_tokens") or 0)),
"output_tokens": max(0, int(raw.get("output_tokens") or 0)),
"cache_creation_input_tokens": max(
0, int(raw.get("cache_creation_input_tokens") or 0)
),
"cache_read_input_tokens": max(
0, int(raw.get("cache_read_input_tokens") or 0)
),
}
def _atomic_health(value: dict[str, Any]) -> None:
"""Persist non-secret subscription/transport health for the owner dashboard."""
try:
HEALTH_PATH.parent.mkdir(parents=True, exist_ok=True)
temporary = HEALTH_PATH.with_name(f".{HEALTH_PATH.name}.{os.getpid()}.tmp")
temporary.write_text(
json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
os.replace(temporary, HEALTH_PATH)
except OSError:
pass
def _previous_health() -> dict[str, Any]:
"""Read the prior non-secret health snapshot when it is still valid JSON."""
try:
value = json.loads(HEALTH_PATH.read_text(encoding="utf-8"))
except (OSError, TypeError, ValueError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _claude_environment() -> dict[str, str]:
"""Return an environment that cannot silently select metered API billing."""
environment = os.environ.copy()
environment.pop("ANTHROPIC_API_KEY", None)
environment.pop("CLAUDE_API_KEY", None)
return environment
def _subscription_health(force: bool = False) -> dict[str, Any]:
"""Probe the native CLI login and cache the non-secret account result."""
global _auth_probe_at, _auth_probe_value
now = time.monotonic()
with _auth_probe_lock:
if not force and _auth_probe_value and now - _auth_probe_at < 60:
return dict(_auth_probe_value)
checked_at = datetime.now(timezone.utc).isoformat()
try:
completed = subprocess.run(
[CLAUDE_BIN, "auth", "status"],
text=True,
capture_output=True,
timeout=15,
env=_claude_environment(),
check=False,
)
raw = json.loads(completed.stdout) if completed.stdout.strip() else {}
except (OSError, subprocess.SubprocessError, ValueError, json.JSONDecodeError):
completed = None
raw = {}
authenticated = bool(
completed
and completed.returncode == 0
and isinstance(raw, dict)
and raw.get("loggedIn") is True
and raw.get("apiProvider") == "firstParty"
)
previous = _previous_health()
value = {
"transport": "claude-code-cli-subscription",
"state": "available" if authenticated else "unavailable",
"checked_at": checked_at,
"authenticated": authenticated,
"api_provider": raw.get("apiProvider") if isinstance(raw, dict) else None,
"auth_method": raw.get("authMethod") if isinstance(raw, dict) else None,
"subscription_type": raw.get("subscriptionType")
if isinstance(raw, dict)
else None,
}
# Readiness probes must not erase the most recently observed native
# usage window or successful route metadata. They only refresh auth.
for key in (
"rate_limit",
"last_success_at",
"last_error_at",
"latency_ms",
"model",
"effort",
):
if key in previous:
value[key] = previous[key]
_auth_probe_at = now
_auth_probe_value = value
_atomic_health(value)
return dict(value)
def _invoke(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, int], str]:
"""Run one native, first-party Claude Code subscription request."""
requested = payload.get("model")
if not isinstance(requested, str):
raise ValueError("Claude model is required")
model, effort = _route(requested, payload)
command = [
CLAUDE_BIN,
"-p",
"--output-format",
"stream-json",
"--verbose",
"--no-session-persistence",
"--tools",
"",
"--model",
model,
"--effort",
effort,
"--json-schema",
json.dumps(STRUCTURED_SCHEMA, separators=(",", ":")),
]
environment = _claude_environment()
started = time.monotonic()
with _slots:
completed = subprocess.run(
command,
input=_prompt(payload),
text=True,
capture_output=True,
timeout=TIMEOUT_SECONDS,
env=environment,
check=False,
)
result_event: dict[str, Any] = {}
rate_limit: dict[str, Any] = {}
for line in completed.stdout.splitlines():
try:
event = json.loads(line)
except (TypeError, ValueError, json.JSONDecodeError):
continue
if not isinstance(event, dict):
continue
if event.get("type") == "rate_limit_event":
raw = event.get("rate_limit_info")
if isinstance(raw, dict):
rate_limit = raw
if event.get("type") == "result":
result_event = event
error_text = "\n".join(
value for value in (completed.stderr.strip(), completed.stdout[-8000:]) if value
)
if completed.returncode or result_event.get("is_error"):
health = _subscription_health()
health.update(
{
"state": "capacity-limited"
if CAPACITY_PATTERN.search(error_text)
else "unavailable",
"last_error_at": datetime.now(timezone.utc).isoformat(),
"model": model,
"effort": effort,
"rate_limit": rate_limit,
}
)
_atomic_health(
health
)
kind = "capacity" if CAPACITY_PATTERN.search(error_text) else "provider"
raise RuntimeError(f"{kind}: {error_text[-1200:] or 'Claude CLI failed'}")
structured = result_event.get("structured_output")
if not isinstance(structured, dict):
raw_result = result_event.get("result")
try:
structured = json.loads(raw_result) if isinstance(raw_result, str) else None
except (TypeError, ValueError, json.JSONDecodeError):
structured = None
if not isinstance(structured, dict):
raise RuntimeError("provider: Claude CLI returned no structured result")
usage = _usage(result_event)
actual_model = str(
next(iter(result_event.get("modelUsage") or {}), model)
if isinstance(result_event.get("modelUsage"), dict)
else model
)
health = _subscription_health()
health.update(
{
"state": "available",
"last_success_at": datetime.now(timezone.utc).isoformat(),
"latency_ms": int((time.monotonic() - started) * 1000),
"model": actual_model,
"effort": effort,
"rate_limit": rate_limit,
}
)
_atomic_health(health)
return structured, usage, actual_model
def _message(
structured: dict[str, Any], usage: dict[str, int], model: str
) -> dict[str, Any]:
"""Build one Anthropic Messages response from the structured CLI result."""
content: list[dict[str, Any]] = []
text = structured.get("text")
if isinstance(text, str) and text:
content.append({"type": "text", "text": text})
tool_calls = structured.get("tool_calls")
if isinstance(tool_calls, list):
for raw in tool_calls:
if not isinstance(raw, dict) or not isinstance(raw.get("name"), str):
continue
tool_input = raw.get("input")
content.append(
{
"type": "tool_use",
"id": f"toolu_{uuid.uuid4().hex}",
"name": raw["name"],
"input": tool_input if isinstance(tool_input, dict) else {},
}
)
if not content:
content.append({"type": "text", "text": ""})
return {
"id": f"msg_{uuid.uuid4().hex}",
"type": "message",
"role": "assistant",
"model": model,
"content": content,
"stop_reason": "tool_use"
if any(item["type"] == "tool_use" for item in content)
else "end_turn",
"stop_sequence": None,
"usage": usage,
}
def _sse(message: dict[str, Any]) -> bytes:
"""Encode a completed message as a standards-compliant Anthropic SSE stream."""
events: list[tuple[str, dict[str, Any]]] = []
opening = dict(message)
opening["content"] = []
opening["stop_reason"] = None
opening["usage"] = {
"input_tokens": message["usage"]["input_tokens"],
"output_tokens": 0,
}
events.append(("message_start", {"type": "message_start", "message": opening}))
for index, block in enumerate(message["content"]):
if block["type"] == "text":
start = {"type": "text", "text": ""}
delta = {"type": "text_delta", "text": block["text"]}
else:
start = {key: block[key] for key in ("type", "id", "name")}
start["input"] = {}
delta = {
"type": "input_json_delta",
"partial_json": json.dumps(block["input"], separators=(",", ":")),
}
events.extend(
[
(
"content_block_start",
{
"type": "content_block_start",
"index": index,
"content_block": start,
},
),
(
"content_block_delta",
{
"type": "content_block_delta",
"index": index,
"delta": delta,
},
),
("content_block_stop", {"type": "content_block_stop", "index": index}),
]
)
events.append(
(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": message["stop_reason"], "stop_sequence": None},
"usage": {"output_tokens": message["usage"]["output_tokens"]},
},
)
)
events.append(("message_stop", {"type": "message_stop"}))
return "".join(
f"event: {name}\ndata: {json.dumps(value, separators=(',', ':'))}\n\n"
for name, value in events
).encode("utf-8")
class Handler(BaseHTTPRequestHandler):
"""Serve the subset of Anthropic Messages used by Switchyard."""
server_version = "HermesClaudeCodeBroker/2"
def log_message(self, format: str, *args: object) -> None:
return
def _json(self, status: int, value: dict[str, Any]) -> 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 _error(self, status: int, error_type: str, message: str) -> None:
self._json(
status,
{"type": "error", "error": {"type": error_type, "message": message}},
)
def _check_auth(self) -> bool:
if _authorized(self.headers.get("Authorization"), self.headers.get("x-api-key")):
return True
self._error(401, "authentication_error", "unauthorized")
return False
def do_GET(self) -> None: # noqa: N802
if self.path == "/health":
executable = shutil.which(CLAUDE_BIN) or (
CLAUDE_BIN if Path(CLAUDE_BIN).is_file() else ""
)
health = _subscription_health()
status = 200 if executable and health.get("authenticated") else 503
self._json(
status,
{
"ok": status == 200,
"provider": "claude-code-subscription",
"subscription_type": health.get("subscription_type"),
"auth_method": health.get("auth_method"),
},
)
return
if self.path != "/v1/models":
self._error(404, "not_found", "not found")
return
if not self._check_auth():
return
catalog = load_catalog()
providers = catalog.get("providers", {})
claude = providers.get("claude", {}) if isinstance(providers, dict) else {}
raw_models = claude.get("models", []) if isinstance(claude, dict) else []
models = sorted(
model
for model in raw_models
if isinstance(model, str) and model.startswith("claude-")
)
self._json(200, {"data": [{"id": model, "type": "model"} for model in models]})
def do_POST(self) -> None: # noqa: N802
if self.path not in {"/v1/messages", "/v1/messages/count_tokens"}:
self._error(404, "not_found", "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._error(413, "request_too_large", "request too large")
return
try:
payload = json.loads(self.rfile.read(length))
except (TypeError, ValueError, json.JSONDecodeError):
self._error(400, "invalid_request_error", "JSON object required")
return
if not isinstance(payload, dict):
self._error(400, "invalid_request_error", "JSON object required")
return
if self.path.endswith("count_tokens"):
self._json(200, {"input_tokens": max(1, len(_prompt(payload)) // 4)})
return
try:
structured, usage, model = _invoke(payload)
message = _message(structured, usage, model)
except ValueError as exc:
self._error(400, "invalid_request_error", str(exc))
return
except subprocess.TimeoutExpired:
self._error(504, "timeout_error", "Claude Code request timed out")
return
except (OSError, RuntimeError) as exc:
detail = str(exc)
status = 429 if detail.startswith("capacity:") else 503
self._error(
status,
"rate_limit_error" if status == 429 else "api_error",
detail.partition(": ")[2] or detail,
)
return
if payload.get("stream"):
body = _sse(message)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self._json(200, message)
class Server(ThreadingHTTPServer):
"""Threaded broker with bounded provider-side concurrency."""
daemon_threads = True
if __name__ == "__main__":
_subscription_health(force=True)
Server((HOST, PORT), Handler).serve_forever()