Skip to content
Closed
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
31 changes: 28 additions & 3 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 "
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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
Expand Down
138 changes: 129 additions & 9 deletions agent/gemini_native_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<model>: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/<model>`` 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(
Expand Down Expand Up @@ -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


Expand All @@ -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

Expand Down Expand Up @@ -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/<model>: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
Expand All @@ -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 <api_key> 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
Expand Down
43 changes: 35 additions & 8 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading