InsuranceBot / backend /providers /google_gemini_llm.py
rohitsar567's picture
chore(cleanup): purge stale narrative/tombstones/dead code β€” codebase reads as the current standard
23b8fad
Raw
History Blame Contribute Delete
23.9 kB
"""Google Gemini β€” primary LLM tier for the single-brain stack.
Google AI Studio's free tier on `gemini-2.5-flash` gives 1500 req/day
with native JSON mode (`responseMimeType: "application/json"`). This is
best-in-class free quality for conversation and beats every model in NIM
or OpenRouter free tiers.
Wire-shape: REST API direct via httpx (no SDK dependency β€” matches the pattern
of nvidia_nim_llm.py and openrouter_llm.py).
Endpoint:
POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}
Request body:
{
"contents": [
{"role": "user", "parts": [{"text": "..."}]},
{"role": "model", "parts": [{"text": "..."}]}
],
"systemInstruction": {"parts": [{"text": "..."}]},
"generationConfig": {
"temperature": 0.6,
"maxOutputTokens": 700,
"responseMimeType": "application/json"
}
}
Role mapping:
OpenAI shape β†’ Gemini shape
role="system" β†’ systemInstruction (separate top-level field)
role="user" β†’ contents[i].role = "user"
role="assistant" β†’ contents[i].role = "model"
JSON mode:
response_format == {"type": "json_object"} β†’
generationConfig.responseMimeType = "application/json"
The provider matches the `LLMProvider` interface so it slots straight
into the single-brain stack (single_brain.py) as the primary tier.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import os
import threading
import time
from typing import Optional
import httpx
from backend.providers.base import ChatMessage, LLMProvider, LLMResult
GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"
GEMINI_CACHE_URL = "https://generativelanguage.googleapis.com/v1beta/cachedContents"
DEFAULT_MODEL = "gemini-2.5-flash" # free-tier, brain-grade. NOT the -lite tier: it does not reliably support single-brain tool-calling (save_profile_field).
# ----------------------------------------------------------------------------
# Module-level cachedContents registry.
#
# Keyed by `(model, sha256(system_text))` so the same base preamble shared
# across brain calls deduplicates onto one cache. Each value is a small
# dict with the cache `name` (the server-side resource id used in subsequent
# generateContent bodies as `cachedContent`) and the local-clock `expires_at`
# wall-time so we can self-evict before issuing a guaranteed-miss request.
#
# A threading.Lock guards entries against the rare interleave where two
# coroutines on different event loops race to create the same cache. In
# practice asyncio gives us implicit single-task ordering on one loop, but
# this is cheap insurance and keeps the contract honest if the module is ever
# pulled into a thread pool.
# ----------------------------------------------------------------------------
_CACHE_REGISTRY: dict[tuple[str, str, str], dict] = {}
_CACHE_REGISTRY_LOCK = threading.Lock()
# Gemini cachedContents server-side TTL ceiling. Google's
# `cachedContents` resources live up to ~60min on free tier; we refresh
# before that ceiling so an in-flight call never lands on an expired cache.
# `CACHE_REFRESH_AGE_SEC` is the wall-clock age at which we proactively
# re-create even if local `expires_at` has not yet elapsed β€” keeps us safely
# below the server-side TTL drift window observed in production.
CACHE_REFRESH_AGE_SEC = 50 * 60 # 50min β€” refresh BEFORE 60min server ceiling
def _cache_key(model: str, system_text: str, dynamic_prefix: str = "") -> tuple[str, str, str]:
"""Build the registry key for a (model, system_text, dynamic_prefix) tuple.
The key partitions on a separate `dynamic_prefix` hash (in addition to
the preamble SHA256) so per-persona caches don't collide on the same
static preamble hash when `_dynamic_profile_block` varies per persona.
`dynamic_prefix` defaults to "" so callers that don't pass it get the
preamble-only key.
Hashing inputs rather than storing the raw string keeps the registry
footprint tiny even when the preamble is multi-KB.
"""
static_h = hashlib.sha256(system_text.encode("utf-8")).hexdigest()
dyn_h = hashlib.sha256(dynamic_prefix.encode("utf-8")).hexdigest() if dynamic_prefix else ""
return (model, static_h, dyn_h)
# ---------------------------------------------------------------------------
# Normalized provider error class.
#
# Gemini's REST surface returns different error shapes for different failure
# modes (429 rate-limit, 400 BlockedReason / SafetyRating, 404 cache not
# found, 500/503 server errors). The brain tier needs a stable contract:
# `BrainProviderError(retryable=bool)` where `retryable=True` signals the
# tier should fall through to the next provider, and `retryable=False`
# signals a hard error (auth / content blocked) that should surface to the
# caller without burning fallback budget.
# ---------------------------------------------------------------------------
class BrainProviderError(RuntimeError):
"""Stable provider-error envelope consumed by the brain tier.
Attributes:
provider: short name ("gemini" / "nim" / ...)
retryable: True if the tier wrapper should try the next provider.
False for auth / content-block / non-recoverable errors.
status: HTTP-like status code if applicable, else None.
raw: the original exception (kept as __cause__ via `raise from`).
"""
def __init__(
self,
message: str,
*,
provider: str = "gemini",
retryable: bool = True,
status: Optional[int] = None,
):
super().__init__(message)
self.provider = provider
self.retryable = retryable
self.status = status
def _classify_gemini_error(status_code: int, detail: str) -> BrainProviderError:
"""Map a Gemini REST response into BrainProviderError(retryable=bool).
Routing rules (the brain tier's expectations):
- 429 (rate-limit / quota) β†’ retryable (next tier)
- 5xx (server errors) β†’ retryable (next tier)
- 408 / 504 (timeout) β†’ retryable
- 401 / 403 (auth, key revoked) β†’ NOT retryable (surface)
- 400 with BlockedReason / SafetyRating β†’ NOT retryable (content block)
- 400 other (malformed request) β†’ NOT retryable (caller bug)
- 404 cachedContent β†’ retryable (cache lapsed,
uncached retry already wired
in chat() but tier-level
fallback is still safe).
"""
detail_l = (detail or "").lower()
retryable = False
if status_code == 429:
retryable = True
elif 500 <= status_code < 600:
retryable = True
elif status_code in (408, 504):
retryable = True
elif status_code == 404 and ("cache" in detail_l or "cachedcontent" in detail_l):
retryable = True
elif status_code == 400 and (
"blocked" in detail_l or "safety" in detail_l or "blockreason" in detail_l
):
retryable = False
elif status_code in (401, 403):
retryable = False
return BrainProviderError(
f"Gemini API {status_code}: {detail[:300]}",
provider="gemini",
retryable=retryable,
status=status_code,
)
def invalidate_cache(model: str, system_text: str, dynamic_prefix: str = "") -> None:
"""Drop a cache registry entry β€” called by upstream after a 4xx response
that names a stale `cachedContent`. The server-side cache may still be
alive (it will lapse on TTL), but our reference is gone so the next
chat() call provisions a fresh one.
`dynamic_prefix` is an optional partition arg; defaults to "". When
supplied, only the matching (model, system_text, dynamic_prefix)
entry is dropped.
"""
key = _cache_key(model, system_text, dynamic_prefix)
with _CACHE_REGISTRY_LOCK:
_CACHE_REGISTRY.pop(key, None)
def _to_gemini_contents(messages: list[ChatMessage]) -> tuple[Optional[str], list[dict]]:
"""Split an OpenAI-style message list into (systemInstruction, contents).
Gemini's API expects:
- `systemInstruction` as a separate top-level field (one block, the
concatenation of every system message in order)
- `contents` as a list of `{role: "user" | "model", parts: [{text: ...}]}`
entries (no "system" role allowed inside contents)
OpenAI's role names map cleanly:
- "system" β†’ folded into systemInstruction
- "user" β†’ contents[i].role = "user"
- "assistant" β†’ contents[i].role = "model"
Empty / non-string content is coerced to a safe empty string so a stray
None never breaks the wire payload.
"""
system_chunks: list[str] = []
contents: list[dict] = []
for m in messages:
text = m.content if isinstance(m.content, str) else str(m.content or "")
if m.role == "system":
if text:
system_chunks.append(text)
continue
if m.role == "user":
contents.append({"role": "user", "parts": [{"text": text}]})
elif m.role == "assistant":
contents.append({"role": "model", "parts": [{"text": text}]})
else:
# Unknown role β€” treat as user input rather than dropping it.
contents.append({"role": "user", "parts": [{"text": text}]})
system_instruction = "\n\n".join(system_chunks) if system_chunks else None
return system_instruction, contents
class GoogleGeminiLLM(LLMProvider):
"""Google Gemini Flash via the AI Studio REST API.
`api_key` is read from the `GOOGLE_API_KEY` env var at *chat-time* β€” NOT
at module import time β€” so missing-key errors only surface when this
provider is actually called. That keeps cold imports cheap and lets the
Tier 0 layer cleanly fall through to Tier 1 (NIM) when the key is
unavailable (e.g., on the eval harness machine).
"""
name = "gemini"
def __init__(
self,
model: str = DEFAULT_MODEL,
api_key: Optional[str] = None,
timeout: float = 25.0,
):
# Defer the key-presence check to chat() β€” see class docstring.
self.api_key = api_key or os.environ.get("GOOGLE_API_KEY", "")
self.model = model
self.timeout = timeout
self.name = f"gemini::{model}"
async def create_cache(
self,
system_text: str,
ttl_seconds: int = 300,
dynamic_prefix: str = "",
) -> Optional[str]:
"""Create (or reuse) a Gemini `cachedContents` resource for `system_text`.
Returns the cache resource name (e.g. `"cachedContents/<UUID>"`)
that downstream `chat()` calls should pass as `cached_content_name`.
Returns None on ANY failure (missing key, too-small payload, 4xx, network
error) β€” the caller is expected to proceed without caching when this is
the case.
Re-uses an existing live cache from the module registry when the
(model, system_text) pair matches AND the local `expires_at` is still
in the future. Cache misses + creation failures are silent (logged at
INFO) so a caching outage never breaks the main path (fail-safe).
"""
if not self.api_key or not system_text:
return None
key = _cache_key(self.model, system_text, dynamic_prefix)
now = time.time()
with _CACHE_REGISTRY_LOCK:
entry = _CACHE_REGISTRY.get(key)
# TWO-stage refresh:
# (a) self-evict ~10s before LOCAL expires_at.
# (b) PROACTIVELY refresh if the entry is older than
# CACHE_REFRESH_AGE_SEC (50min) regardless of expires_at β€”
# guards against server-side TTL drift on long-lived
# caches and keeps every entry well below the ~60min
# cachedContents ceiling.
if entry:
created_at = entry.get("created_at", 0)
age = now - created_at if created_at else 0
if (
entry.get("expires_at", 0) > now + 10
and age < CACHE_REFRESH_AGE_SEC
):
return entry.get("name")
# else: fall through and recreate
# `model` must be the fully-qualified Gemini path "models/<id>".
body: dict = {
"model": f"models/{self.model}",
"systemInstruction": {"parts": [{"text": system_text}]},
"ttl": f"{int(ttl_seconds)}s",
}
url = f"{GEMINI_CACHE_URL}?key={self.api_key}"
headers = {"Content-Type": "application/json"}
client_timeout = httpx.Timeout(
connect=2.0, read=self.timeout, write=2.0, pool=2.0
)
try:
async with httpx.AsyncClient(timeout=client_timeout) as client:
resp = await client.post(url, headers=headers, json=body)
if resp.status_code >= 400:
# Most common 4xx is "the request must contain at least N
# tokens of cached content" β€” below the Gemini minimum the
# cache simply isn't allowed. Log + return None so the caller
# falls through to the uncached path.
detail = ""
try:
detail = resp.text[:300]
except Exception:
pass
logging.info(
"gemini.create_cache %s (model=%s): %s",
resp.status_code, self.model, detail,
)
return None
payload = resp.json()
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
raise
except Exception as e: # noqa: BLE001 β€” fail-safe: any error = no cache
logging.info(
"gemini.create_cache raised %s (model=%s): %s",
type(e).__name__, self.model, str(e)[:200],
)
return None
cache_name = payload.get("name") or ""
if not cache_name:
return None
with _CACHE_REGISTRY_LOCK:
now_create = time.time()
_CACHE_REGISTRY[key] = {
"name": cache_name,
# Store local expiry; the registered TTL is server-side
# truth, but we shadow it locally so we self-evict before
# the inevitable 4xx on an expired reference.
"expires_at": now_create + ttl_seconds,
# created_at lets us proactively refresh entries that have
# lived past CACHE_REFRESH_AGE_SEC even
# when caller set a longer TTL than Google honours.
"created_at": now_create,
}
logging.info(
"gemini.create_cache OK (model=%s, name=%s, ttl=%ss)",
self.model, cache_name, ttl_seconds,
)
return cache_name
async def chat(
self,
messages: list[ChatMessage],
temperature: float = 0.6,
max_tokens: int = 700,
response_format: Optional[dict] = None,
cached_content_name: Optional[str] = None,
**kwargs, # absorb OR-specific kwargs like `models=[...]` β€” ignored here
) -> LLMResult:
if not self.api_key:
raise RuntimeError(
"GOOGLE_API_KEY not set. Get a key at "
"https://aistudio.google.com/app/apikey and add "
"GOOGLE_API_KEY=... to .env"
)
system_instruction, contents = _to_gemini_contents(messages)
generation_config: dict = {
"temperature": temperature,
"maxOutputTokens": max_tokens,
}
# JSON mode β€” Gemini's native equivalent of OpenAI response_format.
if response_format and response_format.get("type") == "json_object":
generation_config["responseMimeType"] = "application/json"
body: dict = {
"contents": contents,
"generationConfig": generation_config,
}
# When a cachedContents resource is in play, the entire systemInstruction
# already lives inside it server-side; including it again in the request
# body causes a 400 INVALID_ARGUMENT ("cached prompt and inline prompt
# mutually exclusive"). Only attach systemInstruction on the uncached
# path.
if cached_content_name:
body["cachedContent"] = cached_content_name
elif system_instruction:
body["systemInstruction"] = {"parts": [{"text": system_instruction}]}
# API key goes in the URL query param β€” the Google AI Studio default.
# The header form (`x-goog-api-key`) also works but is undocumented for
# the v1beta generativelanguage endpoint.
url = f"{GEMINI_BASE_URL}/{self.model}:generateContent?key={self.api_key}"
headers = {"Content-Type": "application/json"}
# Per-phase timeouts mirroring the NIM/OpenRouter pattern: a stuck
# connection releases its slot on its own deadline rather than holding
# past the outer wait_for cancellation.
#
# Timeout binding: bind the httpx read timeout to
# `self.timeout - 2.0` so the underlying connection times out ~2s
# BEFORE the outer wait_for cancellation. This surfaces a clean
# BrainProviderError(retryable=True) instead of an
# asyncio.CancelledError leaking up through the cancellation chain.
read_timeout = max(2.0, self.timeout - 2.0)
client_timeout = httpx.Timeout(
connect=2.0,
read=read_timeout,
write=2.0,
pool=2.0,
)
async with httpx.AsyncClient(timeout=client_timeout) as client:
try:
resp = await client.post(url, headers=headers, json=body)
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
raise
except httpx.TimeoutException as e:
# TimeoutException β†’ retryable BrainProviderError so the
# tier falls through cleanly instead of seeing
# asyncio.CancelledError.
raise BrainProviderError(
f"Gemini timeout after {read_timeout:.1f}s (model={self.model})",
provider="gemini", retryable=True, status=None,
) from e
except httpx.HTTPError as e:
# Network / DNS / connection-refused etc. β€” retryable.
raise BrainProviderError(
f"Gemini transport error ({type(e).__name__}): {str(e)[:200]}",
provider="gemini", retryable=True, status=None,
) from e
# Graceful fallback when a cache reference is stale.
# Symptoms: 400/404 with body mentioning "cachedContent" /
# "cache" / "not found". Strip the reference, re-add the inline
# systemInstruction, drop the registry entry, retry once.
if (
cached_content_name
and resp.status_code in (400, 403, 404)
and any(
tok in (resp.text or "").lower()
for tok in ("cache", "cachedcontent")
)
):
logging.info(
"gemini.chat cache miss/invalid (%s) β€” retrying uncached (model=%s)",
resp.status_code, self.model,
)
# Best-effort invalidate by direct name match in the registry.
with _CACHE_REGISTRY_LOCK:
for k, v in list(_CACHE_REGISTRY.items()):
if v.get("name") == cached_content_name:
_CACHE_REGISTRY.pop(k, None)
body.pop("cachedContent", None)
if system_instruction:
body["systemInstruction"] = {
"parts": [{"text": system_instruction}]
}
try:
resp = await client.post(url, headers=headers, json=body)
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
raise
except httpx.TimeoutException as e:
raise BrainProviderError(
f"Gemini retry timeout after {read_timeout:.1f}s (model={self.model})",
provider="gemini", retryable=True, status=None,
) from e
except httpx.HTTPError as e:
raise BrainProviderError(
f"Gemini retry transport error ({type(e).__name__}): {str(e)[:200]}",
provider="gemini", retryable=True, status=None,
) from e
if resp.status_code >= 400:
# Normalize the error into BrainProviderError so the tier
# sees a stable contract:
# retryable=True β†’ fall through to next tier
# retryable=False β†’ surface to caller (auth/content-block)
# The original httpx.HTTPStatusError is preserved as __cause__
# so logs still carry the full upstream trail.
detail = ""
try:
detail = resp.text[:500]
except Exception:
pass
upstream_err = httpx.HTTPStatusError(
f"Gemini API {resp.status_code}: {detail}",
request=resp.request,
response=resp,
)
normalized = _classify_gemini_error(resp.status_code, detail)
raise normalized from upstream_err
payload = resp.json()
# Response shape:
# {"candidates": [{"content": {"parts": [{"text": "..."}], "role": "model"},
# "finishReason": "STOP"}],
# "usageMetadata": {"promptTokenCount": N, "candidatesTokenCount": M, ...}}
text = ""
try:
candidates = payload.get("candidates") or []
if candidates:
parts = (candidates[0].get("content") or {}).get("parts") or []
# Concatenate every text part β€” Gemini sometimes returns
# multiple chunks per candidate when streaming-shape leaks
# into a non-streaming response.
text = "".join(
p.get("text", "") for p in parts if isinstance(p, dict)
)
except Exception:
text = ""
usage = payload.get("usageMetadata") or {}
return LLMResult(
text=text,
model=self.model,
prompt_tokens=usage.get("promptTokenCount"),
completion_tokens=usage.get("candidatesTokenCount"),
raw=payload,
)
# ----------------------------------------------------------------------------
# Factory
# ----------------------------------------------------------------------------
def get_gemini_llm(
model: str = DEFAULT_MODEL,
timeout: float = 25.0,
) -> GoogleGeminiLLM:
"""Return a fresh GoogleGeminiLLM client.
Gemini Flash typically responds in 1-3s so the 25s default timeout is
a generous outer bound that still bails fast on a quota / network
stall.
Common models:
- "gemini-2.5-flash" β†’ conversational brain tier (default)
"""
return GoogleGeminiLLM(model=model, timeout=timeout)
__all__ = [
"GoogleGeminiLLM",
"get_gemini_llm",
"DEFAULT_MODEL",
"invalidate_cache",
"BrainProviderError",
"CACHE_REFRESH_AGE_SEC",
]