diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index eecddb62764..1884369c28b 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -4357,6 +4357,49 @@ def resolve_provider_client( else (client, final_model)) # Try custom first, then API-key providers (Codex excluded here: # falling through to Codex with no model is a stale-constant trap). + # + # When main_runtime carries a concrete base_url + api_key for a + # named custom provider (custom:), use it directly instead of + # re-resolving from the bare "custom" provider name. Re-resolution + # loses the provider name and falls back to OpenRouter or a wrong + # API-key provider — the main agent already solved this, we just + # need to reuse its answer. (#45472) + if main_runtime: + main_base = str(main_runtime.get("base_url") or "").strip().rstrip("/") + main_key = str(main_runtime.get("api_key") or "").strip() + if main_base and main_key: + final_model = _normalize_resolved_model( + model or main_runtime.get("model") or "gpt-4o-mini", + provider, + ) + extra = {} + _clean_base, _dq = _extract_url_query_params(main_base) + if _dq: + extra["default_query"] = _dq + if base_url_host_matches(main_base, "api.kimi.com"): + extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} + elif base_url_host_matches(main_base, "api.githubcopilot.com"): + from hermes_cli.copilot_auth import copilot_request_headers + extra["default_headers"] = copilot_request_headers( + is_agent_turn=True, is_vision=is_vision + ) + elif base_url_host_matches(main_base, "integrate.api.nvidia.com"): + extra["default_headers"] = build_nvidia_nim_headers(main_base) + else: + try: + from providers import get_provider_profile as _gpf_main + _ph_main = _gpf_main(provider) + if _ph_main and _ph_main.default_headers: + extra["default_headers"] = dict(_ph_main.default_headers) + except Exception: + pass + _merged_main = _apply_user_default_headers(extra.get("default_headers")) + if _merged_main: + extra["default_headers"] = _merged_main + client = OpenAI(api_key=main_key, base_url=_clean_base, **extra) + client = _wrap_if_needed(client, final_model, main_base, main_key) + return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + else (client, final_model)) for try_fn in (_try_custom_endpoint, _resolve_api_key_provider): client, default = try_fn() if client is not None: diff --git a/tests/agent/test_auxiliary_named_custom_providers.py b/tests/agent/test_auxiliary_named_custom_providers.py index afceeec02d9..fe4a8c34d37 100644 --- a/tests/agent/test_auxiliary_named_custom_providers.py +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -491,3 +491,73 @@ class TestCustomProviderAliasCollision: assert isinstance(client, OpenAI) assert "override.example.com" in str(client.base_url) assert client.api_key == "override-key" + + +class TestResolveProviderClientMainRuntimeCustom: + """When the main agent uses a named custom provider (custom:), + resolve_provider_client('custom', ..., main_runtime=...) must reuse the + main_runtime's base_url + api_key instead of re-resolving from the bare + 'custom' provider name. Re-resolution loses the provider name and falls + back to OpenRouter or a wrong API-key provider. (#45472)""" + + def test_custom_provider_main_runtime_used_directly(self, tmp_path, monkeypatch): + """main_runtime with base_url + api_key for a named custom provider + is used directly, bypassing the _try_custom_endpoint / API-key + fallback chain.""" + from agent.auxiliary_client import resolve_provider_client + main_runtime = { + "provider": "custom", + "base_url": "https://my-gateway.example.com/v1", + "api_key": "***", + "model": "glm-5.1", + } + client, model = resolve_provider_client( + "custom", + model="explicit-glm-5.1", + main_runtime=main_runtime, + ) + assert client is not None + assert model == "explicit-glm-5.1" + assert "my-gateway.example.com" in str(client.base_url) + assert client.api_key == "***" + + def test_custom_provider_main_runtime_no_credentials_falls_through(self, tmp_path, monkeypatch): + """When main_runtime has no base_url or no api_key, the existing + _try_custom_endpoint / _resolve_api_key_provider fallback chain is + still tried.""" + # Ensure no env-provided credentials interfere + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + from agent.auxiliary_client import resolve_provider_client + # main_runtime with key but no base_url → must fall through + client, model = resolve_provider_client( + "custom", + main_runtime={"api_key": "k", "base_url": ""}, + ) + # Should fall through to _try_custom_endpoint → return None,None + # because no OPENAI_BASE_URL is set and no custom endpoint is configured + assert client is None + + def test_custom_provider_main_runtime_respects_explicit_base_url(self, tmp_path): + """explicit_base_url still wins over main_runtime — the caller's + explicit argument is the strongest signal.""" + from agent.auxiliary_client import resolve_provider_client + main_runtime = { + "base_url": "https://main-runtime.example.com/v1", + "api_key": "sk-main", + "model": "ignored-model", + } + client, model = resolve_provider_client( + "custom", + model="explicit-model", + explicit_base_url="https://explicit.example.com/v1", + explicit_api_key="sk-explicit", + main_runtime=main_runtime, + ) + assert client is not None + assert model == "explicit-model" + assert "explicit.example.com" in str(client.base_url) + assert client.api_key == "sk-explicit"