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

302 lines
11 KiB
Python

"""Schema tests for the ``[model_catalog]`` and ``[models]`` config sections.
Both sections land schema-first: defaults reproduce current behavior (no
catalog refresh, no per-model overrides) and nothing consumes them in the
provider layer yet. Covered here:
* validation — refresh literal, ge bounds, extra-forbid override entries,
and the conservative reasoning_format dialect set;
* TOML quoted-key parsing for model ids containing dots and slashes;
* to_toml_dict / persist round-trips (``exclude_defaults=False`` means both
sections always materialize on a full-tree persist);
* migration-framework compatibility — an existing payload without the
sections loads with ``changed`` False and picks up pure defaults.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
import tomli_w
from pydantic import ValidationError
from opensquilla.gateway.config import (
KNOWN_REASONING_FORMATS,
GatewayConfig,
ModelCatalogConfig,
ModelOverrideConfig,
)
from opensquilla.gateway.config_migration import (
LATEST_CONFIG_VERSION,
migrate_config_payload,
)
from opensquilla.onboarding import config_store
# ---------------------------------------------------------------------------
# [model_catalog] schema
# ---------------------------------------------------------------------------
def test_model_catalog_defaults_are_offline_first() -> None:
cfg = GatewayConfig()
assert cfg.model_catalog.refresh == "off"
assert cfg.model_catalog.pin_path == ""
assert cfg.model_catalog.stale_after_days == 45
def test_model_catalog_accepts_startup_refresh_and_pin_path() -> None:
cfg = GatewayConfig.model_validate(
{
"model_catalog": {
"refresh": "startup",
"pin_path": "/srv/airgap/model-catalog.json",
"stale_after_days": 7,
}
}
)
assert cfg.model_catalog.refresh == "startup"
assert cfg.model_catalog.pin_path == "/srv/airgap/model-catalog.json"
assert cfg.model_catalog.stale_after_days == 7
@pytest.mark.parametrize("bad_refresh", ["daily", "on", "always", ""])
def test_model_catalog_rejects_unknown_refresh_values(bad_refresh: str) -> None:
with pytest.raises(ValidationError):
GatewayConfig.model_validate({"model_catalog": {"refresh": bad_refresh}})
@pytest.mark.parametrize("bad_days", [0, -1])
def test_model_catalog_stale_after_days_must_be_at_least_one(bad_days: int) -> None:
with pytest.raises(ValidationError):
GatewayConfig.model_validate({"model_catalog": {"stale_after_days": bad_days}})
def test_model_catalog_env_prefix_binds(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENSQUILLA_MODEL_CATALOG_REFRESH", "startup")
monkeypatch.setenv("OPENSQUILLA_MODEL_CATALOG_STALE_AFTER_DAYS", "10")
catalog = ModelCatalogConfig()
assert catalog.refresh == "startup"
assert catalog.stale_after_days == 10
# ---------------------------------------------------------------------------
# [models] override schema
# ---------------------------------------------------------------------------
def test_models_defaults_to_empty_mapping() -> None:
assert GatewayConfig().models == {}
def test_models_toml_quoted_keys_parse(tmp_path: Path) -> None:
"""Model ids carrying dots/slashes must survive as TOML quoted keys."""
toml_path = tmp_path / "config.toml"
toml_path.write_text(
"\n".join(
[
'[models.custom."qwen3-32b-awq"]',
"context_window = 131072",
"max_output_tokens = 8192",
"supports_tools = true",
"",
'[models.openrouter."z-ai/glm-5.2"]',
'reasoning_format = "openrouter"',
"supports_reasoning = true",
"input_cost_per_mtok = 0.5",
"output_cost_per_mtok = 2.0",
"cache_read_cost_per_mtok = 0.05",
"cache_write_cost_per_mtok = 0.6",
'thinking_level_map = { high = "high", medium = "medium" }',
"",
]
),
encoding="utf-8",
)
cfg = GatewayConfig.load(toml_path)
custom = cfg.models["custom"]["qwen3-32b-awq"]
assert custom.context_window == 131072
assert custom.max_output_tokens == 8192
assert custom.supports_tools is True
assert custom.reasoning_format is None # unset fields stay "no override"
glm = cfg.models["openrouter"]["z-ai/glm-5.2"]
assert glm.reasoning_format == "openrouter"
assert glm.supports_reasoning is True
assert glm.input_cost_per_mtok == 0.5
assert glm.output_cost_per_mtok == 2.0
assert glm.cache_read_cost_per_mtok == 0.05
assert glm.cache_write_cost_per_mtok == 0.6
assert glm.thinking_level_map == {"high": "high", "medium": "medium"}
def test_model_override_rejects_unknown_fields() -> None:
with pytest.raises(ValidationError):
GatewayConfig.model_validate(
{"models": {"custom": {"qwen3-32b-awq": {"context_windw": 131072}}}}
)
@pytest.mark.parametrize(
"override",
[
{"context_window": 0},
{"max_output_tokens": 0},
{"input_cost_per_mtok": -0.1},
{"output_cost_per_mtok": -1},
{"cache_read_cost_per_mtok": -0.01},
{"cache_write_cost_per_mtok": -1},
],
ids=[
"context-window",
"max-output",
"input-cost",
"output-cost",
"cache-read-cost",
"cache-write-cost",
],
)
def test_model_override_rejects_out_of_range_values(override: dict[str, Any]) -> None:
with pytest.raises(ValidationError):
GatewayConfig.model_validate({"models": {"custom": {"m": override}}})
def test_model_override_accepts_cache_cost_fields() -> None:
override = ModelOverrideConfig(
input_cost_per_mtok=0.2, cache_read_cost_per_mtok=0.05, cache_write_cost_per_mtok=0.6
)
assert override.cache_read_cost_per_mtok == 0.05
assert override.cache_write_cost_per_mtok == 0.6
@pytest.mark.parametrize("dialect", sorted(KNOWN_REASONING_FORMATS))
def test_reasoning_format_accepts_every_known_dialect(dialect: str) -> None:
override = ModelOverrideConfig(reasoning_format=dialect)
assert override.reasoning_format == dialect
def test_reasoning_format_normalizes_case_and_whitespace() -> None:
assert ModelOverrideConfig(reasoning_format=" OpenAI ").reasoning_format == "openai"
def test_reasoning_format_rejects_unknown_dialect() -> None:
with pytest.raises(ValidationError, match="not a known dialect"):
ModelOverrideConfig(reasoning_format="mystery-dialect")
def test_glob_looking_keys_are_allowed_but_inert() -> None:
"""Globs are not supported: keys are exact-match only, but glob-looking
strings are still accepted (a never-matching key is harmless, and key
screening could reject unusual legitimate model ids)."""
cfg = GatewayConfig.model_validate(
{"models": {"openrouter": {"z-ai/*": {"supports_vision": False}}}}
)
assert cfg.models["openrouter"]["z-ai/*"].supports_vision is False
# ---------------------------------------------------------------------------
# to_toml_dict / persist round-trips
# ---------------------------------------------------------------------------
def test_to_toml_dict_always_materializes_both_sections() -> None:
"""to_toml_dict dumps with exclude_defaults=False, so a full-tree persist
always writes the sections — the documented downgrade caveat."""
data = GatewayConfig().to_toml_dict()
assert data["model_catalog"] == {
"refresh": "off",
"pin_path": "",
"stale_after_days": 45,
}
assert data["models"] == {}
def test_to_toml_dict_drops_unset_override_fields() -> None:
cfg = GatewayConfig.model_validate(
{"models": {"custom": {"qwen3-32b-awq": {"context_window": 131072}}}}
)
data = cfg.to_toml_dict()
# exclude_none=True keeps the entry TOML-serializable: only set fields.
assert data["models"] == {"custom": {"qwen3-32b-awq": {"context_window": 131072}}}
def test_persist_round_trip_preserves_catalog_and_overrides(tmp_path: Path) -> None:
payload = {
"model_catalog": {"refresh": "startup", "pin_path": "/srv/catalog.toml"},
"models": {
"custom": {"qwen3-32b-awq": {"context_window": 131072}},
"openrouter": {
"z-ai/glm-5.2": {
"reasoning_format": "openrouter",
"input_cost_per_mtok": 0.5,
}
},
},
}
source = tmp_path / "config.toml"
with source.open("wb") as fh:
tomli_w.dump(payload, fh)
cfg = config_store.load_config(source)
target = tmp_path / "persisted.toml"
config_store.persist_config(cfg, path=target, backup=False)
reloaded = config_store.load_config(target)
assert reloaded.model_catalog.refresh == "startup"
assert reloaded.model_catalog.pin_path == "/srv/catalog.toml"
assert reloaded.model_catalog.stale_after_days == 45
assert reloaded.models == cfg.models
assert reloaded.models["custom"]["qwen3-32b-awq"].context_window == 131072
glm = reloaded.models["openrouter"]["z-ai/glm-5.2"]
assert glm.reasoning_format == "openrouter"
assert glm.input_cost_per_mtok == 0.5
# ---------------------------------------------------------------------------
# Migration framework: purely additive, no migration fires
# ---------------------------------------------------------------------------
def test_existing_payload_without_sections_loads_unchanged() -> None:
"""A pre-0.5.x-shaped payload (no model_catalog/models keys) must load
without triggering a migration rewrite and pick up pure defaults."""
payload = {
"config_version": LATEST_CONFIG_VERSION,
"host": "127.0.0.1",
"port": 18791,
"llm": {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"},
}
result = migrate_config_payload(payload)
assert result.changed is False
assert "model_catalog" not in result.payload
assert "models" not in result.payload
cfg = GatewayConfig.model_validate(result.payload)
assert cfg.model_catalog.refresh == "off"
assert cfg.models == {}
def test_file_without_sections_is_not_rewritten_on_load(tmp_path: Path) -> None:
toml_path = tmp_path / "config.toml"
with toml_path.open("wb") as fh:
tomli_w.dump({"port": 18791}, fh)
original_bytes = toml_path.read_bytes()
cfg = GatewayConfig.load(toml_path)
assert cfg.model_catalog.refresh == "off"
assert cfg.models == {}
assert toml_path.read_bytes() == original_bytes
assert not list(tmp_path.glob("config.toml.backup.*"))