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.
173 lines
8.0 KiB
Python
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"]
|