config: resolve thinking strength across agent, provider, and model layers

This commit is contained in:
2026-10-06 21:06:46 +08:00
parent 0c9bb2e067
commit ffd4dbbe26
6 changed files with 271 additions and 0 deletions
+3
View File
@@ -21,10 +21,13 @@ from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
from .models import OpenAIProvider as OpenAIProvider
from .models import PluginsConfig as PluginsConfig
from .models import ProviderConfig as ProviderConfig
from .models import ReasoningEffortConfig as ReasoningEffortConfig
from .models import SkillDiscoverySource as SkillDiscoverySource
from .models import SkillsConfig as SkillsConfig
from .models import compose_agent_system_content as compose_agent_system_content
from .path import get_default_path as _get_default_path
from .reasoning import ReasoningConfig as ReasoningConfig
from .reasoning import resolve_reasoning as resolve_reasoning
from .routing import EffectiveProvider as EffectiveProvider
from .routing import ProviderPreset as ProviderPreset
from .routing import default_url_for_preset as default_url_for_preset
+22
View File
@@ -13,6 +13,14 @@ type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
type AllowPathMode = Literal["read", "write", "exec"]
# Thinking strength, normalized across API surfaces (``reasoning_effort`` on
# ``[agent]``, a provider, or a single model; the most specific one wins).
# "" = send nothing and let the provider pick its own default.
# Mapped per surface by ``config.reasoning``: chat completions / Responses send
# ``reasoning_effort`` / ``reasoning.effort`` (``max`` is DeepSeek-only and
# clamps to ``xhigh`` elsewhere), Anthropic maps it onto ``thinking``.
type ReasoningEffortConfig = Literal["", "none", "minimal", "low", "medium", "high", "xhigh", "max"]
# Built-in skill discovery sources (``[skills].discover``): our own user config
# directory, and the skill directories other harnesses install into.
type SkillDiscoverySource = Literal["plyngent", "claude"]
@@ -150,6 +158,14 @@ class AgentConfig(Struct, omit_defaults=True):
compact_user_prefix: str = ""
compact_seed_text: str = ""
# Thinking strength for every request (a provider or single model may
# override either value; the most specific one wins).
# reasoning_effort: "" | none | minimal | low | medium | high | xhigh | max.
# thinking_budget_tokens: explicit Anthropic ``thinking`` budget (0 = derive
# it from ``reasoning_effort``). See config/reasoning.py.
reasoning_effort: ReasoningEffortConfig = ""
thinking_budget_tokens: int = 0
class PluginsConfig(Struct, omit_defaults=True):
"""Third-party plugins (not tool-specific config).
@@ -268,6 +284,9 @@ class ModelConfig(Struct, omit_defaults=True):
url: str = ""
# DeepSeek API surface override ("" = inherit provider convention).
convention: DeepSeekConvention = ""
# Thinking strength for this model only ("" / 0 = inherit the provider).
reasoning_effort: ReasoningEffortConfig = ""
thinking_budget_tokens: int = 0
class HttpTimeoutConfig(Struct, omit_defaults=True):
@@ -299,6 +318,9 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True):
models: dict[str, ModelConfig] = field(default_factory=dict)
# float = single timeout for the session; table = connect/read split; omit = defaults.
timeout: float | HttpTimeoutConfig | None = None
# Thinking strength for every model of this provider ("" / 0 = inherit [agent]).
reasoning_effort: ReasoningEffortConfig = ""
thinking_budget_tokens: int = 0
def _default_openai_models() -> dict[str, ModelConfig]:
+80
View File
@@ -0,0 +1,80 @@
"""Thinking-strength resolution across config layers and API surfaces.
``reasoning_effort`` (and the optional Anthropic ``thinking_budget_tokens``) may
be set on ``[agent]``, on a provider, or on a single model. This module merges
the layers — the most specific one wins, per field — and normalizes the result
for the API surface the provider actually talks to, so the agent loop and the
provider bridges receive one value with a single meaning:
* ``""`` — send nothing; the provider applies its own default.
* chat completions / Responses — ``reasoning_effort`` / ``reasoning.effort``.
* Anthropic — ``thinking`` (an effort level has no wire form there).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from .routing import model_config_for, resolve_effective_provider
if TYPE_CHECKING:
from .models import AgentConfig, Provider, ReasoningEffortConfig
# DeepSeek's own ``reasoning_effort`` accepts "max"; OpenAI and Anthropic do not,
# so anywhere else it clamps down to the highest level they do understand.
_DEEPSEEK_CHAT_CONVENTIONS = frozenset({"", "openai"})
@dataclass(frozen=True, slots=True)
class ReasoningConfig:
"""Merged thinking settings for one provider + model."""
effort: ReasoningEffortConfig = ""
# Explicit Anthropic ``thinking`` budget; 0 = derive it from ``effort``.
thinking_budget_tokens: int = 0
def _clamp_effort(effort: ReasoningEffortConfig, *, preset: str, convention: str) -> ReasoningEffortConfig:
"""Keep ``max`` only on the DeepSeek chat surface that defines it."""
if effort != "max":
return effort
if preset == "deepseek" and convention in _DEEPSEEK_CHAT_CONVENTIONS:
return effort
return "xhigh"
def resolve_reasoning(
agent: AgentConfig | None,
provider: Provider,
model: str | None,
) -> ReasoningConfig:
"""Merge ``[agent]`` < provider < model thinking settings for *model*.
*agent* is ``None`` for callers that only know the provider (config tools,
tests); the model layer is the provider's entry for *model*, if any. Each
field falls back on its own, so a model may inherit only the effort (or only
the budget) from above.
"""
effective = resolve_effective_provider(provider, model=model)
model_config = model_config_for(provider, model)
effort: ReasoningEffortConfig = ""
for layer in (model_config, provider, agent):
if layer is not None and layer.reasoning_effort:
effort = layer.reasoning_effort
break
budget = 0
for layer in (model_config, provider, agent):
if layer is not None and layer.thinking_budget_tokens > 0:
budget = layer.thinking_budget_tokens
break
return ReasoningConfig(
effort=_clamp_effort(effort, preset=effective.preset, convention=effective.convention),
thinking_budget_tokens=budget,
)
__all__ = ["ReasoningConfig", "resolve_reasoning"]
+2
View File
@@ -29,6 +29,8 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
assert store.agent_config.allow_paths == {}
assert store.agent_config.auto_continue_limits is False
assert store.agent_config.max_context_tokens == 200_000
assert store.agent_config.reasoning_effort == ""
assert store.agent_config.thinking_budget_tokens == 0
assert store.plugins_config.enable == []
assert store.plugins_config.disable == []
assert store.networking_config.ssrf_assume_public_cidrs == []
+64
View File
@@ -299,6 +299,70 @@ def test_read_bad_config() -> None:
assert isinstance(config.bad_providers, Mapping)
def test_reasoning_effort_from_toml(tmp_path: Path) -> None:
path = tmp_path / "reasoning.toml"
_ = path.write_text(
"""
[agent]
reasoning_effort = "low"
thinking_budget_tokens = 4000
[providers.ds]
preset = "deepseek"
access_key_or_token = "sk-test"
reasoning_effort = "high"
[providers.ds.models]
"deepseek-v4-pro" = { text = true, reasoning_effort = "max", thinking_budget_tokens = 32000 }
"deepseek-v4-flash" = { text = true }
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
agent_cfg = config.agent_config
assert agent_cfg.reasoning_effort == "low"
assert agent_cfg.thinking_budget_tokens == 4000
provider = config.providers["ds"]
assert provider.reasoning_effort == "high"
assert provider.thinking_budget_tokens == 0
assert provider.models["deepseek-v4-pro"].reasoning_effort == "max"
assert provider.models["deepseek-v4-pro"].thinking_budget_tokens == 32000
assert provider.models["deepseek-v4-flash"].reasoning_effort == ""
assert provider.models["deepseek-v4-flash"].thinking_budget_tokens == 0
def test_reasoning_effort_defaults_are_unset(tmp_path: Path) -> None:
path = tmp_path / "reasoning-default.toml"
_ = path.write_text(
"""
[providers.ds]
preset = "deepseek"
access_key_or_token = "sk-test"
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
assert config.agent_config.reasoning_effort == ""
assert config.agent_config.thinking_budget_tokens == 0
assert config.providers["ds"].reasoning_effort == ""
def test_reasoning_effort_invalid_is_bad_provider(tmp_path: Path) -> None:
path = tmp_path / "reasoning-bad.toml"
_ = path.write_text(
"""
[providers.ds]
preset = "deepseek"
access_key_or_token = "sk-test"
reasoning_effort = "highest"
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
assert "ds" not in config.providers
assert "ds" in config.bad_providers
def test_provider_with_empty_models_is_recoverable(tmp_path: Path) -> None:
path = tmp_path / "empty-models.toml"
_ = path.write_text(
+100
View File
@@ -0,0 +1,100 @@
"""Tests for thinking-strength resolution across config layers and surfaces."""
from __future__ import annotations
from plyngent.config.models import (
AgentConfig,
AnthropicProvider,
DeepseekProvider,
ModelConfig,
OpenAICompatibleProvider,
OpenAIProvider,
)
from plyngent.config.reasoning import resolve_reasoning
def _compat() -> OpenAICompatibleProvider:
return OpenAICompatibleProvider(access_key_or_token="sk-test", url="https://example.com/v1")
def test_agent_layer_only() -> None:
reasoning = resolve_reasoning(
AgentConfig(reasoning_effort="high", thinking_budget_tokens=9000),
_compat(),
"gpt-4o-mini",
)
assert reasoning.effort == "high"
assert reasoning.thinking_budget_tokens == 9000
def test_provider_overrides_agent_and_model_overrides_provider() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example.com/v1",
reasoning_effort="low",
models={"gpt-4o-mini": ModelConfig(reasoning_effort="xhigh")},
)
agent = AgentConfig(reasoning_effort="minimal")
assert resolve_reasoning(agent, provider, "gpt-4o-mini").effort == "xhigh"
assert resolve_reasoning(agent, provider, "other-model").effort == "low"
assert resolve_reasoning(agent, provider, None).effort == "low"
def test_fields_fall_back_independently() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example.com/v1",
reasoning_effort="high",
thinking_budget_tokens=20000,
models={"gpt-4o-mini": ModelConfig(thinking_budget_tokens=4000)},
)
reasoning = resolve_reasoning(AgentConfig(reasoning_effort="low"), provider, "gpt-4o-mini")
# Model sets the budget only → the effort still comes from the provider.
assert reasoning.effort == "high"
assert reasoning.thinking_budget_tokens == 4000
def test_agent_layer_optional() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example.com/v1",
reasoning_effort="medium",
)
assert resolve_reasoning(None, provider, "gpt-4o-mini").effort == "medium"
def test_unset_layers_stay_unspecified() -> None:
reasoning = resolve_reasoning(None, _compat(), "gpt-4o-mini")
assert reasoning.effort == ""
assert reasoning.thinking_budget_tokens == 0
def test_non_positive_budget_is_ignored() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example.com/v1",
thinking_budget_tokens=0,
)
resolved = resolve_reasoning(AgentConfig(thinking_budget_tokens=-5), provider, "gpt-4o-mini")
assert resolved.thinking_budget_tokens == 0
def test_max_survives_only_on_deepseek_chat() -> None:
chat = DeepseekProvider(access_key_or_token="sk-test", reasoning_effort="max")
assert resolve_reasoning(None, chat, "deepseek-v4-pro").effort == "max"
responses = DeepseekProvider(access_key_or_token="sk-test", convention="responses", reasoning_effort="max")
assert resolve_reasoning(None, responses, "deepseek-v4-pro").effort == "xhigh"
anthropic = AnthropicProvider(access_key_or_token="sk-test", reasoning_effort="max")
assert resolve_reasoning(None, anthropic, "claude-test").effort == "xhigh"
openai = OpenAIProvider(access_key_or_token="sk-test", reasoning_effort="max")
assert resolve_reasoning(None, openai, "gpt-5.4").effort == "xhigh"
def test_effort_levels_pass_through_unchanged() -> None:
provider = _compat()
for effort in ("none", "minimal", "low", "medium", "high", "xhigh"):
resolved = resolve_reasoning(AgentConfig(reasoning_effort=effort), provider, "gpt-4o-mini")
assert resolved.effort == effort