mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/lmproto: extract pure SSE helpers; drop fake response tests
Test sse_data_payload and http_error_message with plain bytes instead of a cast-heavy AsyncResponse double.
This commit is contained in:
@@ -25,6 +25,10 @@ if TYPE_CHECKING:
|
|||||||
from niquests.models import AsyncResponse
|
from niquests.models import AsyncResponse
|
||||||
|
|
||||||
|
|
||||||
|
_HTTP_ERROR = 400
|
||||||
|
_ERROR_BODY_PREVIEW = 500
|
||||||
|
|
||||||
|
|
||||||
async def _close_async_response(resp: AsyncResponse) -> None:
|
async def _close_async_response(resp: AsyncResponse) -> None:
|
||||||
"""Close a streaming response; support sync or async close()."""
|
"""Close a streaming response; support sync or async close()."""
|
||||||
for name in ("aclose", "close"):
|
for name in ("aclose", "close"):
|
||||||
@@ -37,6 +41,43 @@ async def _close_async_response(resp: AsyncResponse) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def sse_data_payload(line: bytes) -> bytes | None | Literal[False]:
|
||||||
|
"""Parse one SSE line.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``bytes`` payload after ``data: ``,
|
||||||
|
``None`` to skip (comment/empty/non-data),
|
||||||
|
``False`` when the stream is finished (``[DONE]``).
|
||||||
|
"""
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
if stripped in {b"data: [DONE]", b"[DONE]"}:
|
||||||
|
return False
|
||||||
|
if not stripped.startswith(b"data: "):
|
||||||
|
return None
|
||||||
|
payload = stripped[6:].strip()
|
||||||
|
if payload == b"[DONE]":
|
||||||
|
return False
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def http_error_message(status_code: int, body: bytes | str | None = None) -> str | None:
|
||||||
|
"""Return an error string for HTTP ``status_code`` >= 400, else ``None``."""
|
||||||
|
if status_code < _HTTP_ERROR:
|
||||||
|
return None
|
||||||
|
msg = f"chat completions HTTP {status_code}"
|
||||||
|
if body is None:
|
||||||
|
return msg
|
||||||
|
if isinstance(body, (bytes, bytearray)):
|
||||||
|
text = bytes(body[:_ERROR_BODY_PREVIEW]).decode(errors="replace")
|
||||||
|
else:
|
||||||
|
text = str(body)[:_ERROR_BODY_PREVIEW]
|
||||||
|
return f"{msg}: {text}" if text else msg
|
||||||
|
|
||||||
|
|
||||||
class BaseOpenAIClient:
|
class BaseOpenAIClient:
|
||||||
session: AsyncSession
|
session: AsyncSession
|
||||||
encoder: msgspec.json.Encoder
|
encoder: msgspec.json.Encoder
|
||||||
@@ -54,22 +95,20 @@ class BaseOpenAIClient:
|
|||||||
|
|
||||||
async def _ensure_ok(self, resp: object) -> None:
|
async def _ensure_ok(self, resp: object) -> None:
|
||||||
"""Raise if the HTTP response is an error (stream or non-stream)."""
|
"""Raise if the HTTP response is an error (stream or non-stream)."""
|
||||||
http_error = 400
|
|
||||||
status = getattr(resp, "status_code", None)
|
status = getattr(resp, "status_code", None)
|
||||||
if not isinstance(status, int) or status < http_error:
|
if not isinstance(status, int):
|
||||||
return
|
return
|
||||||
body = ""
|
|
||||||
content = getattr(resp, "content", None)
|
content = getattr(resp, "content", None)
|
||||||
if content is not None:
|
body: bytes | str | None = None
|
||||||
if isinstance(content, (bytes, bytearray)):
|
if isinstance(content, bytes | bytearray):
|
||||||
body = bytes(content[:500]).decode(errors="replace")
|
body = bytes(content)
|
||||||
else:
|
elif isinstance(content, str):
|
||||||
body = str(content)[:500]
|
body = content
|
||||||
|
msg = http_error_message(status, body)
|
||||||
|
if msg is None:
|
||||||
|
return
|
||||||
if hasattr(resp, "close") or hasattr(resp, "aclose"):
|
if hasattr(resp, "close") or hasattr(resp, "aclose"):
|
||||||
await _close_async_response(cast("AsyncResponse", resp))
|
await _close_async_response(cast("AsyncResponse", resp))
|
||||||
msg = f"chat completions HTTP {status}"
|
|
||||||
if body:
|
|
||||||
msg = f"{msg}: {body}"
|
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
async def _parse_sse(self, resp: AsyncResponse) -> AsyncIterator[ChatCompletionChunk]:
|
async def _parse_sse(self, resp: AsyncResponse) -> AsyncIterator[ChatCompletionChunk]:
|
||||||
@@ -79,17 +118,12 @@ class BaseOpenAIClient:
|
|||||||
async for raw in resp.iter_lines():
|
async for raw in resp.iter_lines():
|
||||||
if not raw:
|
if not raw:
|
||||||
continue
|
continue
|
||||||
line = bytes(raw).strip()
|
parsed = sse_data_payload(bytes(raw))
|
||||||
if line in {b"data: [DONE]", b"[DONE]"}:
|
if parsed is None:
|
||||||
break
|
|
||||||
if not line.startswith(b"data: "):
|
|
||||||
continue
|
continue
|
||||||
payload = line[6:].strip()
|
if parsed is False:
|
||||||
if payload == b"[DONE]":
|
|
||||||
break
|
break
|
||||||
if not payload:
|
yield self.chunk_decoder.decode(parsed)
|
||||||
continue
|
|
||||||
yield self.chunk_decoder.decode(payload)
|
|
||||||
finally:
|
finally:
|
||||||
await _close_async_response(resp)
|
await _close_async_response(resp)
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,71 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, cast
|
from plyngent.lmproto.openai_compatible.client import http_error_message, sse_data_payload
|
||||||
|
from plyngent.lmproto.openai_compatible.model import ChatCompletionChunk
|
||||||
from plyngent.lmproto.openai_compatible.client import BaseOpenAIClient
|
|
||||||
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import AsyncIterator
|
|
||||||
|
|
||||||
from niquests.models import AsyncResponse
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeResp:
|
def test_sse_data_payload_done_variants() -> None:
|
||||||
status_code: int
|
assert sse_data_payload(b"data: [DONE]") is False
|
||||||
closed: bool
|
assert sse_data_payload(b"[DONE]") is False
|
||||||
content: bytes
|
assert sse_data_payload(b"data: [DONE]\n") is False
|
||||||
_lines: list[bytes]
|
assert sse_data_payload(b"data: [DONE] ") is False
|
||||||
|
|
||||||
def __init__(self, lines: list[bytes], *, status_code: int = 200) -> None:
|
|
||||||
self.status_code = status_code
|
|
||||||
self.closed = False
|
|
||||||
self._lines = lines
|
|
||||||
self.content = b""
|
|
||||||
|
|
||||||
async def iter_lines(self) -> AsyncIterator[bytes]:
|
|
||||||
for line in self._lines:
|
|
||||||
yield line
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.closed = True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_parse_sse_stops_at_done() -> None:
|
def test_sse_data_payload_skip() -> None:
|
||||||
client = BaseOpenAIClient(OpenAIConfig(access_key_or_token="t", base_url="http://x"))
|
assert sse_data_payload(b"") is None
|
||||||
# Minimal OpenAI-style chunk payload
|
assert sse_data_payload(b": comment") is None
|
||||||
chunk = (
|
assert sse_data_payload(b"event: message") is None
|
||||||
|
assert sse_data_payload(b"data: ") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_data_payload_json() -> None:
|
||||||
|
line = (
|
||||||
b'data: {"id":"1","object":"chat.completion.chunk","created":0,'
|
b'data: {"id":"1","object":"chat.completion.chunk","created":0,'
|
||||||
b'"model":"m","choices":[{"index":0,"delta":{"content":"hi"},'
|
b'"model":"m","choices":[{"index":0,"delta":{"content":"hi"},'
|
||||||
b'"finish_reason":null}]}'
|
b'"finish_reason":null}]}'
|
||||||
)
|
)
|
||||||
resp = _FakeResp([chunk, b"", b"data: [DONE]", b"data: should-not-parse"])
|
payload = sse_data_payload(line)
|
||||||
out = [c async for c in client._parse_sse(cast("AsyncResponse", cast("object", resp)))]
|
assert isinstance(payload, bytes)
|
||||||
assert len(out) == 1
|
chunk = msgspec_decode_chunk(payload)
|
||||||
assert out[0].choices[0].delta.content == "hi"
|
assert chunk.choices[0].delta.content == "hi"
|
||||||
assert resp.closed is True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_parse_sse_http_error() -> None:
|
def msgspec_decode_chunk(payload: bytes) -> ChatCompletionChunk:
|
||||||
import pytest
|
import msgspec
|
||||||
|
|
||||||
client = BaseOpenAIClient(OpenAIConfig(access_key_or_token="t", base_url="http://x"))
|
return msgspec.json.decode(payload, type=ChatCompletionChunk)
|
||||||
resp = _FakeResp([], status_code=500)
|
|
||||||
resp.content = b'{"error":"boom"}'
|
|
||||||
with pytest.raises(RuntimeError, match="500"):
|
def test_sse_stream_stops_at_done() -> None:
|
||||||
_ = [c async for c in client._parse_sse(cast("AsyncResponse", cast("object", resp)))]
|
"""Simulate a line iterator: only pre-DONE data lines are decoded."""
|
||||||
assert resp.closed is True
|
lines = [
|
||||||
|
b'data: {"id":"1","object":"chat.completion.chunk","created":0,'
|
||||||
|
b'"model":"m","choices":[{"index":0,"delta":{"content":"a"},'
|
||||||
|
b'"finish_reason":null}]}',
|
||||||
|
b"",
|
||||||
|
b"data: [DONE]",
|
||||||
|
b'data: {"id":"1","choices":[{"delta":{"content":"never"}}]}',
|
||||||
|
]
|
||||||
|
payloads: list[bytes] = []
|
||||||
|
for line in lines:
|
||||||
|
parsed = sse_data_payload(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
if parsed is False:
|
||||||
|
break
|
||||||
|
payloads.append(parsed)
|
||||||
|
assert len(payloads) == 1
|
||||||
|
assert msgspec_decode_chunk(payloads[0]).choices[0].delta.content == "a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_error_message() -> None:
|
||||||
|
assert http_error_message(200) is None
|
||||||
|
assert http_error_message(399) is None
|
||||||
|
err = http_error_message(500, b'{"error":"boom"}')
|
||||||
|
assert err is not None
|
||||||
|
assert "500" in err
|
||||||
|
assert "boom" in err
|
||||||
|
err503 = http_error_message(503, "unavailable")
|
||||||
|
assert err503 is not None
|
||||||
|
assert "503" in err503
|
||||||
|
assert "unavailable" in err503
|
||||||
|
|||||||
Reference in New Issue
Block a user