core/runtime: route clients by model-level preset

This commit is contained in:
2026-07-28 21:44:12 +08:00
parent 68025ca626
commit 3b72698e14
4 changed files with 47 additions and 54 deletions
+1 -1
View File
@@ -268,7 +268,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
preferred_model=preferred_model, preferred_model=preferred_model,
interactive=interactive, interactive=interactive,
): ):
client = create_client(provider) client = create_client(provider, model=preferred_model)
try: try:
if client_supports_models(client): if client_supports_models(client):
remote_ids = await fetch_remote_model_ids(client) remote_ids = await fetch_remote_model_ids(client)
+3 -1
View File
@@ -532,8 +532,10 @@ def status_cmd(state: ReplState) -> None:
last_rounds = state.agent.last_turn_rounds last_rounds = state.agent.last_turn_rounds
ctx_tag = "api" if ctx_src == "api" else "est" ctx_tag = "api" if ctx_src == "api" else "est"
ctx_tilde = "" if ctx_src == "api" else "~" ctx_tilde = "" if ctx_src == "api" else "~"
effective = state.effective_provider
click.echo( 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"session={state.session_id} messages={len(state.agent.messages)} "
f"pending_retry={pending_disp}\n" f"pending_retry={pending_disp}\n"
f"tools={'on' if state.tools_enabled else 'off'} " f"tools={'on' if state.tools_enabled else 'off'} "
+10 -2
View File
@@ -16,6 +16,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.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
@@ -72,7 +73,7 @@ class ReplState:
def __post_init__(self) -> None: def __post_init__(self) -> None:
# DeepSeek client uses a compatible but distinct param type; treat as ChatClient. # 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.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
@@ -274,7 +275,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 = 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() 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)
@@ -285,9 +286,16 @@ class ReplState:
self.invalidate_remote_models() self.invalidate_remote_models()
def _models_cache_key(self) -> tuple[str, str]: 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 "" url = getattr(self.provider, "url", "") or ""
return (self.provider_name, str(url)) 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: 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
+33 -50
View File
@@ -3,13 +3,7 @@ from __future__ import annotations
import math import math
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from plyngent.config.models import ( from plyngent.config.routing import EffectiveProvider, resolve_effective_provider
DeepseekProvider,
HttpTimeoutConfig,
OpenAICompatibleProvider,
OpenAIProvider,
Provider,
)
from plyngent.lmproto.deepseek import DeepseekOpenAIClient from plyngent.lmproto.deepseek import DeepseekOpenAIClient
from plyngent.lmproto.openai import OpenAIClient from plyngent.lmproto.openai import OpenAIClient
from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig
@@ -20,11 +14,11 @@ from plyngent.lmproto.openai_compatible.config import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping
from plyngent.agent.responses_client import ResponsesChatClient from plyngent.agent.responses_client import ResponsesChatClient
from plyngent.config.models import HttpTimeoutConfig, Provider
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" 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" DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com"
type ProtocolClient = OpenAIClient | OpenAICompatibleClient | DeepseekOpenAIClient | ResponsesChatClient type ProtocolClient = OpenAIClient | OpenAICompatibleClient | DeepseekOpenAIClient | ResponsesChatClient
@@ -62,7 +56,6 @@ def normalize_http_timeout(timeout: float | HttpTimeoutConfig | None) -> HttpTim
raise InvalidHttpTimeoutError(msg) raise InvalidHttpTimeoutError(msg)
return value return value
# Remaining union member: HttpTimeoutConfig
connect = DEFAULT_HTTP_CONNECT_TIMEOUT if timeout.connect is None else float(timeout.connect) 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) read = DEFAULT_HTTP_READ_TIMEOUT if timeout.read is None else float(timeout.read)
if not math.isfinite(connect) or connect <= 0: if not math.isfinite(connect) or connect <= 0:
@@ -74,60 +67,50 @@ def normalize_http_timeout(timeout: float | HttpTimeoutConfig | None) -> HttpTim
return (connect, read) return (connect, read)
def provider_to_openai_config(provider: OpenAIProvider | OpenAICompatibleProvider | DeepseekProvider) -> OpenAIConfig: def provider_to_openai_config(
"""Map a provider config entry to :class:`OpenAIConfig`.""" provider: Provider | EffectiveProvider,
if isinstance(provider, OpenAIProvider): *,
base_url = provider.url or DEFAULT_OPENAI_BASE_URL model: str | None = None,
elif isinstance(provider, DeepseekProvider): ) -> OpenAIConfig:
base_url = provider.url or DEFAULT_DEEPSEEK_BASE_URL """Map provider/effective config to the shared OpenAI-compatible HTTP config."""
if isinstance(provider, EffectiveProvider):
effective = provider
else: else:
base_url = provider.url effective = resolve_effective_provider(provider, model=model)
if not base_url: if not effective.url and effective.preset in {"openai-compatible", "deepseek"}:
msg = "openai-compatible provider requires a non-empty url" msg = f"{effective.preset} provider requires a non-empty url"
raise ProviderNotSupportedError(msg) raise ProviderNotSupportedError(msg)
try: try:
http_timeout = normalize_http_timeout(provider.timeout) http_timeout = normalize_http_timeout(effective.timeout)
except InvalidHttpTimeoutError as exc: except InvalidHttpTimeoutError as exc:
raise ProviderNotSupportedError(str(exc)) from exc raise ProviderNotSupportedError(str(exc)) from exc
return OpenAIConfig( return OpenAIConfig(
access_key_or_token=provider.access_key_or_token, access_key_or_token=effective.access_key_or_token,
base_url=base_url, base_url=effective.url,
timeout=http_timeout, timeout=http_timeout,
) )
def _deepseek_convention(extras: Mapping[str, str]) -> str: def create_client(provider: Provider, *, model: str | None = None) -> ProtocolClient:
return extras.get("convention", "openai").lower() """Build a protocol client for *provider*, applying model-level routing.
``preset`` always decides API conventions: ``openai`` → /responses,
def create_client(provider: Provider) -> ProtocolClient: ``openai-compatible`` → /chat/completions, ``anthropic`` → /messages.
"""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.
""" """
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 from plyngent.agent.responses_client import wrap_openai_for_agent
return wrap_openai_for_agent( return wrap_openai_for_agent(
OpenAIClient(provider_to_openai_config(provider)), OpenAIClient(provider_to_openai_config(effective)),
provider_tools=provider.provider_tools or None, provider_tools=effective.provider_tools or None,
) )
if isinstance(provider, OpenAICompatibleProvider): if effective.preset == "openai-compatible":
return OpenAICompatibleClient(provider_to_openai_config(provider)) return OpenAICompatibleClient(provider_to_openai_config(effective))
if isinstance(provider, DeepseekProvider): if effective.preset == "deepseek":
convention = _deepseek_convention(provider.extras) return DeepseekOpenAIClient(provider_to_openai_config(effective))
if convention in {"openai", "openai_compat", "openai-compatible"}: if effective.preset == "anthropic":
return DeepseekOpenAIClient(provider_to_openai_config(provider)) msg = "anthropic preset is configured but native Anthropic client is not implemented yet"
if convention == "anthropic":
msg = "deepseek anthropic convention is not implemented"
raise ProviderNotSupportedError(msg)
msg = f"unknown deepseek convention {convention!r}"
raise ProviderNotSupportedError(msg) raise ProviderNotSupportedError(msg)
# Remaining Provider variant: AnthropicProvider msg = f"provider preset {effective.preset!r} is not supported"
msg = "anthropic provider client is not implemented"
raise ProviderNotSupportedError(msg) raise ProviderNotSupportedError(msg)