mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
+51
-16
@@ -642,8 +642,13 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
|
|||||||
|
|
||||||
@slash.command("models")
|
@slash.command("models")
|
||||||
@click.option("--refresh", is_flag=True, help="Bypass cache and re-fetch GET /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
|
@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."""
|
"""List models (remote-first, plus config-only ids). Always tries GET /models."""
|
||||||
del refresh # always re-fetch; flag kept for CLI compatibility / docs
|
del refresh # always re-fetch; flag kept for CLI compatibility / docs
|
||||||
remote: list[str] | None = None
|
remote: list[str] | None = None
|
||||||
@@ -667,6 +672,17 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
|
|||||||
except (KeyError, ValueError) as exc:
|
except (KeyError, ValueError) as exc:
|
||||||
click.secho(f"could not recover provider: {exc}", fg="yellow", err=True)
|
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())
|
config_ids = set(state.config_model_ids())
|
||||||
choices = model_choices_for_provider(state.provider, remote_ids=remote)
|
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")
|
@slash.command("model")
|
||||||
@click.argument("model_id", required=False, type=MODEL_ID)
|
@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
|
@click.pass_obj
|
||||||
def model_cmd(state: ReplState, model_id: str | None) -> None:
|
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)."""
|
"""Show or switch model (Tab: remote-first plus config; live fetch on pick).
|
||||||
if not model_id:
|
|
||||||
|
``/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}")
|
click.echo(f"model={state.model}")
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
choices = _await(state.merged_model_choices(refresh=True))
|
if persist:
|
||||||
state.model = select_model(
|
try:
|
||||||
state.provider,
|
path = state.persist_models_to_config(mode="current")
|
||||||
preferred=model_id.strip(),
|
click.echo(f"persisted model {state.model!r} for {state.provider_name!r} to {path}")
|
||||||
choices=choices,
|
except (KeyError, ValueError, OSError) as exc:
|
||||||
)
|
click.secho(f"error: could not persist model: {exc}", fg="red")
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
@slash.command("tools")
|
@slash.command("tools")
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from plyngent.runtime import create_client
|
|||||||
from plyngent.tools import DEFAULT_TOOLS, set_workspace_root
|
from plyngent.tools import DEFAULT_TOOLS, set_workspace_root
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from plyngent.config.models import Provider
|
from plyngent.config.models import Provider
|
||||||
from plyngent.config.store import ConfigStore
|
from plyngent.config.store import ConfigStore
|
||||||
from plyngent.memory import MemoryStore
|
from plyngent.memory import MemoryStore
|
||||||
@@ -292,6 +294,28 @@ class ReplState:
|
|||||||
model=self.model,
|
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:
|
def _try_set_provider(self, pname: str) -> bool:
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,59 @@ class ConfigStore:
|
|||||||
_ = self._bad_providers.pop(name, None)
|
_ = self._bad_providers.pop(name, None)
|
||||||
return promoted
|
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 --
|
# -- persistence --
|
||||||
|
|
||||||
def write(self) -> None:
|
def write(self) -> None:
|
||||||
|
|||||||
@@ -341,6 +341,36 @@ async def test_verbose_toggle(state: ReplState) -> None:
|
|||||||
assert get_verbose_tool_results() is False
|
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:
|
async def test_yolo_toggle(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
assert state.effective_yolo() == "off"
|
assert state.effective_yolo() == "off"
|
||||||
assert state.soft_confirm_enabled() is True
|
assert state.soft_confirm_enabled() is True
|
||||||
|
|||||||
@@ -179,6 +179,39 @@ def test_write_new_config() -> None:
|
|||||||
assert config.providers["foo2"].access_key_or_token == "sk-00301212"
|
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:
|
def test_update_config() -> None:
|
||||||
file = Path(__file__).parent / "plyngent-edit-2.toml"
|
file = Path(__file__).parent / "plyngent-edit-2.toml"
|
||||||
_ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)
|
_ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)
|
||||||
|
|||||||
Reference in New Issue
Block a user