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.
- axio_transport_google/__init__.py +947 -0
- axio_transport_google/_generated_types.py +500 -0
- axio_transport_google/py.typed +0 -0
- axio_transport_google/realtime.py +604 -0
- axio_transport_google/tools.py +76 -0
- axio_transport_google/tui/__init__.py +15 -0
- axio_transport_google/tui/google.py +143 -0
- axio_transport_google-0.1.0.dist-info/METADATA +36 -0
- axio_transport_google-0.1.0.dist-info/RECORD +11 -0
- axio_transport_google-0.1.0.dist-info/WHEEL +4 -0
- axio_transport_google-0.1.0.dist-info/entry_points.txt +15 -0
|
@@ -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
|