mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
147 lines
4.9 KiB
Python
147 lines
4.9 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import queue
|
|
import threading
|
|
from collections.abc import Generator
|
|
from contextlib import contextmanager
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Final
|
|
|
|
from pydantic import JsonValue, TypeAdapter
|
|
|
|
from .fixtures.recording import local_response_header
|
|
from .models import CapturedRequest
|
|
from .recorded_http import RecordedHttpResponse, RecordedHttpStreamResponse, RecordedResponse
|
|
|
|
JSON_VALUE: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
|
EXCLUDED_REQUEST_HEADERS: Final = frozenset(
|
|
{
|
|
"host",
|
|
"content-length",
|
|
"connection",
|
|
"accept-encoding",
|
|
"user-agent",
|
|
"x-litellm-parity-route",
|
|
}
|
|
)
|
|
EXCLUDED_RESPONSE_HEADERS: Final = frozenset({"content-length", "transfer-encoding", "connection"})
|
|
|
|
|
|
class ReplayServer(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(("127.0.0.1", 0), _ReplayHandler)
|
|
self.responses: queue.Queue[RecordedResponse] = queue.Queue()
|
|
self.requests: queue.Queue[CapturedRequest] = queue.Queue()
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
return f"http://127.0.0.1:{self.server_address[1]}"
|
|
|
|
def enqueue_response(self, response: RecordedResponse) -> None:
|
|
self.responses.put(response)
|
|
|
|
def take_requests(self, expected_count: int) -> tuple[CapturedRequest, ...]:
|
|
request_count: Final = self.requests.qsize()
|
|
if request_count != expected_count:
|
|
raise AssertionError(f"expected exactly {expected_count} provider requests, received {request_count}")
|
|
return tuple(self.requests.get_nowait() for _ in range(request_count))
|
|
|
|
def reset(self) -> None:
|
|
while not self.responses.empty():
|
|
self.responses.get_nowait()
|
|
while not self.requests.empty():
|
|
self.requests.get_nowait()
|
|
|
|
|
|
class _ReplayHandler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def do_POST(self) -> None:
|
|
self._replay()
|
|
|
|
def do_GET(self) -> None:
|
|
self._replay()
|
|
|
|
def do_PUT(self) -> None:
|
|
self._replay()
|
|
|
|
def do_PATCH(self) -> None:
|
|
self._replay()
|
|
|
|
def do_DELETE(self) -> None:
|
|
self._replay()
|
|
|
|
def _replay(self) -> None:
|
|
provider: Final = self.server
|
|
assert isinstance(provider, ReplayServer)
|
|
length: Final = int(self.headers.get("content-length") or "0")
|
|
raw_body: Final = self.rfile.read(length) if length else b""
|
|
content_type: Final = self.headers.get("content-type", "")
|
|
body: Final = (
|
|
JSON_VALUE.validate_json(raw_body)
|
|
if raw_body and content_type.lower().startswith("application/json")
|
|
else base64.b64encode(raw_body).decode("ascii")
|
|
if raw_body
|
|
else None
|
|
)
|
|
headers: Final = tuple(
|
|
sorted(
|
|
(name.lower(), value)
|
|
for name, value in self.headers.raw_items()
|
|
if name.lower() not in EXCLUDED_REQUEST_HEADERS
|
|
)
|
|
)
|
|
provider.requests.put(
|
|
CapturedRequest(
|
|
method=self.command,
|
|
path=self.path,
|
|
headers=headers,
|
|
body=body,
|
|
user_agent=self.headers.get("user-agent"),
|
|
)
|
|
)
|
|
try:
|
|
response: Final = provider.responses.get(timeout=5)
|
|
except queue.Empty:
|
|
self.send_error(500, "no replay response queued")
|
|
return
|
|
self.send_response_only(response.status_code)
|
|
for header in response.headers:
|
|
if header.name.lower() not in EXCLUDED_RESPONSE_HEADERS:
|
|
self.send_header(header.name, local_response_header(header.name, header.value, provider.url))
|
|
if isinstance(response, RecordedHttpResponse):
|
|
response_body: Final = response.body_bytes()
|
|
self.send_header("content-length", str(len(response_body)))
|
|
self.end_headers()
|
|
self.wfile.write(response_body)
|
|
return
|
|
assert isinstance(response, RecordedHttpStreamResponse)
|
|
self.send_header("transfer-encoding", "chunked")
|
|
self.end_headers()
|
|
for chunk in response.chunks:
|
|
data = chunk.data_bytes()
|
|
self.wfile.write(f"{len(data):X}\r\n".encode("ascii"))
|
|
self.wfile.write(data)
|
|
self.wfile.write(b"\r\n")
|
|
self.wfile.flush()
|
|
self.wfile.write(b"0\r\n\r\n")
|
|
self.wfile.flush()
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
return
|
|
|
|
|
|
@contextmanager
|
|
def replay_server() -> Generator[ReplayServer]:
|
|
server: Final = ReplayServer()
|
|
thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield server
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=5)
|