From 60bc5957a7c4db15e1e0e71bffac7175f1524678 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 6 Oct 2026 21:07:42 +0800 Subject: [PATCH] cli: resolve thinking settings for the chat session --- src/plyngent/cli/state.py | 15 ++++- src/plyngent/runtime/client_factory.py | 23 ++++++-- tests/test_cli/test_reasoning_state.py | 69 +++++++++++++++++++++++ tests/test_runtime/test_client_factory.py | 23 ++++++++ 4 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 tests/test_cli/test_reasoning_state.py diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 2b043de..63d8dfa 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -21,7 +21,7 @@ from plyngent.cli.models_source import ( fetch_remote_model_ids, model_choices_for_provider, ) -from plyngent.config import EffectiveProvider, resolve_effective_provider +from plyngent.config import EffectiveProvider, ReasoningConfig, resolve_effective_provider, resolve_reasoning from plyngent.memory.database.store import normalize_workspace from plyngent.runtime import create_client from plyngent.tools import InstanceState, SessionState @@ -101,7 +101,7 @@ class ReplState: _remote_models_error: str | None = field(default=None, init=False, repr=False) def __post_init__(self) -> None: - self.client = create_client(self.provider, model=self.model) + self.client = create_client(self.provider, model=self.model, reasoning=self.reasoning) self.workspace = Path(self.workspace).expanduser().resolve() self.instance_state.workspace_root = self.workspace self.instance_state.workspace.root = self.workspace @@ -447,6 +447,7 @@ class ReplState: memory=self.memory, session_id=self.session_id, max_rounds=self.max_rounds, + reasoning_effort=self.reasoning.effort, on_limit=on_limit, stream=self.stream_enabled, system_prompt=system_prompt, @@ -472,7 +473,7 @@ class ReplState: # Preserve live stream toggle if agent already exists. if hasattr(self, "agent"): self.stream_enabled = self.agent.stream - self.client = create_client(self.provider, model=self.model) + self.client = create_client(self.provider, model=self.model, reasoning=self.reasoning) self.agent = self._make_agent() # Restore history without re-marking already-stored messages as dirty. self.agent.replace_messages(messages, persist_from=persist_from) @@ -493,6 +494,14 @@ class ReplState: """Selected provider after model-level preset/url overrides.""" return resolve_effective_provider(self.provider, model=self.model) + @property + def reasoning(self) -> ReasoningConfig: + """Merged thinking settings for the selected provider + model. + + ``[agent]`` < provider < model; see :mod:`plyngent.config.reasoning`. + """ + return resolve_reasoning(self.config.agent_config, self.provider, self.model) + def invalidate_remote_models(self) -> None: """Drop cached remote model catalog (provider/client change).""" self._remote_models = None diff --git a/src/plyngent/runtime/client_factory.py b/src/plyngent/runtime/client_factory.py index 3ad3630..ce806b1 100644 --- a/src/plyngent/runtime/client_factory.py +++ b/src/plyngent/runtime/client_factory.py @@ -3,6 +3,7 @@ from __future__ import annotations import math from typing import TYPE_CHECKING +from plyngent.config.reasoning import ReasoningConfig, resolve_reasoning from plyngent.config.routing import EffectiveProvider, resolve_effective_provider from plyngent.lmproto.anthropic import AnthropicClient from plyngent.lmproto.anthropic.config import AnthropicConfig as AnthropicConfigCls @@ -103,7 +104,7 @@ def provider_to_openai_config( ) -def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient: +def _anthropic_client(effective: EffectiveProvider, reasoning: ReasoningConfig) -> AnthropicClient: """Build an Anthropic Messages client (real or DeepSeek convention) for *effective*.""" try: http_timeout = normalize_http_timeout(effective.timeout) @@ -115,6 +116,7 @@ def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient: api_key=effective.access_key_or_token, base_url=effective.url or DEFAULT_DEEPSEEK_BASE_URL, timeout=http_timeout, + thinking_budget_tokens=reasoning.thinking_budget_tokens, ) ) return AnthropicClient( @@ -122,11 +124,17 @@ def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient: api_key=effective.access_key_or_token, base_url=effective.url or DEFAULT_ANTHROPIC_BASE_URL, timeout=http_timeout, + thinking_budget_tokens=reasoning.thinking_budget_tokens, ) ) -def create_client(provider: Provider, *, model: str | None = None) -> ProtocolClient: +def create_client( + provider: Provider, + *, + model: str | None = None, + reasoning: ReasoningConfig | None = None, +) -> ProtocolClient: """Build a protocol client for *provider*, applying model-level routing. ``preset`` always decides API conventions: ``openai`` → /responses, @@ -134,8 +142,15 @@ def create_client(provider: Provider, *, model: str | None = None) -> ProtocolCl Exception: a ``deepseek`` preset picks the Responses or Anthropic surface when its effective ``convention`` is ``"responses"`` / ``"anthropic"``, else chat completions. + + *reasoning* is the merged thinking config for this provider + model (see + :func:`plyngent.config.reasoning.resolve_reasoning`); callers without an + ``[agent]`` layer may omit it and get the provider/model layers only. It only + reaches surfaces that cannot read the setting off the request param — + Anthropic needs a token budget, while chat/Responses carry an effort level. """ effective = resolve_effective_provider(provider, model=model) + spec = reasoning if reasoning is not None else resolve_reasoning(None, provider, model) if effective.preset == "openai": return OpenAIClient(provider_to_openai_config(effective)) if effective.preset == "openai-compatible": @@ -144,9 +159,9 @@ def create_client(provider: Provider, *, model: str | None = None) -> ProtocolCl if effective.convention == "responses": return DeepseekResponsesClient(provider_to_openai_config(effective)) if effective.convention == "anthropic": - return _anthropic_client(effective) + return _anthropic_client(effective, spec) return DeepseekOpenAIClient(provider_to_openai_config(effective)) if effective.preset == "anthropic": - return _anthropic_client(effective) + return _anthropic_client(effective, spec) msg = f"provider preset {effective.preset!r} is not supported" raise ProviderNotSupportedError(msg) diff --git a/tests/test_cli/test_reasoning_state.py b/tests/test_cli/test_reasoning_state.py new file mode 100644 index 0000000..14f3d65 --- /dev/null +++ b/tests/test_cli/test_reasoning_state.py @@ -0,0 +1,69 @@ +"""Wiring of resolved thinking settings into the CLI session state.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit + +from plyngent.cli.state import ReplState +from plyngent.config.models import AnthropicProvider, DatabaseConfig, ModelConfig +from plyngent.config.store import ConfigStore +from plyngent.lmproto.anthropic import AnthropicClient +from plyngent.memory import MemoryStore + +if TYPE_CHECKING: + from pathlib import Path + +_DOCUMENT = """ +[agent] +reasoning_effort = "low" +thinking_budget_tokens = 2000 +""" + + +async def _state(tmp_path: Path, provider: AnthropicProvider, model: str) -> tuple[ReplState, MemoryStore]: + memory = await MemoryStore.open(DatabaseConfig()) + config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.parse(_DOCUMENT)) + config.providers = {"local": provider} + state = ReplState( + config=config, + memory=memory, + workspace=tmp_path, + provider_name="local", + provider=provider, + model=model, + tools_enabled=False, + ) + await state.new_session("t") + return state, memory + + +@pytest.mark.asyncio +async def test_agent_and_client_get_resolved_thinking_settings(tmp_path: Path) -> None: + provider = AnthropicProvider( + access_key_or_token="sk-test", + reasoning_effort="high", + models={"claude-test": ModelConfig(reasoning_effort="max")}, + ) + state, memory = await _state(tmp_path, provider, "claude-test") + try: + # Model layer wins over the provider layer, and both over ``[agent]``. + assert state.agent.reasoning_effort == "xhigh" + assert isinstance(state.client, AnthropicClient) + assert state.client.thinking_budget_tokens == 2000 + finally: + await memory.close() + + +@pytest.mark.asyncio +async def test_provider_layer_wins_when_model_has_no_override(tmp_path: Path) -> None: + provider = AnthropicProvider(access_key_or_token="sk-test", thinking_budget_tokens=9000) + state, memory = await _state(tmp_path, provider, "claude-test") + try: + assert state.agent.reasoning_effort == "low" + assert isinstance(state.client, AnthropicClient) + assert state.client.thinking_budget_tokens == 9000 + finally: + await memory.close() diff --git a/tests/test_runtime/test_client_factory.py b/tests/test_runtime/test_client_factory.py index 76250c0..dbd8557 100644 --- a/tests/test_runtime/test_client_factory.py +++ b/tests/test_runtime/test_client_factory.py @@ -167,6 +167,29 @@ def test_anthropic_client_created() -> None: assert client.session.timeout == 30 +def test_anthropic_client_thinking_budget_from_config() -> None: + from plyngent.config.reasoning import ReasoningConfig + from plyngent.lmproto.anthropic import AnthropicClient + + provider = AnthropicProvider( + access_key_or_token="sk-test", + thinking_budget_tokens=8000, + models={"claude-test": ModelConfig(thinking_budget_tokens=16000)}, + ) + default_client = create_client(provider, model="claude-test") + assert isinstance(default_client, AnthropicClient) + assert default_client.thinking_budget_tokens == 16000 + + # An explicit (resolved) reasoning spec wins over the provider/model layers. + client = create_client( + provider, + model="claude-test", + reasoning=ReasoningConfig(effort="high", thinking_budget_tokens=4000), + ) + assert isinstance(client, AnthropicClient) + assert client.thinking_budget_tokens == 4000 + + def test_model_level_preset_url_overrides_parent() -> None: provider = OpenAICompatibleProvider( access_key_or_token="sk-test",