mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-19 04:54:37 +00:00
* test(integration): Converse stream post_call guardrail relays frames as they arrive (LIT-9515) * fix(bedrock): stream Converse passthrough frames through post_call guardrails instead of buffering (LIT-9515) A post_call guardrail that scans through apply_guardrail now runs on /bedrock/.../converse-stream the way it runs on /v1/messages: frames reach the caller as the upstream sends them, every fifth frame and the end of the stream are scanned, and a block after the first frame ends the stream with an AWS exception frame. streaming_buffer_until_moderated still holds the stream and applies masks per content block. Guardrails that need the whole response (own async_post_call_success_hook, mask_response_content, native hooks, policy pipelines) keep the buffered path. * fix(bedrock): apply held Converse stream masks when the upstream ends with an exception frame (LIT-9515) * fix(proxy): log a relayed passthrough stream's spend before its mid-stream failure hook (LIT-9515) * fix(bedrock): close and report every way a relayed Converse stream ends (LIT-9515) - The framer ends the stream with an empty item once the upstream closes, so held masks are applied whether the upstream ends with messageStop, an exception or error frame, or a dropped connection - A guardrail HTTPException after the first frame reaches the relay, which logs the stream's spend, runs the failure hook and ends the stream with the exception frame, instead of ending inside the guardrail with no failure row - A failure before the first frame waits for the stream's spend to be logged, as a mid-stream failure does - Closing the response before its body starts closes the guardrail layers and the upstream - Each stacked guardrail layer gets its own copy of every frame, so an earlier layer's end-of-stream rewrite never reaches frames a later layer still holds - Deltas whose text ends in half a surrogate pair are decoded with the standard JSON parser * fix(bedrock): relay a Converse frame whose payload is not UTF-8 instead of ending the stream (LIT-9515) * test(integration): cover Converse stream relay failure, end-of-stream-only, stacked, disconnect and burst cells (LIT-9515) * fix(bedrock): estimate prompt tokens for a passthrough stream cut before Bedrock reports usage (LIT-9515) Bedrock sends usage only in the last frame of a stream, so a stream ended by a guardrail block or a client disconnect logged 0 prompt tokens while AWS still charges for the prompt. The stream collector now estimates them from the request the way chat completions streams do * fix(bedrock): read tool result blocks without recursion in the passthrough prompt estimate (LIT-9515) * fix(passthrough): keep the spend flush running when the wait for it is cancelled (LIT-9515) * fix(bedrock): keep a converse-stream buffered when its guardrail releases a held stream on scan (LIT-9515) A held post_call guardrail with streaming_buffer_release_on_scan releases each window as received once its scan passes, so the relay delivered the original text instead of the guardrail's masks. Such a stream keeps the buffered path, which applies masks per content block as on main. * fix(bedrock): keep a converse-stream buffered when a post_call guardrail runs in parallel (LIT-9515) The relay ran every guardrail in config order, so a run_in_parallel guardrail listed before a masking guardrail scanned the unmasked text. Main's buffered path runs parallel guardrails after the sequential ones and discards their rewrites, which the relay cannot reproduce, so these streams stay buffered * fix(bedrock): mark Noma v2 relay scans like chat and stop reading Bedrock when a held stream's client leaves - Noma v2 scans on the converse-stream relay carry stream, and the last one carries the finished Converse response with its stopReason, as chat sends them, so Noma keeps one record per reply. Other guardrails get the request unchanged. When the last sampled scan already covered the whole answer, the end-of-stream scan still runs for Noma so it gets the finished answer - A held relay stream whose client leaves before the first frame stops reading Bedrock and returns 499, like chat. When Bedrock sent nothing yet, the budget reservation and the parallel-request slot are released - A cancelled wait for the first streamed chunk now stops the upstream read instead of leaving it running - A Noma v2 scan cancelled before Noma answers records not_run instead of success with an empty response * fix(bedrock): send Noma the finished Converse reply's tool calls and reasoning as Converse blocks * fix(bedrock): give Noma the ended scan of a Converse reply that only calls a tool without input * test(bedrock): expect the ValidationException main now returns for a held block before the first frame Main's AWS-shaped passthrough errors (#46522) answer a guardrail block before the first frame with x-amzn-errortype ValidationException, so boto3 reads the code and the guardrail message instead of a bare 400. * fix(bedrock): keep held masks when an earlier sampled guardrail ends the Converse stream with its message A sampled guardrail that blocks with disable_exception_on_block ends the stream with its message as raw event-stream bytes. A held guardrail after it on the same key only read frame objects, so it never saw the messageStop, skipped its write-back and released the held text without its masks. The Converse handler now reads those bytes back into frames, so the held guardrail sees the end of the message and masks the text * fix(proxy): free the parallel slot when a caller leaves a Converse relay holding part of the first frame The relay skipped releasing the slot and budget reservation whenever a spend flush was scheduled, but a flush of a partial first frame builds no response and logs nothing, so the slot stayed held until it expired. The flush now says whether it logged the stream. --------- Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
240 lines
8.4 KiB
Python
240 lines
8.4 KiB
Python
from __future__ import annotations
|
|
|
|
import ssl
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable, Generator, Iterator, Mapping
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from functools import partial
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from queue import SimpleQueue
|
|
from types import MappingProxyType
|
|
from typing import BinaryIO, Final
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Request:
|
|
method: str
|
|
target: str
|
|
headers: Mapping[str, str]
|
|
body: bytes
|
|
|
|
|
|
class AbortedBody(Exception):
|
|
"""The client closed the connection before the body it announced was complete."""
|
|
|
|
|
|
def exactly(stream: BinaryIO, size: int) -> bytes:
|
|
data: Final = stream.read(size)
|
|
if len(data) < size:
|
|
raise AbortedBody
|
|
return data
|
|
|
|
|
|
def chunked_body(stream: BinaryIO) -> Iterator[bytes]:
|
|
while True:
|
|
size_line: Final = stream.readline()
|
|
if not size_line:
|
|
raise AbortedBody
|
|
size: Final = int(size_line.split(b";")[0].strip(), 16)
|
|
if size == 0:
|
|
while stream.readline().strip():
|
|
pass
|
|
return
|
|
yield exactly(stream, size)
|
|
stream.readline()
|
|
|
|
|
|
def read_body(headers: Mapping[str, str], stream: BinaryIO) -> bytes:
|
|
if headers.get("transfer-encoding", "").lower() == "chunked":
|
|
return b"".join(chunked_body(stream))
|
|
return exactly(stream, int(headers.get("content-length", "0")))
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Reply:
|
|
status: int = 200
|
|
body: bytes = b"{}"
|
|
content_type: str = "application/json"
|
|
chunks: tuple[bytes, ...] | None = None
|
|
abort_after: int | None = None
|
|
gate_before_first: threading.Event | None = None
|
|
gate_after_first: threading.Event | None = None
|
|
pause_between_chunks: float = 0
|
|
heartbeat: bytes | None = None
|
|
headers: Mapping[str, str] = MappingProxyType({})
|
|
drop_connection: bool = False
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Wire:
|
|
url: str
|
|
received: SimpleQueue[Request]
|
|
disconnected: SimpleQueue[str]
|
|
connected: SimpleQueue[str]
|
|
closed: SimpleQueue[str]
|
|
|
|
def drain(self) -> tuple[Request, ...]:
|
|
return tuple(self.received.get_nowait() for _ in range(self.received.qsize()))
|
|
|
|
def connections(self) -> int:
|
|
return self.connected.qsize()
|
|
|
|
def open_connections(self) -> int:
|
|
return self.connected.qsize() - self.closed.qsize()
|
|
|
|
|
|
def _await_release(
|
|
gate: threading.Event, closing: threading.Event, beat: Callable[[], None] | None = None, every: float = 0.05
|
|
) -> bool:
|
|
while not closing.is_set():
|
|
if gate.wait(timeout=every):
|
|
return True
|
|
if beat is not None:
|
|
beat()
|
|
return gate.is_set()
|
|
|
|
|
|
@contextmanager
|
|
def wire_server(
|
|
respond: Callable[[Request], Reply],
|
|
tls: ssl.SSLContext | None = None,
|
|
port: int = 0,
|
|
keep_alive: bool = False,
|
|
) -> Generator[Wire, None, None]:
|
|
"""Owned TCP peer; requests traverse the real HTTP client and serialization. With `keep_alive` the
|
|
peer honours HTTP/1.1 persistent connections so `connections()` counts the client's TCP sessions."""
|
|
received: Final[SimpleQueue[Request]] = SimpleQueue()
|
|
errors: Final[SimpleQueue[Exception]] = SimpleQueue()
|
|
disconnected: Final[SimpleQueue[str]] = SimpleQueue()
|
|
connected: Final[SimpleQueue[str]] = SimpleQueue()
|
|
closed: Final[SimpleQueue[str]] = SimpleQueue()
|
|
closing: Final = threading.Event()
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
timeout = 5
|
|
|
|
def setup(self) -> None:
|
|
super().setup()
|
|
connected.put(f"{self.client_address[0]}:{self.client_address[1]}")
|
|
|
|
def finish(self) -> None:
|
|
super().finish()
|
|
closed.put(f"{self.client_address[0]}:{self.client_address[1]}")
|
|
|
|
def write_chunk(self, chunk: bytes) -> None:
|
|
self.wfile.write(b"%x\r\n%s\r\n" % (len(chunk), chunk))
|
|
self.wfile.flush()
|
|
|
|
def respond(self) -> None:
|
|
headers: Final = {name.lower(): value for name, value in self.headers.items()}
|
|
try:
|
|
body: Final = read_body(headers, self.rfile)
|
|
except AbortedBody:
|
|
self.close_connection = True
|
|
disconnected.put(self.path)
|
|
return
|
|
request: Final = Request(self.command, self.path, headers, body)
|
|
received.put(request)
|
|
try:
|
|
reply = respond(request)
|
|
except Exception as error:
|
|
errors.put(error)
|
|
reply = Reply(status=500)
|
|
if reply.drop_connection:
|
|
self.close_connection = True
|
|
disconnected.put(request.target)
|
|
return
|
|
self.send_response(reply.status)
|
|
self.send_header("content-type", reply.content_type)
|
|
for name, value in reply.headers.items():
|
|
self.send_header(name, value)
|
|
if reply.chunks is None:
|
|
self.send_header("content-length", str(len(reply.body)))
|
|
else:
|
|
self.send_header("transfer-encoding", "chunked")
|
|
if not keep_alive:
|
|
self.send_header("connection", "close")
|
|
self.end_headers()
|
|
try:
|
|
if self.command == "HEAD":
|
|
self.wfile.flush()
|
|
elif reply.chunks is None:
|
|
self.wfile.write(reply.body)
|
|
else:
|
|
for index, chunk in enumerate(reply.chunks):
|
|
if reply.abort_after == index:
|
|
break
|
|
if (
|
|
index == 0
|
|
and reply.gate_before_first is not None
|
|
and not _await_release(reply.gate_before_first, closing)
|
|
):
|
|
break
|
|
self.write_chunk(chunk)
|
|
if (
|
|
index == 0
|
|
and reply.gate_after_first is not None
|
|
and not (
|
|
_await_release(reply.gate_after_first, closing)
|
|
if reply.heartbeat is None
|
|
else _await_release(
|
|
reply.gate_after_first,
|
|
closing,
|
|
partial(self.write_chunk, reply.heartbeat),
|
|
reply.pause_between_chunks,
|
|
)
|
|
)
|
|
):
|
|
break
|
|
if reply.pause_between_chunks and index + 1 < len(reply.chunks):
|
|
time.sleep(reply.pause_between_chunks)
|
|
else:
|
|
self.wfile.write(b"0\r\n\r\n")
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
disconnected.put(request.target)
|
|
except Exception as error:
|
|
errors.put(error)
|
|
self.close_connection = not keep_alive
|
|
|
|
do_POST = respond
|
|
do_PUT = respond
|
|
do_GET = respond
|
|
do_DELETE = respond
|
|
do_PATCH = respond
|
|
do_HEAD = respond
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
pass
|
|
|
|
class OwnedHTTPServer(ThreadingHTTPServer):
|
|
daemon_threads = False
|
|
request_queue_size = 128
|
|
|
|
def server_bind(self) -> None:
|
|
super().server_bind()
|
|
if tls is not None:
|
|
self.socket = tls.wrap_socket(self.socket, server_side=True)
|
|
|
|
with OwnedHTTPServer(("127.0.0.1", port), Handler) as server:
|
|
thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05})
|
|
thread.start()
|
|
try:
|
|
yield Wire(
|
|
f"{'https' if tls is not None else 'http'}://127.0.0.1:{server.server_port}",
|
|
received,
|
|
disconnected,
|
|
connected,
|
|
closed,
|
|
)
|
|
finally:
|
|
closing.set()
|
|
server.shutdown()
|
|
thread.join(timeout=6)
|
|
assert not thread.is_alive(), "Owned HTTP server survived cleanup"
|
|
server.server_close()
|
|
failure: Final = None if errors.empty() else errors.get_nowait()
|
|
assert failure is None, f"Owned HTTP peer failed: {failure!r}"
|