mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-09 00:47: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 AgentConfig as AgentConfig
|
||||||
from .models import AnthropicProvider as AnthropicProvider
|
from .models import AnthropicProvider as AnthropicProvider
|
||||||
from .models import DatabaseConfig as DatabaseConfig
|
from .models import DatabaseConfig as DatabaseConfig
|
||||||
|
from .models import DeepSeekConvention as DeepSeekConvention
|
||||||
from .models import DeepseekProvider as DeepseekProvider
|
from .models import DeepseekProvider as DeepseekProvider
|
||||||
from .models import HttpTimeoutConfig as HttpTimeoutConfig
|
from .models import HttpTimeoutConfig as HttpTimeoutConfig
|
||||||
from .models import ModelConfig as ModelConfig
|
from .models import ModelConfig as ModelConfig
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from msgspec import Struct, field
|
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.
|
# Built-in persona when ``[agent].system_prompt`` is omitted.
|
||||||
# Set ``system_prompt = ""`` to omit the persona block only.
|
# Set ``system_prompt = ""`` to omit the persona block only.
|
||||||
# Override with a multi-line TOML literal (prefer ''' so nested " is fine).
|
# 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
|
cost_factor: float = 1.0
|
||||||
preset: str = ""
|
preset: str = ""
|
||||||
url: str = ""
|
url: str = ""
|
||||||
|
# DeepSeek API surface override ("" = inherit provider convention).
|
||||||
|
convention: DeepSeekConvention = ""
|
||||||
|
|
||||||
|
|
||||||
class HttpTimeoutConfig(Struct, omit_defaults=True):
|
class HttpTimeoutConfig(Struct, omit_defaults=True):
|
||||||
@@ -244,9 +253,19 @@ def _default_deepseek_models() -> dict[str, ModelConfig]:
|
|||||||
|
|
||||||
|
|
||||||
class DeepseekProvider(ProviderConfig, tag="deepseek"):
|
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)
|
models: dict[str, ModelConfig] = field(default_factory=_default_deepseek_models)
|
||||||
|
convention: DeepSeekConvention = ""
|
||||||
extras: dict[str, str] = field(default_factory=dict)
|
extras: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Literal, cast
|
|||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
AnthropicProvider,
|
AnthropicProvider,
|
||||||
|
DeepseekProvider,
|
||||||
HttpTimeoutConfig,
|
HttpTimeoutConfig,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
OpenAICompatibleProvider,
|
OpenAICompatibleProvider,
|
||||||
@@ -30,6 +31,9 @@ class EffectiveProvider:
|
|||||||
timeout: float | HttpTimeoutConfig | None
|
timeout: float | HttpTimeoutConfig | None
|
||||||
provider_tools: list[dict[str, object]]
|
provider_tools: list[dict[str, object]]
|
||||||
source: Provider
|
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:
|
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,
|
``preset`` always decides API conventions: ``openai`` means Responses,
|
||||||
``openai-compatible`` means Chat Completions, ``anthropic`` means Messages.
|
``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)
|
parent_preset = provider_preset(provider)
|
||||||
model_cfg = model_config_for(provider, model)
|
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":
|
if preset == "openai" and isinstance(provider, OpenAIProvider) and parent_preset == "openai":
|
||||||
provider_tools = [dict(item) for item in provider.provider_tools]
|
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(
|
return EffectiveProvider(
|
||||||
preset=preset,
|
preset=preset,
|
||||||
access_key_or_token=provider.access_key_or_token,
|
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,
|
timeout=provider.timeout,
|
||||||
provider_tools=provider_tools,
|
provider_tools=provider_tools,
|
||||||
source=provider,
|
source=provider,
|
||||||
|
convention=convention,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,4 +123,5 @@ def effective_provider_to_config_dict(effective: EffectiveProvider) -> Mapping[s
|
|||||||
"model": effective.model or "",
|
"model": effective.model or "",
|
||||||
"timeout": effective.timeout,
|
"timeout": effective.timeout,
|
||||||
"provider_tools": effective.provider_tools,
|
"provider_tools": effective.provider_tools,
|
||||||
|
"convention": effective.convention,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ access_key_or_token = "sk-1145141919810"
|
|||||||
[providers.foo1]
|
[providers.foo1]
|
||||||
preset = "deepseek"
|
preset = "deepseek"
|
||||||
access_key_or_token = "sk-1145141919810"
|
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"}
|
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:
|
def test_read_empty_config() -> None:
|
||||||
config = plyngent.config.load(Path(__file__).parent / "plyngent-empty.toml")
|
config = plyngent.config.load(Path(__file__).parent / "plyngent-empty.toml")
|
||||||
assert isinstance(config.providers, Mapping)
|
assert isinstance(config.providers, Mapping)
|
||||||
|
|||||||
Reference in New Issue
Block a user