From db6d2a83f45666433e33dd08a378cb3f141d8473 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 1 Aug 2026 02:01:06 +0800 Subject: [PATCH] lmproto: add DeepSeek Responses client --- src/plyngent/agent/types.py | 6 +- src/plyngent/lmproto/deepseek/__init__.py | 1 + .../lmproto/deepseek/responses/__init__.py | 1 + .../lmproto/deepseek/responses/client.py | 58 ++++++++++++ tests/test_lmproto/test_deepseek_responses.py | 89 +++++++++++++++++++ 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 src/plyngent/lmproto/deepseek/responses/__init__.py create mode 100644 src/plyngent/lmproto/deepseek/responses/client.py create mode 100644 tests/test_lmproto/test_deepseek_responses.py diff --git a/src/plyngent/agent/types.py b/src/plyngent/agent/types.py index d589f9f..17d3f8d 100644 --- a/src/plyngent/agent/types.py +++ b/src/plyngent/agent/types.py @@ -3,8 +3,10 @@ from __future__ import annotations from plyngent.lmproto.anthropic.client import AnthropicClient -from plyngent.lmproto.deepseek.openai_compat.client import DeepseekOpenAIClient +from plyngent.lmproto.deepseek import DeepseekOpenAIClient, DeepseekResponsesClient from plyngent.lmproto.openai.client import OpenAIClient from plyngent.lmproto.openai_compatible.client import OpenAICompatibleClient -type AnyLLMClient = OpenAICompatibleClient | DeepseekOpenAIClient | OpenAIClient | AnthropicClient +type AnyLLMClient = ( + OpenAICompatibleClient | DeepseekOpenAIClient | DeepseekResponsesClient | OpenAIClient | AnthropicClient +) diff --git a/src/plyngent/lmproto/deepseek/__init__.py b/src/plyngent/lmproto/deepseek/__init__.py index a2c5087..f74af4f 100644 --- a/src/plyngent/lmproto/deepseek/__init__.py +++ b/src/plyngent/lmproto/deepseek/__init__.py @@ -6,3 +6,4 @@ from .openai_compat.model import DeepSeekReasoningEffort as DeepSeekReasoningEff from .openai_compat.model import NamedChatMessage as NamedChatMessage from .openai_compat.model import ThinkingOptions as ThinkingOptions from .openai_compat.model import ToolChatMessage as ToolChatMessage +from .responses.client import DeepseekResponsesClient as DeepseekResponsesClient diff --git a/src/plyngent/lmproto/deepseek/responses/__init__.py b/src/plyngent/lmproto/deepseek/responses/__init__.py new file mode 100644 index 0000000..2352cbf --- /dev/null +++ b/src/plyngent/lmproto/deepseek/responses/__init__.py @@ -0,0 +1 @@ +from .client import DeepseekResponsesClient as DeepseekResponsesClient diff --git a/src/plyngent/lmproto/deepseek/responses/client.py b/src/plyngent/lmproto/deepseek/responses/client.py new file mode 100644 index 0000000..b5dc0c2 --- /dev/null +++ b/src/plyngent/lmproto/deepseek/responses/client.py @@ -0,0 +1,58 @@ +"""DeepSeek Responses API client (``POST /responses`` on the DeepSeek endpoint).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast, override + +from plyngent.lmproto.openai.client import OpenAIClient + +# DeepSeek Responses SSE streams end with a terminal event instead of +# ``data: [DONE]`` (see https://api-docs.deepseek.com/guides/responses_api). +_RESPONSES_TERMINAL_EVENTS = frozenset({"response.completed", "response.incomplete", "response.failed"}) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from plyngent.lmproto.openai.model import ResponseStreamEvent + from plyngent.lmproto.openai_compatible.config import OpenAIConfig + + +class DeepseekResponsesClient(OpenAIClient): + """DeepSeek Responses client: OpenAI Responses format on DeepSeek's endpoint. + + Differs from :class:`~plyngent.lmproto.openai.OpenAIClient` only in + streaming: DeepSeek sends no ``data: [DONE]``, so the SSE loop stops at a + terminal event (``response.completed`` / ``response.incomplete`` / + ``response.failed``) instead of waiting for ``[DONE]``. + """ + + kind: str = "responses" + + def __init__(self, config: OpenAIConfig) -> None: + super().__init__(config) + + @override + async def _parse_response_sse(self, resp: object) -> AsyncIterator[ResponseStreamEvent]: + """Yield Responses API SSE events; stop at terminal events (no ``[DONE]``).""" + from plyngent.lmproto.openai_compatible.client import ( + close_async_response, + sse_data_payload, + ) + + typed = cast("Any", resp) + try: + await self._ensure_ok(typed, what="responses") + async for raw in typed.iter_lines(): + if not raw: + continue + parsed = sse_data_payload(bytes(raw)) + if parsed is None: + continue + if parsed is False: + break + event = self.response_event_decoder.decode(parsed) + yield event + if event.type in _RESPONSES_TERMINAL_EVENTS: + break + finally: + await close_async_response(typed) diff --git a/tests/test_lmproto/test_deepseek_responses.py b/tests/test_lmproto/test_deepseek_responses.py new file mode 100644 index 0000000..76e96af --- /dev/null +++ b/tests/test_lmproto/test_deepseek_responses.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import pytest + +from plyngent.lmproto.deepseek import DeepseekResponsesClient +from plyngent.lmproto.openai import Response, ResponsesCreateParam, response_output_text +from plyngent.lmproto.openai_compatible.config import OpenAIConfig + + +def _sample_response_body() -> bytes: + return ( + b'{"id":"resp_1","object":"response","created_at":1,"model":"deepseek-v4-flash",' + b'"status":"completed","output":[' + b'{"id":"msg_1","type":"message","role":"assistant","status":"completed",' + b'"content":[{"type":"output_text","text":"hello from deepseek","annotations":[]}]}' + b"]}" + ) + + +def test_client_kind_and_base_url() -> None: + client = DeepseekResponsesClient(OpenAIConfig(access_key_or_token="sk", base_url="https://api.deepseek.com")) + assert client.kind == "responses" + assert client.session.base_url == "https://api.deepseek.com" + + +@pytest.mark.asyncio +async def test_client_responses_create(monkeypatch: pytest.MonkeyPatch) -> None: + client = DeepseekResponsesClient(OpenAIConfig(access_key_or_token="sk", base_url="https://api.deepseek.com")) + + class _Resp: + status_code = 200 + + @property + def content(self) -> bytes: + return _sample_response_body() + + async def fake_post(path: str, **kwargs: object) -> _Resp: + assert path == "/responses" + assert kwargs.get("stream") is False + return _Resp() + + monkeypatch.setattr(client.session, "post", fake_post) + result = await client.responses(ResponsesCreateParam(model="deepseek-v4-flash", input="hi")) + assert isinstance(result, Response) + assert response_output_text(result) == "hello from deepseek" + + +@pytest.mark.asyncio +async def test_client_responses_stream_stops_at_terminal(monkeypatch: pytest.MonkeyPatch) -> None: + """DeepSeek sends no ``[DONE]``: the stream must stop at a terminal event.""" + client = DeepseekResponsesClient(OpenAIConfig(access_key_or_token="sk", base_url="https://api.deepseek.com")) + + class _Resp: + status_code = 200 + + async def iter_lines(self): + yield ( + b'data: {"type":"response.output_text.delta","delta":"hel",' + b'"item_id":"m","output_index":0,"content_index":0,"sequence_number":1}' + ) + # Terminal event carries the full response (no [DONE] follows). + yield ( + b'data: {"type":"response.completed","sequence_number":2,' + b'"response":{"id":"resp_1","object":"response","created_at":1,' + b'"model":"deepseek-v4-flash","status":"completed","output":[]}}' + ) + # Anything after the terminal event must not be consumed. + yield b'data: {"type":"response.output_text.delta","delta":"ignored"}' + + def close(self) -> None: + return None + + async def fake_post(path: str, **kwargs: object) -> _Resp: + assert path == "/responses" + assert kwargs.get("stream") is True + return _Resp() + + monkeypatch.setattr(client.session, "post", fake_post) + stream = await client.responses( + ResponsesCreateParam(model="deepseek-v4-flash", input="hi"), + stream=True, + ) + events = [event async for event in stream] + assert [event.type for event in events] == [ + "response.output_text.delta", + "response.completed", + ] + assert isinstance(events[1].response, dict) + assert events[1].response["status"] == "completed"