Skip to content

Commit a911d4d

Browse files
authored
feat(gemini): migrate to google-genai SDK, keep gemini_api alias (#1382)
* feat(gemini): migrate to google-genai SDK (video/audio upload, adaptive concurrency), keep gemini_api alias * fix(gemini): correct FileState comparison in upload polling; derive audio mime type from extension * fix(gemini): map audio extensions to Gemini-canonical MIME types in both backends
1 parent c3eaca3 commit a911d4d

6 files changed

Lines changed: 955 additions & 290 deletions

File tree

lmms_eval/models/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"egogpt": "EgoGPT",
3737
"from_log": "FromLog",
3838
"fuyu": "Fuyu",
39-
"gemini_api": "GeminiAPI",
39+
"gemini": "Gemini",
4040
"gpt4o_audio": "GPT4OAudio",
4141
"gemma3": "Gemma3",
4242
"gpt4v": "GPT4V",
@@ -113,6 +113,7 @@
113113
}
114114

115115
AVAILABLE_CHAT_TEMPLATE_MODELS = {
116+
"gemini": "Gemini",
116117
"bagel_lmms_engine": "BagelLmmsEngine",
117118
"fastvideo": "FastVideo",
118119
"internvl_hf": "InternVLHf",
@@ -142,6 +143,7 @@
142143
}
143144

144145
MODEL_ALIASES: dict[str, tuple[str, ...]] = {
146+
"gemini": ("gemini_api",),
145147
"dummy": ("dummy_video_reader",),
146148
"openai": ("openai_compatible", "openai_compatible_chat"),
147149
"async_openai": ("async_openai_compatible_chat", "async_openai_compatible"),

lmms_eval/models/chat/gemini.py

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import time
2+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
3+
from typing import List, Union
4+
5+
from loguru import logger as eval_logger
6+
from PIL import Image
7+
from tqdm import tqdm
8+
9+
from lmms_eval.api.instance import GenerationResult, TokenCounts
10+
from lmms_eval.api.registry import register_model
11+
from lmms_eval.models.model_utils.concurrency_control import (
12+
decide_next_concurrency,
13+
is_rate_limit_error,
14+
make_prefix_hash,
15+
)
16+
from lmms_eval.models.model_utils.usage_metrics import (
17+
get_running_totals,
18+
is_budget_exceeded,
19+
log_usage,
20+
)
21+
from lmms_eval.models.simple.gemini import Gemini as GeminiSimple
22+
from lmms_eval.models.simple.gemini import (
23+
_audio_mime_type,
24+
_extract_safety_tag,
25+
_image_to_bytes,
26+
)
27+
from lmms_eval.protocol import ChatMessages
28+
29+
try:
30+
from google.genai import types
31+
except ImportError:
32+
types = None
33+
34+
35+
def _chat_messages_to_gemini_contents(chat_messages: ChatMessages, upload_fn, system_parts: list) -> list:
36+
"""Convert ChatMessages protocol to Gemini-native types.Content list.
37+
38+
System messages are extracted into ``system_parts`` (modified in-place)
39+
so they can be passed via GenerateContentConfig.system_instruction.
40+
41+
Args:
42+
chat_messages: Structured chat messages from the protocol layer.
43+
upload_fn: Callable to upload a video and return a file reference.
44+
system_parts: Mutable list; system message text is appended here.
45+
"""
46+
contents = []
47+
for message in chat_messages.messages:
48+
parts = []
49+
for content in message.content:
50+
if content.type == "text":
51+
parts.append(types.Part.from_text(text=content.text))
52+
elif content.type == "image":
53+
img = content.url
54+
if isinstance(img, Image.Image):
55+
parts.append(types.Part.from_bytes(data=_image_to_bytes(img), mime_type="image/png"))
56+
elif isinstance(img, str):
57+
parts.append(types.Part.from_bytes(data=_image_to_bytes(Image.open(img).convert("RGB")), mime_type="image/png"))
58+
elif isinstance(img, dict):
59+
if "bytes" in img and img["bytes"] is not None:
60+
parts.append(types.Part.from_bytes(data=img["bytes"], mime_type="image/png"))
61+
elif "path" in img and img["path"] is not None:
62+
parts.append(types.Part.from_bytes(data=_image_to_bytes(Image.open(img["path"]).convert("RGB")), mime_type="image/png"))
63+
elif content.type == "video":
64+
video_url = content.url
65+
if isinstance(video_url, dict):
66+
video_url = video_url.get("path") or video_url.get("url")
67+
if isinstance(video_url, str):
68+
file_ref = upload_fn(video_url)
69+
parts.append(types.Part.from_uri(file_uri=file_ref.uri, mime_type=file_ref.mime_type))
70+
elif content.type == "audio":
71+
audio_url = content.url
72+
if isinstance(audio_url, str):
73+
with open(audio_url, "rb") as f:
74+
parts.append(types.Part.from_bytes(data=f.read(), mime_type=_audio_mime_type(audio_url)))
75+
elif isinstance(audio_url, dict) and "array" in audio_url:
76+
import io
77+
78+
import soundfile as sf
79+
80+
buf = io.BytesIO()
81+
sf.write(buf, audio_url["array"], audio_url["sampling_rate"], format="WAV")
82+
buf.seek(0)
83+
parts.append(types.Part.from_bytes(data=buf.read(), mime_type="audio/wav"))
84+
85+
if message.role == "system":
86+
# Gemini has no system role in contents; collect for system_instruction
87+
for p in parts:
88+
if hasattr(p, "text"):
89+
system_parts.append(p.text)
90+
else:
91+
system_parts.append(p)
92+
continue
93+
94+
role = "model" if message.role == "assistant" else "user"
95+
if parts:
96+
contents.append(types.Content(role=role, parts=parts))
97+
98+
return contents
99+
100+
101+
@register_model("gemini")
102+
class Gemini(GeminiSimple):
103+
is_simple = False
104+
105+
def generate_until(self, requests) -> List[GenerationResult]:
106+
if not requests:
107+
return []
108+
109+
reordered_requests = list(requests)
110+
pbar = tqdm(total=len(reordered_requests), disable=(self.rank != 0), desc="Model Responding")
111+
112+
responses: List[Union[GenerationResult, None]] = [None] * len(reordered_requests)
113+
total_latency = 0.0
114+
total_tokens = 0
115+
current_concurrency = min(self.num_concurrent, self.adaptive_config.max_concurrency)
116+
117+
dispatch_order = list(range(len(reordered_requests)))
118+
if self.prefix_aware_queue:
119+
prefix_hashes = {}
120+
for idx in dispatch_order:
121+
req = reordered_requests[idx]
122+
prefix_text = req.args[0] if isinstance(req.args[0], str) else ""
123+
if not prefix_text:
124+
_, doc_to_messages, _, doc_id, task, split = req.args
125+
chat_raw = doc_to_messages(self.task_dict[task][split][doc_id])
126+
# Extract first text content for prefix hash
127+
for msg in chat_raw if isinstance(chat_raw, list) else []:
128+
for c in msg.get("content", []) if isinstance(msg, dict) else []:
129+
if isinstance(c, dict) and c.get("type") == "text":
130+
prefix_text = c.get("text", "")
131+
break
132+
if prefix_text:
133+
break
134+
prefix_hashes[idx] = make_prefix_hash(prefix_text, self.prefix_hash_chars)
135+
dispatch_order.sort(key=lambda idx: (prefix_hashes[idx], idx))
136+
137+
cursor = 0
138+
failed_requests = 0
139+
rate_limited_requests = 0
140+
latencies: List[float] = []
141+
completed_since_adapt = 0
142+
in_flight = {}
143+
max_workers = max(1, self.adaptive_config.max_concurrency if self.adaptive_concurrency else current_concurrency)
144+
145+
def process_single_request(local_index: int, contents: list, config, task_name: str):
146+
if contents is None:
147+
return "", local_index, False, False, 0.0, 0, 0, 0
148+
149+
started_at = time.time()
150+
rate_limited = False
151+
last_error_msg = "unknown error"
152+
token_counts_result = (0, 0, 0)
153+
154+
for attempt in range(self.max_retries):
155+
try:
156+
response = self.client.models.generate_content(
157+
model=self.model_version,
158+
contents=contents,
159+
config=config,
160+
)
161+
162+
elapsed = time.time() - started_at
163+
input_tokens = 0
164+
output_tokens = 0
165+
reasoning_tokens = 0
166+
167+
meta = getattr(response, "usage_metadata", None)
168+
if meta:
169+
input_tokens = getattr(meta, "prompt_token_count", 0) or 0
170+
output_tokens = getattr(meta, "candidates_token_count", 0) or 0
171+
reasoning_tokens = getattr(meta, "thoughts_token_count", 0) or 0
172+
173+
log_usage(
174+
model_name=self.model_version,
175+
task_name=task_name,
176+
input_tokens=input_tokens,
177+
output_tokens=output_tokens,
178+
reasoning_tokens=reasoning_tokens,
179+
source="model",
180+
)
181+
182+
try:
183+
response_text = response.text
184+
except (ValueError, AttributeError):
185+
response_text = None
186+
187+
# google-genai SDK returns None for blocked/truncated responses.
188+
# Try to salvage partial thinking text before falling back to a tag.
189+
if response_text is None:
190+
try:
191+
for candidate in response.candidates:
192+
for part in getattr(candidate.content, "parts", []):
193+
text = getattr(part, "text", None)
194+
if text:
195+
response_text = text
196+
break
197+
if response_text:
198+
break
199+
except Exception:
200+
pass
201+
202+
if response_text is None:
203+
response_text = _extract_safety_tag(response)
204+
205+
return response_text, local_index, True, rate_limited, elapsed, output_tokens, input_tokens, reasoning_tokens
206+
207+
except Exception as exc:
208+
error_msg = str(exc)
209+
last_error_msg = error_msg
210+
211+
if "finish_reason" in error_msg and ("SAFETY" in error_msg or "is 2" in error_msg):
212+
elapsed = time.time() - started_at
213+
tag = f"[SAFETY_BLOCKED:{error_msg[:100]}]"
214+
return tag, local_index, True, False, elapsed, 0, 0, 0
215+
216+
rate_limited = rate_limited or is_rate_limit_error(error_msg)
217+
eval_logger.info(f"Attempt {attempt + 1}/{self.max_retries} failed: {error_msg}")
218+
if attempt < self.max_retries - 1:
219+
time.sleep(self.retry_backoff_s * (attempt + 1))
220+
else:
221+
eval_logger.error(f"All {self.max_retries} attempts failed. Last error: {error_msg}")
222+
223+
elapsed = time.time() - started_at
224+
error_preview = last_error_msg.replace("\n", " ")[:200]
225+
return f"[LMMS_EVAL_REQUEST_FAILED after {self.max_retries} retries] {error_preview}", local_index, False, rate_limited, elapsed, 0, 0, 0
226+
227+
def maybe_update_concurrency(force: bool = False):
228+
nonlocal current_concurrency, failed_requests, rate_limited_requests, latencies, completed_since_adapt
229+
230+
if not self.adaptive_concurrency:
231+
return
232+
sample_threshold = max(4, current_concurrency)
233+
if not force and completed_since_adapt < sample_threshold:
234+
return
235+
if completed_since_adapt <= 0:
236+
return
237+
238+
decision = decide_next_concurrency(
239+
current_concurrency=current_concurrency,
240+
total_requests=completed_since_adapt,
241+
failed_requests=failed_requests,
242+
rate_limited_requests=rate_limited_requests,
243+
latencies=latencies,
244+
config=self.adaptive_config,
245+
)
246+
if decision.next_concurrency != decision.current_concurrency:
247+
eval_logger.info(f"Adaptive concurrency: {decision.current_concurrency} -> {decision.next_concurrency} " f"(fail={decision.failure_rate:.3f}, rl={decision.rate_limit_rate:.3f}, p95={decision.p95_latency_s:.3f}s)")
248+
current_concurrency = decision.next_concurrency
249+
failed_requests = 0
250+
rate_limited_requests = 0
251+
latencies = []
252+
completed_since_adapt = 0
253+
254+
def build_payload_for_index(global_index: int):
255+
req = reordered_requests[global_index]
256+
_, doc_to_messages, gen_kwargs, doc_id, task, split = req.args
257+
258+
chat_messages_raw = doc_to_messages(self.task_dict[task][split][doc_id])
259+
chat_messages = ChatMessages(**{"messages": chat_messages_raw})
260+
261+
request_gen_kwargs = dict(gen_kwargs)
262+
263+
system_parts = []
264+
contents = _chat_messages_to_gemini_contents(chat_messages, self._upload_video, system_parts)
265+
266+
config = self._build_generation_config(request_gen_kwargs)
267+
if system_parts:
268+
# Inject system instruction
269+
system_text = "\n".join(p if isinstance(p, str) else "" for p in system_parts)
270+
config = types.GenerateContentConfig(
271+
max_output_tokens=config.max_output_tokens,
272+
temperature=config.temperature,
273+
safety_settings=config.safety_settings,
274+
thinking_config=config.thinking_config if hasattr(config, "thinking_config") else None,
275+
system_instruction=system_text,
276+
)
277+
278+
return contents, config, task
279+
280+
# ---- Dispatch loop ----
281+
282+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
283+
while cursor < len(dispatch_order) or in_flight:
284+
while cursor < len(dispatch_order) and len(in_flight) < max(1, current_concurrency):
285+
request_index = dispatch_order[cursor]
286+
287+
if is_budget_exceeded():
288+
responses[request_index] = GenerationResult(text="[LMMS_EVAL_BUDGET_EXCEEDED]", token_counts=TokenCounts())
289+
pbar.update(1)
290+
cursor += 1
291+
continue
292+
293+
contents, config, task_name = build_payload_for_index(request_index)
294+
future = executor.submit(process_single_request, request_index, contents, config, task_name)
295+
in_flight[future] = request_index
296+
cursor += 1
297+
298+
if not in_flight:
299+
break
300+
301+
done, _ = wait(in_flight, return_when=FIRST_COMPLETED)
302+
for future in done:
303+
(
304+
response_text,
305+
local_index,
306+
success,
307+
rate_limited,
308+
elapsed,
309+
completion_tokens,
310+
input_tokens,
311+
reasoning_tokens,
312+
) = future.result()
313+
in_flight.pop(future, None)
314+
responses[local_index] = GenerationResult(
315+
text=response_text,
316+
token_counts=TokenCounts(
317+
input_tokens=input_tokens,
318+
output_tokens=completion_tokens,
319+
reasoning_tokens=reasoning_tokens,
320+
),
321+
)
322+
total_latency += elapsed
323+
total_tokens += completion_tokens
324+
latencies.append(elapsed)
325+
if not success:
326+
failed_requests += 1
327+
if rate_limited:
328+
rate_limited_requests += 1
329+
completed_since_adapt += 1
330+
totals = get_running_totals()
331+
pbar.set_postfix({"tokens": f"{totals['total_tokens']:,}"}, refresh=False)
332+
pbar.update(1)
333+
maybe_update_concurrency(force=False)
334+
335+
maybe_update_concurrency(force=True)
336+
pbar.close()
337+
338+
self._cleanup_files()
339+
340+
return [r if r is not None else GenerationResult(text="", token_counts=TokenCounts()) for r in responses]

0 commit comments

Comments
 (0)