Files
hermes-agent/agent/gemini_native_adapter.py
T
Teknium 1db2452272 refactor(agent/adapters): simplify gemini, vertex, moonshot adapters (-607 LOC)
Collapse the four Gemini chat/completions wrapper classes into SimpleNamespace
shims, finish-reason/tool-choice/HTTP-error mappings become dicts, shared
_usage_from_metadata/_assistant_message between sync and streaming translation,
vertex credential loading split into _load_credentials/_needs_refresh.
Gemini request/response/schema output byte-identical against merge-base.
2026-09-02 13:29:47 -07:00

879 lines
38 KiB
Python

"""OpenAI-compatible facade over Google AI Studio's native Gemini API.
Hermes keeps ``api_mode='chat_completions'`` for the ``gemini`` provider so the
agent loop keeps its OpenAI-shaped message flow; this shim converts those
``messages[]`` / ``tools[]`` requests into ``models/{model}:generateContent``
payloads and converts the responses back. Google's OpenAI-compat endpoint has
been brittle for the multi-turn tool loop (auth churn, tool-call replay quirks,
thought-signature requirements); the native API is the canonical path.
"""
from __future__ import annotations
import asyncio
import base64
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.gemini_schema import 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"
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
# Published max output-token ceiling shared by every current Gemini text model.
# Used when the caller passes max_tokens=None: unlike OpenAI-compat endpoints,
# Gemini's native API applies a low internal default and truncates output.
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
def bare_gemini_model_id(model: str) -> str:
"""Strip Gemini's own provider prefix from an aggregator-style model id."""
name = (model or "").strip()
lowered = name.lower()
for prefix in ("google/", "gemini/"):
if lowered.startswith(prefix):
return name[len(prefix):].strip() or name
return name
def _gemini_major_version(model: str) -> Optional[int]:
"""Extract the major version from a Gemini model id (``gemini-3.6-flash`` → 3)."""
match = re.match(r"gemini-(\d+)", bare_gemini_model_id(model).lower())
return int(match.group(1)) if match else None
def gemini_requires_tool_call_ids(model: str) -> bool:
"""Whether functionCall/functionResponse parts must carry explicit ids.
Gemini 3+ needs explicit ids so replayed parallel tool calls pair with
their responses; Gemini 2.x rejects unexpected ``id`` fields, so gate on
the major version.
"""
version = _gemini_major_version(model)
return version is not None and version >= 3
def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
normalized = str(base_url or "").strip().rstrip("/").lower()
return "generativelanguage.googleapis.com" in normalized and not normalized.endswith("/openai")
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 API key; return ``"free"``, ``"paid"`` or
``"unknown"`` (probe failed — callers should proceed without blocking)."""
key = (api_key or "").strip()
if not key:
return "unknown"
normalized_base = str(base_url or DEFAULT_GEMINI_BASE_URL).strip().rstrip("/") or DEFAULT_GEMINI_BASE_URL
if normalized_base.lower().endswith("/openai"):
normalized_base = normalized_base[: -len("/openai")]
payload = {"contents": [{"role": "user", "parts": [{"text": "hi"}]}], "generationConfig": {"maxOutputTokens": 1}}
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(
f"{normalized_base}/models/{model}:generateContent",
params={"key": key},
json=payload,
headers={"Content-Type": "application/json", "X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}"},
)
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")
if rpd_header:
# Free-tier daily caps top out at 1000 (flash-lite); Tier 1 starts ~1500+.
try:
return "free" if int(rpd_header) <= 1000 else "paid"
except (TypeError, ValueError):
pass
if resp.status_code == 429:
try:
body_text = resp.text or ""
except Exception:
body_text = ""
return "free" if "free_tier" in body_text.lower() else "paid"
return "paid" if 200 <= resp.status_code < 300 else "unknown"
def is_free_tier_quota_error(error_message: str) -> bool:
"""Return True when a Gemini 429 message indicates free-tier exhaustion."""
return bool(error_message) and "free_tier" in error_message.lower()
_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"
)
def is_standard_key_auth_error(status: int, error_message: str, reason: str = "") -> bool:
"""Return True when a Gemini 401 indicates Google rejected the key TYPE.
Google rejects legacy "Standard" Google Cloud API keys with a misleading
401 asking for an OAuth 2 access token, optionally carrying ErrorInfo
reason ``ACCESS_TOKEN_TYPE_UNSUPPORTED``. Scoped narrowly so a plain bad
key (``API_KEY_INVALID``) keeps its existing message.
"""
if status != 401:
return False
return reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED" or "expected oauth 2 access token" in (error_message or "").lower()
_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 and "
"restart your session. Details: https://ai.google.dev/gemini-api/docs/api-key"
)
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,
) -> None:
super().__init__(message)
self.code = code
self.status_code = status_code
self.response = response
self.retry_after = retry_after
self.details = details or {}
def _coerce_content_to_text(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
pieces: List[str] = []
for part in content:
if isinstance(part, str):
pieces.append(part)
elif isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
pieces.append(part["text"])
return "\n".join(pieces)
return str(content)
def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]:
if not isinstance(content, list):
text = _coerce_content_to_text(content)
return [{"text": text}] if text else []
parts: List[Dict[str, Any]] = []
for item in content:
if isinstance(item, str):
parts.append({"text": item})
continue
if not isinstance(item, dict):
continue
ptype = item.get("type")
if ptype == "text":
text = item.get("text")
if isinstance(text, str) and text:
parts.append({"text": text})
elif ptype == "image_url":
url = ((item.get("image_url") or {}).get("url") or "")
if not isinstance(url, str) or not url.startswith("data:"):
continue
try:
header, encoded = url.split(",", 1)
mime = header.split(":", 1)[1].split(";", 1)[0]
raw = base64.b64decode(encoded)
except Exception:
continue
parts.append({"inlineData": {"mimeType": mime, "data": base64.b64encode(raw).decode("ascii")}})
return parts
def _tool_call_extra_signature(tool_call: Dict[str, Any]) -> Optional[str]:
extra = tool_call.get("extra_content") or {}
if not isinstance(extra, dict):
return None
google = extra.get("google") or extra.get("thought_signature")
sig = (google.get("thought_signature") or google.get("thoughtSignature")) if isinstance(google, dict) else google
return sig if isinstance(sig, str) and sig else None
# Stands in for a model turn that never arrived (stream failure / interrupt /
# quota fallback) when history leaves a human user text turn directly after a
# tool-result turn, keeping the request alternation-valid while the user's
# message remains a turn of its own (mirrors gemini-cli's placeholder repair).
_INTERRUPTED_RESPONSE_PLACEHOLDER = "[The previous response was interrupted before it completed.]"
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}
if not isinstance(args, dict):
args = {"_value": args}
part: Dict[str, Any] = {"functionCall": {"name": str(fn.get("name") or ""), "args": args}}
if include_ids and _tool_call_id(tool_call):
part["functionCall"]["id"] = _tool_call_id(tool_call)
# Cross-provider tool_calls (e.g. fallback from xAI/Anthropic) carry no
# Gemini thoughtSignature; without the sentinel, Gemini 3 thinking models
# reject replayed history with 400 INVALID_ARGUMENT.
part["thoughtSignature"] = _tool_call_extra_signature(tool_call) or "skip_thought_signature_validator"
return part
def _looks_like_json_schema(node: Any) -> bool:
"""True if a parsed value contains a JSON-Schema-style ``$ref`` pointer (``#/...``).
Gemini 3 resolves ``$ref``/``$defs`` inside a functionResponse.response
payload and rejects unknown pointers with HTTP 400, so a tool result that
is itself a JSON Schema (e.g. ``tool_describe`` output) must be forwarded
as opaque text. Detection is structural: false positives only lose the
structured shape, never the content.
"""
if isinstance(node, dict):
return any(
(key == "$ref" and isinstance(value, str) and value.startswith("#/")) or _looks_like_json_schema(value)
for key, value 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_name_by_call_id = tool_name_by_call_id or {}
tool_call_id = str(message.get("tool_call_id") or "")
# Gemini requires functionResponse.name to echo the matching
# functionCall.name, so the call-id mapping beats the result's own name
# (which may be an unwrapped internal name, e.g. an MCP tool via `tool_call`).
name = str(tool_name_by_call_id.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
response = parsed if isinstance(parsed, dict) and not _looks_like_json_schema(parsed) else {"output": content}
function_response: Dict[str, Any] = {"name": name, "response": response}
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, so older models get the text-only downgrade.
if is_gemini3:
image_parts = [p for p in _extract_multimodal_parts(raw_content) if "inlineData" in p]
if image_parts:
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 _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=tool_name_by_call_id, include_ids=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 tool_calls if isinstance(tool_calls, list) else []:
if not isinstance(tool_call, dict):
continue
tool_call_id = _tool_call_id(tool_call)
tool_name = str(((tool_call.get("function") or {}).get("name") or ""))
if tool_call_id and tool_name:
tool_name_by_call_id[tool_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})
# Alternation contract for generateContent:
# 1) Adjacent same-role contents merge (consecutive same-role contents are
# rejected with HTTP 400 "multiturn requests [must] alternate").
# 2) Exception: 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 tool results
# (functionResponse + functionResponse) still merge.
# 3) The split pair is kept API-valid by interposing a placeholder model
# turn between the functionResponse content and the human text.
merged_contents: List[Dict[str, Any]] = []
for content in contents:
prev = merged_contents[-1] if merged_contents 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_contents.append({"role": "model", "parts": [{"text": _INTERRUPTED_RESPONSE_PLACEHOLDER}]})
if same_role:
merged_contents[-1]["parts"].extend(content["parts"])
else:
merged_contents.append(content)
joined_system = "\n".join(part for part in system_text_parts if part).strip()
system_instruction = {"role": "system", "parts": [{"text": joined_system}]} if joined_system else None
return merged_contents, system_instruction
def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]:
if not isinstance(tools, list):
return []
declarations: List[Dict[str, Any]] = []
for tool in tools:
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"]):
continue
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):
decl["parameters"] = sanitize_gemini_tool_parameters(fn["parameters"])
declarations.append(decl)
return [{"functionDeclarations": declarations}] if declarations else []
_TOOL_CHOICE_MODES = {"auto": "AUTO", "required": "ANY", "none": "NONE"}
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]}}
if isinstance(tool_choice, dict):
name = (tool_choice.get("function") or {}).get("name")
if isinstance(name, str) and name:
return {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": [name]}}
return None
def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]:
if not isinstance(config, dict) or not config:
return None
budget = config.get("thinkingBudget", config.get("thinking_budget"))
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():
normalized["thinkingLevel"] = level.strip().lower()
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``; a global 4096/16384
``max_tokens`` can be consumed entirely by high thinking, leaving
``finishReason=MAX_TOKENS`` with no answer.
"""
normalized = _normalize_thinking_config(thinking_config)
if not normalized:
return False
if normalized.get("includeThoughts") is False:
return "thinkingLevel" in normalized or bool(normalized.get("thinkingBudget"))
budget = normalized.get("thinkingBudget")
return not (isinstance(budget, int) and budget <= 0 and "thinkingLevel" not in normalized)
def _effective_gemini_max_output_tokens(max_tokens: Optional[int], thinking_config: Any) -> int:
"""Resolve native ``maxOutputTokens``: an omitted/invalid cap becomes the
published ceiling (Gemini truncates on its low internal default), and an
explicit cap is raised to that ceiling when thinking is enabled so thought
tokens do not starve the answer."""
try:
requested = int(max_tokens)
except (TypeError, ValueError):
requested = 0
if requested <= 0:
return GEMINI_DEFAULT_MAX_OUTPUT_TOKENS
if _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 = "",
) -> 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)
request: Dict[str, Any] = {"contents": contents}
for key, value in (
("systemInstruction", system_instruction),
("tools", _translate_tools_to_gemini(tools)),
("toolConfig", _translate_tool_choice_to_gemini(tool_choice)),
):
if value:
request[key] = value
generation_config: Dict[str, Any] = {}
if temperature is not None:
generation_config["temperature"] = temperature
generation_config["maxOutputTokens"] = _effective_gemini_max_output_tokens(max_tokens, thinking_config)
if top_p is not None:
generation_config["topP"] = top_p
if stop:
generation_config["stopSequences"] = stop if isinstance(stop, list) else [str(stop)]
if normalized_thinking := _normalize_thinking_config(thinking_config):
generation_config["thinkingConfig"] = normalized_thinking
request["generationConfig"] = generation_config
return request
_FINISH_REASON_MAP = {
"STOP": "stop", "MAX_TOKENS": "length", "SAFETY": "content_filter", "RECITATION": "content_filter", "OTHER": "stop",
}
def _map_gemini_finish_reason(reason: str) -> str:
return _FINISH_REASON_MAP.get(str(reason or "").upper(), "stop")
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 _new_call_id(fc: Dict[str, Any]) -> str:
"""Echo Gemini's functionCall id when present, else mint an OpenAI-style one."""
fc_id = fc.get("id")
return fc_id if isinstance(fc_id, str) and fc_id else 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:
return SimpleNamespace(
prompt_tokens=int(usage_meta.get("promptTokenCount") or 0),
completion_tokens=int(usage_meta.get("candidatesTokenCount") or 0),
total_tokens=int(usage_meta.get("totalTokenCount") or 0),
prompt_tokens_details=SimpleNamespace(cached_tokens=int(usage_meta.get("cachedContentTokenCount") or 0)),
)
def _completion(model: str, message: SimpleNamespace, finish_reason: str, usage: SimpleNamespace) -> SimpleNamespace:
return SimpleNamespace(
id=f"chatcmpl-{uuid.uuid4().hex[:12]}", object="chat.completion", created=int(time.time()), model=model,
choices=[SimpleNamespace(index=0, message=message, finish_reason=finish_reason)], usage=usage,
)
def _assistant_message(content: Any, tool_calls: Any, reasoning: Any) -> SimpleNamespace:
return SimpleNamespace(
role="assistant", content=content, tool_calls=tool_calls,
reasoning=reasoning, reasoning_content=reasoning, reasoning_details=None,
)
def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespace:
candidates = resp.get("candidates") or []
if not isinstance(candidates, list) or not candidates:
return _completion(model, _assistant_message("", None, None), "stop", _usage_from_metadata({}))
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 []
text_pieces: List[str] = []
reasoning_pieces: List[str] = []
tool_calls: List[SimpleNamespace] = []
for index, part in enumerate(parts or []):
if not isinstance(part, dict):
continue
if part.get("thought") is True and isinstance(part.get("text"), str):
reasoning_pieces.append(part["text"])
continue
if isinstance(part.get("text"), str):
text_pieces.append(part["text"])
continue
fc = part.get("functionCall")
if isinstance(fc, dict) and fc.get("name"):
tool_call = SimpleNamespace(
id=_new_call_id(fc), type="function", index=index,
function=SimpleNamespace(name=str(fc["name"]), arguments=_dump_call_args(fc)),
)
extra_content = _tool_call_extra_from_part(part)
if extra_content:
tool_call.extra_content = extra_content
tool_calls.append(tool_call)
finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(str(cand.get("finishReason") or ""))
message = _assistant_message(
"".join(text_pieces) if text_pieces else None, tool_calls or None, "".join(reasoning_pieces) or None
)
return _completion(model, message, finish_reason, _usage_from_metadata(resp.get("usageMetadata") or {}))
class _GeminiStreamChunk(SimpleNamespace):
pass
def _make_stream_chunk(
*, model: str, content: str = "", tool_call_delta: Optional[Dict[str, Any]] = None,
finish_reason: Optional[str] = None, reasoning: str = "",
) -> _GeminiStreamChunk:
tool_calls = None
if tool_call_delta is not None:
tool_delta = SimpleNamespace(
index=tool_call_delta.get("index", 0),
id=tool_call_delta.get("id") or f"call_{uuid.uuid4().hex[:12]}",
type="function",
function=SimpleNamespace(name=tool_call_delta.get("name") or "", arguments=tool_call_delta.get("arguments") or ""),
)
extra_content = tool_call_delta.get("extra_content")
if isinstance(extra_content, dict):
tool_delta.extra_content = extra_content
tool_calls = [tool_delta]
delta = SimpleNamespace(
role="assistant", content=content or None, tool_calls=tool_calls,
reasoning=reasoning or None, reasoning_content=reasoning or None,
)
return _GeminiStreamChunk(
id=f"chatcmpl-{uuid.uuid4().hex[:12]}", object="chat.completion.chunk", created=int(time.time()), model=model,
choices=[SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)], usage=None,
)
def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
buffer = ""
for chunk in response.iter_text():
if not chunk:
continue
buffer += chunk
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.rstrip("\r")
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
return
try:
payload = json.loads(data)
except json.JSONDecodeError:
logger.debug("Non-JSON Gemini SSE line: %s", data[:200])
continue
if isinstance(payload, dict):
yield payload
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
if part.get("thought") is True and isinstance(part.get("text"), str):
chunks.append(_make_stream_chunk(model=model, reasoning=part["text"]))
continue
if isinstance(part.get("text"), str) and part["text"]:
chunks.append(_make_stream_chunk(model=model, content=part["text"]))
fc = part.get("functionCall")
if isinstance(fc, dict) and fc.get("name"):
name = str(fc["name"])
args_str = _dump_call_args(fc, sort_keys=True)
thought_signature = part.get("thoughtSignature") if isinstance(part.get("thoughtSignature"), str) else ""
call_key = json.dumps(
{"part_index": part_index, "name": name, "thought_signature": thought_signature}, sort_keys=True
)
slot = tool_call_indices.get(call_key)
if slot is None:
slot = {"index": len(tool_call_indices), "id": _new_call_id(fc), "last_arguments": ""}
tool_call_indices[call_key] = slot
# Gemini re-sends the full args each event; emit only the new suffix.
last_arguments = str(slot.get("last_arguments") or "")
emitted_arguments = args_str[len(last_arguments):] if args_str.startswith(last_arguments) else args_str
slot["last_arguments"] = args_str
chunks.append(_make_stream_chunk(model=model, tool_call_delta={
"index": slot["index"], "id": slot["id"], "name": name,
"arguments": emitted_arguments, "extra_content": _tool_call_extra_from_part(part),
}))
finish_reason_raw = str(cand.get("finishReason") or "")
if finish_reason_raw:
finish_chunk = _make_stream_chunk(
model=model, finish_reason="tool_calls" if tool_call_indices else _map_gemini_finish_reason(finish_reason_raw)
)
# Carry usageMetadata on the finish chunk so the streaming loop can
# record token counts like the non-streaming path does.
usage_meta = event.get("usageMetadata") or {}
if usage_meta:
finish_chunk.usage = _usage_from_metadata(usage_meta)
chunks.append(finish_chunk)
return chunks
_HTTP_ERROR_CODES = {401: "gemini_unauthorized", 429: "gemini_rate_limited", 404: "gemini_model_not_found"}
def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = None) -> GeminiAPIError:
status = response.status_code
if body_text is None:
try:
body_text = response.text
except Exception:
body_text = ""
body_text = body_text or ""
err_obj: Any = None
if body_text:
try:
parsed = json.loads(body_text)
err_obj = parsed.get("error") if isinstance(parsed, dict) else None
except (ValueError, TypeError):
pass
if not isinstance(err_obj, dict):
err_obj = {}
err_status = str(err_obj.get("status") or "").strip()
err_message = str(err_obj.get("message") or "").strip()
details_list = err_obj.get("details")
reason = ""
metadata: Dict[str, Any] = {}
for detail in details_list if isinstance(details_list, list) else []:
if isinstance(detail, dict) and not reason and str(detail.get("@type") or "").endswith("/google.rpc.ErrorInfo"):
reason_value, md = detail.get("reason"), detail.get("metadata")
if isinstance(reason_value, str):
reason = reason_value
if isinstance(md, dict):
metadata = md
retry_after: Optional[float] = None
header_retry = response.headers.get("Retry-After") or response.headers.get("retry-after")
if header_retry:
try:
retry_after = float(header_retry)
except (TypeError, ValueError):
pass
if err_message:
message = f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}"
else:
message = f"Gemini returned HTTP {status}: {body_text[:500]}"
# Users who bypassed the setup wizard (raw GOOGLE_API_KEY in .env) still
# need to learn that the free tier cannot sustain an agent session.
if status == 429 and is_free_tier_quota_error(err_message or body_text):
message = message + _FREE_TIER_GUIDANCE
# Legacy "Standard" key rejection: Google's raw 401 misleadingly asks for
# OAuth; append the actual fix (mint a new Gemini API key in AI Studio).
if is_standard_key_auth_error(status, err_message or body_text, reason):
message = message + _STANDARD_KEY_GUIDANCE
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 over Gemini's native REST API.
``client.chat.completions.create(**kwargs)`` mirrors the OpenAI SDK surface.
"""
# Declared for agent/auxiliary_client.py: already a complete client, so it
# is 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(
"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."
)
self.api_key = api_key
self.base_url = (base_url or DEFAULT_GEMINI_BASE_URL).rstrip("/").removesuffix("/openai")
self._default_headers = dict(default_headers or {})
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion))
self.is_closed = False
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
try:
self._http.close()
except Exception:
pass
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,
# Client context per Gemini's partner-integration guidance.
"User-Agent": f"hermes-agent/{_HERMES_VERSION} (gemini-native)",
"X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}",
**self._default_headers,
}
@staticmethod
def _advance_stream_iterator(iterator: Iterator[_GeminiStreamChunk]) -> tuple[bool, Optional[_GeminiStreamChunk]]:
try:
return False, next(iterator)
except StopIteration:
return True, None
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:
thinking_config = None
if isinstance(extra_body, dict):
thinking_config = extra_body.get("thinking_config") or extra_body.get("thinkingConfig")
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=thinking_config, model=model,
)
model = bare_gemini_model_id(model)
if stream:
return self._stream_completion(model=model, request=request, timeout=timeout)
url = f"{self.base_url}/models/{model}:generateContent"
response = self._http.post(url, json=request, headers=self._headers(), timeout=timeout)
if response.status_code != 200:
raise gemini_http_error(response)
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, request: Dict[str, Any], timeout: Any = None) -> Iterator[_GeminiStreamChunk]:
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse"
stream_headers = {**self._headers(), "Accept": "text/event-stream"}
def _generator() -> Iterator[_GeminiStreamChunk]:
try:
with self._http.stream("POST", url, json=request, headers=stream_headers, timeout=timeout) as response:
if response.status_code != 200:
raise gemini_http_error(response, body_text=read_streaming_error_body(response))
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
return _generator()
class AsyncGeminiNativeClient:
"""Async wrapper used by auxiliary_client for native Gemini calls."""
def __init__(self, sync_client: GeminiNativeClient):
self._sync = sync_client
self.api_key = sync_client.api_key
self.base_url = sync_client.base_url
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create_chat_completion))
# The auxiliary cache evicts entries by leaf client; GeminiNativeClient
# is itself the leaf (no OpenAI client beneath it).
self._real_client = sync_client
async def _create_chat_completion(self, **kwargs: Any) -> Any:
stream = bool(kwargs.get("stream"))
result = await asyncio.to_thread(self._sync.chat.completions.create, **kwargs)
if not stream:
return result
async def _async_stream() -> Any:
while True:
done, chunk = await asyncio.to_thread(self._sync._advance_stream_iterator, result)
if done:
break
yield chunk
return _async_stream()
async def close(self) -> None:
await asyncio.to_thread(self._sync.close)