core/config+cli: /model --persist and /models --persist to TOML

ensure_model / merge_models update the provider catalog in memory; --persist
writes plyngent.toml so next launch has a warmer model list.
This commit is contained in:
2026-07-18 01:32:06 +08:00
parent 65d248064e
commit 2368e46efd
5 changed files with 191 additions and 16 deletions
+51 -16
View File
@@ -642,8 +642,13 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
@slash.command("models")
@click.option("--refresh", is_flag=True, help="Bypass cache and re-fetch GET /models.")
@click.option(
"--persist",
is_flag=True,
help="Merge remote (and recovered) model ids into plyngent.toml for this provider.",
)
@click.pass_obj
def models_cmd(state: ReplState, *, refresh: bool) -> None:
def models_cmd(state: ReplState, *, refresh: bool, persist: bool) -> None: # noqa: C901
"""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
@@ -667,6 +672,17 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
except (KeyError, ValueError) as exc:
click.secho(f"could not recover provider: {exc}", fg="yellow", err=True)
if persist:
try:
path = state.persist_models_to_config(mode="catalog", catalog_ids=remote or ())
click.echo(
f"persisted {len(state.provider.models)} model(s) for "
f"{state.provider_name!r} to {path}"
)
except (KeyError, ValueError, OSError) as exc:
click.secho(f"error: could not persist models: {exc}", fg="red")
return
config_ids = set(state.config_model_ids())
choices = model_choices_for_provider(state.provider, remote_ids=remote)
@@ -696,24 +712,43 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
@slash.command("model")
@click.argument("model_id", required=False, type=MODEL_ID)
@click.option(
"--persist",
is_flag=True,
help="Write the current (or newly selected) model id into plyngent.toml.",
)
@click.pass_obj
def model_cmd(state: ReplState, model_id: str | None) -> None:
"""Show or switch model (Tab: remote-first plus config; live fetch on pick)."""
if not model_id:
def model_cmd(state: ReplState, model_id: str | None, *, persist: bool) -> None:
"""Show or switch model (Tab: remote-first plus config; live fetch on pick).
``/model --persist`` saves the active model id into the provider catalog in
TOML (faster Tab/list on next launch). ``/model <id> --persist`` switches
then saves.
"""
if model_id:
try:
choices = _await(state.merged_model_choices(refresh=True))
state.model = select_model(
state.provider,
preferred=model_id.strip(),
choices=choices,
)
state.rebuild_client()
_await(state.persist_llm_selection())
click.echo(f"switched model to {state.model}")
except click.ClickException as exc:
click.echo(f"error: {exc}")
return
elif not persist:
click.echo(f"model={state.model}")
return
try:
choices = _await(state.merged_model_choices(refresh=True))
state.model = select_model(
state.provider,
preferred=model_id.strip(),
choices=choices,
)
state.rebuild_client()
_await(state.persist_llm_selection())
click.echo(f"switched model to {state.model}")
except click.ClickException as exc:
click.echo(f"error: {exc}")
if persist:
try:
path = state.persist_models_to_config(mode="current")
click.echo(f"persisted model {state.model!r} for {state.provider_name!r} to {path}")
except (KeyError, ValueError, OSError) as exc:
click.secho(f"error: could not persist model: {exc}", fg="red")
@slash.command("tools")
+24
View File
@@ -20,6 +20,8 @@ from plyngent.runtime import create_client
from plyngent.tools import DEFAULT_TOOLS, set_workspace_root
if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
@@ -292,6 +294,28 @@ class ReplState:
model=self.model,
)
def persist_models_to_config(
self,
*,
mode: Literal["current", "catalog"],
catalog_ids: Sequence[str] | None = None,
) -> Path:
"""Merge model id(s) into TOML for the current provider and write disk.
*mode* ``current``: ensure :attr:`model` is in the provider catalog.
*mode* ``catalog``: union *catalog_ids* (or empty) into the catalog.
Returns the config path written. Raises ``OSError`` / ``ValueError`` /
``KeyError`` on failure.
"""
if mode == "current":
self.provider = self.config.ensure_model(self.provider_name, self.model)
else:
ids = list(catalog_ids) if catalog_ids is not None else []
self.provider = self.config.merge_models(self.provider_name, ids)
self.config.write()
return self.config.path
def _try_set_provider(self, pname: str) -> bool:
import click
+53
View File
@@ -200,6 +200,59 @@ class ConfigStore:
_ = self._bad_providers.pop(name, None)
return promoted
def _take_provider(self, name: str) -> Provider:
"""Return a ready or recoverable provider, promoting recoverable into ready map."""
if name in self._providers:
return self._providers[name]
if name in self._recoverable_providers:
provider = self._recoverable_providers.pop(name)
self._providers[name] = provider
_ = self._bad_providers.pop(name, None)
return provider
msg = f"unknown provider {name!r}"
raise KeyError(msg)
def ensure_model(self, name: str, model_id: str) -> Provider:
"""Ensure *model_id* exists under ``providers[name].models`` (in memory).
Does not write the TOML file unless the caller invokes :meth:`write`.
"""
mid = model_id.strip()
if not mid:
msg = "model id must not be empty"
raise ValueError(msg)
provider = self._take_provider(name)
if mid in provider.models:
return provider
models = dict(provider.models)
models[mid] = ModelConfig()
updated = msgspec.structs.replace(provider, models=models)
self._providers[name] = updated
return updated
def merge_models(self, name: str, model_ids: Sequence[str]) -> Provider:
"""Union *model_ids* into the provider catalog (keep existing configs).
Does not write the TOML file unless the caller invokes :meth:`write`.
"""
provider = self._take_provider(name)
models = dict(provider.models)
added = False
for raw in model_ids:
mid = raw.strip() if raw else ""
if not mid or mid in models:
continue
models[mid] = ModelConfig()
added = True
if not added and models:
return provider
if not models:
msg = f"cannot merge models for provider {name!r}: no model ids"
raise ValueError(msg)
updated = msgspec.structs.replace(provider, models=models)
self._providers[name] = updated
return updated
# -- persistence --
def write(self) -> None: