Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/clients/gemini.py: 27%
105 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
1"""Google Gemini REST API client for the Lexigram LLM routing system.
3Implements the :class:`~lexigram.contracts.ai.protocols.LLMClientProtocol` protocol
4against the ``generativelanguage.googleapis.com`` v1beta endpoint.
6Key responsibilities:
7* Translate OpenAI-compatible ``role``/``content`` messages into Gemini's
8 ``contents[].parts`` format (including image ``inline_data`` parts).
9* Map Gemini errors to the framework's typed exception hierarchy so the
10 router can classify them without provider-specific logic.
11* Return a normalised :class:`~lexigram.ai.llm.types.Completion` object.
13Notes:
14 Gemini does *not* expose an OpenAI-compatible endpoint — this client
15 uses the native REST API directly via :class:`~lexigram.http.BaseURLHTTPClient`.
16"""
18from __future__ import annotations
20from typing import TYPE_CHECKING, Any
22from lexigram.ai.llm.exceptions import (
23 LLMAuthenticationError,
24 LLMContentFilterError,
25 LLMError,
26 LLMModelNotFoundError,
27 LLMRateLimitError,
28)
29from lexigram.ai.llm.http.client import ResilientHTTPClient
30from lexigram.ai.llm.types import (
31 AIError,
32 Completion,
33 StreamChunk,
34)
35from lexigram.contracts.core.health import HealthCheckResult, HealthStatus
36from lexigram.contracts.web.http_models import HttpStatusError
37from lexigram.logging import (
38 get_logger,
39)
40from lexigram.result import Err, Ok, Result
42logger = get_logger(__name__)
44_BASE_URL = "https://generativelanguage.googleapis.com"
46from lexigram.ai.llm.clients.base import AbstractLLMClient
47from lexigram.ai.llm.clients.gemini_helpers import (
48 inject_thinking_config,
49 messages_to_gemini,
50 parse_gemini_response_with_tools,
51 parse_gemini_sse_body,
52 tool_to_gemini_function,
53)
55if TYPE_CHECKING:
56 from collections.abc import AsyncIterator
58 from lexigram.ai.llm.config import ClientConfig
60__all__ = ["GeminiClient", "_messages_to_gemini"]
62_messages_to_gemini = messages_to_gemini
65class GeminiClient(AbstractLLMClient):
66 """Client for the Google Gemini REST API.
68 Wraps ``generativelanguage.googleapis.com/v1beta/models/{model}:generateContent``
69 and normalises the response to a standard :class:`~lexigram.ai.llm.types.Completion`.
71 The Gemini API uses API-key query-parameter authentication, not Bearer
72 tokens. The key is read from ``ClientConfig.api_key`` and is never logged.
74 Example:
75 >>> config = ClientConfig(
76 ... provider="gemini",
77 ... model="gemini-2.5-flash",
78 ... api_key="AIza…",
79 ... timeout=60.0,
80 ... )
81 >>> client = GeminiClient(config)
82 >>> completion = await client.complete(
83 ... messages=[{"role": "user", "content": "Describe quantum entanglement."}],
84 ... model="gemini-2.5-flash",
85 ... max_tokens=512,
86 ... )
87 >>> print(completion.content)
88 """
90 def __init__(self, config: ClientConfig) -> None:
91 """Initialise the Gemini client.
93 Args:
94 config: LLM configuration. ``config.api_key`` must be set;
95 ``config.api_base`` overrides the default base URL when set.
96 """
97 super().__init__(config=config)
98 self._http: ResilientHTTPClient | None = None
100 @property
101 def _api_key(self) -> str:
102 """Return the raw API key string.
104 Raises:
105 LLMAuthenticationError: When ``config.api_key`` is not set.
106 """
107 key = self.config.api_key
108 if key is None:
109 msg = "GeminiClient requires api_key in ClientConfig"
110 raise LLMAuthenticationError(msg)
111 return key.get_secret_value()
113 @property
114 def _base_url(self) -> str:
115 """Return the API base URL, using config override when provided."""
116 return self.config.api_base or _BASE_URL
118 def _get_http(self) -> ResilientHTTPClient:
119 """Return a lazily-created HTTP client instance."""
120 if self._http is None:
121 self._http = ResilientHTTPClient(
122 base_url=self._base_url,
123 headers={"Content-Type": "application/json"},
124 timeout=self.config.timeout,
125 name="gemini-client",
126 )
127 return self._http
129 # ──────────────────────────────────────────────────────────────────
130 # LLMClientProtocol protocol implementation
131 # ──────────────────────────────────────────────────────────────────
133 async def _do_complete( # type: ignore[override]
134 self,
135 messages: list[dict[str, Any]],
136 *,
137 model: str | None = None,
138 temperature: float = 0.2,
139 max_tokens: int | None = None,
140 **kwargs: Any,
141 ) -> Result[Completion, LLMError]:
142 """Generate a completion from Gemini.
144 Args:
145 messages: OpenAI-compatible message list.
146 model: Model identifier override. When ``None``, uses
147 ``config.model``.
148 temperature: Sampling temperature (0.0–2.0).
149 max_tokens: Maximum output tokens.
150 **kwargs: Ignored for protocol compatibility.
152 Returns:
153 ``Ok(Completion)`` on success. ``Err(LLMError)`` for recoverable
154 failures (rate limit, quota, content filter, model not found).
156 Raises:
157 LLMAuthenticationError: When the API key is invalid (HTTP 401/403).
158 AIError: For unexpected infrastructure failures.
159 """
160 active_model = model or self.config.model
161 contents = messages_to_gemini(messages)
162 payload: dict[str, Any] = {
163 "contents": contents,
164 "generationConfig": {
165 "temperature": temperature,
166 },
167 }
168 if max_tokens is not None:
169 payload["generationConfig"]["maxOutputTokens"] = max_tokens
170 inject_thinking_config(payload["generationConfig"], self.config)
171 tools = kwargs.pop("tools", None)
172 if tools:
173 payload["tools"] = [
174 {"functionDeclarations": [tool_to_gemini_function(t) for t in tools]}
175 ]
177 path = f"/v1beta/models/{active_model}:generateContent?key={self._api_key}"
179 try:
180 http = self._get_http()
181 response = await http.post(path, json=payload)
182 response.raise_for_status()
183 except (
184 HttpStatusError,
185 OSError,
186 ConnectionError,
187 TimeoutError,
188 RuntimeError,
189 ) as exc:
190 return self._handle_error_as_result(exc)
192 data: dict[str, Any] = response.json
193 try:
194 return Ok(parse_gemini_response_with_tools(data, active_model))
195 except AIError as exc:
196 return Err(LLMContentFilterError(str(exc)))
198 async def _do_stream_chat(
199 self,
200 messages: list[Any],
201 *,
202 model: str | None = None,
203 temperature: float = 0.2,
204 max_tokens: int | None = None,
205 **kwargs: Any,
206 ) -> Result[AsyncIterator[StreamChunk], LLMError]:
207 """Stream completion tokens from Gemini.
209 Uses ``streamGenerateContent`` with SSE (``alt=sse``) to yield
210 incremental text deltas as :class:`StreamChunk` objects.
212 Args:
213 messages: OpenAI-compatible message list.
214 model: Model override.
215 temperature: Sampling temperature.
216 max_tokens: Maximum output tokens.
217 **kwargs: Ignored for protocol compatibility.
219 Returns:
220 ``Ok(AsyncIterator[StreamChunk])`` on successful connection.
221 ``Err(LLMError)`` for recoverable failures.
223 Raises:
224 LLMAuthenticationError: When credentials are invalid.
225 AIError: For unexpected infrastructure failures.
226 """
227 active_model = model or self.config.model
228 contents = messages_to_gemini(messages)
229 payload: dict[str, Any] = {
230 "contents": contents,
231 "generationConfig": {"temperature": temperature},
232 }
233 if max_tokens is not None:
234 payload["generationConfig"]["maxOutputTokens"] = max_tokens
235 inject_thinking_config(payload["generationConfig"], self.config)
237 path = (
238 f"/v1beta/models/{active_model}:streamGenerateContent"
239 f"?key={self._api_key}&alt=sse"
240 )
241 try:
242 http = self._get_http()
243 response = await http.post(path, json=payload)
244 response.raise_for_status()
245 except (
246 HttpStatusError,
247 OSError,
248 ConnectionError,
249 TimeoutError,
250 RuntimeError,
251 ) as exc:
252 return self._handle_error_as_result(exc)
254 # The non-streaming post returns the full SSE body as text;
255 # parse newline-delimited JSON objects from the array.
256 result = parse_gemini_sse_body(response.text or "", active_model)
258 async def _to_async() -> AsyncIterator[StreamChunk]:
259 for chunk in result:
260 yield chunk
262 return Ok(_to_async())
264 async def _do_chat( # type: ignore[override]
265 self,
266 messages: list[dict[str, Any]],
267 tools: list[Any] | None = None,
268 *,
269 model: str | None = None,
270 temperature: float = 0.2,
271 max_tokens: int | None = None,
272 **kwargs: Any,
273 ) -> Result[Completion, LLMError]:
274 """Generate completion with optional Gemini function calling.
276 Tool calling is handled by :meth:`_do_complete` via
277 ``complete(..., tools=...)``; this method forwards the tool
278 descriptors to keep the ``chat`` code path consistent.
280 Args:
281 messages: OpenAI-compatible message list.
282 tools: Optional tool/function descriptors.
283 model: Model override.
284 temperature: Sampling temperature.
285 max_tokens: Maximum output tokens.
286 **kwargs: Ignored for protocol compatibility.
288 Returns:
289 ``Ok(Completion)`` on success. ``Err(LLMError)`` for recoverable
290 failures.
291 """
292 return await self._do_complete(
293 messages,
294 model=model,
295 temperature=temperature,
296 max_tokens=max_tokens,
297 tools=tools,
298 **kwargs,
299 )
301 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
302 """Perform a lightweight health check against the Gemini API.
304 Attempts to list models — a zero-cost metadata call that verifies
305 the API key is valid and the service is reachable.
307 Args:
308 timeout: Seconds to wait for the response (informational; the
309 client-level timeout in ``config.timeout`` is used).
311 Returns:
312 :class:`~lexigram.contracts.core.health.HealthCheckResult`.
313 """
314 try:
315 http = self._get_http()
316 response = await http.get(
317 f"/v1beta/models?key={self._api_key}&pageSize=1",
318 )
319 response.raise_for_status()
320 return HealthCheckResult(component="gemini", status=HealthStatus.HEALTHY)
321 except (OSError, ConnectionError, TimeoutError, RuntimeError) as exc:
322 return HealthCheckResult(
323 component="gemini",
324 status=HealthStatus.UNHEALTHY,
325 error=str(exc),
326 )
328 def _handle_error_as_result(self, error: Exception) -> Result[Any, LLMError]:
329 """Map a caught exception to ``Err`` (recoverable) or re-raise (infra)."""
330 status: int | None = None
331 if isinstance(error, HttpStatusError):
332 status = error.status
334 if status in (401, 403):
335 raise LLMAuthenticationError(
336 f"gemini: authentication failed ({status}): {error}"
337 ) from error
338 if status == 429:
339 return Err(LLMRateLimitError(f"gemini: rate limit exceeded: {error}"))
340 if status == 400:
341 err_str = str(error).lower()
342 if "not found" in err_str or "does not exist" in err_str:
343 return Err(LLMModelNotFoundError(f"gemini: model not found: {error}"))
344 return Err(LLMError(f"gemini: invalid request: {error}"))
345 if status == 404:
346 return Err(LLMModelNotFoundError(f"gemini: model not found: {error}"))
347 raise AIError(f"gemini: infrastructure error: {error}") from error
349 async def close(self) -> None:
350 """Close the underlying HTTP client and release connections."""
351 if self._http is not None:
352 await self._http.close()
353 self._http = None
354 await super().close()