From 4cd2eb013c0f160293df1ad5b7d344cfdfeb6fe7 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Wed, 16 Sep 2026 05:59:08 +0800 Subject: [PATCH] fix(agent): honor ProviderProfile.create_client() for api_key aux routes The auxiliary api_key branch built openai.OpenAI directly, so an out-of-tree provider registered with auth_type="api_key" lost its native transport for auxiliary tasks even though the main-agent path (_provider_supplied_client) and the external_process branch honor the same hook. Consult the profile's create_client() before the built-in gemini/OpenAI ladder; None (the default) falls through untouched, and a raising profile is logged and skipped (#112384). --- agent/auxiliary_client.py | 33 ++++ ...test_auxiliary_provider_supplied_client.py | 151 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 tests/agent/test_auxiliary_provider_supplied_client.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 38da013d7c..19df05535d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -4851,6 +4851,30 @@ def _resolve_azure_foundry_branch(req: _ResolveRequest) -> _ResolveResult: "runtime resolution failed (run: hermes doctor for diagnostics)") +def _api_key_profile_supplied_client(provider: str, **client_kwargs: Any) -> Any | None: + """Registered profile's own client for an ``api_key`` aux route, or ``None``. + + Same registration seam as ``agent_runtime_helpers._provider_supplied_client`` (main agent) + and the ``external_process`` branch below: a profile whose wire protocol is not + OpenAI-over-HTTP overrides ``ProviderProfile.create_client()`` to supply its transport. + A profile that raises is logged and skipped — a third-party plugin can only fail to + provide a client, never take the auxiliary resolution down.""" + try: + from providers import get_provider_profile + profile = get_provider_profile(provider) + except Exception: + return None + if profile is None: + return None + try: + return profile.create_client(**client_kwargs) + except Exception: + logger.warning("resolve_provider_client: provider profile %r failed to create an " + "auxiliary client; falling back to the standard client path", + provider, exc_info=True) + return None + + def _resolve_api_key_branch(req: _ResolveRequest, pconfig: Any, resolve_creds: Callable) -> _ResolveResult: """PROVIDER_REGISTRY ``api_key`` providers (Anthropic via its own resolver), honouring explicit overrides.""" provider = req.provider @@ -4895,6 +4919,15 @@ def _resolve_api_key_branch(req: _ResolveRequest, pconfig: Any, resolve_creds: C if req.explicit_base_url and provider != "actual": base_url = _to_openai_base_url(req.explicit_base_url.strip().rstrip("/")) final_model = _normalize_resolved_model(req.model or _get_aux_model_for_provider(provider), provider) + # Provider-supplied client, consulted before the built-in gemini/OpenAI ladder so an + # out-of-tree provider registered with auth_type="api_key" keeps its native transport for + # auxiliary tasks — the same hook the main-agent path honors (#112384). ``None`` (the + # profile default) falls through to the standard construction, so built-in providers + # without a registered profile are untouched. + profile_client = _api_key_profile_supplied_client(provider, api_key=api_key, base_url=base_url) + if profile_client is not None: + logger.debug("resolve_provider_client: %s native client from provider profile (%s)", provider, final_model) + return _route_client(req, profile_client, final_model) if provider == "gemini": from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url if is_native_gemini_base_url(base_url): diff --git a/tests/agent/test_auxiliary_provider_supplied_client.py b/tests/agent/test_auxiliary_provider_supplied_client.py new file mode 100644 index 0000000000..0e0a6684ab --- /dev/null +++ b/tests/agent/test_auxiliary_provider_supplied_client.py @@ -0,0 +1,151 @@ +"""Auxiliary ``api_key`` routes honor ``ProviderProfile.create_client()`` — parity with the main agent. + +``resolve_provider_client()``'s ``api_key`` branch used to build ``openai.OpenAI`` directly, +so an out-of-tree provider registered with ``auth_type="api_key"`` lost its native transport +for auxiliary tasks even though the main-agent path +(``agent_runtime_helpers._provider_supplied_client``) honors the same hook (#112384). +These tests pin the seam through the real resolution entry point: the native client +(sync + async), the ``None`` fall-through for ordinary providers, and failure isolation +for a broken plugin. +""" + +from __future__ import annotations + +import pytest + +import providers as _providers +from providers.base import ProviderProfile + +_PROBE_ENV_VAR = "AUX_SEAM_PROBE_AUTH" +_PROBE_KEY = "probe-sentinel" + + +class _FakeNativeClient: + HERMES_SKIP_TRANSPORT_WRAP = True + HERMES_SKIP_ASYNC_WRAP = True + + def __init__(self, **kwargs): + self.kwargs = kwargs + + +class _NativeProfile(ProviderProfile): + def create_client(self, **kwargs): + return _FakeNativeClient(**kwargs) + + +class _PassThroughProfile(ProviderProfile): + """Ordinary provider: ``create_client`` returns None so the standard client is built.""" + + def create_client(self, **kwargs): + return None + + +class _ExplodingProfile(ProviderProfile): + def create_client(self, **kwargs): + raise RuntimeError("plugin is broken") + + +def _probe_profile(cls, name: str) -> ProviderProfile: + return cls( + name=name, + auth_type="api_key", + env_vars=(_PROBE_ENV_VAR,), + base_url=f"https://{name}.invalid", + default_aux_model="probe-model", + ) + + +@pytest.fixture +def registered(tmp_path, monkeypatch): + """Register provider profiles for one test; restore both registries and the secret scope after. + + Mirrors the import-time synthesis ``hermes_cli.auth`` performs for plugin ``api_key`` + profiles, which is what routes them into ``_resolve_api_key_branch`` in the first place. + """ + import hermes_cli.auth as _auth + from agent import secret_scope as _secret_scope + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + _providers._discover_providers() + providers_snapshot = ( + dict(_providers._REGISTRY), + dict(_providers._ALIASES), + _providers._PROVIDER_LIST_CACHE, + ) + auth_snapshot = dict(_auth.PROVIDER_REGISTRY) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + home_token = set_hermes_home_override(str(tmp_path)) + scope_token = _secret_scope.set_secret_scope({_PROBE_ENV_VAR: _PROBE_KEY}) + + yield ( + lambda profile: ( + ( + _providers.register_provider(profile), + _auth._register_plugin_provider(profile), + ) + and profile + ) + ) + + _secret_scope.reset_secret_scope(scope_token) + reset_hermes_home_override(home_token) + _providers._REGISTRY.clear() + _providers._REGISTRY.update(providers_snapshot[0]) + _providers._ALIASES.clear() + _providers._ALIASES.update(providers_snapshot[1]) + _providers._PROVIDER_LIST_CACHE = providers_snapshot[2] + _auth.PROVIDER_REGISTRY.clear() + _auth.PROVIDER_REGISTRY.update(auth_snapshot) + + +def test_native_profile_supplies_the_auxiliary_client(registered): + from agent.auxiliary_client import resolve_provider_client + + registered(_probe_profile(_NativeProfile, "aux-seam-native")) + client, model = resolve_provider_client( + "aux-seam-native", "probe-model", task="title_generation" + ) + + assert isinstance(client, _FakeNativeClient) + assert model == "probe-model" + # The hook receives the same mapping the branch would have passed to openai.OpenAI. + assert client.kwargs["api_key"] == _PROBE_KEY + assert client.kwargs["base_url"] == "https://aux-seam-native.invalid" + + +def test_native_profile_client_survives_the_async_route(registered): + from agent.auxiliary_client import resolve_provider_client + + registered(_probe_profile(_NativeProfile, "aux-seam-native")) + client, model = resolve_provider_client( + "aux-seam-native", "probe-model", async_mode=True + ) + + # HERMES_SKIP_ASYNC_WRAP: a native transport is not rebuilt as AsyncOpenAI. + assert isinstance(client, _FakeNativeClient) + assert model == "probe-model" + + +def test_profile_returning_none_falls_through_to_the_standard_client(registered): + from openai import OpenAI + + from agent.auxiliary_client import resolve_provider_client + + registered(_probe_profile(_PassThroughProfile, "aux-seam-passthrough")) + client, model = resolve_provider_client("aux-seam-passthrough", "probe-model") + + assert isinstance(client, OpenAI) + assert model == "probe-model" + + +def test_a_broken_profile_falls_back_to_the_standard_client(registered): + from openai import OpenAI + + from agent.auxiliary_client import resolve_provider_client + + registered(_probe_profile(_ExplodingProfile, "aux-seam-boom")) + client, model = resolve_provider_client("aux-seam-boom", "probe-model") + + # A raising plugin can only fail to provide a client, never break auxiliary resolution. + assert isinstance(client, OpenAI) + assert model == "probe-model"