{"repo":"NousResearch/hermes-agent","public_id":"3410","display_label":"Auxiliary client custom provider resolution diverging from main runtime","summary":"Most members concern the auxiliary client (e.g., vision, title generation) resolving named custom providers or the 'main' alias differently from the active main runtime, with several PRs proposing to reuse or mirror main_runtime credentials. One member also mentions custom endpoint config after setup.","subsystem":"agent","counts":{"members":11,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":59301,"merged_at":"2026-07-06T02:23:27+00:00"},"resolution":{"state":"partially_resolved","by":"pr-59301"},"freshness":{"state":"verified","last_verified_at":"2026-07-06T02:23:27+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15854545454545454},"fix_edges":[{"issue_number":3410,"pr_number":5978,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-08T00:59:47+00:00"},{"issue_number":45472,"pr_number":59301,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-06T02:23:27+00:00"},{"issue_number":45480,"pr_number":59301,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-06T02:23:27+00:00"}],"members":[{"kind":"issue","number":3410,"title":"Auxiliary auto/custom resolution can diverge from the active custom runtime","url":null,"state":"closed"},{"kind":"issue","number":19437,"title":"Auxiliary client uses independent resolution path, diverges from main conversation loop","url":null,"state":"open"},{"kind":"issue","number":45472,"title":"Auxiliary client (vision, title gen) fails to resolve `custom:<name>` provider — falls back to wrong endpoint","url":null,"state":"closed"},{"kind":"pr","number":3441,"title":"fix(auxiliary): mirror active runtime in _resolve_custom_runtime","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":3442,"title":"fix(setup+auxiliary): custom endpoint loses config after setup, and auxiliary ignores active provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5978","number":5978}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":5978,"title":"fix(auxiliary): resolve named custom providers and 'main' alias in auxiliary routing","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","gateway/platforms/base.py","gateway/platforms/feishu.py","gateway/platforms/telegram.py","gateway/run.py","tests/agent/test_auxiliary_named_custom_providers.py","tests/gateway/test_telegram_caption_merge.py","website/docs/user-guide/configuration.md"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -1142,7 +1142,13 @@ def resolve_provider_client(\n     if provider == \"codex\":\n         provider = \"openai-codex\"\n     if provider == \"main\":\n-        provider = \"custom\"\n+        # Resolve to the user's actual main provider so named custom providers\n+        # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.\n+        main_prov = _read_main_provider()\n+        if main_prov and main_prov not in (\"auto\", \"main\", \"\"):\n+            provider = main_prov\n+        else:\n+            provider = \"custom\"\n \n     # ── Auto: try all providers in priority order ────────────────────\n     if provider == \"auto\":","@@ -1238,6 +1244,28 @@ def resolve_provider_client(\n                        \"but no endpoint credentials found\")\n         return None, None\n \n+    # ── Named custom providers (config.yaml custom_providers list) ───\n+    try:\n+        from hermes_cli.runtime_provider import _get_named_custom_provider\n+        custom_entry = _get_named_custom_provider(provider)\n+        if custom_entry:\n+            custom_base = custom_entry.get(\"base_url\", \"\").strip()\n+            custom_key = custom_entry.get(\"api_key\", \"\").strip() or \"no-key-required\"\n+            if custom_base:\n+                final_model = model or _read_main_model() or \"gpt-4o-mini\"\n+                client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)\n+                logger.debug(\n+                    \"resolve_provider_client: named custom provider %r (%s)\",\n+                    provider, final_model)\n+                return (_to_async_client(client, final_model) if async_mode\n+                        else (client, final_model))\n+            logger.warning(\n+                \"resolve_provider_client: named custom provider %r has no base_url\",\n+                provider)\n+            return None, None\n+    except ImportError:\n+        pass\n+\n     # ── API-key providers from PROVIDER_REGISTRY ─────────────────────\n     try:\n         from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials","@@ -1358,6 +1386,11 @@ def _normalize_vision_provider(provider: Optional[str]) -> str:\n     if provider == \"codex\":\n         return \"openai-codex\"\n     if provider == \"main\":\n+        # Resolve to actual main provider — named custom providers and\n+        # non-aggregator providers need to pass through as their real name.\n+        main_prov = _read_main_provider()\n+        if main_prov and main_prov not in (\"auto\", \"main\", \"\"):\n+            return main_prov\n         return \"custom\"\n     return provider\n "]},{"file":"gateway/platforms/base.py","hunks":["@@ -1105,6 +1105,22 @@ async def _send_with_retry(\n             logger.error(\"[%s] Fallback send also failed: %s\", self.name, fallback_result.error)\n         return fallback_result\n \n+    @staticmethod\n+    def _merge_caption(existing_text: Optional[str], new_text: str) -> str:\n+        \"\"\"Merge a new caption into existing text, avoiding duplicates.\n+\n+        Uses line-by-line exact match (not substring) to prevent false positives\n+        where a shorter caption is silently dropped because it appears as a\n+        substring of a longer one (e.g. \"Meeting\" inside \"Meeting agenda\").\n+        Whitespace is normalised for comparison.\n+        \"\"\"\n+        if not existing_text:\n+            return new_text\n+        existing_captions = [c.strip() for c in existing_text.split(\"\\n\\n\")]\n+        if new_text.strip() not in existing_captions:\n+            return f\"{existing_text}\\n\\n{new_text}\".strip()\n+        return existing_text\n+\n     async def handle_message(self, event: MessageEvent) -> None:\n         \"\"\"\n         Process an incoming message.","@@ -1164,10 +1180,7 @@ async def handle_message(self, event: MessageEvent) -> None:\n                     existing.media_urls.extend(event.media_urls)\n                     existing.media_types.extend(event.media_types)\n                     if event.text:\n-                        if not existing.text:\n-                            existing.text = event.text\n-                        elif event.text not in existing.text:\n-                            existing.text = f\"{existing.text}\\n\\n{event.text}\".strip()\n+                        existing.text = self._merge_caption(existing.text, event.text)\n                 else:\n                     self._pending_messages[session_key] = event\n                 return  # Don't interrupt now - will run after current task completes"]},{"file":"gateway/platforms/feishu.py","hunks":["@@ -2065,10 +2065,7 @@ async def _enqueue_media_event(self, event: MessageEvent) -> None:\n         existing.media_urls.extend(event.media_urls)\n         existing.media_types.extend(event.media_types)\n         if event.text:\n-            if not existing.text:\n-                existing.text = event.text\n-            elif event.text not in existing.text.split(\"\\n\\n\"):\n-                existing.text = f\"{existing.text}\\n\\n{event.text}\"\n+            existing.text = self._merge_caption(existing.text, event.text)\n         existing.timestamp = event.timestamp\n         if event.message_id:\n             existing.message_id = event.message_id"]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -2222,10 +2222,7 @@ def _enqueue_photo_event(self, batch_key: str, event: MessageEvent) -> None:\n             existing.media_urls.extend(event.media_urls)\n             existing.media_types.extend(event.media_types)\n             if event.text:\n-                if not existing.text:\n-                    existing.text = event.text\n-                elif event.text not in existing.text:\n-                    existing.text = f\"{existing.text}\\n\\n{event.text}\".strip()\n+                existing.text = self._merge_caption(existing.text, event.text)\n \n         prior_task = self._pending_photo_batch_tasks.get(batch_key)\n         if prior_task and not prior_task.done():","@@ -2415,11 +2412,7 @@ async def _queue_media_group_event(self, media_group_id: str, event: MessageEven\n             existing.media_urls.extend(event.media_urls)\n             existing.media_types.extend(event.media_types)\n             if event.text:\n-                if existing.text:\n-                    if event.text not in existing.text.split(\"\\n\\n\"):\n-                        existing.text = f\"{existing.text}\\n\\n{event.text}\"\n-                else:\n-                    existing.text = event.text\n+                existing.text = self._merge_caption(existing.text, event.text)\n \n         prior_task = self._media_group_tasks.get(media_group_id)\n         if prior_task:"]},{"file":"gateway/run.py","hunks":["@@ -1987,10 +1987,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                             existing.media_urls.extend(event.media_urls)\n                             existing.media_types.extend(event.media_types)\n                             if event.text:\n-                                if not existing.text:\n-                                    existing.text = event.text\n-                                elif event.text not in existing.text:\n-                                    existing.text = f\"{existing.text}\\n\\n{event.text}\".strip()\n+                                existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text)\n                         else:\n                             adapter._pending_messages[_quick_key] = event\n                     else:"]},{"file":"tests/agent/test_auxiliary_named_custom_providers.py","hunks":["@@ -0,0 +1,151 @@\n+\"\"\"Tests for named custom provider and 'main' alias resolution in auxiliary_client.\"\"\"\n+\n+import os\n+from unittest.mock import patch, MagicMock\n+\n+import pytest\n+\n+\n[redacted:email](autouse=True)\n+def _isolate(tmp_path, monkeypatch):\n+    \"\"\"Redirect HERMES_HOME and clear module caches.\"\"\"\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    # Write a minimal config so load_config doesn't fail\n+    (hermes_home / \"config.yaml\").write_text(\"model:\\n  default: test-model\\n\")\n+\n+\n+def _write_config(tmp_path, config_dict):\n+    \"\"\"Write a config.yaml to the test HERMES_HOME.\"\"\"\n+    import yaml\n+    config_path = tmp_path / \".hermes\" / \"config.yaml\"\n+    config_path.write_text(yaml.dump(config_dict))\n+\n+\n+class TestNormalizeVisionProvider:\n+    \"\"\"_normalize_vision_provider should resolve 'main' to actual main provider.\"\"\"\n+\n+    def test_main_resolves_to_named_custom(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"my-model\", \"provider\": \"custom:beans\"},\n+            \"custom_providers\": [{\"name\": \"beans\", \"base_url\": \"http://localhost/v1\"}],\n+        })\n+        from agent.auxiliary_client import _normalize_vision_provider\n+        assert _normalize_vision_provider(\"main\") == \"custom:beans\"\n+\n+    def test_main_resolves_to_openrouter(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"anthropic/claude-sonnet-4\", \"provider\": \"openrouter\"},\n+        })\n+        from agent.auxiliary_client import _normalize_vision_provider\n+        assert _normalize_vision_provider(\"main\") == \"openrouter\"\n+\n+    def test_main_resolves_to_deepseek(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"deepseek-chat\", \"provider\": \"deepseek\"},\n+        })\n+        from agent.auxiliary_client import _normalize_vision_provider\n+        assert _normalize_vision_provider(\"main\") == \"deepseek\"\n+\n+    def test_main_falls_back_to_custom_when_no_provider(self, tmp_path):\n+        _write_config(tmp_path, {\"model\": {\"default\": \"gpt-4o\"}})\n+        from agent.auxiliary_client import _normalize_vision_provider\n+        assert _normalize_vision_provider(\"main\") == \"custom\"\n+\n+    def test_bare_provider_name_unchanged(self):\n+        from agent.auxiliary_client import _normalize_vision_provider\n+        assert _normalize_vision_provider(\"beans\") == \"beans\"\n+        assert _normalize_vision_provider(\"deepseek\") == \"deepseek\"\n+\n+    def test_codex_alias_still_works(self):\n+        from agent.auxiliary_client import _normalize_vision_provider\n+        assert _normalize_vision_provider(\"codex\") == \"openai-codex\"\n+\n+    def test_auto_unchanged(self):\n+        from agent.auxiliary_client import _normalize_vision_provider\n+        assert _normalize_vision_provider(\"auto\") == \"auto\"\n+        assert _normalize_vision_provider(None) == \"auto\"\n+\n+\n+class TestResolveProviderClientMainAlias:\n+    \"\"\"resolve_provider_client('main', ...) should resolve to actual main provider.\"\"\"\n+\n+    def test_main_resolves_to_named_custom_provider(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"my-model\", \"provider\": \"beans\"},\n+            \"custom_providers\": [\n+                {\"name\": \"beans\", \"base_url\": \"http://beans.local/v1\", \"api_key\": \"k\"},\n+            ],\n+        })\n+        from agent.auxiliary_client import resolve_provider_client\n+        client, model = resolve_provider_client(\"main\", \"override-model\")\n+        assert client is not None\n+        assert model == \"override-model\"\n+        assert \"beans.local\" in str(client.base_url)\n+\n+    def test_main_with_custom_colon_prefix(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"my-model\", \"provider\": \"custom:beans\"},\n+            \"custom_providers\": [\n+                {\"name\": \"beans\", \"base_url\": \"http://beans.local/v1\", \"api_key\": \"k\"},\n+            ],\n+        })\n+        from agent.auxiliary_client import resolve_provider_client\n+        client, model = resolve_provider_client(\"main\", \"test\")\n+        assert client is not None\n+        assert \"beans.local\" in str(client.base_url)\n+\n+\n+class TestResolveProviderClientNamedCustom:\n+    \"\"\"resolve_provider_client should resolve named custom providers directly.\"\"\"\n+\n+    def test_named_custom_provider(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"test-model\"},\n+            \"custom_providers\": [\n+                {\"name\": \"beans\", \"base_url\": \"http://beans.local/v1\", \"api_key\": \"k\"},\n+            ],\n+        })\n+        from agent.auxiliary_client import resolve_provider_client\n+        client, model = resolve_provider_client(\"beans\", \"my-model\")\n+        assert client is not None\n+        assert model == \"my-model\"\n+        assert \"beans.local\" in str(client.base_url)\n+\n+    def test_named_custom_provider_default_model(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"main-model\"},\n+            \"custom_providers\": [\n+                {\"name\": \"beans\", \"base_url\": \"http://beans.local/v1\", \"api_key\": \"k\"},\n+            ],\n+        })\n+        from agent.auxiliary_client import resolve_provider_client\n+        client, model = resolve_provider_client(\"beans\")\n+        assert client is not None\n+        # Should use _read_main_model() fallback\n+        assert model == \"main-model\"\n+\n+    def test_named_custom_no_api_key_uses_fallback(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"test\"},\n+            \"custom_providers\": [\n+                {\"name\": \"local\", \"base_url\": \"http://localhost:8080/v1\"},\n+            ],\n+        })\n+        from agent.auxiliary_client import resolve_provider_client\n+        client, model = resolve_provider_client(\"local\", \"test\")\n+        assert client is not None\n+        # no-key-required should be used\n+\n+    def test_nonexistent_named_custom_falls_through(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"test\"},\n+            \"custom_providers\": [\n+                {\"name\": \"beans\", \"base_url\": \"http://beans.local/v1\"},\n+            ],\n+        })\n+        from agent.auxiliary_client import resolve_provider_client\n+        # \"coffee\" doesn't exist in custom_providers\n+        client, model = resolve_provider_client(\"coffee\", \"test\")\n+        assert client is None"]},{"file":"tests/gateway/test_telegram_caption_merge.py","hunks":["@@ -0,0 +1,77 @@\n+\"\"\"Tests for TelegramPlatform._merge_caption caption deduplication logic.\"\"\"\n+\n+import pytest\n+\n+from gateway.platforms.telegram import TelegramAdapter\n+\n+merge = TelegramAdapter._merge_caption\n+\n+\n+class TestMergeCaptionBasic:\n+    def test_no_existing_text(self):\n+        assert merge(None, \"Hello\") == \"Hello\"\n+\n+    def test_empty_existing_text(self):\n+        assert merge(\"\", \"Hello\") == \"Hello\"\n+\n+    def test_exact_duplicate_dropped(self):\n+        assert merge(\"Revenue\", \"Revenue\") == \"Revenue\"\n+\n+    def test_different_captions_merged(self):\n+        result = merge(\"Q3 Results\", \"Q4 Projections\")\n+        assert result == \"Q3 Results\\n\\nQ4 Projections\"\n+\n+\n+class TestMergeCaptionSubstringBug:\n+    \"\"\"These are the exact scenarios that the old substring check got wrong.\"\"\"\n+\n+    def test_shorter_caption_not_dropped_when_substring(self):\n+        # Bug: \"Meeting\" in \"Meeting agenda\" → True → caption was silently lost\n+        result = merge(\"Meeting agenda\", \"Meeting\")\n+        assert result == \"Meeting agenda\\n\\nMeeting\"\n+\n+    def test_longer_caption_not_dropped_when_contains_existing(self):\n+        # \"Revenue and Profit\" contains \"Revenue\", but they are different captions\n+        result = merge(\"Revenue\", \"Revenue and Profit\")\n+        assert result == \"Revenue\\n\\nRevenue and Profit\"\n+\n+    def test_prefix_caption_not_dropped(self):\n+        result = merge(\"Q3 Results - Revenue\", \"Q3 Results\")\n+        assert result == \"Q3 Results - Revenue\\n\\nQ3 Results\"\n+\n+\n+class TestMergeCaptionWhitespace:\n+    def test_trailing_space_treated_as_duplicate(self):\n+        assert merge(\"Revenue\", \"Revenue  \") == \"Revenue\"\n+\n+    def test_leading_space_treated_as_duplicate(self):\n+        assert merge(\"Revenue\", \"  Revenue\") == \"Revenue\"\n+\n+    def test_whitespace_only_new_text_not_added(self):\n+        # strip() makes it empty string → falsy check in callers guards this,\n+        # but _merge_caption itself: strip matches \"\" which is not in list → would merge.\n+        # Callers already guard with `if event.text:` so this is an edge case.\n+        result = merge(\"Revenue\", \"   \")\n+        # \"   \".strip() == \"\" → not in [\"Revenue\"] → gets merged (caller guards prevent this)\n+        assert \"\\n\\n\" in result or result == \"Revenue\"\n+\n+\n+class TestMergeCaptionMultipleItems:\n+    def test_three_unique_captions_all_present(self):\n+        text = merge(None, \"A\")\n+        text = merge(text, \"B\")\n+        text = merge(text, \"C\")\n+        assert text == \"A\\n\\nB\\n\\nC\"\n+\n+    def test_duplicate_in_middle_dropped(self):\n+        text = merge(None, \"A\")\n+        text = merge(text, \"B\")\n+        text = merge(text, \"A\")  # duplicate\n+        assert text == \"A\\n\\nB\"\n+\n+    def test_album_scenario_revenue_profit(self):\n+        # Album Item 1: \"Revenue and Profit\", Item 2: \"Revenue\"\n+        # Old bug: \"Revenue\" in [\"Revenue and Profit\"] → True → lost\n+        text = merge(None, \"Revenue and Profit\")\n+        text = merge(text, \"Revenue\")\n+        assert text == \"Revenue and Profit\\n\\nRevenue\""]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -553,7 +553,7 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t\n \n When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL.\n \n-Available providers: `auto`, `openrouter`, `nous`, `codex`, `copilot`, `anthropic`, `main`, `zai`, `kimi-coding`, `minimax`, and any provider registered in the [provider registry](/docs/reference/environment-variables).\n+Available providers: `auto`, `openrouter`, `nous`, `codex`, `copilot`, `anthropic`, `main`, `zai`, `kimi-coding`, `minimax`, any provider registered in the [provider registry](/docs/reference/environment-variables), or any named custom provider from your `custom_providers` list (e.g. `provider: \"beans\"`).\n \n ### Full auxiliary config reference\n ","@@ -704,7 +704,7 @@ auxiliary:\n     model: \"my-local-model\"\n ```\n \n-`provider: \"main\"` follows the same custom endpoint Hermes uses for normal chat. That endpoint can be set directly with `OPENAI_BASE_URL`, or saved once through `hermes model` and persisted in `config.yaml`.\n+`provider: \"main\"` uses whatever provider Hermes uses for normal chat — whether that's a named custom provider (e.g. `beans`), a built-in provider like `openrouter`, or a legacy `OPENAI_BASE_URL` endpoint.\n \n :::tip\n If you use Codex OAuth as your main model provider, vision works automatically — no extra configuration needed. Codex is included in the auto-detection chain for vision.\n"]}]},{"kind":"pr","number":19470,"title":"fix(agent): plumb main_runtime through vision aux resolution","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":19485,"title":"fix(auxiliary): resolve provider/model from live runtime, not stale config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b1f13a8c5f47933c94c065753ceff23507b61e56","number":0},{"kind":"pr","value":"#19470","number":19470}],"category":"partial_fix","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45531,"title":"fix: custom provider auxiliary client resolution failures","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5e116285465854b1d149247c84a4d2c2d031b386","number":0},{"kind":"commit","value":"92da7a9970548340f0d538ef94d7af543d1dc7a5","number":0},{"kind":"pr","value":"#59301","number":59301}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":45545,"title":"fix(auxiliary): reuse main_runtime credentials for named custom providers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59301","number":59301}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":59301,"title":"fix(auxiliary): reuse main_runtime credentials for named custom providers","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","tests/agent/test_auxiliary_named_custom_providers.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -4307,6 +4307,8 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\",\n \n     # ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ───────────\n     if provider == \"custom\":\n+        custom_base = \"\"\n+        custom_key = \"\"\n         if explicit_base_url:\n             custom_base = _to_openai_base_url(explicit_base_url).strip()\n             custom_key = (","@@ -4321,6 +4323,19 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\",\n                     \"but base_url is empty\"\n                 )\n                 return None, None\n+        elif main_runtime:\n+            # When main_runtime carries a concrete base_url + api_key for a\n+            # named custom provider (custom:<name>), use it directly instead\n+            # of re-resolving from the bare \"custom\" provider name.\n+            # Re-resolution loses the provider name and falls back to\n+            # OpenRouter or a wrong API-key provider — the main agent already\n+            # solved this, we just need to reuse its answer. (#45472)\n+            _main_base = str(main_runtime.get(\"base_url\") or \"\").strip().rstrip(\"/\")\n+            _main_key = str(main_runtime.get(\"api_key\") or \"\").strip()\n+            if _main_base and _main_key:\n+                custom_base = _main_base\n+                custom_key = _main_key\n+        if custom_base and custom_key:\n             final_model = _normalize_resolved_model(\n                 model or (main_runtime.get(\"model\") if main_runtime else None) or \"gpt-4o-mini\",\n                 provider,"]},{"file":"tests/agent/test_auxiliary_named_custom_providers.py","hunks":["@@ -491,3 +491,73 @@ def test_explicit_overrides_applied_on_api_key_branch(self, tmp_path, monkeypatc\n         assert isinstance(client, OpenAI)\n         assert \"override.example.com\" in str(client.base_url)\n         assert client.api_key == \"override-key\"\n+\n+\n+class TestResolveProviderClientMainRuntimeCustom:\n+    \"\"\"When the main agent uses a named custom provider (custom:<name>),\n+    resolve_provider_client('custom', ..., main_runtime=...) must reuse the\n+    main_runtime's base_url + api_key instead of re-resolving from the bare\n+    'custom' provider name.  Re-resolution loses the provider name and falls\n+    back to OpenRouter or a wrong API-key provider. (#45472)\"\"\"\n+\n+    def test_custom_provider_main_runtime_used_directly(self, tmp_path, monkeypatch):\n+        \"\"\"main_runtime with base_url + api_key for a named custom provider\n+        is used directly, bypassing the _try_custom_endpoint / API-key\n+        fallback chain.\"\"\"\n+        from agent.auxiliary_client import resolve_provider_client\n+        main_runtime = {\n+            \"provider\": \"custom\",\n+            \"base_url\": \"https://my-gateway.example.com/v1\",\n+            \"api_key\": \"***\",\n+            \"model\": \"glm-5.1\",\n+        }\n+        client, model = resolve_provider_client(\n+            \"custom\",\n+            model=\"explicit-glm-5.1\",\n+            main_runtime=main_runtime,\n+        )\n+        assert client is not None\n+        assert model == \"explicit-glm-5.1\"\n+        assert \"my-gateway.example.com\" in str(client.base_url)\n+        assert client.api_key == \"***\"\n+\n+    def test_custom_provider_main_runtime_no_credentials_falls_through(self, tmp_path, monkeypatch):\n+        \"\"\"When main_runtime has no base_url or no api_key, the existing\n+        _try_custom_endpoint / _resolve_api_key_provider fallback chain is\n+        still tried.\"\"\"\n+        # Ensure no env-provided credentials interfere\n+        monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"OPENAI_BASE_URL\", raising=False)\n+        monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"ANTHROPIC_API_KEY\", raising=False)\n+\n+        from agent.auxiliary_client import resolve_provider_client\n+        # main_runtime with key but no base_url → must fall through\n+        client, model = resolve_provider_client(\n+            \"custom\",\n+            main_runtime={\"api_key\": \"k\", \"base_url\": \"\"},\n+        )\n+        # Should fall through to _try_custom_endpoint → return None,None\n+        # because no OPENAI_BASE_URL is set and no custom endpoint is configured\n+        assert client is None\n+\n+    def test_custom_provider_main_runtime_respects_explicit_base_url(self, tmp_path):\n+        \"\"\"explicit_base_url still wins over main_runtime — the caller's\n+        explicit argument is the strongest signal.\"\"\"\n+        from agent.auxiliary_client import resolve_provider_client\n+        main_runtime = {\n+            \"base_url\": \"https://main-runtime.example.com/v1\",\n+            \"api_key\": \"[redacted:secret-assignment]\",\n+            \"model\": \"ignored-model\",\n+        }\n+        client, model = resolve_provider_client(\n+            \"custom\",\n+            model=\"explicit-model\",\n+            explicit_base_url=\"https://explicit.example.com/v1\",\n+            explicit_api_key=\"[redacted:secret-assignment]\",\n+            main_runtime=main_runtime,\n+        )\n+        assert client is not None\n+        assert model == \"explicit-model\"\n+        assert \"explicit.example.com\" in str(client.base_url)\n+        assert client.api_key == \"sk-explicit\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}