{"repo":"NousResearch/hermes-agent","public_id":"51448","display_label":"LM Studio URL construction and API path bugs","summary":"These issues and fixes concern incorrect base URL normalization and API path construction when connecting to LM Studio, including problems with endpoint routing and unsupported tool-calling behavior on Windows.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54567},"resolution":{"state":"partially_resolved","by":"pr-54567"},"freshness":{"state":"verified","last_verified_at":"2026-06-29T03:46:45+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":53256,"pr_number":54567,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":51448,"title":"[Bug]: Hermes Desktop + LM Studio on native Windows fails (\"empty stream, no finish_reason\"); identical config works under WSL","url":null,"state":"closed"},{"kind":"issue","number":53256,"title":"[Bug]: Hermes Desktop incorrectly constructs API paths and forces unsupported tool-calling when connecting to an LM Studio server","url":null,"state":"closed"},{"kind":"pr","number":53295,"title":"fix: normalize lmstudio base urls","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54567","number":54567}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":54567,"title":"fix(lmstudio): normalize base URLs so chats/probes hit correct endpoints","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/model_metadata.py","hermes_cli/auth.py","hermes_cli/models.py","hermes_cli/runtime_provider.py","scripts/release.py","tests/agent/test_model_metadata_local_ctx.py","tests/hermes_cli/test_api_key_providers.py","tests/hermes_cli/test_model_validation.py","tests/hermes_cli/test_runtime_provider_resolution.py"],"fix_hunks":[{"file":"agent/model_metadata.py","hunks":["@@ -478,6 +478,16 @@ def _infer_provider_from_url(base_url: str) -> Optional[str]:\n     return None\n \n \n+def _lmstudio_server_root(base_url: str) -> str:\n+    \"\"\"Return the LM Studio server root for native ``/api/v1`` endpoints.\"\"\"\n+    root = _normalize_base_url(base_url).rstrip(\"/\")\n+    for suffix in (\"/api/v1\", \"/api\", \"/v1\"):\n+        if root.endswith(suffix):\n+            root = root[: -len(suffix)].rstrip(\"/\")\n+            break\n+    return root\n+\n+\n def _is_known_provider_base_url(base_url: str) -> bool:\n     return _infer_provider_from_url(base_url) is not None\n ","@@ -549,6 +559,7 @@ def detect_local_server_type(base_url: str, api_key: str = \"\") -> Optional[str]:\n     server_url = normalized\n     if server_url.endswith(\"/v1\"):\n         server_url = server_url[:-3]\n+    lmstudio_url = _lmstudio_server_root(base_url)\n \n     headers = _auth_headers(api_key)\n ","@@ -556,7 +567,7 @@ def detect_local_server_type(base_url: str, api_key: str = \"\") -> Optional[str]:\n         with httpx.Client(timeout=2.0, headers=headers) as client:\n             # LM Studio exposes /api/v1/models — check first (most specific)\n             try:\n-                r = client.get(f\"{server_url}/api/v1/models\")\n+                r = client.get(f\"{lmstudio_url}/api/v1/models\")\n                 if r.status_code == 200:\n                     return \"lm-studio\"\n             except Exception:","@@ -774,7 +785,7 @@ def fetch_endpoint_model_metadata(\n     if is_local_endpoint(normalized):\n         try:\n             if detect_local_server_type(normalized, api_key=[redacted:secret-assignment] == \"lm-studio\":\n-                server_url = normalized[:-3].rstrip(\"/\") if normalized.endswith(\"/v1\") else normalized\n+                server_url = _lmstudio_server_root(normalized)\n                 response = requests.get(\n                     server_url.rstrip(\"/\") + \"/api/v1/models\",\n                     headers=headers,","@@ -1297,6 +1308,7 @@ def _query_local_context_length(model: str, base_url: str, api_key: str = \"\") ->\n     server_url = base_url.rstrip(\"/\")\n     if server_url.endswith(\"/v1\"):\n         server_url = server_url[:-3]\n+    lmstudio_url = _lmstudio_server_root(base_url)\n \n     headers = _auth_headers(api_key)\n ","@@ -1340,7 +1352,7 @@ def _query_local_context_length(model: str, base_url: str, api_key: str = \"\") ->\n             # Use _model_id_matches for fuzzy matching: LM Studio stores models as\n             # \"publisher/slug\" but users configure only \"slug\" after \"local:\" prefix.\n             if server_type == \"lm-studio\":\n-                resp = client.get(f\"{server_url}/api/v1/models\")\n+                resp = client.get(f\"{lmstudio_url}/api/v1/models\")\n                 if resp.status_code == 200:\n                     data = resp.json()\n                     for m in data.get(\"models\", []):"]},{"file":"hermes_cli/auth.py","hunks":["@@ -704,6 +704,22 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) ->\n     return default_url\n \n \n+def _normalize_lmstudio_runtime_base_url(base_url: str) -> str:\n+    \"\"\"Return the OpenAI-compatible LM Studio runtime base URL.\n+\n+    LM Studio's native management API lives under ``/api/v1`` while its\n+    OpenAI-compatible chat endpoint lives under ``/v1``. Users often paste\n+    either form into ``LM_BASE_URL`` or ``model.base_url``; normalize before\n+    the OpenAI SDK appends ``/chat/completions``.\n+    \"\"\"\n+    root = str(base_url or \"\").strip().rstrip(\"/\")\n+    for suffix in (\"/api/v1\", \"/api\", \"/v1\"):\n+        if root.endswith(suffix):\n+            root = root[: -len(suffix)].rstrip(\"/\")\n+            break\n+    return (root or \"http://127.0.0.1:1234\") + \"/v1\"\n+\n+\n # =============================================================================\n # Error Types\n # =============================================================================","@@ -6341,6 +6357,9 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:\n     else:\n         base_url = pconfig.inference_base_url\n \n+    if provider_id == \"lmstudio\":\n+        base_url = _normalize_lmstudio_runtime_base_url(base_url)\n+\n     return {\n         \"provider\": provider_id,\n         \"api_key\": api_key,"]},{"file":"hermes_cli/models.py","hunks":["@@ -2902,13 +2902,19 @@ def _is_github_models_base_url(base_url: Optional[str]) -> bool:\n \n \n def _lmstudio_server_root(base_url: Optional[str]) -> Optional[str]:\n-    \"\"\"Strip ``/v1`` suffix from an LM Studio base URL to get the native API root.\n+    \"\"\"Return the LM Studio server root for native ``/api/v1`` endpoints.\n \n+    Users commonly copy either the OpenAI-compatible runtime URL\n+    (``.../v1``) or the native API prefix (``.../api`` / ``.../api/v1``).\n+    Native probes append ``/api/v1/...`` themselves, so normalize all accepted\n+    forms back to the bare server root to avoid ``/api/api/v1`` requests.\n     Returns ``None`` when the base URL is empty/invalid.\n     \"\"\"\n     root = (base_url or \"\").strip().rstrip(\"/\")\n-    if root.endswith(\"/v1\"):\n-        root = root[:-3].rstrip(\"/\")\n+    for suffix in (\"/api/v1\", \"/api\", \"/v1\"):\n+        if root.endswith(suffix):\n+            root = root[: -len(suffix)].rstrip(\"/\")\n+            break\n     return root or None\n \n "]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -463,6 +463,9 @@ def _resolve_runtime_from_pool_entry(\n         provider=provider, api_mode=api_mode, model_cfg=model_cfg\n     )\n \n+    if provider == \"lmstudio\":\n+        base_url = auth_mod._normalize_lmstudio_runtime_base_url(base_url)\n+\n     return {\n         \"provider\": provider,\n         \"api_mode\": api_mode,","@@ -1914,6 +1917,8 @@ def resolve_runtime_provider(\n         # Strip trailing /v1 for OpenCode Anthropic models (see comment above).\n         if api_mode == \"anthropic_messages\" and provider in {\"opencode-zen\", \"opencode-go\"}:\n             base_url = re.sub(r\"/v1/?$\", \"\", base_url)\n+        if provider == \"lmstudio\":\n+            base_url = auth_mod._normalize_lmstudio_runtime_base_url(base_url)\n         return {\n             \"provider\": provider,\n             \"api_mode\": api_mode,"]},{"file":"scripts/release.py","hunks":["@@ -173,6 +173,7 @@\n     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"dirtyren\",\n+    \"[redacted:email]\": \"lkevincc0\",\n     \"[redacted:email]\": \"Cossackx\",  # PR #52528 salvage (Windows hermes-shim resolution + prefer --update on recovery; #52378)\n     \"[redacted:email]\": \"Icather\",  # PR #45554 salvage (self-lock guard breaks Windows update-recovery infinite loop; #52378 / #45542)\n     \"--email\": \"[redacted:email]\","]},{"file":"tests/agent/test_model_metadata_local_ctx.py","hunks":["@@ -424,6 +424,31 @@ def test_lmstudio_loaded_instance_beats_max_context_length(self):\n             \"max_context_length (1048576) must not win over loaded_instances.\"\n         )\n \n+    def test_lmstudio_native_api_base_url_is_not_doubled(self):\n+        from agent.model_metadata import _query_local_context_length\n+\n+        native_resp = self._make_resp(200, {\n+            \"models\": [\n+                {\n+                    \"key\": \"publisher/model-a\",\n+                    \"id\": \"publisher/model-a\",\n+                    \"loaded_instances\": [{\"config\": {\"context_length\": 32768}}],\n+                },\n+            ]\n+        })\n+        client_mock = self._make_client(\n+            native_resp,\n+            self._make_resp(404, {}),\n+            self._make_resp(404, {}),\n+        )\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"lm-studio\"), \\\n+             patch(\"httpx.Client\", return_value=client_mock):\n+            result = _query_local_context_length(\"publisher/model-a\", \"http://localhost:1234/api/v1\")\n+\n+        assert result == 32768\n+        assert client_mock.get.call_args_list[0].args[0] == \"http://localhost:1234/api/v1/models\"\n+\n \n class TestDetectLocalServerTypeAuth:\n     def test_passes_bearer_token_to_probe_requests(self):","@@ -445,6 +470,24 @@ def test_passes_bearer_token_to_probe_requests(self):\n             \"Authorization\": \"Bearer lm-token\"\n         }\n \n+    def test_native_api_base_url_is_not_doubled(self):\n+        from agent.model_metadata import detect_local_server_type\n+\n+        resp = MagicMock()\n+        resp.status_code = 200\n+\n+        client_mock = MagicMock()\n+        client_mock.__enter__ = lambda s: client_mock\n+        client_mock.__exit__ = MagicMock(return_value=False)\n+        client_mock.get.return_value = resp\n+\n+        result = None\n+        with patch(\"httpx.Client\", return_value=client_mock):\n+            result = detect_local_server_type(\"http://localhost:1234/api/v1\")\n+\n+        assert result == \"lm-studio\"\n+        assert client_mock.get.call_args_list[0].args[0] == \"http://localhost:1234/api/v1/models\"\n+\n \n class TestFetchEndpointModelMetadataLmStudio:\n     \"\"\"fetch_endpoint_model_metadata should use LM Studio's native models endpoint.\"\"\"","@@ -489,6 +532,33 @@ def test_uses_native_models_endpoint_only(self):\n         assert result[\"lmstudio-community/Qwen3.5-27B-GGUF/Qwen3.5-27B-Q8_0.gguf\"][\"context_length\"] == 131072\n         assert result[\"Qwen3.5-27B-GGUF/Qwen3.5-27B-Q8_0.gguf\"][\"context_length\"] == 131072\n \n+    def test_native_api_base_url_is_not_doubled(self):\n+        from agent.model_metadata import fetch_endpoint_model_metadata\n+\n+        native_resp = self._make_resp(\n+            {\n+                \"models\": [\n+                    {\n+                        \"key\": \"publisher/model-a\",\n+                        \"id\": \"publisher/model-a\",\n+                        \"loaded_instances\": [\n+                            {\"config\": {\"context_length\": 65536}}\n+                        ],\n+                    }\n+                ]\n+            }\n+        )\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"lm-studio\"), \\\n+             patch(\"agent.model_metadata.requests.get\", return_value=native_resp) as mock_get:\n+            result = fetch_endpoint_model_metadata(\n+                \"http://localhost:1234/api/v1\",\n+                force_refresh=True,\n+            )\n+\n+        assert mock_get.call_args[0][0] == \"http://localhost:1234/api/v1/models\"\n+        assert result[\"publisher/model-a\"][\"context_length\"] == 65536\n+\n \n class TestQueryLocalContextLengthNetworkError:\n     \"\"\"_query_local_context_length handles network failures gracefully.\"\"\""]},{"file":"tests/hermes_cli/test_api_key_providers.py","hunks":["@@ -427,6 +427,15 @@ def test_resolve_lmstudio_uses_token_and_base_url_from_env(self, monkeypatch):\n         assert creds[\"api_key\"] == \"lm-token\"\n         assert creds[\"base_url\"] == \"http://lmstudio.remote:4321/v1\"\n \n+    def test_resolve_lmstudio_normalizes_native_api_base_url_from_env(self, monkeypatch):\n+        monkeypatch.setenv(\"LM_API_KEY\", \"lm-token\")\n+        monkeypatch.setenv(\"LM_BASE_URL\", \"http://lmstudio.remote:4321/api/v1\")\n+\n+        creds = resolve_api_key_provider_credentials(\"lmstudio\")\n+\n+        assert creds[\"provider\"] == \"lmstudio\"\n+        assert creds[\"base_url\"] == \"http://lmstudio.remote:4321/v1\"\n+\n     def test_resolve_lmstudio_no_api_key_substitutes_placeholder(self, monkeypatch):\n         # No-auth LM Studio: when LM_API_KEY isn't set, runtime credentials\n         # carry a placeholder so gateway/TUI/cron paths see the local server"]},{"file":"tests/hermes_cli/test_model_validation.py","hunks":["@@ -677,6 +677,19 @@ def test_fetch_lmstudio_models_filters_embedding_type(self):\n \n         assert models == [\"publisher/chat-model\"]\n \n+    def test_fetch_lmstudio_models_normalizes_native_api_base_url(self):\n+        mock_resp = MagicMock()\n+        mock_resp.__enter__.return_value = mock_resp\n+        mock_resp.__exit__.return_value = False\n+        mock_resp.read.return_value = b'{\"models\":[{\"key\":\"publisher/chat-model\",\"type\":\"llm\"}]}'\n+\n+        with patch(\"hermes_cli.models.urllib.request.urlopen\", return_value=mock_resp) as mock_urlopen:\n+            models = fetch_lmstudio_models(base_url=\"http://localhost:1234/api/v1\")\n+\n+        request = mock_urlopen.call_args[0][0]\n+        assert request.full_url == \"http://localhost:1234/api/v1/models\"\n+        assert models == [\"publisher/chat-model\"]\n+\n     def test_validate_lmstudio_rejects_embedding_models(self):\n         mock_resp = MagicMock()\n         mock_resp.__enter__.return_value = mock_resp"]},{"file":"tests/hermes_cli/test_runtime_provider_resolution.py","hunks":["@@ -416,6 +416,32 @@ def test_resolve_runtime_provider_lmstudio_saved_base_url_wins_over_env(monkeypa\n     assert resolved[\"api_key\"] == \"dummy-lm-api-key\"\n \n \n+def test_resolve_runtime_provider_lmstudio_normalizes_native_api_saved_base_url(monkeypatch):\n+    monkeypatch.delenv(\"LM_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"LM_BASE_URL\", raising=False)\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"lmstudio\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\n+            \"provider\": \"lmstudio\",\n+            \"base_url\": \"http://192.168.1.10:1234/api/v1\",\n+            \"default\": \"qwen/qwen3-coder-30b\",\n+        },\n+    )\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_pool\",\n+        lambda provider: type(\"Pool\", (), {\"has_credentials\": lambda self: False})(),\n+    )\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"lmstudio\")\n+\n+    assert resolved[\"provider\"] == \"lmstudio\"\n+    assert resolved[\"api_mode\"] == \"chat_completions\"\n+    assert resolved[\"base_url\"] == \"http://192.168.1.10:1234/v1\"\n+\n+\n def test_resolve_runtime_provider_openrouter_explicit(monkeypatch):\n     monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"openrouter\")\n     monkeypatch.setattr(rp, \"_get_model_config\", lambda: {})\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}