769 lines
30 KiB
Python
769 lines
30 KiB
Python
"""Layered model-catalog resolution (ModelCatalog.resolve_entry).
|
|
|
|
Covers per-field authority merging (user > live > corrections > snapshot >
|
|
synthesized), glob corrections matching, per-1k → per-Mtok cost adaptation,
|
|
cold-instance resolution, override validation — plus a parity net asserting
|
|
the legacy resolve paths still return today's exact values.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tomllib
|
|
from importlib import resources
|
|
|
|
import pytest
|
|
|
|
from opensquilla.provider import model_catalog as model_catalog_module
|
|
from opensquilla.provider.catalog_types import ModelCatalogEntry
|
|
from opensquilla.provider.model_catalog import (
|
|
ModelCatalog,
|
|
resolve_effective_context_window,
|
|
)
|
|
|
|
|
|
def _install_corrections(monkeypatch: pytest.MonkeyPatch, toml_text: str) -> None:
|
|
"""Route the corrections layer at parsed TOML text (packaged file is empty)."""
|
|
tables = model_catalog_module._normalize_corrections(tomllib.loads(toml_text))
|
|
monkeypatch.setattr(model_catalog_module, "_corrections_tables", lambda: tables)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cold instance — a bare ModelCatalog() with no network warmup must resolve
|
|
# through corrections + snapshot + synthesized (ensemble.py builds bare
|
|
# instances per member).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cold_instance_resolves_snapshot_known_model() -> None:
|
|
entry = ModelCatalog().resolve_entry("gpt-5.5", provider="openai")
|
|
|
|
assert entry.source == "snapshot"
|
|
assert entry.provider_id == "openai"
|
|
assert entry.model_id == "gpt-5.5"
|
|
assert entry.context_window == 1_050_000
|
|
assert entry.max_output_tokens == 128_000
|
|
assert entry.supports_reasoning is True
|
|
assert entry.supports_tools is True
|
|
assert entry.supports_vision is True
|
|
# The snapshot never knows the streaming dialect.
|
|
assert entry.reasoning_format == "none"
|
|
# The snapshot vendors models.dev cost keys verbatim.
|
|
assert entry.input_cost_per_mtok == pytest.approx(5.0)
|
|
assert entry.output_cost_per_mtok == pytest.approx(30.0)
|
|
assert entry.cache_read_cost_per_mtok == pytest.approx(0.5)
|
|
|
|
|
|
def test_cold_instance_synthesizes_unknown_model() -> None:
|
|
entry = ModelCatalog().resolve_entry("model-that-does-not-exist-anywhere")
|
|
|
|
assert entry.source == "synthesized"
|
|
assert entry.context_window == 32_768
|
|
assert entry.max_output_tokens == 8_192
|
|
assert entry.supports_tools is True
|
|
assert entry.supports_reasoning is False
|
|
assert entry.supports_vision is False
|
|
assert entry.input_cost_per_mtok is None
|
|
assert entry.output_cost_per_mtok is None
|
|
assert entry.quality_prior is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-field authority merging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _populated_catalog() -> ModelCatalog:
|
|
catalog = ModelCatalog()
|
|
catalog._populate_from_data(
|
|
[
|
|
{
|
|
"id": "vendor/model-x",
|
|
"name": "Vendor Model X",
|
|
"context_length": 200_000,
|
|
"top_provider": {"max_completion_tokens": 30_000},
|
|
"supported_parameters": ["tools", "reasoning"],
|
|
"architecture": {"input_modalities": ["text", "image"]},
|
|
"pricing": {"prompt": "0.0000025", "completion": "0.00001"},
|
|
}
|
|
]
|
|
)
|
|
return catalog
|
|
|
|
|
|
def test_user_overrides_beat_live_per_field() -> None:
|
|
catalog = _populated_catalog()
|
|
catalog.set_user_overrides(
|
|
{"openrouter/vendor/model-x": {"context_window": 111, "quality_prior": 0.9}}
|
|
)
|
|
|
|
entry = catalog.resolve_entry("vendor/model-x", provider="openrouter")
|
|
|
|
assert entry.source == "user"
|
|
# User-set fields win outright…
|
|
assert entry.context_window == 111
|
|
assert entry.quality_prior == 0.9
|
|
# …while the live layer still fills everything the user left unset.
|
|
assert entry.max_output_tokens == 30_000
|
|
assert entry.display_name == "Vendor Model X"
|
|
assert entry.supports_reasoning is True
|
|
assert entry.supports_vision is True
|
|
assert entry.reasoning_format == "openrouter"
|
|
|
|
|
|
def test_live_beats_corrections_and_corrections_fill_unset(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
catalog = ModelCatalog()
|
|
catalog._populate_from_data(
|
|
[
|
|
{
|
|
"id": "vendor/model-y",
|
|
"context_length": 100_000,
|
|
"top_provider": {"max_completion_tokens": 2_048},
|
|
"supported_parameters": ["tools"],
|
|
}
|
|
]
|
|
)
|
|
_install_corrections(
|
|
monkeypatch,
|
|
"""
|
|
[openrouter."vendor/model-y"]
|
|
max_output_tokens = 9999
|
|
supports_reasoning = true
|
|
reasoning_format = "deepseek"
|
|
quality_prior = 0.5
|
|
""",
|
|
)
|
|
|
|
entry = catalog.resolve_entry("vendor/model-y", provider="openrouter")
|
|
|
|
assert entry.source == "live"
|
|
# Live knows these — the correction must NOT override them…
|
|
assert entry.max_output_tokens == 2_048
|
|
assert entry.supports_reasoning is False
|
|
# …but fields live left unset are filled from corrections.
|
|
assert entry.reasoning_format == "deepseek"
|
|
assert entry.quality_prior == 0.5
|
|
|
|
|
|
def test_corrections_beat_snapshot_and_snapshot_fills_unset(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_install_corrections(
|
|
monkeypatch,
|
|
"""
|
|
[openai."gpt-4"]
|
|
context_window = 7777
|
|
supports_vision = true
|
|
""",
|
|
)
|
|
|
|
entry = ModelCatalog().resolve_entry("gpt-4", provider="openai")
|
|
|
|
assert entry.source == "corrections"
|
|
assert entry.context_window == 7_777 # corrections beat snapshot's 8192
|
|
assert entry.supports_vision is True # corrections beat snapshot's False
|
|
assert entry.max_output_tokens == 8_192 # snapshot fills unset fields
|
|
assert entry.supports_tools is True
|
|
assert entry.supports_reasoning is False
|
|
|
|
|
|
def test_snapshot_beats_synthesized_but_floor_fills_gaps(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# A correction that carries only metadata still gets the synthesized
|
|
# budget floor for the numeric fields nothing knows.
|
|
_install_corrections(
|
|
monkeypatch,
|
|
"""
|
|
[zhipu."glm-9-experimental"]
|
|
family = "glm"
|
|
""",
|
|
)
|
|
|
|
entry = ModelCatalog().resolve_entry("glm-9-experimental", provider="zhipu")
|
|
|
|
assert entry.source == "corrections"
|
|
assert entry.family == "glm"
|
|
assert entry.context_window == 32_768
|
|
assert entry.max_output_tokens == 8_192
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Glob corrections matching
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_glob_corrections_match_lowercased_model_and_exact_key_wins(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_install_corrections(
|
|
monkeypatch,
|
|
"""
|
|
[zhipu."glm-9-pro"]
|
|
display_name = "Exact GLM-9 Pro"
|
|
context_window = 123456
|
|
|
|
[zhipu."glm-9*"]
|
|
display_name = "Glob GLM-9"
|
|
max_output_tokens = 4242
|
|
supports_reasoning = true
|
|
""",
|
|
)
|
|
catalog = ModelCatalog()
|
|
|
|
# Uppercase spelling still matches: model ids are lowercased for lookup.
|
|
exact = catalog.resolve_entry("GLM-9-Pro", provider="zhipu")
|
|
assert exact.display_name == "Exact GLM-9 Pro" # exact key beats the glob
|
|
assert exact.context_window == 123_456
|
|
assert exact.max_output_tokens == 4_242 # glob fills what exact left unset
|
|
assert exact.supports_reasoning is True
|
|
|
|
globbed = catalog.resolve_entry("glm-9-mini", provider="zhipu")
|
|
assert globbed.source == "corrections"
|
|
assert globbed.display_name == "Glob GLM-9"
|
|
assert globbed.context_window == 32_768 # synthesized floor fills the rest
|
|
|
|
# Corrections are provider-scoped: same model id, other provider → none.
|
|
other = catalog.resolve_entry("glm-9-mini", provider="openai")
|
|
assert other.source == "synthesized"
|
|
assert other.display_name == ""
|
|
|
|
|
|
def test_corrections_drop_unknown_and_mistyped_fields() -> None:
|
|
tables = model_catalog_module._normalize_corrections(
|
|
{
|
|
"vendor": {
|
|
"model-z": {
|
|
"context_window": 5_000,
|
|
"supports_reasoning": "true", # string, not bool → dropped
|
|
"bogus_field": 1, # unknown → dropped
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
assert tables == {"vendor": {"model-z": {"context_window": 5_000}}}
|
|
|
|
|
|
def test_packaged_corrections_file_parses_with_expected_tables() -> None:
|
|
text = (
|
|
resources.files("opensquilla.provider")
|
|
.joinpath("catalog_overrides.toml")
|
|
.read_text(encoding="utf-8")
|
|
)
|
|
payload = tomllib.loads(text)
|
|
# Window/pricing corrections (retired static tables) + the transcribed
|
|
# capability ladder (see the "capability ladder migration" section).
|
|
assert set(payload) == {
|
|
"moonshot",
|
|
"anthropic",
|
|
"openrouter",
|
|
"dashscope",
|
|
"volcengine",
|
|
"volcengine_coding_plan",
|
|
"byteplus",
|
|
"deepseek",
|
|
"gemini",
|
|
"zhipu",
|
|
"qianfan",
|
|
"tencent_tokenhub",
|
|
"tencent_token_plan",
|
|
"tokenrhythm",
|
|
"kimi_coding_openai",
|
|
"kimi_coding_anthropic",
|
|
"minimax",
|
|
"minimax_cn",
|
|
"minimax_global",
|
|
"minimax_openai",
|
|
"minimax_coding_openai",
|
|
"minimax_coding_anthropic",
|
|
"mimo_openai",
|
|
"mimo_anthropic",
|
|
}
|
|
assert set(payload["moonshot"]) == {
|
|
"moonshot-v1-8k",
|
|
"moonshot-v1-32k",
|
|
"moonshot-v1-128k",
|
|
"kimi-k2.5*",
|
|
"kimi-k2.6*",
|
|
"kimi-k2.7*",
|
|
"kimi-k2-thinking*",
|
|
"*",
|
|
}
|
|
assert set(payload["anthropic"]) == {
|
|
"claude-opus-4-6",
|
|
"claude-sonnet-4-6",
|
|
"claude-haiku-4-5-20251001",
|
|
}
|
|
assert set(payload["openrouter"]) == {
|
|
"anthropic/claude-opus-4.8",
|
|
"anthropic/claude-sonnet-4.6",
|
|
"x-ai/grok-4.3",
|
|
"stepfun/step-3.5-flash",
|
|
}
|
|
# Every packaged row survives normalization — no unknown field names,
|
|
# no mistyped values (a dropped field would silently weaken a layer).
|
|
tables = model_catalog_module._normalize_corrections(payload)
|
|
assert {p: set(t) for p, t in tables.items()} == {p: set(t) for p, t in payload.items()}
|
|
assert all(fields for table in tables.values() for fields in table.values())
|
|
|
|
|
|
def test_ladder_glob_rows_keep_specific_before_general_file_order() -> None:
|
|
"""FILE ORDER of the transcribed ladder rows is load-bearing.
|
|
|
|
The corrections loader matches globs in file order, each filling only
|
|
fields still unset — so a more specific prefix must appear before the
|
|
general one it overlaps (qwen3.5* before qwen3*, doubao-seed-1-6*
|
|
before *thinking*) and the "*" catch-all must be the LAST key of every
|
|
ladder provider table. Reordering rows would silently change matching.
|
|
"""
|
|
payload = tomllib.loads(
|
|
resources.files("opensquilla.provider")
|
|
.joinpath("catalog_overrides.toml")
|
|
.read_text(encoding="utf-8")
|
|
)
|
|
for provider in (
|
|
"dashscope",
|
|
"moonshot",
|
|
"volcengine",
|
|
"byteplus",
|
|
"gemini",
|
|
"zhipu",
|
|
"tencent_tokenhub",
|
|
"tencent_token_plan",
|
|
"tokenrhythm",
|
|
):
|
|
keys = list(payload[provider])
|
|
assert keys[-1] == "*", provider
|
|
assert keys.count("*") == 1, provider
|
|
dashscope = list(payload["dashscope"])
|
|
assert dashscope.index("qwen3.5*") < dashscope.index("qwen3*")
|
|
assert dashscope.index("qwen3.6*") < dashscope.index("qwen3*")
|
|
volcengine = list(payload["volcengine"])
|
|
assert volcengine.index("doubao-seed-1-6*") < volcengine.index("*thinking*")
|
|
byteplus = list(payload["byteplus"])
|
|
assert byteplus.index("kimi-k2-*") < byteplus.index("*thinking*")
|
|
# deepseek is a single catch-all (reasoning_shape transcription).
|
|
assert list(payload["deepseek"]) == ["*"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Packaged corrections data rows (windows/pricing from in-repo static tables)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_moonshot_v1_windows_resolve_from_packaged_corrections() -> None:
|
|
# moonshot-v1-* is absent from the snapshot's moonshot table, so without
|
|
# these rows resolve_entry would fall to the synthesized floor (32k/8k).
|
|
# The corrections rows are the canonical source of these windows (the
|
|
# in-repo static fallback table they were copied from has been retired);
|
|
# the literals below pin the retired table's exact values.
|
|
catalog = ModelCatalog()
|
|
expected_windows = {
|
|
"moonshot-v1-8k": (8_192, 8_192),
|
|
"moonshot-v1-32k": (32_768, 32_768),
|
|
"moonshot-v1-128k": (131_072, 131_072),
|
|
}
|
|
for model, (max_output, context_window) in expected_windows.items():
|
|
entry = catalog.resolve_entry(model, provider="moonshot")
|
|
assert entry.source == "corrections", model
|
|
assert entry.context_window == context_window, model
|
|
assert entry.max_output_tokens == max_output, model
|
|
# The 8k SKU is 8192/8192, not the synthesized 32k/8k.
|
|
eight_k = catalog.resolve_entry("moonshot-v1-8k", provider="moonshot")
|
|
assert (eight_k.max_output_tokens, eight_k.context_window) == (8_192, 8_192)
|
|
|
|
|
|
def test_anthropic_listing_models_priced_via_packaged_corrections() -> None:
|
|
# The corrections rows are the canonical metadata for the SKUs the
|
|
# Anthropic adapter lists (the adapter's _KNOWN_MODELS table has been
|
|
# retired; list_models resolves these rows through the shared catalog).
|
|
# Values are verified against Anthropic's published per-model pricing
|
|
# page (input/output per Mtok). The opus-4-6 and sonnet-4-6 rows carry
|
|
# no cache pricing, so their cache_read/write fall through to the
|
|
# snapshot's vendored models.dev cache rates for the same SKU; the
|
|
# haiku-4-5 row now carries its own cache rates directly.
|
|
listing_rows = (
|
|
("claude-opus-4-6", "Claude Opus 4.6", 200_000, 32_000, 5.0, 25.0, 0.5, 6.25),
|
|
("claude-sonnet-4-6", "Claude Sonnet 4.6", 200_000, 16_000, 3.0, 15.0, 0.3, 3.75),
|
|
("claude-haiku-4-5-20251001", "Claude Haiku 4.5", 200_000, 8_192, 1.0, 5.0, 0.1, 1.25),
|
|
)
|
|
catalog = ModelCatalog()
|
|
for (
|
|
model_id,
|
|
display_name,
|
|
context_window,
|
|
max_output,
|
|
in_mtok,
|
|
out_mtok,
|
|
cr_mtok,
|
|
cw_mtok,
|
|
) in listing_rows:
|
|
entry = catalog.resolve_entry(model_id, provider="anthropic")
|
|
assert entry.source == "corrections", model_id
|
|
assert entry.display_name == display_name, model_id
|
|
# Windows come from the same rows; corrections beat the snapshot.
|
|
assert entry.context_window == context_window, model_id
|
|
assert entry.max_output_tokens == max_output, model_id
|
|
assert entry.input_cost_per_mtok == pytest.approx(in_mtok)
|
|
assert entry.output_cost_per_mtok == pytest.approx(out_mtok)
|
|
# The retired table carried no cache pricing — corrections stays
|
|
# silent on it, so the snapshot's vendored rate fills the gap.
|
|
assert entry.cache_read_cost_per_mtok == pytest.approx(cr_mtok)
|
|
assert entry.cache_write_cost_per_mtok == pytest.approx(cw_mtok)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Live-layer cost adaptation (per-1k cache → per-Mtok canonical)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_live_costs_adapt_per_1k_to_per_mtok() -> None:
|
|
entry = _populated_catalog().resolve_entry("vendor/model-x", provider="openrouter")
|
|
|
|
# OpenRouter per-token "0.0000025" → cached 0.0025/1k → canonical 2.5/Mtok.
|
|
assert entry.input_cost_per_mtok == pytest.approx(2.5)
|
|
assert entry.output_cost_per_mtok == pytest.approx(10.0)
|
|
|
|
|
|
def test_live_zero_price_stays_unknown_not_free() -> None:
|
|
catalog = ModelCatalog()
|
|
catalog._populate_from_data([{"id": "vendor/free-model", "context_length": 8_192}])
|
|
|
|
entry = catalog.resolve_entry("vendor/free-model", provider="openrouter")
|
|
|
|
# The live cache's 0.0 means "free or unknown" — never claim a known $0.
|
|
assert entry.input_cost_per_mtok is None
|
|
assert entry.output_cost_per_mtok is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# User overrides — keying and validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_user_override_qualified_key_beats_bare_key_per_field() -> None:
|
|
catalog = ModelCatalog()
|
|
catalog.set_user_overrides(
|
|
{
|
|
"deepseek/some-model": {"context_window": 1_000},
|
|
"Some-Model": {"context_window": 2_000, "max_output_tokens": 500},
|
|
}
|
|
)
|
|
|
|
qualified = catalog.resolve_entry("some-model", provider="deepseek")
|
|
assert qualified.context_window == 1_000 # qualified key wins the field
|
|
assert qualified.max_output_tokens == 500 # bare key fills what it left unset
|
|
|
|
bare = catalog.resolve_entry("some-model")
|
|
assert bare.context_window == 2_000
|
|
|
|
|
|
def test_set_user_overrides_rejects_unknown_fields_and_bad_types() -> None:
|
|
catalog = ModelCatalog()
|
|
catalog.set_user_overrides({"kept-model": {"context_window": 4_096}})
|
|
|
|
with pytest.raises(ValueError, match="bogus_field"):
|
|
catalog.set_user_overrides({"m": {"bogus_field": 1}})
|
|
with pytest.raises(ValueError, match="context_window"):
|
|
catalog.set_user_overrides({"m": {"context_window": "big"}})
|
|
with pytest.raises(ValueError, match="supports_tools"):
|
|
catalog.set_user_overrides({"m": {"supports_tools": "false"}})
|
|
# Identity / derived fields are not overridable.
|
|
with pytest.raises(ValueError, match="source"):
|
|
catalog.set_user_overrides({"m": {"source": "user"}})
|
|
|
|
# A rejected replacement leaves the previous overrides installed.
|
|
assert catalog.resolve_entry("kept-model").context_window == 4_096
|
|
|
|
|
|
def test_explicit_false_and_zero_overrides_win() -> None:
|
|
catalog = _populated_catalog()
|
|
catalog.set_user_overrides(
|
|
{"vendor/model-x": {"supports_reasoning": False, "max_output_tokens": 0}}
|
|
)
|
|
|
|
entry = catalog.resolve_entry("vendor/model-x", provider="openrouter")
|
|
|
|
# Presence in a layer = knowledge: False/0 beat the live layer's values.
|
|
assert entry.supports_reasoning is False
|
|
assert entry.max_output_tokens == 0
|
|
|
|
|
|
def test_context_window_override_governs_legacy_resolver_with_source() -> None:
|
|
# The [models.*] user-override layer is the top layer of the legacy
|
|
# resolve_context_window chain too, attributed as "override"; models
|
|
# without an override keep resolving through live/snapshot/corrections
|
|
# exactly as the parity net below pins.
|
|
catalog = _populated_catalog()
|
|
catalog.set_user_overrides({"openrouter/vendor/model-x": {"context_window": 111}})
|
|
|
|
assert catalog.resolve_context_window_with_source(
|
|
"vendor/model-x", "openrouter"
|
|
) == (111, "override")
|
|
assert catalog.resolve_context_window("vendor/model-x", "openrouter") == 111
|
|
assert catalog.user_context_window_override("vendor/model-x", "openrouter") == 111
|
|
# Other models are untouched by the installed override.
|
|
assert catalog.resolve_context_window_with_source("gpt-5.5", "openai") == (
|
|
1_050_000,
|
|
"catalog",
|
|
)
|
|
assert catalog.user_context_window_override("gpt-5.5", "openai") is None
|
|
|
|
|
|
def test_context_window_override_zero_is_not_a_budgeting_window() -> None:
|
|
# A 0 override wins per-field in resolve_entry (presence = knowledge) but
|
|
# cannot serve as a budgeting window, so the legacy chain resolves past it.
|
|
catalog = _populated_catalog()
|
|
catalog.set_user_overrides({"openrouter/vendor/model-x": {"context_window": 0}})
|
|
|
|
assert catalog.user_context_window_override("vendor/model-x", "openrouter") is None
|
|
assert catalog.resolve_context_window_with_source("vendor/model-x", "openrouter") == (
|
|
200_000,
|
|
"catalog",
|
|
)
|
|
|
|
|
|
def test_resolve_effective_context_window_layers() -> None:
|
|
# Shared implementation of "[models.*] override > global config window >
|
|
# catalog > default" used by the engine budgeting and RPC paths.
|
|
catalog = _populated_catalog()
|
|
catalog.set_user_overrides({"openrouter/vendor/model-x": {"context_window": 111}})
|
|
|
|
assert resolve_effective_context_window(
|
|
catalog, "vendor/model-x", provider="openrouter", global_override=999_000
|
|
) == (111, "override")
|
|
assert resolve_effective_context_window(
|
|
catalog, "gpt-5.5", provider="openai", global_override=999_000
|
|
) == (999_000, "config")
|
|
assert resolve_effective_context_window(catalog, "gpt-5.5", provider="openai") == (
|
|
1_050_000,
|
|
"catalog",
|
|
)
|
|
# Junk/non-positive global values count as unset.
|
|
assert resolve_effective_context_window(
|
|
catalog, "gpt-5.5", provider="openai", global_override=-5
|
|
) == (1_050_000, "catalog")
|
|
|
|
|
|
def test_resolve_effective_context_window_duck_types_plain_catalogs() -> None:
|
|
class _PlainCatalog:
|
|
def resolve_context_window(self, model_id: str, provider: str = "") -> int:
|
|
return 42_000
|
|
|
|
# Without *_with_source the value is attributed "catalog" — never
|
|
# "override" — so the global config value still applies over it.
|
|
assert resolve_effective_context_window(_PlainCatalog(), "m") == (42_000, "catalog")
|
|
assert resolve_effective_context_window(_PlainCatalog(), "m", global_override=50_000) == (
|
|
50_000,
|
|
"config",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Snapshot-layer cost emission (compact in/out/cr/cw_mtok keys → per-Mtok
|
|
# fields). The committed snapshot carries no cost keys yet, so these drive
|
|
# the layer through a synthetic entry.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_snapshot_cost_keys_emit_per_mtok_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
model_catalog_module,
|
|
"_models_dev_model",
|
|
lambda provider_id, model_id: {
|
|
"ctx": 200_000,
|
|
"out": 32_000,
|
|
"reasoning": True,
|
|
"tools": True,
|
|
"vision": False,
|
|
"in_mtok": 2.5,
|
|
"out_mtok": 10, # int leaf — coerced to float
|
|
"cr_mtok": 0.25,
|
|
"cw_mtok": 3.125,
|
|
},
|
|
)
|
|
|
|
entry = ModelCatalog().resolve_entry("priced-model", provider="acme")
|
|
|
|
assert entry.source == "snapshot"
|
|
assert entry.input_cost_per_mtok == pytest.approx(2.5)
|
|
assert entry.output_cost_per_mtok == pytest.approx(10.0)
|
|
assert isinstance(entry.output_cost_per_mtok, float)
|
|
assert entry.cache_read_cost_per_mtok == pytest.approx(0.25)
|
|
assert entry.cache_write_cost_per_mtok == pytest.approx(3.125)
|
|
|
|
|
|
def test_snapshot_partial_cost_keys_leave_missing_fields_unknown(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
model_catalog_module,
|
|
"_models_dev_model",
|
|
lambda provider_id, model_id: {"ctx": 8_192, "out": 4_096, "in_mtok": 0.5},
|
|
)
|
|
|
|
entry = ModelCatalog().resolve_entry("partial-model", provider="acme")
|
|
|
|
assert entry.input_cost_per_mtok == pytest.approx(0.5)
|
|
assert entry.output_cost_per_mtok is None
|
|
assert entry.cache_read_cost_per_mtok is None
|
|
assert entry.cache_write_cost_per_mtok is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PARITY NET — the legacy resolve paths must keep returning today's exact
|
|
# values (captured as literals from the pre-change tree). get_capabilities
|
|
# now resolves through resolve_entry + the transcribed ladder rows, and
|
|
# resolve_max_tokens / resolve_context_window remain independent; any drift
|
|
# here is an accidental behavior change. The exhaustive provider x model
|
|
# sweep lives in test_capability_ladder_parity.py.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# (model, provider) → (resolve_max_tokens, resolve_context_window)
|
|
_LEGACY_LIMITS: dict[tuple[str, str], tuple[int, int]] = {
|
|
("gpt-5.5", "openai"): (128_000, 1_050_000),
|
|
("gpt-5.4-mini", "openai"): (128_000, 400_000),
|
|
("deepseek-v4-pro", "deepseek"): (384_000, 1_000_000),
|
|
("kimi-k2.5", "moonshot"): (8_192, 262_144),
|
|
("glm-5", "zhipu"): (131_072, 204_800),
|
|
("glm-5", "zai"): (16_384, 202_752),
|
|
# models.dev's 2026-07-08 refresh lowered openrouter z-ai/glm-5.2 max
|
|
# output from 131_072 to 32_768 (the cross-provider min); the window is
|
|
# unchanged.
|
|
("z-ai/glm-5.2", ""): (32_768, 1_000_000),
|
|
("gemini-3.5-flash", "gemini"): (65_536, 1_048_576),
|
|
("minimax-m2.7", ""): (131_072, 204_800),
|
|
("step-3.5-flash", ""): (16_384, 256_000),
|
|
("moonshot-v1-32k", "moonshot"): (8_192, 32_768),
|
|
("grok-4.3", ""): (16_384, 1_000_000),
|
|
("totally-unknown-model", ""): (16_384, 200_000),
|
|
("mystery-local", "ollama"): (8_192, 8_192),
|
|
("some-cloud-model", "openai"): (16_384, 200_000),
|
|
}
|
|
|
|
# (model, provider, base_url) →
|
|
# (supports_reasoning, supports_tools, supports_vision, reasoning_format)
|
|
_LEGACY_CAPS: dict[tuple[str, str, str], tuple[bool, bool, bool, str]] = {
|
|
("claude-opus-4.8", "anthropic", ""): (False, True, False, "none"),
|
|
("llama3.2:3b", "ollama", ""): (False, True, False, "none"),
|
|
("gpt-5.5", "openai", "https://api.openai.com/v1"): (True, True, False, "openai"),
|
|
("deepseek-v4-pro", "deepseek", ""): (True, True, False, "deepseek"),
|
|
("glm-5", "zhipu", ""): (True, True, False, "zai"),
|
|
("glm-4.6", "zhipu", ""): (False, True, False, "none"),
|
|
("qwen3-coder-plus", "dashscope", ""): (True, True, False, "dashscope"),
|
|
("kimi-k2.5", "moonshot", ""): (True, True, True, "moonshot"),
|
|
("gemini-3.5-flash", "gemini", ""): (True, True, True, "gemini"),
|
|
("doubao-seed-1-6-251015", "volcengine", ""): (True, True, True, "volcengine"),
|
|
("totally-unknown-model", "openrouter", ""): (False, True, False, "none"),
|
|
("gpt-4", "openai", ""): (False, True, False, "none"),
|
|
}
|
|
|
|
|
|
def test_parity_legacy_limits_unchanged() -> None:
|
|
catalog = ModelCatalog()
|
|
for (model, provider), (max_tokens, context_window) in _LEGACY_LIMITS.items():
|
|
assert catalog.resolve_max_tokens(model, provider=provider) == max_tokens, (
|
|
model,
|
|
provider,
|
|
)
|
|
assert catalog.resolve_context_window(model, provider) == context_window, (
|
|
model,
|
|
provider,
|
|
)
|
|
|
|
|
|
def test_parity_legacy_capabilities_unchanged() -> None:
|
|
catalog = ModelCatalog()
|
|
for (model, provider, base_url), expected in _LEGACY_CAPS.items():
|
|
caps = catalog.get_capabilities(model, provider_name=provider, base_url=base_url)
|
|
observed = (
|
|
caps.supports_reasoning,
|
|
caps.supports_tools,
|
|
caps.supports_vision,
|
|
caps.reasoning_format,
|
|
)
|
|
assert observed == expected, (model, provider, base_url)
|
|
|
|
|
|
def test_full_entry_literals_snapshot_and_transcribed_ladder() -> None:
|
|
# Full-literal entry pins (frozen-dataclass equality covers every field,
|
|
# including the four cost fields — the snapshot now vendors models.dev
|
|
# cost keys, so these are no longer all None). openai has NO ladder
|
|
# rows — the api.openai.com branch is host-guarded and stays code — so
|
|
# its snapshot resolution is byte-identical to the pre-ladder tree:
|
|
# supports_reasoning is snapshot data but reasoning_format stays "none"
|
|
# (the snapshot never knows the dialect). deepseek/gemini entries now
|
|
# layer the transcribed capability-ladder rows (reasoning_format,
|
|
# ladder-scoped capability booleans) over snapshot windows, so their
|
|
# source moved to "corrections" — deliberately, as part of the
|
|
# capability-ladder migration; the corrections rows carry no cost
|
|
# fields, so cost still comes from the snapshot layer underneath.
|
|
catalog = ModelCatalog()
|
|
expected_entries = (
|
|
ModelCatalogEntry(
|
|
provider_id="openai",
|
|
model_id="gpt-5.5",
|
|
context_window=1_050_000,
|
|
max_output_tokens=128_000,
|
|
supports_reasoning=True,
|
|
supports_tools=True,
|
|
supports_vision=True,
|
|
input_cost_per_mtok=5.0,
|
|
output_cost_per_mtok=30.0,
|
|
cache_read_cost_per_mtok=0.5,
|
|
source="snapshot",
|
|
),
|
|
ModelCatalogEntry(
|
|
provider_id="deepseek",
|
|
model_id="deepseek-chat",
|
|
context_window=1_000_000,
|
|
max_output_tokens=384_000,
|
|
# The ladder's deepseek shape: every model of the provider
|
|
# reasons through the deepseek dialect (snapshot said False).
|
|
supports_reasoning=True,
|
|
supports_tools=True,
|
|
supports_vision=False,
|
|
reasoning_format="deepseek",
|
|
input_cost_per_mtok=0.14,
|
|
output_cost_per_mtok=0.28,
|
|
cache_read_cost_per_mtok=0.0028,
|
|
source="corrections",
|
|
),
|
|
ModelCatalogEntry(
|
|
provider_id="gemini",
|
|
model_id="gemini-2.5-pro",
|
|
context_window=1_048_576,
|
|
max_output_tokens=65_536,
|
|
supports_reasoning=True,
|
|
supports_tools=True,
|
|
supports_vision=True,
|
|
reasoning_format="gemini",
|
|
input_cost_per_mtok=1.25,
|
|
output_cost_per_mtok=10.0,
|
|
cache_read_cost_per_mtok=0.125,
|
|
source="corrections",
|
|
),
|
|
ModelCatalogEntry(
|
|
provider_id="zhipu",
|
|
model_id="glm-4.6",
|
|
context_window=204_800,
|
|
max_output_tokens=131_072,
|
|
# Ladder semantics win over the snapshot's reasoning=True:
|
|
# the zai shape reasons only for glm-4.5/glm-4.7/glm-5.
|
|
supports_reasoning=False,
|
|
supports_tools=True,
|
|
supports_vision=False,
|
|
reasoning_format="none",
|
|
input_cost_per_mtok=0.6,
|
|
output_cost_per_mtok=2.2,
|
|
cache_read_cost_per_mtok=0.11,
|
|
cache_write_cost_per_mtok=0.0,
|
|
source="corrections",
|
|
),
|
|
)
|
|
for want in expected_entries:
|
|
got = catalog.resolve_entry(want.model_id, provider=want.provider_id)
|
|
assert got == want, (want.provider_id, want.model_id)
|