From 3b72698e14a0a722a3791f25dbe8e667e5840e7a Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 28 Jul 2026 21:44:12 +0800 Subject: [PATCH] core/runtime: route clients by model-level preset --- src/plyngent/cli/app.py | 2 +- src/plyngent/cli/slash.py | 4 +- src/plyngent/cli/state.py | 12 +++- src/plyngent/runtime/client_factory.py | 83 ++++++++++---------------- 4 files changed, 47 insertions(+), 54 deletions(-) diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index 7fe021d..261a583 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -268,7 +268,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration preferred_model=preferred_model, interactive=interactive, ): - client = create_client(provider) + client = create_client(provider, model=preferred_model) try: if client_supports_models(client): remote_ids = await fetch_remote_model_ids(client) diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index ba4d56a..d26eb20 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -532,8 +532,10 @@ def status_cmd(state: ReplState) -> None: last_rounds = state.agent.last_turn_rounds ctx_tag = "api" if ctx_src == "api" else "est" ctx_tilde = "" if ctx_src == "api" else "~" + effective = state.effective_provider click.echo( - f"provider={state.provider_name} model={state.model}\n" + f"provider={state.provider_name} model={state.model} " + f"preset={effective.preset} url={effective.url}\n" f"session={state.session_id} messages={len(state.agent.messages)} " f"pending_retry={pending_disp}\n" f"tools={'on' if state.tools_enabled else 'off'} " diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index ec87d8f..1600777 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -16,6 +16,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.memory.database.store import normalize_workspace from plyngent.runtime import create_client from plyngent.tools import InstanceState, SessionState @@ -72,7 +73,7 @@ class ReplState: def __post_init__(self) -> None: # DeepSeek client uses a compatible but distinct param type; treat as ChatClient. - self.client = cast("ChatClient", cast("object", create_client(self.provider))) + self.client = cast("ChatClient", cast("object", create_client(self.provider, model=self.model))) self.workspace = Path(self.workspace).expanduser().resolve() self.instance_state.workspace_root = self.workspace self.instance_state.workspace.root = self.workspace @@ -274,7 +275,7 @@ class ReplState: # Preserve live stream toggle if agent already exists. if hasattr(self, "agent"): self.stream_enabled = self.agent.stream - self.client = cast("ChatClient", cast("object", create_client(self.provider))) + self.client = cast("ChatClient", cast("object", create_client(self.provider, model=self.model))) self.agent = self._make_agent() # Restore history without re-marking already-stored messages as dirty. self.agent.replace_messages(messages, persist_from=persist_from) @@ -285,9 +286,16 @@ class ReplState: self.invalidate_remote_models() def _models_cache_key(self) -> tuple[str, str]: + # Remote model lists belong to the provider endpoint used for discovery, + # not model-level overrides (the model is not selected yet there). url = getattr(self.provider, "url", "") or "" return (self.provider_name, str(url)) + @property + def effective_provider(self) -> EffectiveProvider: + """Selected provider after model-level preset/url overrides.""" + return resolve_effective_provider(self.provider, model=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 a76a537..221865c 100644 --- a/src/plyngent/runtime/client_factory.py +++ b/src/plyngent/runtime/client_factory.py @@ -3,13 +3,7 @@ from __future__ import annotations import math from typing import TYPE_CHECKING -from plyngent.config.models import ( - DeepseekProvider, - HttpTimeoutConfig, - OpenAICompatibleProvider, - OpenAIProvider, - Provider, -) +from plyngent.config.routing import EffectiveProvider, resolve_effective_provider from plyngent.lmproto.deepseek import DeepseekOpenAIClient from plyngent.lmproto.openai import OpenAIClient from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig @@ -20,11 +14,11 @@ from plyngent.lmproto.openai_compatible.config import ( ) if TYPE_CHECKING: - from collections.abc import Mapping - from plyngent.agent.responses_client import ResponsesChatClient + from plyngent.config.models import HttpTimeoutConfig, Provider DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" +DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1" DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com" type ProtocolClient = OpenAIClient | OpenAICompatibleClient | DeepseekOpenAIClient | ResponsesChatClient @@ -62,7 +56,6 @@ def normalize_http_timeout(timeout: float | HttpTimeoutConfig | None) -> HttpTim raise InvalidHttpTimeoutError(msg) return value - # Remaining union member: HttpTimeoutConfig connect = DEFAULT_HTTP_CONNECT_TIMEOUT if timeout.connect is None else float(timeout.connect) read = DEFAULT_HTTP_READ_TIMEOUT if timeout.read is None else float(timeout.read) if not math.isfinite(connect) or connect <= 0: @@ -74,60 +67,50 @@ def normalize_http_timeout(timeout: float | HttpTimeoutConfig | None) -> HttpTim return (connect, read) -def provider_to_openai_config(provider: OpenAIProvider | OpenAICompatibleProvider | DeepseekProvider) -> OpenAIConfig: - """Map a provider config entry to :class:`OpenAIConfig`.""" - if isinstance(provider, OpenAIProvider): - base_url = provider.url or DEFAULT_OPENAI_BASE_URL - elif isinstance(provider, DeepseekProvider): - base_url = provider.url or DEFAULT_DEEPSEEK_BASE_URL +def provider_to_openai_config( + provider: Provider | EffectiveProvider, + *, + model: str | None = None, +) -> OpenAIConfig: + """Map provider/effective config to the shared OpenAI-compatible HTTP config.""" + if isinstance(provider, EffectiveProvider): + effective = provider else: - base_url = provider.url - if not base_url: - msg = "openai-compatible provider requires a non-empty url" - raise ProviderNotSupportedError(msg) + effective = resolve_effective_provider(provider, model=model) + if not effective.url and effective.preset in {"openai-compatible", "deepseek"}: + msg = f"{effective.preset} provider requires a non-empty url" + raise ProviderNotSupportedError(msg) try: - http_timeout = normalize_http_timeout(provider.timeout) + http_timeout = normalize_http_timeout(effective.timeout) except InvalidHttpTimeoutError as exc: raise ProviderNotSupportedError(str(exc)) from exc return OpenAIConfig( - access_key_or_token=provider.access_key_or_token, - base_url=base_url, + access_key_or_token=effective.access_key_or_token, + base_url=effective.url, timeout=http_timeout, ) -def _deepseek_convention(extras: Mapping[str, str]) -> str: - return extras.get("convention", "openai").lower() +def create_client(provider: Provider, *, model: str | None = None) -> ProtocolClient: + """Build a protocol client for *provider*, applying model-level routing. - -def create_client(provider: Provider) -> ProtocolClient: - """Build a protocol client for the given provider config entry. - - OpenAI platform providers are wrapped so the agent uses the Responses API - while still exposing a chat-completions-shaped interface. - - Raises: - ProviderNotSupportedError: When the provider preset (or DeepSeek convention) - has no implemented client yet. + ``preset`` always decides API conventions: ``openai`` → /responses, + ``openai-compatible`` → /chat/completions, ``anthropic`` → /messages. """ - if isinstance(provider, OpenAIProvider): + effective = resolve_effective_provider(provider, model=model) + if effective.preset == "openai": from plyngent.agent.responses_client import wrap_openai_for_agent return wrap_openai_for_agent( - OpenAIClient(provider_to_openai_config(provider)), - provider_tools=provider.provider_tools or None, + OpenAIClient(provider_to_openai_config(effective)), + provider_tools=effective.provider_tools or None, ) - if isinstance(provider, OpenAICompatibleProvider): - return OpenAICompatibleClient(provider_to_openai_config(provider)) - if isinstance(provider, DeepseekProvider): - convention = _deepseek_convention(provider.extras) - if convention in {"openai", "openai_compat", "openai-compatible"}: - return DeepseekOpenAIClient(provider_to_openai_config(provider)) - if convention == "anthropic": - msg = "deepseek anthropic convention is not implemented" - raise ProviderNotSupportedError(msg) - msg = f"unknown deepseek convention {convention!r}" + if effective.preset == "openai-compatible": + return OpenAICompatibleClient(provider_to_openai_config(effective)) + if effective.preset == "deepseek": + return DeepseekOpenAIClient(provider_to_openai_config(effective)) + if effective.preset == "anthropic": + msg = "anthropic preset is configured but native Anthropic client is not implemented yet" raise ProviderNotSupportedError(msg) - # Remaining Provider variant: AnthropicProvider - msg = "anthropic provider client is not implemented" + msg = f"provider preset {effective.preset!r} is not supported" raise ProviderNotSupportedError(msg)