From d5e7c3aa530ef28fcd56b2756cff4fdf85798491 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Wed, 15 Jul 2026 10:41:33 +0800 Subject: [PATCH] core/lmproto: await async AsyncResponse.content niquests exposes content as an async property; reading it without await left a coroutine warning and skipped the body. Only load body on HTTP errors for streams so SSE is not consumed early. --- .../lmproto/deepseek/openai_compat/client.py | 13 ++++++-- .../lmproto/openai_compatible/client.py | 31 ++++++++++++++----- tests/test_lmproto/test_sse_parse.py | 23 +++++++++++++- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/plyngent/lmproto/deepseek/openai_compat/client.py b/src/plyngent/lmproto/deepseek/openai_compat/client.py index f3b7e53..cb57f38 100644 --- a/src/plyngent/lmproto/deepseek/openai_compat/client.py +++ b/src/plyngent/lmproto/deepseek/openai_compat/client.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Literal, overload import msgspec -from ...openai_compatible.client import BaseOpenAIClient +from ...openai_compatible.client import BaseOpenAIClient, read_response_body if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -45,5 +45,12 @@ class DeepseekOpenAIClient(BaseOpenAIClient): headers={"Content-Type": "application/json"}, stream=False, ) - assert resp.content is not None - return self.decoder.decode(resp.content) + await self._ensure_ok(resp) + body = await read_response_body(resp) + if body is None: + msg = "chat completions response body is empty" + raise RuntimeError(msg) + if not isinstance(body, (bytes, bytearray)): + msg = f"chat completions response body has unexpected type {type(body)!r}" + raise TypeError(msg) + return self.decoder.decode(bytes(body)) diff --git a/src/plyngent/lmproto/openai_compatible/client.py b/src/plyngent/lmproto/openai_compatible/client.py index fce7bd1..fa2f048 100644 --- a/src/plyngent/lmproto/openai_compatible/client.py +++ b/src/plyngent/lmproto/openai_compatible/client.py @@ -41,6 +41,18 @@ async def _close_async_response(resp: AsyncResponse) -> None: return +async def read_response_body(resp: object) -> bytes | str | None: + """Read response body; niquests ``AsyncResponse.content`` is an async property.""" + content = getattr(resp, "content", None) + if inspect.isawaitable(content): + content = await content + if isinstance(content, bytes | bytearray): + return bytes(content) + if isinstance(content, str): + return content + return None + + def sse_data_payload(line: bytes) -> bytes | None | Literal[False]: """Parse one SSE line. @@ -98,12 +110,11 @@ class BaseOpenAIClient: status = getattr(resp, "status_code", None) if not isinstance(status, int): return - content = getattr(resp, "content", None) + # Only pull the body for error responses — success streams must not + # await content (that would consume the SSE body). body: bytes | str | None = None - if isinstance(content, bytes | bytearray): - body = bytes(content) - elif isinstance(content, str): - body = content + if status >= _HTTP_ERROR: + body = await read_response_body(resp) msg = http_error_message(status, body) if msg is None: return @@ -163,8 +174,14 @@ class OpenAIClient(BaseOpenAIClient): stream=False, ) await self._ensure_ok(resp) - assert resp.content is not None - return self.decoder.decode(resp.content) + body = await read_response_body(resp) + if body is None: + msg = "chat completions response body is empty" + raise RuntimeError(msg) + if not isinstance(body, (bytes, bytearray)): + msg = f"chat completions response body has unexpected type {type(body)!r}" + raise TypeError(msg) + return self.decoder.decode(bytes(body)) def merge_stream_tool_calls(deltas: list[StreamToolCallDelta]) -> list[AssistantFunctionToolCall]: diff --git a/tests/test_lmproto/test_sse_parse.py b/tests/test_lmproto/test_sse_parse.py index 878d014..ea5db12 100644 --- a/tests/test_lmproto/test_sse_parse.py +++ b/tests/test_lmproto/test_sse_parse.py @@ -1,6 +1,10 @@ from __future__ import annotations -from plyngent.lmproto.openai_compatible.client import http_error_message, sse_data_payload +from plyngent.lmproto.openai_compatible.client import ( + http_error_message, + read_response_body, + sse_data_payload, +) from plyngent.lmproto.openai_compatible.model import ChatCompletionChunk @@ -69,3 +73,20 @@ def test_http_error_message() -> None: assert err503 is not None assert "503" in err503 assert "unavailable" in err503 + + +async def test_response_body_awaits_async_content() -> None: + class AsyncContentResp: + @property + async def content(self) -> bytes: + return b'{"ok":true}' + + body = await read_response_body(AsyncContentResp()) + assert body == b'{"ok":true}' + + +async def test_response_body_sync_bytes() -> None: + class SyncContentResp: + content = b"plain" + + assert await read_response_body(SyncContentResp()) == b"plain"