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.
- {ccs_llmconnector-1.1.0.dist-info → ccs_llmconnector-1.1.2.dist-info}/METADATA +1 -1
- ccs_llmconnector-1.1.2.dist-info/RECORD +16 -0
- {ccs_llmconnector-1.1.0.dist-info → ccs_llmconnector-1.1.2.dist-info}/WHEEL +1 -1
- llmconnector/__init__.py +21 -21
- llmconnector/anthropic_client.py +266 -266
- llmconnector/client.py +291 -291
- llmconnector/client_cli.py +42 -42
- llmconnector/gemini_client.py +406 -391
- llmconnector/grok_client.py +270 -270
- llmconnector/openai_client.py +256 -256
- llmconnector/types.py +48 -48
- llmconnector/utils.py +77 -77
- ccs_llmconnector-1.1.0.dist-info/RECORD +0 -16
- {ccs_llmconnector-1.1.0.dist-info → ccs_llmconnector-1.1.2.dist-info}/entry_points.txt +0 -0
- {ccs_llmconnector-1.1.0.dist-info → ccs_llmconnector-1.1.2.dist-info}/licenses/LICENSE +0 -0
- {ccs_llmconnector-1.1.0.dist-info → ccs_llmconnector-1.1.2.dist-info}/top_level.txt +0 -0
llmconnector/gemini_client.py
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 =
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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 =
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 =
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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:
|