2026-08-11 03:15:34 -03:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Credential-isolating Codex Responses proxy for Hermes chat tenants."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import base64
|
|
|
|
|
import binascii
|
2026-08-12 23:08:21 -03:00
|
|
|
import fcntl
|
2026-08-11 03:15:34 -03:00
|
|
|
import hmac
|
|
|
|
|
import json
|
|
|
|
|
import os
|
2026-08-12 23:08:21 -03:00
|
|
|
import tempfile
|
2026-08-11 03:15:34 -03:00
|
|
|
import time
|
2026-08-12 23:48:06 -03:00
|
|
|
from datetime import datetime, timezone
|
2026-08-11 03:15:34 -03:00
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
|
from pathlib import Path
|
2026-08-11 23:03:13 -03:00
|
|
|
from typing import Any, Iterable
|
2026-08-11 03:15:34 -03:00
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
2026-09-13 01:48:05 -05:00
|
|
|
from routing_catalog import load_catalog, resolve_route
|
2026-08-11 20:22:26 -03:00
|
|
|
|
2026-08-11 03:15:34 -03:00
|
|
|
|
|
|
|
|
HOST = os.environ.get("HERMES_CODEX_BROKER_LISTEN_HOST", "0.0.0.0")
|
|
|
|
|
PORT = int(os.environ.get("HERMES_CODEX_BROKER_LISTEN_PORT", "9003"))
|
2026-08-15 17:58:47 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _relay_token() -> str:
|
|
|
|
|
"""Read relay auth without exporting it to Codex subprocesses."""
|
|
|
|
|
path = Path(
|
|
|
|
|
os.environ.get(
|
|
|
|
|
"HERMES_CODEX_BROKER_KEY_FILE", "/runtime-access/chat-relay-key"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
return path.read_text(encoding="utf-8").strip()
|
|
|
|
|
except OSError:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
TOKEN = _relay_token()
|
2026-08-11 03:15:34 -03:00
|
|
|
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)))
|
2026-08-12 06:22:29 -03:00
|
|
|
MAX_RESPONSE_BYTES = int(
|
|
|
|
|
os.environ.get("HERMES_CODEX_BROKER_MAX_RESPONSE", str(64 << 20))
|
|
|
|
|
)
|
2026-08-11 03:15:34 -03:00
|
|
|
READ_TIMEOUT_SECONDS = float(os.environ.get("HERMES_CODEX_BROKER_READ_TIMEOUT", "900"))
|
2026-08-11 20:22:26 -03:00
|
|
|
FALLBACK_ALLOWED_MODELS = {
|
2026-08-11 03:15:34 -03:00
|
|
|
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()
|
|
|
|
|
}
|
2026-08-11 20:22:26 -03:00
|
|
|
ROUTED_MODEL_PREFIX = "route/codex/"
|
2026-08-12 23:08:21 -03:00
|
|
|
TOKEN_REFRESH_SKEW_SECONDS = int(
|
|
|
|
|
os.environ.get("HERMES_CODEX_BROKER_REFRESH_SKEW_SECONDS", "300")
|
|
|
|
|
)
|
2026-08-12 23:48:06 -03:00
|
|
|
HEALTH_PATH = Path(
|
|
|
|
|
os.environ.get("HERMES_CODEX_HEALTH_PATH", "/opt/data/provider-health/codex.json")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _previous_health() -> dict[str, Any]:
|
|
|
|
|
"""Read the previous non-secret native transport health snapshot."""
|
|
|
|
|
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 _record_health(state: str, **updates: Any) -> None:
|
|
|
|
|
"""Atomically publish current first-party Codex health without credentials."""
|
|
|
|
|
value = _previous_health()
|
|
|
|
|
value.update(
|
|
|
|
|
{
|
|
|
|
|
"transport": "codex-chatgpt-subscription",
|
|
|
|
|
"state": state,
|
|
|
|
|
"checked_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"authenticated": state != "unavailable",
|
|
|
|
|
**updates,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
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 _requested_effort(payload: dict[str, Any]) -> str:
|
|
|
|
|
"""Return the bounded Codex reasoning effort carried by a routed request."""
|
|
|
|
|
reasoning = payload.get("reasoning")
|
|
|
|
|
if isinstance(reasoning, dict) and reasoning.get("effort") in {
|
|
|
|
|
"low",
|
|
|
|
|
"medium",
|
|
|
|
|
"high",
|
|
|
|
|
"xhigh",
|
|
|
|
|
}:
|
|
|
|
|
return str(reasoning["effort"])
|
|
|
|
|
return "medium"
|
2026-08-11 20:22:26 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-11 03:15:34 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 23:08:21 -03:00
|
|
|
def _token_expiry(token: str) -> float:
|
|
|
|
|
"""Return a JWT expiry timestamp, or zero for an opaque token."""
|
|
|
|
|
try:
|
|
|
|
|
encoded = token.split(".")[1]
|
|
|
|
|
encoded += "=" * (-len(encoded) % 4)
|
|
|
|
|
return float(json.loads(base64.urlsafe_b64decode(encoded)).get("exp", 0))
|
|
|
|
|
except (IndexError, ValueError, TypeError, json.JSONDecodeError, binascii.Error):
|
|
|
|
|
return 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_codex_auth(path: Path, payload: dict[str, Any]) -> None:
|
|
|
|
|
"""Atomically persist refreshed first-party credentials for every CLI lane."""
|
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
fd, temporary = tempfile.mkstemp(prefix=".auth.", suffix=".json", dir=path.parent)
|
|
|
|
|
try:
|
|
|
|
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
|
|
|
json.dump(payload, stream, separators=(",", ":"))
|
|
|
|
|
stream.write("\n")
|
|
|
|
|
stream.flush()
|
|
|
|
|
os.fsync(stream.fileno())
|
|
|
|
|
os.chmod(temporary, 0o600)
|
|
|
|
|
os.replace(temporary, path)
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
os.unlink(temporary)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _access_token(*, force_refresh: bool = False) -> str:
|
|
|
|
|
"""Return a live ChatGPT OAuth token, refreshing the canonical CLI store."""
|
2026-08-11 03:15:34 -03:00
|
|
|
codex_home = Path(
|
|
|
|
|
os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))
|
|
|
|
|
).expanduser()
|
2026-08-12 23:08:21 -03:00
|
|
|
auth_path = codex_home / "auth.json"
|
|
|
|
|
lock_path = codex_home / "hermes-codex-broker.lock"
|
|
|
|
|
codex_home.mkdir(parents=True, exist_ok=True)
|
2026-08-11 03:15:34 -03:00
|
|
|
try:
|
2026-08-12 23:08:21 -03:00
|
|
|
with lock_path.open("a+", encoding="utf-8") as lock:
|
|
|
|
|
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
|
|
|
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
raise RuntimeError("owner Codex authentication is invalid")
|
|
|
|
|
tokens = payload.get("tokens") or {}
|
|
|
|
|
if not isinstance(tokens, dict):
|
|
|
|
|
raise RuntimeError("owner Codex authentication is invalid")
|
|
|
|
|
token = str(tokens.get("access_token") or "").strip()
|
|
|
|
|
if not token:
|
|
|
|
|
raise RuntimeError("owner Codex access token is unavailable")
|
|
|
|
|
expires_at = _token_expiry(token)
|
|
|
|
|
should_refresh = force_refresh or (
|
|
|
|
|
bool(expires_at)
|
|
|
|
|
and expires_at <= time.time() + TOKEN_REFRESH_SKEW_SECONDS
|
|
|
|
|
)
|
|
|
|
|
if should_refresh:
|
|
|
|
|
refresh_token = str(tokens.get("refresh_token") or "").strip()
|
|
|
|
|
if not refresh_token:
|
|
|
|
|
raise RuntimeError("owner Codex refresh token is unavailable")
|
|
|
|
|
# Use the same first-party ChatGPT OAuth refresh as Codex CLI.
|
|
|
|
|
# This never introduces an OpenAI API key or metered billing.
|
|
|
|
|
from hermes_cli.auth import refresh_codex_oauth_pure
|
|
|
|
|
|
|
|
|
|
refreshed = refresh_codex_oauth_pure(
|
|
|
|
|
token,
|
|
|
|
|
refresh_token,
|
|
|
|
|
timeout_seconds=30.0,
|
|
|
|
|
)
|
|
|
|
|
tokens["access_token"] = refreshed["access_token"]
|
|
|
|
|
tokens["refresh_token"] = refreshed.get(
|
|
|
|
|
"refresh_token", refresh_token
|
|
|
|
|
)
|
|
|
|
|
payload["tokens"] = tokens
|
|
|
|
|
payload["last_refresh"] = refreshed.get("last_refresh")
|
|
|
|
|
_write_codex_auth(auth_path, payload)
|
|
|
|
|
token = str(tokens["access_token"]).strip()
|
|
|
|
|
return token
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
raise
|
2026-08-11 03:15:34 -03:00
|
|
|
except (OSError, ValueError) as exc:
|
|
|
|
|
raise RuntimeError("owner Codex authentication is unavailable") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-16 06:55:49 -03:00
|
|
|
def _normalize_input_images(response_input: list[Any]) -> None:
|
|
|
|
|
"""Normalize Chat-Completions image parts for the Codex Responses API."""
|
2026-08-16 07:12:11 -03:00
|
|
|
|
|
|
|
|
def image_url_value(part: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
|
|
|
"""Return a Responses URL from URL-object or base64-source forms."""
|
2026-08-16 07:24:20 -03:00
|
|
|
|
|
|
|
|
def search(candidate: Any, depth: int = 0) -> tuple[str | None, str | None]:
|
|
|
|
|
if isinstance(candidate, str):
|
|
|
|
|
if candidate.startswith(("data:image/", "https://", "http://")):
|
|
|
|
|
return candidate, None
|
|
|
|
|
return None, None
|
|
|
|
|
if not isinstance(candidate, dict) or depth > 4:
|
|
|
|
|
return None, None
|
2026-08-16 07:12:11 -03:00
|
|
|
detail = candidate.get("detail")
|
|
|
|
|
url = candidate.get("url")
|
|
|
|
|
if isinstance(url, str) and url.strip():
|
|
|
|
|
return url, detail if isinstance(detail, str) else None
|
|
|
|
|
data = candidate.get("data")
|
|
|
|
|
media_type = candidate.get("media_type") or candidate.get("mime_type")
|
|
|
|
|
if (
|
|
|
|
|
isinstance(data, str)
|
|
|
|
|
and data
|
|
|
|
|
and isinstance(media_type, str)
|
|
|
|
|
and media_type.startswith("image/")
|
|
|
|
|
and all(character not in media_type for character in "\r\n;, ")
|
|
|
|
|
):
|
|
|
|
|
return f"data:{media_type};base64,{data}", (
|
|
|
|
|
detail if isinstance(detail, str) else None
|
|
|
|
|
)
|
2026-08-16 07:24:20 -03:00
|
|
|
for nested in candidate.values():
|
|
|
|
|
nested_url, nested_detail = search(nested, depth + 1)
|
|
|
|
|
if nested_url:
|
|
|
|
|
return nested_url, (
|
|
|
|
|
detail if isinstance(detail, str) else nested_detail
|
|
|
|
|
)
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
for candidate in (part.get("image_url"), part.get("source"), part):
|
|
|
|
|
image_url, detail = search(candidate)
|
|
|
|
|
if image_url:
|
|
|
|
|
return image_url, detail
|
2026-08-16 07:12:11 -03:00
|
|
|
return None, None
|
|
|
|
|
|
2026-08-16 07:24:20 -03:00
|
|
|
def image_shape(part: dict[str, Any]) -> str:
|
|
|
|
|
"""Describe only structural types when an upstream image is malformed."""
|
|
|
|
|
|
|
|
|
|
def describe(value: Any, depth: int = 0) -> Any:
|
|
|
|
|
if depth > 3:
|
|
|
|
|
return type(value).__name__
|
|
|
|
|
if isinstance(value, dict):
|
|
|
|
|
return {
|
|
|
|
|
str(key)[:40]: describe(nested, depth + 1)
|
|
|
|
|
for key, nested in list(value.items())[:12]
|
|
|
|
|
}
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
return [describe(nested, depth + 1) for nested in value[:4]]
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
return f"str[{len(value)}]"
|
|
|
|
|
return type(value).__name__
|
|
|
|
|
|
|
|
|
|
return json.dumps(describe(part), sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
2026-08-16 06:55:49 -03:00
|
|
|
for item in response_input:
|
2026-08-16 07:00:50 -03:00
|
|
|
if not isinstance(item, dict):
|
2026-08-16 06:55:49 -03:00
|
|
|
continue
|
|
|
|
|
content = item.get("content")
|
|
|
|
|
if not isinstance(content, list):
|
|
|
|
|
continue
|
|
|
|
|
for part in content:
|
|
|
|
|
if not isinstance(part, dict) or part.get("type") not in {
|
2026-08-16 07:12:11 -03:00
|
|
|
"image",
|
2026-08-16 06:55:49 -03:00
|
|
|
"image_url",
|
|
|
|
|
"input_image",
|
|
|
|
|
}:
|
|
|
|
|
continue
|
2026-08-16 07:12:11 -03:00
|
|
|
image_url, detail = image_url_value(part)
|
|
|
|
|
if isinstance(detail, str) and detail and "detail" not in part:
|
|
|
|
|
part["detail"] = detail
|
2026-08-16 06:55:49 -03:00
|
|
|
if not isinstance(image_url, str) or not image_url.strip():
|
2026-08-16 07:24:20 -03:00
|
|
|
raise ValueError(
|
|
|
|
|
"non-empty Responses image URL required; shape="
|
|
|
|
|
+ image_shape(part)
|
|
|
|
|
)
|
2026-08-16 06:55:49 -03:00
|
|
|
part["type"] = "input_image"
|
|
|
|
|
part["image_url"] = image_url
|
2026-08-16 07:12:11 -03:00
|
|
|
part.pop("source", None)
|
2026-08-16 06:55:49 -03:00
|
|
|
|
|
|
|
|
|
2026-08-11 03:15:34 -03:00
|
|
|
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")
|
2026-08-11 20:22:26 -03:00
|
|
|
if not isinstance(model, str):
|
|
|
|
|
raise ValueError("unsupported Codex model")
|
|
|
|
|
model = _real_model(model)
|
|
|
|
|
if not model.startswith("gpt-"):
|
2026-08-11 03:15:34 -03:00
|
|
|
raise ValueError("unsupported Codex model")
|
2026-08-11 20:22:26 -03:00
|
|
|
payload["model"] = model
|
2026-08-11 22:51:01 -03:00
|
|
|
response_input = payload.get("input")
|
|
|
|
|
if isinstance(response_input, str):
|
|
|
|
|
if not response_input.strip():
|
|
|
|
|
raise ValueError("non-empty Responses input required")
|
|
|
|
|
# Switchyard accepts OpenAI Chat Completions requests and translates
|
|
|
|
|
# their final text to a scalar Responses input. The first-party Codex
|
|
|
|
|
# endpoint is stricter than the public Responses API and only accepts
|
|
|
|
|
# a list of typed input items.
|
|
|
|
|
payload["input"] = [
|
|
|
|
|
{
|
|
|
|
|
"type": "message",
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [{"type": "input_text", "text": response_input}],
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
elif isinstance(response_input, dict):
|
|
|
|
|
payload["input"] = [response_input]
|
|
|
|
|
elif not isinstance(response_input, list) or not response_input:
|
|
|
|
|
raise ValueError("non-empty Responses input list required")
|
2026-08-16 06:55:49 -03:00
|
|
|
_normalize_input_images(payload["input"])
|
2026-08-12 01:21:20 -03:00
|
|
|
# Switchyard uses ``max_output_tokens`` to bound the tiny classifier call,
|
|
|
|
|
# but its Responses translation can also copy that internal option onto the
|
|
|
|
|
# selected provider request. The first-party subscription Codex endpoint
|
|
|
|
|
# does not accept any of the public API token-budget aliases. Let Codex use
|
|
|
|
|
# its own response budget instead of turning a healthy fallback into a 400.
|
2026-08-12 06:22:29 -03:00
|
|
|
for unsupported_key in (
|
2026-08-12 01:21:20 -03:00
|
|
|
"max_output_tokens",
|
|
|
|
|
"max_completion_tokens",
|
|
|
|
|
"max_tokens",
|
2026-08-12 06:22:29 -03:00
|
|
|
# The subscription Codex backend owns sampling. OpenAI-compatible
|
|
|
|
|
# clients may add these public API fields during a retry; forwarding
|
|
|
|
|
# them turns an otherwise healthy fallback into HTTP 400.
|
|
|
|
|
"temperature",
|
|
|
|
|
"top_p",
|
|
|
|
|
"frequency_penalty",
|
|
|
|
|
"presence_penalty",
|
|
|
|
|
"logprobs",
|
|
|
|
|
"top_logprobs",
|
|
|
|
|
"seed",
|
|
|
|
|
"n",
|
|
|
|
|
"stop",
|
2026-08-12 01:21:20 -03:00
|
|
|
):
|
2026-08-12 06:22:29 -03:00
|
|
|
payload.pop(unsupported_key, None)
|
2026-08-11 03:15:34 -03:00
|
|
|
# Tenant conversations must not enter the owner's server-side history.
|
|
|
|
|
payload["store"] = False
|
|
|
|
|
payload["stream"] = True
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 23:03:13 -03:00
|
|
|
def _completed_response(lines: Iterable[str]) -> dict[str, Any]:
|
|
|
|
|
"""Collapse a Codex SSE stream for a non-streaming Responses caller."""
|
|
|
|
|
terminal_response: dict[str, Any] | None = None
|
2026-08-11 23:12:43 -03:00
|
|
|
output_items: dict[int, dict[str, Any]] = {}
|
2026-08-12 07:29:05 -03:00
|
|
|
function_argument_deltas: dict[str, list[str]] = {}
|
|
|
|
|
completed_function_arguments: set[str] = set()
|
2026-08-11 23:03:13 -03:00
|
|
|
upstream_error = ""
|
2026-08-12 07:29:05 -03:00
|
|
|
|
|
|
|
|
def function_key(event: dict[str, Any]) -> str:
|
|
|
|
|
"""Identify one streamed function call without retaining its content."""
|
|
|
|
|
item_id = str(event.get("item_id") or "").strip()
|
|
|
|
|
if item_id:
|
|
|
|
|
return item_id
|
|
|
|
|
output_index = event.get("output_index")
|
|
|
|
|
return f"output:{output_index}" if isinstance(output_index, int) else ""
|
|
|
|
|
|
|
|
|
|
def validate_arguments(arguments: Any) -> None:
|
|
|
|
|
"""Reject an incomplete JSON object so Switchyard can fail over."""
|
|
|
|
|
if not isinstance(arguments, str):
|
|
|
|
|
raise RuntimeError("Codex returned malformed function arguments")
|
|
|
|
|
try:
|
|
|
|
|
parsed_arguments = json.loads(arguments)
|
|
|
|
|
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
"Codex returned retryable malformed function arguments"
|
|
|
|
|
) from exc
|
|
|
|
|
if not isinstance(parsed_arguments, dict):
|
|
|
|
|
raise RuntimeError("Codex returned malformed function arguments")
|
|
|
|
|
|
2026-08-11 23:03:13 -03:00
|
|
|
for line in lines:
|
|
|
|
|
if not line.startswith("data:"):
|
|
|
|
|
continue
|
|
|
|
|
value = line[5:].strip()
|
|
|
|
|
if not value or value == "[DONE]":
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
event = json.loads(value)
|
|
|
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
|
|
|
continue
|
|
|
|
|
if not isinstance(event, dict):
|
|
|
|
|
continue
|
|
|
|
|
event_type = str(event.get("type") or "")
|
|
|
|
|
response = event.get("response")
|
2026-08-12 07:29:05 -03:00
|
|
|
if event_type == "response.function_call_arguments.delta":
|
|
|
|
|
key = function_key(event)
|
|
|
|
|
delta = event.get("delta")
|
|
|
|
|
if key and isinstance(delta, str):
|
|
|
|
|
function_argument_deltas.setdefault(key, []).append(delta)
|
|
|
|
|
elif event_type == "response.function_call_arguments.done":
|
|
|
|
|
key = function_key(event)
|
|
|
|
|
arguments = event.get("arguments")
|
|
|
|
|
if not isinstance(arguments, str) and key:
|
|
|
|
|
arguments = "".join(function_argument_deltas.get(key, []))
|
|
|
|
|
validate_arguments(arguments)
|
|
|
|
|
if key:
|
|
|
|
|
completed_function_arguments.add(key)
|
2026-08-11 23:12:43 -03:00
|
|
|
if event_type == "response.output_item.done":
|
|
|
|
|
item = event.get("item")
|
|
|
|
|
output_index = event.get("output_index")
|
|
|
|
|
if isinstance(item, dict) and isinstance(output_index, int):
|
|
|
|
|
output_items[output_index] = item
|
2026-08-11 23:03:13 -03:00
|
|
|
if event_type in {
|
|
|
|
|
"response.completed",
|
|
|
|
|
"response.failed",
|
|
|
|
|
"response.incomplete",
|
|
|
|
|
} and isinstance(response, dict):
|
|
|
|
|
terminal_response = response
|
|
|
|
|
elif event_type == "error":
|
|
|
|
|
error = event.get("error")
|
|
|
|
|
if isinstance(error, dict):
|
|
|
|
|
upstream_error = str(error.get("message") or error.get("type") or "")
|
|
|
|
|
else:
|
|
|
|
|
upstream_error = str(error or "")
|
2026-08-12 07:29:05 -03:00
|
|
|
for key, deltas in function_argument_deltas.items():
|
|
|
|
|
if key not in completed_function_arguments:
|
|
|
|
|
validate_arguments("".join(deltas))
|
2026-08-11 23:03:13 -03:00
|
|
|
if terminal_response is not None:
|
2026-08-12 06:22:29 -03:00
|
|
|
status = str(terminal_response.get("status") or "").lower()
|
|
|
|
|
if status != "completed":
|
|
|
|
|
details = terminal_response.get("incomplete_details") or {}
|
|
|
|
|
reason = (
|
|
|
|
|
str(details.get("reason") or "")
|
|
|
|
|
if isinstance(details, dict)
|
|
|
|
|
else ""
|
|
|
|
|
)
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
"Codex returned a retryable incomplete response"
|
|
|
|
|
+ (f": {reason}" if reason else "")
|
|
|
|
|
)
|
2026-08-11 23:12:43 -03:00
|
|
|
# The subscription Codex endpoint streams complete output items but
|
|
|
|
|
# currently leaves the terminal response's output array empty. Public
|
|
|
|
|
# Responses clients, including Switchyard, expect those items there.
|
|
|
|
|
if output_items and not terminal_response.get("output"):
|
|
|
|
|
terminal_response["output"] = [
|
|
|
|
|
output_items[index] for index in sorted(output_items)
|
|
|
|
|
]
|
2026-08-12 07:13:32 -03:00
|
|
|
# A Responses stream can be marked ``completed`` even when a function
|
|
|
|
|
# call was cut off at the provider's output boundary. Passing that
|
|
|
|
|
# downstream as HTTP 200 makes Hermes retry the same broken turn until
|
|
|
|
|
# it emits the unhelpful "Response truncated" message. Reject malformed
|
|
|
|
|
# terminal tool calls here so Switchyard can fail over to another
|
|
|
|
|
# eligible provider/model for this boundary.
|
|
|
|
|
for item in terminal_response.get("output") or []:
|
|
|
|
|
if not isinstance(item, dict) or item.get("type") != "function_call":
|
|
|
|
|
continue
|
|
|
|
|
if str(item.get("status") or "completed").lower() == "incomplete":
|
|
|
|
|
raise RuntimeError("Codex returned a retryable incomplete tool call")
|
|
|
|
|
arguments = item.get("arguments")
|
2026-08-12 07:29:05 -03:00
|
|
|
validate_arguments(arguments)
|
2026-08-11 23:03:13 -03:00
|
|
|
return terminal_response
|
|
|
|
|
raise RuntimeError(upstream_error or "Codex stream ended without a terminal response")
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 06:50:13 -03:00
|
|
|
def _normalized_stream(body: bytes, completed: dict[str, Any]) -> bytes:
|
|
|
|
|
"""Return Responses SSE with the reconstructed terminal response attached."""
|
|
|
|
|
normalized: list[str] = []
|
|
|
|
|
replaced_terminal = False
|
2026-08-12 07:55:35 -03:00
|
|
|
pending_event_line = ""
|
2026-08-12 06:50:13 -03:00
|
|
|
for line in body.decode("utf-8", errors="replace").splitlines():
|
2026-08-12 07:55:35 -03:00
|
|
|
if line.startswith("event:"):
|
|
|
|
|
pending_event_line = line
|
2026-08-12 07:45:35 -03:00
|
|
|
continue
|
2026-08-12 06:50:13 -03:00
|
|
|
if line.startswith("data:"):
|
|
|
|
|
value = line[5:].strip()
|
|
|
|
|
try:
|
|
|
|
|
event = json.loads(value)
|
|
|
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
|
|
|
event = None
|
2026-08-12 07:55:35 -03:00
|
|
|
event_type = event.get("type") if isinstance(event, dict) else ""
|
|
|
|
|
item = event.get("item") if isinstance(event, dict) else None
|
|
|
|
|
if event_type == "response.function_call_arguments.done" or (
|
|
|
|
|
event_type == "response.output_item.done"
|
|
|
|
|
and isinstance(item, dict)
|
|
|
|
|
and item.get("type") == "function_call"
|
2026-08-12 07:45:35 -03:00
|
|
|
):
|
2026-08-12 07:55:35 -03:00
|
|
|
# Switchyard translates each terminal function-call snapshot
|
|
|
|
|
# into another Chat Completions argument delta. Retain the
|
|
|
|
|
# already-validated delta sequence, not its duplicate copies.
|
|
|
|
|
pending_event_line = ""
|
2026-08-12 07:45:35 -03:00
|
|
|
continue
|
2026-08-12 07:55:35 -03:00
|
|
|
if isinstance(event, dict) and event_type == "response.completed":
|
|
|
|
|
stream_completed = dict(completed)
|
|
|
|
|
stream_completed["output"] = [
|
|
|
|
|
output_item
|
|
|
|
|
for output_item in stream_completed.get("output") or []
|
|
|
|
|
if not isinstance(output_item, dict)
|
|
|
|
|
or output_item.get("type") != "function_call"
|
|
|
|
|
]
|
|
|
|
|
event["response"] = stream_completed
|
2026-08-12 06:50:13 -03:00
|
|
|
line = "data: " + json.dumps(event, separators=(",", ":"))
|
|
|
|
|
replaced_terminal = True
|
2026-08-12 07:55:35 -03:00
|
|
|
if pending_event_line:
|
|
|
|
|
normalized.append(pending_event_line)
|
|
|
|
|
pending_event_line = ""
|
|
|
|
|
elif pending_event_line and line:
|
|
|
|
|
normalized.append(pending_event_line)
|
|
|
|
|
pending_event_line = ""
|
2026-08-12 06:50:13 -03:00
|
|
|
normalized.append(line)
|
2026-08-12 07:55:35 -03:00
|
|
|
if pending_event_line:
|
|
|
|
|
normalized.append(pending_event_line)
|
2026-08-12 06:50:13 -03:00
|
|
|
if not replaced_terminal:
|
|
|
|
|
raise RuntimeError("Codex stream ended without a completed event")
|
|
|
|
|
# Preserve the blank event terminator required by SSE clients. Responses
|
|
|
|
|
# streams end at response.completed; they do not require a Chat
|
|
|
|
|
# Completions-style [DONE] sentinel.
|
|
|
|
|
return ("\n".join(normalized).rstrip("\n") + "\n\n").encode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 03:15:34 -03:00
|
|
|
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:
|
2026-08-12 23:48:06 -03:00
|
|
|
token = _access_token()
|
2026-08-11 03:15:34 -03:00
|
|
|
except RuntimeError as exc:
|
2026-08-12 23:48:06 -03:00
|
|
|
_record_health(
|
|
|
|
|
"unavailable",
|
|
|
|
|
authenticated=False,
|
|
|
|
|
last_error_at=datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
error_type=type(exc).__name__,
|
|
|
|
|
)
|
2026-08-11 03:15:34 -03:00
|
|
|
self._json(503, {"ok": False, "error": str(exc)})
|
|
|
|
|
return
|
2026-08-12 23:48:06 -03:00
|
|
|
_record_health(
|
|
|
|
|
"available",
|
|
|
|
|
authenticated=True,
|
|
|
|
|
token_expires_at=_token_expiry(token) or None,
|
|
|
|
|
)
|
2026-08-11 03:15:34 -03:00
|
|
|
self._json(200, {"ok": True, "provider": "openai-codex"})
|
|
|
|
|
return
|
|
|
|
|
if self.path in {"/models", "/v1/models"}:
|
2026-09-13 01:48:05 -05:00
|
|
|
catalog = load_catalog()
|
|
|
|
|
providers = catalog.get("providers", {}) if isinstance(catalog, dict) else {}
|
|
|
|
|
codex = providers.get("codex", {}) if isinstance(providers, dict) else {}
|
|
|
|
|
discovered = codex.get("models", []) if isinstance(codex, dict) else []
|
|
|
|
|
models = {
|
|
|
|
|
model for model in discovered if isinstance(model, str) and model.startswith("gpt-")
|
|
|
|
|
} or FALLBACK_ALLOWED_MODELS
|
2026-08-11 03:15:34 -03:00
|
|
|
self._json(
|
|
|
|
|
200,
|
|
|
|
|
{
|
|
|
|
|
"object": "list",
|
|
|
|
|
"data": [
|
|
|
|
|
{"id": model, "object": "model", "owned_by": "openai-codex"}
|
2026-09-13 01:48:05 -05:00
|
|
|
for model in sorted(models)
|
2026-08-11 03:15:34 -03:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
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:
|
2026-08-11 23:03:13 -03:00
|
|
|
payload = json.loads(self.rfile.read(length))
|
|
|
|
|
requested_stream = payload.get("stream") is True
|
|
|
|
|
payload = _validate_payload(payload)
|
2026-08-12 23:48:06 -03:00
|
|
|
model = str(payload["model"])
|
|
|
|
|
effort = _requested_effort(payload)
|
|
|
|
|
started = time.monotonic()
|
2026-08-11 03:15:34 -03:00
|
|
|
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()
|
2026-08-12 23:48:06 -03:00
|
|
|
state = (
|
|
|
|
|
"capacity-limited"
|
|
|
|
|
if response.status_code == 429
|
|
|
|
|
else "unavailable"
|
|
|
|
|
if response.status_code in {401, 403}
|
|
|
|
|
else "degraded"
|
|
|
|
|
)
|
|
|
|
|
_record_health(
|
|
|
|
|
state,
|
|
|
|
|
last_error_at=datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
status_code=response.status_code,
|
|
|
|
|
model=model,
|
|
|
|
|
effort=effort,
|
|
|
|
|
)
|
2026-08-11 03:15:34 -03:00
|
|
|
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
|
|
|
|
|
|
2026-08-12 06:22:29 -03:00
|
|
|
# Validate the terminal Responses event before committing
|
|
|
|
|
# HTTP 200 downstream. The Codex subscription endpoint can
|
|
|
|
|
# end a tool call with ``response.incomplete`` while still
|
|
|
|
|
# returning HTTP 200. If that is streamed through, Hermes
|
|
|
|
|
# sees malformed JSON and eventually exposes "Response
|
|
|
|
|
# truncated" to the user. Buffering one model boundary
|
|
|
|
|
# lets Switchyard receive a retryable 502 and choose another
|
|
|
|
|
# provider/model instead. Tool execution remains streamed
|
|
|
|
|
# by Hermes after this short model boundary completes.
|
|
|
|
|
body = response.read()
|
|
|
|
|
if len(body) > MAX_RESPONSE_BYTES:
|
|
|
|
|
raise RuntimeError("Codex response exceeded broker limit")
|
|
|
|
|
completed = _completed_response(
|
|
|
|
|
body.decode("utf-8", errors="replace").splitlines()
|
|
|
|
|
)
|
2026-08-12 23:48:06 -03:00
|
|
|
_record_health(
|
|
|
|
|
"available",
|
|
|
|
|
last_success_at=datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
latency_ms=int((time.monotonic() - started) * 1000),
|
|
|
|
|
model=model,
|
|
|
|
|
effort=effort,
|
|
|
|
|
token_expires_at=_token_expiry(token) or None,
|
|
|
|
|
)
|
2026-08-11 23:03:13 -03:00
|
|
|
if not requested_stream:
|
2026-08-12 06:22:29 -03:00
|
|
|
self._json(200, completed)
|
2026-08-11 23:03:13 -03:00
|
|
|
return
|
|
|
|
|
|
2026-08-12 06:50:13 -03:00
|
|
|
body = _normalized_stream(body, completed)
|
|
|
|
|
|
2026-08-11 03:15:34 -03:00
|
|
|
self.send_response(200)
|
|
|
|
|
self.send_header("Content-Type", "text/event-stream")
|
2026-08-12 06:22:29 -03:00
|
|
|
self.send_header("Content-Length", str(len(body)))
|
2026-08-11 03:15:34 -03:00
|
|
|
self.send_header("Cache-Control", "no-store")
|
|
|
|
|
self.send_header("X-Accel-Buffering", "no")
|
|
|
|
|
self.end_headers()
|
|
|
|
|
response_started = True
|
2026-08-12 06:22:29 -03:00
|
|
|
self.wfile.write(body)
|
|
|
|
|
self.wfile.flush()
|
2026-08-11 03:15:34 -03:00
|
|
|
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
|
2026-08-12 23:48:06 -03:00
|
|
|
_record_health(
|
|
|
|
|
"degraded",
|
|
|
|
|
last_error_at=datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
error_type=type(exc).__name__,
|
|
|
|
|
)
|
2026-08-11 03:15:34 -03:00
|
|
|
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:
|
2026-08-15 17:58:47 -03:00
|
|
|
raise SystemExit("runtime relay key is unavailable")
|
2026-08-12 23:48:06 -03:00
|
|
|
try:
|
|
|
|
|
token = _access_token()
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
_record_health(
|
|
|
|
|
"unavailable",
|
|
|
|
|
authenticated=False,
|
|
|
|
|
last_error_at=datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
error_type=type(exc).__name__,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
_record_health(
|
|
|
|
|
"available",
|
|
|
|
|
authenticated=True,
|
|
|
|
|
token_expires_at=_token_expiry(token) or None,
|
|
|
|
|
)
|
2026-08-11 03:15:34 -03:00
|
|
|
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|