Auxiliary task API key resolution and credential lookup
NousResearch/hermes-agent · cluster 9318 · agent
Data as of
4 open items. Fix merged in #20215. Check it covers your case.
The short answer
- Known?
- Yes. 28 related items grouped here, 9 issues already closed.
- Fix event
- A fix was merged to main in PR #20215. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #20215, #59207. That is separate from the fix event. - Still open
- 1 issue and 3 PRs.
Next step: read #20215, then check whether your report is one of the 1 still open.
Read #20215Pick up a Lab taskThese 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.
Open issues1
- Issue#26879[Bug] auxiliary task provider identity lost when base_url + api_key are both set
Open PRs3
Fixes on record
-
#20215 fix(auxiliary): avoid locking into custom path when api_key is empty (salvage #16944)
files:
agent/auxiliary_client.pyShow the diff
-
agent/auxiliary_client.py@@ -3132,8 +3132,14 @@ def _resolve_task_provider_model( if task: # Config.yaml is the primary source for per-task overrides. - if cfg_base_url: + if cfg_base_url and cfg_api_key: + # Both base_url and api_key explicitly set → custom endpoint. return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode + if cfg_base_url and cfg_provider and cfg_provider != "auto": + # base_url set without api_key but with a known provider — use + # the provider so it can resolve credentials from env vars + # (e.g. OPENROUTER_API_KEY) instead of locking into "custom". + return cfg_provider, resolved_model, cfg_base_url, None, resolved_api_mode if cfg_provider and cfg_provider != "auto": return cfg_provider, resolved_model, None, None, resolved_api_mode
-
-
#21219 fix(credential_pool): resolve key mix-up when custom providers share base_url (salvage #19094)
files:
agent/credential_pool.py,hermes_cli/runtime_provider.py,tests/agent/test_credential_pool.pyShow the diff
-
agent/credential_pool.py@@ -305,14 +305,29 @@ def _iter_custom_providers(config: Optional[dict] = None): yield _normalize_custom_pool_name(name), entry -def get_custom_provider_pool_key(base_url: str) -> Optional[str]: +def get_custom_provider_pool_key(base_url: str, provider_name: Optional[str] = None) -> Optional[str]: """Look up the custom_providers list in config.yaml and return 'custom:<name>' for a matching base_url. + When provider_name is given, prefer matching by name first (solving the case where + multiple custom providers share the same base_url but have different API keys). + Falls back to base_url matching when no name match is found. + Returns None if no match is found. """ if not base_url: return None normalized_url = base_url.strip().rstrip("/") + + # When a provider name is given, try to match by name first. + # This fixes the P1 bug where two custom providers sharing the same + # base_url always resolve to the first one's credentials. + if provider_name: + normalized_name = _normalize_custom_pool_name(provider_name) + for norm_name, entry in _iter_custom_providers(): + if norm_name == normalized_name: + return f"{CUSTOM_POOL_PREFIX}{norm_name}" + + # Fall back to base_url matching (original behavior) for norm_name, entry in _iter_custom_providers(): entry_url = str(entry.get("base_url") or "").strip().rstrip("/") if entry_url and entry_url == normalized_url: -
hermes_cli/runtime_provider.py@@ -319,9 +319,10 @@ def _try_resolve_from_custom_pool( base_url: str, provider_label: str, api_mode_override: Optional[str] = None, + provider_name: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Check if a credential pool exists for a custom endpoint and return a runtime dict if so.""" - pool_key = get_custom_provider_pool_key(base_url) + pool_key = get_custom_provider_pool_key(base_url, provider_name=provider_name) if not pool_key: return None try: @@ -521,7 +522,7 @@ def _resolve_named_custom_runtime( return None # Check if a credential pool exists for this custom endpoint - pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode")) + pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode"), provider_name=custom_provider.get("name")) if pool_result: # Propagate the model name even when using pooled credentials — # the pool doesn't know about the custom_providers model field. @@ -640,8 +641,11 @@ def _resolve_openrouter_runtime( # For custom endpoints, check if a credential pool exists if effective_provider == "custom" and base_url: + # Pass requested_provider so pool lookup prefers name match over base_url, + # fixing credential mix-ups when multiple custom providers share a base_url. pool_result = _try_resolve_from_custom_pool( base_url, effective_provider, _parse_api_mode(model_cfg.get("api_mode")), + provider_name=requested_provider if requested_norm != "custom" else None, ) if pool_result: return pool_result -
tests/agent/test_credential_pool.py@@ -924,6 +924,43 @@ def test_get_custom_provider_pool_key(tmp_path, monkeypatch): assert get_custom_provider_pool_key("") is None +def test_get_custom_provider_pool_key_prefers_name_over_base_url(tmp_path, monkeypatch): + """When two custom providers share the same base_url, provider_name resolves to the correct one.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + import yaml + config_path = tmp_path / "hermes" / "config.yaml" + config_path.write_text(yaml.dump({ + "custom_providers": [ + { + "name": "provider-a", + "base_url": "http://gateway:8080/v1", + "api_key": "sk-aaa", + }, + { + "name": "provider-b", + "base_url": "http://gateway:8080/v1", + "api_key": "sk-bbb", + }, + ] + })) + + from agent.credential_pool import get_custom_provider_pool_key + + # Without provider_name, first match wins (backward compatible) + assert get_custom_provider_pool_key("http://gateway:8080/v1") == "custom:provider-a" + + # With provider_name, exact name match wins regardless of order + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-b") == "custom:provider-b" + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-a") == "custom:provider-a" + + # Name match with non-matching base_url still works via fallback + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="nonexistent") == "custom:provider-a" + + # Empty provider_name is same as None (backward compatible) + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="") == "custom:provider-a" + + def test_list_custom_pool_providers(tmp_path, monkeypatch): """list_custom_pool_providers returns custom: pool keys from auth.json.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
-
-
#55605 fix(aux): preserve provider identity for resolved endpoints
files:
agent/auxiliary_client.py,tests/agent/test_auxiliary_client.py,tests/run_agent/test_moa_loop_mode.pyShow the diff
-
agent/auxiliary_client.py@@ -5238,9 +5238,10 @@ def _resolve_task_provider_model( 3. "auto" (full auto-detection chain) Returns (provider, model, base_url, api_key, api_mode) where model may - be None (use provider default). When base_url is set, provider is forced - to "custom" and the task uses that direct endpoint. api_mode is one of - "chat_completions", "codex_responses", or None (auto-detect). + be None (use provider default). A bare base_url is treated as custom, but + a first-class provider plus base_url keeps the provider identity so its + auth, transport, and request-shaping behavior still apply. api_mode is one + of "chat_completions", "codex_responses", or None (auto-detect). """ cfg_provider = None cfg_model = None @@ -5273,11 +5274,35 @@ def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str]) return prov, existing_base return "custom", existing_base or target_base + def _preserve_provider_with_base_url(prov: Optional[str]) -> bool: + normalized = str(prov or "").strip().lower() + if normalized in {"", "auto", "custom"} or normalized.startswith("custom:"): + return False + try: + from hermes_cli.providers import get_provider + + return get_provider(normalized) is not None + except Exception: + # Keep the high-risk provider-backed routes safe even if provider + # catalog loading is unavailable during early import/test paths. + return normalized in { + "anthropic", + "copilot", + "copilot-acp", + "minimax-oauth", + "nous", + "openai-codex", + "qwen-oauth", + "xai-oauth", + } + if provider: provider, base_url = _expand_direct_api_alias(provider, base_url) if cfg_provider: cfg_provider, cfg_base_url = _expand_direct_api_alias(cfg_provider, cfg_base_url) + if base_url and _preserve_provider_with_base_url(provider): + return provider, resolved_model, base_url, api_key, resolved_api_mode if base_url: return "custom", resolved_model, base_url, api_key, resolved_api_mode if provider: -
tests/agent/test_auxiliary_client.py@@ -31,6 +31,7 @@ _OPENROUTER_MODEL, OPENROUTER_BASE_URL, _resolve_auto, + _resolve_task_provider_model, _resolve_xai_oauth_for_aux, _CodexCompletionsAdapter, ) @@ -108,6 +109,65 @@ def test_uses_max_completion_tokens_for_github_copilot_custom_base_path(self): assert auxiliary_max_tokens_param(2048) == {"max_completion_tokens": 2048} +class TestResolveTaskProviderModel: + @pytest.mark.parametrize( + "provider", + [ + "anthropic", + "minimax-oauth", + "nous", + "openai-codex", + "qwen-oauth", + "xai-oauth", + ], + ) + def test_explicit_base_url_preserves_first_class_provider_identity(self, provider): + resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model( + task="moa_reference", + provider=provider, + model="test-model", + base_url="https://provider.example/v1", + api_key="[redacted:secret-assignment]", + ) + + assert resolved_provider == provider + assert model == "test-model" + assert base_url == "https://provider.example/v1" + assert api_key == "resolved-token" + assert api_mode is None + + @pytest.mark.parametrize("provider", ["", "auto", "custom", "custom:local", "unknown-provider"]) + def test_explicit_base_url_without_first_class_provider_routes_as_custom(self, provider): + resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model( + task="moa_reference", + provider=provider, + model="test-model", + base_url="https://provider.example/v1", + api_key="[redacted:secret-assignment]", + ) + + assert resolved_provider == "custom" + assert model == "test-model" + assert base_url == "https://provider.example/v1" + assert api_key == "resolved-token" + assert api_mode is None + + def test_direct_openai_alias_with_base_url_still_routes_as_custom(self): + resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model( + task="vision", + provider="openai", + model="gpt-4o-mini", + base_url="https://proxy.example/v1", + api_key="[redacted:secret-assignment]", + ) + + assert resolved_provider == "custom" + assert model == "gpt-4o-mini" + assert base_url == "https://proxy.example/v1" + assert api_key == "sk-test" + assert api_mode is None + + class TestBuildCallKwargsMaxTokens: """_build_call_kwargs should not cap output by default (#34530). -
tests/run_agent/test_moa_loop_mode.py@@ -1,6 +1,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import pytest + from run_agent import AIAgent @@ -198,6 +200,47 @@ def fake_resolve(*, requested, target_model=None): assert rt == {"provider": "openai-codex", "model": "gpt-5.5"} [redacted:email]("provider", ["anthropic", "minimax-oauth", "qwen-oauth"]) +def test_moa_provider_backed_slot_survives_aux_resolution(monkeypatch, provider): + """MoA can pass resolved endpoints for provider-backed slots without + call_llm flattening them to generic custom endpoints. + + ``_slot_runtime`` resolves a provider-backed slot to ``provider`` plus a + concrete ``base_url``/``api_key``/``api_mode``; ``_run_reference`` then + forwards that dict to ``call_llm``. ``call_llm`` resolves the routing tuple + via ``_resolve_task_provider_model`` (which takes everything except + ``api_mode``, handled separately). The provider identity must survive that + resolution rather than being flattened to ``custom``. + """ + from agent import moa_loop + from agent.auxiliary_client import _resolve_task_provider_model + + def fake_resolve(*, requested, target_model=None): + return { + "provider": requested, + "api_mode": "anthropic_messages", + "base_url": f"https://{requested}.example/v1", + "api_key": f"token-for-{requested}", + } + + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", fake_resolve + ) + + rt = moa_loop._slot_runtime({"provider": provider, "model": "test-model"}) + # api_mode is forwarded to call_llm directly, not to _resolve_task_provider_model. + resolver_kwargs = {k: v for k, v in rt.items() if k != "api_mode"} + resolved_provider, model, base_url, api_key, _mode = _resolve_task_provider_model( + task="moa_reference", + **resolver_kwargs, + ) + + assert resolved_provider == provider + assert model == "test-model" + assert base_url == f"https://{provider}.example/v1" + assert api_key == f"token-for-{provider}" + + def test_moa_slot_runtime_falls_back_on_resolution_error(monkeypatch): """A slot whose provider can't be resolved still attempts the call with the bare provider/model rather than aborting the whole MoA turn."""
-
-
#59207 fix(auxiliary): inherit model.api_key for same-host custom endpoints when per-task key is empty
files:
agent/auxiliary_client.py,tests/agent/test_auxiliary_client.pyShow the diff
-
agent/auxiliary_client.py@@ -2003,6 +2003,76 @@ def _read_main_provider() -> str: return "" +def _read_main_api_key() -> str: + """Read the user's main model API key from the runtime override or config. + + Mirrors ``_read_main_model`` / ``_read_main_provider``: checks the + process-local ``_RUNTIME_MAIN_API_KEY`` override first (set by + ``set_runtime_main`` when an AIAgent is active), then falls back to + ``model.api_key`` in config.yaml. + + Used by the ``custom`` provider fallback chain so that auxiliary tasks + configured with an explicit ``base_url`` but empty ``api_key`` inherit + the main model's credentials instead of falling to ``no-key-required`` + (issue #9318). + """ + override = _RUNTIME_MAIN_API_KEY + if isinstance(override, str) and override.strip(): + return override.strip() + try: + from hermes_cli.config import load_config + cfg = load_config() + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + key = model_cfg.get("api_key", "") + if isinstance(key, str) and key.strip(): + return key.strip() + except Exception: + pass + return "" + + +def _read_main_base_url() -> str: + """Read the main model's base_url from the runtime override or config. + + Same override-then-config pattern as ``_read_main_api_key``. + """ + override = _RUNTIME_MAIN_BASE_URL + if isinstance(override, str) and override.strip(): + return override.strip() + try: + from hermes_cli.config import load_config + cfg = load_config() + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + base = model_cfg.get("base_url", "") + if isinstance(base, str) and base.strip(): + return base.strip() + except Exception: + pass + return "" + + +def _read_main_api_key_if_same_host(aux_base_url: str) -> str: + """Return the main api_key only when *aux_base_url* points at the same + host as the main model's base_url. + + The #9318 use case is an auxiliary task sharing the main model's + self-hosted gateway (same host, different model) with an empty per-task + api_key. Inheriting unconditionally would send the main credential to + ANY host a misconfigured aux base_url names — a cross-host credential + leak. A host mismatch keeps the previous fail-safe behavior + (``no-key-required`` → 401). + """ + aux_host = base_url_hostname(aux_base_url) + if not aux_host: + return "" + main_host = base_url_hostname(_read_main_base_url()) + if not main_host or aux_host != main_host: + return "" + return _read_main_api_key() + + # Process-local override set by AIAgent at session/turn start. Single-threaded # per turn — no lock needed. Cleared by ``clear_runtime_main()``. _RUNTIME_MAIN_PROVIDER: str = "" @@ -4242,6 +4312,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", custom_key = ( (explicit_api_key or "").strip() or os.getenv("OPENAI_API_KEY", "").strip() + or _read_main_api_key_if_same_host(custom_base) or "no-key-required" # local servers don't need auth ) if not custom_base: -
tests/agent/test_auxiliary_client.py@@ -5185,3 +5185,187 @@ def test_compression_task_uses_minimum_context_constant(self): # Empty / unknown tasks have no minimum assert _task_minimum_context_length("") is None assert _task_minimum_context_length(None) is None + + +class TestCustomEndpointApiKeyInheritance: + """Issue #9318: when an auxiliary task uses provider=custom with an + explicit base_url but empty api_key, the custom_key fallback chain must + inherit ``model.api_key`` from config.yaml before falling to the + ``no-key-required`` placeholder. + + Without this fix, users on self-hosted gateways who share the same + endpoint+credentials for both the main model and auxiliary tasks get 401 + auth errors because the placeholder key is sent instead of the real one. + + Inheritance is host-gated: the main key is only inherited when the aux + base_url points at the same host as the main model's base_url, so a + misconfigured aux endpoint cannot leak the main credential cross-host. + """ + + def test_inherits_main_api_key_when_aux_key_empty(self, monkeypatch): + """RED→GREEN: explicit_api_key is None, OPENAI_API_KEY unset → + model.api_key from config.yaml must be used (same-host gateway).""" + import agent.auxiliary_client as ac + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + fake_config = { + "model": { + "api_key": "sk-main-config-key", + "base_url": "https://gw.example.com/v1", + "default": "main-model", + } + } + captured: dict = {} + + def _capture_create(**kwargs): + captured.update(kwargs) + return MagicMock() + + with patch("hermes_cli.config.load_config", return_value=fake_config), \ + patch.object(ac, "_create_openai_client", side_effect=_capture_create): + client, model = resolve_provider_client( + "custom", + model="test-model", + explicit_base_url="https://gw.example.com/v1", + explicit_api_key=None, + ) + + assert captured.get("api_key") == "sk-main-config-key", ( + "Custom endpoint with empty api_key should inherit " + "model.api_key from config, got: " + + repr(captured.get("api_key")) + ) + + def test_explicit_api_key_takes_precedence(self, monkeypatch): + """explicit_api_key wins over config model.api_key.""" + import agent.auxiliary_client as ac + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + fake_config = {"model": {"api_key": "sk-main-config-key"}} + captured: dict = {} + + def _capture_create(**kwargs): + captured.update(kwargs) + return MagicMock() + + with patch("hermes_cli.config.load_config", return_value=fake_config), \ + patch.object(ac, "_create_openai_client", side_effect=_capture_create): + client, model = resolve_provider_client( + "custom", + model="test-model", + explicit_base_url="https://gw.example.com/v1", + explicit_api_key="[redacted:secret-assignment]", + ) + + assert captured.get("api_key") == "sk-explicit" + + def test_local_server_falls_to_no_key_required(self, monkeypatch): + """When no key is available anywhere (explicit, env, config), fall + back to ``no-key-required`` for local servers (Ollama, etc.).""" + import agent.auxiliary_client as ac + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + fake_config = {"model": {}} # no api_key configured + captured: dict = {} + + def _capture_create(**kwargs): + captured.update(kwargs) + return MagicMock() + + with patch("hermes_cli.config.load_config", return_value=fake_config), \ + patch.object(ac, "_create_openai_client", side_effect=_capture_create): + client, model = resolve_provider_client( + "custom", + model="test-model", + explicit_base_url="http://localhost:11434/v1", + explicit_api_key=None, + ) + + assert captured.get("api_key") == "no-key-required" + + def test_runtime_override_key_is_used(self, monkeypatch): + """When _RUNTIME_MAIN_API_KEY is set (by set_runtime_main), it takes + precedence over config.yaml for the custom endpoint key.""" + import agent.auxiliary_client as ac + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + captured: dict = {} + + def _capture_create(**kwargs): + captured.update(kwargs) + return MagicMock() + + with patch.object(ac, "_RUNTIME_MAIN_API_KEY", "sk-runtime-key"), \ + patch.object(ac, "_RUNTIME_MAIN_BASE_URL", "https://gw.example.com/v1"), \ + patch("hermes_cli.config.load_config", return_value={"model": {}}), \ + patch.object(ac, "_create_openai_client", side_effect=_capture_create): + client, model = resolve_provider_client( + "custom", + model="test-model", + explicit_base_url="https://gw.example.com/v1", + explicit_api_key=None, + ) + + assert captured.get("api_key") == "sk-runtime-key" + + def test_cross_host_aux_endpoint_does_not_inherit_main_key(self, monkeypatch): + """An aux base_url on a DIFFERENT host than the main model must NOT + inherit model.api_key — that would leak the main credential to + whatever host a misconfigured aux endpoint names. Falls back to the + fail-safe no-key-required placeholder instead.""" + import agent.auxiliary_client as ac + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + fake_config = { + "model": { + "api_key": "sk-main-config-key", + "base_url": "https://gw.example.com/v1", + } + } + captured: dict = {} + + def _capture_create(**kwargs): + captured.update(kwargs) + return MagicMock() + + with patch("hermes_cli.config.load_config", return_value=fake_config), \ + patch.object(ac, "_create_openai_client", side_effect=_capture_create): + client, model = resolve_provider_client( + "custom", + model="test-model", + explicit_base_url="https://other-host.example.net/v1", + explicit_api_key=None, + ) + + assert captured.get("api_key") == "no-key-required" + + def test_no_main_base_url_does_not_inherit_main_key(self, monkeypatch): + """When the main model has no base_url (e.g. a first-class provider), + there is no 'same gateway' to match — do not inherit the key.""" + import agent.auxiliary_client as ac + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + fake_config = {"model": {"api_key": "sk-main-config-key"}} + captured: dict = {} + + def _capture_create(**kwargs): + captured.update(kwargs) + return MagicMock() + + with patch("hermes_cli.config.load_config", return_value=fake_config), \ + patch.object(ac, "_create_openai_client", side_effect=_capture_create): + client, model = resolve_provider_client( + "custom", + model="test-model", + explicit_base_url="https://gw.example.com/v1", + explicit_api_key=None, + ) + + assert captured.get("api_key") == "no-key-required"
-
11 PRs closed without merging
From the close comments: duplicate 3 · closed without comment 2 · done elsewhere 2 · superseded by another PR 2 · merged via another change 1 · stale or abandoned 1
-
#9386 Fix custom provider API key precedence · superseded by another PR →
#21219 - #16308 fix(aux-client): resolve named-provider credential when base_url overrides endpoint (#16290) · stale or abandoned
-
#16389 fix(auxiliary.vision): keep named-provider credential pool with custom base_url · done elsewhere →
6cf7a9e,#16290 -
#16944 fix(auxiliary): avoid locking into custom path when api_key is empty · duplicate →
#9318,#16829 -
#16950 fix(auxiliary_client): require api_key for custom provider path · duplicate →
#16944,#9318,#16308
Show 6 more
- #20139 fix(auxiliary): pass cfg_base_url and cfg_api_key when resolving task provider · closed without comment
-
#21286 fix(agent): inherit model.api_key when auxiliary base_url matches main endpoint · superseded by another PR →
#20139,#17760 -
#34652 fix(auxiliary): preserve named provider identity when base_url is passed to _resolve_task_provider_model · duplicate →
#34651,#16290,#26879,#16389,#16727,#26909 - #49131 fix(agent): preserve vision provider for base_url fallback · closed without comment
-
#53688 fix(auxiliary): preserve provider when base_url is passed explicitly · done elsewhere →
a8841e2,#55605,#39732 -
#55911 fix(auxiliary): inherit model.api_key for custom endpoint when per-task key is empty (#9318) · merged via another change →
#59207
Closed issues · 9
- Closed#9318[Bug]: Auxiliary client falls back to "no-key-required" when per-task custom base_url is set but api_key is empty
- Closed#16290[Bug]: auxiliary.vision with provider=zai and custom base_url fails to auto-resolve ZAI_API_KEY
- Closed#16829auxiliary.{task} with provider:auto + base_url set falls into 'custom' path with empty api_key, returns 401
- Closed#21121[Bug]: auxiliary "use as" in dashboard copies base_url but omits api_key, causing 401 on authenticated custom endpoints
- Closed#34651[Bug]: auxiliary vision returns 401 when provider has custom base_url — API key resolves to no-key-required
- Closed#39599auxiliary vision: explicit base_url routes through generic "custom" branch, leaking main model name + OPENAI_API_KEY to the configured backend (Gemini)
- Closed#47741Vision auxiliary with base_url set but no api_key → "no-key-required" → 401
- Closed#49119auxiliary vision: api_key env var fallback skipped when base_url is set
- Closed#53687Bug: auxiliary vision (and other tasks) get 401 when provider + base_url both set in config
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.07: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab