ccs-llmconnector 1.1.0__py3-none-any.whl → 1.1.1__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.
@@ -2,54 +2,54 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import base64
6
- import mimetypes
7
- from pathlib import Path
8
- import logging
9
- from typing import Optional, Sequence
10
- from urllib.request import urlopen
11
-
12
- from google import genai
13
- from google.genai import types
14
-
15
- from .types import ImageInput, MessageSequence, normalize_messages
16
- from .utils import clamp_retries, run_sync_in_thread, run_with_retries
17
-
18
- logger = logging.getLogger(__name__)
5
+ import base64
6
+ import mimetypes
7
+ from pathlib import Path
8
+ import logging
9
+ from typing import Optional, Sequence
10
+ from urllib.request import urlopen
11
+
12
+ from google import genai
13
+ from google.genai import types
14
+
15
+ from .types import ImageInput, MessageSequence, normalize_messages
16
+ from .utils import clamp_retries, run_sync_in_thread, run_with_retries
17
+
18
+ logger = logging.getLogger(__name__)
19
19
 
20
20
 
21
21
  class GeminiClient:
22
22
  """Convenience wrapper around the Google Gemini SDK."""
23
23
 
24
- def generate_response(
25
- self,
26
- *,
27
- api_key: str,
28
- prompt: Optional[str] = None,
29
- model: str,
30
- max_tokens: int = 32000,
31
- reasoning_effort: Optional[str] = None,
32
- images: Optional[Sequence[ImageInput]] = None,
33
- messages: Optional[MessageSequence] = None,
34
- request_id: Optional[str] = None,
35
- timeout_s: Optional[float] = None,
36
- max_retries: Optional[int] = None,
37
- retry_backoff_s: float = 0.5,
38
- ) -> str:
24
+ def generate_response(
25
+ self,
26
+ *,
27
+ api_key: str,
28
+ prompt: Optional[str] = None,
29
+ model: str,
30
+ max_tokens: int = 32000,
31
+ reasoning_effort: Optional[str] = None,
32
+ images: Optional[Sequence[ImageInput]] = None,
33
+ messages: Optional[MessageSequence] = None,
34
+ request_id: Optional[str] = None,
35
+ timeout_s: Optional[float] = None,
36
+ max_retries: Optional[int] = None,
37
+ retry_backoff_s: float = 0.5,
38
+ ) -> str:
39
39
  """Generate a response from the specified Gemini model.
40
40
 
41
41
  Args:
42
42
  api_key: API key used to authenticate with the Gemini API.
43
- prompt: Natural-language instruction or query for the model.
44
- model: Identifier of the Gemini model to target (for example, ``"gemini-2.5-flash"``).
45
- max_tokens: Cap for tokens across the entire exchange, defaults to 32000.
46
- reasoning_effort: Included for API parity; currently unused by the Gemini SDK.
47
- images: Optional collection of image references (local paths, URLs, or data URLs).
48
- messages: Optional list of chat-style messages (role/content).
49
- request_id: Optional request identifier for tracing/logging.
50
- timeout_s: Optional request timeout in seconds.
51
- max_retries: Optional retry count for transient failures.
52
- retry_backoff_s: Base delay (seconds) for exponential backoff between retries.
43
+ prompt: Natural-language instruction or query for the model.
44
+ model: Identifier of the Gemini model to target (for example, ``"gemini-2.5-flash"``).
45
+ max_tokens: Cap for tokens across the entire exchange, defaults to 32000.
46
+ reasoning_effort: Included for API parity; currently unused by the Gemini SDK.
47
+ images: Optional collection of image references (local paths, URLs, or data URLs).
48
+ messages: Optional list of chat-style messages (role/content).
49
+ request_id: Optional request identifier for tracing/logging.
50
+ timeout_s: Optional request timeout in seconds.
51
+ max_retries: Optional retry count for transient failures.
52
+ retry_backoff_s: Base delay (seconds) for exponential backoff between retries.
53
53
 
54
54
  Returns:
55
55
  The text output produced by the model.
@@ -58,179 +58,178 @@ class GeminiClient:
58
58
  ValueError: If required arguments are missing or the request payload is empty.
59
59
  URLError: If an image URL cannot be retrieved.
60
60
  google.genai.errors.APIError: If the underlying Gemini request fails.
61
- """
62
- if not api_key:
63
- raise ValueError("api_key must be provided.")
64
- if not prompt and not messages and not images:
65
- raise ValueError("At least one of prompt, messages, or images must be provided.")
66
- if not model:
67
- raise ValueError("model must be provided.")
68
-
69
- normalized_messages = normalize_messages(prompt=prompt, messages=messages)
70
- contents: list[types.Content] = []
71
- for message in normalized_messages:
72
- parts: list[types.Part] = []
73
- if message["content"]:
74
- parts.append(types.Part.from_text(text=message["content"]))
75
- contents.append(types.Content(role=message["role"], parts=parts))
76
-
77
- if images:
78
- image_parts = [self._to_image_part(image) for image in images]
79
- target_index = next(
80
- (
81
- index
82
- for index in range(len(contents) - 1, -1, -1)
83
- if contents[index].role == "user"
84
- ),
85
- None,
86
- )
87
- if target_index is None:
88
- contents.append(types.Content(role="user", parts=image_parts))
89
- else:
90
- existing_parts = list(contents[target_index].parts or [])
91
- existing_parts.extend(image_parts)
92
- contents[target_index] = types.Content(
93
- role="user", parts=existing_parts
94
- )
95
-
96
- if not contents or not any(content.parts for content in contents):
97
- raise ValueError("No content provided for response generation.")
98
-
99
- config = types.GenerateContentConfig(max_output_tokens=max_tokens)
100
- # reasoning_effort is accepted for compatibility but not currently applied because the
101
- # Gemini SDK does not expose an equivalent configuration parameter.
102
-
103
- retry_count = clamp_retries(max_retries)
104
-
105
- def _build_client() -> genai.Client:
106
- client_kwargs: dict[str, object] = {"api_key": api_key}
107
- if timeout_s is not None:
108
- http_options = getattr(types, "HttpOptions", None)
109
- if http_options is not None:
110
- try:
111
- client_kwargs["http_options"] = http_options(timeout=timeout_s)
112
- except Exception:
113
- logger.debug("Gemini HttpOptions timeout not applied.", exc_info=True)
114
- return genai.Client(**client_kwargs)
115
-
116
- def _run_request() -> str:
117
- client = _build_client()
118
- try:
119
- try:
120
- response = client.models.generate_content(
121
- model=model,
122
- contents=contents,
123
- config=config,
124
- )
125
- except Exception as exc:
126
- logger.exception(
127
- "Gemini generate_content failed: %s request_id=%s",
128
- exc,
129
- request_id,
130
- )
131
- raise
132
- finally:
133
- closer = getattr(client, "close", None)
134
- if callable(closer):
135
- try:
136
- closer()
137
- except Exception:
138
- pass
139
-
140
- if response.text:
141
- result_text = response.text
142
- logger.info(
143
- "Gemini generate_content succeeded: model=%s images=%d text_len=%d request_id=%s",
144
- model,
145
- len(images or []),
146
- len(result_text or ""),
147
- request_id,
148
- )
149
- return result_text
150
-
151
- candidate_texts: list[str] = []
152
- for candidate in getattr(response, "candidates", []) or []:
153
- content_obj = getattr(candidate, "content", None)
154
- if not content_obj:
155
- continue
156
- for part in getattr(content_obj, "parts", []) or []:
157
- text = getattr(part, "text", None)
158
- if text:
159
- candidate_texts.append(text)
160
-
161
- if candidate_texts:
162
- result_text = "\n".join(candidate_texts)
163
- logger.info(
164
- "Gemini generate_content succeeded (candidates): model=%s images=%d text_len=%d request_id=%s",
165
- model,
166
- len(images or []),
167
- len(result_text or ""),
168
- request_id,
169
- )
170
- return result_text
171
-
172
- # Treat successful calls without textual content as a successful, empty response
173
- # rather than raising. This aligns with callers that handle empty outputs gracefully.
174
- logger.info(
175
- "Gemini generate_content succeeded with no text: model=%s images=%d request_id=%s",
176
- model,
177
- len(images or []),
178
- request_id,
179
- )
180
- return ""
181
-
182
- return run_with_retries(
183
- func=_run_request,
184
- max_retries=retry_count,
185
- retry_backoff_s=retry_backoff_s,
186
- request_id=request_id,
187
- )
188
-
189
- async def async_generate_response(
190
- self,
191
- *,
192
- api_key: str,
193
- prompt: Optional[str] = None,
194
- model: str,
195
- max_tokens: int = 32000,
196
- reasoning_effort: Optional[str] = None,
197
- images: Optional[Sequence[ImageInput]] = None,
198
- messages: Optional[MessageSequence] = None,
199
- request_id: Optional[str] = None,
200
- timeout_s: Optional[float] = None,
201
- max_retries: Optional[int] = None,
202
- retry_backoff_s: float = 0.5,
203
- ) -> str:
204
- return await run_sync_in_thread(
205
- lambda: self.generate_response(
206
- api_key=api_key,
207
- prompt=prompt,
208
- model=model,
209
- max_tokens=max_tokens,
210
- reasoning_effort=reasoning_effort,
211
- images=images,
212
- messages=messages,
213
- request_id=request_id,
214
- timeout_s=timeout_s,
215
- max_retries=max_retries,
216
- retry_backoff_s=retry_backoff_s,
217
- )
218
- )
219
-
220
- def generate_image(
221
- self,
222
- *,
223
- api_key: str,
224
- prompt: str,
225
- model: str,
226
- image_size: Optional[str] = None,
227
- aspect_ratio: Optional[str] = None,
228
- image: Optional[ImageInput] = None,
229
- request_id: Optional[str] = None,
230
- timeout_s: Optional[float] = None,
231
- max_retries: Optional[int] = None,
232
- retry_backoff_s: float = 0.5,
233
- ) -> bytes:
61
+ """
62
+ if not api_key:
63
+ raise ValueError("api_key must be provided.")
64
+ if not prompt and not messages and not images:
65
+ raise ValueError("At least one of prompt, messages, or images must be provided.")
66
+ if not model:
67
+ raise ValueError("model must be provided.")
68
+
69
+ normalized_messages = normalize_messages(prompt=prompt, messages=messages)
70
+ contents: list[types.Content] = []
71
+ for message in normalized_messages:
72
+ parts: list[types.Part] = []
73
+ if message["content"]:
74
+ parts.append(types.Part.from_text(text=message["content"]))
75
+ contents.append(types.Content(role=message["role"], parts=parts))
76
+
77
+ if images:
78
+ image_parts = [self._to_image_part(image) for image in images]
79
+ target_index = next(
80
+ (
81
+ index
82
+ for index in range(len(contents) - 1, -1, -1)
83
+ if contents[index].role == "user"
84
+ ),
85
+ None,
86
+ )
87
+ if target_index is None:
88
+ contents.append(types.Content(role="user", parts=image_parts))
89
+ else:
90
+ existing_parts = list(contents[target_index].parts or [])
91
+ existing_parts.extend(image_parts)
92
+ contents[target_index] = types.Content(
93
+ role="user", parts=existing_parts
94
+ )
95
+
96
+ if not contents or not any(content.parts for content in contents):
97
+ raise ValueError("No content provided for response generation.")
98
+
99
+ config = types.GenerateContentConfig(max_output_tokens=max_tokens)
100
+ # reasoning_effort is accepted for compatibility but not currently applied because the
101
+ # Gemini SDK does not expose an equivalent configuration parameter.
102
+
103
+ retry_count = clamp_retries(max_retries)
104
+
105
+ def _build_client() -> genai.Client:
106
+ client_kwargs: dict[str, object] = {"api_key": api_key}
107
+ if timeout_s is not None:
108
+ # Gemini requires at least 10s timeout if set
109
+ effective_timeout = max(10.0, timeout_s)
110
+ if effective_timeout != timeout_s:
111
+ logger.warning("Gemini timeout %ss is too short, clamping to %ss.", timeout_s, effective_timeout)
112
+ client_kwargs["http_options"] = types.HttpOptions(timeout=effective_timeout)
113
+ return genai.Client(**client_kwargs)
114
+
115
+ def _run_request() -> str:
116
+ client = _build_client()
117
+ try:
118
+ try:
119
+ response = client.models.generate_content(
120
+ model=model,
121
+ contents=contents,
122
+ config=config,
123
+ )
124
+ except Exception as exc:
125
+ logger.exception(
126
+ "Gemini generate_content failed: %s request_id=%s",
127
+ exc,
128
+ request_id,
129
+ )
130
+ raise
131
+ finally:
132
+ closer = getattr(client, "close", None)
133
+ if callable(closer):
134
+ try:
135
+ closer()
136
+ except Exception:
137
+ pass
138
+
139
+ if response.text:
140
+ result_text = response.text
141
+ logger.info(
142
+ "Gemini generate_content succeeded: model=%s images=%d text_len=%d request_id=%s",
143
+ model,
144
+ len(images or []),
145
+ len(result_text or ""),
146
+ request_id,
147
+ )
148
+ return result_text
149
+
150
+ candidate_texts: list[str] = []
151
+ for candidate in getattr(response, "candidates", []) or []:
152
+ content_obj = getattr(candidate, "content", None)
153
+ if not content_obj:
154
+ continue
155
+ for part in getattr(content_obj, "parts", []) or []:
156
+ text = getattr(part, "text", None)
157
+ if text:
158
+ candidate_texts.append(text)
159
+
160
+ if candidate_texts:
161
+ result_text = "\n".join(candidate_texts)
162
+ logger.info(
163
+ "Gemini generate_content succeeded (candidates): model=%s images=%d text_len=%d request_id=%s",
164
+ model,
165
+ len(images or []),
166
+ len(result_text or ""),
167
+ request_id,
168
+ )
169
+ return result_text
170
+
171
+ # Treat successful calls without textual content as a successful, empty response
172
+ # rather than raising. This aligns with callers that handle empty outputs gracefully.
173
+ logger.info(
174
+ "Gemini generate_content succeeded with no text: model=%s images=%d request_id=%s",
175
+ model,
176
+ len(images or []),
177
+ request_id,
178
+ )
179
+ return ""
180
+
181
+ return run_with_retries(
182
+ func=_run_request,
183
+ max_retries=retry_count,
184
+ retry_backoff_s=retry_backoff_s,
185
+ request_id=request_id,
186
+ )
187
+
188
+ async def async_generate_response(
189
+ self,
190
+ *,
191
+ api_key: str,
192
+ prompt: Optional[str] = None,
193
+ model: str,
194
+ max_tokens: int = 32000,
195
+ reasoning_effort: Optional[str] = None,
196
+ images: Optional[Sequence[ImageInput]] = None,
197
+ messages: Optional[MessageSequence] = None,
198
+ request_id: Optional[str] = None,
199
+ timeout_s: Optional[float] = None,
200
+ max_retries: Optional[int] = None,
201
+ retry_backoff_s: float = 0.5,
202
+ ) -> str:
203
+ return await run_sync_in_thread(
204
+ lambda: self.generate_response(
205
+ api_key=api_key,
206
+ prompt=prompt,
207
+ model=model,
208
+ max_tokens=max_tokens,
209
+ reasoning_effort=reasoning_effort,
210
+ images=images,
211
+ messages=messages,
212
+ request_id=request_id,
213
+ timeout_s=timeout_s,
214
+ max_retries=max_retries,
215
+ retry_backoff_s=retry_backoff_s,
216
+ )
217
+ )
218
+
219
+ def generate_image(
220
+ self,
221
+ *,
222
+ api_key: str,
223
+ prompt: str,
224
+ model: str,
225
+ image_size: Optional[str] = None,
226
+ aspect_ratio: Optional[str] = None,
227
+ image: Optional[ImageInput] = None,
228
+ request_id: Optional[str] = None,
229
+ timeout_s: Optional[float] = None,
230
+ max_retries: Optional[int] = None,
231
+ retry_backoff_s: float = 0.5,
232
+ ) -> bytes:
234
233
  """Generate an image using Gemini 3 Pro Image.
235
234
 
236
235
  Args:
@@ -255,195 +254,193 @@ class GeminiClient:
255
254
  if not model:
256
255
  raise ValueError("model must be provided.")
257
256
 
258
- config = types.GenerateContentConfig(
259
- tools=[{"google_search": {}}],
260
- image_config=types.ImageConfig(
261
- image_size=image_size or "2K",
262
- aspect_ratio=aspect_ratio,
263
- ),
264
- )
257
+ config = types.GenerateContentConfig(
258
+ tools=[{"google_search": {}}],
259
+ image_config=types.ImageConfig(
260
+ image_size=image_size or "2K",
261
+ aspect_ratio=aspect_ratio,
262
+ ),
263
+ )
265
264
 
266
265
  contents = [prompt]
267
266
  if image:
268
267
  contents.append(self._to_image_part(image))
269
268
 
270
- retry_count = clamp_retries(max_retries)
271
-
272
- def _build_client() -> genai.Client:
273
- client_kwargs: dict[str, object] = {"api_key": api_key}
274
- if timeout_s is not None:
275
- http_options = getattr(types, "HttpOptions", None)
276
- if http_options is not None:
277
- try:
278
- client_kwargs["http_options"] = http_options(timeout=timeout_s)
279
- except Exception:
280
- logger.debug("Gemini HttpOptions timeout not applied.", exc_info=True)
281
- return genai.Client(**client_kwargs)
282
-
283
- def _run_request() -> bytes:
284
- client = _build_client()
285
- try:
286
- try:
287
- response = client.models.generate_content(
288
- model=model,
289
- contents=contents,
290
- config=config,
291
- )
292
- except Exception as exc:
293
- logger.exception(
294
- "Gemini generate_image failed: %s request_id=%s",
295
- exc,
296
- request_id,
297
- )
298
- raise
299
- finally:
300
- closer = getattr(client, "close", None)
301
- if callable(closer):
302
- try:
303
- closer()
304
- except Exception:
305
- pass
306
-
307
- if not response.parts:
308
- raise ValueError("No content returned from Gemini.")
309
-
310
- for part in response.parts:
311
- if part.inline_data:
312
- return part.inline_data.data
313
-
314
- raise ValueError("No image data found in response.")
315
-
316
- return run_with_retries(
317
- func=_run_request,
318
- max_retries=retry_count,
319
- retry_backoff_s=retry_backoff_s,
320
- request_id=request_id,
321
- )
322
-
323
- async def async_generate_image(
324
- self,
325
- *,
326
- api_key: str,
327
- prompt: str,
328
- model: str,
329
- image_size: Optional[str] = None,
330
- aspect_ratio: Optional[str] = None,
331
- image: Optional[ImageInput] = None,
332
- request_id: Optional[str] = None,
333
- timeout_s: Optional[float] = None,
334
- max_retries: Optional[int] = None,
335
- retry_backoff_s: float = 0.5,
336
- ) -> bytes:
337
- return await run_sync_in_thread(
338
- lambda: self.generate_image(
339
- api_key=api_key,
340
- prompt=prompt,
341
- model=model,
342
- image_size=image_size,
343
- aspect_ratio=aspect_ratio,
344
- image=image,
345
- request_id=request_id,
346
- timeout_s=timeout_s,
347
- max_retries=max_retries,
348
- retry_backoff_s=retry_backoff_s,
349
- )
350
- )
351
-
352
- def list_models(
353
- self,
354
- *,
355
- api_key: str,
356
- request_id: Optional[str] = None,
357
- timeout_s: Optional[float] = None,
358
- max_retries: Optional[int] = None,
359
- retry_backoff_s: float = 0.5,
360
- ) -> list[dict[str, Optional[str]]]:
361
- """Return the models available to the authenticated Gemini account."""
362
- if not api_key:
363
- raise ValueError("api_key must be provided.")
364
-
365
- retry_count = clamp_retries(max_retries)
366
-
367
- def _build_client() -> genai.Client:
368
- client_kwargs: dict[str, object] = {"api_key": api_key}
369
- if timeout_s is not None:
370
- http_options = getattr(types, "HttpOptions", None)
371
- if http_options is not None:
372
- try:
373
- client_kwargs["http_options"] = http_options(timeout=timeout_s)
374
- except Exception:
375
- logger.debug("Gemini HttpOptions timeout not applied.", exc_info=True)
376
- return genai.Client(**client_kwargs)
377
-
378
- def _run_request() -> list[dict[str, Optional[str]]]:
379
- models: list[dict[str, Optional[str]]] = []
380
- client = _build_client()
381
- try:
382
- try:
383
- iterator = client.models.list()
384
- except Exception as exc:
385
- logger.exception(
386
- "Gemini list models failed: %s request_id=%s",
387
- exc,
388
- request_id,
389
- )
390
- raise
391
- for model in iterator:
392
- model_id = getattr(model, "name", None)
393
- if model_id is None and isinstance(model, dict):
394
- model_id = model.get("name")
395
- if not model_id:
396
- continue
397
-
398
- # Normalize IDs like "models/<id>" -> "<id>"
399
- if isinstance(model_id, str) and model_id.startswith("models/"):
400
- model_id = model_id.split("/", 1)[1]
401
-
402
- display_name = getattr(model, "display_name", None)
403
- if display_name is None and isinstance(model, dict):
404
- display_name = model.get("display_name")
405
-
406
- models.append({"id": model_id, "display_name": display_name})
407
- finally:
408
- closer = getattr(client, "close", None)
409
- if callable(closer):
410
- try:
411
- closer()
412
- except Exception:
413
- pass
414
-
415
- logger.info(
416
- "Gemini list_models succeeded: count=%d request_id=%s",
417
- len(models),
418
- request_id,
419
- )
420
- return models
421
-
422
- return run_with_retries(
423
- func=_run_request,
424
- max_retries=retry_count,
425
- retry_backoff_s=retry_backoff_s,
426
- request_id=request_id,
427
- )
428
-
429
- async def async_list_models(
430
- self,
431
- *,
432
- api_key: str,
433
- request_id: Optional[str] = None,
434
- timeout_s: Optional[float] = None,
435
- max_retries: Optional[int] = None,
436
- retry_backoff_s: float = 0.5,
437
- ) -> list[dict[str, Optional[str]]]:
438
- return await run_sync_in_thread(
439
- lambda: self.list_models(
440
- api_key=api_key,
441
- request_id=request_id,
442
- timeout_s=timeout_s,
443
- max_retries=max_retries,
444
- retry_backoff_s=retry_backoff_s,
445
- )
446
- )
269
+ retry_count = clamp_retries(max_retries)
270
+
271
+ def _build_client() -> genai.Client:
272
+ client_kwargs: dict[str, object] = {"api_key": api_key}
273
+ if timeout_s is not None:
274
+ # Gemini requires at least 10s timeout if set
275
+ effective_timeout = max(10.0, timeout_s)
276
+ if effective_timeout != timeout_s:
277
+ logger.warning("Gemini timeout %ss is too short, clamping to %ss.", timeout_s, effective_timeout)
278
+ client_kwargs["http_options"] = types.HttpOptions(timeout=effective_timeout)
279
+ return genai.Client(**client_kwargs)
280
+
281
+ def _run_request() -> bytes:
282
+ client = _build_client()
283
+ try:
284
+ try:
285
+ response = client.models.generate_content(
286
+ model=model,
287
+ contents=contents,
288
+ config=config,
289
+ )
290
+ except Exception as exc:
291
+ logger.exception(
292
+ "Gemini generate_image failed: %s request_id=%s",
293
+ exc,
294
+ request_id,
295
+ )
296
+ raise
297
+ finally:
298
+ closer = getattr(client, "close", None)
299
+ if callable(closer):
300
+ try:
301
+ closer()
302
+ except Exception:
303
+ pass
304
+
305
+ if not response.parts:
306
+ raise ValueError("No content returned from Gemini.")
307
+
308
+ for part in response.parts:
309
+ if part.inline_data:
310
+ return part.inline_data.data
311
+
312
+ raise ValueError("No image data found in response.")
313
+
314
+ return run_with_retries(
315
+ func=_run_request,
316
+ max_retries=retry_count,
317
+ retry_backoff_s=retry_backoff_s,
318
+ request_id=request_id,
319
+ )
320
+
321
+ async def async_generate_image(
322
+ self,
323
+ *,
324
+ api_key: str,
325
+ prompt: str,
326
+ model: str,
327
+ image_size: Optional[str] = None,
328
+ aspect_ratio: Optional[str] = None,
329
+ image: Optional[ImageInput] = None,
330
+ request_id: Optional[str] = None,
331
+ timeout_s: Optional[float] = None,
332
+ max_retries: Optional[int] = None,
333
+ retry_backoff_s: float = 0.5,
334
+ ) -> bytes:
335
+ return await run_sync_in_thread(
336
+ lambda: self.generate_image(
337
+ api_key=api_key,
338
+ prompt=prompt,
339
+ model=model,
340
+ image_size=image_size,
341
+ aspect_ratio=aspect_ratio,
342
+ image=image,
343
+ request_id=request_id,
344
+ timeout_s=timeout_s,
345
+ max_retries=max_retries,
346
+ retry_backoff_s=retry_backoff_s,
347
+ )
348
+ )
349
+
350
+ def list_models(
351
+ self,
352
+ *,
353
+ api_key: str,
354
+ request_id: Optional[str] = None,
355
+ timeout_s: Optional[float] = None,
356
+ max_retries: Optional[int] = None,
357
+ retry_backoff_s: float = 0.5,
358
+ ) -> list[dict[str, Optional[str]]]:
359
+ """Return the models available to the authenticated Gemini account."""
360
+ if not api_key:
361
+ raise ValueError("api_key must be provided.")
362
+
363
+ retry_count = clamp_retries(max_retries)
364
+
365
+ def _build_client() -> genai.Client:
366
+ client_kwargs: dict[str, object] = {"api_key": api_key}
367
+ if timeout_s is not None:
368
+ # Gemini requires at least 10s timeout if set
369
+ effective_timeout = max(10.0, timeout_s)
370
+ if effective_timeout != timeout_s:
371
+ logger.warning("Gemini timeout %ss is too short, clamping to %ss.", timeout_s, effective_timeout)
372
+ client_kwargs["http_options"] = types.HttpOptions(timeout=effective_timeout)
373
+ return genai.Client(**client_kwargs)
374
+
375
+ def _run_request() -> list[dict[str, Optional[str]]]:
376
+ models: list[dict[str, Optional[str]]] = []
377
+ client = _build_client()
378
+ try:
379
+ try:
380
+ iterator = client.models.list()
381
+ except Exception as exc:
382
+ logger.exception(
383
+ "Gemini list models failed: %s request_id=%s",
384
+ exc,
385
+ request_id,
386
+ )
387
+ raise
388
+ for model in iterator:
389
+ model_id = getattr(model, "name", None)
390
+ if model_id is None and isinstance(model, dict):
391
+ model_id = model.get("name")
392
+ if not model_id:
393
+ continue
394
+
395
+ # Normalize IDs like "models/<id>" -> "<id>"
396
+ if isinstance(model_id, str) and model_id.startswith("models/"):
397
+ model_id = model_id.split("/", 1)[1]
398
+
399
+ display_name = getattr(model, "display_name", None)
400
+ if display_name is None and isinstance(model, dict):
401
+ display_name = model.get("display_name")
402
+
403
+ models.append({"id": model_id, "display_name": display_name})
404
+ finally:
405
+ closer = getattr(client, "close", None)
406
+ if callable(closer):
407
+ try:
408
+ closer()
409
+ except Exception:
410
+ pass
411
+
412
+ logger.info(
413
+ "Gemini list_models succeeded: count=%d request_id=%s",
414
+ len(models),
415
+ request_id,
416
+ )
417
+ return models
418
+
419
+ return run_with_retries(
420
+ func=_run_request,
421
+ max_retries=retry_count,
422
+ retry_backoff_s=retry_backoff_s,
423
+ request_id=request_id,
424
+ )
425
+
426
+ async def async_list_models(
427
+ self,
428
+ *,
429
+ api_key: str,
430
+ request_id: Optional[str] = None,
431
+ timeout_s: Optional[float] = None,
432
+ max_retries: Optional[int] = None,
433
+ retry_backoff_s: float = 0.5,
434
+ ) -> list[dict[str, Optional[str]]]:
435
+ return await run_sync_in_thread(
436
+ lambda: self.list_models(
437
+ api_key=api_key,
438
+ request_id=request_id,
439
+ timeout_s=timeout_s,
440
+ max_retries=max_retries,
441
+ retry_backoff_s=retry_backoff_s,
442
+ )
443
+ )
447
444
 
448
445
  @staticmethod
449
446
  def _to_image_part(image: ImageInput) -> types.Part: