From 3ea35d671106e586b51aa17ad0f4d162467b92a0 Mon Sep 17 00:00:00 2001 From: srojk34 <286497132+srojk34@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:52:26 +0300 Subject: [PATCH] fix(vertex,moa): register vertex in PROVIDER_REGISTRY and HERMES_OVERLAYS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vertex AI provider (added same-day, commit c73e74386) was never added to either of the two provider registries that agent/auxiliary_client.py and the MoA slot-resolution chain depend on, breaking Vertex outside the main conversation loop: 1. hermes_cli/auth.py::PROVIDER_REGISTRY had no "vertex" entry. The plugin-auto-extend loop that normally fills gaps explicitly skips non-api_key auth types (`if _pp.auth_type != "api_key": continue`), and Vertex was never hand-declared like "bedrock" is. Because resolve_provider_client() in agent/auxiliary_client.py gates everything on `pconfig = PROVIDER_REGISTRY.get(provider)` and returns (None, None) immediately when pconfig is None, its `elif pconfig.auth_type == "vertex"` branch was permanently dead code — every auxiliary Vertex call (vision, title generation, reflection, context compression, MoA reference/ aggregator slots) failed outright, not just a MoA-specific edge case. 2. hermes_cli/providers.py::HERMES_OVERLAYS also had no "vertex" entry, so hermes_cli.providers.get_provider("vertex") returned None. This backs _preserve_provider_with_base_url() in agent/auxiliary_client.py, which a MoA slot's resolved (base_url, api_key) pair needs to keep its "vertex" identity instead of silently collapsing to "custom" — losing the identity _refresh_provider_credentials() needs to re-mint an expired OAuth2 token (~1h lifetime) on a 401, and permanently breaking every subsequent call in that MoA preset for the rest of the session. Fix mirrors the existing "bedrock"/aws_sdk entries in both registries exactly, plus adds a "vertex" branch to _refresh_provider_credentials() (it had branches for openai-codex/nous/anthropic/xai-oauth but not vertex, so a 401 fell through to `return False` without evicting the stale cached client). - hermes_cli/auth.py: hand-declared vertex ProviderConfig(auth_type="vertex") in PROVIDER_REGISTRY, matching bedrock's shape. - hermes_cli/providers.py: vertex HermesOverlay(auth_type="vertex") in HERMES_OVERLAYS + "Google Vertex AI" label override. - agent/auxiliary_client.py: vertex branch in _refresh_provider_credentials that re-mints the token via get_vertex_config() and evicts the stale cached client. - 8 new regression tests across tests/hermes_cli/test_vertex_provider.py and tests/agent/test_auxiliary_client.py: registry membership, end-to-end resolve_provider_client("vertex", ...) building a working client (proving the previously-dead branch is now reachable), and the 401-refresh/cache- eviction path. --- agent/auxiliary_client.py | 18 +++++++ hermes_cli/auth.py | 11 +++++ hermes_cli/providers.py | 14 ++++++ tests/agent/test_auxiliary_client.py | 63 ++++++++++++++++++++++++ tests/hermes_cli/test_vertex_provider.py | 28 +++++++++++ 5 files changed, 134 insertions(+) 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"