mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
config: resolve thinking strength across agent, provider, and model layers
This commit is contained in:
@@ -21,10 +21,13 @@ from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
|
|||||||
from .models import OpenAIProvider as OpenAIProvider
|
from .models import OpenAIProvider as OpenAIProvider
|
||||||
from .models import PluginsConfig as PluginsConfig
|
from .models import PluginsConfig as PluginsConfig
|
||||||
from .models import ProviderConfig as ProviderConfig
|
from .models import ProviderConfig as ProviderConfig
|
||||||
|
from .models import ReasoningEffortConfig as ReasoningEffortConfig
|
||||||
from .models import SkillDiscoverySource as SkillDiscoverySource
|
from .models import SkillDiscoverySource as SkillDiscoverySource
|
||||||
from .models import SkillsConfig as SkillsConfig
|
from .models import SkillsConfig as SkillsConfig
|
||||||
from .models import compose_agent_system_content as compose_agent_system_content
|
from .models import compose_agent_system_content as compose_agent_system_content
|
||||||
from .path import get_default_path as _get_default_path
|
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 EffectiveProvider as EffectiveProvider
|
||||||
from .routing import ProviderPreset as ProviderPreset
|
from .routing import ProviderPreset as ProviderPreset
|
||||||
from .routing import default_url_for_preset as default_url_for_preset
|
from .routing import default_url_for_preset as default_url_for_preset
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
|
|||||||
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
|
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
|
||||||
type AllowPathMode = Literal["read", "write", "exec"]
|
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
|
# Built-in skill discovery sources (``[skills].discover``): our own user config
|
||||||
# directory, and the skill directories other harnesses install into.
|
# directory, and the skill directories other harnesses install into.
|
||||||
type SkillDiscoverySource = Literal["plyngent", "claude"]
|
type SkillDiscoverySource = Literal["plyngent", "claude"]
|
||||||
@@ -150,6 +158,14 @@ class AgentConfig(Struct, omit_defaults=True):
|
|||||||
compact_user_prefix: str = ""
|
compact_user_prefix: str = ""
|
||||||
compact_seed_text: 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):
|
class PluginsConfig(Struct, omit_defaults=True):
|
||||||
"""Third-party plugins (not tool-specific config).
|
"""Third-party plugins (not tool-specific config).
|
||||||
@@ -268,6 +284,9 @@ class ModelConfig(Struct, omit_defaults=True):
|
|||||||
url: str = ""
|
url: str = ""
|
||||||
# DeepSeek API surface override ("" = inherit provider convention).
|
# DeepSeek API surface override ("" = inherit provider convention).
|
||||||
convention: DeepSeekConvention = ""
|
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):
|
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)
|
models: dict[str, ModelConfig] = field(default_factory=dict)
|
||||||
# float = single timeout for the session; table = connect/read split; omit = defaults.
|
# float = single timeout for the session; table = connect/read split; omit = defaults.
|
||||||
timeout: float | HttpTimeoutConfig | None = None
|
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]:
|
def _default_openai_models() -> dict[str, ModelConfig]:
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -29,6 +29,8 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
|
|||||||
assert store.agent_config.allow_paths == {}
|
assert store.agent_config.allow_paths == {}
|
||||||
assert store.agent_config.auto_continue_limits is False
|
assert store.agent_config.auto_continue_limits is False
|
||||||
assert store.agent_config.max_context_tokens == 200_000
|
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.enable == []
|
||||||
assert store.plugins_config.disable == []
|
assert store.plugins_config.disable == []
|
||||||
assert store.networking_config.ssrf_assume_public_cidrs == []
|
assert store.networking_config.ssrf_assume_public_cidrs == []
|
||||||
|
|||||||
@@ -299,6 +299,70 @@ def test_read_bad_config() -> None:
|
|||||||
assert isinstance(config.bad_providers, Mapping)
|
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:
|
def test_provider_with_empty_models_is_recoverable(tmp_path: Path) -> None:
|
||||||
path = tmp_path / "empty-models.toml"
|
path = tmp_path / "empty-models.toml"
|
||||||
_ = path.write_text(
|
_ = path.write_text(
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user