{"repo":"NousResearch/hermes-agent","public_id":"9318","display_label":"Auxiliary task API key resolution and credential lookup","summary":"These issues and fixes concern how auxiliary tasks (including vision) resolve API keys and provider identity when base_url, api_key, or named providers are set in various combinations. A recurring pattern involves credentials being dropped or misidentified, leading to 'no-key-required' fallbacks and 401 errors.","subsystem":"agent","counts":{"members":28,"open_issues":1,"open_issues_distinct":1,"open_prs":3,"closed_issues":9,"merged_prs":4,"attempt_prs":14},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":20215},"resolution":{"state":"partially_resolved","by":"pr-20215,pr-59207"},"freshness":{"state":"verified","last_verified_at":"2026-07-06T00:38:07+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.07386243386243384},"fix_edges":[{"issue_number":9315,"pr_number":21219,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":9318,"pr_number":21219,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":9318,"pr_number":59207,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":16829,"pr_number":20215,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":19083,"pr_number":21219,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":45276,"pr_number":55605,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":53687,"pr_number":55605,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":9318,"title":"[Bug]: Auxiliary client falls back to \"no-key-required\" when per-task custom base_url is set but api_key is empty","url":null,"state":"closed"},{"kind":"issue","number":16290,"title":"[Bug]: auxiliary.vision with provider=zai and custom base_url fails to auto-resolve ZAI_API_KEY","url":null,"state":"closed"},{"kind":"issue","number":16829,"title":"auxiliary.{task} with provider:auto + base_url set falls into 'custom' path with empty api_key, returns 401","url":null,"state":"closed"},{"kind":"issue","number":21121,"title":"[Bug]: auxiliary \"use as\" in dashboard copies base_url but omits api_key, causing 401 on authenticated custom endpoints","url":null,"state":"closed"},{"kind":"issue","number":26879,"title":"[Bug] auxiliary task provider identity lost when base_url + api_key are both set","url":null,"state":"open"},{"kind":"issue","number":34651,"title":"[Bug]: auxiliary vision returns 401 when provider has custom base_url — API key resolves to no-key-required","url":null,"state":"closed"},{"kind":"issue","number":39599,"title":"auxiliary vision: explicit base_url routes through generic \"custom\" branch, leaking main model name + OPENAI_API_KEY to the configured backend (Gemini)","url":null,"state":"closed"},{"kind":"issue","number":47741,"title":"Vision auxiliary with base_url set but no api_key → \"no-key-required\" → 401","url":null,"state":"closed"},{"kind":"issue","number":49119,"title":"auxiliary vision: api_key env var fallback skipped when base_url is set","url":null,"state":"closed"},{"kind":"issue","number":53687,"title":"Bug: auxiliary vision (and other tasks) get 401 when provider + base_url both set in config","url":null,"state":"closed"},{"kind":"pr","number":9386,"title":"Fix custom provider API key precedence","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#21219","number":21219},{"kind":"pr","value":"#21219","number":21219}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":16308,"title":"fix(aux-client): resolve named-provider credential when base_url overrides endpoint (#16290)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":16389,"title":"fix(auxiliary.vision): keep named-provider credential pool with custom base_url","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6cf7a9e330cadabd2a0b7ae21f25dc400fc3aa63","number":0},{"kind":"pr","value":"#16290","number":16290}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":16727,"title":"fix(auxiliary): resolve named provider credentials when base_url is set","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"6cf7a9e330ca","number":0},{"kind":"pr","value":"#16290","number":16290},{"kind":"pr","value":"#15275","number":15275},{"kind":"pr","value":"#16290","number":16290}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":16944,"title":"fix(auxiliary): avoid locking into custom path when api_key is empty","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9318","number":9318},{"kind":"pr","value":"#16829","number":16829}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":16950,"title":"fix(auxiliary_client): require api_key for custom provider path","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16944","number":16944},{"kind":"pr","value":"#9318","number":9318},{"kind":"pr","value":"#16308","number":16308}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":20139,"title":"fix(auxiliary): pass cfg_base_url and cfg_api_key when resolving task provider","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":20215,"title":"fix(auxiliary): avoid locking into custom path when api_key is empty (salvage #16944)","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"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -3132,8 +3132,14 @@ def _resolve_task_provider_model(\n \n     if task:\n         # Config.yaml is the primary source for per-task overrides.\n-        if cfg_base_url:\n+        if cfg_base_url and cfg_api_key:\n+            # Both base_url and api_key explicitly set → custom endpoint.\n             return \"custom\", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode\n+        if cfg_base_url and cfg_provider and cfg_provider != \"auto\":\n+            # base_url set without api_key but with a known provider — use\n+            # the provider so it can resolve credentials from env vars\n+            # (e.g. OPENROUTER_API_KEY) instead of locking into \"custom\".\n+            return cfg_provider, resolved_model, cfg_base_url, None, resolved_api_mode\n         if cfg_provider and cfg_provider != \"auto\":\n             return cfg_provider, resolved_model, None, None, resolved_api_mode\n \n"]}]},{"kind":"pr","number":21219,"title":"fix(credential_pool): resolve key mix-up when custom providers share base_url (salvage #19094)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/credential_pool.py","hermes_cli/runtime_provider.py","tests/agent/test_credential_pool.py"],"fix_hunks":[{"file":"agent/credential_pool.py","hunks":["@@ -305,14 +305,29 @@ def _iter_custom_providers(config: Optional[dict] = None):\n         yield _normalize_custom_pool_name(name), entry\n \n \n-def get_custom_provider_pool_key(base_url: str) -> Optional[str]:\n+def get_custom_provider_pool_key(base_url: str, provider_name: Optional[str] = None) -> Optional[str]:\n     \"\"\"Look up the custom_providers list in config.yaml and return 'custom:<name>' for a matching base_url.\n \n+    When provider_name is given, prefer matching by name first (solving the case where\n+    multiple custom providers share the same base_url but have different API keys).\n+    Falls back to base_url matching when no name match is found.\n+\n     Returns None if no match is found.\n     \"\"\"\n     if not base_url:\n         return None\n     normalized_url = base_url.strip().rstrip(\"/\")\n+\n+    # When a provider name is given, try to match by name first.\n+    # This fixes the P1 bug where two custom providers sharing the same\n+    # base_url always resolve to the first one's credentials.\n+    if provider_name:\n+        normalized_name = _normalize_custom_pool_name(provider_name)\n+        for norm_name, entry in _iter_custom_providers():\n+            if norm_name == normalized_name:\n+                return f\"{CUSTOM_POOL_PREFIX}{norm_name}\"\n+\n+    # Fall back to base_url matching (original behavior)\n     for norm_name, entry in _iter_custom_providers():\n         entry_url = str(entry.get(\"base_url\") or \"\").strip().rstrip(\"/\")\n         if entry_url and entry_url == normalized_url:"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -319,9 +319,10 @@ def _try_resolve_from_custom_pool(\n     base_url: str,\n     provider_label: str,\n     api_mode_override: Optional[str] = None,\n+    provider_name: Optional[str] = None,\n ) -> Optional[Dict[str, Any]]:\n     \"\"\"Check if a credential pool exists for a custom endpoint and return a runtime dict if so.\"\"\"\n-    pool_key = get_custom_provider_pool_key(base_url)\n+    pool_key = get_custom_provider_pool_key(base_url, provider_name=provider_name)\n     if not pool_key:\n         return None\n     try:","@@ -521,7 +522,7 @@ def _resolve_named_custom_runtime(\n         return None\n \n     # Check if a credential pool exists for this custom endpoint\n-    pool_result = _try_resolve_from_custom_pool(base_url, \"custom\", custom_provider.get(\"api_mode\"))\n+    pool_result = _try_resolve_from_custom_pool(base_url, \"custom\", custom_provider.get(\"api_mode\"), provider_name=custom_provider.get(\"name\"))\n     if pool_result:\n         # Propagate the model name even when using pooled credentials —\n         # the pool doesn't know about the custom_providers model field.","@@ -640,8 +641,11 @@ def _resolve_openrouter_runtime(\n \n     # For custom endpoints, check if a credential pool exists\n     if effective_provider == \"custom\" and base_url:\n+        # Pass requested_provider so pool lookup prefers name match over base_url,\n+        # fixing credential mix-ups when multiple custom providers share a base_url.\n         pool_result = _try_resolve_from_custom_pool(\n             base_url, effective_provider, _parse_api_mode(model_cfg.get(\"api_mode\")),\n+            provider_name=requested_provider if requested_norm != \"custom\" else None,\n         )\n         if pool_result:\n             return pool_result"]},{"file":"tests/agent/test_credential_pool.py","hunks":["@@ -924,6 +924,43 @@ def test_get_custom_provider_pool_key(tmp_path, monkeypatch):\n     assert get_custom_provider_pool_key(\"\") is None\n \n \n+def test_get_custom_provider_pool_key_prefers_name_over_base_url(tmp_path, monkeypatch):\n+    \"\"\"When two custom providers share the same base_url, provider_name resolves to the correct one.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    (tmp_path / \"hermes\").mkdir(parents=True, exist_ok=True)\n+    import yaml\n+    config_path = tmp_path / \"hermes\" / \"config.yaml\"\n+    config_path.write_text(yaml.dump({\n+        \"custom_providers\": [\n+            {\n+                \"name\": \"provider-a\",\n+                \"base_url\": \"http://gateway:8080/v1\",\n+                \"api_key\": \"sk-aaa\",\n+            },\n+            {\n+                \"name\": \"provider-b\",\n+                \"base_url\": \"http://gateway:8080/v1\",\n+                \"api_key\": \"sk-bbb\",\n+            },\n+        ]\n+    }))\n+\n+    from agent.credential_pool import get_custom_provider_pool_key\n+\n+    # Without provider_name, first match wins (backward compatible)\n+    assert get_custom_provider_pool_key(\"http://gateway:8080/v1\") == \"custom:provider-a\"\n+\n+    # With provider_name, exact name match wins regardless of order\n+    assert get_custom_provider_pool_key(\"http://gateway:8080/v1\", provider_name=\"provider-b\") == \"custom:provider-b\"\n+    assert get_custom_provider_pool_key(\"http://gateway:8080/v1\", provider_name=\"provider-a\") == \"custom:provider-a\"\n+\n+    # Name match with non-matching base_url still works via fallback\n+    assert get_custom_provider_pool_key(\"http://gateway:8080/v1\", provider_name=\"nonexistent\") == \"custom:provider-a\"\n+\n+    # Empty provider_name is same as None (backward compatible)\n+    assert get_custom_provider_pool_key(\"http://gateway:8080/v1\", provider_name=\"\") == \"custom:provider-a\"\n+\n+\n def test_list_custom_pool_providers(tmp_path, monkeypatch):\n     \"\"\"list_custom_pool_providers returns custom: pool keys from auth.json.\"\"\"\n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n"]}]},{"kind":"pr","number":21286,"title":"fix(agent): inherit model.api_key when auxiliary base_url matches main endpoint","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20139","number":20139},{"kind":"pr","value":"#17760","number":17760}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":26909,"title":"fix(auxiliary): preserve named provider on custom endpoint","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#9288","number":9288},{"kind":"pr","value":"#10823","number":10823},{"kind":"pr","value":"#16389","number":16389},{"kind":"pr","value":"#14718","number":14718},{"kind":"pr","value":"#16727","number":16727}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":34652,"title":"fix(auxiliary): preserve named provider identity when base_url is passed to _resolve_task_provider_model","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34651","number":34651},{"kind":"pr","value":"#16290","number":16290},{"kind":"pr","value":"#26879","number":26879},{"kind":"pr","value":"#16389","number":16389},{"kind":"pr","value":"#16727","number":16727},{"kind":"pr","value":"#26909","number":26909}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":49131,"title":"fix(agent): preserve vision provider for base_url fallback","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":53688,"title":"fix(auxiliary): preserve provider when base_url is passed explicitly","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a8841e2a6","number":0},{"kind":"pr","value":"#55605","number":55605},{"kind":"pr","value":"#39732","number":39732}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":55605,"title":"fix(aux): preserve provider identity for resolved endpoints","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","tests/agent/test_auxiliary_client.py","tests/run_agent/test_moa_loop_mode.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -5238,9 +5238,10 @@ def _resolve_task_provider_model(\n       3. \"auto\" (full auto-detection chain)\n \n     Returns (provider, model, base_url, api_key, api_mode) where model may\n-    be None (use provider default). When base_url is set, provider is forced\n-    to \"custom\" and the task uses that direct endpoint. api_mode is one of\n-    \"chat_completions\", \"codex_responses\", or None (auto-detect).\n+    be None (use provider default). A bare base_url is treated as custom, but\n+    a first-class provider plus base_url keeps the provider identity so its\n+    auth, transport, and request-shaping behavior still apply. api_mode is one\n+    of \"chat_completions\", \"codex_responses\", or None (auto-detect).\n     \"\"\"\n     cfg_provider = None\n     cfg_model = None","@@ -5273,11 +5274,35 @@ def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str])\n             return prov, existing_base\n         return \"custom\", existing_base or target_base\n \n+    def _preserve_provider_with_base_url(prov: Optional[str]) -> bool:\n+        normalized = str(prov or \"\").strip().lower()\n+        if normalized in {\"\", \"auto\", \"custom\"} or normalized.startswith(\"custom:\"):\n+            return False\n+        try:\n+            from hermes_cli.providers import get_provider\n+\n+            return get_provider(normalized) is not None\n+        except Exception:\n+            # Keep the high-risk provider-backed routes safe even if provider\n+            # catalog loading is unavailable during early import/test paths.\n+            return normalized in {\n+                \"anthropic\",\n+                \"copilot\",\n+                \"copilot-acp\",\n+                \"minimax-oauth\",\n+                \"nous\",\n+                \"openai-codex\",\n+                \"qwen-oauth\",\n+                \"xai-oauth\",\n+            }\n+\n     if provider:\n         provider, base_url = _expand_direct_api_alias(provider, base_url)\n     if cfg_provider:\n         cfg_provider, cfg_base_url = _expand_direct_api_alias(cfg_provider, cfg_base_url)\n \n+    if base_url and _preserve_provider_with_base_url(provider):\n+        return provider, resolved_model, base_url, api_key, resolved_api_mode\n     if base_url:\n         return \"custom\", resolved_model, base_url, api_key, resolved_api_mode\n     if provider:"]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -31,6 +31,7 @@\n     _OPENROUTER_MODEL,\n     OPENROUTER_BASE_URL,\n     _resolve_auto,\n+    _resolve_task_provider_model,\n     _resolve_xai_oauth_for_aux,\n     _CodexCompletionsAdapter,\n )","@@ -108,6 +109,65 @@ def test_uses_max_completion_tokens_for_github_copilot_custom_base_path(self):\n             assert auxiliary_max_tokens_param(2048) == {\"max_completion_tokens\": 2048}\n \n \n+class TestResolveTaskProviderModel:\n+    @pytest.mark.parametrize(\n+        \"provider\",\n+        [\n+            \"anthropic\",\n+            \"minimax-oauth\",\n+            \"nous\",\n+            \"openai-codex\",\n+            \"qwen-oauth\",\n+            \"xai-oauth\",\n+        ],\n+    )\n+    def test_explicit_base_url_preserves_first_class_provider_identity(self, provider):\n+        resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(\n+            task=\"moa_reference\",\n+            provider=provider,\n+            model=\"test-model\",\n+            base_url=\"https://provider.example/v1\",\n+            api_key=\"[redacted:secret-assignment]\",\n+        )\n+\n+        assert resolved_provider == provider\n+        assert model == \"test-model\"\n+        assert base_url == \"https://provider.example/v1\"\n+        assert api_key == \"resolved-token\"\n+        assert api_mode is None\n+\n+    @pytest.mark.parametrize(\"provider\", [\"\", \"auto\", \"custom\", \"custom:local\", \"unknown-provider\"])\n+    def test_explicit_base_url_without_first_class_provider_routes_as_custom(self, provider):\n+        resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(\n+            task=\"moa_reference\",\n+            provider=provider,\n+            model=\"test-model\",\n+            base_url=\"https://provider.example/v1\",\n+            api_key=\"[redacted:secret-assignment]\",\n+        )\n+\n+        assert resolved_provider == \"custom\"\n+        assert model == \"test-model\"\n+        assert base_url == \"https://provider.example/v1\"\n+        assert api_key == \"resolved-token\"\n+        assert api_mode is None\n+\n+    def test_direct_openai_alias_with_base_url_still_routes_as_custom(self):\n+        resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(\n+            task=\"vision\",\n+            provider=\"openai\",\n+            model=\"gpt-4o-mini\",\n+            base_url=\"https://proxy.example/v1\",\n+            api_key=\"[redacted:secret-assignment]\",\n+        )\n+\n+        assert resolved_provider == \"custom\"\n+        assert model == \"gpt-4o-mini\"\n+        assert base_url == \"https://proxy.example/v1\"\n+        assert api_key == \"sk-test\"\n+        assert api_mode is None\n+\n+\n class TestBuildCallKwargsMaxTokens:\n     \"\"\"_build_call_kwargs should not cap output by default (#34530).\n "]},{"file":"tests/run_agent/test_moa_loop_mode.py","hunks":["@@ -1,6 +1,8 @@\n from types import SimpleNamespace\n from unittest.mock import MagicMock\n \n+import pytest\n+\n from run_agent import AIAgent\n \n ","@@ -198,6 +200,47 @@ def fake_resolve(*, requested, target_model=None):\n     assert rt == {\"provider\": \"openai-codex\", \"model\": \"gpt-5.5\"}\n \n \n[redacted:email](\"provider\", [\"anthropic\", \"minimax-oauth\", \"qwen-oauth\"])\n+def test_moa_provider_backed_slot_survives_aux_resolution(monkeypatch, provider):\n+    \"\"\"MoA can pass resolved endpoints for provider-backed slots without\n+    call_llm flattening them to generic custom endpoints.\n+\n+    ``_slot_runtime`` resolves a provider-backed slot to ``provider`` plus a\n+    concrete ``base_url``/``api_key``/``api_mode``; ``_run_reference`` then\n+    forwards that dict to ``call_llm``. ``call_llm`` resolves the routing tuple\n+    via ``_resolve_task_provider_model`` (which takes everything except\n+    ``api_mode``, handled separately). The provider identity must survive that\n+    resolution rather than being flattened to ``custom``.\n+    \"\"\"\n+    from agent import moa_loop\n+    from agent.auxiliary_client import _resolve_task_provider_model\n+\n+    def fake_resolve(*, requested, target_model=None):\n+        return {\n+            \"provider\": requested,\n+            \"api_mode\": \"anthropic_messages\",\n+            \"base_url\": f\"https://{requested}.example/v1\",\n+            \"api_key\": f\"token-for-{requested}\",\n+        }\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.runtime_provider.resolve_runtime_provider\", fake_resolve\n+    )\n+\n+    rt = moa_loop._slot_runtime({\"provider\": provider, \"model\": \"test-model\"})\n+    # api_mode is forwarded to call_llm directly, not to _resolve_task_provider_model.\n+    resolver_kwargs = {k: v for k, v in rt.items() if k != \"api_mode\"}\n+    resolved_provider, model, base_url, api_key, _mode = _resolve_task_provider_model(\n+        task=\"moa_reference\",\n+        **resolver_kwargs,\n+    )\n+\n+    assert resolved_provider == provider\n+    assert model == \"test-model\"\n+    assert base_url == f\"https://{provider}.example/v1\"\n+    assert api_key == f\"token-for-{provider}\"\n+\n+\n def test_moa_slot_runtime_falls_back_on_resolution_error(monkeypatch):\n     \"\"\"A slot whose provider can't be resolved still attempts the call with the\n     bare provider/model rather than aborting the whole MoA turn.\"\"\"\n"]}]},{"kind":"pr","number":55911,"title":"fix(auxiliary): inherit model.api_key for custom endpoint when per-task key is empty (#9318)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59207","number":59207}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":59207,"title":"fix(auxiliary): inherit model.api_key for same-host custom endpoints when per-task key is empty","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","tests/agent/test_auxiliary_client.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -2003,6 +2003,76 @@ def _read_main_provider() -> str:\n     return \"\"\n \n \n+def _read_main_api_key() -> str:\n+    \"\"\"Read the user's main model API key from the runtime override or config.\n+\n+    Mirrors ``_read_main_model`` / ``_read_main_provider``: checks the\n+    process-local ``_RUNTIME_MAIN_API_KEY`` override first (set by\n+    ``set_runtime_main`` when an AIAgent is active), then falls back to\n+    ``model.api_key`` in config.yaml.\n+\n+    Used by the ``custom`` provider fallback chain so that auxiliary tasks\n+    configured with an explicit ``base_url`` but empty ``api_key`` inherit\n+    the main model's credentials instead of falling to ``no-key-required``\n+    (issue #9318).\n+    \"\"\"\n+    override = _RUNTIME_MAIN_API_KEY\n+    if isinstance(override, str) and override.strip():\n+        return override.strip()\n+    try:\n+        from hermes_cli.config import load_config\n+        cfg = load_config()\n+        model_cfg = cfg.get(\"model\", {})\n+        if isinstance(model_cfg, dict):\n+            key = model_cfg.get(\"api_key\", \"\")\n+            if isinstance(key, str) and key.strip():\n+                return key.strip()\n+    except Exception:\n+        pass\n+    return \"\"\n+\n+\n+def _read_main_base_url() -> str:\n+    \"\"\"Read the main model's base_url from the runtime override or config.\n+\n+    Same override-then-config pattern as ``_read_main_api_key``.\n+    \"\"\"\n+    override = _RUNTIME_MAIN_BASE_URL\n+    if isinstance(override, str) and override.strip():\n+        return override.strip()\n+    try:\n+        from hermes_cli.config import load_config\n+        cfg = load_config()\n+        model_cfg = cfg.get(\"model\", {})\n+        if isinstance(model_cfg, dict):\n+            base = model_cfg.get(\"base_url\", \"\")\n+            if isinstance(base, str) and base.strip():\n+                return base.strip()\n+    except Exception:\n+        pass\n+    return \"\"\n+\n+\n+def _read_main_api_key_if_same_host(aux_base_url: str) -> str:\n+    \"\"\"Return the main api_key only when *aux_base_url* points at the same\n+    host as the main model's base_url.\n+\n+    The #9318 use case is an auxiliary task sharing the main model's\n+    self-hosted gateway (same host, different model) with an empty per-task\n+    api_key. Inheriting unconditionally would send the main credential to\n+    ANY host a misconfigured aux base_url names — a cross-host credential\n+    leak. A host mismatch keeps the previous fail-safe behavior\n+    (``no-key-required`` → 401).\n+    \"\"\"\n+    aux_host = base_url_hostname(aux_base_url)\n+    if not aux_host:\n+        return \"\"\n+    main_host = base_url_hostname(_read_main_base_url())\n+    if not main_host or aux_host != main_host:\n+        return \"\"\n+    return _read_main_api_key()\n+\n+\n # Process-local override set by AIAgent at session/turn start. Single-threaded\n # per turn — no lock needed. Cleared by ``clear_runtime_main()``.\n _RUNTIME_MAIN_PROVIDER: str = \"\"","@@ -4242,6 +4312,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\",\n             custom_key = (\n                 (explicit_api_key or \"\").strip()\n                 or os.getenv(\"OPENAI_API_KEY\", \"\").strip()\n+                or _read_main_api_key_if_same_host(custom_base)\n                 or \"no-key-required\"  # local servers don't need auth\n             )\n             if not custom_base:"]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -5185,3 +5185,187 @@ def test_compression_task_uses_minimum_context_constant(self):\n         # Empty / unknown tasks have no minimum\n         assert _task_minimum_context_length(\"\") is None\n         assert _task_minimum_context_length(None) is None\n+\n+\n+class TestCustomEndpointApiKeyInheritance:\n+    \"\"\"Issue #9318: when an auxiliary task uses provider=custom with an\n+    explicit base_url but empty api_key, the custom_key fallback chain must\n+    inherit ``model.api_key`` from config.yaml before falling to the\n+    ``no-key-required`` placeholder.\n+\n+    Without this fix, users on self-hosted gateways who share the same\n+    endpoint+credentials for both the main model and auxiliary tasks get 401\n+    auth errors because the placeholder key is sent instead of the real one.\n+\n+    Inheritance is host-gated: the main key is only inherited when the aux\n+    base_url points at the same host as the main model's base_url, so a\n+    misconfigured aux endpoint cannot leak the main credential cross-host.\n+    \"\"\"\n+\n+    def test_inherits_main_api_key_when_aux_key_empty(self, monkeypatch):\n+        \"\"\"RED→GREEN: explicit_api_key is None, OPENAI_API_KEY unset →\n+        model.api_key from config.yaml must be used (same-host gateway).\"\"\"\n+        import agent.auxiliary_client as ac\n+\n+        monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"OPENAI_BASE_URL\", raising=False)\n+\n+        fake_config = {\n+            \"model\": {\n+                \"api_key\": \"sk-main-config-key\",\n+                \"base_url\": \"https://gw.example.com/v1\",\n+                \"default\": \"main-model\",\n+            }\n+        }\n+        captured: dict = {}\n+\n+        def _capture_create(**kwargs):\n+            captured.update(kwargs)\n+            return MagicMock()\n+\n+        with patch(\"hermes_cli.config.load_config\", return_value=fake_config), \\\n+             patch.object(ac, \"_create_openai_client\", side_effect=_capture_create):\n+            client, model = resolve_provider_client(\n+                \"custom\",\n+                model=\"test-model\",\n+                explicit_base_url=\"https://gw.example.com/v1\",\n+                explicit_api_key=None,\n+            )\n+\n+        assert captured.get(\"api_key\") == \"sk-main-config-key\", (\n+            \"Custom endpoint with empty api_key should inherit \"\n+            \"model.api_key from config, got: \"\n+            + repr(captured.get(\"api_key\"))\n+        )\n+\n+    def test_explicit_api_key_takes_precedence(self, monkeypatch):\n+        \"\"\"explicit_api_key wins over config model.api_key.\"\"\"\n+        import agent.auxiliary_client as ac\n+\n+        monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+\n+        fake_config = {\"model\": {\"api_key\": \"sk-main-config-key\"}}\n+        captured: dict = {}\n+\n+        def _capture_create(**kwargs):\n+            captured.update(kwargs)\n+            return MagicMock()\n+\n+        with patch(\"hermes_cli.config.load_config\", return_value=fake_config), \\\n+             patch.object(ac, \"_create_openai_client\", side_effect=_capture_create):\n+            client, model = resolve_provider_client(\n+                \"custom\",\n+                model=\"test-model\",\n+                explicit_base_url=\"https://gw.example.com/v1\",\n+                explicit_api_key=\"[redacted:secret-assignment]\",\n+            )\n+\n+        assert captured.get(\"api_key\") == \"sk-explicit\"\n+\n+    def test_local_server_falls_to_no_key_required(self, monkeypatch):\n+        \"\"\"When no key is available anywhere (explicit, env, config), fall\n+        back to ``no-key-required`` for local servers (Ollama, etc.).\"\"\"\n+        import agent.auxiliary_client as ac\n+\n+        monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+\n+        fake_config = {\"model\": {}}  # no api_key configured\n+        captured: dict = {}\n+\n+        def _capture_create(**kwargs):\n+            captured.update(kwargs)\n+            return MagicMock()\n+\n+        with patch(\"hermes_cli.config.load_config\", return_value=fake_config), \\\n+             patch.object(ac, \"_create_openai_client\", side_effect=_capture_create):\n+            client, model = resolve_provider_client(\n+                \"custom\",\n+                model=\"test-model\",\n+                explicit_base_url=\"http://localhost:11434/v1\",\n+                explicit_api_key=None,\n+            )\n+\n+        assert captured.get(\"api_key\") == \"no-key-required\"\n+\n+    def test_runtime_override_key_is_used(self, monkeypatch):\n+        \"\"\"When _RUNTIME_MAIN_API_KEY is set (by set_runtime_main), it takes\n+        precedence over config.yaml for the custom endpoint key.\"\"\"\n+        import agent.auxiliary_client as ac\n+\n+        monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+\n+        captured: dict = {}\n+\n+        def _capture_create(**kwargs):\n+            captured.update(kwargs)\n+            return MagicMock()\n+\n+        with patch.object(ac, \"_RUNTIME_MAIN_API_KEY\", \"sk-runtime-key\"), \\\n+             patch.object(ac, \"_RUNTIME_MAIN_BASE_URL\", \"https://gw.example.com/v1\"), \\\n+             patch(\"hermes_cli.config.load_config\", return_value={\"model\": {}}), \\\n+             patch.object(ac, \"_create_openai_client\", side_effect=_capture_create):\n+            client, model = resolve_provider_client(\n+                \"custom\",\n+                model=\"test-model\",\n+                explicit_base_url=\"https://gw.example.com/v1\",\n+                explicit_api_key=None,\n+            )\n+\n+        assert captured.get(\"api_key\") == \"sk-runtime-key\"\n+\n+    def test_cross_host_aux_endpoint_does_not_inherit_main_key(self, monkeypatch):\n+        \"\"\"An aux base_url on a DIFFERENT host than the main model must NOT\n+        inherit model.api_key — that would leak the main credential to\n+        whatever host a misconfigured aux endpoint names. Falls back to the\n+        fail-safe no-key-required placeholder instead.\"\"\"\n+        import agent.auxiliary_client as ac\n+\n+        monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+\n+        fake_config = {\n+            \"model\": {\n+                \"api_key\": \"sk-main-config-key\",\n+                \"base_url\": \"https://gw.example.com/v1\",\n+            }\n+        }\n+        captured: dict = {}\n+\n+        def _capture_create(**kwargs):\n+            captured.update(kwargs)\n+            return MagicMock()\n+\n+        with patch(\"hermes_cli.config.load_config\", return_value=fake_config), \\\n+             patch.object(ac, \"_create_openai_client\", side_effect=_capture_create):\n+            client, model = resolve_provider_client(\n+                \"custom\",\n+                model=\"test-model\",\n+                explicit_base_url=\"https://other-host.example.net/v1\",\n+                explicit_api_key=None,\n+            )\n+\n+        assert captured.get(\"api_key\") == \"no-key-required\"\n+\n+    def test_no_main_base_url_does_not_inherit_main_key(self, monkeypatch):\n+        \"\"\"When the main model has no base_url (e.g. a first-class provider),\n+        there is no 'same gateway' to match — do not inherit the key.\"\"\"\n+        import agent.auxiliary_client as ac\n+\n+        monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+\n+        fake_config = {\"model\": {\"api_key\": \"sk-main-config-key\"}}\n+        captured: dict = {}\n+\n+        def _capture_create(**kwargs):\n+            captured.update(kwargs)\n+            return MagicMock()\n+\n+        with patch(\"hermes_cli.config.load_config\", return_value=fake_config), \\\n+             patch.object(ac, \"_create_openai_client\", side_effect=_capture_create):\n+            client, model = resolve_provider_client(\n+                \"custom\",\n+                model=\"test-model\",\n+                explicit_base_url=\"https://gw.example.com/v1\",\n+                explicit_api_key=None,\n+            )\n+\n+        assert captured.get(\"api_key\") == \"no-key-required\"\n"]}]},{"kind":"pr","number":106916,"title":"fix(agent): lift named custom provider key when a task base_url flattens it to bare custom","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#34651","number":34651},{"kind":"pr","value":"#96232","number":96232},{"kind":"pr","value":"#34651","number":34651},{"kind":"pr","value":"#96232","number":96232}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}