Files
hermes-agent/agent/gemini_native_adapter.py
liuhao1024 c19246557a fix(gemini): stop rerouting AQ. keys off the default Studio surface
Google now issues AQ. keys for both Google AI Studio and Vertex AI
express mode, so the AQ. prefix no longer identifies the key family
(#115306): auto-routing every AQ. key to aiplatform.googleapis.com 403s
the whole AI Studio fleet (6293fca019 / df53cae72d).

- normalize_gemini_base_url no longer rewrites by key shape; an express
  key reaches aiplatform only through an explicitly configured base,
  which is still completed to the publishers/google form (#114335 path)
- gemini_http_error appends two-way 403 PERMISSION_DENIED guidance: an
  AQ. key rejected on the Studio host learns about the express base_url,
  a key rejected on an explicit aiplatform base learns about the default
- doctor's explicitly configured aiplatform base now also gets the
  publishers completion; the OAuth Vertex .../endpoints/openapi base
  stays untouched

Fixes #115306
2026-09-20 10:50:27 -07:00

877 lines
47 KiB
Python

"""OpenAI-compatible facade over Google AI Studio's native Gemini API: the ``gemini``
provider keeps ``api_mode='chat_completions'`` so the agent loop stays OpenAI-shaped,
and this shim converts ``messages[]``/``tools[]`` into ``models/{model}:generateContent``
payloads and responses back. Google's OpenAI-compat endpoint is brittle for the
multi-turn tool loop (auth churn, tool-call replay, thought signatures); native is canonical."""
from __future__ import annotations
import asyncio
import base64
import contextlib
import json
import logging
import re
import time
import uuid
from types import SimpleNamespace
from typing import Any, Dict, Iterator, List, Optional
import httpx
from agent.bounded_response import read_streaming_error_body
from agent.retry_utils import parse_retry_after_seconds
from agent.gemini_schema import prepare_gemini_tool_parameters, sanitize_gemini_tool_parameters
logger = logging.getLogger(__name__)
try:
import hermes_cli as _hermes_cli
_HERMES_VERSION = str(_hermes_cli.__version__)
except Exception:
_HERMES_VERSION = "0.0.0"
_API_CLIENT = f"hermes-agent/{_HERMES_VERSION}" # client context per Gemini's partner-integration guidance
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
# A Vertex AI express-mode base, when the user configures one explicitly: aiplatform serves the
# same native API under ``{version}/publishers/google/models/…``; the base carries that prefix so
# every ``{base}/models/{model}:…`` builder (chat, tier probe, TTS) needs no path branching.
# The key itself never decides routing — Google now issues ``AQ.…`` keys for BOTH Google AI Studio
# and Vertex express mode (#115306), so an express key reaches aiplatform only via this base config.
VERTEX_EXPRESS_BASE_URL = "https://aiplatform.googleapis.com/v1beta1/publishers/google"
# Published max output-token ceiling shared by every current Gemini text model; used
# for max_tokens=None because the native API's low internal default truncates output.
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
_FREE_TIER_GUIDANCE = (
"\n\nYour Google API key is on the free tier (a few hundred requests/day for Gemini Flash models). "
"Hermes typically makes 3-10 API calls per user turn, so the free tier is exhausted in a handful of "
"messages and cannot sustain an agent session. Enable billing on your Google Cloud project and "
"regenerate the key in a billing-enabled project: https://aistudio.google.com/apikey"
)
_STANDARD_KEY_GUIDANCE = (
"\n\nGoogle Gemini rejected this API key's type — you do NOT need OAuth. Google began rejecting legacy "
"'Standard' Google Cloud keys for the Gemini API on June 19, 2026, and all Standard keys stop working in "
"September 2026. Open https://aistudio.google.com/api-keys, check the key's type and status, and create a "
"replacement Gemini API key (or, as a temporary bridge, restrict the Standard key to "
"generativelanguage.googleapis.com). Then update GEMINI_API_KEY / GOOGLE_API_KEY in ~/.hermes/.env, "
"delete any leftover Windows/system copy of those variables, and restart. A stale shell key can hide "
"the .env value. Details: https://ai.google.dev/gemini-api/docs/api-key"
)
# Google issues ``AQ.…`` keys for both AI Studio and Vertex express mode; each surface only accepts
# its own keys, so a 403 usually means the key/host pairing is crossed rather than a bad key (#115306).
_EXPRESS_KEY_ON_STUDIO_GUIDANCE = (
"\n\nGoogle issues 'AQ.' keys for both Google AI Studio and Vertex AI express mode, and each surface "
"only accepts its own keys: a Vertex express key always gets 403 PERMISSION_DENIED on "
"generativelanguage.googleapis.com. If this key came from Google Cloud / Vertex (express mode), point "
"the provider at the express surface, e.g. set base_url / GEMINI_BASE_URL to "
"https://aiplatform.googleapis.com/v1beta1."
)
_STUDIO_KEY_ON_EXPRESS_GUIDANCE = (
"\n\nIf this 'AQ.' key came from Google AI Studio (https://aistudio.google.com/apikey), it only works "
"on the default generativelanguage.googleapis.com host — remove the explicit aiplatform base_url so "
"the default surface is used."
)
# Stands in for a model turn that never arrived (stream failure / interrupt / quota
# fallback) when a human user text turn directly follows a tool-result turn, keeping
# the request alternation-valid while the user's message stays a turn of its own
# (mirrors gemini-cli's placeholder repair).
_INTERRUPTED_RESPONSE_PLACEHOLDER = "[The previous response was interrupted before it completed.]"
# Cross-provider tool_calls (e.g. fallback from xAI/Anthropic) carry no Gemini thoughtSignature;
# without this sentinel Gemini 3 thinking models reject replayed history with 400 INVALID_ARGUMENT.
_SKIP_SIGNATURE = "skip_thought_signature_validator"
_END = object() # stream-exhausted marker for _advance_stream_iterator
_TOOL_CHOICE_MODES = {"auto": "AUTO", "required": "ANY", "none": "NONE"}
_FINISH_REASON_MAP = {
"STOP": "stop", "MAX_TOKENS": "length", "SAFETY": "content_filter", "RECITATION": "content_filter", "OTHER": "stop",
}
_HTTP_ERROR_CODES = {401: "gemini_unauthorized", 429: "gemini_rate_limited", 404: "gemini_model_not_found"}
_MISSING_KEY_ERROR = (
"Gemini native client requires an API key, but none was provided. Set GOOGLE_API_KEY or GEMINI_API_KEY in your "
"environment / ~/.hermes/.env (get one at https://aistudio.google.com/app/apikey), or run `hermes setup` to "
"configure the Google provider."
)
def bare_gemini_model_id(model: str) -> str:
"""Strip Gemini's own provider prefix from an aggregator-style model id."""
name = (model or "").strip()
for prefix in ("google/", "gemini/"):
if name.lower().startswith(prefix):
return name[len(prefix):].strip() or name
return name
def gemini_requires_tool_call_ids(model: str) -> bool:
"""Gemini 3+ needs explicit functionCall/functionResponse ids so replayed parallel tool calls
pair with their responses; 2.x rejects the field.
Gemini 3+ models require explicit tool call IDs in replayed history — without them, multi-tool turns can
be rejected or mismatched. Older Gemini models (2.x) reject unexpected ``id`` fields, so this is gated
on the major version. Mirrors earendil-works/pi#7494 (their fix for the same class of bug in the
google-shared converter).
"""
match = re.match(r"gemini-(\d+)", bare_gemini_model_id(model).lower())
return match is not None and int(match.group(1)) >= 3
_API_VERSION_SEGMENT = re.compile(r"^v\d+(?:alpha|beta)?\d*$", re.IGNORECASE)
def is_vertex_express_base_url(base_url: str) -> bool:
"""An aiplatform host without a project path — the express surface. The OAuth Vertex provider's
``…/projects/{p}/locations/{r}/endpoints/openapi`` base is OpenAI-compatible and must stay off
the native adapter."""
normalized = str(base_url or "").strip().lower()
return "aiplatform.googleapis.com" in normalized and "/projects/" not in normalized
def normalize_gemini_base_url(base_url: Optional[str]) -> str:
"""Gemini native base URL with the API version segment guaranteed. Google's own client treats the
base as a host root and appends the version itself, so users configure ``GEMINI_BASE_URL`` (or a
proxy root like ``http://localhost:4000/gemini``) that way; our request builders expect
``{base}/models/{model}:generateContent`` — without ``/v1beta`` that is a guaranteed 404. Trailing
slashes and an ``/openai`` suffix are stripped; an existing version segment (``v1``, ``v1beta``,
``v1alpha``, ...) is kept; empty input returns ``DEFAULT_GEMINI_BASE_URL``. Only the LAST path
segment is inspected, so ``.../v1beta/extra`` still gets ``/v1beta`` appended; this does not
decide routing (see ``is_native_gemini_base_url``).
A Vertex express base (``aiplatform.googleapis.com``, ``…/v1beta1`` or the full
``…/v1beta1/publishers/google``) is completed to the ``publishers/google`` form. The key never
decides routing: Google issues ``AQ.…`` keys for both AI Studio and Vertex express mode
(#115306), so an express key reaches aiplatform only through this explicit base configuration."""
trimmed = str(base_url or "").strip().rstrip("/")
trimmed = re.sub(r"/openai\Z", "", trimmed, flags=re.IGNORECASE).rstrip("/")
if not trimmed:
trimmed = DEFAULT_GEMINI_BASE_URL
if is_vertex_express_base_url(trimmed):
if trimmed.lower().endswith("/publishers/google"):
return trimmed
if not _API_VERSION_SEGMENT.match(trimmed.rsplit("/", 1)[-1]):
trimmed = f"{trimmed}/v1beta1"
return f"{trimmed}/publishers/google"
if _API_VERSION_SEGMENT.match(trimmed.rsplit("/", 1)[-1]):
return trimmed
return f"{trimmed}/v1beta"
def is_native_gemini_base_url(base_url: str) -> bool:
"""True when the endpoint speaks Gemini's native REST API (not ``/openai``)."""
normalized = str(base_url or "").strip().rstrip("/").lower()
if normalized.endswith("/openai"):
return False
return "generativelanguage.googleapis.com" in normalized or is_vertex_express_base_url(normalized)
def gemini_accepts_parameters_json_schema(base_url: str) -> bool:
"""``FunctionDeclaration.parametersJsonSchema`` exists only in the ``v1beta`` surface of
generativelanguage (absent from ``v1`` / ``v1alpha`` content.proto); other versions and
unknown proxies get the legacy ``parameters`` subset."""
return str(base_url or "").strip().rstrip("/").lower().endswith("/v1beta")
def probe_gemini_tier(
api_key: str, base_url: str = DEFAULT_GEMINI_BASE_URL, *, model: str = "gemini-3.7-flash", timeout: float = 10.0
) -> str:
"""Probe a Google AI Studio key → ``"free"`` | ``"paid"`` | ``"unknown"`` (probe failed; callers proceed without blocking)."""
key = (api_key or "").strip()
if not key:
return "unknown"
base = normalize_gemini_base_url(base_url)
payload = {"contents": [{"role": "user", "parts": [{"text": "hi"}]}], "generationConfig": {"maxOutputTokens": 1}}
headers = {"Content-Type": "application/json", "X-Goog-Api-Client": _API_CLIENT}
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(f"{base}/models/{model}:generateContent", params={"key": key}, json=payload, headers=headers)
except Exception as exc:
logger.debug("probe_gemini_tier: network error: %s", exc)
return "unknown"
rpd_header = {k.lower(): v for k, v in resp.headers.items()}.get("x-ratelimit-limit-requests-per-day")
try:
if rpd_header: # free-tier daily caps top out at 1000 (flash-lite); Tier 1 starts ~1500+
return "free" if int(rpd_header) <= 1000 else "paid"
except (TypeError, ValueError):
pass
if resp.status_code == 429:
return "free" if "free_tier" in _response_text(resp).lower() else "paid"
return "paid" if 200 <= resp.status_code < 300 else "unknown"
def _response_text(response: Any) -> str:
try:
return response.text or ""
except Exception:
return ""
def is_free_tier_quota_error(error_message: str) -> bool:
"""True when a Gemini 429 message indicates free-tier exhaustion."""
return bool(error_message) and "free_tier" in error_message.lower()
def wrong_gemini_surface_guidance(base_url: str, api_key: str, err_status: str) -> str:
"""Guidance text for the 403 PERMISSION_DENIED key/host mismatches Google's AQ. rollout created.
Google issues ``AQ.…`` keys for both AI Studio and Vertex express mode, so neither the key prefix
nor the host alone proves the pairing; on a PERMISSION_DENIED, point the user at the surface their
key likely belongs to: an AQ. key rejected by the Studio host may be an express key (the explicit
aiplatform base_url is that key's only route there), and any key rejected by an explicitly
configured aiplatform base may be an AI Studio key (#115306). Empty string when the shape matches
neither."""
if (err_status or "").strip().upper() != "PERMISSION_DENIED":
return ""
normalized = str(base_url or "").strip().lower()
if is_vertex_express_base_url(normalized):
return _STUDIO_KEY_ON_EXPRESS_GUIDANCE
if "generativelanguage.googleapis.com" in normalized and str(api_key or "").strip().upper().startswith("AQ."):
return _EXPRESS_KEY_ON_STUDIO_GUIDANCE
return ""
def is_standard_key_auth_error(
status: int, error_message: str, reason: str = "", *, api_key: str = "",
) -> bool:
"""True when Google rejected a legacy "Standard" (AIza) Cloud key.
Original shape (June 2026): 401 + ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` / "expected OAuth 2
access token". After the September 2026 cutoff the same leftover AIza key often comes
back as 400 ``API_KEY_INVALID`` ("API key not valid") — attach migration guidance on
that 400 only when the presented key is still AIza-shaped, so a mistyped Auth (``AQ.``)
key keeps the raw invalid-key message.
"""
msg = (error_message or "").lower()
if status == 401 and (reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED" or "expected oauth 2 access token" in msg):
return True
if (
status == 400
and (api_key or "").startswith("AIza")
and (reason == "API_KEY_INVALID" or "api key not valid" in msg)
):
return True
return False
class GeminiAPIError(Exception):
"""Error shape compatible with Hermes retry/error classification."""
def __init__(self, message: str, *, code: str = "gemini_api_error", status_code: Optional[int] = None,
response: Optional[httpx.Response] = None, retry_after: Optional[float] = None, details: Optional[Dict[str, Any]] = None):
super().__init__(message)
self.code, self.status_code, self.response = code, status_code, response
self.retry_after, self.details = retry_after, details or {}
# ── OpenAI → Gemini request translation ──────────────────────────────────────
def _text_of(item: Any) -> Optional[str]:
"""Text of an OpenAI content item (plain str or ``{"type": "text"}`` dict), else None."""
if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str):
return item["text"]
return item if isinstance(item, str) else None
def _coerce_content_to_text(content: Any) -> str:
if isinstance(content, list):
return "\n".join(t for t in map(_text_of, content) if t is not None)
return "" if content is None else str(content)
def _inline_data_part(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""``inlineData`` part for an ``image_url`` item carrying a ``data:`` URL; None otherwise."""
url = (item.get("image_url") or {}).get("url") or ""
if item.get("type") != "image_url" or not isinstance(url, str) or not url.startswith("data:"):
return None
try:
header, encoded = url.split(",", 1)
data = base64.b64encode(base64.b64decode(encoded)).decode("ascii")
return {"inlineData": {"mimeType": header.split(":", 1)[1].split(";", 1)[0], "data": data}}
except Exception:
return None
def _multimodal_part(item: Any) -> Optional[Dict[str, Any]]:
text = _text_of(item)
if text or isinstance(item, str):
return {"text": text}
return _inline_data_part(item) if isinstance(item, dict) else None
def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]:
if isinstance(content, list):
return [p for p in map(_multimodal_part, content) if p]
text = _coerce_content_to_text(content)
return [{"text": text}] if text else []
def _tool_call_extra_signature(tool_call: Dict[str, Any]) -> Optional[str]:
"""Replayed Gemini thoughtSignature from ``extra_content`` (``google.thought_signature`` or flat)."""
extra = tool_call.get("extra_content") or {}
sig = (extra.get("google") or extra.get("thought_signature")) if isinstance(extra, dict) else None
if isinstance(sig, dict):
sig = sig.get("thought_signature") or sig.get("thoughtSignature")
return sig if isinstance(sig, str) and sig else None
def _tool_call_id(tool_call: Dict[str, Any]) -> str:
return str(tool_call.get("id") or tool_call.get("call_id") or "")
def _translate_tool_call_to_gemini(tool_call: Dict[str, Any], include_ids: bool = False) -> Dict[str, Any]:
fn = tool_call.get("function") or {}
args_raw = fn.get("arguments", "")
try:
args = json.loads(args_raw) if isinstance(args_raw, str) and args_raw else {}
except json.JSONDecodeError:
args = {"_raw": args_raw}
call: Dict[str, Any] = {"name": str(fn.get("name") or ""), "args": args if isinstance(args, dict) else {"_value": args}}
if include_ids and (call_id := _tool_call_id(tool_call)):
call["id"] = call_id
return {"functionCall": call, "thoughtSignature": _tool_call_extra_signature(tool_call) or _SKIP_SIGNATURE}
def _looks_like_json_schema(node: Any) -> bool:
"""True if a parsed value contains a JSON-Schema ``$ref`` pointer (``#/...``): Gemini 3 resolves
``$ref``/``$defs`` inside functionResponse.response and rejects unknown pointers with HTTP 400, so a
tool result that is itself a JSON Schema (e.g. ``tool_describe`` output) is forwarded as opaque text.
False positives only lose the structured shape, never the content."""
if isinstance(node, dict):
return any((k == "$ref" and isinstance(v, str) and v.startswith("#/")) or _looks_like_json_schema(v) for k, v in node.items())
return isinstance(node, list) and any(_looks_like_json_schema(item) for item in node)
def _translate_tool_result_to_gemini(
message: Dict[str, Any], tool_name_by_call_id: Optional[Dict[str, str]] = None, include_ids: bool = False, *, is_gemini3: bool = False,
) -> Dict[str, Any]:
tool_call_id = str(message.get("tool_call_id") or "")
# functionResponse.name must echo the matching functionCall.name, so the call-id
# mapping beats the result's own name (may be an unwrapped MCP name via `tool_call`).
name = str((tool_name_by_call_id or {}).get(tool_call_id) or message.get("name") or tool_call_id or "tool")
raw_content = message.get("content")
content = _coerce_content_to_text(raw_content)
try:
parsed = json.loads(content) if content.strip().startswith(("{", "[")) else None
except json.JSONDecodeError:
parsed = None
# Gemini 3 resolves JSON-Schema ``$ref`` pointers inside a functionResponse.response payload and rejects
# unknown references with HTTP 400 INVALID_ARGUMENT ("referenced name '#/$defs/...' does not match a
# display_name"; see vercel/ai#14369). A tool result that is itself a JSON Schema (e.g. tool_describe
# output for an MCP tool) must therefore be forwarded as opaque text, not as a structured response.
structured = isinstance(parsed, dict) and not _looks_like_json_schema(parsed)
function_response: Dict[str, Any] = {"name": name, "response": parsed if structured else {"output": content}}
if include_ids and tool_call_id:
function_response["id"] = tool_call_id
# Gemini 3.x accepts images inside functionResponse.parts; 2.x rejects the field.
if image_parts := [p for p in _extract_multimodal_parts(raw_content) if "inlineData" in p] if is_gemini3 else []:
function_response["parts"] = image_parts
return {"functionResponse": function_response}
def _has_function_response(content: Dict[str, Any]) -> bool:
return any(isinstance(part, dict) and "functionResponse" in part for part in content.get("parts", []))
def _merge_alternating(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Alternation contract for generateContent: 1) adjacent same-role contents merge (else HTTP 400
"multiturn requests [must] alternate"); 2) EXCEPT never fuse a human user text turn into a preceding
user content that only carries functionResponse parts (or vice versa) — Gemini 3 accepts the fold but
reads the text as a continuation of the tool result and returns an empty candidate (parallel
functionResponse + functionResponse still merge); 3) the split pair stays API-valid via an interposed
placeholder model turn."""
merged: List[Dict[str, Any]] = []
# Compatibility contract for native Gemini generateContent: 1) Same-role adjacent contents still merge
# in general (strict user/model alternation for ordinary text turns and parallel tool-result grouping;
# consecutive same-role contents are rejected with HTTP 400 "Please ensure that multiturn requests
# alternate between user and model"). 2) Exception: do NOT fuse a human user text turn into a preceding
# user content that only carries functionResponse parts (or vice versa). Gemini 3 accepts that fold with
# HTTP 200 but then reads the trailing text as a continuation of the tool result — it returns an empty
# candidate or "finishes the user's sentence" instead of answering (same defect gemini-cli fixed in
# google-gemini/gemini-cli#28700). 3) Because rule 1's HTTP 400 makes two consecutive user contents
# unsafe to emit (#55125 — the reason this merge exists), the split pair is kept API-valid by
# interposing a placeholder model turn between the functionResponse content and the human text content,
# mirroring gemini-cli's INTERRUPTED_RESPONSE_PLACEHOLDER repair.
for content in contents:
prev = merged[-1] if merged else None
same_role = prev is not None and prev["role"] == content["role"]
if same_role and content["role"] == "user" and _has_function_response(prev) != _has_function_response(content):
same_role = False
merged.append({"role": "model", "parts": [{"text": _INTERRUPTED_RESPONSE_PLACEHOLDER}]})
if same_role:
merged[-1]["parts"].extend(content["parts"])
else:
merged.append(content)
return merged
def _build_gemini_contents(
messages: List[Dict[str, Any]], include_tool_call_ids: bool = False, *, is_gemini3: bool = False
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
system_text_parts: List[str] = []
contents: List[Dict[str, Any]] = []
tool_name_by_call_id: Dict[str, str] = {}
for msg in messages:
if not isinstance(msg, dict):
continue
role = str(msg.get("role") or "user")
if role == "system":
system_text_parts.append(_coerce_content_to_text(msg.get("content")))
continue
if role in {"tool", "function"}:
part = _translate_tool_result_to_gemini(msg, tool_name_by_call_id, include_tool_call_ids, is_gemini3=is_gemini3)
contents.append({"role": "user", "parts": [part]})
continue
parts = _extract_multimodal_parts(msg.get("content"))
tool_calls = msg.get("tool_calls") or []
for tool_call in (tc for tc in tool_calls if isinstance(tc, dict)) if isinstance(tool_calls, list) else ():
tool_name = str((tool_call.get("function") or {}).get("name") or "")
if (call_id := _tool_call_id(tool_call)) and tool_name:
tool_name_by_call_id[call_id] = tool_name
parts.append(_translate_tool_call_to_gemini(tool_call, include_ids=include_tool_call_ids))
if parts:
contents.append({"role": "model" if role == "assistant" else "user", "parts": parts})
joined_system = "\n".join(part for part in system_text_parts if part).strip()
return _merge_alternating(contents), ({"role": "system", "parts": [{"text": joined_system}]} if joined_system else None)
def _function_declaration(tool: Any, *, json_schema: bool = False) -> Optional[Dict[str, Any]]:
fn = (tool.get("function") or {}) if isinstance(tool, dict) else None
if not isinstance(fn, dict) or not (isinstance(fn.get("name"), str) and fn["name"]):
return None
decl: Dict[str, Any] = {"name": fn["name"]}
if isinstance(fn.get("description"), str) and fn["description"]:
decl["description"] = fn["description"]
if isinstance(fn.get("parameters"), dict):
# Full JSON Schema where the API version has the field (unions, bare arrays,
# $ref survive); the lossy OpenAPI subset elsewhere. Mutually exclusive on the wire.
if json_schema:
decl["parametersJsonSchema"] = prepare_gemini_tool_parameters(fn["parameters"])
else:
decl["parameters"] = sanitize_gemini_tool_parameters(fn["parameters"])
return decl
def _translate_tools_to_gemini(tools: Any, *, json_schema: bool = False) -> List[Dict[str, Any]]:
declarations = [d for d in (_function_declaration(t, json_schema=json_schema)
for t in (tools if isinstance(tools, list) else [])) if d]
return [{"functionDeclarations": declarations}] if declarations else []
def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[Dict[str, Any]]:
if isinstance(tool_choice, str) and tool_choice in _TOOL_CHOICE_MODES:
return {"functionCallingConfig": {"mode": _TOOL_CHOICE_MODES[tool_choice]}}
name = (tool_choice.get("function") or {}).get("name") if isinstance(tool_choice, dict) else None
return {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": [name]}} if isinstance(name, str) and name else None
# (camelCase key, snake_case alias, accepted types, normalizer)
_THINKING_KEYS = (
("thinkingBudget", "thinking_budget", (int, float), int),
("includeThoughts", "include_thoughts", bool, lambda v: v),
("thinkingLevel", "thinking_level", str, lambda v: v.strip().lower()),
)
def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]:
if not isinstance(config, dict):
return None
values = {key: config.get(key, config.get(alias)) for key, alias, _, _ in _THINKING_KEYS}
normalized = {key: norm(values[key]) for key, _, types, norm in _THINKING_KEYS
if isinstance(values[key], types) and (values[key].strip() if isinstance(values[key], str) else True)}
return normalized or None
def _thinking_requests_output_headroom(thinking_config: Any) -> bool:
"""True when Gemini will spend output tokens on thinking: thought tokens bill against ``maxOutputTokens``,
so a global 4096/16384 cap can be consumed entirely by high thinking (``finishReason=MAX_TOKENS``, no answer)."""
normalized = _normalize_thinking_config(thinking_config) or {}
budget, has_level = normalized.get("thinkingBudget"), "thinkingLevel" in normalized
if normalized.get("includeThoughts") is False:
return has_level or bool(budget)
return bool(normalized) and not (isinstance(budget, int) and budget <= 0 and not has_level)
def _effective_gemini_max_output_tokens(max_tokens: Optional[int], thinking_config: Any) -> int:
"""Native ``maxOutputTokens``: an omitted/invalid cap becomes the published ceiling (Gemini
truncates on its low internal default); an explicit cap is raised to the ceiling when
thinking is enabled so thoughts don't starve the answer."""
try:
requested = int(max_tokens)
except (TypeError, ValueError):
requested = 0
if requested <= 0 or _thinking_requests_output_headroom(thinking_config):
return max(requested, GEMINI_DEFAULT_MAX_OUTPUT_TOKENS)
return requested
def build_gemini_request(
*, messages: List[Dict[str, Any]], tools: Any = None, tool_choice: Any = None, temperature: Optional[float] = None,
max_tokens: Optional[int] = None, top_p: Optional[float] = None, stop: Any = None, thinking_config: Any = None,
model: str = "", tools_as_json_schema: bool = False,
) -> Dict[str, Any]:
# Gemini 3+ both requires tool-call ids and accepts multimodal functionResponse parts.
is_gemini3 = gemini_requires_tool_call_ids(model)
contents, system_instruction = _build_gemini_contents(messages, include_tool_call_ids=is_gemini3, is_gemini3=is_gemini3)
optional = (
("systemInstruction", system_instruction),
("tools", _translate_tools_to_gemini(tools, json_schema=tools_as_json_schema)),
("toolConfig", _translate_tool_choice_to_gemini(tool_choice)),
)
request: Dict[str, Any] = {"contents": contents, **{k: v for k, v in optional if v}}
# Key order is part of the wire format (prompt-cache parity): temperature, maxOutputTokens, topP, stop, thinking.
generation = (
("temperature", temperature), ("maxOutputTokens", _effective_gemini_max_output_tokens(max_tokens, thinking_config)),
("topP", top_p), ("stopSequences", (stop if isinstance(stop, list) else [str(stop)]) if stop else None),
("thinkingConfig", _normalize_thinking_config(thinking_config)),
)
request["generationConfig"] = {k: v for k, v in generation if v is not None}
return request
# ── Gemini → OpenAI response translation ─────────────────────────────────────
def _tool_call_extra_from_part(part: Dict[str, Any]) -> Optional[Dict[str, Any]]:
sig = part.get("thoughtSignature")
return {"google": {"thought_signature": sig}} if isinstance(sig, str) and sig else None
def _provider_call_id(fc: Dict[str, Any]) -> Optional[str]:
fc_id = fc.get("id")
return fc_id if isinstance(fc_id, str) and fc_id else None
def _new_call_id(fc: Dict[str, Any]) -> str:
"""Echo the functionCall/delta ``id`` when present, else mint an OpenAI-style one."""
return _provider_call_id(fc) or f"call_{uuid.uuid4().hex[:12]}"
def _dump_call_args(fc: Dict[str, Any], **kwargs: Any) -> str:
try:
return json.dumps(fc.get("args") or {}, ensure_ascii=False, **kwargs)
except (TypeError, ValueError):
return "{}"
def _usage_from_metadata(usage_meta: Dict[str, Any]) -> SimpleNamespace:
count = lambda key: int(usage_meta.get(key) or 0) # noqa: E731
return SimpleNamespace(
prompt_tokens=count("promptTokenCount"), completion_tokens=count("candidatesTokenCount"),
total_tokens=count("totalTokenCount"), prompt_tokens_details=SimpleNamespace(cached_tokens=count("cachedContentTokenCount")),
)
def _envelope(model: str, object_: str, choice: SimpleNamespace, usage: Any, cls: type = SimpleNamespace) -> Any:
"""OpenAI chat.completion / chat.completion.chunk envelope around one choice."""
return cls(id=f"chatcmpl-{uuid.uuid4().hex[:12]}", object=object_, created=int(time.time()), model=model,
choices=[choice], usage=usage)
def _tool_call_ns(name: str, arguments: str, index: int, call_id: str, extra_content: Any) -> SimpleNamespace:
"""OpenAI-shaped tool call; ``extra_content`` attached only when it is a dict."""
extra = {"extra_content": extra_content} if isinstance(extra_content, dict) else {}
return SimpleNamespace(id=call_id, type="function", index=index, function=SimpleNamespace(name=name, arguments=arguments), **extra)
def _part_text(part: Dict[str, Any]) -> tuple[Optional[str], bool]:
"""``(text, is_thought)`` for a candidate part; ``(None, False)`` when it carries no text."""
text = part.get("text")
return (text, part.get("thought") is True) if isinstance(text, str) else (None, False)
def _part_function_call(part: Dict[str, Any]) -> Optional[Dict[str, Any]]:
fc = part.get("functionCall")
return fc if isinstance(fc, dict) and fc.get("name") else None
def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespace:
candidates = resp.get("candidates") or []
cand = parts = None
if isinstance(candidates, list) and candidates:
cand = candidates[0] if isinstance(candidates[0], dict) else {}
content_obj = cand.get("content")
parts = content_obj.get("parts") if isinstance(content_obj, dict) else []
pieces: Dict[bool, List[str]] = {False: [], True: []} # is_thought → text pieces
tool_calls: List[SimpleNamespace] = []
for index, part in enumerate(parts or []):
if not isinstance(part, dict):
continue
text, is_thought = _part_text(part)
if text is not None:
pieces[is_thought].append(text)
elif fc := _part_function_call(part):
tool_calls.append(_tool_call_ns(str(fc["name"]), _dump_call_args(fc), index, _new_call_id(fc), _tool_call_extra_from_part(part)))
finish_reason = "tool_calls" if tool_calls else _FINISH_REASON_MAP.get(str((cand or {}).get("finishReason") or "").upper(), "stop")
usage = _usage_from_metadata((resp.get("usageMetadata") or {}) if cand is not None else {})
reasoning = "".join(pieces[True]) or None
message = SimpleNamespace(role="assistant", content="".join(pieces[False]) if pieces[False] else ("" if cand is None else None),
tool_calls=tool_calls or None, reasoning=reasoning, reasoning_content=reasoning, reasoning_details=None)
return _envelope(model, "chat.completion", SimpleNamespace(index=0, message=message, finish_reason=finish_reason), usage)
class _GeminiStreamChunk(SimpleNamespace): ...
def _make_stream_chunk(
*, model: str, content: str = "", tool_call_delta: Optional[Dict[str, Any]] = None, finish_reason: Optional[str] = None, reasoning: str = "",
) -> _GeminiStreamChunk:
d = tool_call_delta
tool_calls = None if d is None else [
_tool_call_ns(d.get("name") or "", d.get("arguments") or "", d.get("index", 0), _new_call_id(d), d.get("extra_content"))
]
delta = SimpleNamespace(role="assistant", content=content or None, tool_calls=tool_calls, reasoning=reasoning or None,
reasoning_content=reasoning or None)
choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)
return _envelope(model, "chat.completion.chunk", choice, None, cls=_GeminiStreamChunk)
_SSE_DONE = object() # sentinel: terminal [DONE] frame
def _parse_sse_line(line: str) -> Any:
"""One SSE line → payload dict, ``_SSE_DONE`` for the terminal frame, or None."""
line = line.rstrip("\r")
if not line.startswith("data: "):
return None
if (data := line[6:]) == "[DONE]":
return _SSE_DONE
try:
payload = json.loads(data)
except json.JSONDecodeError:
logger.debug("Non-JSON Gemini SSE line: %s", data[:200])
return None
return payload if isinstance(payload, dict) else None
def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
buffer = ""
for chunk in response.iter_text():
buffer += chunk or ""
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
payload = _parse_sse_line(line)
if payload is _SSE_DONE:
return
if payload is not None:
yield payload
# The final frame may not be newline-terminated: flush the residual buffer
# after EOF instead of silently dropping its content (pi#8997 bug class).
if buffer:
payload = _parse_sse_line(buffer)
if payload is not None and payload is not _SSE_DONE:
yield payload
def _tool_call_slot(fc: Dict[str, Any], part: Dict[str, Any], part_index: int, args_str: str,
tool_call_indices: Dict[str, Dict[str, Any]]) -> tuple[str, Optional[Dict[str, Any]]]:
"""``(key, existing slot or None)`` for a streamed functionCall.
Gemini 3 ids each tool call, so the id is the slot identity (``part_index`` and the thought
signature drift across events of one call). Gemini 2.5 sends no id and ``part_index`` restarts
at 0 per event, so two different calls to one tool in separate events would share a slot and
have their arguments concatenated into unparseable JSON: Gemini re-sends full arguments, so a
payload that is not a prefix-extension (or resend) of the slot's accumulated arguments is a
different call and gets its own ``key#N`` slot, kept reachable so its own resend lands on it.
"""
if fc_id := _provider_call_id(fc):
key = json.dumps({"provider_call_id": fc_id}, sort_keys=True)
return key, tool_call_indices.get(key)
thought_signature = part.get("thoughtSignature") if isinstance(part.get("thoughtSignature"), str) else ""
key = json.dumps({"part_index": part_index, "name": fc["name"], "thought_signature": thought_signature}, sort_keys=True)
slot = tool_call_indices.get(key)
if slot is None or args_str.startswith(slot["last_arguments"]):
return key, slot
for other_key, other in tool_call_indices.items():
if other_key.startswith(f"{key}#") and args_str.startswith(other["last_arguments"]):
return other_key, other
return f"{key}#{len(tool_call_indices)}", None
def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: Dict[str, Dict[str, Any]]) -> List[_GeminiStreamChunk]:
candidates = event.get("candidates") or []
if not candidates:
return []
cand = candidates[0] if isinstance(candidates[0], dict) else {}
parts = (cand.get("content") or {}).get("parts") or []
chunks: List[_GeminiStreamChunk] = []
for part_index, part in enumerate(parts):
if not isinstance(part, dict):
continue
text, is_thought = _part_text(part)
if is_thought:
chunks.append(_make_stream_chunk(model=model, reasoning=text))
continue
if text:
chunks.append(_make_stream_chunk(model=model, content=text))
if fc := _part_function_call(part):
name = str(fc["name"])
args_str = _dump_call_args(fc, sort_keys=True)
call_key, slot = _tool_call_slot(fc, part, part_index, args_str, tool_call_indices)
if slot is None:
slot = tool_call_indices[call_key] = {"index": len(tool_call_indices), "id": _new_call_id(fc), "last_arguments": ""}
# Gemini re-sends the full args each event; emit only the new suffix.
last_arguments = slot["last_arguments"]
slot["last_arguments"] = args_str
delta = {"index": slot["index"], "id": slot["id"], "name": name, "extra_content": _tool_call_extra_from_part(part),
"arguments": args_str[len(last_arguments):] if args_str.startswith(last_arguments) else args_str}
chunks.append(_make_stream_chunk(model=model, tool_call_delta=delta))
if finish_reason_raw := str(cand.get("finishReason") or ""):
finish_reason = "tool_calls" if tool_call_indices else _FINISH_REASON_MAP.get(finish_reason_raw.upper(), "stop")
finish_chunk = _make_stream_chunk(model=model, finish_reason=finish_reason)
if usage_meta := event.get("usageMetadata") or {}: # rides on the finish chunk so the stream loop records tokens
finish_chunk.usage = _usage_from_metadata(usage_meta)
chunks.append(finish_chunk)
return chunks
def _error_info(err_obj: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
"""``(reason, metadata)`` from the first google.rpc.ErrorInfo detail (later ones fill gaps until reason is set)."""
reason, metadata = "", {}
details = err_obj.get("details")
for detail in details if isinstance(details, list) else []:
if isinstance(detail, dict) and not reason and str(detail.get("@type") or "").endswith("/google.rpc.ErrorInfo"):
reason = detail["reason"] if isinstance(detail.get("reason"), str) else reason
metadata = detail["metadata"] if isinstance(detail.get("metadata"), dict) else metadata
return reason, metadata
def _error_object(body_text: str) -> Dict[str, Any]:
"""The ``error`` object of a Google JSON error body, or ``{}``."""
try:
parsed = json.loads(body_text) if body_text else None
except (ValueError, TypeError):
return {}
err_obj = parsed.get("error") if isinstance(parsed, dict) else None
return err_obj if isinstance(err_obj, dict) else {}
def gemini_http_error(
response: httpx.Response, *, body_text: Optional[str] = None, api_key: str = "", base_url: str = "",
) -> GeminiAPIError:
status = response.status_code
body_text = (_response_text(response) if body_text is None else body_text) or ""
err_obj = _error_object(body_text)
err_status, err_message = (str(err_obj.get(k) or "").strip() for k in ("status", "message"))
reason, metadata = _error_info(err_obj)
retry_after = parse_retry_after_seconds(response.headers)
message = (
f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}" if err_message
else f"Gemini returned HTTP {status}: {body_text[:500]}"
)
# Users who bypassed the setup wizard (raw GOOGLE_API_KEY in .env) still need to learn the free
# tier cannot sustain an agent session; a legacy "Standard" key gets the real fix (Google's raw
# 401 asks for OAuth; after Sept 2026 the same AIza key is often a 400 API_KEY_INVALID).
if status == 429 and is_free_tier_quota_error(err_message or body_text):
message += _FREE_TIER_GUIDANCE
if is_standard_key_auth_error(status, err_message or body_text, reason, api_key=api_key):
message += _STANDARD_KEY_GUIDANCE
if status == 403:
message += wrong_gemini_surface_guidance(base_url, api_key, err_status)
return GeminiAPIError(
message, code=_HTTP_ERROR_CODES.get(status, f"gemini_http_{status}"), status_code=status, response=response,
retry_after=retry_after, details={"status": err_status, "reason": reason, "metadata": metadata, "message": err_message},
)
class GeminiNativeClient:
"""Minimal OpenAI-SDK-compatible facade (``client.chat.completions.create(**kwargs)``) over Gemini's native REST API."""
# For agent/auxiliary_client.py: a complete client, never re-dispatched through a wire adapter.
# (No HERMES_SKIP_ASYNC_WRAP — the async path has a real conversion, AsyncGeminiNativeClient.)
HERMES_SKIP_TRANSPORT_WRAP = True
def __init__(
self, *, api_key: str, base_url: Optional[str] = None, default_headers: Optional[Dict[str, str]] = None,
timeout: Any = None, http_client: Optional[httpx.Client] = None, **_: Any,
) -> None:
if not (api_key or "").strip():
raise RuntimeError(_MISSING_KEY_ERROR)
self.api_key, self.is_closed = api_key, False
self.base_url = normalize_gemini_base_url(base_url)
self._default_headers = dict(default_headers or {})
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion))
self._http = http_client or httpx.Client(timeout=timeout or httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0))
def close(self) -> None:
self.is_closed = True
with contextlib.suppress(Exception):
self._http.close()
# OpenAI-client duck-type surface: callers may use ``with client:``.
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def _headers(self) -> Dict[str, str]:
return {"Content-Type": "application/json", "Accept": "application/json", "x-goog-api-key": self.api_key,
"User-Agent": f"{_API_CLIENT} (gemini-native)", "X-Goog-Api-Client": _API_CLIENT, **self._default_headers}
@staticmethod
def _advance_stream_iterator(iterator: Iterator[_GeminiStreamChunk]) -> tuple[bool, Optional[_GeminiStreamChunk]]:
chunk = next(iterator, _END)
return (True, None) if chunk is _END else (False, chunk)
def _create_chat_completion(
self, *, model: str = "gemini-3.7-flash", messages: Optional[List[Dict[str, Any]]] = None, stream: bool = False,
tools: Any = None, tool_choice: Any = None, temperature: Optional[float] = None, max_tokens: Optional[int] = None,
top_p: Optional[float] = None, stop: Any = None, extra_body: Optional[Dict[str, Any]] = None, timeout: Any = None, **_: Any,
) -> Any:
extra = extra_body if isinstance(extra_body, dict) else {}
request = build_gemini_request(
messages=messages or [], tools=tools, tool_choice=tool_choice, temperature=temperature, max_tokens=max_tokens,
top_p=top_p, stop=stop, thinking_config=extra.get("thinking_config") or extra.get("thinkingConfig"), model=model,
tools_as_json_schema=gemini_accepts_parameters_json_schema(self.base_url),
)
model = bare_gemini_model_id(model)
url = f"{self.base_url}/models/{model}:"
if stream:
return self._stream_completion(model, url + "streamGenerateContent?alt=sse", request, timeout)
response = self._http.post(url + "generateContent", json=request, headers=self._headers(), timeout=timeout)
if response.status_code != 200:
raise gemini_http_error(response, api_key=self.api_key, base_url=self.base_url)
try:
payload = response.json()
except ValueError as exc:
raise GeminiAPIError(
f"Invalid JSON from Gemini native API: {exc}", code="gemini_invalid_json", status_code=response.status_code, response=response,
) from exc
return translate_gemini_response(payload, model=model)
def _stream_completion(self, model: str, url: str, request: Dict[str, Any], timeout: Any) -> Iterator[_GeminiStreamChunk]:
try:
headers = {**self._headers(), "Accept": "text/event-stream"}
with self._http.stream("POST", url, json=request, headers=headers, timeout=timeout) as response:
if response.status_code != 200:
raise gemini_http_error(
response, body_text=read_streaming_error_body(response), api_key=self.api_key, base_url=self.base_url,
)
tool_call_indices: Dict[str, Dict[str, Any]] = {}
for event in _iter_sse_events(response):
yield from translate_stream_event(event, model, tool_call_indices)
except httpx.HTTPError as exc:
raise GeminiAPIError(f"Gemini streaming request failed: {exc}", code="gemini_stream_error") from exc
class AsyncGeminiNativeClient:
"""Async wrapper used by auxiliary_client for native Gemini calls."""
def __init__(self, sync_client: GeminiNativeClient):
# ``_real_client``: the auxiliary cache evicts entries by leaf client; GeminiNativeClient is itself the leaf.
self._sync = self._real_client = sync_client
self.api_key, self.base_url = sync_client.api_key, sync_client.base_url
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion))
# Expose the underlying sync client as _real_client so the auxiliary cache's eviction-by-leaf-client
# helper (#23482) can find and drop this async entry when the sync GeminiNativeClient is poisoned.
# GeminiNativeClient is itself the leaf (no OpenAI client beneath it), so we point at the sync_client
# directly.
async def _create_chat_completion(self, **kwargs: Any) -> Any:
result = await asyncio.to_thread(self._sync.chat.completions.create, **kwargs)
return self._async_stream(result) if kwargs.get("stream") else result
async def _async_stream(self, iterator: Iterator[_GeminiStreamChunk]) -> Any:
while not (step := await asyncio.to_thread(self._sync._advance_stream_iterator, iterator))[0]:
yield step[1]
async def close(self) -> None:
await asyncio.to_thread(self._sync.close)