diff --git a/hermes_cli/models.py b/hermes_cli/models.py index eeeeebb181..20f388319e 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1605,8 +1605,8 @@ def _chat_catalog_rows(models): def _configured_relay_base_url(provider: str) -> str: """``model.base_url`` when it points the *configured* provider at a relay/proxy, else "". - Discovery must probe the same endpoint inference uses (#121387): with ``model.base_url`` - set for the configured provider, the vendor's canonical host is NOT the catalog to list. + Discovery must probe the same endpoint inference uses (#121387): when ``model.base_url`` + differs from the registered endpoint, the vendor's canonical host is NOT the catalog to list. Mirrors the ``$OPENAI_BASE_URL`` -> ``model.base_url`` -> canonical precedence of ``_openai_discovery_base_url`` for every built-in provider, not just OpenAI. """ @@ -1622,7 +1622,15 @@ def _configured_relay_base_url(provider: str) -> str: return "" except Exception: return "" - return str(model_cfg.get("base_url") or "").strip().rstrip("/") + base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") + from hermes_cli.auth import PROVIDER_REGISTRY + + registered = PROVIDER_REGISTRY.get(normalize_provider(provider)) + # Setup persists canonical URLs too. They still need the provider's native discovery + # (notably Codex OAuth); the generic relay probe only supports API-key providers. + if registered and base_url == registered.inference_base_url.rstrip("/"): + return "" + return base_url def _relay_model_catalog(normalized: str, relay: str) -> Optional[list[str]]: diff --git a/tests/hermes_cli/test_codex_credential_host_binding.py b/tests/hermes_cli/test_codex_credential_host_binding.py index 0d5fbf2cad..09ad041751 100644 --- a/tests/hermes_cli/test_codex_credential_host_binding.py +++ b/tests/hermes_cli/test_codex_credential_host_binding.py @@ -203,6 +203,44 @@ def test_picker_direct_chatgpt_positive_control(monkeypatch, picker_http): assert _authorized_hosts(picker_http) == {"chatgpt.com"} +@pytest.mark.parametrize("suffix", ["", "/"]) +def test_full_picker_discovers_codex_models_with_pinned_canonical_url(monkeypatch, suffix): + """A saved canonical URL must not bypass account discovery via the relay-only API-key path.""" + import httpx + + from hermes_cli.inventory import build_model_options_payload, load_picker_context + + home = _home(monkeypatch) + _write_config(home, base_url=CHATGPT + suffix) + _write_singleton(home, JWT) + tokens_before = json.loads((home / "auth.json").read_bytes())["providers"]["openai-codex"]["tokens"] + config_before = (home / "config.yaml").read_bytes() + discovered = "gpt-6-astra" + seen = [] + + def catalog_get(url, headers=None, **kwargs): + seen.append((url, headers)) + return SimpleNamespace(status_code=200, json=lambda: { + "models": [{"slug": discovered, "visibility": "list", "priority": 0}], + }) + + monkeypatch.setattr(httpx, "get", catalog_get) + # Unrelated metadata sources stay offline; config, auth, discovery, cache and picker are real. + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr("hermes_cli.models.get_curated_nous_model_ids", lambda: []) + monkeypatch.setattr("hermes_cli.models.fetch_ollama_cloud_models", lambda **kw: []) + monkeypatch.setattr("hermes_cli.models_pricing.get_pricing_for_provider", lambda *a, **kw: {}) + + payload = build_model_options_payload(load_picker_context(), refresh=True) + row = next(p for p in payload["providers"] if p["slug"] == "openai-codex") + assert discovered in row["models"] + assert seen + assert all(urlparse(url).hostname == "chatgpt.com" for url, _ in seen) + assert all(headers["Authorization"] == f"Bearer {JWT}" for _, headers in seen) + assert json.loads((home / "auth.json").read_bytes())["providers"]["openai-codex"]["tokens"] == tokens_before + assert (home / "config.yaml").read_bytes() == config_before + + def test_picker_refuses_opaque_key_aimed_at_chatgpt(picker_http): """Defense in depth: a non-JWT key composed with chatgpt.com is never sent there.""" from hermes_cli.codex_models import get_codex_model_ids diff --git a/tests/hermes_cli/test_models_relay_base_url.py b/tests/hermes_cli/test_models_relay_base_url.py index 8c14b35c9a..a78aece829 100644 --- a/tests/hermes_cli/test_models_relay_base_url.py +++ b/tests/hermes_cli/test_models_relay_base_url.py @@ -19,6 +19,30 @@ class _RecordingProfile: return ["relay-only-model"] +@pytest.mark.parametrize("provider", ["openai-codex", "copilot", "nous", "opencode-zen"]) +def test_canonical_url_preserves_native_catalog_but_other_paths_stay_relays(monkeypatch, provider): + from hermes_cli.auth import PROVIDER_REGISTRY + from hermes_cli.config import atomic_config_write + from hermes_constants import get_hermes_home + + canonical = PROVIDER_REGISTRY[provider].inference_base_url + config_path = get_hermes_home() / "config.yaml" + calls = [] + + def native_catalog(slug, force_refresh): + calls.append(slug) + return ["native-catalog-model"] + + monkeypatch.setitem(models._PROVIDER_CATALOG_FETCHERS, provider, native_catalog) + monkeypatch.setattr(models, "_relay_model_catalog", lambda *args: None) + for base_url, expected in [(canonical, [provider]), (canonical + "/relay", [])]: + calls.clear() + atomic_config_write(config_path, {"model": {"provider": provider, "base_url": base_url}}) + catalog = models.provider_model_ids(provider, force_refresh=True) + assert calls == expected + assert ("native-catalog-model" in catalog) == bool(expected) + + def test_relay_base_url_is_probed_for_configured_provider(monkeypatch): monkeypatch.setattr( models,