core/cli: remote-first model lists; always fetch GET /models

Prefer provider catalog over config for selection/Tab; fetch at startup,
/provider, /model, and /models. Config-only ids remain as fallback.
This commit is contained in:
2026-07-17 15:36:06 +08:00
parent 18008de7f1
commit 8ebe68b3af
5 changed files with 79 additions and 21 deletions
+25 -3
View File
@@ -180,7 +180,7 @@ async def _run_oneshot(state: ReplState, prompt_text: str) -> int:
return EXIT_OK if ok else EXIT_TURN_FAILED
async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
*,
config_path: Path | None,
provider_name: str | None,
@@ -245,8 +245,27 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
preferred_model=preferred_model,
interactive=interactive,
)
model_id = select_model(provider, preferred=preferred_model, interactive=interactive)
_ = create_client(provider)
# Build client early so we can always try GET /models for remote-first lists.
from plyngent.cli.models_source import (
client_supports_models,
fetch_remote_model_ids,
model_choices_for_provider,
)
client = create_client(provider)
remote_ids: list[str] | None = None
try:
if client_supports_models(client):
remote_ids = await fetch_remote_model_ids(client)
except (RuntimeError, TypeError, OSError, ValueError):
remote_ids = None
choices = model_choices_for_provider(provider, remote_ids=remote_ids)
model_id = select_model(
provider,
preferred=preferred_model,
interactive=interactive,
choices=choices,
)
except ProviderNotSupportedError as exc:
raise click.ClickException(str(exc)) from exc
@@ -263,6 +282,9 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
interactive_limits=interactive,
yolo=yolo,
)
# Seed remote model cache from startup fetch so Tab/complete stays warm.
if remote_ids is not None:
state.seed_remote_models(remote_ids)
if not quiet and not oneshot:
click.secho(f"workspace: {state.workspace}", fg="bright_black", err=True)
+29 -8
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import inspect
from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable
from typing import TYPE_CHECKING, Literal, Protocol, cast, runtime_checkable
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
@@ -11,6 +11,8 @@ if TYPE_CHECKING:
# Cache remote catalog this long (seconds) unless /models --refresh.
DEFAULT_MODELS_CACHE_TTL = 300.0
type ModelListPrefer = Literal["remote", "union", "config"]
@runtime_checkable
class SupportsModels(Protocol):
@@ -25,12 +27,30 @@ def config_model_ids(provider: Provider) -> list[str]:
def merge_model_choices(
config_ids: Iterable[str],
remote_ids: Iterable[str] | None = None,
*,
prefer: ModelListPrefer = "remote",
) -> list[str]:
"""Union config and remote ids (sorted, unique)."""
merged: set[str] = {i for i in config_ids if i}
if remote_ids is not None:
merged.update(i for i in remote_ids if i)
return sorted(merged)
"""Merge config and remote model ids.
*prefer*:
- ``remote`` (default): remote catalog first (sorted), then config-only ids
- ``union``: sorted unique union
- ``config``: config first, then remote-only ids
"""
config_list = [i for i in config_ids if i]
remote_list = [i for i in (remote_ids or ()) if i]
if not remote_list:
return sorted(set(config_list))
if prefer == "union":
return sorted(set(config_list) | set(remote_list))
remote_sorted = sorted(set(remote_list))
config_only = sorted(set(config_list) - set(remote_sorted))
if prefer == "remote":
return [*remote_sorted, *config_only]
# config first
config_sorted = sorted(set(config_list))
remote_only = sorted(set(remote_list) - set(config_sorted))
return [*config_sorted, *remote_only]
def client_supports_models(client: object) -> bool:
@@ -57,6 +77,7 @@ def model_choices_for_provider(
provider: Provider,
*,
remote_ids: Sequence[str] | None = None,
prefer: ModelListPrefer = "remote",
) -> list[str]:
"""Config plus remote catalog for selection / Tab complete."""
return merge_model_choices(config_model_ids(provider), remote_ids)
"""Config plus remote catalog for selection / Tab complete (remote-first)."""
return merge_model_choices(config_model_ids(provider), remote_ids, prefer=prefer)
+11 -8
View File
@@ -607,7 +607,8 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
state.provider_name = pname
state.provider = provider
state.rebuild_client()
choices = _await(state.merged_model_choices(refresh=False))
# Always request remote catalog for the new provider (bypass stale cache).
choices = _await(state.merged_model_choices(refresh=True))
if prev_model and (prev_model in choices or prev_model in provider.models):
state.model = prev_model
else:
@@ -638,11 +639,12 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
@click.option("--refresh", is_flag=True, help="Bypass cache and re-fetch GET /models.")
@click.pass_obj
def models_cmd(state: ReplState, *, refresh: bool) -> None:
"""List models (config plus remote GET /models)."""
"""List models (remote-first, plus config-only ids). Always tries GET /models."""
del refresh # always re-fetch; flag kept for CLI compatibility / docs
remote: list[str] | None = None
remote_err: str | None = None
try:
remote = _await(state.ensure_remote_models(refresh=refresh))
remote = _await(state.ensure_remote_models(refresh=True))
except (RuntimeError, TypeError, OSError, ValueError) as exc:
remote_err = str(exc)
remote = state.cached_remote_models()
@@ -669,10 +671,10 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
remote_set = set(remote or ())
for mid in choices:
tags: list[str] = []
if mid in config_ids:
tags.append("config")
if mid in remote_set:
tags.append("remote")
if mid in config_ids:
tags.append("config")
suffix = f" ({', '.join(tags)})" if tags else ""
mark = " *" if mid == state.model else ""
click.echo(f"{mid}{mark}{suffix}")
@@ -681,7 +683,8 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
click.secho(f"remote list unavailable: {remote_err}", fg="yellow", err=True)
elif remote is not None:
click.echo(
f"({len(remote)} remote, {len(config_ids)} config; cache TTL {int(DEFAULT_MODELS_CACHE_TTL)}s)",
f"(remote-first: {len(remote)} remote, {len(config_ids)} config; "
f"cache TTL {int(DEFAULT_MODELS_CACHE_TTL)}s)",
err=True,
)
@@ -690,12 +693,12 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
@click.argument("model_id", required=False, type=MODEL_ID)
@click.pass_obj
def model_cmd(state: ReplState, model_id: str | None) -> None:
"""Show or switch model (Tab: config plus cached remote)."""
"""Show or switch model (Tab: remote-first plus config; live fetch on pick)."""
if not model_id:
click.echo(f"model={state.model}")
return
try:
choices = _await(state.merged_model_choices(refresh=False))
choices = _await(state.merged_model_choices(refresh=True))
state.model = select_model(
state.provider,
preferred=model_id.strip(),
+7
View File
@@ -167,6 +167,13 @@ class ReplState:
self._remote_models_key = None
self._remote_models_error = None
def seed_remote_models(self, ids: list[str]) -> None:
"""Install a freshly fetched remote catalog into the session cache."""
self._remote_models = list(ids)
self._remote_models_fetched_at = time.monotonic()
self._remote_models_key = self._models_cache_key()
self._remote_models_error = None
def cached_remote_models(self) -> list[str] | None:
"""Return cached remote ids if still valid for the current provider."""
if self._remote_models is None or self._remote_models_fetched_at is None:
+7 -2
View File
@@ -12,10 +12,14 @@ from plyngent.cli.models_source import (
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider
def test_merge_model_choices_union() -> None:
assert merge_model_choices(["b", "a"], ["a", "c"]) == ["a", "b", "c"]
def test_merge_model_choices_remote_first() -> None:
# remote-first: remote sorted, then config-only
assert merge_model_choices(["b", "a"], ["a", "c"]) == ["a", "c", "b"]
assert merge_model_choices(["a"], None) == ["a"]
assert merge_model_choices([], ["z"]) == ["z"]
assert merge_model_choices(["cfg"], ["remote", "cfg"], prefer="remote") == ["cfg", "remote"]
assert merge_model_choices(["b", "a"], ["a", "c"], prefer="union") == ["a", "b", "c"]
assert merge_model_choices(["b", "a"], ["a", "c"], prefer="config") == ["a", "b", "c"]
def test_model_choices_for_provider() -> None:
@@ -26,6 +30,7 @@ def test_model_choices_for_provider() -> None:
)
assert config_model_ids(provider) == ["cfg"]
assert model_choices_for_provider(provider, remote_ids=["remote", "cfg"]) == ["cfg", "remote"]
assert model_choices_for_provider(provider, remote_ids=["remote"]) == ["remote", "cfg"]
def test_client_supports_models() -> None: