Mirrowel commited on
Commit
f03c448
·
2 Parent(s): 8c2f222 abdc406

Merge branch 'Antigravity'

Browse files
src/rotator_library/client.py CHANGED
@@ -620,8 +620,9 @@ class RotatingClient:
620
  litellm.ServiceUnavailableError,
621
  litellm.InternalServerError,
622
  APIConnectionError,
 
623
  ) as e:
624
- # This is a critical, typed error from litellm that signals a key failure.
625
  # We do not try to parse it here. We wrap it and raise it immediately
626
  # for the outer retry loop to handle.
627
  lib_logger.warning(
@@ -1065,7 +1066,10 @@ class RotatingClient:
1065
  )
1066
 
1067
  # Only trigger provider-wide cooldown for rate limits, not quota issues
1068
- if classified_error.status_code == 429 and classified_error.error_type != "quota_exceeded":
 
 
 
1069
  cooldown_duration = classified_error.retry_after or 60
1070
  await self.cooldown_manager.start_cooldown(
1071
  provider, cooldown_duration
@@ -1225,9 +1229,9 @@ class RotatingClient:
1225
 
1226
  # Handle rate limits with cooldown (exclude quota_exceeded from provider-wide cooldown)
1227
  if (
1228
- (classified_error.status_code == 429 and classified_error.error_type != "quota_exceeded")
1229
- or classified_error.error_type == "rate_limit"
1230
- ):
1231
  cooldown_duration = classified_error.retry_after or 60
1232
  await self.cooldown_manager.start_cooldown(
1233
  provider, cooldown_duration
@@ -1494,7 +1498,7 @@ class RotatingClient:
1494
  lib_logger.info(
1495
  f"Attempting stream with credential {mask_credential(current_cred)} (Attempt {attempt + 1}/{self.max_retries})"
1496
  )
1497
-
1498
  if pre_request_callback:
1499
  try:
1500
  await pre_request_callback(
@@ -1973,9 +1977,9 @@ class RotatingClient:
1973
 
1974
  # Handle rate limits with cooldown (exclude quota_exceeded)
1975
  if (
1976
- (classified_error.status_code == 429 and classified_error.error_type != "quota_exceeded")
1977
- or classified_error.error_type == "rate_limit"
1978
- ):
1979
  cooldown_duration = classified_error.retry_after or 60
1980
  await self.cooldown_manager.start_cooldown(
1981
  provider, cooldown_duration
 
620
  litellm.ServiceUnavailableError,
621
  litellm.InternalServerError,
622
  APIConnectionError,
623
+ httpx.HTTPStatusError,
624
  ) as e:
625
+ # This is a critical, typed error from litellm or httpx that signals a key failure.
626
  # We do not try to parse it here. We wrap it and raise it immediately
627
  # for the outer retry loop to handle.
628
  lib_logger.warning(
 
1066
  )
1067
 
1068
  # Only trigger provider-wide cooldown for rate limits, not quota issues
1069
+ if (
1070
+ classified_error.status_code == 429
1071
+ and classified_error.error_type != "quota_exceeded"
1072
+ ):
1073
  cooldown_duration = classified_error.retry_after or 60
1074
  await self.cooldown_manager.start_cooldown(
1075
  provider, cooldown_duration
 
1229
 
1230
  # Handle rate limits with cooldown (exclude quota_exceeded from provider-wide cooldown)
1231
  if (
1232
+ classified_error.status_code == 429
1233
+ and classified_error.error_type != "quota_exceeded"
1234
+ ) or classified_error.error_type == "rate_limit":
1235
  cooldown_duration = classified_error.retry_after or 60
1236
  await self.cooldown_manager.start_cooldown(
1237
  provider, cooldown_duration
 
1498
  lib_logger.info(
1499
  f"Attempting stream with credential {mask_credential(current_cred)} (Attempt {attempt + 1}/{self.max_retries})"
1500
  )
1501
+
1502
  if pre_request_callback:
1503
  try:
1504
  await pre_request_callback(
 
1977
 
1978
  # Handle rate limits with cooldown (exclude quota_exceeded)
1979
  if (
1980
+ classified_error.status_code == 429
1981
+ and classified_error.error_type != "quota_exceeded"
1982
+ ) or classified_error.error_type == "rate_limit":
1983
  cooldown_duration = classified_error.retry_after or 60
1984
  await self.cooldown_manager.start_cooldown(
1985
  provider, cooldown_duration
src/rotator_library/error_handler.py CHANGED
@@ -18,12 +18,60 @@ from litellm.exceptions import (
18
  )
19
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  def extract_retry_after_from_body(error_body: Optional[str]) -> Optional[int]:
22
  """
23
  Extract the retry-after time from an API error response body.
24
 
25
  Handles various error formats including:
26
  - Gemini CLI: "Your quota will reset after 39s."
 
27
  - Generic: "quota will reset after 120s", "retry after 60s"
28
 
29
  Args:
@@ -35,21 +83,21 @@ def extract_retry_after_from_body(error_body: Optional[str]) -> Optional[int]:
35
  if not error_body:
36
  return None
37
 
38
- # Pattern to match various "reset after Xs" or "retry after Xs" formats
39
  patterns = [
40
- r"quota will reset after\s*(\d+)s",
41
- r"reset after\s*(\d+)s",
42
- r"retry after\s*(\d+)s",
43
  r"try again in\s*(\d+)\s*seconds?",
44
  ]
45
 
46
  for pattern in patterns:
47
  match = re.search(pattern, error_body, re.IGNORECASE)
48
  if match:
49
- try:
50
- return int(match.group(1))
51
- except (ValueError, IndexError):
52
- continue
53
 
54
  return None
55
 
@@ -306,14 +354,91 @@ class ClassifiedError:
306
  return f"ClassifiedError(type={self.error_type}, status={self.status_code}, retry_after={self.retry_after}, original_exc={self.original_exception})"
307
 
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  def get_retry_after(error: Exception) -> Optional[int]:
310
  """
311
  Extracts the 'retry-after' duration in seconds from an exception message.
312
  Handles both integer and string representations of the duration, as well as JSON bodies.
313
  Also checks HTTP response headers for httpx.HTTPStatusError instances.
 
 
 
 
 
314
  """
315
- # 0. For httpx errors, check response headers first (most reliable)
316
  if isinstance(error, httpx.HTTPStatusError):
 
 
 
 
 
 
 
 
 
 
 
 
317
  headers = error.response.headers
318
  # Check standard Retry-After header (case-insensitive)
319
  retry_header = headers.get("retry-after") or headers.get("Retry-After")
@@ -339,81 +464,51 @@ def get_retry_after(error: Exception) -> Optional[int]:
339
  except (ValueError, TypeError):
340
  pass
341
 
342
- error_str = str(error).lower()
 
 
 
 
 
343
 
344
- # 1. Try to parse JSON from the error string to find 'retryDelay'
345
- try:
346
- # It's common for the actual JSON to be embedded in the string representation
347
- json_match = re.search(r"(\{.*\})", error_str, re.DOTALL)
348
- if json_match:
349
- error_json = json.loads(json_match.group(1))
350
- retry_info = error_json.get("error", {}).get("details", [{}])[0]
351
- if retry_info.get("@type") == "type.googleapis.com/google.rpc.RetryInfo":
352
- delay_str = retry_info.get("retryDelay", {}).get("seconds")
353
- if delay_str:
354
- return int(delay_str)
355
- # Fallback for the other format
356
- delay_str = retry_info.get("retryDelay")
357
- if isinstance(delay_str, str) and delay_str.endswith("s"):
358
- return int(delay_str[:-1])
359
-
360
- except (json.JSONDecodeError, IndexError, KeyError, TypeError):
361
- pass # If JSON parsing fails, proceed to regex and attribute checks
362
-
363
- # 2. Common regex patterns for 'retry-after' (with duration format support)
364
  patterns = [
365
  r"retry[-_\s]after:?\s*(\d+)", # Matches: retry-after, retry_after, retry after
366
  r"retry in\s*(\d+)\s*seconds?",
367
  r"wait for\s*(\d+)\s*seconds?",
368
- r'"retryDelay":\s*"(\d+)s"',
369
  r"x-ratelimit-reset:?\s*(\d+)",
370
- r"quota will reset after\s*(\d+)s", # Gemini CLI rate limit format
371
- r"reset after\s*(\d+)s", # Generic reset after format
 
 
372
  ]
373
 
374
  for pattern in patterns:
375
- match = re.search(pattern, error_str)
376
  if match:
 
 
 
 
 
 
377
  try:
378
- return int(match.group(1))
379
  except (ValueError, IndexError):
380
  continue
381
 
382
- # 3. Handle duration formats like "60s", "2m", "1h"
383
- duration_match = re.search(r"(\d+)\s*([smh])", error_str)
384
- if duration_match:
385
- try:
386
- value = int(duration_match.group(1))
387
- unit = duration_match.group(2)
388
- if unit == "s":
389
- return value
390
- elif unit == "m":
391
- return value * 60
392
- elif unit == "h":
393
- return value * 3600
394
- except (ValueError, IndexError):
395
- pass
396
-
397
- # 4. Handle cases where the error object itself has the attribute
398
  if hasattr(error, "retry_after"):
399
  value = getattr(error, "retry_after")
400
  if isinstance(value, int):
401
  return value
402
  if isinstance(value, str):
403
- # Try to parse string formats
404
- if value.isdigit():
405
- return int(value)
406
- # Handle "60s", "2m" format in attribute
407
- duration_match = re.search(r"(\d+)\s*([smh])", value.lower())
408
- if duration_match:
409
- val = int(duration_match.group(1))
410
- unit = duration_match.group(2)
411
- if unit == "s":
412
- return val
413
- elif unit == "m":
414
- return val * 60
415
- elif unit == "h":
416
- return val * 3600
417
 
418
  return None
419
 
 
18
  )
19
 
20
 
21
+ def _parse_duration_string(duration_str: str) -> Optional[int]:
22
+ """
23
+ Parse duration strings in various formats to total seconds.
24
+
25
+ Handles:
26
+ - Compound durations: '156h14m36.752463453s', '2h30m', '45m30s'
27
+ - Simple durations: '562476.752463453s', '3600s', '60m', '2h'
28
+ - Plain seconds (no unit): '562476'
29
+
30
+ Args:
31
+ duration_str: Duration string to parse
32
+
33
+ Returns:
34
+ Total seconds as integer, or None if parsing fails
35
+ """
36
+ if not duration_str:
37
+ return None
38
+
39
+ total_seconds = 0
40
+ remaining = duration_str.strip().lower()
41
+
42
+ # Try parsing as plain number first (no units)
43
+ try:
44
+ return int(float(remaining))
45
+ except ValueError:
46
+ pass
47
+
48
+ # Parse hours component
49
+ hour_match = re.match(r"(\d+)h", remaining)
50
+ if hour_match:
51
+ total_seconds += int(hour_match.group(1)) * 3600
52
+ remaining = remaining[hour_match.end() :]
53
+
54
+ # Parse minutes component
55
+ min_match = re.match(r"(\d+)m", remaining)
56
+ if min_match:
57
+ total_seconds += int(min_match.group(1)) * 60
58
+ remaining = remaining[min_match.end() :]
59
+
60
+ # Parse seconds component (including decimals like 36.752463453s)
61
+ sec_match = re.match(r"([\d.]+)s", remaining)
62
+ if sec_match:
63
+ total_seconds += int(float(sec_match.group(1)))
64
+
65
+ return total_seconds if total_seconds > 0 else None
66
+
67
+
68
  def extract_retry_after_from_body(error_body: Optional[str]) -> Optional[int]:
69
  """
70
  Extract the retry-after time from an API error response body.
71
 
72
  Handles various error formats including:
73
  - Gemini CLI: "Your quota will reset after 39s."
74
+ - Antigravity: "quota will reset after 156h14m36s"
75
  - Generic: "quota will reset after 120s", "retry after 60s"
76
 
77
  Args:
 
83
  if not error_body:
84
  return None
85
 
86
+ # Pattern to match various "reset after" formats - capture the full duration string
87
  patterns = [
88
+ r"quota will reset after\s*([\dhmso.]+)", # Matches compound: 156h14m36s or 120s
89
+ r"reset after\s*([\dhmso.]+)",
90
+ r"retry after\s*([\dhmso.]+)",
91
  r"try again in\s*(\d+)\s*seconds?",
92
  ]
93
 
94
  for pattern in patterns:
95
  match = re.search(pattern, error_body, re.IGNORECASE)
96
  if match:
97
+ duration_str = match.group(1)
98
+ result = _parse_duration_string(duration_str)
99
+ if result is not None:
100
+ return result
101
 
102
  return None
103
 
 
354
  return f"ClassifiedError(type={self.error_type}, status={self.status_code}, retry_after={self.retry_after}, original_exc={self.original_exception})"
355
 
356
 
357
+ def _extract_retry_from_json_body(json_text: str) -> Optional[int]:
358
+ """
359
+ Extract retry delay from a JSON error response body.
360
+
361
+ Handles Antigravity/Google API error formats with details array containing:
362
+ - RetryInfo with retryDelay: "562476.752463453s"
363
+ - ErrorInfo metadata with quotaResetDelay: "156h14m36.752463453s"
364
+
365
+ Args:
366
+ json_text: JSON string (original case, not lowercased)
367
+
368
+ Returns:
369
+ Retry delay in seconds, or None if not found
370
+ """
371
+ try:
372
+ # Find JSON object in the text
373
+ json_match = re.search(r"(\{.*\})", json_text, re.DOTALL)
374
+ if not json_match:
375
+ return None
376
+
377
+ error_json = json.loads(json_match.group(1))
378
+ details = error_json.get("error", {}).get("details", [])
379
+
380
+ # Iterate through ALL details items (not just index 0)
381
+ for detail in details:
382
+ detail_type = detail.get("@type", "")
383
+
384
+ # Check RetryInfo for retryDelay (most authoritative)
385
+ # Note: Case-sensitive key names as returned by API
386
+ if "google.rpc.RetryInfo" in detail_type:
387
+ delay_str = detail.get("retryDelay")
388
+ if delay_str:
389
+ # Handle both {"seconds": "123"} format and "123.456s" string format
390
+ if isinstance(delay_str, dict):
391
+ seconds = delay_str.get("seconds")
392
+ if seconds:
393
+ return int(float(seconds))
394
+ elif isinstance(delay_str, str):
395
+ result = _parse_duration_string(delay_str)
396
+ if result is not None:
397
+ return result
398
+
399
+ # Check ErrorInfo metadata for quotaResetDelay (Antigravity-specific)
400
+ if "google.rpc.ErrorInfo" in detail_type:
401
+ metadata = detail.get("metadata", {})
402
+ # Try both camelCase and lowercase variants
403
+ quota_reset_delay = metadata.get("quotaResetDelay") or metadata.get(
404
+ "quotaresetdelay"
405
+ )
406
+ if quota_reset_delay:
407
+ result = _parse_duration_string(quota_reset_delay)
408
+ if result is not None:
409
+ return result
410
+
411
+ except (json.JSONDecodeError, IndexError, KeyError, TypeError):
412
+ pass
413
+
414
+ return None
415
+
416
+
417
  def get_retry_after(error: Exception) -> Optional[int]:
418
  """
419
  Extracts the 'retry-after' duration in seconds from an exception message.
420
  Handles both integer and string representations of the duration, as well as JSON bodies.
421
  Also checks HTTP response headers for httpx.HTTPStatusError instances.
422
+
423
+ Supports Antigravity/Google API error formats:
424
+ - RetryInfo with retryDelay: "562476.752463453s"
425
+ - ErrorInfo metadata with quotaResetDelay: "156h14m36.752463453s"
426
+ - Human-readable message: "quota will reset after 156h14m36s"
427
  """
428
+ # 0. For httpx errors, check response body and headers
429
  if isinstance(error, httpx.HTTPStatusError):
430
+ # First, try to parse the response body JSON (contains retryDelay/quotaResetDelay)
431
+ # This is where Antigravity puts the retry information
432
+ try:
433
+ response_text = error.response.text
434
+ if response_text:
435
+ result = _extract_retry_from_json_body(response_text)
436
+ if result is not None:
437
+ return result
438
+ except Exception:
439
+ pass # Response body may not be available
440
+
441
+ # Fallback to HTTP headers
442
  headers = error.response.headers
443
  # Check standard Retry-After header (case-insensitive)
444
  retry_header = headers.get("retry-after") or headers.get("Retry-After")
 
464
  except (ValueError, TypeError):
465
  pass
466
 
467
+ # 1. Try to parse JSON from the error string representation
468
+ # Some exceptions embed JSON in their string representation
469
+ error_str = str(error)
470
+ result = _extract_retry_from_json_body(error_str)
471
+ if result is not None:
472
+ return result
473
 
474
+ # 2. Common regex patterns for 'retry-after' (with compound duration support)
475
+ # Use lowercase for pattern matching
476
+ error_str_lower = error_str.lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  patterns = [
478
  r"retry[-_\s]after:?\s*(\d+)", # Matches: retry-after, retry_after, retry after
479
  r"retry in\s*(\d+)\s*seconds?",
480
  r"wait for\s*(\d+)\s*seconds?",
481
+ r'"retrydelay":\s*"([\d.]+)s?"', # retryDelay in JSON (lowercased)
482
  r"x-ratelimit-reset:?\s*(\d+)",
483
+ # Compound duration patterns (Antigravity format)
484
+ r"quota will reset after\s*([\dhms.]+)", # e.g., "156h14m36s" or "120s"
485
+ r"reset after\s*([\dhms.]+)",
486
+ r'"quotaresetdelay":\s*"([\dhms.]+)"', # quotaResetDelay in JSON (lowercased)
487
  ]
488
 
489
  for pattern in patterns:
490
+ match = re.search(pattern, error_str_lower)
491
  if match:
492
+ duration_str = match.group(1)
493
+ # Try parsing as compound duration first
494
+ result = _parse_duration_string(duration_str)
495
+ if result is not None:
496
+ return result
497
+ # Fallback to simple integer
498
  try:
499
+ return int(duration_str)
500
  except (ValueError, IndexError):
501
  continue
502
 
503
+ # 3. Handle cases where the error object itself has the attribute
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
  if hasattr(error, "retry_after"):
505
  value = getattr(error, "retry_after")
506
  if isinstance(value, int):
507
  return value
508
  if isinstance(value, str):
509
+ result = _parse_duration_string(value)
510
+ if result is not None:
511
+ return result
 
 
 
 
 
 
 
 
 
 
 
512
 
513
  return None
514
 
src/rotator_library/providers/antigravity_provider.py CHANGED
@@ -1919,15 +1919,18 @@ class AntigravityProvider(AntigravityAuthBase, ProviderInterface):
1919
  system_instruction = None
1920
  gemini_contents = []
1921
 
1922
- # Extract system prompt
1923
- if messages and messages[0].get("role") == "system":
 
1924
  system_content = messages.pop(0).get("content", "")
1925
  if system_content:
1926
- system_parts = self._parse_content_parts(
1927
  system_content, _strip_cache_control=True
1928
  )
1929
- if system_parts:
1930
- system_instruction = {"role": "user", "parts": system_parts}
 
 
1931
 
1932
  # Build tool_call_id → name mapping
1933
  tool_id_to_name = {}
 
1919
  system_instruction = None
1920
  gemini_contents = []
1921
 
1922
+ # Extract system prompts (handle multiple consecutive system messages)
1923
+ system_parts = []
1924
+ while messages and messages[0].get("role") == "system":
1925
  system_content = messages.pop(0).get("content", "")
1926
  if system_content:
1927
+ new_parts = self._parse_content_parts(
1928
  system_content, _strip_cache_control=True
1929
  )
1930
+ system_parts.extend(new_parts)
1931
+
1932
+ if system_parts:
1933
+ system_instruction = {"role": "user", "parts": system_parts}
1934
 
1935
  # Build tool_call_id → name mapping
1936
  tool_id_to_name = {}