Files
hermes-agent/tests/agent/test_models_dev.py
kshitij 5b4c91f1db refactor(models): simplify-pass follow-ups on the refresh path
- Cold force_refresh (fresh CLI process, e.g. hermes config refresh)
  now hydrates the memory cache from disk before fetching, so the
  conditional GET actually fires on the flow the feature was built for
  instead of silently re-downloading the full ~2 MB registry
  (empirically probed: If-None-Match sent, 304 serves disk data).
- Conditional-GET decision is passed in explicitly
  (_fetch_models_dev_from_network(conditional=...)) by callers holding
  the fetch lock, removing the hidden read of module globals inside
  the fetch; the background worker now fetches INSIDE the lock,
  symmetric with foreground (true singleflight — no concurrent
  double-download, no fetching against mid-commit etag state).
- Corrupt disk cache is QUARANTINED (renamed to .json.corrupt) rather
  than left in place: rejection becomes a one-time event instead of a
  re-read + re-parse + warning + unlink on every hot-path call while
  offline (probed: 1 warning across 5 calls, was 5).
- Dropped the dead _DEFAULT_MODELS_DEV_URL constant; module and
  function docstrings updated to match the servable-cache conditional
  semantics.
2026-08-14 03:31:22 +05:30

1308 lines
49 KiB
Python

"""Tests for agent.models_dev — models.dev registry integration."""
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch, MagicMock
import pytest
from agent.models_dev import (
PROVIDER_TO_MODELS_DEV,
_extract_context,
_default_model_override,
_explicit_model_override,
_override_context_window,
_override_for,
_NotModified,
_validate_registry,
fetch_models_dev,
get_model_capabilities,
get_model_info,
get_provider_info,
lookup_models_dev_context,
)
SAMPLE_REGISTRY = {
"anthropic": {
"id": "anthropic",
"name": "Anthropic",
"models": {
"claude-opus-4-6": {
"id": "claude-opus-4-6",
"limit": {"context": 1000000, "output": 128000},
},
"claude-sonnet-4-6": {
"id": "claude-sonnet-4-6",
"limit": {"context": 1000000, "output": 64000},
},
"claude-sonnet-4-0": {
"id": "claude-sonnet-4-0",
"limit": {"context": 200000, "output": 64000},
},
},
},
"github-copilot": {
"id": "github-copilot",
"name": "GitHub Copilot",
"models": {
"claude-opus-4.6": {
"id": "claude-opus-4.6",
"limit": {"context": 128000, "output": 32000},
},
},
},
"xai": {
"id": "xai",
"name": "xAI",
"models": {
"grok-build-0.1": {
"id": "grok-build-0.1",
"limit": {"context": 256000, "output": 64000},
},
},
},
"kilo": {
"id": "kilo",
"name": "Kilo Gateway",
"models": {
"anthropic/claude-sonnet-4.6": {
"id": "anthropic/claude-sonnet-4.6",
"limit": {"context": 1000000, "output": 128000},
},
},
},
"deepseek": {
"id": "deepseek",
"name": "DeepSeek",
"models": {
"deepseek-chat": {
"id": "deepseek-chat",
"limit": {"context": 128000, "output": 8192},
},
},
},
"audio-only": {
"id": "audio-only",
"models": {
"tts-model": {
"id": "tts-model",
"limit": {"context": 0, "output": 0},
},
},
},
}
class TestProviderMapping:
def test_all_mapped_providers_are_strings(self):
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
assert isinstance(hermes_id, str)
assert isinstance(mdev_id, str)
def test_known_providers_mapped(self):
assert PROVIDER_TO_MODELS_DEV["anthropic"] == "anthropic"
assert PROVIDER_TO_MODELS_DEV["copilot"] == "github-copilot"
assert PROVIDER_TO_MODELS_DEV["stepfun"] == "stepfun"
assert PROVIDER_TO_MODELS_DEV["kilocode"] == "kilo"
assert PROVIDER_TO_MODELS_DEV["ai-gateway"] == "vercel"
def test_xai_oauth_uses_xai_catalog(self):
assert PROVIDER_TO_MODELS_DEV["xai"] == "xai"
assert PROVIDER_TO_MODELS_DEV["xai-oauth"] == "xai"
def test_unmapped_provider_not_in_dict(self):
assert "nous" not in PROVIDER_TO_MODELS_DEV
class TestExtractContext:
def test_valid_entry(self):
assert _extract_context({"limit": {"context": 128000}}) == 128000
def test_non_dict_returns_none(self):
assert _extract_context("not a dict") is None
class TestLookupModelsDevContext:
@patch("agent.models_dev.fetch_models_dev")
def test_exact_match(self, mock_fetch):
mock_fetch.return_value = SAMPLE_REGISTRY
assert lookup_models_dev_context("anthropic", "claude-opus-4-6") == 1000000
@patch("agent.models_dev.fetch_models_dev")
def test_zero_context_filtered(self, mock_fetch):
mock_fetch.return_value = SAMPLE_REGISTRY
# audio-only is not a mapped provider, but test the filtering directly
data = SAMPLE_REGISTRY["audio-only"]["models"]["tts-model"]
assert _extract_context(data) is None
class TestFetchModelsDev:
@pytest.fixture(autouse=True)
def _reset_fetch_state(self):
import agent.models_dev as md
md._models_dev_cache = {}
md._models_dev_cache_time = 0
md._models_dev_retry_after = 0
md._models_dev_refresh_in_flight = False
yield
md._models_dev_cache = {}
md._models_dev_cache_time = 0
md._models_dev_retry_after = 0
md._models_dev_refresh_in_flight = False
def _mock_response(self, data, etag="", status_code=200):
"""Build a MagicMock response with optional ETag header."""
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = data
resp.headers = {"ETag": etag} if etag else {}
resp.raise_for_status = MagicMock()
return resp
@patch("agent.models_dev.requests.get")
def test_stale_disk_cache_returns_without_foreground_network(self, mock_get):
"""#35838: stale disk cache should not wait on models.dev timeout."""
import agent.models_dev as md
md._models_dev_cache = {}
md._models_dev_cache_time = 0
with patch.object(md, "_disk_cache_age_seconds",
return_value=md._MODELS_DEV_CACHE_TTL + 60), \
patch.object(md, "_load_disk_cache", return_value=SAMPLE_REGISTRY), \
patch.object(md, "_load_etag", return_value=""), \
patch.object(md, "_start_background_refresh_models_dev") as mock_refresh:
result = fetch_models_dev()
mock_get.assert_not_called()
mock_refresh.assert_called_once()
assert "anthropic" in result
@patch("agent.models_dev.requests.get")
def test_stale_cache_failure_enters_backoff_and_suppresses_retry(self, mock_get):
import agent.models_dev as md
mock_get.side_effect = OSError("models.dev unreachable")
md._models_dev_cache = SAMPLE_REGISTRY
md._models_dev_cache_time = time.time() - md._MODELS_DEV_CACHE_TTL - 1
with patch.object(
md,
"_disk_cache_age_seconds",
return_value=md._MODELS_DEV_CACHE_TTL + 60,
), patch.object(md, "_load_disk_cache", return_value=SAMPLE_REGISTRY), \
patch.object(md, "_load_etag", return_value=""):
first = fetch_models_dev()
# Join the background refresh worker so its failure backoff is
# observable and requests.get stays patched for its lifetime.
for worker in threading.enumerate():
if worker.name == "models-dev-refresh":
worker.join(timeout=5)
assert not worker.is_alive()
assert first == SAMPLE_REGISTRY
assert not md._models_dev_refresh_in_flight
assert md._models_dev_retry_after > time.time()
mock_get.assert_called_once()
# A subsequent stale-cache hit inside the backoff window must not
# spawn another refresh worker (in_flight is set synchronously
# before the worker thread starts, so False proves no spawn).
md._models_dev_cache_time = time.time() - md._MODELS_DEV_CACHE_TTL - 1
second = fetch_models_dev()
assert second == SAMPLE_REGISTRY
assert not md._models_dev_refresh_in_flight
mock_get.assert_called_once()
@patch("agent.models_dev.requests.get")
def test_background_refresh_success_commits_registry(self, mock_get):
"""The bg worker must save disk + swap mem cache + clear backoff."""
import agent.models_dev as md
response = self._mock_response(SAMPLE_REGISTRY, etag='"abc123"')
mock_get.return_value = response
md._models_dev_cache = {"stale": {}}
md._models_dev_cache_time = 0
md._models_dev_retry_after = time.time() - 1
with patch.object(md, "_save_disk_cache") as mock_save, \
patch.object(md, "_load_etag", return_value=""), \
patch.object(md, "_save_etag") as mock_save_etag:
# Run the worker synchronously — deterministic, no thread.
md._models_dev_refresh_in_flight = True
md._background_refresh_models_dev()
# ETag is committed together with the cache body so the sidecar
# can never get ahead of the data it vouches for.
mock_save.assert_called_once_with(SAMPLE_REGISTRY, '"abc123"')
mock_save_etag.assert_not_called()
assert md._models_dev_cache == SAMPLE_REGISTRY
assert md._models_dev_cache_time > 0
assert md._models_dev_retry_after == 0
assert not md._models_dev_refresh_in_flight
@patch("agent.models_dev.requests.get")
def test_concurrent_refreshes_share_one_network_request(self, mock_get):
import agent.models_dev as md
request_started = threading.Event()
release_request = threading.Event()
response = self._mock_response(SAMPLE_REGISTRY)
def blocking_get(*_args, **_kwargs):
request_started.set()
assert release_request.wait(timeout=5)
return response
mock_get.side_effect = blocking_get
with patch.object(md, "_disk_cache_age_seconds", return_value=None), patch.object(
md, "_save_disk_cache"
), patch.object(md, "_load_etag", return_value=""), \
patch.object(md, "_save_etag"), \
ThreadPoolExecutor(max_workers=6) as pool:
futures = [pool.submit(fetch_models_dev) for _ in range(6)]
assert request_started.wait(timeout=2)
release_request.set()
results = [future.result(timeout=5) for future in futures]
assert results == [SAMPLE_REGISTRY] * 6
mock_get.assert_called_once()
@patch("agent.models_dev.requests.get")
def test_force_refresh_bypasses_failure_backoff(self, mock_get):
import agent.models_dev as md
response = self._mock_response(SAMPLE_REGISTRY)
mock_get.side_effect = [OSError("models.dev unreachable"), response]
with patch.object(md, "_disk_cache_age_seconds", return_value=None), patch.object(
md, "_load_disk_cache", return_value={}
), patch.object(md, "_save_disk_cache"), \
patch.object(md, "_load_etag", return_value=""), \
patch.object(md, "_save_etag"):
assert fetch_models_dev() == {}
assert fetch_models_dev(force_refresh=True) == SAMPLE_REGISTRY
assert mock_get.call_count == 2
assert md._models_dev_retry_after == 0
@pytest.mark.parametrize(
("cache", "cache_time", "disk_data", "expected"),
[
(SAMPLE_REGISTRY, lambda md: time.time(), {}, SAMPLE_REGISTRY),
(
SAMPLE_REGISTRY,
lambda md: time.time() - md._MODELS_DEV_CACHE_TTL - 1,
{},
SAMPLE_REGISTRY,
),
({}, lambda _md: 0, {}, {}),
],
ids=["fresh-memory", "stale-memory", "missing"],
)
@patch("agent.models_dev.requests.get")
def test_network_disabled_never_fetches(
self, mock_get, cache, cache_time, disk_data, expected
):
import agent.models_dev as md
md._models_dev_cache = cache
md._models_dev_cache_time = cache_time(md)
with patch.object(md, "_load_disk_cache", return_value=disk_data):
result = fetch_models_dev(allow_network=False)
assert result == expected
mock_get.assert_not_called()
# ---------------------------------------------------------------------------
# ETag conditional GET
# ---------------------------------------------------------------------------
class TestETagConditionalGet:
"""Tests for ETag-based conditional GET (If-None-Match / 304 handling)."""
@pytest.fixture(autouse=True)
def _reset_fetch_state(self):
import agent.models_dev as md
md._models_dev_cache = {}
md._models_dev_cache_time = 0
md._models_dev_retry_after = 0
md._models_dev_refresh_in_flight = False
yield
md._models_dev_cache = {}
md._models_dev_cache_time = 0
md._models_dev_retry_after = 0
md._models_dev_refresh_in_flight = False
@patch("agent.models_dev.requests.get")
def test_etag_sent_when_cached(self, mock_get):
"""If-None-Match header is sent when a cached ETag exists."""
import agent.models_dev as md
response = MagicMock()
response.status_code = 200
response.json.return_value = SAMPLE_REGISTRY
response.headers = {"ETag": '"v2"'}
response.raise_for_status = MagicMock()
mock_get.return_value = response
# Conditional GET requires a servable in-memory registry — an
# If-None-Match without one invites a 304 against nothing.
md._models_dev_cache = SAMPLE_REGISTRY
md._models_dev_cache_time = 0
with patch.object(md, "_disk_cache_age_seconds", return_value=None), \
patch.object(md, "_load_disk_cache", return_value={}), \
patch.object(md, "_save_disk_cache"), \
patch.object(md, "_load_etag", return_value='"v1"'), \
patch.object(md, "_save_etag"):
fetch_models_dev(force_refresh=True)
call_kwargs = mock_get.call_args
headers = call_kwargs.kwargs.get("headers", {})
assert headers.get("If-None-Match") == '"v1"'
@patch("agent.models_dev.requests.get")
def test_304_reconfirms_cache_freshness(self, mock_get):
"""A 304 Not Modified re-confirms the existing cache without download."""
import agent.models_dev as md
response = MagicMock()
response.status_code = 304
mock_get.return_value = response
md._models_dev_cache = SAMPLE_REGISTRY
md._models_dev_cache_time = 0
md._models_dev_retry_after = time.time() + 100 # backoff was armed
with patch.object(md, "_load_etag", return_value='"v1"'), \
patch.object(md, "_save_etag"):
# Run the background worker synchronously
md._models_dev_refresh_in_flight = True
md._background_refresh_models_dev()
# Cache content unchanged
assert md._models_dev_cache == SAMPLE_REGISTRY
# Freshness timestamp advanced
assert md._models_dev_cache_time > 0
# Backoff cleared
assert md._models_dev_retry_after == 0
assert not md._models_dev_refresh_in_flight
# response.json() was never called — no body to parse
response.json.assert_not_called()
@patch("agent.models_dev.requests.get")
def test_foreground_304_returns_existing_cache(self, mock_get):
"""Foreground fetch with 304 returns the existing cache."""
import agent.models_dev as md
response = MagicMock()
response.status_code = 304
mock_get.return_value = response
md._models_dev_cache = SAMPLE_REGISTRY
md._models_dev_cache_time = 0
md._models_dev_retry_after = 0
with patch.object(md, "_disk_cache_age_seconds", return_value=None), \
patch.object(md, "_load_disk_cache", return_value={}), \
patch.object(md, "_load_etag", return_value='"v1"'), \
patch.object(md, "_save_etag"):
result = fetch_models_dev(force_refresh=True)
assert result == SAMPLE_REGISTRY
assert md._models_dev_cache_time > 0
@patch("agent.models_dev.requests.get")
def test_new_etag_persisted_after_successful_fetch(self, mock_get):
"""A successful fetch with an ETag in the response persists it."""
import agent.models_dev as md
response = MagicMock()
response.status_code = 200
response.json.return_value = SAMPLE_REGISTRY
response.headers = {"ETag": '"new-etag"'}
response.raise_for_status = MagicMock()
mock_get.return_value = response
with patch.object(md, "_disk_cache_age_seconds", return_value=None), \
patch.object(md, "_load_disk_cache", return_value={}), \
patch.object(md, "_save_disk_cache") as mock_save, \
patch.object(md, "_load_etag", return_value=""), \
patch.object(md, "_save_etag") as mock_save_etag:
fetch_models_dev()
# ETag rides along with the cache body into _save_disk_cache.
mock_save.assert_called_once_with(SAMPLE_REGISTRY, '"new-etag"')
mock_save_etag.assert_not_called()
@patch("agent.models_dev.requests.get")
def test_no_etag_header_sent_without_cached_etag(self, mock_get):
"""No If-None-Match header when no cached ETag exists."""
import agent.models_dev as md
response = MagicMock()
response.status_code = 200
response.json.return_value = SAMPLE_REGISTRY
response.headers = {}
response.raise_for_status = MagicMock()
mock_get.return_value = response
with patch.object(md, "_disk_cache_age_seconds", return_value=None), \
patch.object(md, "_load_disk_cache", return_value={}), \
patch.object(md, "_save_disk_cache"), \
patch.object(md, "_load_etag", return_value=""), \
patch.object(md, "_save_etag"):
fetch_models_dev()
call_kwargs = mock_get.call_args
headers = call_kwargs.kwargs.get("headers", {})
assert "If-None-Match" not in headers
# ---------------------------------------------------------------------------
# Corrupt / invalid cache rejection
# ---------------------------------------------------------------------------
class TestCorruptCacheRejection:
"""A corrupt or empty disk cache must be rejected, not served as {}."""
def test_validate_registry_rejects_empty_dict(self):
assert not _validate_registry({})
def test_validate_registry_rejects_non_dict(self):
assert not _validate_registry("not a dict")
assert not _validate_registry(None)
assert not _validate_registry([])
def test_validate_registry_accepts_populated_dict(self):
assert _validate_registry({"anthropic": {}})
def test_corrupt_json_on_disk_rejected_with_warning(self, tmp_path, caplog):
"""Invalid JSON in a REAL cache file is rejected with a warning."""
import logging
import agent.models_dev as md
cache = tmp_path / "models_dev_cache.json"
cache.write_text("not json{{{", encoding="utf-8")
with patch.object(md, "_get_cache_path", return_value=cache), \
patch.object(md, "_get_etag_path", return_value=tmp_path / "models_dev_cache.etag"):
with caplog.at_level(logging.WARNING):
result = md._load_disk_cache()
assert result == {}
assert any("disk cache" in r.message for r in caplog.records)
def test_empty_dict_on_disk_rejected_with_warning(self, tmp_path, caplog):
"""A REAL cache file containing {} is rejected with a warning."""
import logging
import agent.models_dev as md
cache = tmp_path / "models_dev_cache.json"
cache.write_text("{}", encoding="utf-8")
with patch.object(md, "_get_cache_path", return_value=cache), \
patch.object(md, "_get_etag_path", return_value=tmp_path / "models_dev_cache.etag"):
with caplog.at_level(logging.WARNING):
result = md._load_disk_cache()
assert result == {}
assert any("corrupt or empty" in r.message for r in caplog.records)
def test_corrupt_cache_clears_etag_sidecar(self, tmp_path):
"""Rejecting a corrupt cache must drop the ETag sidecar (#35838 loop).
If the sidecar outlives the registry it vouches for, the next
conditional GET draws a 304 against nothing and the process serves
{} forever. Clearing the sidecar forces an unconditional refetch.
"""
import agent.models_dev as md
cache = tmp_path / "models_dev_cache.json"
etag = tmp_path / "models_dev_cache.etag"
cache.write_text("corrupt!!", encoding="utf-8")
etag.write_text("stale-etag", encoding="utf-8")
with patch.object(md, "_get_cache_path", return_value=cache), \
patch.object(md, "_get_etag_path", return_value=etag):
result = md._load_disk_cache()
assert result == {}
assert not etag.exists()
# The corrupt file is quarantined (renamed), so the rejection is
# a one-time event instead of a re-parse + warning per call.
assert not cache.exists()
assert cache.with_suffix(".json.corrupt").exists()
def test_conditional_get_skipped_without_servable_cache(self):
"""No If-None-Match header when the process holds no registry.
A conditional GET without a servable cache invites a 304 that
leaves the process with no data at all — the permanent
empty-registry loop. The header is only sent when _models_dev_cache
is populated.
"""
import agent.models_dev as md
captured: dict = {}
def fake_get(url, headers=None, timeout=None):
captured["headers"] = dict(headers or {})
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"anthropic": {"models": {}}}
resp.headers = {"ETag": "fresh"}
return resp
with patch.object(md.requests, "get", side_effect=fake_get), \
patch.object(md, "_load_etag", return_value="stale-etag"), \
patch.object(md, "_models_dev_cache", {}):
data, etag = md._fetch_models_dev_from_network()
assert "If-None-Match" not in captured["headers"]
assert data == {"anthropic": {"models": {}}}
assert etag == "fresh"
def test_304_with_empty_cache_arms_backoff_and_clears_etag(self, tmp_path):
"""Defense in depth: a 304 landing on an empty registry must not
mark {} as fresh — it clears the sidecar and arms the backoff."""
import agent.models_dev as md
etag = tmp_path / "models_dev_cache.etag"
etag.write_text("stale", encoding="utf-8")
with patch.object(md, "_get_etag_path", return_value=etag), \
patch.object(md, "_models_dev_cache", {}):
before = md._models_dev_retry_after
try:
md._confirm_cache_not_modified(where="test")
assert not etag.exists()
assert md._models_dev_retry_after > time.time() - 1
finally:
md._models_dev_retry_after = before
# ---------------------------------------------------------------------------
# Mirror URL override via config
# ---------------------------------------------------------------------------
class TestMirrorUrlOverride:
"""models_dev.url config key overrides the API endpoint."""
@pytest.fixture(autouse=True)
def _reset_fetch_state(self):
import agent.models_dev as md
md._models_dev_cache = {}
md._models_dev_cache_time = 0
md._models_dev_retry_after = 0
md._models_dev_refresh_in_flight = False
yield
md._models_dev_cache = {}
md._models_dev_cache_time = 0
md._models_dev_retry_after = 0
md._models_dev_refresh_in_flight = False
@patch("agent.models_dev.requests.get")
def test_mirror_url_used_when_configured(self, mock_get):
"""When config has models_dev.url, requests.get hits that URL."""
import agent.models_dev as md
response = MagicMock()
response.status_code = 200
response.json.return_value = SAMPLE_REGISTRY
response.headers = {}
response.raise_for_status = MagicMock()
mock_get.return_value = response
fake_config = {"models_dev": {"url": "https://mirror.example.com/api.json"}}
with patch.object(md, "_disk_cache_age_seconds", return_value=None), \
patch.object(md, "_load_disk_cache", return_value={}), \
patch.object(md, "_save_disk_cache"), \
patch.object(md, "_load_etag", return_value=""), \
patch.object(md, "_save_etag"), \
patch("hermes_cli.config.load_config_readonly", return_value=fake_config):
fetch_models_dev()
call_args = mock_get.call_args
assert "mirror.example.com" in call_args.args[0]
@patch("agent.models_dev.requests.get")
def test_default_url_used_when_not_configured(self, mock_get):
"""Without config override, the default models.dev URL is used."""
import agent.models_dev as md
response = MagicMock()
response.status_code = 200
response.json.return_value = SAMPLE_REGISTRY
response.headers = {}
response.raise_for_status = MagicMock()
mock_get.return_value = response
with patch.object(md, "_disk_cache_age_seconds", return_value=None), \
patch.object(md, "_load_disk_cache", return_value={}), \
patch.object(md, "_save_disk_cache"), \
patch.object(md, "_load_etag", return_value=""), \
patch.object(md, "_save_etag"), \
patch("hermes_cli.config.load_config_readonly", return_value={}):
fetch_models_dev()
call_args = mock_get.call_args
assert "models.dev" in call_args.args[0]
@patch("agent.models_dev.requests.get")
def test_empty_url_falls_back_to_default(self, mock_get):
"""An empty string URL in config falls back to the default."""
import agent.models_dev as md
response = MagicMock()
response.status_code = 200
response.json.return_value = SAMPLE_REGISTRY
response.headers = {}
response.raise_for_status = MagicMock()
mock_get.return_value = response
fake_config = {"models_dev": {"url": ""}}
with patch.object(md, "_disk_cache_age_seconds", return_value=None), \
patch.object(md, "_load_disk_cache", return_value={}), \
patch.object(md, "_save_disk_cache"), \
patch.object(md, "_load_etag", return_value=""), \
patch.object(md, "_save_etag"), \
patch("hermes_cli.config.load_config_readonly", return_value=fake_config):
fetch_models_dev()
call_args = mock_get.call_args
assert "models.dev" in call_args.args[0]
# ---------------------------------------------------------------------------
# No-network-on-hot-paths invariant
# ---------------------------------------------------------------------------
class TestNoNetworkOnHotPaths:
"""Query functions must default to allow_network=False on hot paths."""
@patch("agent.models_dev.requests.get")
def test_get_model_capabilities_default_no_network(self, mock_get):
"""get_model_capabilities defaults to allow_network=False."""
with patch("agent.models_dev.fetch_models_dev") as mock_fetch:
mock_fetch.return_value = CAPS_REGISTRY
get_model_capabilities("anthropic", "claude-sonnet-4")
# fetch_models_dev was called with allow_network=False
mock_fetch.assert_called_once_with(allow_network=False)
@patch("agent.models_dev.requests.get")
def test_get_model_info_default_no_network(self, mock_get):
"""get_model_info defaults to allow_network=False."""
with patch("agent.models_dev.fetch_models_dev") as mock_fetch:
mock_fetch.return_value = SAMPLE_REGISTRY
get_model_info("anthropic", "claude-opus-4-6")
mock_fetch.assert_called_once_with(allow_network=False)
@patch("agent.models_dev.requests.get")
def test_lookup_models_dev_context_default_no_network(self, mock_get):
"""lookup_models_dev_context defaults to allow_network=False."""
with patch("agent.models_dev.fetch_models_dev") as mock_fetch:
mock_fetch.return_value = SAMPLE_REGISTRY
lookup_models_dev_context("anthropic", "claude-opus-4-6")
mock_fetch.assert_called_once_with(allow_network=False)
@patch("agent.models_dev.requests.get")
def test_get_model_capabilities_explicit_network(self, mock_get):
"""get_model_capabilities can opt into network."""
with patch("agent.models_dev.fetch_models_dev") as mock_fetch:
mock_fetch.return_value = CAPS_REGISTRY
get_model_capabilities("anthropic", "claude-sonnet-4", allow_network=True)
# allow_network=True uses the zero-arg call shape so the dozens of
# test sites that monkeypatch fetch_models_dev with zero-arg
# lambdas keep working.
mock_fetch.assert_called_once_with()
# ---------------------------------------------------------------------------
# get_model_capabilities — vision via modalities.input
# ---------------------------------------------------------------------------
CAPS_REGISTRY = {
"google": {
"id": "google",
"models": {
"gemma-4-31b-it": {
"id": "gemma-4-31b-it",
"attachment": False,
"tool_call": True,
"modalities": {"input": ["text", "image"]},
"limit": {"context": 128000, "output": 8192},
},
"gemma-3-1b": {
"id": "gemma-3-1b",
"tool_call": True,
"limit": {"context": 32000, "output": 8192},
},
"text-only-with-stale-attachment": {
"id": "text-only-with-stale-attachment",
"attachment": True,
"tool_call": True,
"modalities": {"input": ["text"]},
"limit": {"context": 128000, "output": 8192},
},
},
},
"anthropic": {
"id": "anthropic",
"models": {
"claude-sonnet-4": {
"id": "claude-sonnet-4",
"attachment": True,
"tool_call": True,
"limit": {"context": 200000, "output": 64000},
},
},
},
}
class TestGetModelCapabilities:
"""Tests for get_model_capabilities vision detection."""
def test_vision_from_attachment_flag(self):
"""Models with attachment=True and no modalities should report supports_vision=True."""
with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY):
caps = get_model_capabilities("anthropic", "claude-sonnet-4")
assert caps is not None
assert caps.supports_vision is True
def test_modalities_non_dict_handled(self):
"""Non-dict modalities field should not crash."""
registry = {
"google": {"id": "google", "models": {
"weird-model": {
"id": "weird-model",
"modalities": "text", # not a dict
"limit": {"context": 200000, "output": 8192},
},
}},
}
with patch("agent.models_dev.fetch_models_dev", return_value=registry):
caps = get_model_capabilities("gemini", "weird-model")
assert caps is not None
assert caps.supports_vision is False
# ---------------------------------------------------------------------------
# Per-model metadata overrides (model_overrides config)
# ---------------------------------------------------------------------------
class TestModelOverrides:
"""Tests for the model_overrides config system."""
def _setup_overrides(self, overrides_dict):
"""Patch _load_model_overrides to return the given dict."""
import agent.models_dev as md
return patch.object(md, "_load_model_overrides", return_value=overrides_dict)
# --- override resolution ---
def test_per_provider_model_override(self):
"""Per-provider+model override is found first."""
overrides = {
"upstage": {
"solar-pro4": {"context_window": 524288},
},
}
with self._setup_overrides(overrides):
result = _explicit_model_override("upstage", "solar-pro4")
assert result is not None
assert result["context_window"] == 524288
def test_explicit_override_case_insensitive_model(self):
"""Model ids match case-insensitively, mirroring catalog lookup."""
overrides = {
"upstage": {
"Solar-Pro4": {"context_window": 524288},
},
}
with self._setup_overrides(overrides):
result = _explicit_model_override("upstage", "solar-pro4")
assert result is not None
assert result["context_window"] == 524288
def test_provider_key_accepts_either_id_space(self):
"""Override keyed by Hermes id resolves for models.dev id and back."""
overrides = {
"copilot": {
"my-model": {"context_window": 111111},
},
}
with self._setup_overrides(overrides):
# Caller passes the models.dev id; config keyed by Hermes id.
result = _explicit_model_override("github-copilot", "my-model")
assert result is not None
assert result["context_window"] == 111111
overrides = {
"github-copilot": {
"my-model": {"context_window": 222222},
},
}
with self._setup_overrides(overrides):
# Caller passes the Hermes id; config keyed by models.dev id.
result = _explicit_model_override("copilot", "my-model")
assert result is not None
assert result["context_window"] == 222222
def test_default_fills_gap_for_unknown_model(self):
"""_default applies to models the catalog does not know."""
overrides = {
"upstage": {
"_default": {"context_window": 128000},
},
}
with self._setup_overrides(overrides):
result = _override_for("upstage", "unknown-model", catalog_hit=False)
assert result is not None
assert result["context_window"] == 128000
def test_default_does_not_clamp_catalog_known_model(self):
"""FILL-GAP semantics: _default never displaces catalog data.
A `_default: {context_window: 128000}` must not clamp every
catalog-known model of the provider — it only fills catalog misses.
"""
overrides = {
"upstage": {
"_default": {"context_window": 128000},
},
"_default": {"context_window": 65536},
}
with self._setup_overrides(overrides):
result = _override_for("upstage", "known-model", catalog_hit=True)
assert result is None
def test_global_default_fallback(self):
"""Global _default is used when provider has no section."""
overrides = {
"_default": {"context_window": 65536},
}
with self._setup_overrides(overrides):
result = _default_model_override("unknown-provider")
assert result is not None
assert result["context_window"] == 65536
def test_no_override_returns_none(self):
with self._setup_overrides({}):
assert _explicit_model_override("anthropic", "claude-sonnet-4") is None
assert _default_model_override("anthropic") is None
def test_explicit_beats_default(self):
"""Per-provider+model wins over per-provider _default."""
overrides = {
"upstage": {
"solar-pro4": {"context_window": 524288},
"_default": {"context_window": 128000},
},
}
with self._setup_overrides(overrides):
result = _override_for("upstage", "solar-pro4", catalog_hit=False)
assert result is not None
assert result["context_window"] == 524288
def test_per_provider_default_beats_global(self):
overrides = {
"upstage": {
"_default": {"context_window": 128000},
},
"_default": {"context_window": 65536},
}
with self._setup_overrides(overrides):
result = _default_model_override("upstage")
assert result is not None
assert result["context_window"] == 128000
# --- _override_context_window (explicit-only, early-chain) ---
def test_override_context_window_returns_value(self):
overrides = {
"upstage": {
"syn-pro": {"context_window": 65536},
},
}
with self._setup_overrides(overrides):
ctx = _override_context_window("upstage", "syn-pro")
assert ctx == 65536
def test_override_context_window_returns_none_when_missing(self):
with self._setup_overrides({}):
ctx = _override_context_window("upstage", "syn-pro")
assert ctx is None
def test_override_context_window_rejects_zero(self):
overrides = {
"upstage": {
"bad-model": {"context_window": 0},
},
}
with self._setup_overrides(overrides):
ctx = _override_context_window("upstage", "bad-model")
assert ctx is None
def test_override_context_window_ignores_default(self):
"""Early-chain lookup is explicit-only: a _default must not preempt
more specific sources (custom_providers, live probes)."""
overrides = {
"upstage": {
"_default": {"context_window": 128000},
},
}
with self._setup_overrides(overrides):
ctx = _override_context_window("upstage", "syn-pro")
assert ctx is None
def test_malformed_context_window_warns_once(self, caplog):
"""Garbage values are rejected with a one-shot warning, not silence."""
import logging
import agent.models_dev as md
md._OVERRIDE_WARNED_KEYS.clear()
overrides = {
"upstage": {
"bad-model": {"context_window": "512k"},
},
}
with self._setup_overrides(overrides), caplog.at_level(logging.WARNING):
assert _override_context_window("upstage", "bad-model") is None
assert _override_context_window("upstage", "bad-model") is None
warnings = [r for r in caplog.records if "model_overrides" in r.message]
assert len(warnings) == 1
# --- get_model_capabilities with overrides ---
def test_caps_override_unknown_model(self):
"""Override provides capabilities for a model NOT in the catalog (#8731)."""
overrides = {
"custom:my-vllm": {
"my-llava-model": {
"context_window": 8192,
"supports_vision": True,
"supports_reasoning": False,
"supports_tools": True,
},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value={}):
caps = get_model_capabilities("custom:my-vllm", "my-llava-model")
assert caps is not None
assert caps.context_window == 8192
assert caps.supports_vision is True
assert caps.supports_reasoning is False
assert caps.supports_tools is True
def test_caps_override_patches_existing_catalog_entry(self):
"""Explicit override patches specific fields on a known entry (#84482)."""
overrides = {
"anthropic": {
"claude-sonnet-4": {
"context_window": 500000,
},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY):
caps = get_model_capabilities("anthropic", "claude-sonnet-4")
assert caps is not None
# Override wins
assert caps.context_window == 500000
# Non-overridden fields preserved from catalog
assert caps.supports_vision is True
assert caps.supports_tools is True
def test_caps_default_does_not_clamp_catalog_model(self):
"""A _default must not displace catalog data for known models."""
overrides = {
"anthropic": {
"_default": {"context_window": 1000},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY):
caps = get_model_capabilities("anthropic", "claude-sonnet-4")
assert caps is not None
assert caps.context_window != 1000
def test_caps_no_override_no_catalog_returns_none(self):
with self._setup_overrides({}), \
patch("agent.models_dev.fetch_models_dev", return_value={}):
caps = get_model_capabilities("anthropic", "unknown-model")
assert caps is None
def test_caps_override_default_for_unknown_model(self):
"""Per-provider _default provides capabilities for unknown models."""
overrides = {
"custom:my-vllm": {
"_default": {
"context_window": 32768,
"supports_tools": True,
},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value={}):
caps = get_model_capabilities("custom:my-vllm", "some-new-model")
assert caps is not None
assert caps.context_window == 32768
assert caps.supports_tools is True
# --- lookup_models_dev_context with overrides ---
def test_context_lookup_override_wins_over_catalog(self):
overrides = {
"anthropic": {
"claude-opus-4-6": {"context_window": 500000},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value=SAMPLE_REGISTRY):
ctx = lookup_models_dev_context("anthropic", "claude-opus-4-6")
assert ctx == 500000
def test_context_lookup_override_for_unknown_provider(self):
overrides = {
"upstage": {
"solar-pro4": {"context_window": 524288},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value={}):
ctx = lookup_models_dev_context("upstage", "solar-pro4")
assert ctx == 524288
def test_context_lookup_default_fills_catalog_miss(self):
"""_default supplies context for a model the catalog lacks."""
overrides = {
"anthropic": {
"_default": {"context_window": 77777},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value=SAMPLE_REGISTRY):
ctx = lookup_models_dev_context("anthropic", "model-not-in-catalog")
assert ctx == 77777
def test_context_lookup_default_does_not_clamp_catalog(self):
"""_default must not beat a catalog-known model's real context."""
overrides = {
"anthropic": {
"_default": {"context_window": 1000},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value=SAMPLE_REGISTRY):
ctx = lookup_models_dev_context("anthropic", "claude-opus-4-6")
assert ctx == 1000000 # catalog value, not the _default
# --- get_model_info with overrides (canonical schema) ---
def test_model_info_override_for_unknown_model(self):
"""Canonical-schema override provides metadata for an unknown model.
Same key space as every other consumer — context_window,
max_output_tokens, supports_* — NOT the internal catalog shape.
"""
overrides = {
"custom:my-vllm": {
"my-llava-model": {
"model_family": "llava",
"supports_reasoning": False,
"supports_tools": True,
"context_window": 8192,
"max_output_tokens": 4096,
},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value={}):
info = get_model_info("custom:my-vllm", "my-llava-model")
assert info is not None
assert info.family == "llava"
assert info.context_window == 8192
assert info.max_output == 4096
assert info.tool_call is True
assert info.reasoning is False
def test_model_info_override_merges_with_catalog(self):
"""Override patches context without clobbering the catalog's output.
The limit sub-dict is MERGED: an override setting only
context_window preserves the catalog's limit.output.
"""
overrides = {
"anthropic": {
"claude-sonnet-4-6": {
"context_window": 500000,
},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value=SAMPLE_REGISTRY):
info = get_model_info("anthropic", "claude-sonnet-4-6")
assert info is not None
# Override wins for the field it sets
assert info.context_window == 500000
# Sub-dict merge: catalog's limit.output survives
assert info.max_output == 64000
# Non-overridden fields preserved from catalog
assert info.name == "claude-sonnet-4-6"
def test_model_info_default_does_not_clamp_catalog(self):
"""_default fills gaps only — known models keep catalog metadata."""
overrides = {
"anthropic": {
"_default": {"context_window": 1000},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value=SAMPLE_REGISTRY):
info = get_model_info("anthropic", "claude-sonnet-4-6")
assert info is not None
assert info.context_window == 1000000
# --- e2e config plumbing (real config.yaml, no _load_model_overrides mock) ---
def test_e2e_overrides_load_from_real_config_yaml(self, tmp_path, monkeypatch):
"""The real config path works end-to-end: config.yaml on disk ->
load_config_readonly -> cfg_get -> override applied.
Every other test mocks _load_model_overrides; this one exercises
the actual wiring (key name, cfg accessor, cache invalidation).
"""
import importlib
import agent.models_dev as md
import hermes_cli.config as hc
home = tmp_path / "hermes"
home.mkdir()
(home / "config.yaml").write_text(
"model_overrides:\n"
" upstage:\n"
" solar-pro4:\n"
" context_window: 524288\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(home))
# Reset caches that memoize config paths (the override layer has
# no local cache — it rides load_config_readonly's mtime cache).
hc_cache = getattr(hc, "_LOAD_CONFIG_CACHE", None)
if isinstance(hc_cache, dict):
hc_cache.clear()
raw_cache = getattr(hc, "_RAW_CONFIG_CACHE", None)
if isinstance(raw_cache, dict):
raw_cache.clear()
importlib.reload # no-op guard: modules stay loaded, caches cleared
with patch("agent.models_dev.fetch_models_dev", return_value={}):
ctx = lookup_models_dev_context("upstage", "solar-pro4")
assert ctx == 524288
def test_suffix_keyed_model_counts_as_catalog_hit(self):
"""A suffix-keyed catalog model (kimi-k2.6:cloud) is KNOWN: a
_default must not displace its capabilities."""
registry = {
"ollama-cloud": {
"id": "ollama-cloud",
"models": {
"kimi-k2.6:cloud": {
"id": "kimi-k2.6:cloud",
"tool_call": True,
"limit": {"context": 262144, "output": 8192},
},
},
},
}
overrides = {
"ollama-cloud": {
"_default": {"context_window": 1000, "supports_tools": False},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value=registry):
caps = get_model_capabilities("ollama-cloud", "kimi-k2.6")
assert caps is not None
assert caps.context_window == 262144 # catalog, not the _default
assert caps.supports_tools is True
def test_model_info_unknown_model_gets_safe_defaults(self):
"""get_model_info's unknown-model path seeds the same safe
defaults as get_model_capabilities (200K/tools-on), so a partial
override doesn't yield ctx=0/tools-off."""
overrides = {
"custom:my-vllm": {
"my-model": {"supports_reasoning": True},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value={}):
info = get_model_info("custom:my-vllm", "my-model")
assert info is not None
assert info.context_window == 200000
assert info.max_output == 8192
assert info.tool_call is True
assert info.reasoning is True
def test_model_info_vision_override_sets_input_modality(self):
"""supports_vision: true surfaces as an image input modality."""
overrides = {
"custom:my-vllm": {
"my-model": {
"supports_vision": True,
"context_window": 8192,
},
},
}
with self._setup_overrides(overrides), \
patch("agent.models_dev.fetch_models_dev", return_value={}):
info = get_model_info("custom:my-vllm", "my-model")
assert info is not None
assert "image" in info.input_modalities
assert info.attachment is True