{"repo":"NousResearch/hermes-agent","public_id":"1760","display_label":"Custom provider config falling back to OpenRouter","summary":"Most members concern custom or local provider settings (model.provider, base_url, api_key) in config.yaml not being respected, with resolution reportedly defaulting to OpenRouter across the CLI, gateway, ACP sessions, and auxiliary paths. The group mixes bug reports with proposed fixes and refactors, and a few touch adjacent topics such as the setup wizard overwriting config.","subsystem":"acp","counts":{"members":21,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":6,"merged_prs":5,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15103,"merged_at":"2026-04-24T11:54:17+00:00"},"resolution":{"state":"partially_resolved","by":"pr-1994,pr-4180,pr-15103"},"freshness":{"state":"stale","last_verified_at":"2026-04-28T08:47:26+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.09809523809523807},"fix_edges":[{"issue_number":1760,"pr_number":1994,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-18T23:00:14+00:00"},{"issue_number":4172,"pr_number":4165,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-03-31T05:02:53+00:00"},{"issue_number":4172,"pr_number":4180,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-31T06:17:27+00:00"},{"issue_number":13489,"pr_number":15103,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-24T11:54:17+00:00"},{"issue_number":13489,"pr_number":16922,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-28T08:47:26+00:00"},{"issue_number":14676,"pr_number":15103,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-24T11:54:17+00:00"}],"members":[{"kind":"issue","number":1760,"title":"[Bug]: Custom provider config (base_url, api_key) not being used - defaults to OpenRouter","url":null,"state":"closed"},{"kind":"issue","number":4172,"title":"[Bug] Provider auto-detection ignores model.provider in config.yaml — stale API keys override explicit custom endpoint","url":null,"state":"closed"},{"kind":"issue","number":5358,"title":"[Bug]: Gateway and CLI ignore model.provider config — fall back to OpenRouter when OPENROUTER_API_KEY exists","url":null,"state":"closed"},{"kind":"issue","number":12146,"title":"Agent runs fall back to openrouter despite model.provider=custom in config.yaml","url":null,"state":"closed"},{"kind":"issue","number":13489,"title":"fix(provider): ACP sessions with provider=\"custom\" fail to resolve correct credential","url":null,"state":"open"},{"kind":"issue","number":14676,"title":"bug: bare 'custom' provider falls through to OpenRouter — no base_url resolution","url":null,"state":"closed"},{"kind":"issue","number":16623,"title":"[Bug]: Explicit local OpenAI-compatible config still resolves back to OpenRouter","url":null,"state":"closed"},{"kind":"pr","number":1762,"title":"Fix custom provider: use config base_url and api_key (Fixes #1760)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1994","number":1994}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1994,"title":"fix: custom provider uses config base_url and api_key over env vars (#1760)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/runtime_provider.py","tests/test_runtime_provider_resolution.py"],"fix_hunks":[{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -171,6 +171,12 @@ def _resolve_openrouter_runtime(\n     model_cfg = _get_model_config()\n     cfg_base_url = model_cfg.get(\"base_url\") if isinstance(model_cfg.get(\"base_url\"), str) else \"\"\n     cfg_provider = model_cfg.get(\"provider\") if isinstance(model_cfg.get(\"provider\"), str) else \"\"\n+    cfg_api_key = \"\"\n+    for k in (\"api_key\", \"api\"):\n+        v = model_cfg.get(k)\n+        if isinstance(v, str) and v.strip():\n+            cfg_api_key = v.strip()\n+            break\n     requested_norm = (requested_provider or \"\").strip().lower()\n     cfg_provider = cfg_provider.strip().lower()\n ","@@ -178,26 +184,24 @@ def _resolve_openrouter_runtime(\n     env_openrouter_base_url = os.getenv(\"OPENROUTER_BASE_URL\", \"\").strip()\n \n     use_config_base_url = False\n-    if cfg_base_url.strip() and not explicit_base_url and not env_openai_base_url:\n+    if cfg_base_url.strip() and not explicit_base_url:\n         if requested_norm == \"auto\":\n-            if not cfg_provider or cfg_provider == \"auto\":\n-                use_config_base_url = True\n-        elif requested_norm == \"custom\":\n-            # Persisted custom endpoints store their base URL in config.yaml.\n-            # If OPENAI_BASE_URL is not currently set in the environment, keep\n-            # honoring that saved endpoint instead of falling back to OpenRouter.\n-            if cfg_provider == \"custom\":\n+            if (not cfg_provider or cfg_provider == \"auto\") and not env_openai_base_url:\n                 use_config_base_url = True\n+        elif requested_norm == \"custom\" and cfg_provider == \"custom\":\n+            # provider: custom — use base_url from config (Fixes #1760).\n+            use_config_base_url = True\n \n     # When the user explicitly requested the openrouter provider, skip\n     # OPENAI_BASE_URL — it typically points to a custom / non-OpenRouter\n     # endpoint and would prevent switching back to OpenRouter (#874).\n     skip_openai_base = requested_norm == \"openrouter\"\n \n+    # For custom, prefer config base_url over env so config.yaml is honored (#1760).\n     base_url = (\n         (explicit_base_url or \"\").strip()\n-        or (\"\" if skip_openai_base else env_openai_base_url)\n         or (cfg_base_url.strip() if use_config_base_url else \"\")\n+        or (\"\" if skip_openai_base else env_openai_base_url)\n         or env_openrouter_base_url\n         or OPENROUTER_BASE_URL\n     ).rstrip(\"/\")","@@ -216,8 +220,10 @@ def _resolve_openrouter_runtime(\n             or \"\"\n         )\n     else:\n+        # Custom endpoint: use api_key from config when using config base_url (#1760).\n         api_key = (\n             explicit_api_key\n+            or (cfg_api_key if use_config_base_url else \"\")\n             or os.getenv(\"OPENAI_API_KEY\")\n             or os.getenv(\"OPENROUTER_API_KEY\")\n             or \"\""]},{"file":"tests/test_runtime_provider_resolution.py","hunks":["@@ -177,6 +177,50 @@ def test_custom_endpoint_uses_saved_config_base_url_when_env_missing(monkeypatch\n     assert resolved[\"api_key\"] == \"local-key\"\n \n \n+def test_custom_endpoint_uses_config_api_key_over_env(monkeypatch):\n+    \"\"\"provider: custom with base_url and api_key in config uses them (#1760).\"\"\"\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"openrouter\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\n+            \"provider\": \"custom\",\n+            \"base_url\": \"https://my-api.example.com/v1\",\n+            \"api_key\": \"[redacted:secret-assignment]\",\n+        },\n+    )\n+    monkeypatch.setenv(\"OPENAI_BASE_URL\", \"https://other.example.com/v1\")\n+    monkeypatch.setenv(\"OPENAI_API_KEY\", \"env-key\")\n+    monkeypatch.delenv(\"OPENROUTER_BASE_URL\", raising=False)\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"custom\")\n+\n+    assert resolved[\"base_url\"] == \"https://my-api.example.com/v1\"\n+    assert resolved[\"api_key\"] == \"config-api-key\"\n+\n+\n+def test_custom_endpoint_uses_config_api_field_when_no_api_key(monkeypatch):\n+    \"\"\"provider: custom with 'api' in config uses it as api_key (#1760).\"\"\"\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"openrouter\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\n+            \"provider\": \"custom\",\n+            \"base_url\": \"https://custom.example.com/v1\",\n+            \"api\": \"config-api-field\",\n+        },\n+    )\n+    monkeypatch.delenv(\"OPENAI_BASE_URL\", raising=False)\n+    monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"custom\")\n+\n+    assert resolved[\"base_url\"] == \"https://custom.example.com/v1\"\n+    assert resolved[\"api_key\"] == \"config-api-field\"\n+\n+\n def test_custom_endpoint_auto_provider_prefers_openai_key(monkeypatch):\n     \"\"\"Auto provider with non-OpenRouter base_url should prefer OPENAI_API_KEY.\n \n"]}]},{"kind":"pr","number":4165,"title":"refactor: make config.yaml the single source of truth for endpoint URLs","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","cli.py","gateway/run.py","hermes_cli/main.py","hermes_cli/models.py","hermes_cli/runtime_provider.py","hermes_cli/setup.py","hermes_cli/tools_config.py","tests/agent/test_auxiliary_client.py","tests/hermes_cli/test_setup_model_provider.py","tests/test_cli_provider_resolution.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -7,7 +7,7 @@\n Resolution order for text tasks (auto mode):\n   1. OpenRouter  (OPENROUTER_API_KEY)\n   2. Nous Portal (~/.hermes/auth.json active provider)\n-  3. Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY)\n+  3. Custom endpoint (config.yaml model.base_url + OPENAI_API_KEY)\n   4. Codex OAuth (Responses API via chatgpt.com with gpt-5.3-codex,\n      wrapped to look like a chat.completions client)\n   5. Native Anthropic","@@ -584,15 +584,11 @@ def _try_nous() -> Tuple[Optional[OpenAI], Optional[str]]:\n \n \n def _read_main_model() -> str:\n-    \"\"\"Read the user's configured main model from config/env.\n+    \"\"\"Read the user's configured main model from config.yaml.\n \n-    Falls back through HERMES_MODEL → LLM_MODEL → config.yaml model.default\n-    so the auxiliary client can use the same model as the main agent when no\n-    dedicated auxiliary model is available.\n+    config.yaml model.default is the single source of truth for the active\n+    model. Environment variables are no longer consulted.\n     \"\"\"\n-    from_env = os.getenv(\"OPENAI_MODEL\") or os.getenv(\"HERMES_MODEL\") or os.getenv(\"LLM_MODEL\")\n-    if from_env:\n-        return from_env.strip()\n     try:\n         from hermes_cli.config import load_config\n         cfg = load_config()"]},{"file":"cli.py","hunks":["@@ -1124,9 +1124,9 @@ def __init__(\n         self.acp_args: list[str] = []\n         self.base_url = (\n             base_url\n-            or os.getenv(\"OPENAI_BASE_URL\")\n-            or os.getenv(\"OPENROUTER_BASE_URL\", CLI_CONFIG[\"model\"][\"base_url\"])\n-        )\n+            or CLI_CONFIG[\"model\"].get(\"base_url\", \"\")\n+            or os.getenv(\"OPENROUTER_BASE_URL\", \"\")\n+        ) or None\n         # Match key to resolved base_url: OpenRouter URL → prefer OPENROUTER_API_KEY,\n         # custom endpoint → prefer OPENAI_API_KEY (issue #560).\n         # Note: _ensure_runtime_credentials() re-resolves this before first use.","@@ -3239,7 +3239,7 @@ def _show_model_and_providers(self):\n                         print(f\"      {mid}{current_marker}\")\n                 elif p[\"id\"] == \"custom\":\n                     from hermes_cli.models import _get_custom_base_url\n-                    custom_url = _get_custom_base_url() or os.getenv(\"OPENAI_BASE_URL\", \"\")\n+                    custom_url = _get_custom_base_url()\n                     if custom_url:\n                         print(f\"      endpoint: {custom_url}\")\n                     if is_active:"]},{"file":"gateway/run.py","hunks":["@@ -364,20 +364,19 @@ def _load_gateway_config() -> dict:\n \n \n def _resolve_gateway_model(config: dict | None = None) -> str:\n-    \"\"\"Read model from env/config — mirrors the resolution in _run_agent_sync.\n+    \"\"\"Read model from config.yaml — single source of truth.\n \n     Without this, temporary AIAgent instances (memory flush, /compress) fall\n     back to the hardcoded default which fails when the active provider is\n     openai-codex.\n     \"\"\"\n-    model = os.getenv(\"HERMES_MODEL\") or os.getenv(\"LLM_MODEL\") or \"\"\n     cfg = config if config is not None else _load_gateway_config()\n     model_cfg = cfg.get(\"model\", {})\n     if isinstance(model_cfg, str):\n-        model = model_cfg\n+        return model_cfg\n     elif isinstance(model_cfg, dict):\n-        model = model_cfg.get(\"default\") or model_cfg.get(\"model\") or model\n-    return model\n+        return model_cfg.get(\"default\") or model_cfg.get(\"model\") or \"\"\n+    return \"\"\n \n \n def _resolve_hermes_bin() -> Optional[list[str]]:","@@ -2759,7 +2758,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n                     {\n                         \"role\": \"session_meta\",\n                         \"tools\": tool_defs or [],\n-                        \"model\": os.getenv(\"HERMES_MODEL\", \"\"),\n+                        \"model\": _resolve_gateway_model(),\n                         \"platform\": source.platform.value if source.platform else \"\",\n                         \"timestamp\": ts,\n                     }","@@ -3224,9 +3223,11 @@ async def _handle_provider_command(self, event: MessageEvent) -> str:\n             except Exception:\n                 current_provider = \"openrouter\"\n \n-        # Detect custom endpoint\n-        if current_provider == \"openrouter\" and os.getenv(\"OPENAI_BASE_URL\", \"\").strip():\n-            current_provider = \"custom\"\n+        # Detect custom endpoint from config base_url\n+        if current_provider == \"openrouter\":\n+            _cfg_base = model_cfg.get(\"base_url\", \"\") if isinstance(model_cfg, dict) else \"\"\n+            if _cfg_base and \"openrouter.ai\" not in _cfg_base:\n+                current_provider = \"custom\"\n \n         current_label = _PROVIDER_LABELS.get(current_provider, current_provider)\n "]},{"file":"hermes_cli/main.py","hunks":["@@ -1050,10 +1050,6 @@ def _model_flow_openrouter(config, current_model=\"\"):\n \n     selected = _prompt_model_selection(openrouter_models, current_model=current_model)\n     if selected:\n-        # Clear any custom endpoint and set provider to openrouter\n-        if get_env_value(\"OPENAI_BASE_URL\"):\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _save_model_choice(selected)\n \n         # Update config provider and deactivate any OAuth provider","@@ -1143,10 +1139,6 @@ def _model_flow_nous(config, current_model=\"\"):\n         # Reactivate Nous as the provider and update config\n         inference_url = creds.get(\"base_url\", \"\")\n         _update_config_for_provider(\"nous\", inference_url)\n-        # Clear any custom endpoint that might conflict\n-        if get_env_value(\"OPENAI_BASE_URL\"):\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         print(f\"Default model set to: {selected} (via Nous Portal)\")\n     else:\n         print(\"No change.\")","@@ -1191,10 +1183,6 @@ def _model_flow_openai_codex(config, current_model=\"\"):\n     if selected:\n         _save_model_choice(selected)\n         _update_config_for_provider(\"openai-codex\", DEFAULT_CODEX_BASE_URL)\n-        # Clear custom endpoint env vars that would otherwise override Codex.\n-        if get_env_value(\"OPENAI_BASE_URL\"):\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         print(f\"Default model set to: {selected} (via OpenAI Codex)\")\n     else:\n         print(\"No change.\")","@@ -1275,11 +1263,6 @@ def _model_flow_custom(config):\n         if probe.get(\"suggested_base_url\"):\n             print(f\"  If this server expects /v1, try base URL: {probe['suggested_base_url']}\")\n \n-    if base_url:\n-        save_env_value(\"OPENAI_BASE_URL\", effective_url)\n-    if api_key:\n-        save_env_value(\"OPENAI_API_KEY\", api_key)\n-\n     if model_name:\n         _save_model_choice(model_name)\n ","@@ -1439,9 +1422,6 @@ def _model_flow_named_custom(config, provider_info):\n \n     # If a model is saved, just activate immediately — no probing needed\n     if saved_model:\n-        save_env_value(\"OPENAI_BASE_URL\", base_url)\n-        if api_key:\n-            save_env_value(\"OPENAI_API_KEY\", api_key)\n         _save_model_choice(saved_model)\n \n         cfg = load_config()","@@ -1513,9 +1493,6 @@ def _model_flow_named_custom(config, provider_info):\n             return\n \n     # Activate and save the model to the custom_providers entry\n-    save_env_value(\"OPENAI_BASE_URL\", base_url)\n-    if api_key:\n-        save_env_value(\"OPENAI_API_KEY\", api_key)\n     _save_model_choice(model_name)\n \n     cfg = load_config()","@@ -1829,11 +1806,6 @@ def _model_flow_copilot(config, current_model=\"\"):\n             catalog=catalog,\n             api_key=[redacted:secret-assignment]\n         ) or selected\n-        # Clear stale custom-endpoint overrides so the Copilot provider wins cleanly.\n-        if get_env_value(\"OPENAI_BASE_URL\"):\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n-\n         initial_cfg = load_config()\n         current_effort = _current_reasoning_effort(initial_cfg)\n         reasoning_efforts = github_model_reasoning_efforts(","@@ -2058,11 +2030,6 @@ def _model_flow_kimi(config, current_model=\"\"):\n             selected = None\n \n     if selected:\n-        # Clear custom endpoint if set (avoid confusion)\n-        if get_env_value(\"OPENAI_BASE_URL\"):\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n-\n         _save_model_choice(selected)\n \n         # Update config with provider and base URL","@@ -2165,11 +2132,6 @@ def _model_flow_api_key_provider(config, provider_id, current_model=\"\"):\n             selected = None\n \n     if selected:\n-        # Clear custom endpoint if set (avoid confusion)\n-        if get_env_value(\"OPENAI_BASE_URL\"):\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n-\n         _save_model_choice(selected)\n \n         # Update config with provider and base URL","@@ -2381,11 +2343,6 @@ def _model_flow_anthropic(config, current_model=\"\"):\n             selected = None\n \n     if selected:\n-        # Clear custom endpoint if set\n-        if get_env_value(\"OPENAI_BASE_URL\"):\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n-\n         _save_model_choice(selected)\n \n         # Update config with provider — clear base_url since"]},{"file":"hermes_cli/models.py","hunks":["@@ -347,7 +347,7 @@ def list_available_providers() -> list[dict[str, str]]:\n         try:\n             from hermes_cli.auth import get_auth_status, has_usable_secret\n             if pid == \"custom\":\n-                custom_base_url = _get_custom_base_url() or os.getenv(\"OPENAI_BASE_URL\", \"\")\n+                custom_base_url = _get_custom_base_url() or \"\"\n                 has_creds = bool(custom_base_url.strip())\n             elif pid == \"openrouter\":\n                 has_creds = has_usable_secret(os.getenv(\"OPENROUTER_API_KEY\", \"\"))"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -229,28 +229,22 @@ def _resolve_openrouter_runtime(\n     requested_norm = (requested_provider or \"\").strip().lower()\n     cfg_provider = cfg_provider.strip().lower()\n \n-    env_openai_base_url = os.getenv(\"OPENAI_BASE_URL\", \"\").strip()\n     env_openrouter_base_url = os.getenv(\"OPENROUTER_BASE_URL\", \"\").strip()\n \n+    # Use config base_url when available and the provider context matches.\n+    # OPENAI_BASE_URL env var is no longer consulted — config.yaml is\n+    # the single source of truth for endpoint URLs.\n     use_config_base_url = False\n     if cfg_base_url.strip() and not explicit_base_url:\n         if requested_norm == \"auto\":\n-            if (not cfg_provider or cfg_provider == \"auto\") and not env_openai_base_url:\n+            if not cfg_provider or cfg_provider == \"auto\":\n                 use_config_base_url = True\n         elif requested_norm == \"custom\" and cfg_provider == \"custom\":\n-            # provider: custom — use base_url from config (Fixes #1760).\n             use_config_base_url = True\n \n-    # When the user explicitly requested the openrouter provider, skip\n-    # OPENAI_BASE_URL — it typically points to a custom / non-OpenRouter\n-    # endpoint and would prevent switching back to OpenRouter (#874).\n-    skip_openai_base = requested_norm == \"openrouter\"\n-\n-    # For custom, prefer config base_url over env so config.yaml is honored (#1760).\n     base_url = (\n         (explicit_base_url or \"\").strip()\n         or (cfg_base_url.strip() if use_config_base_url else \"\")\n-        or (\"\" if skip_openai_base else env_openai_base_url)\n         or env_openrouter_base_url\n         or OPENROUTER_BASE_URL\n     ).rstrip(\"/\")"]},{"file":"hermes_cli/setup.py","hunks":["@@ -941,10 +941,6 @@ def setup_model_provider(config: dict):\n             else:\n                 print_warning(\"Skipped - agent won't work without an API key\")\n \n-        # Clear any custom endpoint if switching to OpenRouter\n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n \n         # Update config.yaml and deactivate any OAuth provider so the\n         # resolver doesn't keep returning the old provider (e.g. Codex).","@@ -1032,10 +1028,6 @@ def setup_model_provider(config: dict):\n \n             mock_args = argparse.Namespace()\n             _login_openai_codex(mock_args, PROVIDER_REGISTRY[\"openai-codex\"])\n-            # Clear custom endpoint vars that would override provider routing.\n-            if existing_custom:\n-                save_env_value(\"OPENAI_BASE_URL\", \"\")\n-                save_env_value(\"OPENAI_API_KEY\", \"\")\n             _update_config_for_provider(\"openai-codex\", DEFAULT_CODEX_BASE_URL)\n             _set_model_provider(config, \"openai-codex\", DEFAULT_CODEX_BASE_URL)\n         except SystemExit:","@@ -1118,10 +1110,6 @@ def setup_model_provider(config: dict):\n                     \"  If you get billing errors, check your plan at https://open.bigmodel.cn/\"\n                 )\n \n-        # Clear custom endpoint vars if switching\n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _set_model_provider(config, \"zai\", zai_base_url)\n         selected_base_url = zai_base_url\n ","@@ -1151,10 +1139,6 @@ def setup_model_provider(config: dict):\n             else:\n                 print_warning(\"Skipped - agent won't work without an API key\")\n \n-        # Clear custom endpoint vars if switching\n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _set_model_provider(config, \"kimi-coding\", pconfig.inference_base_url)\n         selected_base_url = pconfig.inference_base_url\n ","@@ -1184,10 +1168,6 @@ def setup_model_provider(config: dict):\n             else:\n                 print_warning(\"Skipped - agent won't work without an API key\")\n \n-        # Clear custom endpoint vars if switching\n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _set_model_provider(config, \"minimax\", pconfig.inference_base_url)\n         selected_base_url = pconfig.inference_base_url\n ","@@ -1217,10 +1197,6 @@ def setup_model_provider(config: dict):\n             else:\n                 print_warning(\"Skipped - agent won't work without an API key\")\n \n-        # Clear custom endpoint vars if switching\n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _set_model_provider(config, \"minimax-cn\", pconfig.inference_base_url)\n         selected_base_url = pconfig.inference_base_url\n ","@@ -1250,10 +1226,6 @@ def setup_model_provider(config: dict):\n             else:\n                 print_warning(\"Skipped - agent won't work without an API key\")\n \n-        # Clear custom endpoint vars if switching\n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _set_model_provider(config, \"kilocode\", pconfig.inference_base_url)\n         selected_base_url = pconfig.inference_base_url\n ","@@ -1352,10 +1324,6 @@ def setup_model_provider(config: dict):\n                 else:\n                     print_warning(\"Skipped — agent won't work without credentials\")\n \n-        # Clear custom endpoint vars if switching\n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         # Don't save base_url for Anthropic — resolve_runtime_provider()\n         # always hardcodes it. Stale base_urls contaminate other providers.\n         _set_model_provider(config, \"anthropic\")","@@ -1386,10 +1354,6 @@ def setup_model_provider(config: dict):\n             else:\n                 print_warning(\"Skipped - agent won't work without an API key\")\n \n-        # Clear custom endpoint vars if switching\n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _update_config_for_provider(\"ai-gateway\", pconfig.inference_base_url, default_model=\"anthropic/claude-opus-4.6\")\n         _set_model_provider(config, \"ai-gateway\", pconfig.inference_base_url)\n ","@@ -1418,10 +1382,6 @@ def setup_model_provider(config: dict):\n             else:\n                 print_warning(\"Skipped - agent won't work without an API key\")\n \n-        # Clear custom endpoint vars if switching\n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _update_config_for_provider(\"alibaba\", pconfig.inference_base_url, default_model=\"qwen3.5-plus\")\n         _set_model_provider(config, \"alibaba\", pconfig.inference_base_url)\n ","@@ -1451,10 +1411,6 @@ def setup_model_provider(config: dict):\n             else:\n                 print_warning(\"Skipped - agent won't work without an API key\")\n \n-        # Clear custom endpoint vars if switching\n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _set_model_provider(config, \"opencode-zen\", pconfig.inference_base_url)\n         selected_base_url = pconfig.inference_base_url\n ","@@ -1484,10 +1440,6 @@ def setup_model_provider(config: dict):\n             else:\n                 print_warning(\"Skipped - agent won't work without an API key\")\n \n-        # Clear custom endpoint vars if switching\n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _set_model_provider(config, \"opencode-go\", pconfig.inference_base_url)\n         selected_base_url = pconfig.inference_base_url\n ","@@ -1518,9 +1470,6 @@ def setup_model_provider(config: dict):\n             else:\n                 print_warning(\"Skipped - agent won't work without a GitHub token or gh auth login\")\n \n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _set_model_provider(config, \"copilot\", pconfig.inference_base_url)\n         selected_base_url = pconfig.inference_base_url\n ","@@ -1534,9 +1483,6 @@ def setup_model_provider(config: dict):\n         print_info(f\"Base marker: {pconfig.inference_base_url}\")\n         print()\n \n-        if existing_custom:\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _set_model_provider(config, \"copilot-acp\", pconfig.inference_base_url)\n         selected_base_url = pconfig.inference_base_url\n ","@@ -1553,9 +1499,6 @@ def setup_model_provider(config: dict):\n         api_key = [redacted:secret-assignment]\"  HF Token\", password=True)\n         if api_key:\n             save_env_value(\"HF_TOKEN\", api_key)\n-            # Clear OpenRouter env vars to prevent routing confusion\n-            save_env_value(\"OPENAI_BASE_URL\", \"\")\n-            save_env_value(\"OPENAI_API_KEY\", \"\")\n         _set_model_provider(config, \"huggingface\", pconfig.inference_base_url)\n         selected_base_url = pconfig.inference_base_url\n ","@@ -1632,7 +1575,9 @@ def setup_model_provider(config: dict):\n             _oai_key = prompt(_api_key_label, password=[redacted:secret-assignment]\n             if _oai_key:\n                 save_env_value(\"OPENAI_API_KEY\", _oai_key)\n-                save_env_value(\"OPENAI_BASE_URL\", _base_url)\n+                # Save vision base URL to config (not .env — only secrets go there)\n+                _vaux = config.setdefault(\"auxiliary\", {}).setdefault(\"vision\", {})\n+                _vaux[\"base_url\"] = _base_url\n                 if \"api.openai.com\" in _base_url.lower():\n                     _oai_vision_models = [\"gpt-4o\", \"gpt-4o-mini\", \"gpt-4.1\", \"gpt-4.1-mini\", \"gpt-4.1-nano\"]\n                     _vm_choices = _oai_vision_models + [\"Use default (gpt-4o-mini)\"]"]},{"file":"hermes_cli/tools_config.py","hunks":["@@ -983,8 +983,13 @@ def _configure_simple_requirements(ts_key: str):\n             key_label = \"    OPENAI_API_KEY\" if \"api.openai.com\" in base_url.lower() else \"    API key\"\n             api_key = [redacted:secret-assignment] password=True)\n             if api_key and api_key.strip():\n-                save_env_value(\"OPENAI_BASE_URL\", base_url)\n                 save_env_value(\"OPENAI_API_KEY\", api_key.strip())\n+                # Save vision base URL to config (not .env — only secrets go there)\n+                from hermes_cli.config import load_config, save_config\n+                _cfg = load_config()\n+                _aux = _cfg.setdefault(\"auxiliary\", {}).setdefault(\"vision\", {})\n+                _aux[\"base_url\"] = base_url\n+                save_config(_cfg)\n                 if \"api.openai.com\" in base_url.lower():\n                     save_env_value(\"AUXILIARY_VISION_MODEL\", \"gpt-4o-mini\")\n                 _print_success(\"    Saved\")"]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -465,9 +465,16 @@ def test_nous_takes_priority_over_codex(self, monkeypatch, codex_auth_dir):\n         assert model == \"google/gemini-3-flash-preview\"\n \n     def test_custom_endpoint_over_codex(self, monkeypatch, codex_auth_dir):\n-        monkeypatch.setenv(\"OPENAI_BASE_URL\", \"http://localhost:1234/v1\")\n+        config = {\n+            \"model\": {\n+                \"provider\": \"custom\",\n+                \"base_url\": \"http://localhost:1234/v1\",\n+                \"default\": \"my-local-model\",\n+            }\n+        }\n         monkeypatch.setenv(\"OPENAI_API_KEY\", \"lm-studio-key\")\n-        monkeypatch.setenv(\"OPENAI_MODEL\", \"my-local-model\")\n+        monkeypatch.setattr(\"hermes_cli.config.load_config\", lambda: config)\n+        monkeypatch.setattr(\"hermes_cli.runtime_provider.load_config\", lambda: config)\n         # Override the autouse monkeypatch for codex\n         monkeypatch.setattr(\n             \"agent.auxiliary_client._read_codex_access_token\",","@@ -726,10 +733,17 @@ def test_vision_uses_nous_when_available(self, monkeypatch):\n \n     def test_vision_forced_main_uses_custom_endpoint(self, monkeypatch):\n         \"\"\"When explicitly forced to 'main', vision CAN use custom endpoint.\"\"\"\n+        config = {\n+            \"model\": {\n+                \"provider\": \"custom\",\n+                \"base_url\": \"http://localhost:1234/v1\",\n+                \"default\": \"my-local-model\",\n+            }\n+        }\n         monkeypatch.setenv(\"AUXILIARY_VISION_PROVIDER\", \"main\")\n-        monkeypatch.setenv(\"OPENAI_BASE_URL\", \"http://localhost:1234/v1\")\n         monkeypatch.setenv(\"OPENAI_API_KEY\", \"local-key\")\n-        monkeypatch.setenv(\"OPENAI_MODEL\", \"my-local-model\")\n+        monkeypatch.setattr(\"hermes_cli.config.load_config\", lambda: config)\n+        monkeypatch.setattr(\"hermes_cli.runtime_provider.load_config\", lambda: config)\n         with patch(\"agent.auxiliary_client._read_nous_auth\", return_value=None), \\\n              patch(\"agent.auxiliary_client.OpenAI\") as mock_openai:\n             client, model = get_vision_auxiliary_client()","@@ -827,9 +841,16 @@ def test_forced_nous_not_configured(self, monkeypatch):\n         assert model is None\n \n     def test_forced_main_uses_custom(self, monkeypatch):\n-        monkeypatch.setenv(\"OPENAI_BASE_URL\", \"http://local:8080/v1\")\n+        config = {\n+            \"model\": {\n+                \"provider\": \"custom\",\n+                \"base_url\": \"http://local:8080/v1\",\n+                \"default\": \"my-local-model\",\n+            }\n+        }\n         monkeypatch.setenv(\"OPENAI_API_KEY\", \"local-key\")\n-        monkeypatch.setenv(\"OPENAI_MODEL\", \"my-local-model\")\n+        monkeypatch.setattr(\"hermes_cli.config.load_config\", lambda: config)\n+        monkeypatch.setattr(\"hermes_cli.runtime_provider.load_config\", lambda: config)\n         with patch(\"agent.auxiliary_client._read_nous_auth\", return_value=None), \\\n              patch(\"agent.auxiliary_client.OpenAI\") as mock_openai:\n             client, model = _resolve_forced_provider(\"main\")","@@ -858,10 +879,17 @@ def test_forced_main_uses_config_saved_custom_endpoint(self, monkeypatch):\n \n     def test_forced_main_skips_openrouter_nous(self, monkeypatch):\n         \"\"\"Even if OpenRouter key is set, 'main' skips it.\"\"\"\n+        config = {\n+            \"model\": {\n+                \"provider\": \"custom\",\n+                \"base_url\": \"http://local:8080/v1\",\n+                \"default\": \"my-local-model\",\n+            }\n+        }\n         monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")\n-        monkeypatch.setenv(\"OPENAI_BASE_URL\", \"http://local:8080/v1\")\n         monkeypatch.setenv(\"OPENAI_API_KEY\", \"local-key\")\n-        monkeypatch.setenv(\"OPENAI_MODEL\", \"my-local-model\")\n+        monkeypatch.setattr(\"hermes_cli.config.load_config\", lambda: config)\n+        monkeypatch.setattr(\"hermes_cli.runtime_provider.load_config\", lambda: config)\n         with patch(\"agent.auxiliary_client._read_nous_auth\", return_value=None), \\\n              patch(\"agent.auxiliary_client.OpenAI\") as mock_openai:\n             client, model = _resolve_forced_provider(\"main\")"]},{"file":"tests/hermes_cli/test_setup_model_provider.py","hunks":["@@ -129,16 +129,13 @@ def fake_prompt_choice(question, choices, default=0):\n \n     env = _read_env(tmp_path)\n \n-    # _model_flow_custom saves env vars and config to disk\n-    assert env.get(\"OPENAI_BASE_URL\") == \"http://localhost:8000/v1\"\n-    assert env.get(\"OPENAI_API_KEY\") == \"local-key\"\n-\n-    # The model config is saved as a dict by _model_flow_custom\n+    # _model_flow_custom saves config to disk (base_url in config, not .env)\n     reloaded = load_config()\n     model_cfg = reloaded.get(\"model\", {})\n     if isinstance(model_cfg, dict):\n         assert model_cfg.get(\"provider\") == \"custom\"\n         assert model_cfg.get(\"default\") == \"llm\"\n+        assert model_cfg.get(\"base_url\") == \"http://localhost:8000/v1\"\n \n \n def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(tmp_path, monkeypatch):","@@ -232,8 +229,11 @@ def fake_prompt_choice(question, choices, default=0):\n     env = _read_env(tmp_path)\n \n     assert env.get(\"OPENAI_API_KEY\") == \"sk-openai\"\n-    assert env.get(\"OPENAI_BASE_URL\") == \"https://api.openai.com/v1\"\n     assert env.get(\"AUXILIARY_VISION_MODEL\") == \"gpt-4o-mini\"\n+    # Vision base URL saved to config.yaml, not .env\n+    reloaded = load_config()\n+    vision_cfg = reloaded.get(\"auxiliary\", {}).get(\"vision\", {})\n+    assert vision_cfg.get(\"base_url\") == \"https://api.openai.com/v1\"\n \n \n def test_setup_copilot_uses_gh_auth_and_saves_provider(tmp_path, monkeypatch):","@@ -433,8 +433,7 @@ def fake_prompt_choice(question, choices, default=0):\n     env = _read_env(tmp_path)\n     reloaded = load_config()\n \n-    assert env.get(\"OPENAI_BASE_URL\") == \"\"\n-    assert env.get(\"OPENAI_API_KEY\") == \"\"\n+    # OPENAI_BASE_URL is no longer written/cleared in .env — config is authoritative\n     assert reloaded[\"model\"][\"provider\"] == \"openai-codex\"\n     assert reloaded[\"model\"][\"default\"] == \"openai/gpt-5.3-codex\"\n     assert reloaded[\"model\"][\"base_url\"] == \"https://chatgpt.com/backend-api/codex\""]},{"file":"tests/test_cli_provider_resolution.py","hunks":["@@ -466,6 +466,6 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):\n     output = capsys.readouterr().out\n \n     assert \"Saving the working base URL instead\" in output\n-    assert saved_env[\"OPENAI_BASE_URL\"] == \"http://localhost:8000/v1\"\n-    assert saved_env[\"OPENAI_API_KEY\"] == \"local-key\"\n+    # OPENAI_BASE_URL is no longer saved to .env — config.yaml is authoritative\n+    assert \"OPENAI_BASE_URL\" not in saved_env\n     assert saved_env[\"MODEL\"] == \"llm\"\n\\ No newline at end of file\n"]}]},{"kind":"pr","number":4174,"title":"fix: custom provider falls through to OpenRouter — three missing resolution paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4165","number":4165}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":4180,"title":"fix: setup wizard overwrites custom endpoint config (#4172)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","tests/hermes_cli/test_setup.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -1278,10 +1278,25 @@ def _model_flow_custom(config):\n         save_config(cfg)\n         deactivate_provider()\n \n+        # Sync the caller's config dict so the setup wizard's final\n+        # save_config(config) preserves our model settings.  Without\n+        # this, the wizard overwrites model.provider/base_url with\n+        # the stale values from its own config dict (#4172).\n+        config[\"model\"] = dict(model)\n+\n         print(f\"Default model set to: {model_name} (via {effective_url})\")\n     else:\n         if base_url or api_key:\n             deactivate_provider()\n+        # Even without a model name, persist the custom endpoint on the\n+        # caller's config dict so the setup wizard doesn't lose it.\n+        _caller_model = config.get(\"model\")\n+        if not isinstance(_caller_model, dict):\n+            _caller_model = {\"default\": _caller_model} if _caller_model else {}\n+        _caller_model[\"provider\"] = \"custom\"\n+        _caller_model[\"base_url\"] = effective_url\n+        _caller_model.pop(\"api_mode\", None)\n+        config[\"model\"] = _caller_model\n         print(\"Endpoint saved. Use `/model` in chat or `hermes model` to set a model.\")\n \n     # Auto-save to custom_providers so it appears in the menu next time"]},{"file":"tests/hermes_cli/test_setup.py","hunks":["@@ -118,11 +118,125 @@ def fake_prompt_choice(question, choices, default=0):\n     # Core assertion: switching to custom endpoint clears OAuth provider\n     assert get_active_provider() is None\n \n-    # _model_flow_custom writes config via its own load/save cycle\n+    # Simulate what the real setup wizard does: save_config(config) AFTER\n+    # setup_model_provider returns.  This is the step that previously\n+    # overwrote model.provider/base_url (#4172).\n+    save_config(config)\n+\n+    reloaded = load_config()\n+    assert isinstance(reloaded.get(\"model\"), dict), (\n+        \"model should be a dict after custom setup, not \"\n+        + repr(type(reloaded.get(\"model\")))\n+    )\n+    assert reloaded[\"model\"].get(\"provider\") == \"custom\"\n+    assert reloaded[\"model\"].get(\"default\") == \"custom/model\"\n+    assert \"custom.example\" in reloaded[\"model\"].get(\"base_url\", \"\")\n+\n+\n+def test_custom_setup_preserves_provider_after_wizard_save_config(\n+    tmp_path, monkeypatch\n+):\n+    \"\"\"Regression test for #4172: the setup wizard's final save_config(config)\n+    must not overwrite model.provider/base_url that _model_flow_custom set.\n+\n+    Simulates the full flow:\n+      1. load config (fresh install — model is a string)\n+      2. setup_model_provider picks custom\n+      3. wizard calls save_config(config) afterward\n+      4. verify resolve_requested_provider returns \"custom\"\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    _clear_provider_env(monkeypatch)\n+\n+    config = load_config()\n+    # Sanity: fresh install has model as a string\n+    assert isinstance(config.get(\"model\"), str) or config.get(\"model\") is None\n+\n+    def fake_prompt_choice(question, choices, default=0):\n+        if question == \"Select your inference provider:\":\n+            return 3  # Custom endpoint\n+        tts_idx = _maybe_keep_current_tts(question, choices)\n+        if tts_idx is not None:\n+            return tts_idx\n+        raise AssertionError(f\"Unexpected prompt_choice call: {question}\")\n+\n+    monkeypatch.setattr(\"hermes_cli.setup.prompt_choice\", fake_prompt_choice)\n+\n+    input_values = iter([\n+        \"http://localhost:11434/v1\",  # Ollama URL\n+        \"\",                           # no API key (local)\n+        \"qwen3.5:32b\",               # model name\n+        \"\",                           # context length (auto-detect)\n+    ])\n+    monkeypatch.setattr(\"builtins.input\", lambda _prompt=\"\": next(input_values))\n+    monkeypatch.setattr(\"hermes_cli.setup.prompt_yes_no\", lambda *a, **kw: False)\n+    monkeypatch.setattr(\"hermes_cli.auth.detect_external_credentials\", lambda: [])\n+    monkeypatch.setattr(\"hermes_cli.main._save_custom_provider\", lambda *a, **kw: None)\n+    monkeypatch.setattr(\n+        \"hermes_cli.models.probe_api_models\",\n+        lambda api_key, base_url: {\"models\": [\"qwen3.5:32b\"], \"probed_url\": base_url + \"/models\"},\n+    )\n+\n+    # Full wizard cycle\n+    setup_model_provider(config)\n+    save_config(config)  # ← this is what the real wizard does\n+\n+    # Verify config on disk\n+    reloaded = load_config()\n+    assert isinstance(reloaded[\"model\"], dict)\n+    assert reloaded[\"model\"][\"provider\"] == \"custom\"\n+    assert reloaded[\"model\"][\"base_url\"] == \"http://localhost:11434/v1\"\n+    assert reloaded[\"model\"][\"default\"] == \"qwen3.5:32b\"\n+    assert \"api_mode\" not in reloaded[\"model\"]\n+\n+    # Verify the runtime resolver sees \"custom\", not \"auto\"\n+    from hermes_cli.runtime_provider import resolve_requested_provider\n+    assert resolve_requested_provider() == \"custom\"\n+\n+\n+def test_custom_setup_no_model_name_still_preserves_endpoint(\n+    tmp_path, monkeypatch\n+):\n+    \"\"\"When the user enters a URL and key but skips the model name,\n+    model.provider and model.base_url must still survive the wizard's\n+    final save_config(config).\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    _clear_provider_env(monkeypatch)\n+\n+    config = load_config()\n+\n+    def fake_prompt_choice(question, choices, default=0):\n+        if question == \"Select your inference provider:\":\n+            return 3\n+        tts_idx = _maybe_keep_current_tts(question, choices)\n+        if tts_idx is not None:\n+            return tts_idx\n+        raise AssertionError(f\"Unexpected prompt_choice call: {question}\")\n+\n+    monkeypatch.setattr(\"hermes_cli.setup.prompt_choice\", fake_prompt_choice)\n+\n+    input_values = iter([\n+        \"http://192.168.1.50:8080/v1\",  # URL\n+        \"my-key\",                        # API key\n+        \"\",                              # no model name\n+        \"\",                              # context length\n+    ])\n+    monkeypatch.setattr(\"builtins.input\", lambda _prompt=\"\": next(input_values))\n+    monkeypatch.setattr(\"hermes_cli.setup.prompt_yes_no\", lambda *a, **kw: False)\n+    monkeypatch.setattr(\"hermes_cli.auth.detect_external_credentials\", lambda: [])\n+    monkeypatch.setattr(\"hermes_cli.main._save_custom_provider\", lambda *a, **kw: None)\n+    monkeypatch.setattr(\n+        \"hermes_cli.models.probe_api_models\",\n+        lambda api_key, base_url: {\"models\": None, \"probed_url\": base_url + \"/models\"},\n+    )\n+\n+    setup_model_provider(config)\n+    save_config(config)\n+\n     reloaded = load_config()\n-    if isinstance(reloaded.get(\"model\"), dict):\n-        assert reloaded[\"model\"].get(\"provider\") == \"custom\"\n-        assert reloaded[\"model\"].get(\"default\") == \"custom/model\"\n+    assert isinstance(reloaded[\"model\"], dict)\n+    assert reloaded[\"model\"][\"provider\"] == \"custom\"\n+    assert reloaded[\"model\"][\"base_url\"] == \"http://192.168.1.50:8080/v1\"\n \n \n def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch):\n"]}]},{"kind":"pr","number":5369,"title":"fix(gateway,cli): respect model.provider and model.base_url from config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"388dd4789","number":0},{"kind":"pr","value":"#5358","number":5358}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":13831,"title":"fix(runtime_provider): handle provider='custom' with explicit base_url in ACP sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#15103","number":15103},{"kind":"pr","value":"#16922","number":16922}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":14718,"title":"fix(agent): map bare \"custom\" provider to named providers: key via base_url match","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#15103","number":15103},{"kind":"pr","value":"#14719","number":14719}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":14719,"title":"fix(runtime): resolve bare custom provider to loopback or CUSTOM_BASE_URL (#14676)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":15103,"title":"fix(runtime): resolve bare custom provider to loopback or CUSTOM_BASE_URL (salvage #14719)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/runtime_provider.py","scripts/release.py","tests/hermes_cli/test_runtime_provider_resolution.py"],"fix_hunks":[{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -36,6 +36,29 @@ def _normalize_custom_provider_name(value: str) -> str:\n     return value.strip().lower().replace(\" \", \"-\")\n \n \n+def _loopback_hostname(host: str) -> bool:\n+    h = (host or \"\").lower().rstrip(\".\")\n+    return h in {\"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\"}\n+\n+\n+def _config_base_url_trustworthy_for_bare_custom(cfg_base_url: str, cfg_provider: str) -> bool:\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+    so a stale OpenRouter/Z.ai base_url cannot hijack local ``custom`` sessions.\n+    \"\"\"\n+    cfg_provider_norm = (cfg_provider or \"\").strip().lower()\n+    bu = (cfg_base_url or \"\").strip()\n+    if not bu:\n+        return False\n+    if cfg_provider_norm == \"custom\":\n+        return True\n+    if base_url_host_matches(bu, \"openrouter.ai\"):\n+        return False\n+    return _loopback_hostname(base_url_hostname(bu))\n+\n+\n def _detect_api_mode_for_url(base_url: str) -> Optional[str]:\n     \"\"\"Auto-detect api_mode from the resolved base URL.\n ","@@ -472,6 +495,7 @@ def _resolve_openrouter_runtime(\n     cfg_provider = cfg_provider.strip().lower()\n \n     env_openrouter_base_url = os.getenv(\"OPENROUTER_BASE_URL\", \"\").strip()\n+    env_custom_base_url = os.getenv(\"CUSTOM_BASE_URL\", \"\").strip()\n \n     # Use config base_url when available and the provider context matches.\n     # OPENAI_BASE_URL env var is no longer consulted — config.yaml is","@@ -481,11 +505,14 @@ def _resolve_openrouter_runtime(\n         if requested_norm == \"auto\":\n             if not cfg_provider or cfg_provider == \"auto\":\n                 use_config_base_url = True\n-        elif requested_norm == \"custom\" and cfg_provider == \"custom\":\n+        elif requested_norm == \"custom\" and _config_base_url_trustworthy_for_bare_custom(\n+            cfg_base_url, cfg_provider\n+        ):\n             use_config_base_url = True\n \n     base_url = (\n         (explicit_base_url or \"\").strip()\n+        or env_custom_base_url\n         or (cfg_base_url.strip() if use_config_base_url else \"\")\n         or env_openrouter_base_url\n         or OPENROUTER_BASE_URL"]},{"file":"scripts/release.py","hunks":["@@ -191,6 +191,7 @@\n     \"[redacted:email]\": \"ReginaldasR\",\n     \"[redacted:email]\": \"0xharryriddle\",\n     \"[redacted:email]\": \"ericnicolaides\",\n+    \"[redacted:email]\": \"georgex8001\",\n     \"[redacted:email]\": \"benbarclay\",\n     \"[redacted:email]\": \"ChimingLiu\",\n     \"[redacted:email]\": \"openclaw\","]},{"file":"tests/hermes_cli/test_runtime_provider_resolution.py","hunks":["@@ -536,6 +536,72 @@ def test_custom_endpoint_explicit_custom_prefers_config_key(monkeypatch):\n     assert resolved[\"api_key\"] == \"sk-vllm-key\"\n \n \n+def test_bare_custom_uses_loopback_model_base_url_when_provider_not_custom(monkeypatch):\n+    \"\"\"Regression for #14676: /model can select Custom while YAML still lists another provider.\"\"\"\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"openrouter\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\n+            \"provider\": \"openrouter\",\n+            \"base_url\": \"http://127.0.0.1:8082/v1\",\n+            \"default\": \"my-local-model\",\n+        },\n+    )\n+    monkeypatch.delenv(\"CUSTOM_BASE_URL\", raising=False)\n+    monkeypatch.delenv(\"OPENROUTER_BASE_URL\", raising=False)\n+    monkeypatch.delenv(\"OPENAI_BASE_URL\", raising=False)\n+    monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")\n+    monkeypatch.setenv(\"OPENAI_API_KEY\", \"openai-key\")\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"custom\")\n+\n+    assert resolved[\"provider\"] == \"custom\"\n+    assert resolved[\"base_url\"] == \"http://127.0.0.1:8082/v1\"\n+    assert resolved[\"api_key\"] == \"openai-key\"\n+\n+\n+def test_bare_custom_custom_base_url_env_overrides_remote_yaml(monkeypatch):\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"openrouter\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\n+            \"provider\": \"openrouter\",\n+            \"base_url\": \"https://api.openrouter.ai/api/v1\",\n+        },\n+    )\n+    monkeypatch.setenv(\"CUSTOM_BASE_URL\", \"http://localhost:9999/v1\")\n+    monkeypatch.delenv(\"OPENROUTER_BASE_URL\", raising=False)\n+    monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"custom\")\n+\n+    assert resolved[\"provider\"] == \"custom\"\n+    assert resolved[\"base_url\"] == \"http://localhost:9999/v1\"\n+\n+\n+def test_bare_custom_does_not_trust_non_loopback_when_provider_not_custom(monkeypatch):\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"openrouter\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\n+            \"provider\": \"openrouter\",\n+            \"base_url\": \"https://remote.example.com/v1\",\n+        },\n+    )\n+    monkeypatch.delenv(\"CUSTOM_BASE_URL\", raising=False)\n+    monkeypatch.delenv(\"OPENROUTER_BASE_URL\", raising=False)\n+    monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"or-key\")\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"custom\")\n+\n+    assert resolved[\"provider\"] == \"custom\"\n+    assert \"openrouter.ai\" in resolved[\"base_url\"]\n+    assert \"remote.example.com\" not in resolved[\"base_url\"]\n+\n+\n def test_named_custom_provider_uses_saved_credentials(monkeypatch):\n     monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n     monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n"]}]},{"kind":"pr","number":16922,"title":"fix(auxiliary): custom provider URL rewrite + main_runtime model for title gen (widened)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","agent/title_generator.py","cli.py","gateway/run.py","tests/agent/test_title_generator.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -1834,7 +1834,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n     # ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ───────────\n     if provider == \"custom\":\n         if explicit_base_url:\n-            custom_base = explicit_base_url.strip()\n+            custom_base = _to_openai_base_url(explicit_base_url).strip()\n             custom_key = (\n                 (explicit_api_key or \"\").strip()\n                 or os.getenv(\"OPENAI_API_KEY\", \"\").strip()","@@ -1847,7 +1847,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n                 )\n                 return None, None\n             final_model = _normalize_resolved_model(\n-                model or _read_main_model() or \"gpt-4o-mini\",\n+                model or (main_runtime.get(\"model\") if main_runtime else None) or \"gpt-4o-mini\",\n                 provider,\n             )\n             extra = {}","@@ -1895,10 +1895,22 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n             entry_api_mode = (api_mode or custom_entry.get(\"api_mode\") or \"\").strip()\n             if custom_base:\n                 final_model = _normalize_resolved_model(\n-                    model or custom_entry.get(\"model\") or _read_main_model() or \"gpt-4o-mini\",\n+                    model\n+                    or custom_entry.get(\"model\")\n+                    or (main_runtime.get(\"model\") if main_runtime else None)\n+                    or _read_main_model()\n+                    or \"gpt-4o-mini\",\n                     provider,\n                 )\n-                _clean_base2, _dq2 = _extract_url_query_params(custom_base)\n+                # anthropic_messages talks to the /anthropic surface directly;\n+                # OpenAI-wire paths (chat_completions / codex_responses) need the\n+                # /v1 equivalent.  Rewrite only on the OpenAI-wire path so the\n+                # Anthropic fallback SDK still sees the original URL.\n+                if entry_api_mode == \"anthropic_messages\":\n+                    openai_base = custom_base\n+                else:\n+                    openai_base = _to_openai_base_url(custom_base)\n+                _clean_base2, _dq2 = _extract_url_query_params(openai_base)\n                 _extra2 = {\"default_query\": _dq2} if _dq2 else {}\n                 logger.debug(\n                     \"resolve_provider_client: named custom provider %r (%s, api_mode=%s)\",","@@ -1917,7 +1929,12 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n                             \"installed — falling back to OpenAI-wire.\",\n                             provider,\n                         )\n-                        client = OpenAI(api_key=[redacted:secret-assignment] base_url=_clean_base2, **_extra2)\n+                        # Fallback went OpenAI-wire after all — redo the query\n+                        # extraction against the rewritten /v1 URL.\n+                        _fallback_base = _to_openai_base_url(custom_base)\n+                        _fb_clean, _fb_dq = _extract_url_query_params(_fallback_base)\n+                        _fb_extra = {\"default_query\": _fb_dq} if _fb_dq else {}\n+                        client = OpenAI(api_key=[redacted:secret-assignment] base_url=_fb_clean, **_fb_extra)\n                         return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode\n                                 else (client, final_model))\n                     sync_anthropic = AnthropicAuxiliaryClient(","@@ -1936,7 +1953,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n                 ):\n                     client = CodexAuxiliaryClient(client, final_model)\n                 else:\n-                    client = _wrap_if_needed(client, final_model, custom_base)\n+                    client = _wrap_if_needed(client, final_model, openai_base)\n                 return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode\n                         else (client, final_model))\n             logger.warning(","@@ -2038,7 +2055,12 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n \n     if pconfig.auth_type == \"external_process\":\n         creds = resolve_external_process_provider_credentials(provider)\n-        final_model = _normalize_resolved_model(model or _read_main_model(), provider)\n+        final_model = _normalize_resolved_model(\n+            model\n+            or (main_runtime.get(\"model\") if main_runtime else None)\n+            or _read_main_model(),\n+            provider,\n+        )\n         if provider == \"copilot-acp\":\n             api_key = [redacted:secret-assignment]\"api_key\", \"\")).strip()\n             base_url = str(creds.get(\"base_url\", \"\")).strip()"]},{"file":"agent/title_generator.py","hunks":["@@ -30,10 +30,12 @@ def generate_title(\n     assistant_response: str,\n     timeout: float = 30.0,\n     failure_callback: Optional[FailureCallback] = None,\n+    main_runtime: dict = None,\n ) -> Optional[str]:\n     \"\"\"Generate a session title from the first exchange.\n \n-    Uses the auxiliary LLM client (cheapest/fastest available model).\n+    Uses the main runtime's model when available, falling back to the\n+    auxiliary LLM client (cheapest/fastest available model).\n     Returns the title string or None on failure.\n \n     ``failure_callback`` is invoked with ``(task, exception)`` when the","@@ -57,6 +59,7 @@ def generate_title(\n             max_tokens=500,\n             temperature=0.3,\n             timeout=timeout,\n+            main_runtime=main_runtime,\n         )\n         title = (response.choices[0].message.content or \"\").strip()\n         # Clean up: remove quotes, trailing punctuation, prefixes like \"Title: \"","@@ -86,6 +89,7 @@ def auto_title_session(\n     user_message: str,\n     assistant_response: str,\n     failure_callback: Optional[FailureCallback] = None,\n+    main_runtime: dict = None,\n ) -> None:\n     \"\"\"Generate and set a session title if one doesn't already exist.\n ","@@ -107,7 +111,7 @@ def auto_title_session(\n         return\n \n     title = generate_title(\n-        user_message, assistant_response, failure_callback=failure_callback\n+        user_message, assistant_response, failure_callback=failure_callback, main_runtime=main_runtime\n     )\n     if not title:\n         return","@@ -126,6 +130,7 @@ def maybe_auto_title(\n     assistant_response: str,\n     conversation_history: list,\n     failure_callback: Optional[FailureCallback] = None,\n+    main_runtime: dict = None,\n ) -> None:\n     \"\"\"Fire-and-forget title generation after the first exchange.\n ","@@ -147,7 +152,7 @@ def maybe_auto_title(\n     thread = threading.Thread(\n         target=auto_title_session,\n         args=(session_db, session_id, user_message, assistant_response),\n-        kwargs={\"failure_callback\": failure_callback},\n+        kwargs={\"failure_callback\": failure_callback, \"main_runtime\": main_runtime},\n         daemon=True,\n         name=\"auto-title\",\n     )"]},{"file":"cli.py","hunks":["@@ -8814,6 +8814,13 @@ def run_agent():\n                         response,\n                         self.conversation_history,\n                         failure_callback=_title_failure_cb,\n+                        main_runtime={\n+                            \"model\": self.model,\n+                            \"provider\": self.provider,\n+                            \"base_url\": self.base_url,\n+                            \"api_key\": self.api_key,\n+                            \"api_mode\": self.api_mode,\n+                        },\n                     )\n                 except Exception:\n                     pass"]},{"file":"gateway/run.py","hunks":["@@ -10700,6 +10700,13 @@ def _approval_notify_sync(approval_data: dict) -> None:\n                         final_response,\n                         all_msgs,\n                         failure_callback=_title_failure_cb,\n+                        main_runtime={\n+                            \"model\": getattr(agent, \"model\", None),\n+                            \"provider\": getattr(agent, \"provider\", None),\n+                            \"base_url\": getattr(agent, \"base_url\", None),\n+                            \"api_key\": [redacted:secret-assignment] \"api_key\", None),\n+                            \"api_mode\": getattr(agent, \"api_mode\", None),\n+                        } if agent else None,\n                     )\n                 except Exception:\n                     pass"]},{"file":"tests/agent/test_title_generator.py","hunks":["@@ -182,7 +182,7 @@ def test_fires_on_first_exchange(self):\n             import time\n             time.sleep(0.3)\n             mock_auto.assert_called_once_with(\n-                db, \"sess-1\", \"hello\", \"hi there\", failure_callback=None\n+                db, \"sess-1\", \"hello\", \"hi there\", failure_callback=None, main_runtime=None\n             )\n \n     def test_forwards_failure_callback_to_worker(self):","@@ -202,7 +202,7 @@ def _cb(task, exc):\n             import time\n             time.sleep(0.3)\n             mock_auto.assert_called_once_with(\n-                db, \"sess-1\", \"hello\", \"hi there\", failure_callback=_cb\n+                db, \"sess-1\", \"hello\", \"hi there\", failure_callback=_cb, main_runtime=None\n             )\n \n     def test_skips_if_no_response(self):\n"]}]},{"kind":"pr","number":17072,"title":"Fix/lmstudio runtime resolution fork2","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":35056,"title":"fix(provider): unify provider/credential resolution — config.yaml as single source, fail-closed on custom intent","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4600","number":4600},{"kind":"pr","value":"#5358","number":5358},{"kind":"pr","value":"#8919","number":8919},{"kind":"pr","value":"#12146","number":12146},{"kind":"pr","value":"#27132","number":27132},{"kind":"pr","value":"#13489","number":13489},{"kind":"pr","value":"#3263","number":3263},{"kind":"pr","value":"#29285","number":29285}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":63676,"title":"fix(acp): persist original provider identity, not resolved runtime class","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}