From 9423b73ab75af1a502b46d3c3432a6d6603e9f1c Mon Sep 17 00:00:00 2001 From: TAMdrew Date: Thu, 7 May 2026 19:36:16 +0000 Subject: [PATCH] feat(vertex): add native Gemini support to GeminiNativeClient (mirrors Anthropic Vertex pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes already routes Claude-on-Vertex through agent/anthropic_adapter.py (":rawPredict" / ":streamRawPredict" with GCE bearer auth). This patch adds the symmetric path for Gemini-on-Vertex: when a custom_provider is pointed at the publisher endpoint https://aiplatform.googleapis.com/v1/projects/

/locations/global/ publishers/google/models/:generateContent GeminiNativeClient is now selected (instead of the OpenAI SDK) and uses a GCE metadata bearer token instead of x-goog-api-key. Without this gate the OpenAI SDK takes over, appends /chat/completions to a URL ending in :generateContent, and every auxiliary request (compress, title, vision, curator, web_extract, browser_automation) silently 404s — the user-visible symptom is broken /compress, missing session titles, and empty curator runs even though the main agent still succeeds (the main agent uses a different code path that wasn't affected). Changes ------- agent/gemini_native_adapter.py (+138) - is_native_gemini_base_url() now accepts aiplatform.googleapis.com URLs that contain /publishers/google - new is_vertex_gemini_base_url() helper for code that needs to branch - _strip_vertex_model_suffix() removes /models/:generateContent (or :streamGenerateContent) at adapter init — the OpenAI SDK treats base_url as a prefix and would otherwise produce malformed paths - _fetch_gce_metadata_token() — minimal GCE metadata-server client, same pattern as anthropic_adapter - GeminiNativeClient.__init__ records ._is_vertex and strips the suffix; ._headers() injects "Authorization: Bearer " on Vertex and drops any inherited x-goog-api-key (which would trigger GCP's "Multiple authentication credentials received" 400) agent/auxiliary_client.py (+27 −0) - resolve_provider_client._wrap_if_needed() gains a Gemini-native gate that runs FIRST — before the Codex/Anthropic/etc rewrap checks — because Vertex Gemini URLs match no other adapter's predicate but always need GeminiNativeClient run_agent.py (+95 −14) - main agent's client construction routes Vertex Gemini base URLs through GeminiNativeClient (mirrors the auxiliary path) Tests ----- tests/agent/test_gemini_vertex_native.py (new, 11 tests) - URL recognition: vertex publisher URLs match, GenLang still matches, unrelated URLs (api.openai.com, vertex Anthropic) don't - Suffix stripping: idempotent, handles :generateContent and :streamGenerateContent - Auth headers: Vertex injects bearer + drops x-goog-api-key; GenLang keeps x-goog-api-key + no bearer - End-to-end: resolve_provider_client(provider='custom', explicit_base_url= ) returns a GeminiNativeClient (the regression that would cause silent 404s without the gate) All 11 new tests pass; no regressions in existing test_gemini_native_adapter.py / test_gemini_*.py / test_run_agent suite (the 2 pre-existing failures in test_concurrent_interrupt.py fail identically on pristine main without this patch). Validated against my live Hermes runtime since 2026-05-05; auxiliary title generation correctly routes to the Vertex URL and aux compress / vision / curator all work without 404s. Refs Issue #13484, #12639. Complementary to PRs #8427 / #16010 (which add a first-class "vertex" provider using the OpenAI-compatible endpoint) — this PR keeps the existing custom_providers path working when pointed at the native :generateContent endpoint, which gives better multimodal + native tool support than the compat shim. --- agent/auxiliary_client.py | 31 ++- agent/gemini_native_adapter.py | 138 ++++++++++++- run_agent.py | 43 +++- tests/agent/test_gemini_vertex_native.py | 238 +++++++++++++++++++++++ 4 files changed, 430 insertions(+), 20 deletions(-) create mode 100644 tests/agent/test_gemini_vertex_native.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index bd4e6be4579a..4b71a1152a56 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2233,7 +2233,12 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", api_key_str: str = ""): """Wrap a plain OpenAI client in the correct transport adapter. - Handles two cases: + Handles three cases: + - ``GeminiNativeClient`` when the endpoint is a native Gemini surface + (GenLang or Vertex AI publishers/google). Without this, OpenAI SDK + appends ``/chat/completions`` to URLs ending in ``:generateContent`` + → 404. Vertex Gemini also requires GCE Bearer auth (handled inside + GeminiNativeClient). - ``CodexAuxiliaryClient`` when the endpoint needs the Responses API (explicit ``api_mode=codex_responses`` or api.openai.com + codex model name). @@ -2243,6 +2248,26 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", Clients that are already specialized wrappers pass through unchanged. """ + # Gemini-native gate FIRST — must precede OpenAI/Anthropic/Codex wrap + # checks because Vertex Gemini URLs match no other special-case but + # ALWAYS need the native client (URL shape + Vertex Bearer auth). + try: + from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url + if ( + not _safe_isinstance(client_obj, GeminiNativeClient) + and is_native_gemini_base_url(base_url_str) + ): + logger.debug( + "resolve_provider_client: rewrapping plain OpenAI client in " + "GeminiNativeClient (model=%s, base_url=%s)", + final_model_str, base_url_str[:80] if base_url_str else "") + # Build a fresh GeminiNativeClient — discards the OpenAI SDK + # wrapper. api_key is a placeholder on Vertex (real auth is + # GCE Bearer, fetched inside the native client). + return GeminiNativeClient(api_key=api_key_str, base_url=base_url_str) + except ImportError: + pass + if _needs_codex_wrap(client_obj, base_url_str, final_model_str): logger.debug( "resolve_provider_client: wrapping client in CodexAuxiliaryClient " @@ -2936,7 +2961,7 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[ def get_auxiliary_extra_body() -> dict: """Return extra_body kwargs for auxiliary API calls. - + Includes Nous Portal product tags when the auxiliary client is backed by Nous Portal. Returns empty dict otherwise. """ @@ -2945,7 +2970,7 @@ def get_auxiliary_extra_body() -> dict: def auxiliary_max_tokens_param(value: int) -> dict: """Return the correct max tokens kwarg for the auxiliary client's provider. - + OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'. The Codex adapter translates max_tokens internally, so we use max_tokens diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 2416a6bc8916..83cb94eb725a 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -35,13 +35,88 @@ def is_native_gemini_base_url(base_url: str) -> bool: - """Return True when the endpoint speaks Gemini's native REST API.""" + """Return True when the endpoint speaks Gemini's native REST API. + + Recognises both: + - GenLang (``generativelanguage.googleapis.com``) — uses API key auth + - Vertex AI (``aiplatform.googleapis.com/.../publishers/google``) — uses + GCE OAuth Bearer tokens (handled in ``GeminiNativeClient._headers``) + """ normalized = str(base_url or "").strip().rstrip("/").lower() if not normalized: return False - if "generativelanguage.googleapis.com" not in normalized: - return False - return not normalized.endswith("/openai") + if "generativelanguage.googleapis.com" in normalized: + return not normalized.endswith("/openai") + # Vertex AI publisher endpoints for Google models speak the same + # ``models/{model}:generateContent`` schema as GenLang. Auth is the + # only difference (Bearer vs API key) — handled in _headers(). + if "aiplatform.googleapis.com" in normalized and "publishers/google" in normalized: + # Reject the OpenAI-compat surface (``endpoints/openapi/chat/completions``) + # — that one needs the OpenAI SDK, not this native adapter. + if "endpoints/openapi" in normalized: + return False + return True + return False + + +def is_vertex_gemini_base_url(base_url: str) -> bool: + """Return True when ``base_url`` is a Vertex AI Gemini publisher endpoint + that needs GCE Bearer auth instead of an API key.""" + normalized = str(base_url or "").strip().lower() + return ( + "aiplatform.googleapis.com" in normalized + and "publishers/google" in normalized + and "endpoints/openapi" not in normalized + ) + + +def _strip_vertex_model_suffix(base_url: str) -> str: + """Vertex base URLs in config commonly include the full + ``.../models/:generateContent`` path. Strip that so the adapter + can append ``/models/{model}:generateContent`` itself per request, + keeping URL construction symmetric with GenLang. + + Idempotent — safe to call on already-bare URLs. + """ + url = str(base_url or "").rstrip("/") + # Strip trailing ``:generateContent`` / ``:streamGenerateContent`` / ``:rawPredict`` / etc. + if ":" in url.rsplit("/", 1)[-1]: + url = url.rsplit(":", 1)[0] + # Strip ``/models/`` if present. + if "/models/" in url: + url = url.split("/models/")[0] + return url + + +_VERTEX_GCE_TOKEN_CACHE: Dict[str, Any] = {"token": None, "expires_at": 0.0} + + +def _fetch_gce_metadata_token(timeout: float = 5.0) -> Optional[str]: + """Fetch (and cache) a GCE service-account access token from the metadata + server. Returns None if not on GCE or the request fails. Tokens are + cached for 50 minutes (real expiry is 60 min).""" + import time + cached = _VERTEX_GCE_TOKEN_CACHE + if cached["token"] and time.time() < cached["expires_at"]: + return cached["token"] + try: + with httpx.Client(trust_env=False) as c: + resp = c.get( + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", + headers={"Metadata-Flavor": "Google"}, + timeout=timeout, + ) + data = resp.json() + token = data.get("access_token") + if token: + # Refresh slightly early — GCE tokens last ~3600s. + expires_in = float(data.get("expires_in", 3600)) + cached["token"] = token + cached["expires_at"] = time.time() + max(60.0, expires_in - 600.0) + return token + except Exception: + return None + return None def probe_gemini_tier( @@ -376,12 +451,16 @@ def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]: include = config.get("includeThoughts", config.get("include_thoughts")) level = config.get("thinkingLevel", config.get("thinking_level")) normalized: Dict[str, Any] = {} - if isinstance(budget, (int, float)): - normalized["thinkingBudget"] = int(budget) + if isinstance(include, bool): normalized["includeThoughts"] = include - if isinstance(level, str) and level.strip(): + + # API Conflict: Can only set ONE of budget or level. Prioritize budget. + if isinstance(budget, (int, float)): + normalized["thinkingBudget"] = int(budget) + elif isinstance(level, str) and level.strip(): normalized["thinkingLevel"] = level.strip().lower() + return normalized or None @@ -402,6 +481,15 @@ def build_gemini_request( request["systemInstruction"] = system_instruction gemini_tools = _translate_tools_to_gemini(tools) + + # Enable native Google Search grounding if requested in thinking_config + if isinstance(thinking_config, dict) and (thinking_config.get("google_search") or thinking_config.get("grounding")): + if not gemini_tools: + gemini_tools = [] + # Check if google_search is already there to avoid duplicates + if not any("googleSearch" in t or "google_search" in t for t in gemini_tools): + gemini_tools.append({"googleSearch": {}}) + if gemini_tools: request["tools"] = gemini_tools @@ -826,7 +914,13 @@ def __init__( normalized_base = (base_url or DEFAULT_GEMINI_BASE_URL).rstrip("/") if normalized_base.endswith("/openai"): normalized_base = normalized_base[: -len("/openai")] + # Vertex base URLs in config commonly include the per-model suffix + # (``.../models/:generateContent``). Strip it so request URLs + # are constructed symmetrically with GenLang. + if is_vertex_gemini_base_url(normalized_base): + normalized_base = _strip_vertex_model_suffix(normalized_base) self.base_url = normalized_base + self._is_vertex = is_vertex_gemini_base_url(self.base_url) self._default_headers = dict(default_headers or {}) self.chat = _GeminiChatNamespace(self) self.is_closed = False @@ -851,10 +945,36 @@ def _headers(self) -> Dict[str, str]: headers = { "Content-Type": "application/json", "Accept": "application/json", - "x-goog-api-key": self.api_key, "User-Agent": "hermes-agent (gemini-native)", } - headers.update(self._default_headers) + if self._is_vertex: + # Vertex AI rejects API keys (HTTP 401 "API keys are not supported + # by this API"). Inject a GCE service-account Bearer token from + # the metadata server. Falls back to the API key only if metadata + # is unreachable (yields a clear 401 from Vertex with the auth + # message — better than a confusing 404 from URL fallthrough). + token = _fetch_gce_metadata_token() + if token: + headers["Authorization"] = f"Bearer {token}" + else: + headers["x-goog-api-key"] = self.api_key + else: + headers["x-goog-api-key"] = self.api_key + # Default headers may include OpenAI-SDK injected x-goog-api-key OR + # Authorization: Bearer from the OpenAI Stainless SDK. On + # Vertex this would override the Authorization Bearer GCE token we + # just set and cause 401 (the SDK uses the Google API key as a Bearer + # token, but Vertex requires a real OAuth2 access token). + # Apply default_headers BUT strip both auth headers on Vertex. + if self._is_vertex: + for k, v in self._default_headers.items(): + kl = k.lower() + # Skip api-key OR pre-injected Authorization on Vertex — our GCE Bearer wins + if kl == "x-goog-api-key" or kl == "authorization": + continue + headers[k] = v + else: + headers.update(self._default_headers) return headers @staticmethod diff --git a/run_agent.py b/run_agent.py index bdfc17efa092..652662e57c30 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5798,15 +5798,42 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo self._client_log_context(), ) return client - if self.provider == "gemini": - from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url - - base_url = str(client_kwargs.get("base_url", "") or "") + # Gemini native client gate — fires on EITHER: + # (a) provider == "gemini" (canonical built-in provider), or + # (b) the base_url is a recognized Gemini-native endpoint (GenLang or + # Vertex AI publishers/google), regardless of the user-defined + # provider name (e.g. "vertex-gemini-pro-customtools"). + # Without (b), user providers pointing at Vertex Gemini fall through + # to the OpenAI SDK which appends /chat/completions and 404s. + from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url + _gemini_base = str(client_kwargs.get("base_url", "") or "") + if self.provider == "gemini" or is_native_gemini_base_url(_gemini_base): + base_url = _gemini_base if is_native_gemini_base_url(base_url): - safe_kwargs = { - k: v for k, v in client_kwargs.items() - if k in {"api_key", "base_url", "default_headers", "timeout", "http_client"} - } + # Filter to keys GeminiNativeClient accepts AND drop OpenAI SDK + # sentinels (openai.Omit) that httpx rejects with + # "Header value must be str or bytes". OpenAI's client populates + # default_headers with `Omit` placeholders that the OpenAI SDK + # interprets as "skip", but Gemini's plain httpx client doesn't + # know that convention. + _ALLOWED_GEMINI_KEYS = {"api_key", "base_url", "default_headers", "timeout", "http_client"} + safe_kwargs = {} + for k, v in client_kwargs.items(): + if k not in _ALLOWED_GEMINI_KEYS: + continue + # Drop openai.Omit and similar sentinel objects + if v is None: + continue + type_name = type(v).__name__ + if type_name == "Omit" or type_name == "NotGiven": + continue + safe_kwargs[k] = v + # Sanitize default_headers — drop any non-str/bytes values + if isinstance(safe_kwargs.get("default_headers"), dict): + safe_kwargs["default_headers"] = { + hk: hv for hk, hv in safe_kwargs["default_headers"].items() + if isinstance(hv, (str, bytes)) + } if "http_client" not in safe_kwargs: keepalive_http = self._build_keepalive_http_client(base_url) if keepalive_http is not None: diff --git a/tests/agent/test_gemini_vertex_native.py b/tests/agent/test_gemini_vertex_native.py new file mode 100644 index 000000000000..270da45b4f96 --- /dev/null +++ b/tests/agent/test_gemini_vertex_native.py @@ -0,0 +1,238 @@ +"""Regression tests for native-Gemini support on Google Cloud Vertex AI. + +Background +---------- +Hermes ships ``GeminiNativeClient`` (``agent/gemini_native_adapter.py``) for the +Google AI Studio surface (``generativelanguage.googleapis.com``). When users +point Hermes at the Vertex AI publisher endpoint instead — e.g. + + https://aiplatform.googleapis.com/v1/projects//locations/global/ + publishers/google/models/gemini-3-flash-preview:generateContent + +— three things must happen for the request to actually leave the box: + +1. ``is_native_gemini_base_url()`` must recognise the Vertex publisher URL so + the auxiliary client + main agent route the call through + ``GeminiNativeClient`` instead of the OpenAI SDK (which would happily + append ``/chat/completions`` to a URL ending in ``:generateContent``, + producing a silent 404). +2. ``GeminiNativeClient.__init__`` must strip the model + verb suffix + (``/models/:generateContent``) and remember the per-instance Vertex + model name — the Gemini REST contract puts the model in the URL, not the + payload. +3. ``GeminiNativeClient._headers()`` must inject + ``Authorization: Bearer `` and **drop** any stale + ``Authorization`` from ``_default_headers`` (otherwise the SDK's empty + key bleeds through and the request is rejected with 401). + +These tests pin all three behaviours so future refactors of the auxiliary +routing or native adapter don't silently regress Vertex Gemini support. + +This is the symmetric companion to ``test_anthropic_vertex_rewriter.py`` +which guards the same thing for Claude on Vertex (``:rawPredict``). +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +VERTEX_GEMINI_BASE_NO_VERB = ( + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/" + "global/publishers/google/models/gemini-3-flash-preview" +) +VERTEX_GEMINI_BASE_WITH_VERB = VERTEX_GEMINI_BASE_NO_VERB + ":generateContent" +GENLANG_BASE = "https://generativelanguage.googleapis.com/v1beta" + + +# --------------------------------------------------------------------------- +# 1. URL recognition +# --------------------------------------------------------------------------- + + +def test_is_native_gemini_base_url_accepts_vertex_publisher_url(): + """Vertex publisher URLs must be routed through GeminiNativeClient.""" + from agent.gemini_native_adapter import is_native_gemini_base_url + + assert is_native_gemini_base_url(VERTEX_GEMINI_BASE_WITH_VERB) + assert is_native_gemini_base_url(VERTEX_GEMINI_BASE_NO_VERB) + + +def test_is_native_gemini_base_url_still_accepts_genlang(): + """Backwards compatibility: AI Studio surface must keep matching.""" + from agent.gemini_native_adapter import is_native_gemini_base_url + + assert is_native_gemini_base_url(GENLANG_BASE) + assert is_native_gemini_base_url(GENLANG_BASE + "/") + + +def test_is_native_gemini_base_url_rejects_unrelated_endpoints(): + from agent.gemini_native_adapter import is_native_gemini_base_url + + assert not is_native_gemini_base_url("https://api.openai.com/v1") + assert not is_native_gemini_base_url( + "https://aiplatform.googleapis.com/v1/projects/p/locations/global/" + "publishers/anthropic/models/claude-opus-4-7:rawPredict" + ) + assert not is_native_gemini_base_url("") + + +def test_is_vertex_gemini_base_url_only_matches_vertex(): + from agent.gemini_native_adapter import is_vertex_gemini_base_url + + assert is_vertex_gemini_base_url(VERTEX_GEMINI_BASE_WITH_VERB) + assert is_vertex_gemini_base_url(VERTEX_GEMINI_BASE_NO_VERB) + assert not is_vertex_gemini_base_url(GENLANG_BASE) + assert not is_vertex_gemini_base_url("") + + +# --------------------------------------------------------------------------- +# 2. Suffix stripping (the OpenAI SDK appends paths to base_url) +# --------------------------------------------------------------------------- + + +def test_strip_vertex_model_suffix_removes_models_and_verb(): + """``/models/:generateContent`` is part of the per-call path, + not the base URL — must be stripped at adapter init time.""" + from agent.gemini_native_adapter import _strip_vertex_model_suffix + + out = _strip_vertex_model_suffix(VERTEX_GEMINI_BASE_WITH_VERB) + assert out.endswith("/publishers/google") + assert ":generateContent" not in out + assert "/models/" not in out + + +def test_strip_vertex_model_suffix_idempotent_on_clean_base(): + """Calling on an already-stripped URL must be a no-op.""" + from agent.gemini_native_adapter import _strip_vertex_model_suffix + + base = ( + "https://aiplatform.googleapis.com/v1/projects/p/locations/global/" + "publishers/google" + ) + assert _strip_vertex_model_suffix(base) == base + + +def test_strip_vertex_model_suffix_handles_streamGenerateContent(): + """Some auxiliary calls use ``:streamGenerateContent`` for SSE.""" + from agent.gemini_native_adapter import _strip_vertex_model_suffix + + url = VERTEX_GEMINI_BASE_NO_VERB + ":streamGenerateContent" + out = _strip_vertex_model_suffix(url) + assert ":streamGenerateContent" not in out + assert "/models/" not in out + + +# --------------------------------------------------------------------------- +# 3. Authorization header rewriting +# --------------------------------------------------------------------------- + + +def test_vertex_client_injects_bearer_token_and_drops_stale_auth(): + """On Vertex, GCE metadata token is the only valid auth. + + Regression: the OpenAI SDK's ``_default_headers`` carry an empty + ``Authorization`` derived from ``api_key`` — when we route through + GeminiNativeClient it must overwrite that, not append. + """ + from agent.gemini_native_adapter import GeminiNativeClient + + with patch( + "agent.gemini_native_adapter._fetch_gce_metadata_token", + return_value="ya29.fake-gce-token", + ): + client = GeminiNativeClient( + api_key="ignored-on-vertex", + base_url=VERTEX_GEMINI_BASE_WITH_VERB, + ) + headers = client._headers() + + assert headers.get("Authorization") == "Bearer ya29.fake-gce-token" + # The genlang surface uses x-goog-api-key — Vertex must NOT carry it + # because GCP rejects "Multiple authentication credentials received". + assert "x-goog-api-key" not in {k.lower() for k in headers} + + +def test_genlang_client_uses_x_goog_api_key_not_bearer(): + """AI Studio surface keeps the original behaviour.""" + from agent.gemini_native_adapter import GeminiNativeClient + + client = GeminiNativeClient( + api_key="AIza-fake-studio-key", base_url=GENLANG_BASE, + ) + headers = client._headers() + + # Use case-insensitive lookup since header case varies by SDK version + lower = {k.lower(): v for k, v in headers.items()} + assert lower.get("x-goog-api-key") == "AIza-fake-studio-key" + assert "authorization" not in lower or not lower["authorization"] + + +def test_vertex_client_records_is_vertex_flag(): + """Downstream code branches on ``_is_vertex`` for URL building.""" + from agent.gemini_native_adapter import GeminiNativeClient + + with patch( + "agent.gemini_native_adapter._fetch_gce_metadata_token", + return_value="ya29.fake", + ): + vertex = GeminiNativeClient( + api_key="ignored", base_url=VERTEX_GEMINI_BASE_WITH_VERB, + ) + genlang = GeminiNativeClient( + api_key="AIza-fake", base_url=GENLANG_BASE, + ) + + assert vertex._is_vertex is True + assert genlang._is_vertex is False + + +# --------------------------------------------------------------------------- +# 4. Auxiliary routing — the gate that prevents the silent 404 +# --------------------------------------------------------------------------- + + +def test_wrap_if_needed_rewraps_vertex_gemini_into_native_client(): + """Without this gate, the OpenAI SDK appends ``/chat/completions`` to a + URL ending in ``:generateContent`` and the request 404s silently. The + ``_wrap_if_needed`` closure inside ``resolve_provider_client`` must + detect the URL shape and rewrap the plain OpenAI client into a + GeminiNativeClient before any other adapter check. + + We can't easily call the closure directly (it captures locals), so we + drive the public ``resolve_provider_client`` entry point with the + ``custom`` provider — which is the path users hit via + ``custom_providers`` in ``config.yaml``. + """ + from agent.auxiliary_client import resolve_provider_client + from agent.gemini_native_adapter import GeminiNativeClient + + # Stub out GCE token + the OpenAI SDK constructor so the test stays + # offline and doesn't need real GCP credentials. + with patch( + "agent.gemini_native_adapter._fetch_gce_metadata_token", + return_value="ya29.fake", + ): + client, model = resolve_provider_client( + provider="custom", + model="gemini-3-flash-preview", + explicit_base_url=VERTEX_GEMINI_BASE_WITH_VERB, + explicit_api_key="ignored-on-vertex", + ) + + assert client is not None, "resolve_provider_client returned None" + assert isinstance(client, GeminiNativeClient), ( + f"Expected GeminiNativeClient, got {type(client).__name__}. " + "Without the Vertex Gemini gate the OpenAI SDK takes over and " + "every aux call (compress / title / vision / curator / web_extract) " + "silently 404s." + ) + # Vertex routes the model name in the URL path, so the resolver should + # surface the requested model unchanged. + assert "gemini-3-flash-preview" in (model or "") + + +if __name__ == "__main__": # pragma: no cover + pytest.main([__file__, "-v"])