239 lines
8.9 KiB
Python
239 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Credential-isolating Codex Responses proxy for Hermes chat tenants."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import hmac
|
|
import json
|
|
import os
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from routing_catalog import resolve_route
|
|
|
|
|
|
HOST = os.environ.get("HERMES_CODEX_BROKER_LISTEN_HOST", "0.0.0.0")
|
|
PORT = int(os.environ.get("HERMES_CODEX_BROKER_LISTEN_PORT", "9003"))
|
|
TOKEN = os.environ.get("HERMES_IMAGE_BROKER_KEY", "").strip()
|
|
UPSTREAM = os.environ.get(
|
|
"HERMES_CODEX_BROKER_UPSTREAM",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
).rstrip("/")
|
|
MAX_BODY_BYTES = int(os.environ.get("HERMES_CODEX_BROKER_MAX_BODY", str(64 << 20)))
|
|
READ_TIMEOUT_SECONDS = float(os.environ.get("HERMES_CODEX_BROKER_READ_TIMEOUT", "900"))
|
|
FALLBACK_ALLOWED_MODELS = {
|
|
value.strip()
|
|
for value in os.environ.get(
|
|
"HERMES_CODEX_BROKER_MODELS",
|
|
"gpt-5.6-luna,gpt-5.6-sol,gpt-5.6-terra",
|
|
).split(",")
|
|
if value.strip()
|
|
}
|
|
ROUTED_MODEL_PREFIX = "route/codex/"
|
|
|
|
|
|
def _real_model(model: str) -> str:
|
|
"""Translate a Switchyard effort-qualified target into a Codex model."""
|
|
if model.startswith(ROUTED_MODEL_PREFIX):
|
|
model = resolve_route(model)
|
|
return model
|
|
|
|
|
|
def _authorized(header: str | None) -> bool:
|
|
"""Authenticate a tenant without exposing the relay secret."""
|
|
if not TOKEN or not header or not header.startswith("Bearer "):
|
|
return False
|
|
return hmac.compare_digest(header[7:].strip(), TOKEN)
|
|
|
|
|
|
def _access_token() -> str:
|
|
"""Read the current owner token; the Codex CLI remains refresh owner."""
|
|
codex_home = Path(
|
|
os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))
|
|
).expanduser()
|
|
try:
|
|
payload = json.loads((codex_home / "auth.json").read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as exc:
|
|
raise RuntimeError("owner Codex authentication is unavailable") from exc
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError("owner Codex authentication is invalid")
|
|
tokens = payload.get("tokens") or {}
|
|
token = tokens.get("access_token") if isinstance(tokens, dict) else None
|
|
if not isinstance(token, str) or not token.strip():
|
|
raise RuntimeError("owner Codex access token is unavailable")
|
|
token = token.strip()
|
|
try:
|
|
encoded = token.split(".")[1]
|
|
encoded += "=" * (-len(encoded) % 4)
|
|
expires_at = json.loads(base64.urlsafe_b64decode(encoded)).get("exp", 0)
|
|
except (IndexError, ValueError, TypeError, json.JSONDecodeError, binascii.Error):
|
|
expires_at = 0
|
|
if expires_at and time.time() >= float(expires_at):
|
|
raise RuntimeError("owner Codex access token is expired")
|
|
return token
|
|
|
|
|
|
def _upstream_headers(token: str) -> dict[str, str]:
|
|
"""Build the first-party headers expected by the Codex backend."""
|
|
from agent.auxiliary_client import _codex_cloudflare_headers
|
|
|
|
headers = _codex_cloudflare_headers(token)
|
|
headers.update(
|
|
{
|
|
"Accept": "text/event-stream",
|
|
"Authorization": f"Bearer {token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
)
|
|
return headers
|
|
|
|
|
|
def _validate_payload(payload: Any) -> dict[str, Any]:
|
|
"""Allow only bounded Responses requests for the approved model catalog."""
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("JSON object required")
|
|
model = payload.get("model")
|
|
if not isinstance(model, str):
|
|
raise ValueError("unsupported Codex model")
|
|
model = _real_model(model)
|
|
if not model.startswith("gpt-"):
|
|
raise ValueError("unsupported Codex model")
|
|
payload["model"] = model
|
|
# Tenant conversations must not enter the owner's server-side history.
|
|
payload["store"] = False
|
|
payload["stream"] = True
|
|
return payload
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
"""Authenticated streaming proxy; request bodies and tokens are never logged."""
|
|
|
|
server_version = "HermesCodexBroker/1"
|
|
|
|
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 _check_auth(self) -> bool:
|
|
if _authorized(self.headers.get("Authorization")):
|
|
return True
|
|
self._json(401, {"error": {"message": "unauthorized", "type": "auth_error"}})
|
|
return False
|
|
|
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
|
if not self._check_auth():
|
|
return
|
|
if self.path == "/health":
|
|
try:
|
|
_access_token()
|
|
except RuntimeError as exc:
|
|
self._json(503, {"ok": False, "error": str(exc)})
|
|
return
|
|
self._json(200, {"ok": True, "provider": "openai-codex"})
|
|
return
|
|
if self.path in {"/models", "/v1/models"}:
|
|
self._json(
|
|
200,
|
|
{
|
|
"object": "list",
|
|
"data": [
|
|
{"id": model, "object": "model", "owned_by": "openai-codex"}
|
|
for model in sorted(FALLBACK_ALLOWED_MODELS)
|
|
],
|
|
},
|
|
)
|
|
return
|
|
self._json(404, {"error": {"message": "not found", "type": "not_found"}})
|
|
|
|
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
|
if self.path not in {"/responses", "/v1/responses"}:
|
|
self._json(404, {"error": {"message": "not found", "type": "not_found"}})
|
|
return
|
|
if not self._check_auth():
|
|
return
|
|
response_started = False
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
length = 0
|
|
if length <= 0 or length > MAX_BODY_BYTES:
|
|
self._json(413, {"error": {"message": "invalid request size", "type": "invalid_request_error"}})
|
|
return
|
|
|
|
try:
|
|
payload = _validate_payload(json.loads(self.rfile.read(length)))
|
|
token = _access_token()
|
|
timeout = httpx.Timeout(
|
|
READ_TIMEOUT_SECONDS,
|
|
connect=30.0,
|
|
read=READ_TIMEOUT_SECONDS,
|
|
write=60.0,
|
|
pool=30.0,
|
|
)
|
|
with httpx.Client(timeout=timeout, headers=_upstream_headers(token)) as client:
|
|
with client.stream("POST", f"{UPSTREAM}/responses", json=payload) as response:
|
|
if response.status_code >= 400:
|
|
body = response.read()
|
|
self.send_response(response.status_code)
|
|
self.send_header("Content-Type", response.headers.get("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)
|
|
return
|
|
|
|
# HTTP/1.0 close-delimited streaming avoids buffering a
|
|
# potentially long tool-calling turn in the broker.
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Accel-Buffering", "no")
|
|
self.end_headers()
|
|
response_started = True
|
|
for chunk in response.iter_raw():
|
|
if chunk:
|
|
self.wfile.write(chunk)
|
|
self.wfile.flush()
|
|
except ValueError as exc:
|
|
self._json(400, {"error": {"message": str(exc), "type": "invalid_request_error"}})
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
return
|
|
except Exception as exc:
|
|
if response_started:
|
|
return
|
|
self._json(
|
|
502,
|
|
{
|
|
"error": {
|
|
"message": f"Codex broker failed: {type(exc).__name__}: {exc}",
|
|
"type": "upstream_error",
|
|
}
|
|
},
|
|
)
|
|
|
|
def log_message(self, format: str, *args: Any) -> None:
|
|
"""Log only method/path/status metadata, never bodies or headers."""
|
|
print(f"codex-broker {self.address_string()} {format % args}", flush=True)
|
|
|
|
|
|
def main() -> None:
|
|
"""Serve until Kubernetes terminates the sidecar."""
|
|
if not TOKEN:
|
|
raise SystemExit("HERMES_IMAGE_BROKER_KEY is required")
|
|
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|