mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-09 07:06:51 +00:00
12d29a38a7
* test(proxy/proxy_server): pin forwarding routes (PR2) (#28887) * test(proxy): pin proxy_server.py forwarding-route behavior PR2 of the proxy_server.py behavior-pinning project: fills the 12 forwarding-route test files added by the harness PR with happy + error pins for all 52 LLM-facing routes (models, chat/completions, completions, embeddings, moderations, audio, assistants, threads, utils, model-info, model-metrics, queue). Every happy-path test asserts the full response dict via normalize() so the gate enforces real shape pinning rather than status codes. * test(proxy): drop task-plumbing comments from PR2 test files * test(proxy): tighten PR2 error-path status-code pins Apply the same review feedback Greptile gave on PR1 (#28856) and PR3 (#28850) to PR2's forwarding-route tests: - Replace permissive `>= 400` / `in (X, Y)` status assertions with the exact 500/405 the handler actually returns, so a regression that silently shifts the code now fails the pin. - Add a body-presence check alongside each tightened status assertion to satisfy _pin_check.py's no-status-only rule. --------- Co-authored-by: Claude <noreply@anthropic.com> * test(proxy): pin proxy_server.py non-route surface behavior (PR1) (#28856) * test(proxy): pin proxy_server.py non-route surface behavior (PR1) Fills the 7 PR1 placeholder files under tests/test_litellm/proxy/proxy_server/ with behavior pins for the non-route surface of proxy_server.py: lifecycle/init/shutdown, ProxyConfig class methods, DB-overlay config scrubbers, spend counters, background-health helpers, OpenAPI customization, exception handlers, and streaming-generator helpers. 233 tests cover 101 pin-list symbols (1+ happy + 1+ error each). New-tests-only coverage on litellm/proxy/proxy_server.py: 32.80% line / 20.91% branch (PR1 gate: 25% line / 18% branch). Full directory runs in ~22s with -n 4. Plan: https://www.notion.so/Plan-Pin-proxy_server-py-behavior-2026-05-25-36c43b8acdab81ee845fd5365128a2fc * test(proxy): address Greptile review comments on test_lifecycle.py - test_initialize_signature_is_async_with_expected_params: hard-code expected_param_count so a signature change actually trips the gate (previously both sides of the comparison were len(sig.parameters)). - test_check_request_disconnection_invalid_when_connected_times_out: patch asyncio.sleep so the test no longer spins for ~1.2 s of real wall-clock; timeout lowered to 0.05 s. --------- Co-authored-by: Claude <noreply@anthropic.com> * test(proxy/proxy_server): pin control-plane routes (PR3) (#28850) * test(proxy/proxy_server): pin misc routes (PR3, partial) Adds happy + error tests for the misc control-plane routes: GET /, /routes, /adaptive_router/state, /get_logo_url, /get_image, /get_favicon. Also gitignores .pin_list.txt (used by the pin gate). * test(proxy/proxy_server): pin login/SSO routes (PR3, partial) Adds happy + error tests for the 5 login/SSO control-plane routes: GET /fallback/login, POST /login, POST /v2/login, POST /v3/login, POST /v3/login/exchange. Mocks authenticate_user and create_ui_token_object at their imported location. * test(proxy/proxy_server): pin onboarding routes (PR3, partial) Adds happy + error tests for the 2 onboarding control-plane routes: GET /onboarding/get_token, POST /onboarding/claim_token. Wires a MagicMock async context manager for prisma_client.db.tx() and signs the onboarding JWT with the patched master_key. * test(proxy/proxy_server): pin model_cost_map reload routes (PR3, partial) Adds happy + error tests for the 5 model-cost-map control-plane routes: POST /reload/model_cost_map, POST|DELETE|GET /schedule/model_cost_map_reload(/status), GET /model/cost_map/source. Attaches litellm_config to mock_prisma per-test (the table is not in the default _PRISMA_TABLES fixture). * test(proxy/proxy_server): pin anthropic_beta_headers reload routes (PR3, partial) Adds happy + error tests for the 4 anthropic-beta-headers control-plane routes: POST /reload/anthropic_beta_headers, POST|DELETE|GET /schedule/anthropic_beta_headers_reload(/status). Stubs db.litellm_config (not in default _PRISMA_TABLES) and monkeypatches reload_beta_headers_config so no network calls fire. * test(proxy/proxy_server): pin invitation routes (PR3, partial) Adds happy + error tests for the 4 invitation control-plane routes: POST /invitation/new, GET /invitation/info, POST /invitation/update, POST /invitation/delete. Patches _user_has_admin_privileges / _user_has_admin_view to avoid extensive get_user_object mocking. * test(proxy/proxy_server): pin config CRUD routes (PR3, partial) Adds happy + error tests for the 8 config-CRUD control-plane routes: POST /config/update, POST|GET /config/field/update|info, GET /config/list, POST /config/field/delete, POST /config/callback/delete, GET /get/config/callbacks, GET /config/yaml. Attaches litellm_config to mock_prisma per-test. * test(proxy/proxy_server): tighten pin assertions per review - test_routes_misc.py: `b"" in response.content` is trivially true; replace with `len(response.content) > 0` so an empty 405 body trips the gate. - test_routes_login_sso.py: `len(response.content) >= 0` is trivially true; tighten to `> 0`. - test_routes_anthropic_beta.py: replace brittle string-literal checks on the serialized JSON (`'"interval_hours": 12' in payload`) with `json.loads` + dict access so the assertion survives any serializer spacing. - test_routes_config.py: `assert status_code in (404, 500)` was too permissive; the handler re-raises HTTPException(404) verbatim, so pin 404 strictly. --------- Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
556 lines
17 KiB
Python
556 lines
17 KiB
Python
"""Behavior pins for the proxy_server streaming helpers.
|
|
|
|
Pins covered:
|
|
- ``data_generator``
|
|
- ``async_assistants_data_generator``
|
|
- ``_get_client_requested_model_for_streaming``
|
|
- ``_restamp_streaming_chunk_model``
|
|
- ``_fast_serialize_simple_model_response_stream``
|
|
- ``_serialize_streaming_chunk``
|
|
- ``_apply_streaming_chunk_hooks``
|
|
- ``_format_streaming_sse_chunk``
|
|
- ``async_data_generator``
|
|
- ``select_data_generator``
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, AsyncIterator
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
import litellm.proxy.proxy_server as ps
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.proxy_server import (
|
|
_apply_streaming_chunk_hooks,
|
|
_fast_serialize_simple_model_response_stream,
|
|
_format_streaming_sse_chunk,
|
|
_get_client_requested_model_for_streaming,
|
|
_restamp_streaming_chunk_model,
|
|
_serialize_streaming_chunk,
|
|
async_assistants_data_generator,
|
|
async_data_generator,
|
|
data_generator,
|
|
select_data_generator,
|
|
)
|
|
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices, Usage
|
|
|
|
from .conftest import normalize
|
|
|
|
|
|
def _user_auth() -> UserAPIKeyAuth:
|
|
return UserAPIKeyAuth(api_key="sk-test-key", user_id="u")
|
|
|
|
|
|
def _simple_chunk(model: str = "gpt-4", content: str = "hi") -> ModelResponseStream:
|
|
return ModelResponseStream(
|
|
id="chatcmpl-test",
|
|
choices=[
|
|
StreamingChoices(
|
|
finish_reason=None,
|
|
index=0,
|
|
delta=Delta(content=content, role="assistant"),
|
|
)
|
|
],
|
|
created=0,
|
|
model=model,
|
|
object="chat.completion.chunk",
|
|
)
|
|
|
|
|
|
async def _async_iter(items):
|
|
for it in items:
|
|
yield it
|
|
|
|
|
|
async def _async_iter_raises(exc: Exception):
|
|
# yield once then raise — exercises the mid-stream failure branch
|
|
yield _simple_chunk(content="partial")
|
|
raise exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# data_generator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_data_generator_yields_sse_lines_for_dict_chunks():
|
|
class DictChunk:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def dict(self):
|
|
return self._payload
|
|
|
|
chunks = [
|
|
DictChunk({"id": "1", "object": "chat.completion.chunk", "model": "gpt-4"}),
|
|
DictChunk({"id": "2", "object": "chat.completion.chunk", "model": "gpt-4"}),
|
|
]
|
|
out = list(data_generator(chunks))
|
|
|
|
assert len(out) == 2
|
|
payloads = [json.loads(line.removeprefix("data: ").rstrip("\n\n")) for line in out]
|
|
assert normalize(payloads[0]) == {
|
|
"id": "<VOLATILE>",
|
|
"object": "chat.completion.chunk",
|
|
"model": "gpt-4",
|
|
}
|
|
assert payloads[1]["model"] == "gpt-4"
|
|
|
|
|
|
def test_data_generator_fallback_when_dict_raises_exception():
|
|
class BadChunk:
|
|
def dict(self):
|
|
raise RuntimeError("cannot serialize")
|
|
|
|
# When .dict() raises, the inner json.dumps(chunk) on a non-JSON-serializable
|
|
# instance also raises — the generator does not catch the second failure.
|
|
with pytest.raises((TypeError, RuntimeError)):
|
|
list(data_generator([BadChunk()]))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# async_assistants_data_generator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeAssistantsStream:
|
|
"""Mimic the async-context-manager + async-iterable shape of the
|
|
assistants streaming object (e.g. AssistantEventHandler)."""
|
|
|
|
def __init__(self, chunks):
|
|
self._chunks = chunks
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def __aiter__(self):
|
|
async def _gen():
|
|
for c in self._chunks:
|
|
yield c
|
|
|
|
return _gen()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_assistants_data_generator_yields_sse_and_done(monkeypatch):
|
|
chunk = _simple_chunk(content="hello")
|
|
|
|
async def _passthrough_hook(*, user_api_key_dict, response, data, **kwargs):
|
|
return response
|
|
|
|
monkeypatch.setattr(
|
|
ps.proxy_logging_obj,
|
|
"async_post_call_streaming_hook",
|
|
_passthrough_hook,
|
|
)
|
|
|
|
stream = _FakeAssistantsStream([chunk])
|
|
out = []
|
|
async for line in async_assistants_data_generator(
|
|
response=stream,
|
|
user_api_key_dict=_user_auth(),
|
|
request_data={},
|
|
):
|
|
out.append(line)
|
|
|
|
assert out[-1] == "data: [DONE]\n\n"
|
|
body = json.loads(out[0].removeprefix("data: ").rstrip("\n\n"))
|
|
assert normalize(body) == {
|
|
"id": "<VOLATILE>",
|
|
"created": "<VOLATILE>",
|
|
"model": "gpt-4",
|
|
"object": "chat.completion.chunk",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"delta": {"content": "hello", "role": "assistant"},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_assistants_data_generator_hook_failure_yields_error_chunk(
|
|
monkeypatch,
|
|
):
|
|
async def _boom_hook(*args, **kwargs):
|
|
raise RuntimeError("hook exploded")
|
|
|
|
async def _noop_failure(*args, **kwargs):
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
ps.proxy_logging_obj, "async_post_call_streaming_hook", _boom_hook
|
|
)
|
|
monkeypatch.setattr(ps.proxy_logging_obj, "post_call_failure_hook", _noop_failure)
|
|
|
|
stream = _FakeAssistantsStream([_simple_chunk()])
|
|
out = []
|
|
async for line in async_assistants_data_generator(
|
|
response=stream,
|
|
user_api_key_dict=_user_auth(),
|
|
request_data={},
|
|
):
|
|
out.append(line)
|
|
|
|
assert any("error" in line for line in out)
|
|
assert out[-1].startswith('data: {"error":')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_client_requested_model_for_streaming
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_client_requested_model_for_streaming_prefers_client_requested():
|
|
request_data = {
|
|
"_litellm_client_requested_model": "gpt-4",
|
|
"model": "openai/internal-gpt-4",
|
|
"litellm_call_id": "abc",
|
|
}
|
|
result = _get_client_requested_model_for_streaming(request_data)
|
|
assert result == "gpt-4"
|
|
|
|
snapshot = {
|
|
"result": result,
|
|
"client_field_preserved": request_data["_litellm_client_requested_model"],
|
|
"model_field_preserved": request_data["model"],
|
|
}
|
|
assert normalize(snapshot) == {
|
|
"result": "gpt-4",
|
|
"client_field_preserved": "gpt-4",
|
|
"model_field_preserved": "openai/internal-gpt-4",
|
|
}
|
|
|
|
|
|
def test_get_client_requested_model_for_streaming_falls_back_to_model_field():
|
|
result = _get_client_requested_model_for_streaming({"model": "claude-sonnet"})
|
|
assert result == "claude-sonnet"
|
|
|
|
|
|
def test_get_client_requested_model_for_streaming_missing_returns_empty_invalid():
|
|
"""When neither key is set or values are non-strings, the helper returns ""
|
|
rather than raising — callers depend on this to skip restamping."""
|
|
assert _get_client_requested_model_for_streaming({}) == ""
|
|
assert _get_client_requested_model_for_streaming({"model": 123}) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _restamp_streaming_chunk_model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_restamp_streaming_chunk_model_overrides_model_on_basemodel():
|
|
chunk = _simple_chunk(model="openai/internal-x")
|
|
new_chunk, logged = _restamp_streaming_chunk_model(
|
|
chunk=chunk,
|
|
requested_model_from_client="gpt-4",
|
|
request_data={"litellm_call_id": "id-1"},
|
|
model_mismatch_logged=False,
|
|
)
|
|
snapshot = {
|
|
"model": new_chunk.model,
|
|
"logged": logged,
|
|
"same_object": new_chunk is chunk,
|
|
}
|
|
assert snapshot == {"model": "gpt-4", "logged": True, "same_object": True}
|
|
|
|
|
|
def test_restamp_streaming_chunk_model_overrides_model_on_dict():
|
|
chunk = {"model": "internal", "choices": []}
|
|
new_chunk, logged = _restamp_streaming_chunk_model(
|
|
chunk=chunk,
|
|
requested_model_from_client="gpt-4",
|
|
request_data={},
|
|
model_mismatch_logged=True,
|
|
)
|
|
assert new_chunk["model"] == "gpt-4"
|
|
assert logged is True
|
|
|
|
|
|
def test_restamp_streaming_chunk_model_invalid_chunk_type_unchanged():
|
|
"""For a non-BaseModel, non-dict chunk the helper returns it as-is
|
|
along with the original ``model_mismatch_logged`` flag."""
|
|
chunk = "raw string chunk"
|
|
new_chunk, logged = _restamp_streaming_chunk_model(
|
|
chunk=chunk,
|
|
requested_model_from_client="gpt-4",
|
|
request_data={},
|
|
model_mismatch_logged=False,
|
|
)
|
|
assert new_chunk == "raw string chunk"
|
|
assert logged is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _fast_serialize_simple_model_response_stream
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fast_serialize_simple_model_response_stream_returns_bytes_payload():
|
|
chunk = _simple_chunk()
|
|
result = _fast_serialize_simple_model_response_stream(chunk)
|
|
assert isinstance(result, bytes)
|
|
payload = json.loads(result)
|
|
assert normalize(payload) == {
|
|
"id": "<VOLATILE>",
|
|
"object": "chat.completion.chunk",
|
|
"created": "<VOLATILE>",
|
|
"model": "gpt-4",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"delta": {"role": "assistant", "content": "hi"},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def test_fast_serialize_simple_model_response_stream_with_usage_returns_none_invalid():
|
|
"""Fast path bails (returns None) when ``usage`` is populated — the slow
|
|
path is required to preserve usage fields. Returning None here is the
|
|
"I cannot handle this" sentinel, not a hard error."""
|
|
chunk = _simple_chunk()
|
|
chunk.usage = Usage(prompt_tokens=1, completion_tokens=1, total_tokens=2)
|
|
assert _fast_serialize_simple_model_response_stream(chunk) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _serialize_streaming_chunk
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_serialize_streaming_chunk_simple_uses_fast_path_bytes():
|
|
result = _serialize_streaming_chunk(_simple_chunk())
|
|
assert isinstance(result, bytes)
|
|
payload = json.loads(result)
|
|
assert normalize(payload) == {
|
|
"id": "<VOLATILE>",
|
|
"object": "chat.completion.chunk",
|
|
"created": "<VOLATILE>",
|
|
"model": "gpt-4",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"delta": {"role": "assistant", "content": "hi"},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def test_serialize_streaming_chunk_invalid_input_raises_attribute_error():
|
|
"""The helper is typed as ``BaseModel`` — handing it a plain dict trips
|
|
the attribute-access path (no ``model_dump_json``)."""
|
|
with pytest.raises(AttributeError):
|
|
_serialize_streaming_chunk({"not": "a model"}) # type: ignore[arg-type]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _apply_streaming_chunk_hooks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_streaming_chunk_hooks_appends_to_str_so_far(monkeypatch):
|
|
chunk = _simple_chunk(content="abc")
|
|
|
|
async def _passthrough(*, user_api_key_dict, response, data, str_so_far=None):
|
|
return response
|
|
|
|
monkeypatch.setattr(
|
|
ps.proxy_logging_obj, "async_post_call_streaming_hook", _passthrough
|
|
)
|
|
|
|
new_chunk, new_str = await _apply_streaming_chunk_hooks(
|
|
chunk=chunk,
|
|
user_api_key_dict=_user_auth(),
|
|
request_data={},
|
|
str_so_far="prior:",
|
|
)
|
|
|
|
observed = {
|
|
"chunk_is_basemodel": isinstance(new_chunk, ModelResponseStream),
|
|
"str_so_far": new_str,
|
|
"grew": len(new_str) > len("prior:"),
|
|
}
|
|
assert observed == {
|
|
"chunk_is_basemodel": True,
|
|
"str_so_far": "prior:abc",
|
|
"grew": True,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_streaming_chunk_hooks_hook_raises_exception(monkeypatch):
|
|
async def _boom(*args, **kwargs):
|
|
raise RuntimeError("hook failed")
|
|
|
|
monkeypatch.setattr(ps.proxy_logging_obj, "async_post_call_streaming_hook", _boom)
|
|
|
|
with pytest.raises(RuntimeError):
|
|
await _apply_streaming_chunk_hooks(
|
|
chunk=_simple_chunk(),
|
|
user_api_key_dict=_user_auth(),
|
|
request_data={},
|
|
str_so_far="",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _format_streaming_sse_chunk
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_format_streaming_sse_chunk_handles_bytes_and_str_shapes():
|
|
bytes_out = _format_streaming_sse_chunk(b'{"a":1}')
|
|
str_out = _format_streaming_sse_chunk('{"a":1}')
|
|
|
|
snapshot = {
|
|
"bytes_out": bytes_out,
|
|
"str_out": str_out,
|
|
"bytes_starts_with_data": bytes_out.startswith(b"data: "),
|
|
}
|
|
assert snapshot == {
|
|
"bytes_out": b'data: {"a":1}\n\n',
|
|
"str_out": 'data: {"a":1}\n\n',
|
|
"bytes_starts_with_data": True,
|
|
}
|
|
|
|
|
|
def test_format_streaming_sse_chunk_invalid_empty_string_still_wraps():
|
|
"""Edge case: empty string still gets the ``data: \\n\\n`` wrapping
|
|
— clients expect SSE shape even on empty payloads."""
|
|
result = _format_streaming_sse_chunk("")
|
|
assert result == "data: \n\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# async_data_generator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _patch_logging_flags(monkeypatch, needs_wrap=False, needs_per_chunk=False):
|
|
monkeypatch.setattr(
|
|
ps.proxy_logging_obj,
|
|
"needs_iterator_wrap",
|
|
lambda: needs_wrap,
|
|
)
|
|
monkeypatch.setattr(
|
|
ps.proxy_logging_obj,
|
|
"needs_per_chunk_streaming_hook",
|
|
lambda: needs_per_chunk,
|
|
)
|
|
# ``_fire_deferred_stream_logging`` is a classmethod — patch the
|
|
# underlying function so the no-wrap branch is a no-op rather than
|
|
# touching real logging globals.
|
|
monkeypatch.setattr(
|
|
ps.ProxyLogging,
|
|
"_fire_deferred_stream_logging",
|
|
staticmethod(lambda request_data: None),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_data_generator_yields_sse_chunks_and_done(monkeypatch):
|
|
_patch_logging_flags(monkeypatch)
|
|
|
|
response = _async_iter([_simple_chunk(content="hello")])
|
|
out = []
|
|
async for line in async_data_generator(
|
|
response=response,
|
|
user_api_key_dict=_user_auth(),
|
|
request_data={"model": "gpt-4"},
|
|
):
|
|
out.append(line)
|
|
|
|
assert out[-1] == "data: [DONE]\n\n"
|
|
# First chunk is bytes (fast path) wrapped via _format_streaming_sse_chunk.
|
|
first = out[0]
|
|
assert isinstance(first, bytes)
|
|
payload = json.loads(first.removeprefix(b"data: ").rstrip(b"\n\n"))
|
|
assert normalize(payload) == {
|
|
"id": "<VOLATILE>",
|
|
"object": "chat.completion.chunk",
|
|
"created": "<VOLATILE>",
|
|
"model": "gpt-4",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"delta": {"role": "assistant", "content": "hello"},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_data_generator_mid_stream_exception_yields_error_payload(
|
|
monkeypatch,
|
|
):
|
|
_patch_logging_flags(monkeypatch)
|
|
|
|
async def _noop_failure(*args, **kwargs):
|
|
return None
|
|
|
|
monkeypatch.setattr(ps.proxy_logging_obj, "post_call_failure_hook", _noop_failure)
|
|
|
|
response = _async_iter_raises(RuntimeError("upstream blew up"))
|
|
out = []
|
|
async for line in async_data_generator(
|
|
response=response,
|
|
user_api_key_dict=_user_auth(),
|
|
request_data={},
|
|
):
|
|
out.append(line)
|
|
|
|
# First entry is the successful "partial" chunk (bytes), last is the error.
|
|
assert any(
|
|
isinstance(item, str) and item.startswith('data: {"error":') for item in out
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# select_data_generator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_data_generator_returns_async_generator(monkeypatch):
|
|
_patch_logging_flags(monkeypatch)
|
|
|
|
response = _async_iter([_simple_chunk()])
|
|
gen = select_data_generator(
|
|
response=response,
|
|
user_api_key_dict=_user_auth(),
|
|
request_data={"model": "gpt-4"},
|
|
)
|
|
|
|
# Drain to confirm it really is an async iterator emitting SSE shape.
|
|
collected = []
|
|
async for line in gen:
|
|
collected.append(line)
|
|
|
|
snapshot = {
|
|
"is_async_iterable": hasattr(gen, "__aiter__"),
|
|
"yielded_at_least_one": len(collected) >= 1,
|
|
"ends_with_done": collected[-1] == "data: [DONE]\n\n",
|
|
}
|
|
assert snapshot == {
|
|
"is_async_iterable": True,
|
|
"yielded_at_least_one": True,
|
|
"ends_with_done": True,
|
|
}
|
|
|
|
|
|
def test_select_data_generator_missing_required_kwarg_raises_type_error():
|
|
"""``select_data_generator`` requires all three keyword args — calling
|
|
without ``request_data`` raises TypeError at the wrapper, before any
|
|
streaming starts."""
|
|
with pytest.raises(TypeError):
|
|
select_data_generator(response=_async_iter([]), user_api_key_dict=_user_auth()) # type: ignore[call-arg]
|