Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 98 additions & 6 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ def _extract_url_query_params(url: str):
"github-models": "copilot",
"github-copilot-acp": "copilot-acp",
"copilot-acp-agent": "copilot-acp",
"google-vertex": "vertex",
"vertex-ai": "vertex",
"gcp-vertex": "vertex",
"vertexai": "vertex",
"tencent": "tencent-tokenhub",
"tokenhub": "tencent-tokenhub",
"tencent-cloud": "tencent-tokenhub",
Expand Down Expand Up @@ -294,6 +298,37 @@ def _normalize_aux_provider(provider: Optional[str]) -> str:
return _PROVIDER_ALIASES.get(normalized, normalized)


def _provider_profile_config(provider: str) -> Tuple[str, Optional[Any]]:
"""Return a registry-like config from ProviderProfile when auth.py lacks one."""
try:
from providers import get_provider_profile
except Exception:
return provider, None

profile = get_provider_profile(provider)
if profile is None:
return provider, None

canonical = str(getattr(profile, "name", "") or provider).strip().lower() or provider
env_vars = tuple(str(v) for v in (getattr(profile, "env_vars", ()) or ()))
api_key_env_vars = tuple(
v for v in env_vars
if not v.endswith("_BASE_URL") and not v.endswith("_URL")
)
base_url_env_var = next(
(v for v in env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")),
"",
)
return canonical, SimpleNamespace(
id=canonical,
name=str(getattr(profile, "display_name", "") or canonical),
auth_type=str(getattr(profile, "auth_type", "") or "api_key"),
inference_base_url=str(getattr(profile, "base_url", "") or ""),
api_key_env_vars=api_key_env_vars or env_vars,
base_url_env_var=base_url_env_var,
)


# Sentinel: when returned by _fixed_temperature_for_model(), callers must
# strip the ``temperature`` key from API kwargs entirely so the provider's
# server-side default applies. Kimi/Moonshot models manage temperature
Expand Down Expand Up @@ -3560,6 +3595,65 @@ def _try_configured_fallback_for_unavailable_client(
)


def _provider_auth_type(provider: str) -> str:
"""Return a provider's declared auth_type when known."""
normalized = _normalize_aux_provider(provider)
try:
from hermes_cli.auth import PROVIDER_REGISTRY

pconfig = PROVIDER_REGISTRY.get(normalized)
if pconfig is not None:
return str(getattr(pconfig, "auth_type", "") or "").strip().lower()
except Exception:
pass

_canonical, profile_config = _provider_profile_config(normalized)
if profile_config is not None:
return str(getattr(profile_config, "auth_type", "") or "").strip().lower()
return ""


def _explicit_provider_unavailable_message(provider: str) -> str:
"""Build actionable guidance for explicit aux providers that cannot build."""
explicit = (provider or "").strip().lower()
auth_type = _provider_auth_type(explicit)

if auth_type == "vertex":
return (
f"Provider '{explicit}' is set in config.yaml but Vertex AI "
"credentials could not be resolved. Vertex uses OAuth2, not a "
"static API key: set VERTEX_CREDENTIALS_PATH or "
"GOOGLE_APPLICATION_CREDENTIALS to a service-account JSON, or run "
"`gcloud auth application-default login` for ADC. Set "
"vertex.project_id/vertex.region in config.yaml if they are not "
"embedded in the credentials. If google-auth is missing, install "
"the extra with `pip install 'hermes-agent[vertex]'`."
)

if auth_type == "aws_sdk":
return (
f"Provider '{explicit}' is set in config.yaml but AWS SDK "
"credentials could not be resolved. Configure AWS credentials "
"(for example AWS_PROFILE, AWS_REGION, or the standard AWS "
"credential chain), or switch to a different provider with "
"`hermes model`."
)

if auth_type in {"external_process", "oauth_device_code", "oauth_external"} or auth_type.startswith("oauth"):
return (
f"Provider '{explicit}' is set in config.yaml but its account "
"credentials could not be resolved. Run `hermes model` to "
"authenticate or switch providers, or configure an "
"auxiliary fallback_chain for this task."
)

return (
f"Provider '{explicit}' is set in config.yaml but no API key "
f"was found. Set the {explicit.upper()}_API_KEY environment "
f"variable, or switch to a different provider with `hermes model`."
)


def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
"""Resolve inline or env-backed API key from a fallback-chain entry."""
explicit = str(entry.get("api_key") or "").strip()
Expand Down Expand Up @@ -4411,6 +4505,8 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
return None, None

pconfig = PROVIDER_REGISTRY.get(provider)
if pconfig is None:
provider, pconfig = _provider_profile_config(provider)
if pconfig is None:
# Demoted from logger.warning to debug; dedup keyed by provider name
# so the first occurrence surfaces but repeated retries stay silent.
Expand Down Expand Up @@ -5947,9 +6043,7 @@ def call_llm(
resolved_provider = fb_label or resolved_provider
else:
raise RuntimeError(
f"Provider '{_explicit}' is set in config.yaml but no API key "
f"was found. Set the {_explicit.upper()}_API_KEY environment "
f"variable, or switch to a different provider with `hermes model`."
_explicit_provider_unavailable_message(_explicit)
)
# For auto/custom with no credentials, try the full auto chain
# rather than hardcoding OpenRouter (which may be depleted).
Expand Down Expand Up @@ -6521,9 +6615,7 @@ async def async_call_llm(
resolved_provider = fb_label or resolved_provider
else:
raise RuntimeError(
f"Provider '{_explicit}' is set in config.yaml but no API key "
f"was found. Set the {_explicit.upper()}_API_KEY environment "
f"variable, or switch to a different provider with `hermes model`."
_explicit_provider_unavailable_message(_explicit)
)
if client is None and not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
Expand Down
66 changes: 66 additions & 0 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2368,6 +2368,72 @@ def test_explicit_provider_no_client_without_chain_keeps_clear_error(self, monke
reason="provider unavailable",
)

def test_vertex_profile_alias_resolves_auxiliary_client(self, monkeypatch):
"""Vertex auxiliary resolution should use OAuth2/ADC profile metadata."""
import agent.vertex_adapter as vertex_adapter

base_url = "https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/endpoints/openapi"
fake_client = SimpleNamespace(base_url=base_url)

monkeypatch.setattr(vertex_adapter, "has_vertex_credentials", lambda: True)
monkeypatch.setattr(vertex_adapter, "get_vertex_config", lambda: ("ya29.test-token", base_url))

with patch("openai.OpenAI", return_value=fake_client) as mock_openai:
client, model = resolve_provider_client(
"google-vertex",
"google/gemini-3.5-flash",
)

assert client is fake_client
assert model == "google/gemini-3.5-flash"
mock_openai.assert_called_once_with(
api_key="ya29.test-token",
base_url=base_url,
)

def test_vertex_no_client_without_chain_reports_adc_guidance(self, monkeypatch):
"""Vertex has no static API key; auxiliary errors must point at ADC/OAuth."""
with patch("agent.auxiliary_client._get_cached_client",
return_value=(None, None)), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("vertex", "google/gemini-3.5-flash", None, None, None)), \
patch("agent.auxiliary_client._try_configured_fallback_chain",
return_value=(None, None, "")):
with pytest.raises(RuntimeError) as exc_info:
call_llm(
task="compression",
messages=[{"role": "user", "content": "hello"}],
)

msg = str(exc_info.value)
assert "VERTEX_API_KEY" not in msg
assert "no API key" not in msg
assert "OAuth2" in msg
assert "gcloud auth application-default login" in msg
assert "VERTEX_CREDENTIALS_PATH" in msg

@pytest.mark.asyncio
async def test_async_vertex_no_client_without_chain_reports_adc_guidance(self, monkeypatch):
"""Async auxiliary calls should share the same non-API-key guidance."""
with patch("agent.auxiliary_client._get_cached_client",
return_value=(None, None)), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("vertex", "google/gemini-3.5-flash", None, None, None)), \
patch("agent.auxiliary_client._try_configured_fallback_chain",
return_value=(None, None, "")):
with pytest.raises(RuntimeError) as exc_info:
await async_call_llm(
task="compression",
messages=[{"role": "user", "content": "hello"}],
)

msg = str(exc_info.value)
assert "VERTEX_API_KEY" not in msg
assert "no API key" not in msg
assert "OAuth2" in msg
assert "gcloud auth application-default login" in msg
assert "VERTEX_CREDENTIALS_PATH" in msg

def test_fallback_entry_openai_codex_uses_oauth_pool_without_inline_key(self):
"""Configured Codex fallback resolves through Hermes auth / credential pool."""
from agent.auxiliary_client import _resolve_fallback_entry
Expand Down
Loading