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).
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user