diff --git a/src/plyngent/agent/messages_bridge.py b/src/plyngent/agent/messages_bridge.py index d661c15..ebbefb0 100644 --- a/src/plyngent/agent/messages_bridge.py +++ b/src/plyngent/agent/messages_bridge.py @@ -1,7 +1,9 @@ """Convert agent chat history/tools to Anthropic Messages API shapes and back. Agent memory and events stay chat-completions-shaped; only the transport uses -``POST /messages``. OpenAI / DeepSeek paths never enter this module. +``POST /messages``. Real Anthropic and the DeepSeek ``convention="anthropic"`` +surface both route through this module; OpenAI / openai-compatible paths never +enter it. """ from __future__ import annotations diff --git a/src/plyngent/agent/messages_dispatch.py b/src/plyngent/agent/messages_dispatch.py index 6eee7b1..2dbfc08 100644 --- a/src/plyngent/agent/messages_dispatch.py +++ b/src/plyngent/agent/messages_dispatch.py @@ -1,7 +1,9 @@ """Anthropic Messages dispatch: present ``/messages`` as chat completions to the loop. Agent memory and events stay chat-completions-shaped; only the transport uses -``POST /messages``. OpenAI / DeepSeek paths never enter this module. +``POST /messages``. Real Anthropic and the DeepSeek ``convention="anthropic"`` +surface both route through this module; OpenAI / openai-compatible paths never +enter it. """ from __future__ import annotations diff --git a/src/plyngent/runtime/client_factory.py b/src/plyngent/runtime/client_factory.py index 3e12050..3ad3630 100644 --- a/src/plyngent/runtime/client_factory.py +++ b/src/plyngent/runtime/client_factory.py @@ -6,7 +6,11 @@ from typing import TYPE_CHECKING from plyngent.config.routing import EffectiveProvider, resolve_effective_provider from plyngent.lmproto.anthropic import AnthropicClient from plyngent.lmproto.anthropic.config import AnthropicConfig as AnthropicConfigCls -from plyngent.lmproto.deepseek import DeepseekOpenAIClient, DeepseekResponsesClient +from plyngent.lmproto.deepseek import ( + DeepseekAnthropicClient, + DeepseekOpenAIClient, + DeepseekResponsesClient, +) from plyngent.lmproto.openai import OpenAIClient from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig from plyngent.lmproto.openai_compatible.config import ( @@ -23,7 +27,12 @@ DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1" DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com" type ProtocolClient = ( - OpenAIClient | OpenAICompatibleClient | DeepseekOpenAIClient | DeepseekResponsesClient | AnthropicClient + OpenAIClient + | OpenAICompatibleClient + | DeepseekOpenAIClient + | DeepseekResponsesClient + | DeepseekAnthropicClient + | AnthropicClient ) # Backward-compatible name used by older imports/tests. type OpenAICompatibleClientUnion = ProtocolClient @@ -94,13 +103,37 @@ def provider_to_openai_config( ) +def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient: + """Build an Anthropic Messages client (real or DeepSeek convention) for *effective*.""" + try: + http_timeout = normalize_http_timeout(effective.timeout) + except InvalidHttpTimeoutError as exc: + raise ProviderNotSupportedError(str(exc)) from exc + if effective.preset == "deepseek": + return DeepseekAnthropicClient( + AnthropicConfigCls( + api_key=effective.access_key_or_token, + base_url=effective.url or DEFAULT_DEEPSEEK_BASE_URL, + timeout=http_timeout, + ) + ) + return AnthropicClient( + AnthropicConfigCls( + api_key=effective.access_key_or_token, + base_url=effective.url or DEFAULT_ANTHROPIC_BASE_URL, + timeout=http_timeout, + ) + ) + + def create_client(provider: Provider, *, model: str | None = None) -> ProtocolClient: """Build a protocol client for *provider*, applying model-level routing. ``preset`` always decides API conventions: ``openai`` → /responses, ``openai-compatible`` → /chat/completions, ``anthropic`` → /messages. - Exception: a ``deepseek`` preset picks the Responses surface when its - effective ``convention`` is ``"responses"``, else chat completions. + Exception: a ``deepseek`` preset picks the Responses or Anthropic surface + when its effective ``convention`` is ``"responses"`` / ``"anthropic"``, + else chat completions. """ effective = resolve_effective_provider(provider, model=model) if effective.preset == "openai": @@ -110,18 +143,10 @@ def create_client(provider: Provider, *, model: str | None = None) -> ProtocolCl if effective.preset == "deepseek": if effective.convention == "responses": return DeepseekResponsesClient(provider_to_openai_config(effective)) + if effective.convention == "anthropic": + return _anthropic_client(effective) return DeepseekOpenAIClient(provider_to_openai_config(effective)) if effective.preset == "anthropic": - try: - http_timeout = normalize_http_timeout(effective.timeout) - except InvalidHttpTimeoutError as exc: - raise ProviderNotSupportedError(str(exc)) from exc - return AnthropicClient( - AnthropicConfigCls( - api_key=effective.access_key_or_token, - base_url=effective.url or DEFAULT_ANTHROPIC_BASE_URL, - timeout=http_timeout, - ) - ) + return _anthropic_client(effective) msg = f"provider preset {effective.preset!r} is not supported" raise ProviderNotSupportedError(msg) diff --git a/tests/test_runtime/test_client_factory.py b/tests/test_runtime/test_client_factory.py index fc55507..76250c0 100644 --- a/tests/test_runtime/test_client_factory.py +++ b/tests/test_runtime/test_client_factory.py @@ -89,8 +89,54 @@ def test_deepseek_default_and_legacy_conventions_stay_chat() -> None: default_client = create_client(DeepseekProvider(access_key_or_token="sk-test")) assert isinstance(default_client, DeepseekOpenAIClient) assert not isinstance(default_client, DeepseekResponsesClient) - legacy = DeepseekProvider(access_key_or_token="sk-test", convention="anthropic") - assert isinstance(create_client(legacy), DeepseekOpenAIClient) + # extras.convention stays ignored (legacy location). + legacy_extra = DeepseekProvider( + access_key_or_token="sk-test", + extras={"convention": "anthropic"}, + ) + assert isinstance(create_client(legacy_extra), DeepseekOpenAIClient) + + +def test_deepseek_anthropic_convention() -> None: + from plyngent.lmproto.anthropic import AnthropicClient + from plyngent.lmproto.deepseek import DeepseekAnthropicClient + + provider = DeepseekProvider(access_key_or_token="sk-test", convention="anthropic") + client = create_client(provider) + assert isinstance(client, DeepseekAnthropicClient) + assert isinstance(client, AnthropicClient) + assert client.kind == "messages" + assert client.session.base_url == "https://api.deepseek.com/anthropic" + + +def test_deepseek_anthropic_convention_custom_url() -> None: + from plyngent.lmproto.deepseek import DeepseekAnthropicClient + + provider = DeepseekProvider( + access_key_or_token="sk-test", + url="https://gateway.example/anthropic", + convention="anthropic", + ) + client = create_client(provider) + assert isinstance(client, DeepseekAnthropicClient) + assert client.session.base_url == "https://gateway.example/anthropic" + + +def test_deepseek_model_level_anthropic_convention() -> None: + from plyngent.lmproto.deepseek import DeepseekAnthropicClient, DeepseekOpenAIClient + + provider = DeepseekProvider( + access_key_or_token="sk-test", + models={ + "claude-proxy": ModelConfig(convention="anthropic"), + "deepseek-v4-flash": ModelConfig(), + }, + ) + proxy = create_client(provider, model="claude-proxy") + assert isinstance(proxy, DeepseekAnthropicClient) + assert proxy.session.base_url == "https://api.deepseek.com/anthropic" + flash = create_client(provider, model="deepseek-v4-flash") + assert isinstance(flash, DeepseekOpenAIClient) def test_deepseek_model_level_convention_override() -> None: