diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index cd18779e8eaf..f475369d1a10 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3819,6 +3819,24 @@ def _refresh_provider_credentials(provider: str) -> bool: return False _evict_cached_clients(normalized) return True + if normalized == "vertex": + # Mirrors run_agent.py's _try_refresh_vertex_client_credentials + # for the main conversation loop. Without this branch, an + # auxiliary Vertex client (vision, title generation, reflection, + # context compression, ...) that 401s on its ~1h token expiry + # falls through to the final `return False` below: the stale + # client is never evicted from _client_cache (whose cache key + # ignores the rotating bearer token), so every subsequent + # auxiliary Vertex call keeps 401ing until process restart. + from agent.vertex_adapter import get_vertex_config + + token, base_url = get_vertex_config() + if not isinstance(token, str) or not token.strip(): + return False + if not isinstance(base_url, str) or not base_url.strip(): + return False + _evict_cached_clients(normalized) + return True except Exception as exc: logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc) return False diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 011cf817aebd..43d8ef596f09 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -434,6 +434,17 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { api_key_env_vars=(), base_url_env_var="BEDROCK_BASE_URL", ), + "vertex": ProviderConfig( + id="vertex", + name="Google Vertex AI", + auth_type="vertex", + # No static inference_base_url: Vertex's endpoint is computed per + # request from project_id + region (agent/vertex_adapter.py's + # build_vertex_base_url), not a fixed host like the other entries. + inference_base_url="", + api_key_env_vars=(), # OAuth2 (service-account JSON / ADC), not a key + base_url_env_var="", + ), "azure-foundry": ProviderConfig( id="azure-foundry", name="Azure Foundry", diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 2303e2141158..6bfbf01feb09 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -222,6 +222,19 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { transport="bedrock_converse", auth_type="aws_sdk", ), + # Vertex authenticates via OAuth2 (service-account JSON / ADC), not a + # static API key or models.dev entry — resolved specially by + # agent/vertex_adapter.py, like bedrock's aws_sdk. Without an overlay + # entry get_provider("vertex") returns None, which makes + # _preserve_provider_with_base_url() in agent/auxiliary_client.py treat + # a Vertex MoA slot's resolved (base_url, api_key) pair as an unknown + # custom endpoint instead of "vertex" — losing the provider identity + # that _refresh_provider_credentials() needs to re-mint an expired + # OAuth2 token on a 401. + "vertex": HermesOverlay( + transport="openai_chat", + auth_type="vertex", + ), } @@ -390,6 +403,7 @@ _LABEL_OVERRIDES: Dict[str, str] = { "lmstudio": "LM Studio", "local": "Local endpoint", "bedrock": "AWS Bedrock", + "vertex": "Google Vertex AI", "ollama-cloud": "Ollama Cloud", "xai-oauth": "xAI Grok OAuth (SuperGrok / Premium+)", } diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 86b5ae4adc50..921f84ae99d9 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -4347,6 +4347,69 @@ class TestAuxiliaryAuthRefreshRetry: mock_write.assert_called_once_with("fresh-token", "refresh-token-2", 9999999999999) stale_client.close.assert_called_once() + def test_refresh_provider_credentials_remints_vertex_token_and_evicts_cache(self): + """Vertex tokens live ~1h; on a long-running gateway the cached + auxiliary client's bearer token expires mid-session and 401s. + _refresh_provider_credentials("vertex") must re-mint the token via + the adapter (which refreshes in place when near expiry) and evict + the stale cached client so the next call rebuilds with a fresh one — + previously there was no "vertex" branch here at all, so this fell + through to the final `return False` and the stale client (and its + dead token) stayed cached until process restart.""" + stale_client = MagicMock() + cache_key = ("vertex", False, None, None, None) + + with ( + patch("agent.auxiliary_client._client_cache", {cache_key: (stale_client, "google/gemini-3-flash-preview", None)}), + patch( + "agent.vertex_adapter.get_vertex_config", + return_value=("ya29.FRESH", "https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/endpoints/openapi"), + ) as mock_get_config, + ): + from agent.auxiliary_client import _refresh_provider_credentials + + assert _refresh_provider_credentials("vertex") is True + + mock_get_config.assert_called_once() + stale_client.close.assert_called_once() + + def test_refresh_provider_credentials_vertex_returns_false_when_unminted(self): + """No usable token/base_url (e.g. ADC and the service-account file + both failed) — refresh must report failure, not silently evict and + pretend the client is fixed.""" + with patch("agent.vertex_adapter.get_vertex_config", return_value=(None, None)): + from agent.auxiliary_client import _refresh_provider_credentials + + assert _refresh_provider_credentials("vertex") is False + + def test_resolve_provider_client_vertex_builds_client_from_minted_token(self): + """End-to-end: resolve_provider_client("vertex", ...) must reach the + auth_type == "vertex" branch and build a working client, not die at + the PROVIDER_REGISTRY lookup (a plain HERMES_OVERLAYS-only fix would + leave this branch dead code — PROVIDER_REGISTRY is what + resolve_provider_client actually gates on).""" + with ( + patch("agent.vertex_adapter.has_vertex_credentials", return_value=True), + patch( + "agent.vertex_adapter.get_vertex_config", + return_value=("ya29.FRESH", "https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/endpoints/openapi"), + ), + ): + client, model = resolve_provider_client("vertex", "google/gemini-3-flash-preview") + + assert client is not None + assert model == "google/gemini-3-flash-preview" + assert str(client.base_url).rstrip("/") == ( + "https://aiplatform.googleapis.com/v1beta1/projects/p/locations/global/endpoints/openapi" + ) + + def test_resolve_provider_client_vertex_none_when_no_credentials(self): + with patch("agent.vertex_adapter.has_vertex_credentials", return_value=False): + client, model = resolve_provider_client("vertex", "google/gemini-3-flash-preview") + + assert client is None + assert model is None + @pytest.mark.asyncio async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self): stale_client = MagicMock() diff --git a/tests/hermes_cli/test_vertex_provider.py b/tests/hermes_cli/test_vertex_provider.py index af67aacac297..d97b53409020 100644 --- a/tests/hermes_cli/test_vertex_provider.py +++ b/tests/hermes_cli/test_vertex_provider.py @@ -98,3 +98,31 @@ def test_vertex_extra_body_empty_without_reasoning(): p = get_provider_profile("vertex") assert p.build_extra_body(model="google/gemini-3-flash-preview") == {} + + +def test_vertex_registered_in_provider_registry(): + """PROVIDER_REGISTRY (hermes_cli.auth) is what agent/auxiliary_client.py's + resolve_provider_client() looks up before dispatching on auth_type. Without + an entry here, the ``elif pconfig.auth_type == "vertex":`` branch there is + unreachable dead code — every auxiliary Vertex call (vision, title + generation, MoA reference/aggregator slots, ...) fails at the + ``pconfig is None`` guard before ever reaching it.""" + from hermes_cli.auth import PROVIDER_REGISTRY + + cfg = PROVIDER_REGISTRY.get("vertex") + assert cfg is not None + assert cfg.auth_type == "vertex" + + +def test_vertex_registered_in_hermes_overlays(): + """hermes_cli.providers.get_provider("vertex") backs + _preserve_provider_with_base_url() in agent/auxiliary_client.py, which + decides whether a MoA slot's resolved Vertex (base_url, api_key) pair + keeps its "vertex" provider identity or silently collapses to "custom" — + losing the identity _refresh_provider_credentials() needs to re-mint an + expired OAuth2 token on a 401.""" + from hermes_cli.providers import get_provider + + resolved = get_provider("vertex") + assert resolved is not None + assert resolved.auth_type == "vertex"