diff --git a/src/plyngent/config/__init__.py b/src/plyngent/config/__init__.py index e0ea3ee..e05614f 100644 --- a/src/plyngent/config/__init__.py +++ b/src/plyngent/config/__init__.py @@ -10,6 +10,7 @@ from .models import DEFAULT_TOOL_DIRECTIVES as DEFAULT_TOOL_DIRECTIVES from .models import AgentConfig as AgentConfig from .models import AnthropicProvider as AnthropicProvider from .models import DatabaseConfig as DatabaseConfig +from .models import DeepSeekConvention as DeepSeekConvention from .models import DeepseekProvider as DeepseekProvider from .models import HttpTimeoutConfig as HttpTimeoutConfig from .models import ModelConfig as ModelConfig diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index edd1546..1703d22 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -1,7 +1,14 @@ -from typing import Any +from typing import Any, Literal from msgspec import Struct, field +# DeepSeek API surface convention (``DeepseekProvider.convention`` and +# per-model ``ModelConfig.convention`` override). +# - "" (default) / "openai" → OpenAI-compatible chat completions +# - "responses" → OpenAI Responses API (``POST /responses``) +# - "anthropic" → legacy value, ignored (still chat completions) +type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"] + # Built-in persona when ``[agent].system_prompt`` is omitted. # Set ``system_prompt = ""`` to omit the persona block only. # Override with a multi-line TOML literal (prefer ''' so nested " is fine). @@ -162,6 +169,8 @@ class ModelConfig(Struct, omit_defaults=True): cost_factor: float = 1.0 preset: str = "" url: str = "" + # DeepSeek API surface override ("" = inherit provider convention). + convention: DeepSeekConvention = "" class HttpTimeoutConfig(Struct, omit_defaults=True): @@ -244,9 +253,19 @@ def _default_deepseek_models() -> dict[str, ModelConfig]: class DeepseekProvider(ProviderConfig, tag="deepseek"): - """Deepseek API provider with optional extras (e.g. convention).""" + """DeepSeek API provider. + + ``convention`` selects the API surface on ``https://api.deepseek.com``: + - ``""`` / ``"openai"`` → OpenAI-compatible chat completions (default) + - ``"responses"`` → OpenAI Responses API (``POST /responses``) + - ``"anthropic"`` → legacy value, ignored (still chat completions) + + ``extras`` keeps arbitrary provider keys; the legacy ``extras.convention`` + key is parsed but ignored (prefer the typed ``convention`` field). + """ models: dict[str, ModelConfig] = field(default_factory=_default_deepseek_models) + convention: DeepSeekConvention = "" extras: dict[str, str] = field(default_factory=dict) diff --git a/src/plyngent/config/routing.py b/src/plyngent/config/routing.py index 4be7124..46a9144 100644 --- a/src/plyngent/config/routing.py +++ b/src/plyngent/config/routing.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Literal, cast from .models import ( AnthropicProvider, + DeepseekProvider, HttpTimeoutConfig, ModelConfig, OpenAICompatibleProvider, @@ -30,6 +31,9 @@ class EffectiveProvider: timeout: float | HttpTimeoutConfig | None provider_tools: list[dict[str, object]] source: Provider + # DeepSeek API surface (see DeepSeekConvention): "openai" / "anthropic" / + # "responses" for deepseek presets; "" (unused) for other presets. + convention: str = "" def provider_preset(provider: Provider) -> ProviderPreset: @@ -73,6 +77,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. """ parent_preset = provider_preset(provider) model_cfg = model_config_for(provider, model) @@ -88,6 +95,13 @@ def resolve_effective_provider(provider: Provider, *, model: str | None = None) 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 = "" + if preset == "deepseek" and isinstance(provider, DeepseekProvider): + convention = provider.convention or "openai" + if model_cfg is not None and model_cfg.convention: + convention = model_cfg.convention + return EffectiveProvider( preset=preset, access_key_or_token=provider.access_key_or_token, @@ -96,6 +110,7 @@ def resolve_effective_provider(provider: Provider, *, model: str | None = None) timeout=provider.timeout, provider_tools=provider_tools, source=provider, + convention=convention, ) @@ -108,4 +123,5 @@ def effective_provider_to_config_dict(effective: EffectiveProvider) -> Mapping[s "model": effective.model or "", "timeout": effective.timeout, "provider_tools": effective.provider_tools, + "convention": effective.convention, } diff --git a/tests/test_config/plyngent-bad.toml b/tests/test_config/plyngent-bad.toml index bbeb840..b0197ea 100644 --- a/tests/test_config/plyngent-bad.toml +++ b/tests/test_config/plyngent-bad.toml @@ -12,4 +12,4 @@ access_key_or_token = "sk-1145141919810" [providers.foo1] preset = "deepseek" access_key_or_token = "sk-1145141919810" -convention = "anthropic" +unknown_field = "should-be-rejected" diff --git a/tests/test_config/test_config.py b/tests/test_config/test_config.py index 777baa0..b487500 100644 --- a/tests/test_config/test_config.py +++ b/tests/test_config/test_config.py @@ -137,6 +137,116 @@ def test_deepseek_explicit_models_override_defaults() -> None: assert set(provider.models) == {"custom-only"} +def test_deepseek_convention_from_toml(tmp_path: Path) -> None: + path = tmp_path / "deepseek-convention.toml" + _ = path.write_text( + """ +[providers.ds] +preset = "deepseek" +access_key_or_token = "sk-test" +convention = "responses" +""", + encoding="utf-8", + ) + config = plyngent.config.load(path) + provider = config.providers["ds"] + assert isinstance(provider, DeepseekProvider) + assert provider.convention == "responses" + + +def test_deepseek_convention_invalid_is_bad_provider(tmp_path: Path) -> None: + path = tmp_path / "deepseek-bad-convention.toml" + _ = path.write_text( + """ +[providers.ds] +preset = "deepseek" +access_key_or_token = "sk-test" +convention = "bogus" +""", + encoding="utf-8", + ) + config = plyngent.config.load(path) + assert "ds" not in config.providers + assert "ds" in config.bad_providers + + +def test_deepseek_model_convention_override_parses(tmp_path: Path) -> None: + path = tmp_path / "deepseek-model-convention.toml" + _ = path.write_text( + """ +[providers.ds] +preset = "deepseek" +access_key_or_token = "sk-test" + +[providers.ds.models] +"deepseek-v4-flash" = { text = true, convention = "responses" } +"deepseek-v4-pro" = { text = true } +""", + encoding="utf-8", + ) + config = plyngent.config.load(path) + provider = config.providers["ds"] + assert isinstance(provider, DeepseekProvider) + assert provider.models["deepseek-v4-flash"].convention == "responses" + assert provider.models["deepseek-v4-pro"].convention == "" + + +def test_deepseek_convention_write_back(tmp_path: Path) -> None: + path = tmp_path / "deepseek-convention-write.toml" + config = ConfigStore(path=path, document=tomlkit.document()) + config.providers = { + "ds": DeepseekProvider(access_key_or_token="sk-test", convention="responses"), + } + config.write() + text = path.read_text(encoding="utf-8") + assert 'convention = "responses"' in text + again = plyngent.config.load(path) + assert again.providers["ds"].convention == "responses" + + +def test_deepseek_convention_default_not_written(tmp_path: Path) -> None: + from plyngent.config import ModelConfig + + path = tmp_path / "deepseek-convention-default.toml" + config = ConfigStore(path=path, document=tomlkit.document()) + config.providers = { + "ds": DeepseekProvider( + access_key_or_token="sk-test", + models={"m": ModelConfig(text=True)}, + ), + } + config.write() + text = path.read_text(encoding="utf-8") + assert "convention" not in text + + +def test_deepseek_effective_convention_precedence() -> None: + from plyngent.config import ModelConfig, resolve_effective_provider + + provider = DeepseekProvider( + access_key_or_token="sk-test", + models={ + "flash": ModelConfig(convention="responses"), + "pro": ModelConfig(), + }, + ) + # Provider default "" → chat completions convention. + effective = resolve_effective_provider(provider) + assert effective.convention == "openai" + # Model override wins. + effective = resolve_effective_provider(provider, model="flash") + assert effective.convention == "responses" + effective = resolve_effective_provider(provider, model="pro") + assert effective.convention == "openai" + # Provider-level convention is the fallback. + provider = DeepseekProvider(access_key_or_token="sk-test", convention="responses") + assert resolve_effective_provider(provider).convention == "responses" + # Non-deepseek presets leave convention empty. + from plyngent.config import OpenAIProvider + + assert resolve_effective_provider(OpenAIProvider(access_key_or_token="sk")).convention == "" + + def test_read_empty_config() -> None: config = plyngent.config.load(Path(__file__).parent / "plyngent-empty.toml") assert isinstance(config.providers, Mapping)