#!/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()