mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
config: add typed DeepSeek convention field for API surface
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user