test: add scenario eval runner, recorded fixtures, and baseline comparison
This commit is contained in:
parent
3dc1af5f0c
commit
0664cc2d27
38 changed files with 6986 additions and 5 deletions
154
eval/recording/transport.py
Normal file
154
eval/recording/transport.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""Recording transports for Spotify's httpx2 and Anthropic's httpx."""
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
|
||||
from recording.model import RecordedInteraction
|
||||
from recording.redaction import is_sensitive_url
|
||||
|
||||
|
||||
class HttpxRecordingTransport(httpx.AsyncBaseTransport):
|
||||
"""Wrap an httpx transport and retain completed response chunk sequences."""
|
||||
|
||||
def __init__(self, transport: httpx.AsyncBaseTransport) -> None:
|
||||
"""Bind the real transport and start an empty in-memory cassette."""
|
||||
self.transport = transport
|
||||
self.interactions: list[RecordedInteraction] = []
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
"""Forward one request and wrap its response stream for capture."""
|
||||
request.headers["Accept-Encoding"] = "identity"
|
||||
request_body = await request.aread()
|
||||
response = await self.transport.handle_async_request(request)
|
||||
if is_sensitive_url(str(request.url)):
|
||||
return response
|
||||
stream = _HttpxRecordingStream(
|
||||
cast(httpx.AsyncByteStream, response.stream),
|
||||
lambda chunks: self._finish(request, request_body, response.status_code, chunks),
|
||||
)
|
||||
return httpx.Response(
|
||||
response.status_code,
|
||||
headers=response.headers,
|
||||
stream=stream,
|
||||
extensions=response.extensions,
|
||||
request=request,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the wrapped transport."""
|
||||
await self.transport.aclose()
|
||||
|
||||
def _finish(
|
||||
self,
|
||||
request: httpx.Request,
|
||||
request_body: bytes,
|
||||
status: int,
|
||||
chunks: tuple[bytes, ...],
|
||||
) -> None:
|
||||
self.interactions.append(
|
||||
RecordedInteraction(request.method, str(request.url), status, request_body, chunks)
|
||||
)
|
||||
|
||||
|
||||
class Httpx2RecordingTransport(httpx2.AsyncBaseTransport):
|
||||
"""Wrap an httpx2 transport and retain completed response chunk sequences."""
|
||||
|
||||
def __init__(self, transport: httpx2.AsyncBaseTransport) -> None:
|
||||
"""Bind the real transport and start an empty in-memory cassette."""
|
||||
self.transport = transport
|
||||
self.interactions: list[RecordedInteraction] = []
|
||||
|
||||
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
|
||||
"""Forward one request and wrap its response stream for capture."""
|
||||
request.headers["Accept-Encoding"] = "identity"
|
||||
request_body = await request.aread()
|
||||
response = await self.transport.handle_async_request(request)
|
||||
if is_sensitive_url(str(request.url)):
|
||||
return response
|
||||
stream = _Httpx2RecordingStream(
|
||||
cast(httpx2.AsyncByteStream, response.stream),
|
||||
lambda chunks: self._finish(request, request_body, response.status_code, chunks),
|
||||
)
|
||||
return httpx2.Response(
|
||||
response.status_code,
|
||||
headers=response.headers,
|
||||
stream=stream,
|
||||
extensions=response.extensions,
|
||||
request=request,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the wrapped transport."""
|
||||
await self.transport.aclose()
|
||||
|
||||
def _finish(
|
||||
self,
|
||||
request: httpx2.Request,
|
||||
request_body: bytes,
|
||||
status: int,
|
||||
chunks: tuple[bytes, ...],
|
||||
) -> None:
|
||||
self.interactions.append(
|
||||
RecordedInteraction(request.method, str(request.url), status, request_body, chunks)
|
||||
)
|
||||
|
||||
|
||||
class _HttpxRecordingStream(httpx.AsyncByteStream):
|
||||
def __init__(
|
||||
self,
|
||||
stream: httpx.AsyncByteStream,
|
||||
finish: Callable[[tuple[bytes, ...]], None],
|
||||
) -> None:
|
||||
self.stream = stream
|
||||
self.finish = finish
|
||||
self.chunks: list[bytes] = []
|
||||
self.is_finished = False
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
try:
|
||||
async for chunk in self.stream:
|
||||
self.chunks.append(chunk)
|
||||
yield chunk
|
||||
finally:
|
||||
self._finish()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.stream.aclose()
|
||||
self._finish()
|
||||
|
||||
def _finish(self) -> None:
|
||||
if not self.is_finished:
|
||||
self.is_finished = True
|
||||
self.finish(tuple(self.chunks))
|
||||
|
||||
|
||||
class _Httpx2RecordingStream(httpx2.AsyncByteStream):
|
||||
def __init__(
|
||||
self,
|
||||
stream: httpx2.AsyncByteStream,
|
||||
finish: Callable[[tuple[bytes, ...]], None],
|
||||
) -> None:
|
||||
self.stream = stream
|
||||
self.finish = finish
|
||||
self.chunks: list[bytes] = []
|
||||
self.is_finished = False
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
try:
|
||||
async for chunk in self.stream:
|
||||
self.chunks.append(chunk)
|
||||
yield chunk
|
||||
finally:
|
||||
self._finish()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.stream.aclose()
|
||||
self._finish()
|
||||
|
||||
def _finish(self) -> None:
|
||||
if not self.is_finished:
|
||||
self.is_finished = True
|
||||
self.finish(tuple(self.chunks))
|
||||
Loading…
Add table
Add a link
Reference in a new issue