1db2452272
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.
879 lines
38 KiB
Python
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)
|