{"repo":"NousResearch/hermes-agent","public_id":"7494","display_label":"Custom provider base_url routing and config persistence","summary":"These issues and fixes concern custom provider aliases (ollama, vllm, llamacpp) incorrectly falling through to cloud/OpenRouter when a LAN or remote base_url is configured, alongside related CLI wizard config preservation problems.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":28882},"resolution":{"state":"partially_resolved","by":"pr-28882"},"freshness":{"state":"verified","last_verified_at":"2026-05-19T21:23:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":22073,"pr_number":28882,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":27132,"pr_number":28882,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":7494,"title":"[Bug]: Remote Ollama endpoint not working — only connects via localhost","url":null,"state":"closed"},{"kind":"issue","number":27132,"title":"Provider aliases (ollama/vllm/llamacpp) silently fall through to OpenRouter when base_url is a LAN or remote IP","url":null,"state":"closed"},{"kind":"pr","number":22074,"title":"fix(cli): preserve setup config picker writes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28882","number":28882},{"kind":"pr","value":"#27132","number":27132}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":27133,"title":"fix: trust custom provider aliases (ollama/vllm/llamacpp) for LAN/remote base_url","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":28882,"title":"fix(cli): preserve wizard config + honour custom-provider aliases (#22074, #27132)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/runtime_provider.py","hermes_cli/setup.py","scripts/release.py","tests/hermes_cli/test_runtime_provider_resolution.py","tests/hermes_cli/test_setup_model_provider.py"],"fix_hunks":[{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -47,7 +47,8 @@ def _config_base_url_trustworthy_for_bare_custom(cfg_base_url: str, cfg_provider\n     \"\"\"Decide whether ``model.base_url`` may back bare ``custom`` runtime resolution.\n \n     GitHub #14676: the model picker can select Custom while ``model.provider`` still reflects a\n-    previous provider. Reject non-loopback URLs unless the YAML provider is already ``custom``,\n+    previous provider. Reject non-loopback URLs unless the YAML provider is already ``custom``\n+    (or one of the local-server aliases that resolve to ``custom`` — ollama, vllm, llamacpp, …),\n     so a stale OpenRouter/Z.ai base_url cannot hijack local ``custom`` sessions.\n     \"\"\"\n     cfg_provider_norm = (cfg_provider or \"\").strip().lower()","@@ -56,6 +57,17 @@ def _config_base_url_trustworthy_for_bare_custom(cfg_base_url: str, cfg_provider\n         return False\n     if cfg_provider_norm == \"custom\":\n         return True\n+    # GitHub #27132: provider aliases that resolve to \"custom\" at runtime\n+    # (ollama, vllm, llamacpp, …) should be trusted the same way \"custom\"\n+    # is, otherwise a legit LAN/WireGuard ollama endpoint silently falls\n+    # through to OpenRouter.\n+    try:\n+        from hermes_cli.auth import resolve_provider as _resolve_provider\n+\n+        if _resolve_provider(cfg_provider_norm) == \"custom\":\n+            return True\n+    except Exception:\n+        pass\n     if base_url_host_matches(bu, \"openrouter.ai\"):\n         return False\n     return _loopback_hostname(base_url_hostname(bu))","@@ -547,7 +559,20 @@ def _resolve_named_custom_runtime(\n     # Bare `provider=\"custom\"` with an explicit base_url (e.g. propagated\n     # from a `model_aliases:` direct-alias resolution) — build a runtime\n     # directly so the alias's base_url actually takes effect.\n+    #\n+    # GitHub #27132: provider aliases that resolve to \"custom\" at runtime\n+    # (ollama, vllm, llamacpp, …) are treated identically here, so a YAML\n+    # `provider: ollama` with a LAN/WireGuard `base_url` doesn't silently\n+    # fall through to OpenRouter.\n     requested_norm = (requested_provider or \"\").strip().lower()\n+    if requested_norm and requested_norm != \"custom\":\n+        try:\n+            from hermes_cli.auth import resolve_provider as _resolve_provider\n+\n+            if _resolve_provider(requested_norm) == \"custom\":\n+                requested_norm = \"custom\"\n+        except Exception:\n+            pass\n     if requested_norm == \"custom\" and explicit_base_url:\n         base_url = explicit_base_url.strip().rstrip(\"/\")\n         # Check credential pool first — mirrors the named-custom-provider path","@@ -638,6 +663,19 @@ def _resolve_openrouter_runtime(\n             break\n     requested_norm = (requested_provider or \"\").strip().lower()\n     cfg_provider = cfg_provider.strip().lower()\n+    # GitHub #27132: provider aliases that resolve to \"custom\" (ollama,\n+    # vllm, llamacpp, …) follow the same base_url trust + routing rules\n+    # as a bare `provider: custom`. Normalising here keeps every check\n+    # below — `requested_norm == \"custom\"`, the trust check, the pool\n+    # gate up the stack — alias-aware without duplicating the alias map.\n+    if requested_norm and requested_norm != \"custom\":\n+        try:\n+            from hermes_cli.auth import resolve_provider as _resolve_provider\n+\n+            if _resolve_provider(requested_norm) == \"custom\":\n+                requested_norm = \"custom\"\n+        except Exception:\n+            pass\n \n     env_openrouter_base_url = os.getenv(\"OPENROUTER_BASE_URL\", \"\").strip()\n     env_custom_base_url = os.getenv(\"CUSTOM_BASE_URL\", \"\").strip()"]},{"file":"hermes_cli/setup.py","hunks":["@@ -820,13 +820,12 @@ def setup_model_provider(config: dict, *, quick: bool = False):\n     # Re-sync the wizard's config dict from what cmd_model saved to disk.\n     # This is critical: cmd_model writes to disk via its own load/save cycle,\n     # and the wizard's final save_config(config) must not overwrite those\n-    # changes with stale values (#4172).\n+    # changes with stale values (#4172). Refresh the dict in place so callers\n+    # that keep the same object see every section the shared model picker may\n+    # have changed (model, custom_providers, auxiliary, provider metadata, etc.).\n     _refreshed = load_config()\n-    config[\"model\"] = _refreshed.get(\"model\", config.get(\"model\"))\n-    if \"custom_providers\" in _refreshed:\n-        config[\"custom_providers\"] = _refreshed[\"custom_providers\"]\n-    else:\n-        config.pop(\"custom_providers\", None)\n+    config.clear()\n+    config.update(_refreshed)\n \n     # Derive the selected provider for downstream steps (vision setup).\n     selected_provider = None"]},{"file":"scripts/release.py","hunks":["@@ -1145,6 +1145,7 @@\n     \"[redacted:email]\": \"LifeJiggy\",  # stale salvage commit alias (PR #28315)\n     \"[redacted:email]\": \"nekwo\",  # PR #26481 (PS5.1 UTF-8 BOM)\n     \"[redacted:email]\": \"nekwo\",\n+    \"[redacted:email]\": \"stepanov1975\",  # PR #22074 (setup config picker writes)\n     \"[redacted:email]\": \"flooryyyy\",  # PR #26374 (tool_trace error detection)\n     \"[redacted:email]\": \"dgians\",  # PR #26034 (.ts/.py/.sh docs types)\n     \"[redacted:email]\": \"dgians\",  # PR #26034 (bot-committed by zealy-tzco under dgians' PR)"]},{"file":"tests/hermes_cli/test_runtime_provider_resolution.py","hunks":["@@ -2321,3 +2321,74 @@ def select(self):\n     assert resolved[\"provider\"] == \"minimax-oauth\"\n     assert resolved[\"api_mode\"] == \"anthropic_messages\"\n     assert resolved[\"base_url\"] == \"https://api.minimax.io/anthropic\"\n+\n+\n+# ----------------------------------------------------------------------\n+# GitHub #27132 — provider aliases (ollama/vllm/llamacpp/llama-cpp) must\n+# follow the same base_url trust + routing rules as bare `provider: custom`.\n+# Without this, a YAML `provider: ollama` with a LAN/WireGuard `base_url`\n+# silently falls through to OpenRouter (HTTP 401).\n+# ----------------------------------------------------------------------\n+\n+\n[redacted:email](\n+    \"alias,base_url\",\n+    [\n+        (\"ollama\", \"http://192.168.0.103:11434/v1\"),\n+        (\"vllm\", \"http://192.168.0.103:8000/v1\"),\n+        (\"llamacpp\", \"http://192.168.0.103:8080/v1\"),\n+        (\"llama-cpp\", \"http://192.168.0.103:8080/v1\"),\n+    ],\n+)\n+def test_custom_aliases_with_lan_base_url_route_to_custom_not_openrouter(\n+    monkeypatch, alias, base_url\n+):\n+    \"\"\"provider: ollama|vllm|llamacpp + LAN IP must NOT fall through to OpenRouter.\"\"\"\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\"provider\": alias, \"base_url\": base_url},\n+    )\n+    # Pretend OPENROUTER_API_KEY is set so the openrouter fallback would\n+    # otherwise succeed — we want to prove the alias short-circuits before\n+    # reaching it.\n+    monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"sk-or-fake-test\")\n+    # No custom credential pool — exercise the bare-alias path.\n+    monkeypatch.setattr(rp, \"load_pool\", lambda provider: None)\n+\n+    resolved = rp.resolve_runtime_provider()\n+\n+    assert resolved[\"provider\"] == \"custom\", (\n+        f\"alias {alias!r} with LAN base_url should resolve to provider=custom, \"\n+        f\"got {resolved['provider']!r}\"\n+    )\n+    assert resolved[\"base_url\"] == base_url.rstrip(\"/\"), (\n+        f\"base_url should be the configured LAN endpoint, got {resolved['base_url']!r}\"\n+    )\n+\n+\n+def test_custom_alias_with_loopback_base_url_routes_to_custom(monkeypatch):\n+    \"\"\"provider: ollama + loopback should also route to custom (regression guard).\"\"\"\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\"provider\": \"ollama\", \"base_url\": \"http://localhost:11434/v1\"},\n+    )\n+    monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"sk-or-fake-test\")\n+    monkeypatch.setattr(rp, \"load_pool\", lambda provider: None)\n+\n+    resolved = rp.resolve_runtime_provider()\n+\n+    assert resolved[\"provider\"] == \"custom\"\n+    assert resolved[\"base_url\"] == \"http://localhost:11434/v1\"\n+\n+\n+def test_trustworthy_check_accepts_custom_aliases():\n+    \"\"\"_config_base_url_trustworthy_for_bare_custom() must accept aliases for custom.\"\"\"\n+    fn = rp._config_base_url_trustworthy_for_bare_custom\n+    for alias in (\"ollama\", \"vllm\", \"llamacpp\", \"llama-cpp\", \"llama.cpp\"):\n+        assert fn(\"http://192.168.0.103:11434/v1\", alias) is True, (\n+            f\"alias {alias!r} should be trusted with non-loopback base_url\"\n+        )\n+    # Unrelated provider name should still be rejected with non-loopback URL.\n+    assert fn(\"http://192.168.0.103:11434/v1\", \"openrouter\") is False"]},{"file":"tests/hermes_cli/test_setup_model_provider.py","hunks":["@@ -63,6 +63,38 @@ def _write_model_config(provider, base_url=\"\", model_name=\"test-model\"):\n     save_config(cfg)\n \n \n+def _write_aux_config(task=\"compression\", provider=\"gemini\", model_name=\"gemini-2.5-flash\"):\n+    \"\"\"Simulate the aux picker writing a task override to disk.\"\"\"\n+    cfg = load_config()\n+    aux = cfg.setdefault(\"auxiliary\", {})\n+    entry = aux.setdefault(task, {})\n+    entry[\"provider\"] = provider\n+    entry[\"model\"] = model_name\n+    save_config(cfg)\n+\n+\n+def test_setup_model_provider_preserves_auxiliary_choices_written_by_picker(tmp_path, monkeypatch):\n+    \"\"\"Aux choices made inside hermes setup must survive the wizard's final save.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    _clear_provider_env(monkeypatch)\n+\n+    config = load_config()\n+    assert config[\"auxiliary\"][\"compression\"][\"provider\"] == \"auto\"\n+\n+    def fake_select():\n+        _write_aux_config(\"compression\", \"gemini\", \"gemini-2.5-flash\")\n+\n+    monkeypatch.setattr(\"hermes_cli.main.select_provider_and_model\", fake_select)\n+\n+    setup_model_provider(config, quick=True)\n+    save_config(config)  # mirrors run_setup_wizard(section=\"model\") final save\n+\n+    reloaded = load_config()\n+    compression = reloaded[\"auxiliary\"][\"compression\"]\n+    assert compression[\"provider\"] == \"gemini\"\n+    assert compression[\"model\"] == \"gemini-2.5-flash\"\n+\n+\n def test_setup_keep_current_custom_from_config_does_not_fall_through(tmp_path, monkeypatch):\n     \"\"\"Keep-current custom should not fall through to the generic model menu.\"\"\"\n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}