axio-transport-google 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,947 @@
1
+ """Google GenAI (Gemini) transport — aiohttp streaming, SDK for media generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import json
8
+ import logging
9
+ import os
10
+ from collections.abc import AsyncIterator
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Protocol, cast
13
+
14
+ import aiohttp
15
+ from axio.blocks import AudioBlock, ImageBlock, TextBlock, ToolResultBlock, ToolUseBlock, VideoBlock
16
+ from axio.events import (
17
+ AudioOutput,
18
+ ImageOutput,
19
+ IterationEnd,
20
+ ReasoningDelta,
21
+ StreamEvent,
22
+ TextDelta,
23
+ ToolInputDelta,
24
+ ToolUseStart,
25
+ VideoOutput,
26
+ )
27
+ from axio.exceptions import StreamError
28
+ from axio.messages import Message
29
+ from axio.models import Capability, ModelRegistry, ModelSpec
30
+ from axio.tool import Tool
31
+ from axio.transport import CompletionTransport, ImageGenTransport, VideoGenTransport
32
+ from axio.types import StopReason, Usage
33
+
34
+ from axio_transport_google._generated_types import (
35
+ Content,
36
+ FunctionDeclaration,
37
+ GenerateContentRequest,
38
+ GenerationConfig,
39
+ Part,
40
+ ThinkingConfig,
41
+ )
42
+ from axio_transport_google._generated_types import (
43
+ SafetySetting as SafetySettingDict,
44
+ )
45
+ from axio_transport_google._generated_types import (
46
+ Tool as ToolDict,
47
+ )
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ class _RefreshableCredentials(Protocol):
53
+ valid: bool
54
+ expired: bool
55
+ token: str | None
56
+
57
+ def refresh(self, request: object) -> None: ...
58
+
59
+
60
+ # ── Thinking level helpers ──────────────────────────────────────────
61
+
62
+
63
+ def valid_thinking_levels(model_id: str) -> tuple[str, ...] | None:
64
+ """Return valid thinkingLevel values for a Gemini 3+ model, or None for budget-based (2.5) models."""
65
+ if "gemini-3" not in model_id:
66
+ return None
67
+ if "-pro-image" in model_id:
68
+ return ("HIGH",)
69
+ if "-pro" in model_id:
70
+ return ("LOW", "MEDIUM", "HIGH")
71
+ if "-flash-image" in model_id:
72
+ return ("MINIMAL", "HIGH")
73
+ # Flash, Flash-Lite
74
+ return ("MINIMAL", "LOW", "MEDIUM", "HIGH")
75
+
76
+
77
+ def _redact_body(obj: Any) -> Any:
78
+ """Deep-copy a request/response dict, replacing large base64 blobs with a size summary."""
79
+ if isinstance(obj, dict):
80
+ out = {}
81
+ for k, v in obj.items():
82
+ if k == "data" and isinstance(v, str) and len(v) > 200:
83
+ out[k] = f"<{len(v)} chars base64>"
84
+ else:
85
+ out[k] = _redact_body(v)
86
+ return out
87
+ if isinstance(obj, list):
88
+ return [_redact_body(x) for x in obj]
89
+ return obj
90
+
91
+
92
+ # Capability sets for Gemini models
93
+ from .realtime import GeminiLiveSession, GeminiLiveTransport # noqa: F401,E402
94
+
95
+ _VT = frozenset({Capability.text, Capability.vision, Capability.audio, Capability.video, Capability.tool_use})
96
+ _RT = frozenset(
97
+ {Capability.text, Capability.reasoning, Capability.vision, Capability.audio, Capability.video, Capability.tool_use}
98
+ )
99
+ _IMG = frozenset({Capability.text, Capability.vision, Capability.image_generation})
100
+
101
+ GENAI_MODELS: ModelRegistry = ModelRegistry(
102
+ {
103
+ # --- Gemini chat/reasoning models ---
104
+ ModelSpec(
105
+ id="gemini-3.1-pro-preview",
106
+ context_window=1_048_576,
107
+ max_output_tokens=65_536,
108
+ capabilities=_RT,
109
+ input_cost=2.0,
110
+ output_cost=12.0,
111
+ ),
112
+ ModelSpec(
113
+ id="gemini-3-flash-preview",
114
+ context_window=1_048_576,
115
+ max_output_tokens=65_536,
116
+ capabilities=_RT,
117
+ input_cost=0.50,
118
+ output_cost=3.0,
119
+ ),
120
+ ModelSpec(
121
+ id="gemini-3.1-flash-lite-preview",
122
+ context_window=1_048_576,
123
+ max_output_tokens=65_536,
124
+ capabilities=_RT,
125
+ input_cost=0.25,
126
+ output_cost=1.50,
127
+ ),
128
+ # --- Nano Banana (Gemini image generation via generateContent) ---
129
+ ModelSpec(
130
+ id="gemini-3.1-flash-image-preview",
131
+ context_window=1_048_576,
132
+ max_output_tokens=8_192,
133
+ capabilities=_IMG,
134
+ ),
135
+ ModelSpec(
136
+ id="gemini-3-pro-image-preview",
137
+ context_window=1_048_576,
138
+ max_output_tokens=8_192,
139
+ capabilities=_IMG,
140
+ ),
141
+ }
142
+ )
143
+
144
+
145
+ def _get_anthropic_models() -> ModelRegistry:
146
+ """Get Anthropic models with 'anthropic/' prefix for Vertex AI routing."""
147
+ from axio_transport_anthropic import ANTHROPIC_MODELS
148
+
149
+ return ModelRegistry(
150
+ {
151
+ ModelSpec(
152
+ id=f"anthropic/{spec.id}",
153
+ context_window=spec.context_window,
154
+ max_output_tokens=spec.max_output_tokens,
155
+ capabilities=spec.capabilities,
156
+ input_cost=spec.input_cost,
157
+ output_cost=spec.output_cost,
158
+ )
159
+ for spec in ANTHROPIC_MODELS.values()
160
+ }
161
+ )
162
+
163
+
164
+ _FINISH_REASON_MAP: dict[str, StopReason] = {
165
+ "STOP": StopReason.end_turn,
166
+ "MAX_TOKENS": StopReason.max_tokens,
167
+ "SAFETY": StopReason.error,
168
+ "RECITATION": StopReason.error,
169
+ "MALFORMED_FUNCTION_CALL": StopReason.tool_use,
170
+ "UNEXPECTED_TOOL_CALL": StopReason.tool_use,
171
+ "OTHER": StopReason.error,
172
+ "BLOCKLIST": StopReason.error,
173
+ "PROHIBITED_CONTENT": StopReason.error,
174
+ "SPII": StopReason.error,
175
+ "MODEL_ARMOR": StopReason.error,
176
+ "IMAGE_SAFETY": StopReason.error,
177
+ "IMAGE_PROHIBITED_CONTENT": StopReason.error,
178
+ "IMAGE_RECITATION": StopReason.error,
179
+ "IMAGE_OTHER": StopReason.error,
180
+ "NO_IMAGE": StopReason.error,
181
+ }
182
+
183
+ _DEVELOPER_API_BASE = "https://generativelanguage.googleapis.com/v1beta"
184
+
185
+ # API reference (discovery docs):
186
+ # https://aiplatform.googleapis.com/$discovery/rest?version=v1
187
+ # https://aiplatform.googleapis.com/$discovery/rest?version=v1beta1
188
+
189
+
190
+ async def _iter_sse(resp: aiohttp.ClientResponse) -> AsyncIterator[dict[str, Any]]:
191
+ """Parse SSE stream, yielding JSON objects.
192
+
193
+ Uses manual buffering instead of aiohttp readline() which has a 128KB
194
+ line limit — too small for inline image/audio data.
195
+ """
196
+ buf = b""
197
+ async for raw_chunk in resp.content.iter_any():
198
+ buf += raw_chunk
199
+ while b"\n" in buf:
200
+ raw_line, buf = buf.split(b"\n", 1)
201
+ line = raw_line.decode("utf-8", errors="replace").strip()
202
+ if not line or not line.startswith("data: "):
203
+ continue
204
+ try:
205
+ yield json.loads(line[6:])
206
+ except json.JSONDecodeError:
207
+ logger.warning("Failed to parse SSE chunk: %.200s", line)
208
+ # Process any remaining data after stream ends
209
+ if buf:
210
+ line = buf.decode("utf-8", errors="replace").strip()
211
+ if line.startswith("data: "):
212
+ try:
213
+ yield json.loads(line[6:])
214
+ except json.JSONDecodeError:
215
+ logger.warning("Failed to parse final SSE chunk: %.200s", line)
216
+
217
+
218
+ # ── JSON payload builders (no SDK dependency) ───────────────────────
219
+
220
+
221
+ def _strip_title(schema: dict[str, Any]) -> dict[str, Any]:
222
+ """Remove pydantic 'title' keys from a JSON schema recursively."""
223
+ out: dict[str, Any] = {}
224
+ for key, value in schema.items():
225
+ if key == "title":
226
+ continue
227
+ if isinstance(value, dict):
228
+ out[key] = _strip_title(value)
229
+ elif isinstance(value, list):
230
+ out[key] = [_strip_title(item) if isinstance(item, dict) else item for item in value]
231
+ else:
232
+ out[key] = value
233
+ return out
234
+
235
+
236
+ def _build_tools_json(tools: list[Tool[Any]]) -> list[ToolDict]:
237
+ """Convert axio Tool list to Gemini REST API tool declarations."""
238
+ declarations: list[FunctionDeclaration] = []
239
+ for tool in tools:
240
+ schema = _strip_title(tool.input_schema)
241
+ declarations.append(
242
+ {
243
+ "name": tool.name,
244
+ "description": tool.description,
245
+ "parameters": schema,
246
+ }
247
+ )
248
+ return [{"functionDeclarations": declarations}]
249
+
250
+
251
+ def _inline_data_part(block: ImageBlock | AudioBlock | VideoBlock) -> Part:
252
+ return {
253
+ "inlineData": {
254
+ "mimeType": block.media_type,
255
+ "data": base64.b64encode(block.data).decode(),
256
+ }
257
+ }
258
+
259
+
260
+ def _build_contents_json(
261
+ messages: list[Message],
262
+ thought_signatures: dict[str, str] | None = None,
263
+ ) -> list[Content]:
264
+ """Convert axio Message list to Gemini REST API contents array.
265
+
266
+ thought_signatures values are base64-encoded strings ready for JSON.
267
+ """
268
+ contents: list[Content] = []
269
+
270
+ for msg in messages:
271
+ if msg.role == "user":
272
+ tool_results = [b for b in msg.content if isinstance(b, ToolResultBlock)]
273
+ if tool_results and len(tool_results) == len(msg.content):
274
+ tool_result_parts: list[Part] = []
275
+ for tr in tool_results:
276
+ if isinstance(tr.content, str):
277
+ response_dict: dict[str, Any] = {"result": tr.content}
278
+ else:
279
+ text_parts = [b.text for b in tr.content if isinstance(b, TextBlock)]
280
+ response_dict = {"result": "\n".join(text_parts)} if text_parts else {"result": ""}
281
+ if tr.is_error:
282
+ response_dict = {"error": response_dict.get("result", "")}
283
+ tool_result_parts.append(
284
+ {
285
+ "functionResponse": {
286
+ "name": _tool_name_from_id(tr.tool_use_id, messages),
287
+ "response": response_dict,
288
+ "id": tr.tool_use_id,
289
+ }
290
+ }
291
+ )
292
+ # Media from tool results as sibling inlineData parts
293
+ if not isinstance(tr.content, str):
294
+ for content_block in tr.content:
295
+ if isinstance(content_block, (ImageBlock, AudioBlock, VideoBlock)):
296
+ tool_result_parts.append(_inline_data_part(content_block))
297
+ contents.append({"role": "user", "parts": tool_result_parts})
298
+ else:
299
+ user_parts: list[Part] = []
300
+ for message_block in msg.content:
301
+ if isinstance(message_block, TextBlock):
302
+ user_parts.append({"text": message_block.text})
303
+ elif isinstance(message_block, (ImageBlock, AudioBlock, VideoBlock)):
304
+ user_parts.append(_inline_data_part(message_block))
305
+ if user_parts:
306
+ contents.append({"role": "user", "parts": user_parts})
307
+
308
+ elif msg.role == "assistant":
309
+ assistant_parts: list[Part] = []
310
+ for assistant_block in msg.content:
311
+ if isinstance(assistant_block, TextBlock):
312
+ assistant_parts.append({"text": assistant_block.text})
313
+ elif isinstance(assistant_block, (ImageBlock, AudioBlock, VideoBlock)):
314
+ assistant_parts.append(_inline_data_part(assistant_block))
315
+ elif isinstance(assistant_block, ToolUseBlock):
316
+ part: Part = {
317
+ "functionCall": {
318
+ "name": assistant_block.name,
319
+ "args": assistant_block.input,
320
+ "id": assistant_block.id,
321
+ }
322
+ }
323
+ if thought_signatures and assistant_block.id in thought_signatures:
324
+ part["thoughtSignature"] = thought_signatures[assistant_block.id]
325
+ assistant_parts.append(part)
326
+ if assistant_parts:
327
+ contents.append({"role": "model", "parts": assistant_parts})
328
+
329
+ # Gemini requires alternating user/model roles. Merge consecutive
330
+ # same-role contents (e.g. tool-result message + "Proceed." nudge).
331
+ merged: list[Content] = []
332
+ for c in contents:
333
+ if merged and merged[-1]["role"] == c["role"]:
334
+ merged[-1]["parts"].extend(c["parts"])
335
+ else:
336
+ merged.append(c)
337
+ return merged
338
+
339
+
340
+ def _tool_name_from_id(tool_use_id: str, messages: list[Message]) -> str:
341
+ """Find the tool name for a given tool_use_id by scanning assistant messages."""
342
+ for msg in messages:
343
+ if msg.role == "assistant":
344
+ for b in msg.content:
345
+ if isinstance(b, ToolUseBlock) and b.id == tool_use_id:
346
+ return b.name or "unknown"
347
+ return "unknown"
348
+
349
+
350
+ # ── Transport ───────────────────────────────────────────────────────
351
+
352
+
353
+ @dataclass(slots=True)
354
+ class GoogleTransport(CompletionTransport, ImageGenTransport, VideoGenTransport):
355
+ name: str = "Google GenAI"
356
+ api_key: str = ""
357
+ vertexai: bool | None = None
358
+ project: str = ""
359
+ location: str = ""
360
+ model: ModelSpec = field(default_factory=lambda: GENAI_MODELS["gemini-3.1-flash-lite-preview"])
361
+ models: ModelRegistry = field(default_factory=lambda: ModelRegistry(GENAI_MODELS.values()))
362
+ session: aiohttp.ClientSession | None = field(default=None, repr=False, compare=False)
363
+ max_retries: int = 5
364
+ temperature: float | None = field(default=None, repr=False)
365
+ top_p: float | None = field(default=None, repr=False)
366
+ top_k: float | None = field(default=None, repr=False)
367
+ seed: int | None = field(default=None, repr=False)
368
+ safety_settings: list[SafetySettingDict] | None = field(default=None, repr=False)
369
+ debug: bool = False
370
+ nudge_on_media_tool_result: bool = True
371
+ max_output_tokens: int | None = field(default=None, repr=False)
372
+ thinking_budget: int | None = field(default=None, repr=False)
373
+ thinking_level: str | None = field(default=None, repr=False)
374
+ service_tier: str | None = field(default=None, repr=False)
375
+ media_resolution: str | None = field(default=None, repr=False)
376
+ # thought_signature values stored as base64 strings for direct JSON embedding
377
+ _thought_signatures: dict[str, str] = field(default_factory=dict, repr=False, compare=False)
378
+ last_usage: Usage | None = field(default=None, repr=False, compare=False)
379
+ # Vertex AI credentials (lazily initialised)
380
+ _credentials: Any = field(default=None, repr=False, compare=False)
381
+
382
+ def __post_init__(self) -> None:
383
+ if isinstance(self.vertexai, str):
384
+ self.vertexai = self.vertexai.lower() in ("true", "1")
385
+ if self.vertexai is None:
386
+ self.vertexai = os.environ.get("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in ("true", "1")
387
+ if isinstance(self.temperature, str):
388
+ self.temperature = float(self.temperature) if self.temperature else None
389
+ if isinstance(self.top_p, str):
390
+ self.top_p = float(self.top_p) if self.top_p else None
391
+ if isinstance(self.top_k, str):
392
+ self.top_k = float(self.top_k) if self.top_k else None
393
+ if isinstance(self.seed, str):
394
+ self.seed = int(self.seed) if self.seed else None
395
+ if isinstance(self.thinking_budget, str):
396
+ self.thinking_budget = int(self.thinking_budget) if self.thinking_budget else None
397
+ if isinstance(self.thinking_level, str) and self.thinking_level:
398
+ self.thinking_level = self.thinking_level.upper()
399
+ elif not self.thinking_level:
400
+ self.thinking_level = None
401
+
402
+ # ── Auth & URL helpers ──
403
+
404
+ def _get_api_key(self) -> str:
405
+ return self.api_key or os.environ.get("GEMINI_API_KEY", "")
406
+
407
+ async def _get_vertex_token(self) -> str:
408
+ import google.auth
409
+ import google.auth.transport.urllib3
410
+ import urllib3
411
+
412
+ if self._credentials is None:
413
+ credentials, _ = await asyncio.to_thread(
414
+ google.auth.default,
415
+ scopes=["https://www.googleapis.com/auth/cloud-platform"],
416
+ )
417
+ self._credentials = credentials
418
+ creds = cast(_RefreshableCredentials, self._credentials)
419
+ if creds.valid and not creds.expired:
420
+ if not creds.token:
421
+ raise RuntimeError("Google credentials did not return an access token")
422
+ return creds.token
423
+ # creds.refresh() handles all credential types: user OAuth2, service
424
+ # accounts, compute engine metadata, workload identity federation, etc.
425
+ request_factory = cast(Any, google.auth.transport.urllib3.Request)
426
+ await asyncio.to_thread(creds.refresh, request_factory(urllib3.PoolManager()))
427
+ if not creds.token:
428
+ raise RuntimeError("Google credentials did not return an access token")
429
+ return creds.token
430
+
431
+ def _build_url(self, path: str, qs: str = "") -> str:
432
+ """Build a full API URL for the given path.
433
+
434
+ For Developer API: {base}/models/{model}:{method}?key=...&{qs}
435
+ For Vertex AI: {base}/projects/.../models/{model}:{method}?{qs}
436
+ """
437
+ if self.vertexai:
438
+ project = self.project or os.environ.get("GOOGLE_CLOUD_PROJECT", "")
439
+ location = self.location or os.environ.get("GOOGLE_CLOUD_LOCATION", "")
440
+ if location and location != "global":
441
+ base = f"https://{location}-aiplatform.googleapis.com/v1beta1"
442
+ else:
443
+ base = "https://aiplatform.googleapis.com/v1beta1"
444
+ url = f"{base}/projects/{project}/locations/{location}/{path}"
445
+ else:
446
+ api_key = self._get_api_key()
447
+ qs = f"key={api_key}&{qs}" if qs else f"key={api_key}"
448
+ url = f"{_DEVELOPER_API_BASE}/{path}"
449
+ if qs:
450
+ url = f"{url}?{qs}" if "?" not in url else f"{url}&{qs}"
451
+ return url
452
+
453
+ async def _get_headers(self) -> dict[str, str]:
454
+ headers: dict[str, str] = {"Content-Type": "application/json"}
455
+ if self.vertexai:
456
+ token = await self._get_vertex_token()
457
+ headers["Authorization"] = f"Bearer {token}"
458
+ project = self.project or os.environ.get("GOOGLE_CLOUD_PROJECT", "")
459
+ if project:
460
+ headers["x-goog-user-project"] = project
461
+ return headers
462
+
463
+ def get_thinking_options(self) -> tuple[str, ...] | None:
464
+ """Valid thinkingLevel values for the current model, or None if budget-based (2.5)."""
465
+ return valid_thinking_levels(self.model.id)
466
+
467
+ # ── Generation config ──
468
+
469
+ def _build_generation_config_json(self) -> GenerationConfig:
470
+ config: GenerationConfig = {
471
+ "maxOutputTokens": self.max_output_tokens or self.model.max_output_tokens,
472
+ "audioTimestamp": True,
473
+ }
474
+ if self.temperature is not None:
475
+ config["temperature"] = self.temperature
476
+ if self.top_p is not None:
477
+ config["topP"] = self.top_p
478
+ if self.top_k is not None:
479
+ config["topK"] = self.top_k
480
+ if self.seed is not None:
481
+ config["seed"] = self.seed
482
+ if self.media_resolution:
483
+ config["mediaResolution"] = self.media_resolution.upper() # type: ignore[typeddict-item]
484
+ if self.thinking_level or self.thinking_budget is not None or Capability.reasoning in self.model.capabilities:
485
+ thinking: ThinkingConfig = {"includeThoughts": True}
486
+ levels = valid_thinking_levels(self.model.id)
487
+ if levels is not None:
488
+ # Gemini 3+: use thinkingLevel (thinkingBudget is not supported)
489
+ level = (self.thinking_level or "HIGH").upper()
490
+ if level not in levels:
491
+ level = levels[-1] # fall back to highest supported
492
+ thinking["thinkingLevel"] = level # type: ignore[typeddict-item]
493
+ elif self.thinking_budget is not None:
494
+ # Gemini 2.5: use thinkingBudget (thinkingLevel is not supported)
495
+ thinking["thinkingBudget"] = self.thinking_budget
496
+ config["thinkingConfig"] = thinking
497
+ if self.service_tier:
498
+ config["serviceTier"] = self.service_tier # type: ignore[typeddict-unknown-key]
499
+ if Capability.image_generation in self.model.capabilities:
500
+ config["responseModalities"] = ["TEXT", "IMAGE"]
501
+ return config
502
+
503
+ # ── Streaming ──
504
+
505
+ def stream(self, messages: list[Message], tools: list[Tool[Any]], system: str) -> AsyncIterator[StreamEvent]:
506
+ if self.model.id.startswith("anthropic/"):
507
+ return self._stream_anthropic(messages, tools, system)
508
+ return self._do_stream(messages, tools, system)
509
+
510
+ async def _stream_anthropic(
511
+ self, messages: list[Message], tools: list[Tool[Any]], system: str
512
+ ) -> AsyncIterator[StreamEvent]:
513
+ from axio_transport_anthropic import ANTHROPIC_MODELS, AnthropicTransport
514
+
515
+ bare_id = self.model.id.removeprefix("anthropic/")
516
+ model_spec = ANTHROPIC_MODELS.get(bare_id) or self.model
517
+ proxy = AnthropicTransport(
518
+ vertexai=True,
519
+ project=self.project,
520
+ location=self.location,
521
+ model=model_spec,
522
+ max_retries=self.max_retries,
523
+ temperature=self.temperature,
524
+ top_p=self.top_p,
525
+ top_k=int(self.top_k) if self.top_k is not None else None,
526
+ thinking_budget=self.thinking_budget,
527
+ session=self.session,
528
+ )
529
+ async for event in proxy.stream(messages, tools, system):
530
+ yield event
531
+
532
+ async def _do_stream(
533
+ self, messages: list[Message], tools: list[Tool[Any]], system: str
534
+ ) -> AsyncIterator[StreamEvent]:
535
+ assert self.session is not None, "aiohttp session required"
536
+
537
+ contents = _build_contents_json(messages, self._thought_signatures)
538
+
539
+ body: GenerateContentRequest = {"contents": contents}
540
+ if system:
541
+ body["systemInstruction"] = {"parts": [{"text": system}]}
542
+
543
+ is_image_model = Capability.image_generation in self.model.capabilities
544
+ if tools and not is_image_model:
545
+ body["tools"] = _build_tools_json(tools)
546
+
547
+ body["generationConfig"] = self._build_generation_config_json()
548
+
549
+ if self.safety_settings:
550
+ body["safetySettings"] = self.safety_settings
551
+
552
+ model_path = f"publishers/google/models/{self.model.id}" if self.vertexai else f"models/{self.model.id}"
553
+ url = self._build_url(f"{model_path}:streamGenerateContent", "alt=sse")
554
+ headers = await self._get_headers()
555
+
556
+ logger.info(
557
+ "Gemini stream: model=%s, contents=%d, tools=%d",
558
+ self.model.id,
559
+ len(contents),
560
+ len(tools),
561
+ )
562
+ if self.debug:
563
+ logger.warning("DEBUG request body:\n%s", json.dumps(_redact_body(body), indent=2, ensure_ascii=False))
564
+ elif logger.getEffectiveLevel() <= logging.DEBUG:
565
+ for i, c in enumerate(contents):
566
+ logger.debug(" content[%d] role=%s parts=%d", i, c.get("role"), len(c.get("parts", [])))
567
+
568
+ usage = Usage(0, 0)
569
+ stop_reason = StopReason.end_turn
570
+ has_tool_calls = False
571
+
572
+ last_exc: Exception | None = None
573
+ for attempt in range(1, self.max_retries + 1):
574
+ try:
575
+ async with self.session.post(url, json=body, headers=headers) as resp:
576
+ if resp.status != 200:
577
+ error_text = await resp.text()
578
+ if resp.status in (429, 500, 503) and attempt < self.max_retries:
579
+ logger.warning(
580
+ "Gemini HTTP %d (attempt %d/%d): %.200s",
581
+ resp.status,
582
+ attempt,
583
+ self.max_retries,
584
+ error_text,
585
+ )
586
+ await asyncio.sleep(2.0 * (2 ** (attempt - 1)))
587
+ continue
588
+ raise StreamError(f"{resp.status} {resp.reason}: {error_text[:1000]}")
589
+
590
+ async for chunk in _iter_sse(resp):
591
+ if self.debug:
592
+ logger.warning(
593
+ "DEBUG response chunk:\n%s",
594
+ json.dumps(_redact_body(chunk), indent=2, ensure_ascii=False),
595
+ )
596
+ um = chunk.get("usageMetadata")
597
+ if um and "promptTokenCount" in um:
598
+ usage = Usage(
599
+ input_tokens=um["promptTokenCount"],
600
+ output_tokens=um.get("candidatesTokenCount", 0),
601
+ )
602
+ self.last_usage = usage
603
+
604
+ candidates = chunk.get("candidates")
605
+ if not candidates:
606
+ continue
607
+ candidate = candidates[0]
608
+
609
+ fr = candidate.get("finishReason")
610
+ if fr:
611
+ stop_reason = _FINISH_REASON_MAP.get(fr, StopReason.error)
612
+ if fr not in _FINISH_REASON_MAP:
613
+ logger.warning("Unknown finishReason %r", fr)
614
+
615
+ content = candidate.get("content")
616
+ if not content:
617
+ continue
618
+ for part in content.get("parts", []):
619
+ if part.get("thought") and part.get("text"):
620
+ yield ReasoningDelta(index=0, delta=part["text"])
621
+ elif "text" in part and not part.get("thought"):
622
+ yield TextDelta(index=0, delta=part["text"])
623
+ elif "inlineData" in part:
624
+ idata = part["inlineData"]
625
+ mt = idata.get("mimeType", "")
626
+ raw = base64.b64decode(idata.get("data", ""))
627
+ if mt.startswith("image/"):
628
+ yield ImageOutput(index=0, data=raw, media_type=mt)
629
+ elif mt.startswith("audio/"):
630
+ yield AudioOutput(index=0, data=raw, media_type=mt)
631
+ elif mt.startswith("video/"):
632
+ yield VideoOutput(index=0, data=raw, media_type=mt)
633
+ elif "functionCall" in part:
634
+ fc = part["functionCall"]
635
+ call_id = fc.get("id") or f"genai_{fc.get('name')}_{id(fc)}"
636
+ ts = part.get("thoughtSignature")
637
+ if ts:
638
+ self._thought_signatures[call_id] = ts
639
+ yield ToolUseStart(index=0, tool_use_id=call_id, name=fc.get("name", ""))
640
+ args_json = json.dumps(fc.get("args")) if fc.get("args") else "{}"
641
+ yield ToolInputDelta(index=0, tool_use_id=call_id, partial_json=args_json)
642
+ has_tool_calls = True
643
+
644
+ if has_tool_calls:
645
+ stop_reason = StopReason.tool_use
646
+
647
+ logger.info(
648
+ "Gemini stream complete: stop=%s, in=%d, out=%d",
649
+ stop_reason,
650
+ usage.input_tokens,
651
+ usage.output_tokens,
652
+ )
653
+ yield IterationEnd(iteration=0, stop_reason=stop_reason, usage=usage)
654
+ return
655
+
656
+ except StreamError:
657
+ raise
658
+ except Exception as exc:
659
+ last_exc = exc
660
+ status = getattr(exc, "status", getattr(exc, "status_code", None))
661
+ if status in (429, 500, 503) or "ResourceExhausted" in str(exc):
662
+ logger.warning("Gemini retryable error (attempt %d/%d): %s", attempt, self.max_retries, exc)
663
+ if attempt < self.max_retries:
664
+ await asyncio.sleep(2.0 * (2 ** (attempt - 1)))
665
+ continue
666
+ logger.error("Gemini stream error: %s", exc, exc_info=True)
667
+ raise StreamError(str(exc)) from exc
668
+
669
+ raise StreamError(str(last_exc)) from last_exc
670
+
671
+ # ── Image / Veo generation ──
672
+
673
+ async def generate_images(self, prompt: str, *, model: str | None = None, n: int = 1) -> list[bytes]:
674
+ """Generate images via Gemini Nano Banana (generateContent with IMAGE response modality)."""
675
+ assert self.session is not None, "aiohttp session required"
676
+ model_id = model or "gemini-3-pro-image-preview"
677
+ return await self._generate_images_gemini(prompt, model_id=model_id, n=n)
678
+
679
+ async def _generate_images_gemini(self, prompt: str, *, model_id: str, n: int) -> list[bytes]:
680
+ assert self.session is not None
681
+ model_path = f"publishers/google/models/{model_id}" if self.vertexai else f"models/{model_id}"
682
+ url = self._build_url(f"{model_path}:generateContent")
683
+ headers = await self._get_headers()
684
+ results: list[bytes] = []
685
+ for _ in range(n):
686
+ body: dict[str, Any] = {
687
+ "contents": [{"role": "user", "parts": [{"text": prompt}]}],
688
+ "generationConfig": {"responseModalities": ["IMAGE"]},
689
+ }
690
+ async with self.session.post(url, json=body, headers=headers) as resp:
691
+ if resp.status != 200:
692
+ error_text = await resp.text()
693
+ raise StreamError(f"Gemini image {resp.status}: {error_text[:1000]}")
694
+ data = await resp.json()
695
+ for candidate in data.get("candidates", []):
696
+ for part in candidate.get("content", {}).get("parts", []):
697
+ idata = part.get("inlineData")
698
+ if idata and idata.get("mimeType", "").startswith("image/"):
699
+ results.append(base64.b64decode(idata["data"]))
700
+ return results
701
+
702
+ async def generate_videos(
703
+ self,
704
+ prompt: str,
705
+ *,
706
+ model: str | None = None,
707
+ n: int = 1,
708
+ image: bytes | None = None,
709
+ duration_seconds: int | None = None,
710
+ aspect_ratio: str | None = None,
711
+ ) -> list[bytes]:
712
+ """Generate videos using Veo models. Polls until the operation completes."""
713
+ assert self.session is not None, "aiohttp session required"
714
+ model_id = model or "veo-3.1-fast-generate-001"
715
+ model_path = f"publishers/google/models/{model_id}" if self.vertexai else f"models/{model_id}"
716
+ url = self._build_url(f"{model_path}:predictLongRunning")
717
+ headers = await self._get_headers()
718
+
719
+ instance: dict[str, Any] = {"prompt": prompt}
720
+ if image:
721
+ instance["image"] = {
722
+ "bytesBase64Encoded": base64.b64encode(image).decode(),
723
+ "mimeType": "image/jpeg",
724
+ }
725
+ params: dict[str, Any] = {"sampleCount": n}
726
+ if duration_seconds:
727
+ params["durationSeconds"] = duration_seconds
728
+ if aspect_ratio:
729
+ params["aspectRatio"] = aspect_ratio
730
+ body = {"instances": [instance], "parameters": params}
731
+
732
+ async with self.session.post(url, json=body, headers=headers) as resp:
733
+ if resp.status != 200:
734
+ error_text = await resp.text()
735
+ raise StreamError(f"Veo {resp.status}: {error_text[:1000]}")
736
+ operation = await resp.json()
737
+
738
+ # Poll until done
739
+ op_name = operation.get("name", "")
740
+ while not operation.get("done"):
741
+ await asyncio.sleep(5)
742
+ headers = await self._get_headers()
743
+ if self.vertexai:
744
+ poll_url = self._build_url(f"{model_path}:fetchPredictOperation")
745
+ async with self.session.post(
746
+ poll_url,
747
+ json={"operationName": op_name},
748
+ headers=headers,
749
+ ) as resp:
750
+ if resp.status != 200:
751
+ error_text = await resp.text()
752
+ raise StreamError(f"Veo poll {resp.status}: {error_text[:1000]}")
753
+ operation = await resp.json()
754
+ else:
755
+ op_id = op_name.rsplit("/", 1)[-1]
756
+ poll_url = self._build_url(f"models/{model_id}/operations/{op_id}")
757
+ async with self.session.get(poll_url, headers=headers) as resp:
758
+ if resp.status != 200:
759
+ error_text = await resp.text()
760
+ raise StreamError(f"Veo poll {resp.status}: {error_text[:1000]}")
761
+ operation = await resp.json()
762
+
763
+ response = operation.get("response", {})
764
+ results: list[bytes] = []
765
+ # Vertex AI: response.videos[].bytesBase64Encoded (inline) or .gcsUri
766
+ for vid in response.get("videos", []):
767
+ b64 = vid.get("bytesBase64Encoded")
768
+ if b64:
769
+ results.append(base64.b64decode(b64))
770
+ # Vertex AI fallback / Developer API nested structure
771
+ generated = response.get("generatedSamples") or response.get("generateVideoResponse", {}).get(
772
+ "generatedSamples", []
773
+ )
774
+ for sample in generated:
775
+ video = sample.get("video", {})
776
+ b64 = video.get("encodedVideo") or video.get("bytesBase64Encoded")
777
+ if b64:
778
+ results.append(base64.b64decode(b64))
779
+ elif not results and video.get("uri"):
780
+ # Developer API returns a temporary download URL
781
+ headers = await self._get_headers()
782
+ async with self.session.get(video["uri"], headers=headers) as resp:
783
+ if resp.status == 200:
784
+ results.append(await resp.read())
785
+ else:
786
+ logger.warning("Veo video download failed: %d", resp.status)
787
+ return results
788
+
789
+ # ── Model listing ──
790
+
791
+ async def fetch_models(self) -> None:
792
+ """Fetch available Gemini models.
793
+
794
+ Developer API: GET /v1beta/models?key=...
795
+ Vertex AI: GET /v1beta1/publishers/google/models (no project prefix)
796
+ """
797
+ assert self.session is not None, "aiohttp session required"
798
+ try:
799
+ headers = await self._get_headers()
800
+ if self.vertexai:
801
+ # Vertex AI model catalog — no project/location prefix
802
+ base_url = "https://aiplatform.googleapis.com/v1beta1/publishers/google/models"
803
+ else:
804
+ api_key = self._get_api_key()
805
+ base_url = f"{_DEVELOPER_API_BASE}/models?key={api_key}"
806
+
807
+ fetched: list[ModelSpec] = []
808
+ page_token: str | None = None
809
+ while True:
810
+ sep = "&" if "?" in base_url else "?"
811
+ url = f"{base_url}{sep}pageToken={page_token}" if page_token else base_url
812
+ async with self.session.get(url, headers=headers) as resp:
813
+ if resp.status != 200:
814
+ logger.warning("fetch_models HTTP %d", resp.status)
815
+ break
816
+ data = await resp.json()
817
+ # Developer API: {"models": [...]}, Vertex AI: {"publisherModels": [...]}
818
+ raw_models = data.get("models") or data.get("publisherModels") or []
819
+ for model in raw_models:
820
+ name: str = model.get("name", "")
821
+ if "models/" in name:
822
+ model_id = name.split("models/", 1)[1]
823
+ else:
824
+ model_id = name
825
+ if not model_id:
826
+ continue
827
+
828
+ # Developer API populates supportedGenerationMethods;
829
+ # Vertex AI does not — filter by name instead.
830
+ gen_methods: list[str] = model.get("supportedGenerationMethods", [])
831
+ if gen_methods and "generateContent" not in gen_methods:
832
+ continue
833
+ if any(s in model_id for s in ("-tts", "native-audio", "gemini-live-")):
834
+ continue
835
+
836
+ if model_id in GENAI_MODELS:
837
+ fetched.append(GENAI_MODELS[model_id])
838
+ else:
839
+ caps = _RT if model.get("thinking") else _VT
840
+ fetched.append(
841
+ ModelSpec(
842
+ id=model_id,
843
+ context_window=model.get("inputTokenLimit", 1_048_576),
844
+ max_output_tokens=model.get("outputTokenLimit", 8_192),
845
+ capabilities=caps,
846
+ )
847
+ )
848
+ page_token = data.get("nextPageToken")
849
+ if not page_token:
850
+ break
851
+ if fetched:
852
+ self.models = ModelRegistry(fetched)
853
+ else:
854
+ self.models = GENAI_MODELS
855
+ except Exception:
856
+ logger.warning("fetch_models failed, using defaults", exc_info=True)
857
+ self.models = GENAI_MODELS
858
+
859
+ if self.vertexai:
860
+ for spec in _get_anthropic_models().values():
861
+ self.models[spec.id] = spec
862
+
863
+ # ── Serialization ──
864
+
865
+ def to_dict(self) -> dict[str, Any]:
866
+ d: dict[str, Any] = {
867
+ "name": self.name,
868
+ "api_key": self.api_key,
869
+ "models": [
870
+ {
871
+ "id": m.id,
872
+ "context_window": m.context_window,
873
+ "max_output_tokens": m.max_output_tokens,
874
+ "capabilities": sorted(c.value for c in m.capabilities),
875
+ "input_cost": m.input_cost,
876
+ "output_cost": m.output_cost,
877
+ }
878
+ for m in self.models.values()
879
+ ],
880
+ }
881
+ if self.vertexai:
882
+ d["vertexai"] = True
883
+ if self.project:
884
+ d["project"] = self.project
885
+ if self.location:
886
+ d["location"] = self.location
887
+ for k in (
888
+ "temperature",
889
+ "top_p",
890
+ "top_k",
891
+ "seed",
892
+ "max_output_tokens",
893
+ "thinking_budget",
894
+ "thinking_level",
895
+ "service_tier",
896
+ "media_resolution",
897
+ ):
898
+ v = getattr(self, k)
899
+ if v is not None:
900
+ d[k] = v
901
+ if self.safety_settings:
902
+ d["safety_settings"] = self.safety_settings
903
+ return d
904
+
905
+ @classmethod
906
+ def from_dict(cls, data: dict[str, Any]) -> GoogleTransport:
907
+ models = ModelRegistry(
908
+ [
909
+ ModelSpec(
910
+ id=str(m["id"]),
911
+ context_window=int(m.get("context_window", 1_048_576)),
912
+ max_output_tokens=int(m.get("max_output_tokens", 8_192)),
913
+ capabilities=frozenset(
914
+ Capability(c) for c in m.get("capabilities", []) if c in Capability.__members__
915
+ ),
916
+ input_cost=float(m.get("input_cost", 0.0)),
917
+ output_cost=float(m.get("output_cost", 0.0)),
918
+ )
919
+ for m in data.get("models", [])
920
+ ]
921
+ )
922
+ return cls(
923
+ name=str(data.get("name", "")),
924
+ api_key=str(data.get("api_key", "")),
925
+ vertexai=bool(data.get("vertexai", False)),
926
+ project=str(data.get("project", "")),
927
+ location=str(data.get("location", "")),
928
+ models=models,
929
+ temperature=data.get("temperature"),
930
+ top_p=data.get("top_p"),
931
+ top_k=data.get("top_k"),
932
+ seed=data.get("seed"),
933
+ safety_settings=data.get("safety_settings"),
934
+ max_output_tokens=data.get("max_output_tokens"),
935
+ thinking_budget=data.get("thinking_budget"),
936
+ thinking_level=data.get("thinking_level"),
937
+ service_tier=data.get("service_tier"),
938
+ media_resolution=data.get("media_resolution"),
939
+ )
940
+
941
+
942
+ @dataclass(slots=True)
943
+ class VertexAITransport(GoogleTransport):
944
+ """GoogleTransport pre-configured for Vertex AI (includes Anthropic models)."""
945
+
946
+ name: str = "Google Vertex AI"
947
+ vertexai: bool | None = True