mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-24 05:45:11 +00:00
* 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
364 lines
12 KiB
Python
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}]
|