{"repo":"NousResearch/hermes-agent","public_id":"560","display_label":"Custom provider identity lost or overridden by OpenRouter","summary":"A cluster of bugs and fixes centered on custom providers being incorrectly collapsed to 'openrouter', losing their identity during model switching, session resume, config resolution, and credential matching. Work spans CLI, gateway, TUI, desktop, and web surfaces.","subsystem":"acp","counts":{"members":92,"open_issues":7,"open_issues_distinct":7,"open_prs":11,"closed_issues":26,"merged_prs":12,"attempt_prs":47},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1994},"resolution":{"state":"partially_resolved","by":"pr-1994,pr-2792,pr-3112,pr-4180,pr-15103,pr-45578,pr-50385"},"freshness":{"state":"verified","last_verified_at":"2026-06-24T14:12:56+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.019894887720974672},"fix_edges":[{"issue_number":1760,"pr_number":1994,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":2281,"pr_number":2792,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":2281,"pr_number":3112,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":2562,"pr_number":2792,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":4172,"pr_number":4165,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":4172,"pr_number":4180,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":8919,"pr_number":50385,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":13489,"pr_number":15103,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":13489,"pr_number":16922,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":14676,"pr_number":15103,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":44022,"pr_number":45578,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":44022,"pr_number":48547,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":44108,"pr_number":45578,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":48305,"pr_number":51043,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":560,"title":"[Bug]: \"Custom endpoint\" doesn't isolate its API key from other providers","url":null,"state":"closed"},{"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":2281,"title":"[Bug]: config.yaml provider/base_url ignored on fresh session startup","url":null,"state":"closed"},{"kind":"issue","number":2562,"title":"[Bug]: 'custom' provider gets switched to 'openrouter'","url":null,"state":"closed"},{"kind":"issue","number":2602,"title":"[Bug]: Discord Custom Provider (Local LLM) tries to access openrouter instead of custom url","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":5569,"title":"[Bug]: Telegram model switcher defaults to currently selected provider when choosing a new model by name with multiple custom providers present","url":null,"state":"closed"},{"kind":"issue","number":6242,"title":"Bug: /model provider/model syntax routes to default provider instead of specified custom provider","url":null,"state":"closed"},{"kind":"issue","number":8470,"title":"[Bug]: /model provider/model syntax fails to switch to custom provider models","url":null,"state":"open"},{"kind":"issue","number":8919,"title":"Bug: custom provider config ignored at runtime","url":null,"state":"closed"},{"kind":"issue","number":9138,"title":"fix(model): custom:<name>:<model> triple syntax broken on custom providers","url":null,"state":"closed"},{"kind":"issue","number":9147,"title":"Bug: custom:<name>:<model> triple syntax broken when currently on a custom provider","url":null,"state":"open"},{"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":16259,"title":"/model auto-detect hijacks custom:* providers — is_custom guard too narrow","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":"issue","number":24102,"title":"[Bug]: Hermes-Agent keeps probing non-existent google/gemini-3-flash-preview model when using LM Studio OpenAI-compatible API","url":null,"state":"open"},{"kind":"issue","number":34001,"title":"Named custom provider stale_timeout_seconds ignored because runtime provider is normalized to `custom`","url":null,"state":"open"},{"kind":"issue","number":39753,"title":"config priority: OpenRouter catalog overrides explicit custom provider","url":null,"state":"closed"},{"kind":"issue","number":44022,"title":"[Bug]: Desktop/TUI session.resume fails with \"No LLM provider configured\" when session only stored a bare billing_provider (e.g. custom)","url":null,"state":"closed"},{"kind":"issue","number":44108,"title":"Desktop/TUI: session persistence stores resolved provider \"custom\", losing named custom_providers entry identity — resume rebuilds with no credentials","url":null,"state":"closed"},{"kind":"issue","number":45715,"title":"[BUG] Credential pool mismatch: agent.provider resolves to bare 'custom' even when requested_provider is named, causing pool mutation skip + 503 across primary + subagent + bg-review paths","url":null,"state":"open"},{"kind":"issue","number":46625,"title":"TUI session resume routes bare \"custom\" provider to openrouter causing 401","url":null,"state":"closed"},{"kind":"issue","number":47272,"title":"Credential pool provider mismatch for custom:* providers — pool rotation silently skipped","url":null,"state":"closed"},{"kind":"issue","number":47714,"title":"Desktop/TUI sessions fall back to OpenRouter when using named custom provider","url":null,"state":"closed"},{"kind":"issue","number":47961,"title":"[Bug]: Custom provider configurations are overwritten or incorrectly mapped in Desktop App","url":null,"state":"closed"},{"kind":"issue","number":52003,"title":"bug: /model switches provider from custom:xiaomi-token-plan to xiaomi (is_custom check too strict)","url":null,"state":"closed"},{"kind":"issue","number":52496,"title":"[Dashboard] /api/model/set rewrites named custom provider custom:* to openrouter","url":null,"state":"closed"},{"kind":"issue","number":58393,"title":"Desktop model picker cannot reliably show or select configured custom OpenAI-compatible providers","url":null,"state":"closed"},{"kind":"issue","number":58688,"title":"Desktop Model selector routes custom provider models through OpenRouter instead of configured provider","url":null,"state":"closed"},{"kind":"issue","number":63681,"title":"[Bug]: Provider namespace lost during ACP session persist → restore fails for named custom providers","url":null,"state":"open"},{"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","implication":"superseded"},"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 = [redacted:secret-assignment]\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\": \"config-api-key\",\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":2283,"title":"fix(cli): read root-level provider and base_url from config.yaml into model config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3112","number":3112}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2564,"title":"fix(auth): preserve 'custom' provider name instead of silently remapping to 'openrouter'","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2792","number":2792},{"kind":"pr","value":"#2562","number":2562}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2571,"title":"fix(auth): preserve 'custom' provider instead of silently remapping to 'openrouter'","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2792","number":2792},{"kind":"pr","value":"#2562","number":2562}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2633,"title":"fix(auth): preserve 'custom' provider instead of collapsing to 'openrouter'","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2792","number":2792},{"kind":"pr","value":"#2562","number":2562}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2725,"title":"fix(auth): preserve 'custom' provider instead of silently mapping to openrouter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2792","number":2792},{"kind":"pr","value":"#2562","number":2562}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2792,"title":"fix(auth): preserve 'custom' provider instead of silently remapping to 'openrouter'","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/auth.py","hermes_cli/runtime_provider.py","tests/test_runtime_provider_resolution.py"],"fix_hunks":[{"file":"hermes_cli/auth.py","hunks":["@@ -690,8 +690,10 @@ def resolve_provider(\n     }\n     normalized = _PROVIDER_ALIASES.get(normalized, normalized)\n \n-    if normalized in {\"openrouter\", \"custom\"}:\n+    if normalized == \"openrouter\":\n         return \"openrouter\"\n+    if normalized == \"custom\":\n+        return \"custom\"\n     if normalized in PROVIDER_REGISTRY:\n         return normalized\n     if normalized != \"auto\":"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -198,7 +198,7 @@ def _resolve_named_custom_runtime(\n     api_key = [redacted:secret-assignment] for candidate in api_key_candidates if has_usable_secret(candidate)), \"\")\n \n     return {\n-        \"provider\": \"openrouter\",\n+        \"provider\": \"custom\",\n         \"api_mode\": custom_provider.get(\"api_mode\")\n         or _detect_api_mode_for_url(base_url)\n         or \"chat_completions\",","@@ -279,8 +279,16 @@ def _resolve_openrouter_runtime(\n \n     source = \"explicit\" if (explicit_api_key or explicit_base_url) else \"env/config\"\n \n+    # When \"custom\" was explicitly requested, preserve that as the provider\n+    # name instead of silently relabeling to \"openrouter\" (#2562).\n+    # Also provide a placeholder API key for local servers that don't require\n+    # authentication — the OpenAI SDK requires a non-empty api_key string.\n+    effective_provider = \"custom\" if requested_norm == \"custom\" else \"openrouter\"\n+    if effective_provider == \"custom\" and not api_key and not _is_openrouter_url:\n+        api_key = \"[redacted:secret-assignment]\"\n+\n     return {\n-        \"provider\": \"openrouter\",\n+        \"provider\": effective_provider,\n         \"api_mode\": _parse_api_mode(model_cfg.get(\"api_mode\"))\n         or _detect_api_mode_for_url(base_url)\n         or \"chat_completions\","]},{"file":"tests/test_runtime_provider_resolution.py","hunks":["@@ -267,7 +267,7 @@ def test_named_custom_provider_uses_saved_credentials(monkeypatch):\n \n     resolved = rp.resolve_runtime_provider(requested=\"local\")\n \n-    assert resolved[\"provider\"] == \"openrouter\"\n+    assert resolved[\"provider\"] == \"custom\"\n     assert resolved[\"api_mode\"] == \"chat_completions\"\n     assert resolved[\"base_url\"] == \"http://1.2.3.4:1234/v1\"\n     assert resolved[\"api_key\"] == \"local-provider-key\"","@@ -579,3 +579,81 @@ def test_named_custom_provider_anthropic_api_mode(monkeypatch):\n \n     assert resolved[\"api_mode\"] == \"anthropic_messages\"\n     assert resolved[\"base_url\"] == \"https://proxy.example.com/anthropic\"\n+\n+\n+# ------------------------------------------------------------------\n+# fix #2562 — resolve_provider(\"custom\") must not remap to \"openrouter\"\n+# ------------------------------------------------------------------\n+\n+\n+def test_resolve_provider_custom_returns_custom():\n+    \"\"\"resolve_provider('custom') must return 'custom', not 'openrouter'.\"\"\"\n+    from hermes_cli.auth import resolve_provider\n+    assert resolve_provider(\"custom\") == \"custom\"\n+\n+\n+def test_resolve_provider_openrouter_unchanged():\n+    \"\"\"resolve_provider('openrouter') must still return 'openrouter'.\"\"\"\n+    from hermes_cli.auth import resolve_provider\n+    assert resolve_provider(\"openrouter\") == \"openrouter\"\n+\n+\n+def test_custom_provider_runtime_preserves_provider_name(monkeypatch):\n+    \"\"\"resolve_runtime_provider with provider='custom' must return provider='custom'.\"\"\"\n+    monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"OPENAI_BASE_URL\", raising=False)\n+    monkeypatch.delenv(\"OPENROUTER_BASE_URL\", raising=False)\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"model\": {\n+                \"provider\": \"custom\",\n+                \"base_url\": \"http://localhost:8080/v1\",\n+                \"api_key\": \"test-key-123\",\n+            }\n+        },\n+    )\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"custom\")\n+    assert resolved[\"provider\"] == \"custom\", (\n+        f\"Expected provider='custom', got provider='{resolved['provider']}'\"\n+    )\n+    assert resolved[\"base_url\"] == \"http://localhost:8080/v1\"\n+    assert resolved[\"api_key\"] == \"test-key-123\"\n+\n+\n+def test_custom_provider_no_key_gets_placeholder(monkeypatch):\n+    \"\"\"Local server with no API key should get 'no-key-required' placeholder.\"\"\"\n+    monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"OPENAI_BASE_URL\", raising=False)\n+    monkeypatch.delenv(\"OPENROUTER_BASE_URL\", raising=False)\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"model\": {\n+                \"provider\": \"custom\",\n+                \"base_url\": \"http://localhost:8080/v1\",\n+            }\n+        },\n+    )\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"custom\")\n+    assert resolved[\"provider\"] == \"custom\"\n+    assert resolved[\"api_key\"] == \"no-key-required\"\n+    assert resolved[\"base_url\"] == \"http://localhost:8080/v1\"\n+\n+\n+def test_openrouter_provider_not_affected_by_custom_fix(monkeypatch):\n+    \"\"\"Fixing custom must not change openrouter behavior.\"\"\"\n+    monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"OPENAI_BASE_URL\", raising=False)\n+    monkeypatch.delenv(\"OPENROUTER_BASE_URL\", raising=False)\n+    monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"test-or-key\")\n+    monkeypatch.setattr(rp, \"load_config\", lambda: {})\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"openrouter\")\n+    assert resolved[\"provider\"] == \"openrouter\"\n"]}]},{"kind":"pr","number":3112,"title":"fix(cli): read root-level provider and base_url from config.yaml into model config","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -261,6 +261,18 @@ def load_cli_config() -> Dict[str, Any]:\n                 elif isinstance(file_config[\"model\"], dict):\n                     # Old format: model is a dict with default/base_url\n                     defaults[\"model\"].update(file_config[\"model\"])\n+\n+            # Root-level provider and base_url override model config.\n+            # Users may write:\n+            #   model: kimi-k2.5:cloud\n+            #   provider: custom\n+            #   base_url: http://localhost:11434/v1\n+            # These root-level keys must be merged into defaults[\"model\"] so\n+            # they are picked up by CLI provider resolution.\n+            if \"provider\" in file_config and file_config[\"provider\"]:\n+                defaults[\"model\"][\"provider\"] = file_config[\"provider\"]\n+            if \"base_url\" in file_config and file_config[\"base_url\"]:\n+                defaults[\"model\"][\"base_url\"] = file_config[\"base_url\"]\n             \n             # Deep merge file_config into defaults.\n             # First: merge keys that exist in both (deep-merge dicts, overwrite scalars)\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","implication":"superseded"},"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             [redacted:secret-assignment]\"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},{"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","implication":"superseded"},"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             [redacted:secret-assignment]\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":5611,"title":"fix(models): resolve custom providers when switching models by name (…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"791c992b554fea2f66d8e9b2e7d56837b72ecb1a","number":0},{"kind":"pr","value":"#5569","number":5569},{"kind":"pr","value":"#5611","number":5611},{"kind":"pr","value":"#5644","number":5644}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":5644,"title":"fix(model): resolve user-defined provider when switching by default_model name","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"791c992b554fea2f66d8e9b2e7d56837b72ecb1a","number":0},{"kind":"pr","value":"#45006","number":45006}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":6255,"title":"fix(cli): route /model provider/model to user-defined providers","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":8623,"title":"fix(cli): preserve named custom providers in /model switching","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":8963,"title":"fix(config): restore custom provider resolution and nested config han…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"824c9d3812be6603fd4106113d912cc146ac1802","number":0},{"kind":"pr","value":"#50385","number":50385},{"kind":"pr","value":"#8919","number":8919},{"kind":"pr","value":"#8919","number":8919}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":9134,"title":"fix(model): pass api_mode through validation chain and support custom:name:model triple syntax","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":9152,"title":"fix(model): api_mode propagation, triple syntax, and custom provider model listing","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"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":"#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},{"kind":"pr","value":"#15103","number":15103}],"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","implication":"superseded"},"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":16332,"title":"fix(model-switch): treat custom:* providers as custom in is_custom guard (#16259)","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"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","implication":"superseded"},"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\": getattr(agent, \"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":24363,"title":"fix(agent): skip dead /v1/models probes when LM Studio slug isn't loaded","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":34078,"title":"fix(timeouts): plumb requested_provider through gateway to resolve named custom provider timeouts (#34001)","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"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":39803,"title":"fix(models): respect custom provider catalog priority","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":39874,"title":"fix(models): skip OpenRouter catalog lookup when provider is 'custom'","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#39803","number":39803},{"kind":"pr","value":"#39753","number":39753}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":44062,"title":"fix(gateway): don't restore a bare billing provider as the resumed session's provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45578","number":45578},{"kind":"pr","value":"#44022","number":44022}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":44109,"title":"fix(desktop): persist custom provider entry identity in session runtime, not the resolved \"custom\"","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45578","number":45578}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":44151,"title":"fix(tui): skip bare billing classes in session resume (#44022)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44062","number":44062},{"kind":"pr","value":"#44022","number":44022}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":44663,"title":"fix(model_switch): respect explicit provider config over OpenRouter catalog","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50280","number":50280},{"kind":"pr","value":"#45552","number":45552}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":45289,"title":"fix(agent): credential-pool recovery for custom providers (custom vs custom:<name> mismatch)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45434,"title":"fix(models): respect config.model provider+base_url before OpenRouter auto-detect","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50277","number":50277}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45578,"title":"fix(desktop): preserve custom provider identity on resume","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","scripts/release.py","tests/hermes_cli/test_custom_provider_identity.py","tests/test_tui_gateway_server.py","tests/tui_gateway/test_custom_provider_session_persistence.py","tui_gateway/server.py"],"fix_hunks":[{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -660,6 +660,61 @@ def has_named_custom_provider(requested_provider: str) -> bool:\n         return False\n \n \n+def find_custom_provider_identity(base_url: str) -> Optional[str]:\n+    \"\"\"Map an endpoint URL back to its canonical ``custom:<name>`` menu key.\n+\n+    Returns the ``custom:<normalized-name>`` slug of the first ``providers:``\n+    / ``custom_providers:`` entry whose base_url matches, or ``None`` when no\n+    entry owns the URL.\n+\n+    Session persistence stores the agent's *resolved* provider, and for every\n+    named custom endpoint that is the literal string ``\"custom\"`` — the entry\n+    name is lost, and the api_key is deliberately never persisted. The\n+    endpoint URL is the one durable fact that survives the round-trip, so\n+    this reverse lookup lets persist/rebuild paths recover the entry identity\n+    (and with it key_env/api_key/api_mode resolution via\n+    :func:`_get_named_custom_provider`) instead of failing with\n+    ``auth_unavailable`` or silently rebuilding with placeholder credentials.\n+    \"\"\"\n+    target = _normalize_base_url_for_match(base_url)\n+    if not target:\n+        return None\n+    try:\n+        config = load_config()\n+    except Exception:\n+        return None\n+\n+    providers = config.get(\"providers\")\n+    if isinstance(providers, dict):\n+        for ep_name, entry in providers.items():\n+            if not isinstance(entry, dict):\n+                continue\n+            entry_url = (\n+                entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n+            )\n+            if _normalize_base_url_for_match(entry_url) == target:\n+                return f\"custom:{_normalize_custom_provider_name(str(ep_name))}\"\n+\n+    try:\n+        custom_providers = get_compatible_custom_providers(config)\n+    except Exception:\n+        custom_providers = None\n+    for entry in custom_providers or []:\n+        if not isinstance(entry, dict):\n+            continue\n+        name = entry.get(\"name\")\n+        if not isinstance(name, str) or not name.strip():\n+            continue\n+        if _normalize_base_url_for_match(entry.get(\"base_url\")) == target:\n+            return f\"custom:{_normalize_custom_provider_name(name)}\"\n+\n+    return None\n+\n+\n+def _normalize_base_url_for_match(value) -> str:\n+    return str(value or \"\").strip().rstrip(\"/\").lower()\n+\n+\n def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]:\n     extra_body = custom_provider.get(\"extra_body\")\n     if not isinstance(extra_body, dict) or not extra_body:"]},{"file":"scripts/release.py","hunks":["@@ -47,6 +47,8 @@\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"pinguarmy\",\n     \"[redacted:email]\": \"AIalliAI\",\n+    \"[redacted:email]\": \"AIalliAI\",\n+    \"[redacted:email]\": \"HaozheZhang6\",\n     \"[redacted:email]\": \"barronlroth\",\n     \"[redacted:email]\": \"OndrejDrapalik\",\n     \"[redacted:email]\": \"tomekpanek\","]},{"file":"tests/hermes_cli/test_custom_provider_identity.py","hunks":["@@ -0,0 +1,99 @@\n+\"\"\"Unit tests for find_custom_provider_identity (base_url → custom:<name>).\n+\n+Reverse lookup used by tui_gateway session persistence to recover a named\n+``providers:`` / ``custom_providers:`` entry from the only durable fact the\n+session row keeps once the provider has been resolved to the literal string\n+\"custom\": the endpoint URL. See\n+tests/tui_gateway/test_custom_provider_session_persistence.py for the\n+end-to-end persist/resume round-trip.\n+\"\"\"\n+\n+import hermes_cli.runtime_provider as rp\n+\n+\n+def test_matches_legacy_custom_providers_list(monkeypatch):\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"custom_providers\": [\n+                {\"name\": \"MiMo v2.5 Pro\", \"base_url\": \"https://api.mimo.example/v1\"}\n+            ]\n+        },\n+    )\n+    assert (\n+        rp.find_custom_provider_identity(\"https://api.mimo.example/v1\")\n+        == \"custom:mimo-v2.5-pro\"\n+    )\n+\n+\n+def test_matches_providers_dict_by_key(monkeypatch):\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\"providers\": {\"local\": {\"api\": \"http://127.0.0.1:8000/v1\"}}},\n+    )\n+    assert (\n+        rp.find_custom_provider_identity(\"http://127.0.0.1:8000/v1\")\n+        == \"custom:local\"\n+    )\n+\n+\n+def test_match_ignores_trailing_slash_and_case(monkeypatch):\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"custom_providers\": [\n+                {\"name\": \"local\", \"base_url\": \"http://Localhost:8000/v1/\"}\n+            ]\n+        },\n+    )\n+    assert (\n+        rp.find_custom_provider_identity(\"http://localhost:8000/v1\")\n+        == \"custom:local\"\n+    )\n+\n+\n+def test_no_match_returns_none(monkeypatch):\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"custom_providers\": [\n+                {\"name\": \"other\", \"base_url\": \"https://elsewhere.example/v1\"}\n+            ]\n+        },\n+    )\n+    assert rp.find_custom_provider_identity(\"https://api.mimo.example/v1\") is None\n+\n+\n+def test_empty_base_url_returns_none(monkeypatch):\n+    monkeypatch.setattr(\n+        rp, \"load_config\", lambda: {\"custom_providers\": [{\"name\": \"x\"}]}\n+    )\n+    assert rp.find_custom_provider_identity(\"\") is None\n+    assert rp.find_custom_provider_identity(None) is None\n+\n+\n+def test_identity_resolves_back_through_named_lookup(monkeypatch):\n+    \"\"\"The returned slug must be accepted by _get_named_custom_provider —\n+    that is the whole point of persisting it.\"\"\"\n+    config = {\n+        \"custom_providers\": [\n+            {\n+                \"name\": \"mimo-v2.5-pro\",\n+                \"base_url\": \"https://api.mimo.example/v1\",\n+                \"api_key\": \"sk-entry\",\n+            }\n+        ]\n+    }\n+    monkeypatch.setattr(rp, \"load_config\", lambda: config)\n+\n+    slug = rp.find_custom_provider_identity(\"https://api.mimo.example/v1\")\n+    assert slug == \"custom:mimo-v2.5-pro\"\n+\n+    entry = rp._get_named_custom_provider(slug)\n+    assert entry is not None\n+    assert entry[\"base_url\"] == \"https://api.mimo.example/v1\"\n+    assert entry[\"api_key\"] == \"sk-entry\""]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -847,6 +847,41 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display():\n     ]\n \n \n+def test_history_to_messages_keeps_reasoning_only_assistant_turn():\n+    # A thinking-only assistant turn (reasoning present, no visible text) is\n+    # persisted and recallable, but was dropped from the resumed session view\n+    # as \"empty\" -- so it vanished while the agent could still recall it from\n+    # the transcript. Keep it (with reasoning) so the desktop \"Thinking…\"\n+    # disclosure renders. (#44022)\n+    history = [\n+        {\"role\": \"user\", \"content\": \"think about this\"},\n+        {\"role\": \"assistant\", \"content\": \"\", \"reasoning\": \"step-by-step thoughts\"},\n+        {\"role\": \"assistant\", \"content\": \"here is the answer\"},\n+    ]\n+\n+    assert server._history_to_messages(history) == [\n+        {\"role\": \"user\", \"text\": \"think about this\"},\n+        {\"role\": \"assistant\", \"text\": \"\", \"reasoning\": \"step-by-step thoughts\"},\n+        {\"role\": \"assistant\", \"text\": \"here is the answer\"},\n+    ]\n+\n+\n+def test_history_to_messages_still_drops_empty_assistant_without_reasoning():\n+    # A genuinely empty assistant turn (no text, no reasoning, no tool calls)\n+    # remains filtered out -- the fix only spares reasoning-bearing turns.\n+    history = [\n+        {\"role\": \"user\", \"content\": \"hi\"},\n+        {\"role\": \"assistant\", \"content\": \"\", \"reasoning\": \"\"},\n+        {\"role\": \"assistant\", \"content\": \"   \"},\n+        {\"role\": \"assistant\", \"content\": \"real reply\"},\n+    ]\n+\n+    assert server._history_to_messages(history) == [\n+        {\"role\": \"user\", \"text\": \"hi\"},\n+        {\"role\": \"assistant\", \"text\": \"real reply\"},\n+    ]\n+\n+\n def test_history_to_messages_renders_multimodal_content():\n     # bb/gui preserves image URLs in the resume payload so the desktop\n     # renderer's extractEmbeddedImages can pull them back out and display","@@ -971,6 +1006,35 @@ def fake_init_session(sid, key, agent, history, cols=80):\n     assert server._sessions[runtime_sid][\"model_override\"] == captured[\"model_override\"]\n \n \n+def test_stored_session_runtime_overrides_skips_bare_billing_provider():\n+    \"\"\"A bare billing bucket (\"custom\"/\"auto\"/\"openrouter\") must not be restored as the\n+    provider identity on resume. A custom endpoint that never used `/model` persists only\n+    `billing_provider=\"custom\"`; restoring that broke `session.resume` with \"No LLM provider\n+    configured\" (agent_init treats it as non-routable). A real provider, or an explicit\n+    `model_config.provider`, is still restored.\n+    \"\"\"\n+    # Bare \"custom\" bucket, no explicit model_config.provider: no provider override restored.\n+    ov = server._stored_session_runtime_overrides({\"model\": \"my-model\", \"billing_provider\": \"custom\"})\n+    assert \"provider_override\" not in ov\n+    assert ov[\"model_override\"][\"provider\"] is None\n+\n+    for bare in (\"auto\", \"openrouter\", \"custom\"):\n+        ov = server._stored_session_runtime_overrides({\"model\": \"m\", \"billing_provider\": bare})\n+        assert \"provider_override\" not in ov\n+\n+    # A real provider in billing_provider is still restored.\n+    ov = server._stored_session_runtime_overrides({\"model\": \"m\", \"billing_provider\": \"anthropic\"})\n+    assert ov[\"provider_override\"] == \"anthropic\"\n+    assert ov[\"model_override\"][\"provider\"] == \"anthropic\"\n+\n+    # An explicit routable provider in model_config wins over the bare billing bucket.\n+    ov = server._stored_session_runtime_overrides(\n+        {\"model\": \"m\", \"billing_provider\": \"custom\", \"model_config\": {\"provider\": \"custom:myendpoint\"}}\n+    )\n+    assert ov[\"provider_override\"] == \"custom:myendpoint\"\n+    assert ov[\"model_override\"][\"provider\"] == \"custom:myendpoint\"\n+\n+\n def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch):\n     updates = {}\n "]},{"file":"tests/tui_gateway/test_custom_provider_session_persistence.py","hunks":["@@ -0,0 +1,198 @@\n+\"\"\"Session persistence must not strip a custom provider's identity.\n+\n+``_runtime_model_config`` persists the live agent's RESOLVED provider into\n+the session row's ``model_config`` JSON. For any named ``providers:`` /\n+``custom_providers:`` entry (e.g. one called \"mimo-v2.5-pro\"),\n+``agent.provider`` is the literal string \"custom\", so the entry name was\n+lost — and the api_key is deliberately never persisted. On ``session.resume``\n+or ``_reset_session_agent``, ``_stored_session_runtime_overrides`` fed\n+provider=\"custom\" back into ``_make_agent`` →\n+``resolve_runtime_provider(requested=\"custom\")``, which cannot match an entry\n+named \"mimo-v2.5-pro\". Depending on config the rebuild either raised\n+\"No LLM provider configured. Run `hermes model`...\" (resume failed) or\n+silently resolved placeholder credentials (\"no-key-required\") against the\n+patched-back base_url.\n+\n+Fix: persist the REQUESTED/entry identity — ``_runtime_model_config`` maps\n+the agent's base_url back to the canonical ``custom:<name>`` menu key via\n+``find_custom_provider_identity``; ``_make_agent`` performs the same\n+recovery for rows persisted before the fix (and falls back to handing the\n+stored base_url to the direct-alias branch when no entry matches).\n+\n+Related investigation: GH #44070 / PR #44099 (credential-pool base_url\n+pinning); same family of resolved-vs-requested identity loss.\n+\"\"\"\n+\n+import json\n+import types\n+from unittest.mock import MagicMock, patch\n+\n+import hermes_cli.runtime_provider as rp\n+\n+MIMO_URL = \"https://token-plan-cn.xiaomimimo.com/v1\"\n+MIMO_KEY = \"sk-mimo-entry-key\"\n+\n+LEGACY_LIST_CONFIG = {\n+    \"custom_providers\": [\n+        {\n+            \"name\": \"mimo-v2.5-pro\",\n+            \"base_url\": MIMO_URL,\n+            \"api_key\": MIMO_KEY,\n+            \"api_mode\": \"chat_completions\",\n+        }\n+    ]\n+}\n+\n+PROVIDERS_DICT_CONFIG = {\n+    \"providers\": {\n+        \"mimo-v2.5-pro\": {\n+            \"api\": MIMO_URL,\n+            \"api_key\": MIMO_KEY,\n+        }\n+    }\n+}\n+\n+\n+def _custom_agent(base_url=MIMO_URL):\n+    return types.SimpleNamespace(\n+        model=\"mimo-v2.5-pro\",\n+        provider=\"custom\",\n+        base_url=base_url,\n+        api_mode=\"chat_completions\",\n+        reasoning_config=None,\n+        service_tier=None,\n+    )\n+\n+\n+class TestRuntimeModelConfigPersistsEntryIdentity:\n+    def test_persists_menu_key_instead_of_resolved_custom(self, monkeypatch):\n+        monkeypatch.setattr(rp, \"load_config\", lambda: LEGACY_LIST_CONFIG)\n+\n+        from tui_gateway.server import _runtime_model_config\n+\n+        config = _runtime_model_config(_custom_agent())\n+\n+        assert config[\"provider\"] == \"custom:mimo-v2.5-pro\"\n+        assert config[\"base_url\"] == MIMO_URL\n+        # Credentials must keep coming from config/provider resolution,\n+        # never from the session DB.\n+        assert \"api_key\" not in config\n+\n+    def test_persists_menu_key_for_providers_dict_entry(self, monkeypatch):\n+        monkeypatch.setattr(rp, \"load_config\", lambda: PROVIDERS_DICT_CONFIG)\n+\n+        from tui_gateway.server import _runtime_model_config\n+\n+        config = _runtime_model_config(_custom_agent())\n+\n+        assert config[\"provider\"] == \"custom:mimo-v2.5-pro\"\n+\n+    def test_keeps_bare_custom_when_no_entry_matches(self, monkeypatch):\n+        monkeypatch.setattr(rp, \"load_config\", lambda: {})\n+\n+        from tui_gateway.server import _runtime_model_config\n+\n+        config = _runtime_model_config(_custom_agent())\n+\n+        assert config[\"provider\"] == \"custom\"\n+\n+    def test_non_custom_provider_untouched(self, monkeypatch):\n+        def _boom():\n+            raise AssertionError(\"identity lookup must not run for built-ins\")\n+\n+        monkeypatch.setattr(rp, \"load_config\", _boom)\n+\n+        from tui_gateway.server import _runtime_model_config\n+\n+        agent = _custom_agent()\n+        agent.provider = \"anthropic\"\n+        agent.base_url = \"https://api.anthropic.com\"\n+\n+        assert _runtime_model_config(agent)[\"provider\"] == \"anthropic\"\n+\n+\n+def _make_agent_with_override(override, monkeypatch, config):\n+    \"\"\"Run _make_agent through the REAL resolve_runtime_provider against a\n+    patched config, returning the kwargs AIAgent was constructed with.\"\"\"\n+    monkeypatch.setattr(rp, \"load_config\", lambda: config)\n+    monkeypatch.setattr(rp, \"_get_model_config\", lambda: {})\n+    # Keep credential-pool resolution off the developer's real HERMES home.\n+    monkeypatch.setattr(rp, \"_try_resolve_from_custom_pool\", lambda *a, **k: None)\n+\n+    fake_cfg = {\"agent\": {\"system_prompt\": \"\"}, \"model\": {\"default\": \"unused\"}}\n+    with (\n+        patch(\"tui_gateway.server._load_cfg\", return_value=fake_cfg),\n+        patch(\"tui_gateway.server._get_db\", return_value=MagicMock()),\n+        patch(\"tui_gateway.server._load_reasoning_config\", return_value=None),\n+        patch(\"tui_gateway.server._load_service_tier\", return_value=None),\n+        patch(\"tui_gateway.server._load_enabled_toolsets\", return_value=None),\n+        patch(\"run_agent.AIAgent\") as mock_agent,\n+    ):\n+        from tui_gateway.server import _make_agent\n+\n+        _make_agent(\"sid-custom\", \"key-custom\", model_override=override)\n+\n+    return mock_agent.call_args.kwargs\n+\n+\n+class TestResumeRoundTrip:\n+    def test_round_trip_restores_entry_credentials(self, monkeypatch):\n+        \"\"\"persist → stored-overrides → _make_agent resolves the entry's\n+        api_key again (the exact path that raised \"No LLM provider\n+        configured\" before the fix).\"\"\"\n+        monkeypatch.setattr(rp, \"load_config\", lambda: LEGACY_LIST_CONFIG)\n+\n+        from tui_gateway.server import (\n+            _runtime_model_config,\n+            _stored_session_runtime_overrides,\n+        )\n+\n+        model_config = _runtime_model_config(_custom_agent())\n+        row = {\n+            \"model\": \"mimo-v2.5-pro\",\n+            \"model_config\": json.dumps(model_config),\n+        }\n+        overrides = _stored_session_runtime_overrides(row)\n+        assert overrides[\"model_override\"][\"provider\"] == \"custom:mimo-v2.5-pro\"\n+\n+        kwargs = _make_agent_with_override(\n+            overrides[\"model_override\"], monkeypatch, LEGACY_LIST_CONFIG\n+        )\n+\n+        assert kwargs[\"provider\"] == \"custom\"\n+        assert kwargs[\"base_url\"] == MIMO_URL\n+        assert kwargs[\"api_key\"] == MIMO_KEY\n+\n+    def test_legacy_row_with_bare_custom_heals_via_base_url(self, monkeypatch):\n+        \"\"\"Rows persisted BEFORE the fix stored provider=\"custom\"; the\n+        rebuild must recover the entry identity from the stored base_url.\"\"\"\n+        override = {\n+            \"model\": \"mimo-v2.5-pro\",\n+            \"provider\": \"custom\",\n+            \"base_url\": MIMO_URL,\n+            \"api_mode\": \"chat_completions\",\n+        }\n+\n+        kwargs = _make_agent_with_override(override, monkeypatch, LEGACY_LIST_CONFIG)\n+\n+        assert kwargs[\"base_url\"] == MIMO_URL\n+        assert kwargs[\"api_key\"] == MIMO_KEY\n+\n+    def test_legacy_row_without_matching_entry_keeps_endpoint(self, monkeypatch):\n+        \"\"\"No config entry owns the stored URL: the direct-alias branch must\n+        still receive the base_url so resolution targets the session's\n+        endpoint instead of raising auth_unavailable.\"\"\"\n+        monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n+        override = {\n+            \"model\": \"local-model\",\n+            \"provider\": \"custom\",\n+            \"base_url\": \"http://127.0.0.1:8000/v1\",\n+            \"api_mode\": \"chat_completions\",\n+        }\n+\n+        kwargs = _make_agent_with_override(override, monkeypatch, {})\n+\n+        assert kwargs[\"provider\"] == \"custom\"\n+        assert kwargs[\"base_url\"] == \"http://127.0.0.1:8000/v1\"\n+        assert kwargs[\"api_key\"] == \"no-key-required\""]},{"file":"tui_gateway/server.py","hunks":["@@ -1477,6 +1477,11 @@ def _resolve_startup_runtime() -> tuple[str, str | None]:\n     return model, None\n \n \n+# Bare billing buckets are not routable provider identities (kept in parity with the\n+# provider gate in agent_init). Restoring one as a session provider override breaks resume.\n+_BARE_BILLING_PROVIDERS = {\"auto\", \"openrouter\", \"custom\"}\n+\n+\n def _stored_session_runtime_overrides(row: dict | None) -> dict:\n     \"\"\"Return runtime fields persisted with a stored session.\n ","@@ -1503,12 +1508,18 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:\n \n     overrides: dict = {}\n     model = str(row.get(\"model\") or model_config.get(\"model\") or \"\").strip()\n-    provider = str(\n-        model_config.get(\"provider\")\n-        or model_config.get(\"billing_provider\")\n-        or row.get(\"billing_provider\")\n-        or \"\"\n+    # ``billing_provider`` is only the billing bucket — for a custom endpoint it is the\n+    # bare class ``\"custom\"``, which agent_init treats as non-routable, so restoring it as\n+    # the provider override makes ``session.resume`` fail with \"No LLM provider configured\".\n+    # Only restore an explicit provider; otherwise leave it unset so resume falls back to\n+    # the configured default, matching the working CLI path.\n+    explicit_provider = str(model_config.get(\"provider\") or \"\").strip()\n+    billing_provider = str(\n+        model_config.get(\"billing_provider\") or row.get(\"billing_provider\") or \"\"\n     ).strip()\n+    provider = explicit_provider\n+    if not provider and billing_provider.lower() not in _BARE_BILLING_PROVIDERS:\n+        provider = billing_provider\n     base_url = str(model_config.get(\"base_url\") or \"\").strip()\n     api_mode = str(model_config.get(\"api_mode\") or \"\").strip()\n     reasoning_config = model_config.get(\"reasoning_config\")","@@ -1548,6 +1559,25 @@ def _runtime_model_config(agent, existing: dict | None = None) -> dict:\n     if model:\n         config[\"model\"] = model\n     if provider:\n+        if provider == \"custom\" and base_url:\n+            # ``agent.provider`` is the RESOLVED provider, and for any named\n+            # ``providers:`` / ``custom_providers:`` entry that is the literal\n+            # string \"custom\" — persisting it loses the entry identity, so a\n+            # later resume/rebuild cannot re-resolve the entry's credentials\n+            # (the api_key is deliberately never persisted; see\n+            # _stored_session_runtime_overrides). Recover the canonical\n+            # ``custom:<name>`` menu key from the endpoint URL so\n+            # resolve_runtime_provider() can find the entry again.\n+            try:\n+                from hermes_cli.runtime_provider import (\n+                    find_custom_provider_identity,\n+                )\n+\n+                provider = find_custom_provider_identity(base_url) or provider\n+            except Exception:\n+                logger.debug(\n+                    \"custom provider identity lookup failed\", exc_info=True\n+                )\n         config[\"provider\"] = provider\n     if base_url:\n         config[\"base_url\"] = base_url","@@ -3299,9 +3329,30 @@ def _make_agent(\n         override_base_url = model_override.get(\"base_url\")\n         override_api_key = [redacted:secret-assignment]\"api_key\")\n         override_api_mode = model_override.get(\"api_mode\")\n+        resolve_kwargs = {}\n+        if (\n+            override_base_url\n+            and str(requested_provider or \"\").strip().lower() == \"custom\"\n+        ):\n+            # Session rows persisted before the custom-provider identity fix\n+            # (see _runtime_model_config) stored the resolved provider\n+            # \"custom\", which _get_named_custom_provider cannot match back to\n+            # a named ``providers:`` / ``custom_providers:`` entry — the\n+            # rebuild then either raised auth_unavailable or silently\n+            # resolved placeholder credentials against the patched-back\n+            # base_url. Recover the entry identity from the persisted\n+            # base_url; failing that, hand the base_url to the direct-alias\n+            # branch so pool/env credentials can still be resolved for it.\n+            from hermes_cli.runtime_provider import find_custom_provider_identity\n+\n+            recovered = find_custom_provider_identity(override_base_url)\n+            if recovered:\n+                requested_provider = recovered\n+            resolve_kwargs[\"explicit_base_url\"] = override_base_url\n         runtime = resolve_runtime_provider(\n             requested=requested_provider,\n             target_model=model or None,\n+            **resolve_kwargs,\n         )\n         # The switch already resolved concrete credentials/endpoint; honor them\n         # so a custom/named endpoint survives the rebuild even if global","@@ -3657,16 +3708,27 @@ def _history_to_messages(history: list[dict]) -> list[dict]:\n                 {\"role\": \"tool\", \"name\": name, \"context\": _tool_ctx(name, args)}\n             )\n             continue\n-        if not content_text.strip():\n+        # An assistant turn may carry only reasoning/thinking content with no\n+        # visible text (extended-thinking turns, thinking-only recovery\n+        # responses). Such a turn is persisted with its reasoning fields and is\n+        # recallable from the transcript, but dropping it here as \"empty\" makes\n+        # it vanish from the resumed/reloaded session view while the desktop's\n+        # reasoning disclosure has nothing to render. Keep it when it carries\n+        # reasoning so the \"Thinking…\" block still shows. (#44022)\n+        reasoning_keys = (\n+            \"reasoning\",\n+            \"reasoning_content\",\n+            \"reasoning_details\",\n+            \"codex_reasoning_items\",\n+        )\n+        has_reasoning = role == \"assistant\" and any(\n+            m.get(key) for key in reasoning_keys\n+        )\n+        if not content_text.strip() and not has_reasoning:\n             continue\n         msg = {\"role\": role, \"text\": content_text}\n         if role == \"assistant\":\n-            for key in (\n-                \"reasoning\",\n-                \"reasoning_content\",\n-                \"reasoning_details\",\n-                \"codex_reasoning_items\",\n-            ):\n+            for key in reasoning_keys:\n                 if key in m and m.get(key) is not None:\n                     msg[key] = m.get(key)\n         messages.append(msg)\n"]}]},{"kind":"pr","number":45581,"title":"fix(tui): preserve custom provider identity on resume","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45578","number":45578}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":45763,"title":"fix(agent): match custom credential pools via agent.requested_provider","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"6182078cb7","number":0},{"kind":"pr","value":"#117942","number":117942},{"kind":"pr","value":"#89021","number":89021},{"kind":"pr","value":"#117839","number":117839}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":45819,"title":"fix(agent): match custom credential pools by requested provider name","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45763","number":45763},{"kind":"pr","value":"#45715","number":45715},{"kind":"pr","value":"#45763","number":45763},{"kind":"pr","value":"#45823","number":45823}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":45823,"title":"fix(agent): accept custom pool match when config loading fails (#45715)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":48547,"title":"fix(desktop): never persist or restore a named custom provider as bare \"custom\"","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_init.py","agent/prompt_builder.py","agent/system_prompt.py","hermes_cli/config.py","hermes_cli/runtime_provider.py","tests/agent/test_prompt_builder.py","tests/tui_gateway/test_custom_provider_session_persistence.py","tui_gateway/server.py"],"fix_hunks":[{"file":"agent/agent_init.py","hunks":["@@ -1224,6 +1224,12 @@ def init_agent(\n     # targets.\n     agent._task_completion_guidance = bool(_agent_section.get(\"task_completion_guidance\", True))\n \n+    # Universal parallel-tool-call guidance toggle.  Default True.  Separate\n+    # flag from task_completion_guidance because a user may want one but not\n+    # the other.  Steers the model to batch independent tool calls into a\n+    # single turn; the runtime already executes such batches concurrently.\n+    agent._parallel_tool_call_guidance = bool(_agent_section.get(\"parallel_tool_call_guidance\", True))\n+\n     # Local Python toolchain probe toggle.  Default True.  When False,\n     # the probe is skipped entirely (no subprocess calls, no system-prompt\n     # line).  Useful for users on exotic setups where the probe heuristics"]},{"file":"agent/prompt_builder.py","hunks":["@@ -305,6 +305,45 @@ def _strip_yaml_frontmatter(content: str) -> str:\n     \"is always better than inventing a result.\"\n )\n \n+# Universal parallel-tool-call guidance — applied to ALL models.\n+#\n+# Why this matters for cost: every assistant turn resends the entire\n+# accumulated conversation (and, on cache-friendly providers, re-reads the\n+# cached prefix and pays for the newly-appended turn). A model that issues\n+# one tool call per turn multiplies the number of round-trips — and therefore\n+# the resent context — for any task that needs several independent reads,\n+# searches, or safe lookups. Batching independent calls into a single\n+# assistant response collapses N turns into one, cutting both latency and the\n+# resent-context cost that compounds over a long conversation.\n+#\n+# The hermes-agent runtime already executes a batch of tool calls\n+# concurrently when they are independent (read-only tools always; path-scoped\n+# file ops when their targets don't overlap — see\n+# run_agent._execute_tool_calls / tool_dispatch_helpers). The missing piece\n+# was telling the *model* to emit those calls together in the first place;\n+# nothing in the open-source system prompt encouraged batching. This block\n+# closes that gap.\n+#\n+# Short on purpose — shipped in the cached system prompt to every user, every\n+# session. Token cost is paid once at install and amortised across all\n+# sessions via prefix caching. Keep it tight.\n+#\n+# Ported from cline/cline#11514 (\"encourage parallel tool calls\"), adapted\n+# from Cline's TypeScript tool-surface guidance to hermes-agent's Python\n+# prompt-assembly architecture.\n+PARALLEL_TOOL_CALL_GUIDANCE = (\n+    \"# Parallel tool calls\\n\"\n+    \"When you need several pieces of information that don't depend on each \"\n+    \"other, request them together in a single response instead of one tool \"\n+    \"call per turn. Independent reads, searches, web fetches, and read-only \"\n+    \"commands should be batched into the same assistant turn — the runtime \"\n+    \"executes independent calls concurrently, and batching avoids resending \"\n+    \"the whole conversation on every extra round-trip.\\n\"\n+    \"Only serialize calls when a later call genuinely depends on an earlier \"\n+    \"call's result (e.g. you must read a file before you can patch it). When \"\n+    \"in doubt and the calls are independent, batch them.\"\n+)\n+\n # OpenAI GPT/Codex-specific execution guidance.  Addresses known failure modes\n # where GPT models abandon work on partial results, skip prerequisite lookups,\n # hallucinate instead of using tools, and declare \"done\" without verification."]},{"file":"agent/system_prompt.py","hunks":["@@ -33,6 +33,7 @@\n     KANBAN_GUIDANCE,\n     MEMORY_GUIDANCE,\n     OPENAI_MODEL_EXECUTION_GUIDANCE,\n+    PARALLEL_TOOL_CALL_GUIDANCE,\n     PLATFORM_HINTS,\n     SESSION_SEARCH_GUIDANCE,\n     SKILLS_GUIDANCE,","@@ -123,6 +124,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)\n     if getattr(agent, \"_task_completion_guidance\", True) and agent.valid_tool_names:\n         stable_parts.append(TASK_COMPLETION_GUIDANCE)\n \n+    # Universal parallel-tool-call guidance.  Tells the model to batch\n+    # independent tool calls into one assistant turn rather than emitting one\n+    # call per turn — the runtime already runs independent calls concurrently\n+    # (read-only tools always; non-overlapping path-scoped file ops), so the\n+    # only thing missing was steering the model to produce the batch.  Cuts\n+    # round-trips and the resent-context cost that compounds over a long\n+    # conversation.  Gated by config.yaml ``agent.parallel_tool_call_guidance``\n+    # (default True) and only injected when tools are actually loaded.\n+    if getattr(agent, \"_parallel_tool_call_guidance\", True) and agent.valid_tool_names:\n+        stable_parts.append(PARALLEL_TOOL_CALL_GUIDANCE)\n+\n     # Tool-aware behavioral guidance: only inject when the tools are loaded\n     tool_guidance = []\n     if \"memory\" in agent.valid_tool_names:"]},{"file":"hermes_cli/config.py","hunks":["@@ -853,6 +853,15 @@ def _ensure_hermes_home_managed(home: Path):\n         # plausible-looking output when a real path is blocked.  Costs ~80\n         # tokens in the cached system prompt.  Set False to disable globally.\n         \"task_completion_guidance\": True,\n+        # Universal parallel-tool-call guidance — short prompt block applied to\n+        # all models that tells the model to batch independent tool calls\n+        # (reads, searches, web fetches, read-only commands) into one turn\n+        # instead of one call per turn.  The runtime already runs independent\n+        # calls concurrently, so this just steers the model to produce the\n+        # batch — cutting round-trips and the resent-context cost that\n+        # compounds over a long conversation.  Costs ~70 tokens in the cached\n+        # system prompt.  Set False to disable globally.\n+        \"parallel_tool_call_guidance\": True,\n         # Local-environment toolchain probe — surfaces Python/pip/uv/PEP-668\n         # state in the system prompt when something non-default is detected\n         # (e.g. python3 has no pip module, pip→python version mismatch, PEP"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -713,6 +713,69 @@ def find_custom_provider_identity(base_url: str) -> Optional[str]:\n     return None\n \n \n+def canonical_custom_identity(\n+    *,\n+    base_url: Optional[str] = None,\n+    config_provider: Optional[str] = None,\n+) -> Optional[str]:\n+    \"\"\"Recover a routable ``custom:<name>`` identity for a bare custom provider.\n+\n+    The bare string ``\"custom\"`` is the *resolved billing class* shared by\n+    every named ``providers:`` / ``custom_providers:`` entry — it is NOT a\n+    routable provider identity (``resolve_runtime_provider(\"custom\")`` falls\n+    through to the OpenRouter default URL with no api_key, which surfaces to\n+    the user as \"No LLM provider configured\").\n+\n+    Any code path that persists or restores a session's provider override\n+    must run the resolved provider through this helper so a bare ``\"custom\"``\n+    is upgraded back to its durable ``custom:<name>`` menu key. Two recovery\n+    sources, in priority order:\n+\n+    1. ``base_url`` — reverse-lookup the entry that owns the endpoint URL\n+       (the one fact that always survives the persistence round-trip when a\n+       URL was recorded).\n+    2. ``config_provider`` — the active ``config.model.provider`` (or its\n+       ``provider``/``HERMES_INFERENCE_PROVIDER`` equivalent). When the agent\n+       was built without a base_url on the override (the recurring\n+       Desktop/TUI regression vector), the configured provider is the only\n+       durable identity left, so fall back to it when it names a real entry.\n+\n+    Returns ``custom:<name>`` when a routable identity is recovered, else\n+    ``None`` (caller keeps whatever it had — bare ``\"custom\"`` only as a last\n+    resort, e.g. a genuine ad-hoc endpoint with no config entry).\n+    \"\"\"\n+    # 1. Reverse-lookup by endpoint URL.\n+    if base_url:\n+        identity = find_custom_provider_identity(base_url)\n+        if identity:\n+            return identity\n+\n+    # 2. Fall back to the configured provider when it names a real entry.\n+    candidate = str(config_provider or \"\").strip()\n+    if not candidate:\n+        try:\n+            candidate = str(_get_model_config().get(\"provider\") or \"\").strip()\n+        except Exception:\n+            candidate = \"\"\n+    if not candidate:\n+        candidate = os.environ.get(\"HERMES_INFERENCE_PROVIDER\", \"\").strip()\n+\n+    candidate_norm = _normalize_custom_provider_name(candidate)\n+    # A bare/non-routable candidate cannot heal a bare custom override.\n+    if not candidate_norm or candidate_norm in {\"custom\", \"auto\", \"openrouter\"}:\n+        return None\n+    # Only return it when it actually resolves to a configured custom entry,\n+    # so we never invent a `custom:<x>` that resolution can't honor.\n+    try:\n+        if _get_named_custom_provider(candidate) is not None:\n+            if candidate_norm.startswith(\"custom:\"):\n+                return candidate_norm\n+            return f\"custom:{candidate_norm}\"\n+    except Exception:\n+        pass\n+    return None\n+\n+\n def _normalize_base_url_for_match(value) -> str:\n     return str(value or \"\").strip().rstrip(\"/\").lower()\n "]},{"file":"tests/agent/test_prompt_builder.py","hunks":["@@ -27,6 +27,7 @@\n     TOOL_USE_ENFORCEMENT_GUIDANCE,\n     TOOL_USE_ENFORCEMENT_MODELS,\n     OPENAI_MODEL_EXECUTION_GUIDANCE,\n+    PARALLEL_TOOL_CALL_GUIDANCE,\n     MEMORY_GUIDANCE,\n     SESSION_SEARCH_GUIDANCE,\n     PLATFORM_HINTS,","@@ -1497,6 +1498,43 @@ def test_guidance_is_string(self):\n         assert len(OPENAI_MODEL_EXECUTION_GUIDANCE) > 100\n \n \n+class TestParallelToolCallGuidance:\n+    \"\"\"Behavior contracts for the universal parallel-tool-call guidance block.\n+\n+    Asserts the invariants the block must satisfy (steer batching, scope to\n+    independent calls, stay short for the cached prompt) rather than freezing\n+    its exact wording.\n+    \"\"\"\n+\n+    def test_is_nonempty_string(self):\n+        assert isinstance(PARALLEL_TOOL_CALL_GUIDANCE, str)\n+        assert PARALLEL_TOOL_CALL_GUIDANCE.strip()\n+\n+    def test_steers_batching_into_one_response(self):\n+        text = PARALLEL_TOOL_CALL_GUIDANCE.lower()\n+        # Must tell the model to group independent calls together.\n+        assert \"single response\" in text or \"same\" in text and \"turn\" in text\n+        assert \"independent\" in text\n+\n+    def test_carves_out_dependent_calls(self):\n+        # Must NOT tell the model to batch dependent calls — that would break\n+        # ordering (read-before-patch). The block has to acknowledge the\n+        # serialize-when-dependent case.\n+        text = PARALLEL_TOOL_CALL_GUIDANCE.lower()\n+        assert \"depend\" in text\n+\n+    def test_stays_short_for_cached_prompt(self):\n+        # Shipped in every cached system prompt — keep it tight. The existing\n+        # task-completion block is ~600 chars; allow generous headroom but\n+        # guard against accidental essay growth.\n+        assert len(PARALLEL_TOOL_CALL_GUIDANCE) < 900\n+\n+    def test_has_a_heading(self):\n+        # Heading delimits it as its own section in the assembled prompt.\n+        assert PARALLEL_TOOL_CALL_GUIDANCE.lstrip().startswith(\"#\")\n+\n+\n+\n # =========================================================================\n # Budget warning history stripping\n # ========================================================================="]},{"file":"tests/tui_gateway/test_custom_provider_session_persistence.py","hunks":["@@ -111,11 +111,11 @@ def _boom():\n         assert _runtime_model_config(agent)[\"provider\"] == \"anthropic\"\n \n \n-def _make_agent_with_override(override, monkeypatch, config):\n+def _make_agent_with_override(override, monkeypatch, config, model_cfg=None):\n     \"\"\"Run _make_agent through the REAL resolve_runtime_provider against a\n     patched config, returning the kwargs AIAgent was constructed with.\"\"\"\n     monkeypatch.setattr(rp, \"load_config\", lambda: config)\n-    monkeypatch.setattr(rp, \"_get_model_config\", lambda: {})\n+    monkeypatch.setattr(rp, \"_get_model_config\", lambda: model_cfg or {})\n     # Keep credential-pool resolution off the developer's real HERMES home.\n     monkeypatch.setattr(rp, \"_try_resolve_from_custom_pool\", lambda *a, **k: None)\n ","@@ -196,3 +196,159 @@ def test_legacy_row_without_matching_entry_keeps_endpoint(self, monkeypatch):\n         assert kwargs[\"provider\"] == \"custom\"\n         assert kwargs[\"base_url\"] == \"http://127.0.0.1:8000/v1\"\n         assert kwargs[\"api_key\"] == \"no-key-required\"\n+\n+\n+# --- Regression: bare \"custom\" WITHOUT a base_url (GH #44022 / #47714) ------\n+#\n+# The recurring Desktop/TUI \"No LLM provider configured\" regression. Every\n+# point-fix above recovers the entry identity from the persisted base_url —\n+# but a session can be persisted/restored with bare ``provider=\"custom\"`` and\n+# NO base_url (the agent was built without one on the override). Then bare\n+# \"custom\" leaked through verbatim, ``resolve_runtime_provider(\"custom\")``\n+# routed to the OpenRouter default URL with no api_key, and the next turn /\n+# resume failed with \"No LLM provider configured\". These tests lock the\n+# config-fallback recovery at all three leak sites so it cannot regress again.\n+\n+NAMED_CONFIG = {\n+    \"model\": {\"default\": \"mimo-v2.5-pro\", \"provider\": \"custom:mimo-v2.5-pro\"},\n+    \"custom_providers\": [\n+        {\n+            \"name\": \"mimo-v2.5-pro\",\n+            \"base_url\": MIMO_URL,\n+            \"api_key\": MIMO_KEY,\n+            \"api_mode\": \"chat_completions\",\n+        }\n+    ],\n+}\n+\n+\n+class TestBareCustomNoBaseUrlHealsFromConfig:\n+    \"\"\"A named custom provider must never escape as bare ``\"custom\"`` when the\n+    config identifies the active entry — even when no base_url survived.\"\"\"\n+\n+    def test_canonical_identity_recovers_from_config_when_no_base_url(\n+        self, monkeypatch\n+    ):\n+        monkeypatch.setattr(rp, \"load_config\", lambda: NAMED_CONFIG)\n+        monkeypatch.setattr(rp, \"_get_model_config\", lambda: NAMED_CONFIG[\"model\"])\n+\n+        # No base_url to reverse-lookup → must fall back to config.model.provider.\n+        assert (\n+            rp.canonical_custom_identity(base_url=None)\n+            == \"custom:mimo-v2.5-pro\"\n+        )\n+\n+    def test_canonical_identity_returns_none_without_a_real_entry(\n+        self, monkeypatch\n+    ):\n+        # config.model.provider is bare \"custom\" and no entry is named → no\n+        # routable identity to recover; caller keeps its fallback behaviour.\n+        monkeypatch.setattr(rp, \"load_config\", lambda: {})\n+        monkeypatch.setattr(rp, \"_get_model_config\", lambda: {\"provider\": \"custom\"})\n+        monkeypatch.delenv(\"HERMES_INFERENCE_PROVIDER\", raising=False)\n+\n+        assert rp.canonical_custom_identity(base_url=None) is None\n+\n+    def test_persist_recovers_entry_when_agent_has_no_base_url(self, monkeypatch):\n+        monkeypatch.setattr(rp, \"load_config\", lambda: NAMED_CONFIG)\n+        monkeypatch.setattr(rp, \"_get_model_config\", lambda: NAMED_CONFIG[\"model\"])\n+\n+        from tui_gateway.server import _runtime_model_config\n+\n+        agent = _custom_agent(base_url=\"\")  # the regression vector\n+        config = _runtime_model_config(agent)\n+\n+        # Bare \"custom\" must NOT be persisted — it heals to the entry identity.\n+        assert config[\"provider\"] == \"custom:mimo-v2.5-pro\"\n+\n+    def test_restore_heals_bare_custom_row_without_base_url(self, monkeypatch):\n+        monkeypatch.setattr(rp, \"load_config\", lambda: NAMED_CONFIG)\n+        monkeypatch.setattr(rp, \"_get_model_config\", lambda: NAMED_CONFIG[\"model\"])\n+\n+        from tui_gateway.server import _stored_session_runtime_overrides\n+\n+        # A poisoned row from before the fix: bare custom, no base_url.\n+        row = {\n+            \"model\": \"mimo-v2.5-pro\",\n+            \"model_config\": json.dumps(\n+                {\"model\": \"mimo-v2.5-pro\", \"provider\": \"custom\"}\n+            ),\n+            \"billing_provider\": \"custom\",\n+        }\n+        overrides = _stored_session_runtime_overrides(row)\n+\n+        assert overrides[\"provider_override\"] == \"custom:mimo-v2.5-pro\"\n+        assert overrides[\"model_override\"][\"provider\"] == \"custom:mimo-v2.5-pro\"\n+\n+    def test_restore_drops_bare_custom_when_config_cannot_heal(self, monkeypatch):\n+        \"\"\"No recoverable identity: do NOT restore bare \"custom\" as a routable\n+        override — leave it unset so resume falls back to the configured\n+        default instead of the broken OpenRouter route.\"\"\"\n+        monkeypatch.setattr(rp, \"load_config\", lambda: {})\n+        monkeypatch.setattr(rp, \"_get_model_config\", lambda: {})\n+        monkeypatch.delenv(\"HERMES_INFERENCE_PROVIDER\", raising=False)\n+\n+        from tui_gateway.server import _stored_session_runtime_overrides\n+\n+        row = {\n+            \"model\": \"some-model\",\n+            \"model_config\": json.dumps(\n+                {\"model\": \"some-model\", \"provider\": \"custom\"}\n+            ),\n+            \"billing_provider\": \"custom\",\n+        }\n+        overrides = _stored_session_runtime_overrides(row)\n+\n+        assert \"provider_override\" not in overrides\n+        assert overrides[\"model_override\"][\"provider\"] is None\n+\n+    def test_make_agent_heals_bare_custom_no_base_url_end_to_end(self, monkeypatch):\n+        \"\"\"The exact failing path: stored override has bare custom + no\n+        base_url; _make_agent must build the AIAgent with the named entry's\n+        endpoint + key, NOT the OpenRouter default with an empty key.\"\"\"\n+        override = {\n+            \"model\": \"mimo-v2.5-pro\",\n+            \"provider\": \"custom\",\n+            \"base_url\": None,\n+            \"api_mode\": \"chat_completions\",\n+        }\n+\n+        kwargs = _make_agent_with_override(\n+            override, monkeypatch, NAMED_CONFIG, model_cfg=NAMED_CONFIG[\"model\"]\n+        )\n+\n+        assert kwargs[\"base_url\"] == MIMO_URL\n+        assert kwargs[\"api_key\"] == MIMO_KEY\n+        assert \"openrouter.ai\" not in (kwargs.get(\"base_url\") or \"\")\n+\n+    def test_first_db_row_persists_entry_identity_not_bare_custom(self, monkeypatch):\n+        \"\"\"The ORIGIN of poisoned rows: a fresh desktop session's first DB\n+        write (_ensure_session_db_row, before the agent is built) copies the\n+        composer override's RESOLVED provider. A named custom provider's\n+        resolved value is bare \"custom\" — persisting that verbatim seeds the\n+        unresumable row. It must be healed to ``custom:<name>`` here.\"\"\"\n+        monkeypatch.setattr(rp, \"load_config\", lambda: NAMED_CONFIG)\n+        monkeypatch.setattr(rp, \"_get_model_config\", lambda: NAMED_CONFIG[\"model\"])\n+\n+        captured = {}\n+\n+        class _DB:\n+            def create_session(self, key, **kwargs):\n+                captured.update(kwargs)\n+\n+        from tui_gateway import server as srv\n+\n+        monkeypatch.setattr(srv, \"_get_db\", lambda: _DB())\n+        monkeypatch.setattr(srv, \"_resolve_model\", lambda: \"mimo-v2.5-pro\")\n+\n+        session = {\n+            \"session_key\": \"agent:main:desktop:dm:abc\",\n+            # composer override carrying the lossy resolved provider + no base_url\n+            \"model_override\": {\"model\": \"mimo-v2.5-pro\", \"provider\": \"custom\"},\n+        }\n+        srv._ensure_session_db_row(session)\n+\n+        persisted = captured.get(\"model_config\") or {}\n+        assert persisted.get(\"provider\") == \"custom:mimo-v2.5-pro\"\n+\n+"]},{"file":"tui_gateway/server.py","hunks":["@@ -1213,6 +1213,27 @@ def _ensure_session_db_row(session: dict) -> None:\n     ):\n         if val := override.get(src_key):\n             model_config[cfg_key] = str(val)\n+    # The composer override may carry the RESOLVED provider \"custom\" for a named\n+    # ``providers:`` / ``custom_providers:`` entry. Persisting bare \"custom\" here\n+    # (the very first DB write for a fresh desktop session, before the agent is\n+    # built) is the origin of the recurring \"No LLM provider configured\" rows:\n+    # on the next resume bare \"custom\" routes to OpenRouter with no key. Recover\n+    # the durable ``custom:<name>`` identity from the override's base_url, else\n+    # the configured provider, so a routable identity is persisted from the\n+    # start (matches _runtime_model_config's normalization).\n+    if str(model_config.get(\"provider\") or \"\").strip().lower() == \"custom\":\n+        try:\n+            from hermes_cli.runtime_provider import canonical_custom_identity\n+\n+            healed = canonical_custom_identity(\n+                base_url=model_config.get(\"base_url\") or None\n+            )\n+            if healed:\n+                model_config[\"provider\"] = healed\n+        except Exception:\n+            logger.debug(\n+                \"custom provider identity recovery failed (db row)\", exc_info=True\n+            )\n     if (reasoning := session.get(\"create_reasoning_override\")) is not None:\n         model_config[\"reasoning_config\"] = reasoning\n     if tier := session.get(\"create_service_tier_override\"):","@@ -1574,6 +1595,28 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:\n     reasoning_config = model_config.get(\"reasoning_config\")\n     service_tier = str(model_config.get(\"service_tier\") or \"\").strip()\n \n+    # Heal a bare ``\"custom\"`` provider stored by an older build (or any leak\n+    # site that bypassed _runtime_model_config's normalization). Bare custom is\n+    # the resolved billing class, not a routable identity — restoring it as the\n+    # session's provider override routes the resume to the OpenRouter default\n+    # URL with no api_key, surfacing as \"No LLM provider configured\". Recover\n+    # the durable ``custom:<name>`` menu key from the stored base_url, falling\n+    # back to the configured provider when the row has no base_url (the\n+    # recurring Desktop/TUI regression vector). If neither names a real entry,\n+    # drop the bare provider entirely so resume falls back to the configured\n+    # default rather than the broken OpenRouter route.\n+    if provider.strip().lower() == \"custom\":\n+        healed = None\n+        try:\n+            from hermes_cli.runtime_provider import canonical_custom_identity\n+\n+            healed = canonical_custom_identity(base_url=base_url or None)\n+        except Exception:\n+            logger.debug(\n+                \"custom provider identity recovery failed\", exc_info=True\n+            )\n+        provider = healed or (\"\" if not base_url else provider)\n+\n     if model:\n         # Use the same dict-shaped override that live /model switches use so a\n         # DB-restored session can preserve custom endpoint metadata across both","@@ -1608,21 +1651,27 @@ def _runtime_model_config(agent, existing: dict | None = None) -> dict:\n     if model:\n         config[\"model\"] = model\n     if provider:\n-        if provider == \"custom\" and base_url:\n+        if provider.strip().lower() == \"custom\":\n             # ``agent.provider`` is the RESOLVED provider, and for any named\n             # ``providers:`` / ``custom_providers:`` entry that is the literal\n             # string \"custom\" — persisting it loses the entry identity, so a\n             # later resume/rebuild cannot re-resolve the entry's credentials\n             # (the api_key is deliberately never persisted; see\n             # _stored_session_runtime_overrides). Recover the canonical\n-            # ``custom:<name>`` menu key from the endpoint URL so\n-            # resolve_runtime_provider() can find the entry again.\n+            # ``custom:<name>`` menu key from the endpoint URL when present,\n+            # else from the configured provider — this second fallback is the\n+            # fix for sessions built WITHOUT a base_url on the override (the\n+            # recurring Desktop/TUI \"No LLM provider configured\" regression:\n+            # bare \"custom\" with no base_url was persisted verbatim and routed\n+            # to OpenRouter with no key on the next resume).\n             try:\n                 from hermes_cli.runtime_provider import (\n-                    find_custom_provider_identity,\n+                    canonical_custom_identity,\n                 )\n \n-                provider = find_custom_provider_identity(base_url) or provider\n+                provider = (\n+                    canonical_custom_identity(base_url=base_url) or provider\n+                )\n             except Exception:\n                 logger.debug(\n                     \"custom provider identity lookup failed\", exc_info=True","@@ -3464,25 +3513,27 @@ def _make_agent(\n         override_api_key = [redacted:secret-assignment]\"api_key\")\n         override_api_mode = model_override.get(\"api_mode\")\n         resolve_kwargs = {}\n-        if (\n-            override_base_url\n-            and str(requested_provider or \"\").strip().lower() == \"custom\"\n-        ):\n+        if str(requested_provider or \"\").strip().lower() == \"custom\":\n             # Session rows persisted before the custom-provider identity fix\n             # (see _runtime_model_config) stored the resolved provider\n             # \"custom\", which _get_named_custom_provider cannot match back to\n             # a named ``providers:`` / ``custom_providers:`` entry — the\n-            # rebuild then either raised auth_unavailable or silently\n-            # resolved placeholder credentials against the patched-back\n-            # base_url. Recover the entry identity from the persisted\n-            # base_url; failing that, hand the base_url to the direct-alias\n-            # branch so pool/env credentials can still be resolved for it.\n-            from hermes_cli.runtime_provider import find_custom_provider_identity\n-\n-            recovered = find_custom_provider_identity(override_base_url)\n+            # rebuild then either raised auth_unavailable, silently resolved\n+            # placeholder credentials against the patched-back base_url, or\n+            # (when no base_url was stored) routed to the OpenRouter default\n+            # with no key, surfacing as \"No LLM provider configured\". Recover\n+            # the entry identity from the persisted base_url, falling back to\n+            # the configured provider when the override carries no base_url\n+            # (the recurring Desktop/TUI regression vector).\n+            from hermes_cli.runtime_provider import canonical_custom_identity\n+\n+            recovered = canonical_custom_identity(base_url=override_base_url or None)\n             if recovered:\n                 requested_provider = recovered\n-            resolve_kwargs[\"explicit_base_url\"] = override_base_url\n+            if override_base_url:\n+                # Failing identity recovery, still hand the base_url to the\n+                # direct-alias branch so pool/env credentials resolve for it.\n+                resolve_kwargs[\"explicit_base_url\"] = override_base_url\n         runtime = resolve_runtime_provider(\n             requested=requested_provider,\n             target_model=model or None,\n"]}]},{"kind":"pr","number":48550,"title":"fix(desktop): keep custom provider model selections","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"9a15fad0d6c370ba09bf87af92144f46ccd2bc4a","number":0},{"kind":"pr","value":"#63058","number":63058}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":50277,"title":"fix(models): respect config.model provider+base_url before OpenRouter auto-detect","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#50277","number":50277},{"kind":"pr","value":"#45434","number":45434},{"kind":"pr","value":"#45434","number":45434},{"kind":"pr","value":"#50277","number":50277},{"kind":"pr","value":"#45434","number":45434},{"kind":"pr","value":"#45434","number":45434},{"kind":"pr","value":"#39803","number":39803},{"kind":"pr","value":"#39874","number":39874}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":50280,"title":"fix(model_switch): respect explicit provider config over OpenRouter catalog","url":null,"state":"open","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":50385,"title":"fix(config): honor model.api_base for custom providers (alias to base_url)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","tests/cli/test_cli_init.py"]},{"kind":"pr","number":50868,"title":"fix(runtime_provider): honor custom_providers model match when UI rewrote model.provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"791c992b554fea2f66d8e9b2e7d56837b72ecb1a","number":0},{"kind":"pr","value":"#45006","number":45006}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":51043,"title":"fix(tui): preserve config on model switch — atomic writes + custom-provider guard (#48305)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/model_switch.py","hermes_cli/models.py","scripts/release.py","tests/hermes_cli/test_models.py","tests/test_tui_gateway_server.py","tui_gateway/server.py"],"fix_hunks":[{"file":"hermes_cli/model_switch.py","hunks":["@@ -1057,8 +1057,10 @@ def switch_model(\n \n         # --- Step e: detect_provider_for_model() as last resort ---\n         _base = current_base_url or \"\"\n-        is_custom = current_provider in {\"custom\", \"local\"} or (\n-            \"localhost\" in _base or \"127.0.0.1\" in _base\n+        is_custom = (\n+            current_provider in {\"custom\", \"local\"}\n+            or current_provider.startswith(\"custom:\")\n+            or (\"localhost\" in _base or \"127.0.0.1\" in _base)\n         )\n \n         if ("]},{"file":"hermes_cli/models.py","hunks":["@@ -1882,6 +1882,14 @@ def detect_static_provider_for_model(\n         return None\n \n     # --- Step 1: check static provider catalogs for a direct match ---\n+    # If the current provider is a custom endpoint (custom or custom:*), never\n+    # auto-switch away from it based on a static catalog match — the user\n+    # explicitly configured their own endpoint and the same model name may be\n+    # served there (#48305).\n+    _is_custom_current = (\n+        current_provider == \"custom\"\n+        or current_provider.startswith(\"custom:\")\n+    )\n     for pid, models in _PROVIDER_MODELS.items():\n         if (\n             pid in current_keys","@@ -1889,6 +1897,8 @@ def detect_static_provider_for_model(\n             or pid in _BORROWED_MODEL_PROVIDERS\n         ):\n             continue\n+        if _is_custom_current:\n+            continue\n         if any(name_lower == m.lower() for m in models):\n             return (pid, name)\n "]},{"file":"scripts/release.py","hunks":["@@ -1673,6 +1673,7 @@\n     \"[redacted:email]\": \"AMIK-coorporations\",  # PR #40578 (Urdu README) co-author\n     \"[redacted:email]\": \"AMIK-coorporations\",  # PR #40578 (Urdu README)\n     \"[redacted:email]\": \"kyssta-exe\",  # PR #44282 (Windows dashboard re-exec)\n+    \"[redacted:email]\": \"Elshayib\",  # PR #48351 (custom-provider misattribution guard; #48305)\n     \"[redacted:email]\": \"loongfay\",  # PR #43508 (Yuanbao wechat forward msg)\n     \"[redacted:email]\": \"BROCCOLO1D\",  # PR #42733 (lazy-parse docker env config)\n     \"[redacted:email]\": \"underthestars-zhy\",  # PR #46907 co-author (Photon Spectrum project ids)"]},{"file":"tests/hermes_cli/test_models.py","hunks":["@@ -301,6 +301,26 @@ def test_aggregator_not_suggested(self):\n         assert result is not None\n         assert result[0] not in {\"nous\",}  # nous has claude models but shouldn't be suggested\n \n+    def test_custom_provider_not_overridden_by_static_catalog(self):\n+        \"\"\"When current provider is custom:*, a static-catalog match must NOT\n+        override it — otherwise a model served by the user's own endpoint gets\n+        misattributed to a native provider, rewriting model.provider (#48305).\n+\n+        `gpt-5.4` is in the static openai catalog; with current=custom:foo,\n+        detection must return None instead of switching to openai.\n+        \"\"\"\n+        assert detect_provider_for_model(\"gpt-5.4\", \"custom:foo\") is None\n+\n+    def test_bare_custom_provider_not_overridden_by_static_catalog(self):\n+        \"\"\"Same protection for the bare 'custom' provider.\"\"\"\n+        assert detect_provider_for_model(\"gpt-5.4\", \"custom\") is None\n+\n+    def test_non_custom_provider_detection_unaffected(self):\n+        \"\"\"The custom-provider guard must NOT change detection for non-custom\n+        current providers — a static-catalog model still routes normally.\"\"\"\n+        result = detect_provider_for_model(\"gpt-5.4\", \"openrouter\")\n+        assert result is not None and result[0] == \"openai\"\n+\n \n class TestIsNousFreeTier:\n     \"\"\"Tests for is_nous_free_tier — account tier detection.\"\"\""]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -3266,7 +3266,7 @@ def switch_model(self, **kwargs):\n         warning_message=\"\",\n     )\n     seen = {}\n-    saved = {}\n+    saved_values = {}\n \n     def _switch_model(**kwargs):\n         seen.update(kwargs)","@@ -3276,7 +3276,9 @@ def _switch_model(**kwargs):\n     monkeypatch.setattr(\"hermes_cli.model_switch.switch_model\", _switch_model)\n     monkeypatch.setattr(server, \"_restart_slash_worker\", lambda sid, session: None)\n     monkeypatch.setattr(server, \"_emit\", lambda *args, **kwargs: None)\n-    monkeypatch.setattr(\"hermes_cli.config.save_config\", lambda cfg: saved.update(cfg))\n+    # _persist_model_switch uses targeted save_config_value writes (#48305) so it\n+    # preserves sibling model.* keys instead of rewriting the whole block.\n+    monkeypatch.setattr(\"cli.save_config_value\", lambda key, value: saved_values.__setitem__(key, value) or True)\n \n     resp = server.handle_request(\n         {","@@ -3292,9 +3294,9 @@ def _switch_model(**kwargs):\n \n     assert resp[\"result\"][\"value\"] == \"anthropic/claude-sonnet-4.6\"\n     assert seen[\"is_global\"] is True\n-    assert saved[\"model\"][\"default\"] == \"anthropic/claude-sonnet-4.6\"\n-    assert saved[\"model\"][\"provider\"] == \"anthropic\"\n-    assert saved[\"model\"][\"base_url\"] == \"https://api.anthropic.com\"\n+    assert saved_values[\"model.default\"] == \"anthropic/claude-sonnet-4.6\"\n+    assert saved_values[\"model.provider\"] == \"anthropic\"\n+    assert saved_values[\"model.base_url\"] == \"https://api.anthropic.com\"\n \n \n def test_config_set_model_explicit_provider_skips_broken_default_init(monkeypatch):","@@ -7988,3 +7990,73 @@ def _boom():\n     # Field omitted, but the rest of the payload is intact.\n     assert \"active_subagents\" not in usage\n     assert usage[\"model\"] == \"x\"\n+\n+\n+def test_persist_model_switch_preserves_sibling_model_keys(tmp_path, monkeypatch):\n+    \"\"\"#48305: switching models from the TUI must NOT destroy sibling keys under\n+    `model:` (model_slots, model_fallback, etc.). _persist_model_switch now uses\n+    targeted save_config_value writes instead of rewriting the whole block.\"\"\"\n+    import types\n+    import yaml\n+    import cli\n+\n+    cfg_path = tmp_path / \"config.yaml\"\n+    cfg_path.write_text(\n+        \"model:\\n\"\n+        \"  default: old-model\\n\"\n+        \"  provider: openai\\n\"\n+        \"  model_slots:\\n\"\n+        \"    fast: gpt-5-mini\\n\"\n+        \"  model_fallback:\\n\"\n+        \"    - claude-haiku\\n\"\n+        \"agent:\\n\"\n+        \"  system_prompt: keepme\\n\"\n+    )\n+    # save_config_value() resolves the config path from cli._hermes_home, which\n+    # is captured at import time — patch it directly (set_hermes_home_override\n+    # does NOT affect this snapshot).\n+    monkeypatch.setattr(cli, \"_hermes_home\", tmp_path)\n+\n+    result = types.SimpleNamespace(\n+        new_model=\"new-model\", target_provider=\"anthropic\", base_url=None\n+    )\n+    server._persist_model_switch(result)\n+    saved = yaml.safe_load(cfg_path.read_text())\n+\n+    # The switched fields updated...\n+    assert saved[\"model\"][\"default\"] == \"new-model\"\n+    assert saved[\"model\"][\"provider\"] == \"anthropic\"\n+    # ...and the sibling keys SURVIVED (the bug was that they got wiped).\n+    assert saved[\"model\"][\"model_slots\"] == {\"fast\": \"gpt-5-mini\"}\n+    assert saved[\"model\"][\"model_fallback\"] == [\"claude-haiku\"]\n+    assert saved[\"agent\"][\"system_prompt\"] == \"keepme\"\n+\n+\n+def test_persist_model_switch_clears_stale_base_url(tmp_path, monkeypatch):\n+    \"\"\"#48305: switching from a custom endpoint (which set model.base_url) to a\n+    provider with no base_url must CLEAR the stale base_url, not leave it\n+    pointing at the old host.\"\"\"\n+    import types\n+    import yaml\n+    import cli\n+\n+    cfg_path = tmp_path / \"config.yaml\"\n+    cfg_path.write_text(\n+        \"model:\\n\"\n+        \"  default: local-model\\n\"\n+        \"  provider: custom:mylocal\\n\"\n+        \"  base_url: http://localhost:1234/v1\\n\"\n+    )\n+    monkeypatch.setattr(cli, \"_hermes_home\", tmp_path)\n+\n+    # Switch to a native provider with no base_url.\n+    result = types.SimpleNamespace(\n+        new_model=\"claude-haiku\", target_provider=\"anthropic\", base_url=None\n+    )\n+    server._persist_model_switch(result)\n+    saved = yaml.safe_load(cfg_path.read_text())\n+\n+    assert saved[\"model\"][\"default\"] == \"claude-haiku\"\n+    assert saved[\"model\"][\"provider\"] == \"anthropic\"\n+    # Stale custom base_url must be cleared (null coalesces to absent on read).\n+    assert not saved[\"model\"].get(\"base_url\"), saved[\"model\"].get(\"base_url\")"]},{"file":"tui_gateway/server.py","hunks":["@@ -2301,21 +2301,23 @@ def _restart_slash_worker(sid: str, session: dict):\n \n \n def _persist_model_switch(result) -> None:\n-    from hermes_cli.config import save_config\n-\n-    cfg = _load_cfg()\n-    model_cfg = cfg.get(\"model\")\n-    if not isinstance(model_cfg, dict):\n-        model_cfg = {}\n-        cfg[\"model\"] = model_cfg\n-\n-    model_cfg[\"default\"] = result.new_model\n-    model_cfg[\"provider\"] = result.target_provider\n+    # Use targeted, atomic key writes (comment/ordering-preserving) instead of\n+    # rewriting the whole `model:` block. A full-block rewrite via save_config()\n+    # destroys sibling keys the user set under `model:` — `model_slots`,\n+    # `model_fallback`, etc. — when switching models from the TUI (#48305).\n+    from cli import save_config_value\n+\n+    save_config_value(\"model.default\", result.new_model)\n+    save_config_value(\"model.provider\", result.target_provider)\n     if result.base_url:\n-        model_cfg[\"base_url\"] = result.base_url\n+        save_config_value(\"model.base_url\", result.base_url)\n     else:\n-        model_cfg.pop(\"base_url\", None)\n-    save_config(cfg)\n+        # Clear any stale base_url when switching to a provider that doesn't use\n+        # one (e.g. custom endpoint -> native provider). Reads coalesce null to\n+        # absent (`model_cfg.get(\"base_url\") or \"\"`), so a null is equivalent to\n+        # removal without needing a key-delete. Leaving the old value would\n+        # route the new model at the previous custom host (#48305).\n+        save_config_value(\"model.base_url\", None)\n \n \n def _apply_model_switch(\n"]}]},{"kind":"pr","number":52506,"title":"fix(web): preserve named custom providers in main model assignment","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":58429,"title":"fix: case-insensitive provider matching in model picker","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"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},{"kind":"pr","number":63688,"title":"fix(acp): preserve original provider namespace in ACP session persist","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64032","number":64032}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":64032,"title":"fix(acp): preserve original provider namespace through ACP session persist (#63681)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63688","number":63688},{"kind":"pr","value":"#63681","number":63681},{"kind":"pr","value":"#63688","number":63688}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":64125,"title":"fix(acp): preserve custom provider namespace in session persist/restore","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63676","number":63676}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":66879,"title":"fix(acp): persist namespaced provider (custom:xxx) instead of normalized 'custom'","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63676","number":63676}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":70579,"title":"fix(acp): pass base_url to resolve_runtime_provider on session restore","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#37386","number":37386},{"kind":"pr","value":"#37386","number":37386},{"kind":"pr","value":"#37386","number":37386},{"kind":"pr","value":"#37386","number":37386},{"kind":"pr","value":"#37386","number":37386},{"kind":"pr","value":"#37386","number":37386},{"kind":"pr","value":"#63681","number":63681}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":109923,"title":"fix(cli): route custom:<name>:<model> inputs through parse_model_input in /model","url":null,"state":"open","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}