diff --git a/src/plyngent/agent/types.py b/src/plyngent/agent/types.py index 17d3f8d..74b5b0f 100644 --- a/src/plyngent/agent/types.py +++ b/src/plyngent/agent/types.py @@ -3,10 +3,19 @@ from __future__ import annotations from plyngent.lmproto.anthropic.client import AnthropicClient -from plyngent.lmproto.deepseek import DeepseekOpenAIClient, DeepseekResponsesClient +from plyngent.lmproto.deepseek import ( + DeepseekAnthropicClient, + DeepseekOpenAIClient, + DeepseekResponsesClient, +) from plyngent.lmproto.openai.client import OpenAIClient from plyngent.lmproto.openai_compatible.client import OpenAICompatibleClient type AnyLLMClient = ( - OpenAICompatibleClient | DeepseekOpenAIClient | DeepseekResponsesClient | OpenAIClient | AnthropicClient + OpenAICompatibleClient + | DeepseekOpenAIClient + | DeepseekResponsesClient + | DeepseekAnthropicClient + | OpenAIClient + | AnthropicClient ) diff --git a/src/plyngent/lmproto/deepseek/__init__.py b/src/plyngent/lmproto/deepseek/__init__.py index f74af4f..6a302e2 100644 --- a/src/plyngent/lmproto/deepseek/__init__.py +++ b/src/plyngent/lmproto/deepseek/__init__.py @@ -1,3 +1,4 @@ +from .anthropic.client import DeepseekAnthropicClient as DeepseekAnthropicClient from .openai_compat.client import DeepseekOpenAIClient as DeepseekOpenAIClient from .openai_compat.model import AnyChatMessage as AnyChatMessage from .openai_compat.model import AssistantChatMessage as AssistantChatMessage diff --git a/src/plyngent/lmproto/deepseek/anthropic/__init__.py b/src/plyngent/lmproto/deepseek/anthropic/__init__.py new file mode 100644 index 0000000..2a2d0b6 --- /dev/null +++ b/src/plyngent/lmproto/deepseek/anthropic/__init__.py @@ -0,0 +1 @@ +from .client import DeepseekAnthropicClient as DeepseekAnthropicClient diff --git a/src/plyngent/lmproto/deepseek/anthropic/client.py b/src/plyngent/lmproto/deepseek/anthropic/client.py new file mode 100644 index 0000000..52499f8 --- /dev/null +++ b/src/plyngent/lmproto/deepseek/anthropic/client.py @@ -0,0 +1,35 @@ +"""DeepSeek Anthropic Messages client (``POST /messages`` on the DeepSeek endpoint).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +from plyngent.lmproto.anthropic.client import AnthropicClient + +if TYPE_CHECKING: + from plyngent.lmproto.anthropic.config import AnthropicConfig + + +class DeepseekAnthropicClient(AnthropicClient): + """Anthropic Messages client pointed at DeepSeek's Anthropic-compatible endpoint. + + DeepSeek exposes the Anthropic API format at ``https://api.deepseek.com/anthropic`` + (see https://api-docs.deepseek.com/guides/anthropic_api). ``x-api-key`` is fully + supported; ``anthropic-version`` / ``anthropic-beta`` headers are ignored by the + server but harmless. Model ids map server-side: ``claude-opus*`` → ``deepseek-v4-pro``, + ``claude-haiku*`` / ``claude-sonnet*`` → ``deepseek-v4-flash``, unknown names → + ``deepseek-v4-flash``; real DeepSeek ids pass through as-is. + + ``GET /models`` is not documented on the ``/anthropic`` base, so ``models()`` + returns an empty list and model selection stays config-driven. + """ + + kind: str = "messages" + + def __init__(self, config: AnthropicConfig) -> None: + super().__init__(config) + + @override + async def models(self) -> list[str]: + """No documented ``GET /models`` on DeepSeek's ``/anthropic`` base.""" + return [] diff --git a/tests/test_lmproto/test_deepseek_anthropic.py b/tests/test_lmproto/test_deepseek_anthropic.py new file mode 100644 index 0000000..05adacd --- /dev/null +++ b/tests/test_lmproto/test_deepseek_anthropic.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import msgspec +import pytest + +from plyngent.lmproto.anthropic.config import AnthropicConfig +from plyngent.lmproto.anthropic.model import ( + AnthropicMessageResponse, + AnthropicMessagesParam, + AnthropicResponseText, + AnthropicUsage, +) +from plyngent.lmproto.deepseek import DeepseekAnthropicClient + + +def _sample_message_body() -> bytes: + return msgspec.json.encode( + AnthropicMessageResponse( + id="msg_1", + model="deepseek-v4-flash", + content=[AnthropicResponseText(text="hello world")], + stop_reason="end_turn", + usage=AnthropicUsage(input_tokens=9, output_tokens=3), + ) + ) + + +def test_client_kind_and_base_url() -> None: + client = DeepseekAnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic")) + assert client.kind == "messages" + assert client.session.base_url == "https://api.deepseek.com/anthropic" + + +@pytest.mark.asyncio +async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None: + client = DeepseekAnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic")) + + class _Resp: + status_code = 200 + + @property + def content(self) -> bytes: + return _sample_message_body() + + async def fake_post(path: str, **kwargs: object) -> _Resp: + assert path == "/messages" + assert kwargs.get("stream") is False + headers = kwargs.get("headers") + assert isinstance(headers, dict) + assert headers.get("x-api-key") == "sk-test" + assert "anthropic-version" in headers + return _Resp() + + monkeypatch.setattr(client.session, "post", fake_post) + result = await client.messages(AnthropicMessagesParam(model="deepseek-v4-flash", messages=[])) + assert isinstance(result, AnthropicMessageResponse) + assert result.content[0].text == "hello world" + assert result.usage.output_tokens == 3 + + +@pytest.mark.asyncio +async def test_client_messages_stream(monkeypatch: pytest.MonkeyPatch) -> None: + client = DeepseekAnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic")) + + class _Resp: + status_code = 200 + + async def iter_lines(self): + yield b'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hel"}}' + yield b'data: {"type":"message_stop"}' + + def close(self) -> None: + return None + + async def fake_post(path: str, **kwargs: object) -> _Resp: + assert path == "/messages" + assert kwargs.get("stream") is True + return _Resp() + + monkeypatch.setattr(client.session, "post", fake_post) + stream = await client.messages(AnthropicMessagesParam(model="deepseek-v4-flash", messages=[]), stream=True) + events = [event async for event in stream] + assert len(events) == 2 + + +@pytest.mark.asyncio +async def test_client_models_empty_without_network(monkeypatch: pytest.MonkeyPatch) -> None: + client = DeepseekAnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic")) + + async def fail_get(*_args: object, **_kwargs: object) -> object: + msg = "GET /models must not be called on the /anthropic base" + raise AssertionError(msg) + + monkeypatch.setattr(client.session, "get", fail_get) + assert await client.models() == []