File size: 9,806 Bytes
5655a42 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | """HTTP server that forwards OpenAI-compatible requests to a configured upstream.
A credential-attaching forwarder: request/response bodies are never mediated, logged, or rewritten.
The one shim: after a *clean* upstream EOF, a ``text/event-stream`` response that carried a terminal
``finish_reason`` or ``lastOne: true`` but omitted ``data: [DONE]`` gets a single ``[DONE]`` frame.
"""
from __future__ import annotations
import asyncio
import logging
import signal
from typing import Optional
try:
import aiohttp
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError:
aiohttp = None # type: ignore[assignment]
web = None # type: ignore[assignment]
AIOHTTP_AVAILABLE = False
from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential
from hermes_cli.proxy.sse_done import DONE_SSE_FRAME, SseDoneTracker, content_type_is_sse
logger = logging.getLogger(__name__)
# Stripped when forwarding upstream: ``host``/``content-length`` are recomputed by aiohttp,
# ``authorization`` is replaced with our bearer; everything else passes through.
_HOP_BY_HOP_HEADERS = frozenset({
"host", "content-length", "connection", "keep-alive", "proxy-authenticate",
"proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "authorization",
})
# aiohttp recomputes Content-Encoding/Content-Length on stream — let it.
_RESPONSE_DROP_HEADERS = _HOP_BY_HOP_HEADERS | {"content-encoding", "content-length"}
DEFAULT_PORT = 8645
DEFAULT_HOST = "127.0.0.1"
# Mirrors api_server's MAX_REQUEST_BYTES (10 MB); client_max_size bounds every read path,
# including chunked bodies.
MAX_REQUEST_BYTES = 10_000_000
def _require_aiohttp() -> None:
if not AIOHTTP_AVAILABLE:
raise RuntimeError("aiohttp is required for `hermes proxy`. Run `hermes setup` to install it.")
def _json_error(status: int, message: str, code: str = "proxy_error") -> "web.Response":
"""OpenAI-style error JSON response."""
body = {"error": {"message": message, "type": code, "code": code}}
return web.json_response(body, status=status)
def _filter_headers(headers, drop: frozenset = _HOP_BY_HOP_HEADERS) -> dict:
"""Strip hop-by-hop (+ auth) headers; ``drop`` widens the set for upstream responses."""
return {key: value for key, value in headers.items() if key.lower() not in drop}
async def _open_upstream(request: "web.Request", rel_path: str, body: bytes, cred: UpstreamCredential):
"""Send the request upstream with ``cred``; returns ``(session, response)`` or
``(error_response, None)``."""
upstream_url = f"{cred.base_url.rstrip('/')}{rel_path}"
if request.query_string: # preserved verbatim
upstream_url = f"{upstream_url}?{request.query_string}"
fwd_headers = _filter_headers(request.headers)
fwd_headers["Authorization"] = f"{cred.token_type} {cred.bearer}"
logger.debug("proxy: forwarding %s %s -> %s (body=%d bytes)", request.method, rel_path, upstream_url, len(body))
try:
session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=300))
except Exception as exc: # pragma: no cover - aiohttp setup issue
return _json_error(500, f"proxy session init failed: {exc}"), None
try:
upstream_resp = await session.request(
request.method, upstream_url, data=body if body else None, headers=fwd_headers, allow_redirects=False
)
except RuntimeError as exc:
await session.close()
return _json_error(500, str(exc)), None
except aiohttp.ClientError as exc:
await session.close()
logger.warning("proxy: upstream connection failed: %s", exc)
return _json_error(502, f"upstream connection failed: {exc}", code="upstream_unreachable"), None
except asyncio.TimeoutError:
await session.close()
return _json_error(504, "upstream request timed out", code="upstream_timeout"), None
except Exception:
await session.close()
raise
return session, upstream_resp
async def _stream_back(request: "web.Request", session, upstream_resp) -> "web.StreamResponse":
"""Relay status + filtered headers, then the body chunk-by-chunk, appending a missing SSE
``[DONE]`` only after a clean EOF."""
resp = web.StreamResponse(
status=upstream_resp.status, headers=_filter_headers(upstream_resp.headers, _RESPONSE_DROP_HEADERS)
)
await resp.prepare(request)
done_tracker: Optional[SseDoneTracker] = None
if content_type_is_sse(upstream_resp.headers):
done_tracker = SseDoneTracker()
try:
async for chunk in upstream_resp.content.iter_any():
if chunk:
if done_tracker is not None:
done_tracker.feed(chunk)
await resp.write(chunk)
if done_tracker is not None and done_tracker.should_append_done():
try:
await resp.write(DONE_SSE_FRAME)
except Exception as exc: # client hung up at EOF — harmless
logger.debug("proxy: DONE append skipped: %s", exc)
except (aiohttp.ClientError, asyncio.CancelledError, OSError) as exc:
if done_tracker is not None:
done_tracker.mark_interrupted()
logger.warning("proxy: streaming interrupted: %s", exc)
finally:
upstream_resp.release()
await session.close()
await resp.write_eof()
return resp
def create_app(adapter: UpstreamAdapter) -> "web.Application":
"""Build the aiohttp application bound to a specific upstream adapter.
Every adapter method is synchronous and blocking (the Nous adapter takes the 15s cross-process
``_auth_store_lock()`` and may POST a token refresh; xAI rotates its key pool under a lock),
so all three are run via ``asyncio.to_thread`` — a contended lock or refresh must never freeze
the single loop and every other in-flight streaming completion.
"""
_require_aiohttp()
app = web.Application(client_max_size=MAX_REQUEST_BYTES)
# AppKey: forward-compat with aiohttp versions that strip bare-string keys.
app[web.AppKey("adapter", UpstreamAdapter)] = adapter
async def handle_health(request: "web.Request") -> "web.Response":
authenticated = await asyncio.to_thread(adapter.is_authenticated)
return web.json_response({"status": "ok", "upstream": adapter.display_name, "authenticated": authenticated})
async def handle_proxy(request: "web.Request") -> "web.StreamResponse":
rel_path = "/" + request.match_info.get("tail", "").lstrip("/")
if rel_path not in adapter.allowed_paths:
allowed = ", ".join(sorted(adapter.allowed_paths))
return _json_error(
404, f"Path /v1{rel_path} is not forwarded by this proxy. Allowed: {allowed}", code="path_not_allowed"
)
try:
cred = await asyncio.to_thread(adapter.get_credential)
except Exception as exc:
logger.warning("proxy: credential resolution failed: %s", exc)
return _json_error(401, str(exc), code="upstream_auth_failed")
# Body read into memory once (chat/embeddings payloads are small); switch to streaming
# if large multipart uploads ever need forwarding.
body = await request.read()
session, upstream_resp = await _open_upstream(request, rel_path, body, cred)
if upstream_resp is None:
return session
if upstream_resp.status in {401, 429}:
# One-shot retry with a refreshed/rotated credential (Nous: unconditional refresh
# POST under the auth lock; xAI: pool rotation).
try:
retry_cred = await asyncio.to_thread(
adapter.get_retry_credential, failed_credential=cred, status_code=upstream_resp.status
)
except Exception as exc:
logger.warning("proxy: retry credential resolution failed: %s", exc)
retry_cred = None
if retry_cred is not None:
upstream_resp.release()
await session.close()
session, upstream_resp = await _open_upstream(request, rel_path, body, retry_cred)
if upstream_resp is None:
return session
return await _stream_back(request, session, upstream_resp)
app.router.add_get("/health", handle_health) # never goes upstream
app.router.add_route("*", "/v1/{tail:.*}", handle_proxy) # forwards if the path is allowed
return app
async def run_server(
adapter: UpstreamAdapter,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
shutdown_event: Optional[asyncio.Event] = None,
) -> None:
"""Run the proxy in the current event loop until shutdown_event is set."""
_require_aiohttp()
app = create_app(adapter)
runner = web.AppRunner(app, access_log=None)
await runner.setup()
site = web.TCPSite(runner, host=host, port=port)
await site.start()
logger.info("proxy: listening on http://%s:%d/v1 -> %s", host, port, adapter.display_name)
stop_event = shutdown_event or asyncio.Event()
if shutdown_event is None: # we own the loop's lifetime → wire signal handlers
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, stop_event.set) # windows-footgun: ok
except NotImplementedError:
pass # Windows / restricted envs — Ctrl+C still raises KeyboardInterrupt
try:
await stop_event.wait()
finally:
logger.info("proxy: shutting down")
await runner.cleanup()
__all__ = ["create_app", "run_server", "DEFAULT_HOST", "DEFAULT_PORT", "AIOHTTP_AVAILABLE"]
|