# NousResearch/hermes-agent · Model picker ignoring custom base_url for fetches

## Open now

- 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.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #47405. Verify it addresses your case.
- **Recommended:** read PR #47405 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-47405`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member 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.

## Closed / duplicate issues

- #47009: \[Bug\]:  /model picker ignores custom model.base_url when fetching available models

## Prior & competing PRs

- **#47020: fix(models): /model picker now uses custom model.base_url for live model fetch**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#47012`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#47405: fix(models): pass model.base_url to fetch_models in /model picker**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `hermes_cli/models.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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"
    ```
- **#47012: fix(model-picker): honor config base_url for live catalogs**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `1b962f001e7855a7cecf7e7db98ac08d71be0578`, `#47405`
- **#47181: fix(models): pass model.base_url to fetch_models in /model picker**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#47405`

## Fix status

- Merged to main in PR #47405 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-16`.
- Subsystem: `hermes_cli`
