NousResearch/hermes-agent

Model picker ignoring custom base_url for fetches

Cluster 47009 · hermes cli. Data as of

Nothing open on GitHub. Fix merged in #47405. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #47405. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #47405. That is separate from the fix event.
Still open
Nothing open on GitHub.

A bug caused the /model picker to bypass a custom model's configured base_url when retrieving available models from live catalogs or gateway endpoints. Several patches correct the propagation of base_url through the model-fetching pipeline.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #47405 fix(models): pass model.base_url to fetch_models in /model picker

    merged to main

    files: hermes_cli/models.py, plugins/model-providers/anthropic/__init__.py, plugins/model-providers/bedrock/__init__.py, plugins/model-providers/copilot-acp/__init__.py, plugins/model-providers/custom/__init__.py, plugins/model-providers/openrouter/__init__.py, providers/base.py, tests/hermes_cli/test_inventory.py, tests/providers/test_fetch_models_base_url.py

    Show the diff
    • hermes_cli/models.py
      @@ -2371,7 +2371,7 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
                   if not base_url:
                       base_url = _p.base_url
                   if api_key:
      -                live = _p.fetch_models(api_key=[redacted:secret-assignment]
      +                live = _p.fetch_models(api_key=[redacted:secret-assignment] base_url=base_url or None)
                       if live:
                           # Merge static curated list with live API results so
                           # models that the live endpoint omits (stale cache,
    • plugins/model-providers/anthropic/__init__.py
      @@ -17,6 +17,7 @@ def fetch_models(
               self,
               *,
               api_key: str | None = None,
      +        base_url: str | None = None,
               timeout: float = 8.0,
           ) -> list[str] | None:
               """Anthropic uses x-api-key header and anthropic-version."""
    • plugins/model-providers/bedrock/__init__.py
      @@ -11,6 +11,7 @@ def fetch_models(
               self,
               *,
               api_key: str | None = None,
      +        base_url: str | None = None,
               timeout: float = 8.0,
           ) -> list[str] | None:
               """Bedrock model listing requires AWS SDK, not a REST call."""
    • plugins/model-providers/copilot-acp/__init__.py
      @@ -16,6 +16,7 @@ def fetch_models(
               self,
               *,
               api_key: str | None = None,
      +        base_url: str | None = None,
               timeout: float = 8.0,
           ) -> list[str] | None:
               """Model listing is handled by the ACP subprocess."""
    • plugins/model-providers/custom/__init__.py
      @@ -43,12 +43,13 @@ def fetch_models(
               self,
               *,
               api_key: str | None = None,
      +        base_url: str | None = None,
               timeout: float = 8.0,
           ) -> list[str] | None:
               """Custom/Ollama: base_url is user-configured; fetch if set."""
      -        if not self.base_url:
      +        if not (base_url or self.base_url):
                   return None
      -        return super().fetch_models(api_key=[redacted:secret-assignment] timeout=timeout)
      +        return super().fetch_models(api_key=[redacted:secret-assignment] base_url=base_url, timeout=timeout)
       
       
       custom = CustomProfile(
    • plugins/model-providers/openrouter/__init__.py
      @@ -51,6 +51,7 @@ def fetch_models(
               self,
               *,
               api_key: str | None = None,
      +        base_url: str | None = None,
               timeout: float = 8.0,
           ) -> list[str] | None:
               """Fetch from public OpenRouter catalog — no auth required.
      @@ -64,7 +65,7 @@ def fetch_models(
               if _CACHE is not None:
                   return _CACHE
               try:
      -            result = super().fetch_models(api_key=None, timeout=timeout)
      +            result = super().fetch_models(api_key=None, base_url=base_url, timeout=timeout)
                   if result is not None:
                       _CACHE = result
                   return result
    • providers/base.py
      @@ -163,6 +163,7 @@ def fetch_models(
               self,
               *,
               api_key: str | None = None,
      +        base_url: str | None = None,
               timeout: float = 8.0,
           ) -> list[str] | None:
               """Fetch the live model list from the provider's models endpoint.
      @@ -175,7 +176,8 @@ def fetch_models(
                    endpoint differs from the inference base URL, e.g. OpenRouter
                    exposes a public catalog at /api/v1/models while inference is
                    at /api/v1)
      -          2. self.base_url + "/models"  (standard OpenAI-compat fallback)
      +          2. base_url (caller override — user-configured model.base_url)
      +          3. self.base_url + "/models"  (standard OpenAI-compat fallback)
       
               The default implementation sends Bearer auth when api_key is given
               and forwards self.default_headers. Override to customise auth, path,
      @@ -184,11 +186,12 @@ def fetch_models(
               Callers must always fall back to the static _PROVIDER_MODELS list
               when this returns None.
               """
      +        effective_base = base_url or self.base_url
               url = (self.models_url or "").strip()
               if not url:
      -            if not self.base_url:
      +            if not effective_base:
                       return None
      -            url = self.base_url.rstrip("/") + "/models"
      +            url = effective_base.rstrip("/") + "/models"
       
               import json
               import urllib.request
    • tests/hermes_cli/test_inventory.py
      @@ -638,3 +638,25 @@ def test_aggregator_dedup_does_not_empty_user_defined_custom_provider():
           assert "other/model" in or_row["models"]
           assert or_row["total_models"] == 1
       
      +
      +def test_two_custom_providers_with_overlap_both_survive():
      +    """Two user-defined custom endpoints that happen to expose an
      +    overlapping model must each keep their full catalog. Neither is the
      +    aggregator the dedup exists to trim, so cross-filtering between two
      +    user-defined rows must not happen.
      +    """
      +    rows = [
      +        _user_provider_row("custom:proxy-a", ["shared/model", "a/only"]),
      +        _user_provider_row("custom:proxy-b", ["shared/model", "b/only"]),
      +    ]
      +    ctx = _empty_ctx()
      +    with _list_auth_returning(rows):
      +        payload = build_models_payload(ctx)
      +
      +    a_row = next(r for r in payload["providers"] if r["slug"] == "custom:proxy-a")
      +    b_row = next(r for r in payload["providers"] if r["slug"] == "custom:proxy-b")
      +    assert a_row["models"] == ["shared/model", "a/only"]
      +    assert b_row["models"] == ["shared/model", "b/only"]
      +    assert a_row["total_models"] == 2
      +    assert b_row["total_models"] == 2
      +
    • tests/providers/test_fetch_models_base_url.py
      @@ -0,0 +1,140 @@
      +"""Tests for ProviderProfile.fetch_models base_url override (issue #47009)."""
      +
      +import json
      +from http.server import HTTPServer, BaseHTTPRequestHandler
      +from threading import Thread
      +from unittest.mock import patch, MagicMock
      +
      +from providers.base import ProviderProfile
      +
      +
      +class _FakeModelHandler(BaseHTTPRequestHandler):
      +    """Serves /models with a configurable model list."""
      +
      +    models = [{"id": "custom-model-1"}, {"id": "custom-model-2"}]
      +
      +    def do_GET(self):
      +        if self.path.rstrip("/") == "/models":
      +            body = json.dumps({"data": self.models}).encode()
      +            self.send_response(200)
      +            self.send_header("Content-Type", "application/json")
      +            self.end_headers()
      +            self.wfile.write(body)
      +        else:
      +            self.send_response(404)
      +            self.end_headers()
      +
      +    def log_message(self, format, *args):
      +        pass  # suppress noise
      +
      +
      +def _start_server(models=None):
      +    """Start a local HTTP server returning given models. Returns (server, port)."""
      +    if models is not None:
      +        _FakeModelHandler.models = models
      +    server = HTTPServer(("127.0.0.1", 0), _FakeModelHandler)
      +    port = server.server_address[1]
      +    thread = Thread(target=server.serve_forever, daemon=True)
      +    thread.start()
      +    return server, port
      +
      +
      +class TestFetchModelsBaseUrlOverride:
      +    """fetch_models() should use caller-provided base_url when given."""
      +
      +    def test_base_url_override_used(self):
      +        """When base_url is passed, it overrides self.base_url."""
      +        server, port = _start_server([{"id": "proxy-model-a"}])
      +        try:
      +            profile = ProviderProfile(
      +                name="test",
      +                base_url="http://127.0.0.1:1",  # wrong port — should not be used
      +            )
      +            result = profile.fetch_models(
      +                api_key="[redacted:secret-assignment]",
      +                base_url=f"http://127.0.0.1:{port}",
      +            )
      +            assert result == ["proxy-model-a"]
      +        finally:
      +            server.shutdown()
      +
      +    def test_fallback_to_self_base_url(self):
      +        """When base_url is None, falls back to self.base_url."""
      +        server, port = _start_server([{"id": "default-model"}])
      +        try:
      +            profile = ProviderProfile(
      +                name="test",
      +                base_url=f"http://127.0.0.1:{port}",
      +            )
      +            result = profile.fetch_models(api_key="[redacted:secret-assignment]")
      +            assert result == ["default-model"]
      +        finally:
      +            server.shutdown()
      +
      +    def test_no_base_url_returns_none(self):
      +        """When both base_url and self.base_url are empty, returns None."""
      +        profile = ProviderProfile(name="test", base_url="")
      +        result = profile.fetch_models(api_key="[redacted:secret-assignment]", base_url="")
      +        assert result is None
      +
      +    def test_base_url_override_with_models_url_set(self):
      +        """When self.models_url is set, base_url override is ignored (models_url wins)."""
      +        server, port = _start_server([{"id": "from-models-url"}])
      +        try:
      +            profile = ProviderProfile(
      +                name="test",
      +                base_url="http://127.0.0.1:1",
      +                models_url=f"http://127.0.0.1:{port}/models",
      +            )
      +            # base_url override should NOT be used because models_url takes priority
      +            result = profile.fetch_models(
      +                api_key="[redacted:secret-assignment]",
      +                base_url="http://127.0.0.1:1",
      +            )
      +            assert result == ["from-models-url"]
      +        finally:
      +            server.shutdown()
      +
      +
      +class TestCustomProviderBaseUrlPassthrough:
      +    """Custom provider (ollama/local) should pass base_url through to super."""
      +
      +    def test_custom_passes_base_url(self):
      +        """CustomProfile.fetch_models passes base_url to super()."""
      +        server, port = _start_server([{"id": "ollama-model"}])
      +        try:
      +            from plugins.model_providers.custom import CustomProfile
      +            profile = CustomProfile(
      +                name="custom",
      +                base_url="http://127.0.0.1:1",  # wrong port
      +            )
      +            result = profile.fetch_models(
      +                api_key="",
      +                base_url=f"http://127.0.0.1:{port}",
      +            )
      +            assert result == ["ollama-model"]
      +        finally:
      +            server.shutdown()
      +
      +
      +class TestModelPickerBaseUrlIntegration:
      +    """The /model picker path should pass model.base_url to fetch_models."""
      +
      +    def test_picker_passes_base_url(self):
      +        """Verify models.py caller passes base_url to fetch_models."""
      +        mock_profile = MagicMock()
      +        mock_profile.auth_type = "api_key"
      +        mock_profile.base_url = "https://default.api.com"
      +        mock_profile.fetch_models.return_value = ["model-a"]
      +
      +        with (
      +            patch("providers.get_provider_profile", return_value=mock_profile),
      +            patch("hermes_cli.auth.resolve_api_key_provider_credentials",
      +                  return_value={"api_key": "sk-test", "base_url": "https://custom.proxy.com"}),
      +        ):
      +            from hermes_cli.models import provider_model_ids
      +            result = provider_model_ids("test-provider")
      +            # Verify fetch_models was called with base_url
      +            mock_profile.fetch_models.assert_called_once()
      +            call_kwargs = mock_profile.fetch_models.call_args
      +            assert call_kwargs.kwargs.get("base_url") == "https://custom.proxy.com"

3 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1 · superseded by another PR 1

  • #47012 fix(model-picker): honor config base_url for live catalogs · done elsewhere → 1b962f0, #47405
  • #47020 fix(models): /model picker now uses custom model.base_url for live model fetch · superseded by another PR → #47012
  • #47181 fix(models): pass model.base_url to fetch_models in /model picker · merged via another change → #47405
Closed issues · 1
  • Closed#47009[Bug]: /model picker ignores custom model.base_url when fetching available models
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#47405
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
16 Jun 2026

For your agent

The same facts, typed.