ModPilot / llm /gemini.py
ThejasRao's picture
Deploy ModPilot Investigation Engine
7302343
Raw
History Blame Contribute Delete
6 kB
"""Gemini implementation of the LLMClient protocol.
Spec: docs/Specs.md §8, docs/06-AILayer.md §3.2.
Wraps `google-genai` for both Reasoner (gemini-2.5-pro) and Summarizer (gemini-2.5-flash) roles.
Thinking-budget reality (validated 2026-05-13 against the live API):
- `gemini-2.5-pro` is **thinking-only**. Passing `thinking_budget=0` returns
HTTP 400 "Budget 0 is invalid. This model only works in thinking mode."
Reasoner calls must allocate budget for thinking + output.
- `gemini-2.5-flash` allows `thinking_budget=0` to skip thinking entirely.
Summarizer calls and other Flash-backed structured extraction should
pass 0 unless the task genuinely benefits from chain-of-thought.
"""
from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING
import structlog
from google import genai
from google.genai import types
from pydantic import BaseModel
from llm.client import LLMClient, LLMResponse, Message, Role
if TYPE_CHECKING:
from api.config import Settings
logger = structlog.get_logger(__name__)
# Per-model USD rate per 1M tokens — used for cost surfacing in the Verdict
# Timeline + dashboard. Source: ai.google.dev pricing as of 2026-05-13.
# These are approximate; the audit log persists raw token counts so we can
# re-cost retroactively when rates change.
_PRICE_PER_M_TOKENS: dict[str, tuple[float, float]] = {
# model_id → (input $/1M, output $/1M)
"gemini-2.5-pro": (1.25, 10.00),
"gemini-2.5-flash": (0.075, 0.30),
}
def _model_for_role(settings: Settings, role: Role) -> str:
if role is Role.REASONER:
return settings.model_reasoner
if role is Role.SUMMARIZER:
return settings.model_summarizer
raise ValueError(f"unmapped role {role!r}")
def _cost_usd(model: str, input_tokens: int, output_tokens: int) -> float:
rates = _PRICE_PER_M_TOKENS.get(model)
if rates is None:
return 0.0
in_rate, out_rate = rates
return (input_tokens * in_rate + output_tokens * out_rate) / 1_000_000
def _split_messages(messages: list[Message]) -> tuple[str | None, list[types.Content]]:
"""Split into (system_instruction, contents-list-for-generate_content)."""
system_parts: list[str] = []
contents: list[types.Content] = []
for msg in messages:
if msg.role == "system":
system_parts.append(msg.content)
elif msg.role in ("user", "assistant"):
role = "user" if msg.role == "user" else "model"
contents.append(
types.Content(role=role, parts=[types.Part.from_text(text=msg.content)])
)
system_instruction = "\n\n".join(system_parts) if system_parts else None
return system_instruction, contents
class GeminiClient(LLMClient):
"""Default LLMClient — talks to Google's Gemini API."""
def __init__(self, settings: Settings) -> None:
if not settings.gemini_api_key:
raise RuntimeError(
"GEMINI_API_KEY is empty — set it in engine/.env or environment"
)
self._settings = settings
self._client = genai.Client(api_key=settings.gemini_api_key)
async def complete( # noqa: PLR0913 — keyword-only contract from Specs §8.2
self,
*,
role: Role,
messages: list[Message],
response_schema: type[BaseModel] | None = None,
max_tokens: int,
temperature: float = 0.0,
timeout_ms: int,
correlation_id: str,
thinking_budget: int | None = None,
) -> LLMResponse:
model = _model_for_role(self._settings, role)
system_instruction, contents = _split_messages(messages)
thinking_config = (
types.ThinkingConfig(thinking_budget=thinking_budget)
if thinking_budget is not None
else None
)
config = types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=temperature,
max_output_tokens=max_tokens,
response_mime_type="application/json" if response_schema is not None else None,
response_schema=response_schema,
thinking_config=thinking_config,
)
log = logger.bind(correlation_id=correlation_id, role=role.value, model=model)
log.info("llm.call.started", input_messages=len(messages), max_tokens=max_tokens)
started = time.perf_counter()
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=model,
contents=contents,
config=config,
),
timeout=timeout_ms / 1000.0,
)
except TimeoutError:
log.warning("llm.call.timeout", timeout_ms=timeout_ms)
raise
latency_ms = int((time.perf_counter() - started) * 1000)
usage = response.usage_metadata
input_tokens = getattr(usage, "prompt_token_count", 0) or 0
output_tokens = getattr(usage, "candidates_token_count", 0) or 0
cost = _cost_usd(model, input_tokens, output_tokens)
raw_text = response.text or ""
# response.parsed can be BaseModel | dict | Enum | None depending on schema;
# we only surface it when callers asked for a Pydantic-typed result.
parsed_raw = response.parsed if response_schema is not None else None
parsed: BaseModel | None = parsed_raw if isinstance(parsed_raw, BaseModel) else None
log.info(
"llm.call.succeeded",
latency_ms=latency_ms,
input_tokens=input_tokens,
output_tokens=output_tokens,
cost_usd=round(cost, 6),
)
return LLMResponse(
raw_text=raw_text,
input_tokens=input_tokens,
output_tokens=output_tokens,
model=model,
latency_ms=latency_ms,
cost_usd=cost,
parsed=parsed,
)