| """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__) |
|
|
| |
| |
| |
| |
| _PRICE_PER_M_TOKENS: dict[str, tuple[float, float]] = { |
| |
| "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( |
| 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 "" |
| |
| |
| 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, |
| ) |
|
|