Files
hermes-agent/tests/hermes_cli/test_setup_provider_catalog.py
kshitijk4poor 7fec7cf594 fix(setup): profile-owned catalog keeps the curated list when the live fetch fails
The profile branch added for #116408 returned ``merge_profile_catalog(...) or []``, so a
built-in API-key provider with a short curated row and a profile declaring no
fallback_models (minimax, minimax-cn, gemini, kilocode, arcee, stepfun, xiaomi) offered
zero models at first-time setup whenever the live catalog was unreachable — the generic
probe it replaced returned the curated list. Fall back to ``curated``, and share the
key-gated probe with the ``/model`` picker (``models.probe_profile_catalog``) so setup
neither sends a keyless request nor diverges from the picker's rows.
2026-09-20 12:17:52 +05:30

173 lines
8.0 KiB
Python

"""First-time setup must consume the registered provider catalog (#116408)."""
from unittest.mock import Mock
import pytest
@pytest.mark.parametrize("live", [True, False])
def test_setup_offers_registered_provider_catalog(monkeypatch, live):
import providers
from providers.base import ProviderProfile
from hermes_cli import auth, config, model_setup_flows as flows, models
class SetupProfile(ProviderProfile):
def fetch_models(self, *, api_key=None, base_url=None):
assert api_key == "synthetic-test-key"
assert base_url == self.base_url
return list(self.fallback_models) if live else None
profile = SetupProfile(
name="scout-setup-catalog", display_name="Setup catalog",
auth_type="api_key", env_vars=("SCOUT_SETUP_TEST_KEY",),
base_url="https://setup.example.invalid/v1",
fallback_models=("declared-plugin-model",),
)
monkeypatch.setitem(providers._REGISTRY, profile.name, profile)
monkeypatch.setattr(auth, "PROVIDER_REGISTRY", dict(auth.PROVIDER_REGISTRY))
# Mirror into the auth registry the way plugin discovery does; built from public types so the
# helper is independent of the auth module's private mirroring function.
pconfig = auth.ProviderConfig(
profile.name, profile.display_name or profile.name, profile.auth_type, inference_base_url=profile.base_url)
if profile.auth_type == "api_key" and profile.env_vars:
pconfig = auth._api_key_provider(profile.name, profile.display_name or profile.name, profile.base_url, tuple(profile.env_vars), "")
auth.PROVIDER_REGISTRY[profile.name] = pconfig
monkeypatch.setattr(flows, "_ensure_flow_api_key", lambda *_: (None, "synthetic-test-key", False))
monkeypatch.setattr(flows, "_env_base_url", lambda *_: "")
monkeypatch.setattr(flows, "_prompt_base_url_override", lambda value, *_args, **_kwargs: value)
monkeypatch.setattr(flows, "_models_dev_merged", lambda *_: [])
monkeypatch.setattr(config, "load_config", lambda: {})
monkeypatch.setattr(models, "fetch_api_models", lambda *_args, **_kwargs: [])
from hermes_cli import models_pricing
monkeypatch.setattr(models_pricing, "get_pricing_for_provider", lambda *_: {})
picker = Mock(return_value=None)
monkeypatch.setattr(flows, "_pick_model_or_prompt", picker)
monkeypatch.setattr(flows, "_finish_model", Mock())
flows._model_flow_api_key_provider({}, profile.name)
assert picker.call_args.args[0] == list(profile.fallback_models)
def test_setup_uses_profile_endpoint_and_headers(tmp_path, monkeypatch):
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from types import SimpleNamespace
import providers
from providers.base import ProviderProfile
from hermes_cli import model_setup_flows as flows
requests = []
class CatalogHandler(BaseHTTPRequestHandler):
def do_GET(self):
requests.append((self.path, self.headers.get("X-Catalog-Contract")))
payload = json.dumps({"data": [{"id": "live-catalog-model"}]}).encode()
self.send_response(200)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *_args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), CatalogHandler)
worker = Thread(target=server.serve_forever, daemon=True)
worker.start()
base = f"http://127.0.0.1:{server.server_port}"
profile = ProviderProfile(
name="scout-live-catalog", base_url=base + "/inference",
models_url=base + "/catalog", default_headers={"X-Catalog-Contract": "present"},
)
monkeypatch.setitem(providers._REGISTRY, profile.name, profile)
monkeypatch.setattr(flows, "_models_dev_merged", lambda *_: [])
try:
result = flows._api_key_provider_model_list(
profile.name, SimpleNamespace(name="Test catalog"),
"synthetic-test-key", "", profile.base_url,
)
finally:
server.shutdown()
server.server_close()
worker.join(timeout=5)
assert result == ["live-catalog-model"]
assert requests == [("/catalog", "present")]
def test_setup_matches_picker_when_catalog_fetch_fails(monkeypatch):
"""A profile whose catalog is down offers its ``fallback_models`` at setup, exactly what
``/model`` shows via ``models.provider_model_ids`` for the same profile."""
from types import SimpleNamespace
import providers
from providers.base import ProviderProfile
from hermes_cli import model_setup_flows as flows, models
class DownProfile(ProviderProfile):
def fetch_models(self, *, api_key=None, base_url=None, timeout=8.0):
raise ConnectionError("catalog down")
profile = DownProfile(
name="scout-down-catalog", auth_type="api_key", env_vars=("SCOUT_DOWN_TEST_KEY",),
base_url="https://down.example.invalid/v1", fallback_models=("declared-a", "declared-b"),
)
monkeypatch.setitem(providers._REGISTRY, profile.name, profile)
monkeypatch.setattr(flows, "_models_dev_merged", lambda *_: [])
monkeypatch.setattr(models, "_api_key_credentials", lambda *_: ("synthetic-test-key", ""))
setup_rows = flows._api_key_provider_model_list(
profile.name, SimpleNamespace(name="Down"), "synthetic-test-key", "", profile.base_url)
assert setup_rows == ["declared-a", "declared-b"] == models.provider_model_ids(profile.name)
def test_switch_validation_trusts_profile_owned_catalog(monkeypatch):
"""A plugin whose ``fetch_models`` is the catalog (#101705): the model the picker offers is
accepted even when the generic ``/v1/models`` 200s with a different product catalog; a model in
neither is still rejected."""
import providers
from providers.base import ProviderProfile
from hermes_cli import models, models_validate
class PlanProfile(ProviderProfile):
def fetch_models(self, *, api_key=None, base_url=None, timeout=8.0):
return ["plan/model-1"]
profile = PlanProfile(name="scout-plan", auth_type="api_key", env_vars=("SCOUT_PLAN_TEST_KEY",),
base_url="https://plan.example.invalid/v1")
monkeypatch.setitem(providers._REGISTRY, profile.name, profile)
monkeypatch.setattr(models, "_api_key_credentials", lambda *_: ("synthetic-test-key", ""))
monkeypatch.setattr(models, "fetch_api_models", lambda *_a, **_k: ["other-vendor/model-a"])
kw = dict(provider=profile.name, api_key="synthetic-test-key", base_url=profile.base_url)
assert models_validate.validate_requested_model("plan/model-1", **kw)["accepted"] is True
assert models_validate.validate_requested_model("plan/model-9", **kw)["accepted"] is False
def test_setup_keeps_curated_list_when_profile_catalog_is_down_and_declares_no_fallback(monkeypatch):
"""A built-in API-key provider whose profile has no ``fallback_models`` and whose live catalog is
unreachable still offers its curated ``_PROVIDER_MODELS`` row at first-time setup instead of an
empty picker."""
from types import SimpleNamespace
import providers
from providers.base import ProviderProfile
from hermes_cli import model_setup_flows as flows, models
class DownProfile(ProviderProfile):
def fetch_models(self, *, api_key=None, base_url=None, timeout=8.0):
raise ConnectionError("catalog down")
profile = DownProfile(name="scout-curated-only", auth_type="api_key", env_vars=("SCOUT_CURATED_KEY",),
base_url="https://down.example.invalid/v1")
monkeypatch.setitem(providers._REGISTRY, profile.name, profile)
monkeypatch.setitem(models._PROVIDER_MODELS, profile.name, ["curated-a", "curated-b"])
monkeypatch.setattr(flows, "_models_dev_merged", lambda *_: [])
setup_rows = flows._api_key_provider_model_list(
profile.name, SimpleNamespace(name="Down"), "synthetic-test-key", "", profile.base_url)
assert setup_rows == ["curated-a", "curated-b"]