litellm/tests/unit/proxy/client/test_chat.py
Mateo Wang 68a8b12ab3
fix(proxy)!: end a chat completions stream with an error event when a guardrail blocks mid-stream (#46953)
* fix(proxy): end a chat completions stream with an error event when a guardrail blocks mid-stream

A streaming guardrail that raised an HTTPException after the first chunk re-raised it inside the SSE generator, so the 200 body was cut off with no finish_reason, no error event and no [DONE]. Build the error frame through sse_error_payload instead, keeping the status, message and structured detail, and encode values JSON cannot represent as strings so the frame always goes out.

* fix(proxy): keep early guardrail blocks on their HTTP status and end mid-stream errors with [DONE]

A guardrail block before the first chunk is raised again, so its response stays the
400 event-stream body it is on main. A mid-stream error event now carries the failure
hook's replacement error when one is returned, is followed by data: [DONE] on
OpenAI-format streams, and leaves the Gemini route's raise unchanged. Error details
JSON cannot encode are written as strings instead of breaking the event.

* fix(proxy): keep a per-chunk guardrail block on the first chunk on its event-stream 400

* fix(proxy): treat a keepalive ping as a started stream so a later guardrail block ends in-band

* fix(proxy): encode non-JSON guardrail details in early stream error frames

An early guardrail block whose detail holds a value JSON cannot encode sent
400 headers and then cut the body. The error frame now writes such a value as
its string form, the same as the mid-stream event. The keepalive test now runs
on a virtual clock instead of real sleeps.

* fix(proxy): show an in-band stream error event as an error in the CLI, Python client, and dashboard playgrounds

ChatClient.completions_stream raises StreamError on a data frame carrying an error, so litellm-proxy chat prints the block reason and keeps the partial reply out of history. The prompt playground and the Interactions playground read the same frame through a shared streamErrorMessage helper. Adds the audit's integration cells for chat stream error events, and makes the endpoint tests' failure-hook mocks return None so they check the real error payload.

* fix(proxy): print stream error text verbatim in the CLI and read split error events in the prompt playground

The CLI printed the server's error message as Rich markup, so a closing tag like [/pii] in a guardrail message crashed lite chat. The prompt playground now buffers partial lines across reads, ChatClient raises only on a non-empty error, StreamError redacts keys like the other client exceptions, and StreamError is exported from litellm.proxy.client

* fix(proxy): print CLI stream errors without Rich highlighting or emoji codes

* test(proxy): cover the CLI's plain-text 400 error print

* test(proxy): annotate the new client stream test locals as Final

* fix(proxy): send the failure hook replacement on Responses streams and before the first chunk

* fix(proxy): end a Responses stream with response.failed when it fails on a built-in exception

* fix(proxy): keep a failure hook replacement before the first chunk in-band so bare stream routes still answer it

A non-HTTP failure whose hook returns an HTTP error was raised before the first
chunk, which create_response turned into the hook's status but which a bare
StreamingResponse route such as /queue/chat/completions answered as a 200 with
zero bytes. Only the original HTTP errors raise now. The replacement goes out as
the stream's error event, which create_response still answers with the hook's
status as a JSON error.

Adds audit round 2's integration cells for failure hook replacements on chat and
Responses streams, built-in exceptions on Responses streams, and the Python client.

* test(proxy): cover a queue stream whose failure hook replaces a failure before the first chunk

The audit cell posts to /queue/chat/completions with a callback that raises before the first chunk and a failure hook that answers 403. It checks for a 200 event stream whose only event is the replacement, then one [DONE], with the callback's own message absent
2026-10-17 18:00:12 -07:00

364 lines
12 KiB
Python

import importlib
import importlib.util
from importlib.machinery import PathFinder
import json
import time
import site
import sys
from typing import Final
import pytest
import requests
from pydantic import JsonValue, ValidationError
from litellm.proxy.client.chat import ChatClient
from litellm.proxy.client.exceptions import StreamError, UnauthorizedError
def _load_http_mocking_responses():
"""Load the third-party `responses` package even if test collection creates
a top-level `responses` namespace package from `tests/unit/responses`.
"""
module = importlib.import_module("responses")
if hasattr(module, "activate"):
return module
for module_name in list(sys.modules):
if module_name == "responses" or module_name.startswith("responses."):
sys.modules.pop(module_name, None)
search_paths = []
try:
search_paths.extend(site.getsitepackages())
except AttributeError:
pass
user_site = site.getusersitepackages()
if isinstance(user_site, str):
search_paths.append(user_site)
else:
search_paths.extend(user_site)
spec = PathFinder.find_spec("responses", search_paths)
if spec is None or spec.loader is None:
raise ImportError("Unable to load the third-party `responses` package")
module = importlib.util.module_from_spec(spec)
sys.modules["responses"] = module
spec.loader.exec_module(module)
if not hasattr(module, "activate"):
raise ImportError("Unable to load the third-party `responses` package")
return module
responses = _load_http_mocking_responses()
@pytest.fixture
def base_url():
return "http://localhost:8000"
@pytest.fixture
def api_key():
return "test-api-key"
@pytest.fixture
def client(base_url, api_key):
return ChatClient(base_url=base_url, api_key=api_key)
@pytest.fixture
def sample_messages():
return [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Name 3 countries"},
]
def test_client_initialization(base_url, api_key):
"""Test that the ChatClient is properly initialized"""
client = ChatClient(base_url=base_url, api_key=api_key)
assert client._base_url == base_url
assert client._api_key == api_key
def test_client_initialization_strips_trailing_slash():
"""Test that the client properly strips trailing slashes from base_url during initialization"""
base_url = "http://localhost:8000/////"
client = ChatClient(base_url=base_url)
assert client._base_url == "http://localhost:8000"
def test_client_without_api_key(base_url):
"""Test that the client works without an API key"""
client = ChatClient(base_url=base_url)
assert client._api_key is None
def test_completions_request_creation(client, base_url, api_key, sample_messages):
"""Test that completions creates a request with correct URL, headers, and body"""
request = client.completions(
model="gpt-4",
messages=sample_messages,
temperature=0.7,
max_tokens=100,
return_request=True,
)
# Check request method and URL
assert request.method == "POST"
assert request.url == f"{base_url}/chat/completions"
# Check headers
assert request.headers["Content-Type"] == "application/json"
assert request.headers["Authorization"] == f"Bearer {api_key}"
# Check request body
assert request.json == {
"model": "gpt-4",
"messages": sample_messages,
"temperature": 0.7,
"max_tokens": 100,
}
def test_completions_minimal_request(client, sample_messages):
"""Test that completions works with only required parameters"""
request = client.completions(
model="gpt-4", messages=sample_messages, return_request=True
)
# Check request body has only required fields
assert request.json == {"model": "gpt-4", "messages": sample_messages}
def test_completions_all_parameters(client, sample_messages):
"""Test that completions accepts all optional parameters"""
request = client.completions(
model="gpt-4",
messages=sample_messages,
temperature=0.7,
top_p=0.9,
n=2,
max_tokens=100,
presence_penalty=0.5,
frequency_penalty=-0.5,
user="test-user",
return_request=True,
)
# Check all parameters are included in request body
assert request.json == {
"model": "gpt-4",
"messages": sample_messages,
"temperature": 0.7,
"top_p": 0.9,
"n": 2,
"max_tokens": 100,
"presence_penalty": 0.5,
"frequency_penalty": -0.5,
"user": "test-user",
}
@responses.activate
def test_completions_mock_response(client, sample_messages):
"""Test completions with a mocked successful response"""
mock_response = {
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677858242,
"model": "gpt-4",
"usage": {"prompt_tokens": 13, "completion_tokens": 7, "total_tokens": 20},
"choices": [
{
"message": {
"role": "assistant",
"content": "Hello! How can I help you today?",
},
"finish_reason": "stop",
"index": 0,
}
],
}
# Mock the POST request
responses.add(
responses.POST,
f"{client._base_url}/chat/completions",
json=mock_response,
status=200,
)
response = client.completions(model="gpt-4", messages=sample_messages)
assert response == mock_response
assert (
response["choices"][0]["message"]["content"]
== "Hello! How can I help you today?"
)
@responses.activate
def test_completions_unauthorized_error(client, sample_messages):
"""Test that completions raises UnauthorizedError for 401 responses"""
# Mock a 401 response
responses.add(
responses.POST,
f"{client._base_url}/chat/completions",
status=401,
json={"error": "Unauthorized"},
)
with pytest.raises(UnauthorizedError):
client.completions(model="gpt-4", messages=sample_messages)
@responses.activate
def test_completions_other_errors(client, sample_messages):
"""Test that completions raises HTTPError for other error responses"""
# Mock a 500 response
responses.add(
responses.POST,
f"{client._base_url}/chat/completions",
status=500,
json={"error": "Internal Server Error"},
)
with pytest.raises(requests.exceptions.HTTPError) as exc_info:
client.completions(model="gpt-4", messages=sample_messages)
assert exc_info.value.response.status_code == 500
def test_completions_gives_up_at_the_timeout_instead_of_hanging(hanging_server):
"""
A proxy that accepts the connection but never answers used to pin the caller's
process forever, since the request carried no timeout at all.
"""
client = ChatClient(base_url=hanging_server, api_key="sk-test", timeout=1)
started = time.monotonic()
with pytest.raises(requests.exceptions.Timeout):
client.completions(model="gpt-5.4", messages=[{"role": "user", "content": "hi"}])
assert time.monotonic() - started < 10
def test_completions_stream_gives_up_at_the_timeout_instead_of_hanging(hanging_server):
"""
The streaming call opens the response before reading chunks, so a proxy that never
sends its headers used to hang here forever too.
"""
client = ChatClient(base_url=hanging_server, api_key="sk-test", timeout=1)
started = time.monotonic()
with pytest.raises(requests.exceptions.Timeout):
next(client.completions_stream(model="gpt-5.4", messages=[{"role": "user", "content": "hi"}]))
assert time.monotonic() - started < 10
@pytest.mark.parametrize(
"body, expected",
[
(
b'data: {"choices": [{"delta": {"content": "Hel"}}]}\n\n'
b'data: {"choices": [{"delta": {"content": "lo"}}]}\n\n'
b"data: [DONE]\n\n",
[{"choices": [{"delta": {"content": "Hel"}}]}, {"choices": [{"delta": {"content": "lo"}}]}],
),
(
b': keep-alive\n\nevent: ping\n\ndata: not json\n\ndata: {"id": "caf\xc3\xa9"}\n\ndata: 7\n\n',
[{"id": "café"}, 7],
),
(b'data: {"id": 1}\n\ndata: [DONE] \n\ndata: {"id": 2}\n\n', [{"id": 1}]),
(b"", []),
],
)
@responses.activate
def test_completions_stream_yields_parsed_sse_chunks(client, base_url, sample_messages, body, expected):
responses.add(responses.POST, f"{base_url}/chat/completions", body=body, status=200)
assert list(client.completions_stream(model="gpt-4", messages=sample_messages)) == expected
@pytest.mark.parametrize(
"error_frame, error, message",
[
(
b'data: {"error": {"message": "blocked by guardrail", "type": "None", "code": "400"}}',
{"message": "blocked by guardrail", "type": "None", "code": "400"},
"blocked by guardrail",
),
(b'data: {"error": "upstream cut"}', "upstream cut", "upstream cut"),
(b'data: {"error": {"code": "500"}}', {"code": "500"}, "{'code': '500'}"),
],
)
@responses.activate
def test_completions_stream_raises_on_an_error_frame_after_partial_content(
client: ChatClient,
base_url: str,
sample_messages: list[dict[str, str]],
error_frame: bytes,
error: JsonValue,
message: str,
) -> None:
body: Final = b'data: {"choices": [{"delta": {"content": "Hel"}}]}\n\n' + error_frame + b"\n\ndata: [DONE]\n\n"
responses.add(responses.POST, f"{base_url}/chat/completions", body=body, status=200)
stream: Final = client.completions_stream(model="gpt-4", messages=sample_messages)
assert next(stream) == {"choices": [{"delta": {"content": "Hel"}}]}
with pytest.raises(StreamError) as raised:
next(stream)
assert raised.value.error == error
assert str(raised.value) == message
@pytest.mark.parametrize("error", [None, False, 0, "", {}])
@responses.activate
def test_completions_stream_yields_a_chunk_whose_error_is_empty(
client: ChatClient, base_url: str, sample_messages: list[dict[str, str]], error: JsonValue
) -> None:
chunk: Final = {"choices": [{"delta": {"content": "Hel"}}], "error": error}
body: Final = b"data: " + json.dumps(chunk).encode() + b"\n\ndata: [DONE]\n\n"
responses.add(responses.POST, f"{base_url}/chat/completions", body=body, status=200)
assert list(client.completions_stream(model="gpt-4", messages=sample_messages)) == [chunk]
def test_stream_error_redacts_a_key_in_its_message() -> None:
key: Final = "sk-" + "a1b2c3d4" * 4
error: Final = StreamError({"message": f"upstream rejected {key}"})
assert key not in str(error)
assert str(error).startswith("upstream rejected ")
@responses.activate
def test_completions_stream_rejects_bytes_that_are_not_utf8(client, base_url, sample_messages):
responses.add(responses.POST, f"{base_url}/chat/completions", body=b"data: \xff\n\n", status=200)
with pytest.raises(UnicodeDecodeError):
list(client.completions_stream(model="gpt-4", messages=sample_messages))
@pytest.mark.parametrize("line", ['data: {"id": 1}', 5, memoryview(b'data: {"id": 1}')])
@responses.activate
def test_completions_stream_rejects_lines_that_are_not_bytes(client, base_url, sample_messages, monkeypatch, line):
responses.add(responses.POST, f"{base_url}/chat/completions", body=b"", status=200)
monkeypatch.setattr(requests.Response, "iter_lines", lambda self: iter([line]))
with pytest.raises(ValidationError):
list(client.completions_stream(model="gpt-4", messages=sample_messages))
@responses.activate
def test_completions_stream_accepts_bytearray_lines(client, base_url, sample_messages, monkeypatch):
responses.add(responses.POST, f"{base_url}/chat/completions", body=b"", status=200)
monkeypatch.setattr(requests.Response, "iter_lines", lambda self: iter([bytearray(b'data: {"id": 1}')]))
assert list(client.completions_stream(model="gpt-4", messages=sample_messages)) == [{"id": 1}]