mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
lmproto: add DeepSeek Responses client
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .client import DeepseekResponsesClient as DeepseekResponsesClient
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user