{"repo":"NousResearch/hermes-agent","public_id":"47009","display_label":"Model picker ignoring custom base_url for fetches","summary":"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.","subsystem":"hermes_cli","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":47405},"resolution":{"state":"resolved","by":"pr-47405"},"freshness":{"state":"verified","last_verified_at":"2026-06-16T20:09:41+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":47009,"pr_number":47405,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":47009,"title":"[Bug]:  /model picker ignores custom model.base_url when fetching available models","url":null,"state":"closed"},{"kind":"pr","number":47012,"title":"fix(model-picker): honor config base_url for live catalogs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1b962f001e7855a7cecf7e7db98ac08d71be0578","number":0},{"kind":"pr","value":"#47405","number":47405}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":47020,"title":"fix(models): /model picker now uses custom model.base_url for live model fetch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47012","number":47012}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":47181,"title":"fix(models): pass model.base_url to fetch_models in /model picker","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47405","number":47405}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":47405,"title":"fix(models): pass model.base_url to fetch_models in /model picker","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"hermes_cli/models.py","hunks":["@@ -2371,7 +2371,7 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)\n             if not base_url:\n                 base_url = _p.base_url\n             if api_key:\n-                live = _p.fetch_models(api_key=[redacted:secret-assignment]\n+                live = _p.fetch_models(api_key=[redacted:secret-assignment] base_url=base_url or None)\n                 if live:\n                     # Merge static curated list with live API results so\n                     # models that the live endpoint omits (stale cache,"]},{"file":"plugins/model-providers/anthropic/__init__.py","hunks":["@@ -17,6 +17,7 @@ def fetch_models(\n         self,\n         *,\n         api_key: str | None = None,\n+        base_url: str | None = None,\n         timeout: float = 8.0,\n     ) -> list[str] | None:\n         \"\"\"Anthropic uses x-api-key header and anthropic-version.\"\"\""]},{"file":"plugins/model-providers/bedrock/__init__.py","hunks":["@@ -11,6 +11,7 @@ def fetch_models(\n         self,\n         *,\n         api_key: str | None = None,\n+        base_url: str | None = None,\n         timeout: float = 8.0,\n     ) -> list[str] | None:\n         \"\"\"Bedrock model listing requires AWS SDK, not a REST call.\"\"\""]},{"file":"plugins/model-providers/copilot-acp/__init__.py","hunks":["@@ -16,6 +16,7 @@ def fetch_models(\n         self,\n         *,\n         api_key: str | None = None,\n+        base_url: str | None = None,\n         timeout: float = 8.0,\n     ) -> list[str] | None:\n         \"\"\"Model listing is handled by the ACP subprocess.\"\"\""]},{"file":"plugins/model-providers/custom/__init__.py","hunks":["@@ -43,12 +43,13 @@ def fetch_models(\n         self,\n         *,\n         api_key: str | None = None,\n+        base_url: str | None = None,\n         timeout: float = 8.0,\n     ) -> list[str] | None:\n         \"\"\"Custom/Ollama: base_url is user-configured; fetch if set.\"\"\"\n-        if not self.base_url:\n+        if not (base_url or self.base_url):\n             return None\n-        return super().fetch_models(api_key=[redacted:secret-assignment] timeout=timeout)\n+        return super().fetch_models(api_key=[redacted:secret-assignment] base_url=base_url, timeout=timeout)\n \n \n custom = CustomProfile("]},{"file":"plugins/model-providers/openrouter/__init__.py","hunks":["@@ -51,6 +51,7 @@ def fetch_models(\n         self,\n         *,\n         api_key: str | None = None,\n+        base_url: str | None = None,\n         timeout: float = 8.0,\n     ) -> list[str] | None:\n         \"\"\"Fetch from public OpenRouter catalog — no auth required.","@@ -64,7 +65,7 @@ def fetch_models(\n         if _CACHE is not None:\n             return _CACHE\n         try:\n-            result = super().fetch_models(api_key=None, timeout=timeout)\n+            result = super().fetch_models(api_key=None, base_url=base_url, timeout=timeout)\n             if result is not None:\n                 _CACHE = result\n             return result"]},{"file":"providers/base.py","hunks":["@@ -163,6 +163,7 @@ def fetch_models(\n         self,\n         *,\n         api_key: str | None = None,\n+        base_url: str | None = None,\n         timeout: float = 8.0,\n     ) -> list[str] | None:\n         \"\"\"Fetch the live model list from the provider's models endpoint.","@@ -175,7 +176,8 @@ def fetch_models(\n              endpoint differs from the inference base URL, e.g. OpenRouter\n              exposes a public catalog at /api/v1/models while inference is\n              at /api/v1)\n-          2. self.base_url + \"/models\"  (standard OpenAI-compat fallback)\n+          2. base_url (caller override — user-configured model.base_url)\n+          3. self.base_url + \"/models\"  (standard OpenAI-compat fallback)\n \n         The default implementation sends Bearer auth when api_key is given\n         and forwards self.default_headers. Override to customise auth, path,","@@ -184,11 +186,12 @@ def fetch_models(\n         Callers must always fall back to the static _PROVIDER_MODELS list\n         when this returns None.\n         \"\"\"\n+        effective_base = base_url or self.base_url\n         url = (self.models_url or \"\").strip()\n         if not url:\n-            if not self.base_url:\n+            if not effective_base:\n                 return None\n-            url = self.base_url.rstrip(\"/\") + \"/models\"\n+            url = effective_base.rstrip(\"/\") + \"/models\"\n \n         import json\n         import urllib.request"]},{"file":"tests/hermes_cli/test_inventory.py","hunks":["@@ -638,3 +638,25 @@ def test_aggregator_dedup_does_not_empty_user_defined_custom_provider():\n     assert \"other/model\" in or_row[\"models\"]\n     assert or_row[\"total_models\"] == 1\n \n+\n+def test_two_custom_providers_with_overlap_both_survive():\n+    \"\"\"Two user-defined custom endpoints that happen to expose an\n+    overlapping model must each keep their full catalog. Neither is the\n+    aggregator the dedup exists to trim, so cross-filtering between two\n+    user-defined rows must not happen.\n+    \"\"\"\n+    rows = [\n+        _user_provider_row(\"custom:proxy-a\", [\"shared/model\", \"a/only\"]),\n+        _user_provider_row(\"custom:proxy-b\", [\"shared/model\", \"b/only\"]),\n+    ]\n+    ctx = _empty_ctx()\n+    with _list_auth_returning(rows):\n+        payload = build_models_payload(ctx)\n+\n+    a_row = next(r for r in payload[\"providers\"] if r[\"slug\"] == \"custom:proxy-a\")\n+    b_row = next(r for r in payload[\"providers\"] if r[\"slug\"] == \"custom:proxy-b\")\n+    assert a_row[\"models\"] == [\"shared/model\", \"a/only\"]\n+    assert b_row[\"models\"] == [\"shared/model\", \"b/only\"]\n+    assert a_row[\"total_models\"] == 2\n+    assert b_row[\"total_models\"] == 2\n+"]},{"file":"tests/providers/test_fetch_models_base_url.py","hunks":["@@ -0,0 +1,140 @@\n+\"\"\"Tests for ProviderProfile.fetch_models base_url override (issue #47009).\"\"\"\n+\n+import json\n+from http.server import HTTPServer, BaseHTTPRequestHandler\n+from threading import Thread\n+from unittest.mock import patch, MagicMock\n+\n+from providers.base import ProviderProfile\n+\n+\n+class _FakeModelHandler(BaseHTTPRequestHandler):\n+    \"\"\"Serves /models with a configurable model list.\"\"\"\n+\n+    models = [{\"id\": \"custom-model-1\"}, {\"id\": \"custom-model-2\"}]\n+\n+    def do_GET(self):\n+        if self.path.rstrip(\"/\") == \"/models\":\n+            body = json.dumps({\"data\": self.models}).encode()\n+            self.send_response(200)\n+            self.send_header(\"Content-Type\", \"application/json\")\n+            self.end_headers()\n+            self.wfile.write(body)\n+        else:\n+            self.send_response(404)\n+            self.end_headers()\n+\n+    def log_message(self, format, *args):\n+        pass  # suppress noise\n+\n+\n+def _start_server(models=None):\n+    \"\"\"Start a local HTTP server returning given models. Returns (server, port).\"\"\"\n+    if models is not None:\n+        _FakeModelHandler.models = models\n+    server = HTTPServer((\"127.0.0.1\", 0), _FakeModelHandler)\n+    port = server.server_address[1]\n+    thread = Thread(target=server.serve_forever, daemon=True)\n+    thread.start()\n+    return server, port\n+\n+\n+class TestFetchModelsBaseUrlOverride:\n+    \"\"\"fetch_models() should use caller-provided base_url when given.\"\"\"\n+\n+    def test_base_url_override_used(self):\n+        \"\"\"When base_url is passed, it overrides self.base_url.\"\"\"\n+        server, port = _start_server([{\"id\": \"proxy-model-a\"}])\n+        try:\n+            profile = ProviderProfile(\n+                name=\"test\",\n+                base_url=\"http://127.0.0.1:1\",  # wrong port — should not be used\n+            )\n+            result = profile.fetch_models(\n+                api_key=\"[redacted:secret-assignment]\",\n+                base_url=f\"http://127.0.0.1:{port}\",\n+            )\n+            assert result == [\"proxy-model-a\"]\n+        finally:\n+            server.shutdown()\n+\n+    def test_fallback_to_self_base_url(self):\n+        \"\"\"When base_url is None, falls back to self.base_url.\"\"\"\n+        server, port = _start_server([{\"id\": \"default-model\"}])\n+        try:\n+            profile = ProviderProfile(\n+                name=\"test\",\n+                base_url=f\"http://127.0.0.1:{port}\",\n+            )\n+            result = profile.fetch_models(api_key=\"[redacted:secret-assignment]\")\n+            assert result == [\"default-model\"]\n+        finally:\n+            server.shutdown()\n+\n+    def test_no_base_url_returns_none(self):\n+        \"\"\"When both base_url and self.base_url are empty, returns None.\"\"\"\n+        profile = ProviderProfile(name=\"test\", base_url=\"\")\n+        result = profile.fetch_models(api_key=\"[redacted:secret-assignment]\", base_url=\"\")\n+        assert result is None\n+\n+    def test_base_url_override_with_models_url_set(self):\n+        \"\"\"When self.models_url is set, base_url override is ignored (models_url wins).\"\"\"\n+        server, port = _start_server([{\"id\": \"from-models-url\"}])\n+        try:\n+            profile = ProviderProfile(\n+                name=\"test\",\n+                base_url=\"http://127.0.0.1:1\",\n+                models_url=f\"http://127.0.0.1:{port}/models\",\n+            )\n+            # base_url override should NOT be used because models_url takes priority\n+            result = profile.fetch_models(\n+                api_key=\"[redacted:secret-assignment]\",\n+                base_url=\"http://127.0.0.1:1\",\n+            )\n+            assert result == [\"from-models-url\"]\n+        finally:\n+            server.shutdown()\n+\n+\n+class TestCustomProviderBaseUrlPassthrough:\n+    \"\"\"Custom provider (ollama/local) should pass base_url through to super.\"\"\"\n+\n+    def test_custom_passes_base_url(self):\n+        \"\"\"CustomProfile.fetch_models passes base_url to super().\"\"\"\n+        server, port = _start_server([{\"id\": \"ollama-model\"}])\n+        try:\n+            from plugins.model_providers.custom import CustomProfile\n+            profile = CustomProfile(\n+                name=\"custom\",\n+                base_url=\"http://127.0.0.1:1\",  # wrong port\n+            )\n+            result = profile.fetch_models(\n+                api_key=\"\",\n+                base_url=f\"http://127.0.0.1:{port}\",\n+            )\n+            assert result == [\"ollama-model\"]\n+        finally:\n+            server.shutdown()\n+\n+\n+class TestModelPickerBaseUrlIntegration:\n+    \"\"\"The /model picker path should pass model.base_url to fetch_models.\"\"\"\n+\n+    def test_picker_passes_base_url(self):\n+        \"\"\"Verify models.py caller passes base_url to fetch_models.\"\"\"\n+        mock_profile = MagicMock()\n+        mock_profile.auth_type = \"api_key\"\n+        mock_profile.base_url = \"https://default.api.com\"\n+        mock_profile.fetch_models.return_value = [\"model-a\"]\n+\n+        with (\n+            patch(\"providers.get_provider_profile\", return_value=mock_profile),\n+            patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\",\n+                  return_value={\"api_key\": \"sk-test\", \"base_url\": \"https://custom.proxy.com\"}),\n+        ):\n+            from hermes_cli.models import provider_model_ids\n+            result = provider_model_ids(\"test-provider\")\n+            # Verify fetch_models was called with base_url\n+            mock_profile.fetch_models.assert_called_once()\n+            call_kwargs = mock_profile.fetch_models.call_args\n+            assert call_kwargs.kwargs.get(\"base_url\") == \"https://custom.proxy.com\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}