|
| 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