config: add typed DeepSeek convention field for API surface

This commit is contained in:
2026-08-01 01:59:39 +08:00
parent a877dba8c1
commit 8ad33af652
5 changed files with 149 additions and 3 deletions
+1
View File
@@ -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
+21 -2
View File
@@ -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)
+16
View File
@@ -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,
}
+1 -1
View File
@@ -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"
+110
View File
@@ -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)