diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 1703d22..2f4fb1a 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -6,7 +6,8 @@ from msgspec import Struct, field # per-model ``ModelConfig.convention`` override). # - "" (default) / "openai" → OpenAI-compatible chat completions # - "responses" → OpenAI Responses API (``POST /responses``) -# - "anthropic" → legacy value, ignored (still chat completions) +# - "anthropic" → Anthropic Messages API (``POST /messages`` on +# ``https://api.deepseek.com/anthropic``) type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"] # Built-in persona when ``[agent].system_prompt`` is omitted. @@ -255,10 +256,11 @@ def _default_deepseek_models() -> dict[str, ModelConfig]: class DeepseekProvider(ProviderConfig, tag="deepseek"): """DeepSeek API provider. - ``convention`` selects the API surface on ``https://api.deepseek.com``: + ``convention`` selects the API surface on DeepSeek's endpoints: - ``""`` / ``"openai"`` → OpenAI-compatible chat completions (default) - ``"responses"`` → OpenAI Responses API (``POST /responses``) - - ``"anthropic"`` → legacy value, ignored (still chat completions) + - ``"anthropic"`` → Anthropic Messages API (``POST /messages`` on + ``https://api.deepseek.com/anthropic``) ``extras`` keeps arbitrary provider keys; the legacy ``extras.convention`` key is parsed but ignored (prefer the typed ``convention`` field). diff --git a/src/plyngent/config/routing.py b/src/plyngent/config/routing.py index 46a9144..d7f7878 100644 --- a/src/plyngent/config/routing.py +++ b/src/plyngent/config/routing.py @@ -19,6 +19,9 @@ if TYPE_CHECKING: type ProviderPreset = Literal["openai", "openai-compatible", "anthropic", "deepseek"] +DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com" +DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL = "https://api.deepseek.com/anthropic" + @dataclass(frozen=True, slots=True) class EffectiveProvider: @@ -54,11 +57,22 @@ def default_url_for_preset(preset: ProviderPreset) -> str: if preset == "anthropic": return "https://api.anthropic.com/v1" if preset == "deepseek": - return "https://api.deepseek.com" + return DEFAULT_DEEPSEEK_BASE_URL # Generic OpenAI-compatible providers usually require a host-specific URL. return "" +def _default_url_for_deepseek_convention(convention: str) -> str: + """Default DeepSeek base URL for an API convention. + + The Anthropic-convention endpoint lives under ``/anthropic``; chat and + Responses share the plain ``https://api.deepseek.com`` base. + """ + if convention == "anthropic": + return DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL + return DEFAULT_DEEPSEEK_BASE_URL + + def validate_model_preset(preset: str) -> ProviderPreset: if preset in {"openai", "openai-compatible", "anthropic", "deepseek"}: return cast("ProviderPreset", preset) @@ -77,9 +91,9 @@ def resolve_effective_provider(provider: Provider, *, model: str | None = None) ``preset`` always decides API conventions: ``openai`` means Responses, ``openai-compatible`` means Chat Completions, ``anthropic`` means Messages. - Exception: a ``deepseek`` preset may pick the Responses surface via the - ``convention`` field (``"responses"``); model-level ``convention`` wins - over the provider-level one. + Exception: a ``deepseek`` preset may pick the Responses or Anthropic + surface via the ``convention`` field (``"responses"`` / ``"anthropic"``); + model-level ``convention`` wins over the provider-level one. """ parent_preset = provider_preset(provider) model_cfg = model_config_for(provider, model) @@ -87,13 +101,6 @@ def resolve_effective_provider(provider: Provider, *, model: str | None = None) preset = validate_model_preset(model_cfg.preset.strip()) else: preset = parent_preset - url = model_cfg.url.strip() if model_cfg is not None and model_cfg.url.strip() else provider.url.strip() - if not url: - url = default_url_for_preset(preset) - - provider_tools: list[dict[str, object]] = [] - if preset == "openai" and isinstance(provider, OpenAIProvider) and parent_preset == "openai": - provider_tools = [dict(item) for item in provider.provider_tools] # DeepSeek API surface: model override > provider convention > chat default. convention = "" @@ -102,6 +109,17 @@ def resolve_effective_provider(provider: Provider, *, model: str | None = None) if model_cfg is not None and model_cfg.convention: convention = model_cfg.convention + url = model_cfg.url.strip() if model_cfg is not None and model_cfg.url.strip() else provider.url.strip() + if not url: + if preset == "deepseek": + url = _default_url_for_deepseek_convention(convention) + else: + url = default_url_for_preset(preset) + + provider_tools: list[dict[str, object]] = [] + if preset == "openai" and isinstance(provider, OpenAIProvider) and parent_preset == "openai": + provider_tools = [dict(item) for item in provider.provider_tools] + return EffectiveProvider( preset=preset, access_key_or_token=provider.access_key_or_token, diff --git a/tests/test_config/test_config.py b/tests/test_config/test_config.py index b487500..942be5c 100644 --- a/tests/test_config/test_config.py +++ b/tests/test_config/test_config.py @@ -247,6 +247,45 @@ def test_deepseek_effective_convention_precedence() -> None: assert resolve_effective_provider(OpenAIProvider(access_key_or_token="sk")).convention == "" +def test_deepseek_anthropic_convention_default_url() -> None: + from plyngent.config import resolve_effective_provider + from plyngent.config.routing import DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, DEFAULT_DEEPSEEK_BASE_URL + + provider = DeepseekProvider(access_key_or_token="sk-test", convention="anthropic") + effective = resolve_effective_provider(provider) + assert effective.convention == "anthropic" + assert effective.url == DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL + + # Custom provider url wins over the convention default. + provider = DeepseekProvider( + access_key_or_token="sk-test", + url="https://gateway.example/anthropic", + convention="anthropic", + ) + assert resolve_effective_provider(provider).url == "https://gateway.example/anthropic" + + # Other conventions keep the plain deepseek base. + for convention in ("", "openai", "responses"): + provider = DeepseekProvider(access_key_or_token="sk-test", convention=convention) + assert resolve_effective_provider(provider).url == DEFAULT_DEEPSEEK_BASE_URL + + +def test_deepseek_anthropic_model_convention_default_url() -> None: + from plyngent.config import ModelConfig, resolve_effective_provider + from plyngent.config.routing import DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL + + provider = DeepseekProvider( + access_key_or_token="sk-test", + models={"claude-proxy": ModelConfig(convention="anthropic")}, + ) + effective = resolve_effective_provider(provider, model="claude-proxy") + assert effective.convention == "anthropic" + assert effective.url == DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL + # Provider default url for a model without convention stays the deepseek base. + other = resolve_effective_provider(provider, model=None) + assert other.url == "https://api.deepseek.com" + + def test_read_empty_config() -> None: config = plyngent.config.load(Path(__file__).parent / "plyngent-empty.toml") assert isinstance(config.providers, Mapping)