Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/clients/vertex_ai.py: 21%
120 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 Vertex AI LLM client for the Lexigram LLM routing system.
3Implements the :class:`~lexigram.contracts.ai.protocols.LLMClientProtocol`
4protocol against the Vertex AI ``predict`` REST endpoint using the
5``google-auth`` library for service-account OAuth2 authentication.
7The Vertex AI endpoint for Gemini models follows this pattern::
9 https://{region}-aiplatform.googleapis.com/v1/projects/{project}/
10 locations/{location}/publishers/google/models/{model}:generateContent
12Configuration is sourced from ``ClientConfig.extra``:
14* ``vertex_project`` — GCP project ID (required)
15* ``vertex_location`` — Vertex location, e.g. ``us-central1`` (required)
16* ``vertex_region`` — Region prefix for the hostname; defaults to
17 ``vertex_location`` when omitted.
18* ``vertex_credentials_file`` — Path to a service account JSON credentials
19 file. When absent, Application Default Credentials (ADC) are used.
21Notes:
22 ``google-auth`` and ``google-auth-httplib2`` are optional dependencies.
23 An :class:`ImportError` is raised at construction time if they are absent.
24"""
26from __future__ import annotations
28from typing import TYPE_CHECKING, Any, cast
30from lexigram.ai.llm.clients.base import AbstractLLMClient
31from lexigram.ai.llm.clients.gemini_helpers import (
32 inject_thinking_config,
33 messages_to_gemini,
34 parse_gemini_response_with_tools,
35 parse_gemini_sse_body,
36 tool_to_gemini_function,
37)
38from lexigram.ai.llm.exceptions import (
39 LLMAuthenticationError,
40 LLMError,
41 LLMModelNotFoundError,
42 LLMRateLimitError,
43)
44from lexigram.ai.llm.http.client import ResilientHTTPClient
45from lexigram.ai.llm.types import (
46 AIError,
47 Completion,
48 StreamChunk,
49)
50from lexigram.contracts.core import HealthCheckResult, HealthStatus
51from lexigram.contracts.web.http_models import HttpStatusError
52from lexigram.logging import (
53 get_logger,
54)
55from lexigram.result import Err, Ok, Result
57if TYPE_CHECKING:
58 from collections.abc import AsyncIterator
60 from lexigram.ai.llm.config import ClientConfig
62logger = get_logger(__name__)
64__all__ = ["VertexAIClient"]
66_SCOPE = "https://www.googleapis.com/auth/cloud-platform"
69class VertexAIClient(AbstractLLMClient):
70 """Google Vertex AI client using the native REST API.
72 Authenticates via ``google-auth`` service-account credentials and routes
73 requests to the Vertex AI ``generateContent`` endpoint. All message and
74 tool conversion reuses Gemini-native helpers because Vertex AI exposes the
75 same Gemini model contract.
77 Args:
78 config: LLM configuration. ``config.extra`` must contain
79 ``vertex_project`` and ``vertex_location``.
80 """
82 def __init__(self, config: ClientConfig) -> None:
83 """Initialise the Vertex AI client.
85 Args:
86 config: LLM configuration with Vertex-specific ``extra`` keys.
88 Raises:
89 ImportError: If ``google-auth`` is not installed.
90 ValueError: If required ``extra`` keys are missing.
91 """
92 super().__init__(config=config)
94 try:
95 import google.auth # noqa: F401
96 import google.auth.transport.requests # noqa: F401
97 except ImportError as exc:
98 raise ImportError(
99 "VertexAIClient requires 'google-auth'. "
100 "Install with: pip install google-cloud-aiplatform"
101 ) from exc
103 extra: dict[str, Any] = config.extra or {}
104 self._project: str = extra.get("vertex_project", "")
105 self._location: str = extra.get("vertex_location", "")
106 self._region: str = extra.get("vertex_region", "") or self._location
107 self._credentials_file: str | None = extra.get("vertex_credentials_file")
109 if not self._project:
110 raise ValueError(
111 "VertexAIClient requires 'vertex_project' in ClientConfig.extra"
112 )
113 if not self._location:
114 raise ValueError(
115 "VertexAIClient requires 'vertex_location' in ClientConfig.extra"
116 )
118 self._http: ResilientHTTPClient | None = None
119 self._access_token: str | None = None
121 # ──────────────────────────────────────────────────────────────────
122 # Token acquisition
123 # ──────────────────────────────────────────────────────────────────
125 async def _get_access_token(self) -> str:
126 """Return a valid OAuth2 access token, refreshing when expired.
128 Returns:
129 Bearer token string.
131 Raises:
132 AIError: On credential failure.
133 """
134 import google.auth
135 import google.auth.transport.requests
137 try:
138 if self._credentials_file:
139 from google.oauth2 import (
140 service_account,
141 )
143 creds = service_account.Credentials.from_service_account_file(
144 self._credentials_file, scopes=[_SCOPE]
145 )
146 else:
147 creds, _ = google.auth.default(scopes=[_SCOPE])
149 req = google.auth.transport.requests.Request()
150 if not creds.valid:
151 creds.refresh(req)
152 return cast("str", creds.token)
153 except Exception as exc:
154 raise AIError(f"vertex: failed to acquire access token: {exc}") from exc
156 # ──────────────────────────────────────────────────────────────────
157 # HTTP client
158 # ──────────────────────────────────────────────────────────────────
160 async def _get_http(self) -> ResilientHTTPClient:
161 """Return a lazily-created, token-refreshed HTTP client."""
162 token = await self._get_access_token()
163 base_url = f"https://{self._region}-aiplatform.googleapis.com"
164 if self._http is None:
165 self._http = ResilientHTTPClient(
166 base_url=base_url,
167 headers={
168 "Authorization": f"Bearer {token}",
169 "Content-Type": "application/json",
170 },
171 timeout=self.config.timeout,
172 name="vertex-ai-client",
173 )
174 else:
175 # Refresh token header in-place
176 self._http.headers["Authorization"] = f"Bearer {token}"
177 return self._http
179 def _model_path(self, model: str) -> str:
180 """Build the Vertex AI model resource path.
182 Args:
183 model: Model ID, e.g. ``gemini-1.5-pro``.
185 Returns:
186 Full resource path string.
187 """
188 return (
189 f"/v1/projects/{self._project}/locations/{self._location}"
190 f"/publishers/google/models/{model}"
191 )
193 # ──────────────────────────────────────────────────────────────────
194 # LLMClientProtocol implementation
195 # ──────────────────────────────────────────────────────────────────
197 async def _do_complete(
198 self,
199 messages: list[Any],
200 *,
201 model: str | None = None,
202 temperature: float = 0.2,
203 max_tokens: int | None = None,
204 **kwargs: Any,
205 ) -> Result[Completion, LLMError]:
206 """Generate a completion from Vertex AI.
208 Args:
209 messages: OpenAI-compatible message list.
210 model: Model override.
211 temperature: Sampling temperature.
212 max_tokens: Maximum output tokens.
213 **kwargs: Ignored for protocol compatibility.
215 Returns:
216 ``Ok(Completion)`` on success. ``Err(LLMError)`` for recoverable
217 failures.
219 Raises:
220 LLMAuthenticationError: When credentials are invalid.
221 AIError: For unexpected infrastructure failures.
222 """
223 active_model = model or self.config.model
224 contents = messages_to_gemini(messages)
225 payload: dict[str, Any] = {
226 "contents": contents,
227 "generationConfig": {"temperature": temperature},
228 }
229 if max_tokens is not None:
230 payload["generationConfig"]["maxOutputTokens"] = max_tokens
231 inject_thinking_config(payload["generationConfig"], self.config)
232 tools = kwargs.pop("tools", None)
233 if tools:
234 payload["tools"] = [
235 {"functionDeclarations": [tool_to_gemini_function(t) for t in tools]}
236 ]
238 path = f"{self._model_path(active_model)}:generateContent"
239 try:
240 http = await self._get_http()
241 response = await http.post(path, json=payload)
242 response.raise_for_status()
243 except (
244 HttpStatusError,
245 OSError,
246 ConnectionError,
247 TimeoutError,
248 RuntimeError,
249 ) as exc:
250 return self._handle_error_as_result(exc)
252 return Ok(parse_gemini_response_with_tools(response.json, active_model))
254 async def _do_stream_chat(
255 self,
256 messages: list[Any],
257 *,
258 model: str | None = None,
259 temperature: float = 0.2,
260 max_tokens: int | None = None,
261 **kwargs: Any,
262 ) -> Result[AsyncIterator[StreamChunk], LLMError]:
263 """Stream completion tokens from Vertex AI.
265 Args:
266 messages: OpenAI-compatible message list.
267 model: Model override.
268 temperature: Sampling temperature.
269 max_tokens: Maximum output tokens.
270 **kwargs: Ignored for protocol compatibility.
272 Returns:
273 ``Ok(AsyncIterator[StreamChunk])`` on success.
274 ``Err(LLMError)`` for recoverable failures.
276 Raises:
277 LLMAuthenticationError: When credentials are invalid.
278 AIError: For unexpected infrastructure failures.
279 """
280 active_model = model or self.config.model
281 contents = messages_to_gemini(messages)
282 payload: dict[str, Any] = {
283 "contents": contents,
284 "generationConfig": {"temperature": temperature},
285 }
286 if max_tokens is not None:
287 payload["generationConfig"]["maxOutputTokens"] = max_tokens
288 inject_thinking_config(payload["generationConfig"], self.config)
290 path = f"{self._model_path(active_model)}:streamGenerateContent?alt=sse"
291 try:
292 http = await self._get_http()
293 response = await http.post(path, json=payload)
294 response.raise_for_status()
295 except (
296 HttpStatusError,
297 OSError,
298 ConnectionError,
299 TimeoutError,
300 RuntimeError,
301 ) as exc:
302 return self._handle_error_as_result(exc)
304 result = parse_gemini_sse_body(response.text or "", active_model)
306 async def _to_async() -> AsyncIterator[StreamChunk]:
307 for chunk in result:
308 yield chunk
310 return Ok(_to_async())
312 async def _do_chat(
313 self,
314 messages: list[Any],
315 tools: list[Any] | None = None,
316 *,
317 model: str | None = None,
318 temperature: float = 0.2,
319 max_tokens: int | None = None,
320 **kwargs: Any,
321 ) -> Result[Completion, LLMError]:
322 """Generate completion with optional tool calling on Vertex AI.
324 Tool calling is handled by :meth:`_do_complete` via
325 ``complete(..., tools=...)``; this method forwards the tool
326 descriptors to keep the ``chat`` code path consistent.
328 Args:
329 messages: OpenAI-compatible message list.
330 tools: Optional tool descriptors.
331 model: Model override.
332 temperature: Sampling temperature.
333 max_tokens: Maximum output tokens.
334 **kwargs: Ignored for protocol compatibility.
336 Returns:
337 ``Ok(Completion)`` on success. ``Err(LLMError)`` for recoverable
338 failures.
339 """
340 return await self._do_complete(
341 messages,
342 model=model,
343 temperature=temperature,
344 max_tokens=max_tokens,
345 tools=tools,
346 **kwargs,
347 )
349 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
350 """Probe Vertex AI with a minimal generateContent request.
352 Args:
353 timeout: Informational only.
355 Returns:
356 Structured :class:`~lexigram.contracts.core.health.HealthCheckResult`.
357 """
358 try:
359 http = await self._get_http()
360 path = (
361 f"/v1/projects/{self._project}/locations/{self._location}"
362 "/publishers/google/models"
363 )
364 resp = await http.get(path)
365 resp.raise_for_status()
366 except Exception as exc: # noqa: BLE001 - transport/auth SDK stack varies
367 return HealthCheckResult(
368 component="llm.vertex_ai",
369 status=HealthStatus.UNHEALTHY,
370 error=str(exc),
371 details={
372 "project": self._project,
373 "location": self._location,
374 "model": self.config.model,
375 },
376 )
378 return HealthCheckResult(
379 component="llm.vertex_ai",
380 status=HealthStatus.HEALTHY,
381 details={
382 "project": self._project,
383 "location": self._location,
384 "model": self.config.model,
385 },
386 )
388 async def close(self) -> None:
389 """Close the underlying HTTP client."""
390 if self._http is not None:
391 await self._http.close()
392 self._http = None
393 await super().close()
395 def _handle_error_as_result(self, error: Exception) -> Result[Any, LLMError]:
396 """Map a caught exception to ``Err`` or re-raise for infrastructure failures."""
397 status: int | None = None
398 if isinstance(error, HttpStatusError):
399 status = error.status
401 if status in (401, 403):
402 raise LLMAuthenticationError(
403 f"vertex: authentication failed ({status}): {error}"
404 ) from error
405 if status == 429:
406 return Err(LLMRateLimitError(f"vertex: rate limit exceeded: {error}"))
407 if status == 404:
408 return Err(LLMModelNotFoundError(f"vertex: model not found: {error}"))
409 raise AIError(f"vertex: infrastructure error: {error}") from error