From 2368e46efd20c88c720e9e3bfdb0cf440418c52b Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 18 Jul 2026 01:32:06 +0800 Subject: [PATCH] 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. --- src/plyngent/cli/slash.py | 67 +++++++++++++++++++++------- src/plyngent/cli/state.py | 24 ++++++++++ src/plyngent/config/store.py | 53 ++++++++++++++++++++++ tests/test_cli/test_repl_commands.py | 30 +++++++++++++ tests/test_config/test_config.py | 33 ++++++++++++++ 5 files changed, 191 insertions(+), 16 deletions(-) diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index 501d8a6..29a40a3 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -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 --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") diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 481a1b1..fb91449 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -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 diff --git a/src/plyngent/config/store.py b/src/plyngent/config/store.py index ec2fcf4..833d0ff 100644 --- a/src/plyngent/config/store.py +++ b/src/plyngent/config/store.py @@ -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: diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index e3c618c..32982eb 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -341,6 +341,36 @@ async def test_verbose_toggle(state: ReplState) -> None: assert get_verbose_tool_results() is False +async def test_model_persist_writes_config( + state: ReplState, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # Seed a TOML-backed store so write() has a real path. + path = tmp_path / "plyngent.toml" + _ = path.write_text( + """ +[providers.local] +preset = "openai" +access_key_or_token = "sk-test" + +[providers.local.models.gpt-test] +""", + encoding="utf-8", + ) + from plyngent.config import load as load_config + + state.config = load_config(path) + state.provider_name = "local" + state.provider = state.config.providers["local"] + state.model = "gpt-new-id" + assert await handle_slash(state, "/model --persist") is True + out = capsys.readouterr().out + assert "persisted model" in out + assert "gpt-new-id" in out + state.config.reload() + assert "gpt-new-id" in state.config.providers["local"].models + assert "gpt-test" in state.config.providers["local"].models + + async def test_yolo_toggle(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None: assert state.effective_yolo() == "off" assert state.soft_confirm_enabled() is True diff --git a/tests/test_config/test_config.py b/tests/test_config/test_config.py index c402f54..8d40c9a 100644 --- a/tests/test_config/test_config.py +++ b/tests/test_config/test_config.py @@ -179,6 +179,39 @@ def test_write_new_config() -> None: assert config.providers["foo2"].access_key_or_token == "sk-00301212" +def test_ensure_model_and_merge_models(tmp_path: Path) -> None: + path = tmp_path / "models-persist.toml" + _ = path.write_text( + """ +[providers.local] +preset = "openai-compatible" +access_key_or_token = "sk-test" +url = "https://example/v1" + +[providers.local.models.base] +""", + encoding="utf-8", + ) + config = plyngent.config.load(path) + assert "base" in config.providers["local"].models + assert "extra" not in config.providers["local"].models + + provider = config.ensure_model("local", "extra") + assert "extra" in provider.models + assert "base" in provider.models + # idempotent + _ = config.ensure_model("local", "extra") + config.write() + config.reload() + assert set(config.providers["local"].models) == {"base", "extra"} + assert config.providers["local"].access_key_or_token == "sk-test" + + _ = config.merge_models("local", ["extra", "remote-a", "remote-b"]) + config.write() + config.reload() + assert set(config.providers["local"].models) == {"base", "extra", "remote-a", "remote-b"} + + def test_update_config() -> None: file = Path(__file__).parent / "plyngent-edit-2.toml" _ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)