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
- 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
Open PRs0
Fixes on record
-
#47405 fix(models): pass model.base_url to fetch_models in /model picker
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.pyShow 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.00means 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 LabAt a glance
- Subsystem
- hermes cli
- Merged PRs
- #47405
- Closed issues
- 1
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 16 Jun 2026