162 lines
5.7 KiB
Python
162 lines
5.7 KiB
Python
"""Wire-contract freeze for ``models.list`` RPC rows.
|
|
|
|
The Web UI model picker and external control clients index into these rows
|
|
by key name, so the row shape is a public protocol contract (see CLAUDE.md:
|
|
public RPC field names are stable).
|
|
|
|
- Renaming or removing any frozen key is a contract break and must fail here.
|
|
- Adding a key requires deliberately extending the frozen sets in this file —
|
|
that friction is the point: wire additions should be a conscious decision.
|
|
|
|
The row and error shapes are frozen at ``_model_info_to_wire`` /
|
|
``_list_error_to_wire``, the pure builders the ``models.list`` handler maps
|
|
over selector results; the envelope is frozen by driving the handler with a
|
|
fully synthetic in-memory selector stub — zero network either way.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from opensquilla.gateway.rpc import RpcContext
|
|
from opensquilla.gateway.rpc_models import (
|
|
_handle_models_list,
|
|
_list_error_to_wire,
|
|
_model_info_to_wire,
|
|
)
|
|
from opensquilla.provider.selector import ModelListResult, ProviderListError
|
|
from opensquilla.provider.types import ModelInfo
|
|
|
|
# Additive wire evolution: ``source`` (catalog provenance) and
|
|
# ``reasoningFormat`` (reasoning dialect) were added deliberately. Extending
|
|
# this frozen set is the conscious decision the friction is meant to force —
|
|
# renaming or removing any existing key must still fail here.
|
|
MODEL_ROW_KEYS = frozenset(
|
|
{
|
|
"id",
|
|
"name",
|
|
"provider",
|
|
"contextWindow",
|
|
"capabilities",
|
|
"pricing",
|
|
"source",
|
|
"reasoningFormat",
|
|
}
|
|
)
|
|
MODEL_PRICING_KEYS = frozenset({"inputPer1k", "outputPer1k"})
|
|
# Additive top-level envelope key: ``errors`` carries classified, redacted
|
|
# per-provider listing failures alongside ``models``. Each error row is frozen
|
|
# to exactly these keys.
|
|
MODEL_ERROR_KEYS = frozenset({"provider", "kind", "detail"})
|
|
|
|
|
|
def _synthetic_model(**overrides) -> dict:
|
|
kwargs: dict = {
|
|
"provider": "test-provider",
|
|
"model_id": "test-provider/test-model",
|
|
"display_name": "Test Model",
|
|
"context_window": 32_000,
|
|
"supports_tools": True,
|
|
"input_cost_per_1k": 0.001,
|
|
"output_cost_per_1k": 0.002,
|
|
}
|
|
kwargs.update(overrides)
|
|
return ModelInfo(**kwargs).model_dump()
|
|
|
|
|
|
def test_model_row_keys_are_frozen() -> None:
|
|
row = _model_info_to_wire(_synthetic_model())
|
|
assert set(row) == MODEL_ROW_KEYS
|
|
assert set(row["pricing"]) == MODEL_PRICING_KEYS
|
|
|
|
|
|
def test_model_row_values_map_from_model_info() -> None:
|
|
# Field-name mapping (snake_case ModelInfo -> camelCase wire) is part of
|
|
# the contract: clients read contextWindow/pricing.inputPer1k literally.
|
|
row = _model_info_to_wire(_synthetic_model())
|
|
assert row["id"] == "test-provider/test-model"
|
|
assert row["name"] == "Test Model"
|
|
assert row["provider"] == "test-provider"
|
|
assert row["contextWindow"] == 32_000
|
|
assert row["pricing"] == {"inputPer1k": 0.001, "outputPer1k": 0.002}
|
|
|
|
|
|
def test_model_row_carries_catalog_provenance() -> None:
|
|
# A model unknown to every catalog layer still resolves to a synthesized
|
|
# entry, so ``source``/``reasoningFormat`` are always renderable strings.
|
|
row = _model_info_to_wire(_synthetic_model())
|
|
assert isinstance(row["source"], str) and row["source"]
|
|
assert isinstance(row["reasoningFormat"], str) and row["reasoningFormat"]
|
|
|
|
|
|
def test_error_row_keys_are_frozen() -> None:
|
|
err = _list_error_to_wire(
|
|
ProviderListError(
|
|
provider="test-provider",
|
|
model_hint="test-provider/test-model",
|
|
kind="auth_invalid",
|
|
detail="invalid api key",
|
|
)
|
|
)
|
|
assert set(err) == MODEL_ERROR_KEYS
|
|
# ``model_hint`` is selector-internal operator context; it stays off the
|
|
# wire on purpose.
|
|
assert "model_hint" not in err
|
|
assert err == {
|
|
"provider": "test-provider",
|
|
"kind": "auth_invalid",
|
|
"detail": "invalid api key",
|
|
}
|
|
|
|
|
|
|
|
def test_model_row_capability_strings_are_frozen() -> None:
|
|
# Capability strings are matched verbatim by the handler's
|
|
# ``capabilities`` filter and by client-side capability badges.
|
|
with_tools = _model_info_to_wire(_synthetic_model())
|
|
assert with_tools["capabilities"] == ["chat", "tools"]
|
|
|
|
without_tools = _model_info_to_wire(_synthetic_model(supports_tools=False))
|
|
assert without_tools["capabilities"] == ["chat"]
|
|
|
|
|
|
def test_model_row_name_falls_back_to_the_model_id() -> None:
|
|
# Clients rely on ``name`` always being renderable even when a provider
|
|
# returns no display name.
|
|
row = _model_info_to_wire(_synthetic_model(display_name=""))
|
|
assert row["name"] == "test-provider/test-model"
|
|
|
|
|
|
MODEL_ENVELOPE_KEYS = frozenset({"models", "errors"})
|
|
|
|
|
|
class _StubSelector:
|
|
"""Zero-network selector stub returning a fixed ModelListResult."""
|
|
|
|
def __init__(self, result: ModelListResult) -> None:
|
|
self._result = result
|
|
|
|
async def list_models_detailed(self) -> ModelListResult:
|
|
return self._result
|
|
|
|
|
|
async def test_models_list_envelope_keys_are_frozen() -> None:
|
|
result = ModelListResult(
|
|
models=[_synthetic_model()],
|
|
errors=[
|
|
ProviderListError(
|
|
provider="test-provider",
|
|
model_hint="test-provider/test-model",
|
|
kind="auth_invalid",
|
|
detail="invalid api key",
|
|
)
|
|
],
|
|
)
|
|
ctx = RpcContext(conn_id="test", provider_selector=_StubSelector(result))
|
|
envelope = await _handle_models_list({}, ctx)
|
|
|
|
assert set(envelope) == MODEL_ENVELOPE_KEYS
|
|
assert set(envelope["models"][0]) == MODEL_ROW_KEYS
|
|
assert envelope["errors"] == [
|
|
{"provider": "test-provider", "kind": "auth_invalid", "detail": "invalid api key"}
|
|
]
|
|
|