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