mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
cli: resolve thinking settings for the chat session
This commit is contained in:
@@ -21,7 +21,7 @@ from plyngent.cli.models_source import (
|
|||||||
fetch_remote_model_ids,
|
fetch_remote_model_ids,
|
||||||
model_choices_for_provider,
|
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.memory.database.store import normalize_workspace
|
||||||
from plyngent.runtime import create_client
|
from plyngent.runtime import create_client
|
||||||
from plyngent.tools import InstanceState, SessionState
|
from plyngent.tools import InstanceState, SessionState
|
||||||
@@ -101,7 +101,7 @@ class ReplState:
|
|||||||
_remote_models_error: str | None = field(default=None, init=False, repr=False)
|
_remote_models_error: str | None = field(default=None, init=False, repr=False)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
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.workspace = Path(self.workspace).expanduser().resolve()
|
||||||
self.instance_state.workspace_root = self.workspace
|
self.instance_state.workspace_root = self.workspace
|
||||||
self.instance_state.workspace.root = self.workspace
|
self.instance_state.workspace.root = self.workspace
|
||||||
@@ -447,6 +447,7 @@ class ReplState:
|
|||||||
memory=self.memory,
|
memory=self.memory,
|
||||||
session_id=self.session_id,
|
session_id=self.session_id,
|
||||||
max_rounds=self.max_rounds,
|
max_rounds=self.max_rounds,
|
||||||
|
reasoning_effort=self.reasoning.effort,
|
||||||
on_limit=on_limit,
|
on_limit=on_limit,
|
||||||
stream=self.stream_enabled,
|
stream=self.stream_enabled,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
@@ -472,7 +473,7 @@ class ReplState:
|
|||||||
# Preserve live stream toggle if agent already exists.
|
# Preserve live stream toggle if agent already exists.
|
||||||
if hasattr(self, "agent"):
|
if hasattr(self, "agent"):
|
||||||
self.stream_enabled = self.agent.stream
|
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()
|
self.agent = self._make_agent()
|
||||||
# Restore history without re-marking already-stored messages as dirty.
|
# Restore history without re-marking already-stored messages as dirty.
|
||||||
self.agent.replace_messages(messages, persist_from=persist_from)
|
self.agent.replace_messages(messages, persist_from=persist_from)
|
||||||
@@ -493,6 +494,14 @@ class ReplState:
|
|||||||
"""Selected provider after model-level preset/url overrides."""
|
"""Selected provider after model-level preset/url overrides."""
|
||||||
return resolve_effective_provider(self.provider, model=self.model)
|
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:
|
def invalidate_remote_models(self) -> None:
|
||||||
"""Drop cached remote model catalog (provider/client change)."""
|
"""Drop cached remote model catalog (provider/client change)."""
|
||||||
self._remote_models = None
|
self._remote_models = None
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from plyngent.config.reasoning import ReasoningConfig, resolve_reasoning
|
||||||
from plyngent.config.routing import EffectiveProvider, resolve_effective_provider
|
from plyngent.config.routing import EffectiveProvider, resolve_effective_provider
|
||||||
from plyngent.lmproto.anthropic import AnthropicClient
|
from plyngent.lmproto.anthropic import AnthropicClient
|
||||||
from plyngent.lmproto.anthropic.config import AnthropicConfig as AnthropicConfigCls
|
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*."""
|
"""Build an Anthropic Messages client (real or DeepSeek convention) for *effective*."""
|
||||||
try:
|
try:
|
||||||
http_timeout = normalize_http_timeout(effective.timeout)
|
http_timeout = normalize_http_timeout(effective.timeout)
|
||||||
@@ -115,6 +116,7 @@ def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient:
|
|||||||
api_key=effective.access_key_or_token,
|
api_key=effective.access_key_or_token,
|
||||||
base_url=effective.url or DEFAULT_DEEPSEEK_BASE_URL,
|
base_url=effective.url or DEFAULT_DEEPSEEK_BASE_URL,
|
||||||
timeout=http_timeout,
|
timeout=http_timeout,
|
||||||
|
thinking_budget_tokens=reasoning.thinking_budget_tokens,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return AnthropicClient(
|
return AnthropicClient(
|
||||||
@@ -122,11 +124,17 @@ def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient:
|
|||||||
api_key=effective.access_key_or_token,
|
api_key=effective.access_key_or_token,
|
||||||
base_url=effective.url or DEFAULT_ANTHROPIC_BASE_URL,
|
base_url=effective.url or DEFAULT_ANTHROPIC_BASE_URL,
|
||||||
timeout=http_timeout,
|
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.
|
"""Build a protocol client for *provider*, applying model-level routing.
|
||||||
|
|
||||||
``preset`` always decides API conventions: ``openai`` → /responses,
|
``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
|
Exception: a ``deepseek`` preset picks the Responses or Anthropic surface
|
||||||
when its effective ``convention`` is ``"responses"`` / ``"anthropic"``,
|
when its effective ``convention`` is ``"responses"`` / ``"anthropic"``,
|
||||||
else chat completions.
|
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)
|
effective = resolve_effective_provider(provider, model=model)
|
||||||
|
spec = reasoning if reasoning is not None else resolve_reasoning(None, provider, model)
|
||||||
if effective.preset == "openai":
|
if effective.preset == "openai":
|
||||||
return OpenAIClient(provider_to_openai_config(effective))
|
return OpenAIClient(provider_to_openai_config(effective))
|
||||||
if effective.preset == "openai-compatible":
|
if effective.preset == "openai-compatible":
|
||||||
@@ -144,9 +159,9 @@ def create_client(provider: Provider, *, model: str | None = None) -> ProtocolCl
|
|||||||
if effective.convention == "responses":
|
if effective.convention == "responses":
|
||||||
return DeepseekResponsesClient(provider_to_openai_config(effective))
|
return DeepseekResponsesClient(provider_to_openai_config(effective))
|
||||||
if effective.convention == "anthropic":
|
if effective.convention == "anthropic":
|
||||||
return _anthropic_client(effective)
|
return _anthropic_client(effective, spec)
|
||||||
return DeepseekOpenAIClient(provider_to_openai_config(effective))
|
return DeepseekOpenAIClient(provider_to_openai_config(effective))
|
||||||
if effective.preset == "anthropic":
|
if effective.preset == "anthropic":
|
||||||
return _anthropic_client(effective)
|
return _anthropic_client(effective, spec)
|
||||||
msg = f"provider preset {effective.preset!r} is not supported"
|
msg = f"provider preset {effective.preset!r} is not supported"
|
||||||
raise ProviderNotSupportedError(msg)
|
raise ProviderNotSupportedError(msg)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -167,6 +167,29 @@ def test_anthropic_client_created() -> None:
|
|||||||
assert client.session.timeout == 30
|
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:
|
def test_model_level_preset_url_overrides_parent() -> None:
|
||||||
provider = OpenAICompatibleProvider(
|
provider = OpenAICompatibleProvider(
|
||||||
access_key_or_token="sk-test",
|
access_key_or_token="sk-test",
|
||||||
|
|||||||
Reference in New Issue
Block a user