You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
138 lines
5.0 KiB
138 lines
5.0 KiB
"""Recording reverse proxy for the Anthropic API.
|
|
|
|
Claude Code is pointed at it with ANTHROPIC_BASE_URL. Every request body is
|
|
recorded (decompressed) before being forwarded unchanged, so a test can check
|
|
what actually left the machine. Responses are streamed back untouched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import gzip
|
|
import os
|
|
import threading
|
|
import zlib
|
|
from dataclasses import dataclass, field
|
|
|
|
import aiohttp
|
|
from aiohttp import web
|
|
|
|
DEFAULT_UPSTREAM = "https://api.anthropic.com"
|
|
|
|
# Hop-by-hop headers (RFC 9110) plus the ones aiohttp recomputes.
|
|
_SKIP_REQUEST_HEADERS = {"host", "content-length", "connection", "keep-alive", "transfer-encoding", "upgrade"}
|
|
_SKIP_RESPONSE_HEADERS = {"content-length", "connection", "keep-alive", "transfer-encoding", "upgrade"}
|
|
|
|
|
|
@dataclass
|
|
class Exchange:
|
|
method: str
|
|
path: str
|
|
status: int | None
|
|
body: str # decoded request body, best effort
|
|
|
|
|
|
def _decode(body: bytes, encoding: str) -> str:
|
|
encoding = encoding.lower().strip()
|
|
try:
|
|
if encoding == "gzip":
|
|
body = gzip.decompress(body)
|
|
elif encoding == "deflate":
|
|
body = zlib.decompress(body)
|
|
elif encoding == "zstd":
|
|
import zstandard
|
|
|
|
body = zstandard.ZstdDecompressor().decompressobj().decompress(body)
|
|
elif encoding == "br":
|
|
import brotli # optional
|
|
|
|
body = brotli.decompress(body)
|
|
except Exception: # keep the raw bytes: a failed decode must not hide data
|
|
pass
|
|
return body.decode("utf-8", errors="replace")
|
|
|
|
|
|
@dataclass
|
|
class RecordingProxy:
|
|
upstream: str = field(default_factory=lambda: os.environ.get("BENCH_UPSTREAM", DEFAULT_UPSTREAM))
|
|
exchanges: list[Exchange] = field(default_factory=list)
|
|
port: int = 0
|
|
|
|
def __post_init__(self) -> None:
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._runner: web.AppRunner | None = None
|
|
self._session: aiohttp.ClientSession | None = None
|
|
self._thread: threading.Thread | None = None
|
|
self._lock = threading.Lock()
|
|
|
|
@property
|
|
def base_url(self) -> str:
|
|
return f"http://127.0.0.1:{self.port}"
|
|
|
|
def wire_text(self) -> str:
|
|
"""Every recorded request body, concatenated."""
|
|
with self._lock:
|
|
return "\n".join(e.body for e in self.exchanges)
|
|
|
|
def start(self) -> "RecordingProxy":
|
|
ready = threading.Event()
|
|
self._thread = threading.Thread(target=self._run, args=(ready,), daemon=True)
|
|
self._thread.start()
|
|
if not ready.wait(10):
|
|
raise RuntimeError("proxy did not start")
|
|
return self
|
|
|
|
def stop(self) -> None:
|
|
if self._loop is None:
|
|
return
|
|
fut = asyncio.run_coroutine_threadsafe(self._shutdown(), self._loop)
|
|
fut.result(10)
|
|
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
if self._thread:
|
|
self._thread.join(10)
|
|
|
|
def _run(self, ready: threading.Event) -> None:
|
|
self._loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(self._loop)
|
|
self._loop.run_until_complete(self._startup())
|
|
ready.set()
|
|
self._loop.run_forever()
|
|
|
|
async def _startup(self) -> None:
|
|
self._session = aiohttp.ClientSession(auto_decompress=False, trust_env=True,
|
|
timeout=aiohttp.ClientTimeout(total=None, sock_read=600))
|
|
app = web.Application(client_max_size=256 * 1024 * 1024)
|
|
app.router.add_route("*", "/{tail:.*}", self._handle)
|
|
self._runner = web.AppRunner(app, access_log=None)
|
|
await self._runner.setup()
|
|
site = web.TCPSite(self._runner, "127.0.0.1", self.port)
|
|
await site.start()
|
|
self.port = site._server.sockets[0].getsockname()[1] # type: ignore[union-attr]
|
|
|
|
async def _shutdown(self) -> None:
|
|
if self._runner:
|
|
await self._runner.cleanup()
|
|
if self._session:
|
|
await self._session.close()
|
|
|
|
async def _handle(self, request: web.Request) -> web.StreamResponse:
|
|
assert self._session is not None
|
|
body = await request.read()
|
|
exchange = Exchange(request.method, request.path_qs, None,
|
|
_decode(body, request.headers.get("Content-Encoding", "")))
|
|
with self._lock:
|
|
self.exchanges.append(exchange)
|
|
|
|
headers = {k: v for k, v in request.headers.items() if k.lower() not in _SKIP_REQUEST_HEADERS}
|
|
url = self.upstream.rstrip("/") + request.path_qs
|
|
async with self._session.request(request.method, url, headers=headers, data=body) as upstream:
|
|
exchange.status = upstream.status
|
|
response = web.StreamResponse(status=upstream.status, reason=upstream.reason)
|
|
for k, v in upstream.headers.items():
|
|
if k.lower() not in _SKIP_RESPONSE_HEADERS:
|
|
response.headers.add(k, v)
|
|
await response.prepare(request)
|
|
async for chunk in upstream.content.iter_any():
|
|
await response.write(chunk)
|
|
await response.write_eof()
|
|
return response
|
|
|