223 lines
9.0 KiB
Python
223 lines
9.0 KiB
Python
"""Wire-contract freeze for the ``onboarding.catalog`` RPC payload.
|
|
|
|
The catalog is consumed by the Web UI onboarding wizard and by external
|
|
control clients, so its key names are a public protocol contract (see
|
|
CLAUDE.md: public RPC field names are stable). These tests pin today's
|
|
exact key sets:
|
|
|
|
- Renaming or removing any frozen key is a contract break and must fail here.
|
|
- Adding a key is allowed, but for the exact-key-set assertions below it
|
|
requires deliberately updating the frozen set in this file — that friction
|
|
is the point: additions to the wire surface should be a conscious decision.
|
|
|
|
Everything asserted here is built from pure in-process catalog builders
|
|
(``provider_catalog_payload`` / ``router_catalog_payload`` and the
|
|
``onboarding.catalog`` handler); no network, credentials, or gateway boot.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from opensquilla.gateway import rpc_onboarding
|
|
from opensquilla.gateway.config import ROUTER_TIER_PROFILE_IDS
|
|
from opensquilla.gateway.rpc import RpcContext
|
|
from opensquilla.onboarding.provider_specs import provider_catalog_payload
|
|
from opensquilla.onboarding.router_specs import router_catalog_payload
|
|
|
|
# Top-level catalog sections the Web UI switches on. Exact set: a renamed or
|
|
# dropped section silently blanks a wizard page.
|
|
CATALOG_TOP_LEVEL_KEYS = frozenset(
|
|
{
|
|
"providers",
|
|
"channels",
|
|
"searchProviders",
|
|
"routerProfiles",
|
|
"memoryEmbeddingProviders",
|
|
"imageGenerationProviders",
|
|
"audioProviders",
|
|
}
|
|
)
|
|
|
|
# Per-provider entry shape rendered by the provider picker.
|
|
PROVIDER_ENTRY_KEYS = frozenset(
|
|
{
|
|
"providerId",
|
|
"label",
|
|
"backend",
|
|
"providerKind",
|
|
"runtimeSupported",
|
|
"verification",
|
|
"envKey",
|
|
"defaultBaseUrl",
|
|
"requiresApiKey",
|
|
"requiresBaseUrl",
|
|
"routerSupported",
|
|
"deployment",
|
|
"blocking",
|
|
"canProbe",
|
|
"readmeScenarios",
|
|
"whatYouNeed",
|
|
"defaultDirectModel",
|
|
# Deliberate preset-surface additions: each provider entry now carries
|
|
# its registry preset(s) and the preset-declared default model. Adding
|
|
# these to the frozen set is the conscious decision the friction forces.
|
|
"defaultModel",
|
|
"presets",
|
|
"capabilities",
|
|
"fields",
|
|
}
|
|
)
|
|
|
|
# Per-preset entry shape rendered by the preset picker (exactly one preset per
|
|
# provider today; the wire is a list to allow future multi-preset providers).
|
|
PROVIDER_PRESET_KEYS = frozenset(
|
|
{"presetId", "label", "description", "synthesized", "defaultModel", "tiers"}
|
|
)
|
|
|
|
# Per-field spec shape the wizard uses to render setup form inputs.
|
|
PROVIDER_FIELD_KEYS = frozenset(
|
|
{
|
|
"name",
|
|
"label",
|
|
"type",
|
|
"required",
|
|
"default",
|
|
"description",
|
|
"secret",
|
|
}
|
|
)
|
|
|
|
# Router catalog shapes: profile list + per-tier route cards.
|
|
ROUTER_CATALOG_TOP_LEVEL_KEYS = frozenset({"defaultTier", "textTiers", "modes", "profiles"})
|
|
ROUTER_MODE_KEYS = frozenset({"mode", "label", "description"})
|
|
ROUTER_PROFILE_KEYS = frozenset({"profileId", "providerId", "label", "tiers"})
|
|
ROUTER_TIER_PAYLOAD_KEYS = frozenset(
|
|
{"provider", "model", "description", "thinkingLevel", "supportsImage"}
|
|
)
|
|
|
|
# The canonical text tiers (c0-c3, per router_tiers.py) and the nine tier
|
|
# profiles shipped today. Hardcoded on purpose: dropping a profile or renaming
|
|
# a tier changes what onboarding clients can select, so it must fail here even
|
|
# if the source constant is edited in the same change.
|
|
FROZEN_TEXT_TIERS = ("c0", "c1", "c2", "c3")
|
|
FROZEN_ROUTER_PROFILE_IDS = frozenset(
|
|
{
|
|
"byteplus",
|
|
"dashscope",
|
|
"deepseek",
|
|
"gemini",
|
|
"moonshot",
|
|
"openai",
|
|
"openrouter",
|
|
"volcengine",
|
|
"zhipu",
|
|
}
|
|
)
|
|
# Curated presets that are NOT persistable tier_profile ids: packaged tier
|
|
# data applied as inline tiers (synthesized=False on the wire, but the id
|
|
# stays outside FROZEN_ROUTER_PROFILE_IDS).
|
|
FROZEN_CURATED_INLINE_PRESET_IDS = frozenset({"tokenrhythm"})
|
|
|
|
|
|
async def test_onboarding_catalog_top_level_sections_are_frozen() -> None:
|
|
# The handler ignores params and only reads pure catalog builders, so a
|
|
# bare RpcContext is enough — no gateway boot required.
|
|
payload = await rpc_onboarding._onboarding_catalog(None, RpcContext(conn_id="contract"))
|
|
assert set(payload) == CATALOG_TOP_LEVEL_KEYS
|
|
|
|
|
|
def test_provider_catalog_entry_keys_are_frozen() -> None:
|
|
entries = provider_catalog_payload()
|
|
assert entries, "provider catalog must not be empty"
|
|
for entry in entries:
|
|
assert set(entry) == PROVIDER_ENTRY_KEYS, entry["providerId"]
|
|
assert entry["fields"], entry["providerId"]
|
|
for field in entry["fields"]:
|
|
assert set(field) == PROVIDER_FIELD_KEYS, (entry["providerId"], field.get("name"))
|
|
|
|
# The default provider must stay addressable by its shipped id.
|
|
provider_ids = {entry["providerId"] for entry in entries}
|
|
assert "openrouter" in provider_ids
|
|
|
|
|
|
def test_provider_catalog_preset_keys_are_frozen() -> None:
|
|
entries = provider_catalog_payload()
|
|
for entry in entries:
|
|
presets = entry["presets"]
|
|
# Exactly one registry preset per runtime-supported provider today —
|
|
# widening to several curated presets is an allowed (deliberate)
|
|
# evolution, but silently losing the preset is a break.
|
|
assert len(presets) == 1, entry["providerId"]
|
|
for preset in presets:
|
|
assert set(preset) == PROVIDER_PRESET_KEYS, entry["providerId"]
|
|
# Preset ids are provider-bound (both packaged and synthesized
|
|
# preset ids equal their provider id); clients echo presetId back
|
|
# verbatim through onboarding.provider.configure.
|
|
assert preset["presetId"] == entry["providerId"]
|
|
assert isinstance(preset["synthesized"], bool), entry["providerId"]
|
|
# Preset tiers reuse the router-catalog tier payload shape so the
|
|
# same client component renders both.
|
|
tiers = preset["tiers"]
|
|
assert set(FROZEN_TEXT_TIERS) <= set(tiers), entry["providerId"]
|
|
for tier_name, tier in tiers.items():
|
|
assert set(tier) == ROUTER_TIER_PAYLOAD_KEYS, (entry["providerId"], tier_name)
|
|
# The entry-level defaultModel mirrors the (single) preset's.
|
|
assert entry["defaultModel"] == presets[0]["defaultModel"], entry["providerId"]
|
|
|
|
|
|
def test_provider_catalog_preset_synthesized_flag_tracks_packaged_presets() -> None:
|
|
# The legacy nine plus the curated-inline set ship packaged (curated)
|
|
# presets; everything else is synthesized. A packaged preset silently
|
|
# degrading to synthesized (or vice versa) changes what onboarding
|
|
# clients offer to apply.
|
|
packaged = FROZEN_ROUTER_PROFILE_IDS | FROZEN_CURATED_INLINE_PRESET_IDS
|
|
entries = {entry["providerId"]: entry for entry in provider_catalog_payload()}
|
|
for provider_id, entry in entries.items():
|
|
expected_synthesized = provider_id not in packaged
|
|
assert entry["presets"][0]["synthesized"] is expected_synthesized, provider_id
|
|
|
|
|
|
def test_router_catalog_top_level_shape_is_frozen() -> None:
|
|
payload = router_catalog_payload()
|
|
assert set(payload) == ROUTER_CATALOG_TOP_LEVEL_KEYS
|
|
# Tier names are canonical c0-c3 and c1 is the default route; clients
|
|
# (tier pills, onboarding tier tables) key directly off these strings.
|
|
assert payload["textTiers"] == list(FROZEN_TEXT_TIERS)
|
|
assert payload["defaultTier"] == "c1"
|
|
|
|
|
|
def test_router_catalog_modes_are_frozen() -> None:
|
|
payload = router_catalog_payload()
|
|
modes = payload["modes"]
|
|
for mode in modes:
|
|
assert set(mode) == ROUTER_MODE_KEYS
|
|
# Mode ids are sent back verbatim by onboarding.router.configure clients.
|
|
# "custom" is a deliberate addition: the provider-agnostic generalization
|
|
# of openrouter-mix accepted for any active provider.
|
|
assert {mode["mode"] for mode in modes} == {
|
|
"recommended",
|
|
"openrouter-mix",
|
|
"custom",
|
|
"disabled",
|
|
}
|
|
|
|
|
|
def test_router_catalog_profiles_and_tier_payloads_are_frozen() -> None:
|
|
payload = router_catalog_payload()
|
|
profiles = payload["profiles"]
|
|
|
|
profile_ids = {profile["profileId"] for profile in profiles}
|
|
assert profile_ids == FROZEN_ROUTER_PROFILE_IDS
|
|
# Keep the frozen wire set in lockstep with the source-of-truth constant;
|
|
# if this fails, either the wire or the constant changed without the other.
|
|
assert profile_ids == set(ROUTER_TIER_PROFILE_IDS)
|
|
|
|
for profile in profiles:
|
|
assert set(profile) == ROUTER_PROFILE_KEYS, profile.get("profileId")
|
|
tiers = profile["tiers"]
|
|
# Every profile must route all four text tiers; image_model is the
|
|
# only other tier slot exposed to onboarding.
|
|
assert set(FROZEN_TEXT_TIERS) <= set(tiers), profile["profileId"]
|
|
assert set(tiers) <= set(FROZEN_TEXT_TIERS) | {"image_model"}, profile["profileId"]
|
|
for tier_name, tier in tiers.items():
|
|
assert set(tier) == ROUTER_TIER_PAYLOAD_KEYS, (profile["profileId"], tier_name)
|