330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""Interactive [llm_ensemble] onboarding runner (offline, monkeypatched prompts)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import tomllib
|
|
import types
|
|
from typing import Any
|
|
|
|
|
|
class _Answer:
|
|
def __init__(self, value: Any) -> None:
|
|
self.value = value
|
|
|
|
def ask(self) -> Any:
|
|
return self.value
|
|
|
|
|
|
class _BaseQuestionary(types.SimpleNamespace):
|
|
"""Complete fake: ``flow._styled`` eagerly wraps every prompt method."""
|
|
|
|
def confirm(self, message: str, **_kwargs: Any) -> _Answer:
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def select(self, message: str, **_kwargs: Any) -> _Answer:
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **_kwargs: Any) -> _Answer:
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs: Any) -> _Answer:
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def checkbox(self, message: str, **_kwargs: Any) -> _Answer:
|
|
raise AssertionError(f"unexpected checkbox prompt: {message}")
|
|
|
|
|
|
def test_interactive_ensemble_configure_persists(tmp_path, monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
calls: list[str] = []
|
|
|
|
class _Questionary(_BaseQuestionary):
|
|
def confirm(self, message: str, **kwargs: Any) -> _Answer:
|
|
calls.append(message)
|
|
if message == "Enable the LLM ensemble?":
|
|
# Model router ships as the default strategy; ensemble is opt-in.
|
|
assert kwargs.get("default") is False
|
|
return _Answer(True)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def select(self, message: str, **kwargs: Any) -> _Answer:
|
|
calls.append(message)
|
|
if message == "Ensemble selection mode":
|
|
assert kwargs.get("choices") == [
|
|
"router_dynamic",
|
|
"static_openrouter_b5",
|
|
"static_tokenrhythm_b5",
|
|
"custom_b5",
|
|
]
|
|
assert kwargs.get("default") == "static_openrouter_b5"
|
|
return _Answer("router_dynamic")
|
|
if message == "Policy when all proposers fail":
|
|
assert kwargs.get("choices") == ["fallback_single", "error"]
|
|
assert kwargs.get("default") == "fallback_single"
|
|
return _Answer("error")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs: Any) -> _Answer:
|
|
calls.append(message)
|
|
if message.startswith("Ensemble model options"):
|
|
return _Answer("prov/model-a, prov/model-b")
|
|
if message == "Minimum successful proposers":
|
|
assert kwargs.get("default") == "1"
|
|
return _Answer("2")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs: Any) -> _Answer:
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_ensemble_configure()
|
|
|
|
assert calls[0] == "Enable the LLM ensemble?"
|
|
data = tomllib.loads(target.read_text())
|
|
ensemble = data["llm_ensemble"]
|
|
assert ensemble["enabled"] is True
|
|
assert ensemble["selection_mode"] == "router_dynamic"
|
|
assert ensemble["model_options"] == ["prov/model-a", "prov/model-b"]
|
|
assert ensemble["min_successful_proposers"] == 2
|
|
assert ensemble["all_failed_policy"] == "error"
|
|
|
|
|
|
def test_interactive_ensemble_blank_model_options_keep_current(
|
|
tmp_path, monkeypatch
|
|
):
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
target.write_text(
|
|
"[llm_ensemble]\n"
|
|
"enabled = true\n"
|
|
'selection_mode = "router_dynamic"\n'
|
|
'model_options = ["stored/model-a", "stored/model-b"]\n'
|
|
"min_successful_proposers = 3\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
class _Questionary(_BaseQuestionary):
|
|
def confirm(self, message: str, **kwargs: Any) -> _Answer:
|
|
if message == "Enable the LLM ensemble?":
|
|
return _Answer(True)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def select(self, message: str, **kwargs: Any) -> _Answer:
|
|
# Accepting the stored defaults must be a no-op edit.
|
|
if message in {"Ensemble selection mode", "Policy when all proposers fail"}:
|
|
assert kwargs.get("default")
|
|
return _Answer(kwargs["default"])
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs: Any) -> _Answer:
|
|
if message.startswith("Ensemble model options"):
|
|
return _Answer("")
|
|
if message == "Minimum successful proposers":
|
|
assert kwargs.get("default") == "3"
|
|
return _Answer(kwargs["default"])
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_ensemble_configure()
|
|
|
|
data = tomllib.loads(target.read_text())
|
|
ensemble = data["llm_ensemble"]
|
|
assert ensemble["model_options"] == ["stored/model-a", "stored/model-b"]
|
|
assert ensemble["selection_mode"] == "router_dynamic"
|
|
assert ensemble["min_successful_proposers"] == 3
|
|
|
|
|
|
def test_interactive_ensemble_disable_is_a_single_answer(tmp_path, monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
target.write_text(
|
|
"[llm_ensemble]\n"
|
|
'model_options = ["stored/model-a"]\n',
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
class _Questionary(_BaseQuestionary):
|
|
def confirm(self, message: str, **_kwargs: Any) -> _Answer:
|
|
if message == "Enable the LLM ensemble?":
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def select(self, message: str, **_kwargs: Any) -> _Answer:
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **_kwargs: Any) -> _Answer:
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_ensemble_configure()
|
|
|
|
# Sparse persistence may omit default-equal keys (the ensemble ships
|
|
# disabled), so pin the reloaded semantic state instead of raw TOML.
|
|
from opensquilla.onboarding.config_store import load_config
|
|
|
|
saved = load_config(target)
|
|
assert saved.llm_ensemble.enabled is False
|
|
# Tuning values stay untouched for a later re-enable.
|
|
assert list(saved.llm_ensemble.model_options) == ["stored/model-a"]
|
|
|
|
|
|
def test_interactive_ensemble_configure_without_tty_prints_hint(
|
|
tmp_path, monkeypatch, capsys
|
|
):
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: False)
|
|
|
|
result = flow.run_interactive_ensemble_configure(config_path=target)
|
|
|
|
assert result.warnings == ["tty_required"]
|
|
assert not target.exists()
|
|
out = capsys.readouterr().out
|
|
assert "Headless ensemble:" in out
|
|
assert "opensquilla onboard configure ensemble --enabled" in out
|
|
|
|
|
|
def test_ensemble_saved_message_directs_to_gateway_reload(tmp_path, monkeypatch):
|
|
"""The CLI runner edits the config file on disk, and file edits are only
|
|
read at boot — the save message must direct the operator to a gateway
|
|
reload/restart instead of claiming the change applies to the next turn
|
|
(that is only true for the RPC hot-apply path)."""
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
messages: list[str] = []
|
|
|
|
class _Console:
|
|
def print(self, message: Any = "", *_a: Any, **_kw: Any) -> None:
|
|
messages.append(str(message))
|
|
|
|
monkeypatch.setattr(flow, "console", _Console())
|
|
|
|
class _Questionary(_BaseQuestionary):
|
|
def confirm(self, message: str, **_kwargs: Any) -> _Answer:
|
|
if message == "Enable the LLM ensemble?":
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_ensemble_configure()
|
|
|
|
joined = "\n".join(messages)
|
|
assert "no restart needed" not in joined
|
|
assert "applies to the next turn" not in joined
|
|
assert "opensquilla gateway reload" in joined
|
|
|
|
|
|
def test_interactive_configure_offers_and_dispatches_ensemble(
|
|
tmp_path, monkeypatch
|
|
):
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.config_store import PersistResult
|
|
|
|
target = tmp_path / "custom.toml"
|
|
seen: dict[str, Any] = {}
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
def fake_runner(config_path=None):
|
|
seen["config_path"] = config_path
|
|
return PersistResult(
|
|
path=target,
|
|
backup_path=None,
|
|
restart_required=False,
|
|
warnings=[],
|
|
)
|
|
|
|
monkeypatch.setattr(flow, "run_interactive_ensemble_configure", fake_runner)
|
|
|
|
class _Questionary(_BaseQuestionary):
|
|
def select(self, message: str, **kwargs: Any) -> _Answer:
|
|
assert message == "Section"
|
|
titles = kwargs["choices"]
|
|
ensemble_title = next(
|
|
(t for t in titles if t.startswith("Ensemble")), None
|
|
)
|
|
if ensemble_title is not None and "config_path" not in seen:
|
|
return _Answer(ensemble_title)
|
|
done = next(t for t in titles if t in ("Done", "Exit (nothing changed)"))
|
|
return _Answer(done)
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
result = flow.run_interactive_configure(config_path=target)
|
|
|
|
assert result is not None
|
|
assert seen["config_path"] == target
|
|
|
|
|
|
class _ValidatedAnswer:
|
|
"""Fake prompt honouring ``validate=`` like questionary: rejected input
|
|
is re-asked, so garbage can only leak through when no validator is set."""
|
|
|
|
def __init__(self, values: list[Any], validate: Any) -> None:
|
|
self._values = list(values)
|
|
self._validate = validate
|
|
|
|
def ask(self) -> Any:
|
|
while self._values:
|
|
candidate = self._values.pop(0)
|
|
if self._validate is None or self._validate(candidate) is True:
|
|
return candidate
|
|
raise AssertionError("prompt inputs exhausted without an accepted value")
|
|
|
|
|
|
def test_interactive_ensemble_min_proposers_garbage_reprompts(tmp_path, monkeypatch):
|
|
"""Typing a non-number used to blow up in the mutation layer with a raw
|
|
``ValueError`` after every other answer had already been given."""
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
class _Questionary(_BaseQuestionary):
|
|
def confirm(self, message: str, **_kwargs: Any) -> _Answer:
|
|
if message == "Enable the LLM ensemble?":
|
|
return _Answer(True)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def select(self, message: str, **kwargs: Any) -> _Answer:
|
|
if message in {"Ensemble selection mode", "Policy when all proposers fail"}:
|
|
return _Answer(kwargs["default"])
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs: Any):
|
|
if message.startswith("Ensemble model options"):
|
|
return _Answer("")
|
|
if message == "Minimum successful proposers":
|
|
return _ValidatedAnswer(["many", "0", "2"], kwargs.get("validate"))
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_ensemble_configure()
|
|
|
|
data = tomllib.loads(target.read_text())
|
|
assert data["llm_ensemble"]["min_successful_proposers"] == 2
|