Files
2026-07-13 13:12:33 +08:00

305 lines
10 KiB
Python

"""Wire-contract pins for onboarding mutation RPC semantics.
The CLI onboarding hardening pass widened several mutation signatures to
``None`` = keep-current and made re-saves state-dependent. Those semantics
are reachable from the unmodified gateway RPC handlers
(``onboarding.provider.configure``, ``onboarding.router.configure``,
``onboarding.search.configure``, ``onboarding.channel.probe``/``upsert``),
so this module is the explicit sign-off: each test pins one wire-visible
behavior so any future change to it is a conscious contract decision.
Pinned here:
- **Keep-current re-saves** (deliberate change): a same-provider re-save
carries over stored ``provider_routing``/``max_tokens``, a blank
``apiKey`` keeps the stored key, and an operator-authored inline router
ladder survives a provider save. ``onboarding.router.configure`` with
``mode=disabled`` keeps the effective ladder stored inline for re-enable.
- **Explicit JSON null = legacy default** (compatibility): a client sending
``null`` for ``model``/``proxy``/``maxResults``/... gets the pre-widening
reset/derive behavior, not keep-current.
- **Blank required channel secrets hard-fail** (deliberate change): probe
and upsert reject a genuinely blank secret; with a stored entry both are
merge-aware, so blank-means-keep round-trips.
All configs are synthetic; no network or credentials involved.
"""
from __future__ import annotations
import tomllib
import pytest
import opensquilla.gateway.rpc_onboarding # noqa: F401 ensures registration
from opensquilla.gateway.auth import Principal
from opensquilla.gateway.rpc import RpcContext, get_dispatcher
def _admin_ctx() -> RpcContext:
return RpcContext(
conn_id="contract",
principal=Principal(
role="operator",
scopes=frozenset({"operator.admin"}),
is_owner=True,
authenticated=True,
),
)
async def _dispatch(method: str, params: dict):
return await get_dispatcher().dispatch("r1", method, params, _admin_ctx())
@pytest.fixture()
def config_file(tmp_path, monkeypatch):
target = tmp_path / "c.toml"
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
return target
# ---------------------------------------------------------------------------
# Keep-current re-save semantics (deliberate wire-visible change).
# ---------------------------------------------------------------------------
async def test_provider_resave_keeps_stored_provider_routing_and_max_tokens(
config_file,
):
config_file.write_text(
"[llm]\n"
'provider = "openrouter"\n'
'model = "custom/model-x"\n'
'api_key = "sk-old"\n'
"max_tokens = 4096\n"
"[llm.provider_routing]\n"
'"custom/model-x" = "custom-upstream"\n',
encoding="utf-8",
)
res = await _dispatch(
"onboarding.provider.configure",
{"providerId": "openrouter", "model": "custom/model-x", "apiKey": "sk-new"},
)
assert res.error is None, res.error
# providerRouting was never sent: the stored table is carried over
# (legacy behavior reset it to {}); max_tokens rides the stored section.
assert res.payload["entry"]["provider_routing"] == {
"custom/model-x": "custom-upstream"
}
data = tomllib.loads(config_file.read_text())
assert data["llm"]["provider_routing"] == {"custom/model-x": "custom-upstream"}
assert data["llm"]["max_tokens"] == 4096
async def test_provider_resave_blank_api_key_keeps_stored_key(config_file):
config_file.write_text(
'[llm]\nprovider = "openrouter"\nmodel = "custom/model-x"\napi_key = "sk-stored"\n',
encoding="utf-8",
)
res = await _dispatch(
"onboarding.provider.configure",
{"providerId": "openrouter", "model": "custom/model-x", "apiKey": ""},
)
# Legacy behavior raised "requires an api_key"; blank now keeps stored.
assert res.error is None, res.error
data = tomllib.loads(config_file.read_text())
assert data["llm"]["api_key"] == "sk-stored"
async def test_provider_resave_keeps_operator_authored_router_ladder(config_file):
config_file.write_text(
"[llm]\n"
'provider = "openrouter"\n'
'model = "custom/model-x"\n'
'api_key = "sk-old"\n'
"[squilla_router]\n"
"enabled = true\n"
"[squilla_router.tiers.c0]\n"
'provider = "openrouter"\n'
'model = "custom/cheap"\n'
"[squilla_router.tiers.c1]\n"
'provider = "openrouter"\n'
'model = "custom/model-x"\n'
"[squilla_router.tiers.c2]\n"
'provider = "openrouter"\n'
'model = "custom/mid"\n'
"[squilla_router.tiers.c3]\n"
'provider = "openrouter"\n'
'model = "custom/big"\n',
encoding="utf-8",
)
res = await _dispatch(
"onboarding.provider.configure",
{"providerId": "openrouter", "model": "custom/model-x", "apiKey": "sk-new"},
)
assert res.error is None, res.error
data = tomllib.loads(config_file.read_text())
# Legacy behavior reverted the ladder to the packaged openrouter profile.
assert data["squilla_router"]["tiers"]["c0"]["model"] == "custom/cheap"
assert data["squilla_router"]["tiers"]["c3"]["model"] == "custom/big"
assert "tier_profile" not in data["squilla_router"]
async def test_router_disable_keeps_effective_ladder_inline(config_file):
config_file.write_text(
"[llm]\n"
'provider = "openrouter"\n'
'model = "custom/model-x"\n'
'api_key = "sk"\n'
"[squilla_router]\n"
"enabled = true\n"
"[squilla_router.tiers.c0]\n"
'provider = "openrouter"\n'
'model = "custom/cheap"\n'
"[squilla_router.tiers.c1]\n"
'provider = "openrouter"\n'
'model = "custom/model-x"\n'
"[squilla_router.tiers.c2]\n"
'provider = "openrouter"\n'
'model = "custom/mid"\n'
"[squilla_router.tiers.c3]\n"
'provider = "openrouter"\n'
'model = "custom/big"\n',
encoding="utf-8",
)
res = await _dispatch("onboarding.router.configure", {"mode": "disabled"})
assert res.error is None, res.error
assert res.payload["entry"]["mode"] == "disabled"
data = tomllib.loads(config_file.read_text())
assert data["squilla_router"]["enabled"] is False
# The operator's ladder stays stored inline so a re-enable can restore
# it (legacy behavior reset it to the packaged profile).
assert data["squilla_router"]["tiers"]["c0"]["model"] == "custom/cheap"
assert data["squilla_router"]["tiers"]["c3"]["model"] == "custom/big"
# ---------------------------------------------------------------------------
# Explicit JSON null keeps the LEGACY defaults (compatibility pin).
# ---------------------------------------------------------------------------
async def test_provider_configure_null_model_resets_to_derived_default(config_file):
config_file.write_text(
'[llm]\nprovider = "deepseek"\nmodel = "custom-stored-model"\napi_key = "sk-old"\n',
encoding="utf-8",
)
res = await _dispatch(
"onboarding.provider.configure",
{"providerId": "deepseek", "model": None, "apiKey": "sk-new"},
)
assert res.error is None, res.error
# null must behave like the legacy empty string: derive the router
# profile default, NOT keep the stored custom model.
assert res.payload["entry"]["model"] != "custom-stored-model"
assert res.payload["entry"]["model"]
async def test_search_configure_null_params_reset_to_legacy_defaults(config_file):
from opensquilla.search.types import DEFAULT_SEARCH_MAX_RESULTS
config_file.write_text(
'search_provider = "duckduckgo"\n'
"search_max_results = 9\n"
'search_proxy = "http://127.0.0.1:7890"\n'
'search_fallback_policy = "network"\n'
"search_diagnostics = true\n",
encoding="utf-8",
)
res = await _dispatch(
"onboarding.search.configure",
{
"providerId": "duckduckgo",
"maxResults": None,
"proxy": None,
"useEnvProxy": None,
"fallbackPolicy": None,
"diagnostics": None,
},
)
assert res.error is None, res.error
entry = res.payload["entry"]
assert entry["max_results"] == DEFAULT_SEARCH_MAX_RESULTS
assert entry["proxy"] == ""
assert entry["fallback_policy"] == "off"
assert entry["diagnostics"] is False
async def test_search_configure_absent_params_also_reset_to_legacy_defaults(
config_file,
):
"""Over RPC, ABSENT optional search params keep the legacy reset
semantics (the keep-current widening is CLI-only); pinned so the two
surfaces cannot drift apart silently."""
from opensquilla.search.types import DEFAULT_SEARCH_MAX_RESULTS
config_file.write_text(
'search_provider = "duckduckgo"\nsearch_max_results = 9\n', encoding="utf-8"
)
res = await _dispatch(
"onboarding.search.configure", {"providerId": "duckduckgo"}
)
assert res.error is None, res.error
assert res.payload["entry"]["max_results"] == DEFAULT_SEARCH_MAX_RESULTS
# ---------------------------------------------------------------------------
# Blank required channel secrets: hard-fail without a stored entry,
# merge-aware with one (probe mirrors upsert).
# ---------------------------------------------------------------------------
async def test_channel_probe_blank_secret_without_stored_entry_fails(config_file):
res = await _dispatch(
"onboarding.channel.probe",
{"entry": {"type": "telegram", "name": "t1", "token": ""}},
)
assert res.error is not None
assert res.error.code == "onboarding.channel.invalid"
assert "token" in res.error.message
async def test_channel_upsert_blank_secret_without_stored_entry_fails(config_file):
res = await _dispatch(
"onboarding.channel.upsert",
{"entry": {"type": "telegram", "name": "t1", "token": ""}},
)
assert res.error is not None
assert res.error.code == "onboarding.channel.invalid"
async def test_channel_probe_blank_secret_merges_stored_entry(config_file):
config_file.write_text(
"[[channels.channels]]\n"
'type = "telegram"\n'
'name = "t1"\n'
'token = "tg-stored"\n',
encoding="utf-8",
)
res = await _dispatch(
"onboarding.channel.probe",
{"entry": {"type": "telegram", "name": "t1", "token": ""}},
)
assert res.error is None, res.error
assert res.payload["status"] == "ready"
# Secrets never round-trip in the probe response.
assert res.payload["entry"]["token"] != "tg-stored"