Mirrowel commited on
Commit
e4ee46f
Β·
1 Parent(s): 1ec1463

fix(antigravity): πŸ› TLDR: Huge ass pass fixing things + Gemini 3 Flash addition

Browse files
src/rotator_library/providers/antigravity_provider.py CHANGED
@@ -4,7 +4,7 @@ Antigravity Provider - Refactored Implementation
4
 
5
  A clean, well-structured provider for Google's Antigravity API, supporting:
6
  - Gemini 2.5 (Pro/Flash) with thinkingBudget
7
- - Gemini 3 (Pro/Image) with thinkingLevel
8
  - Claude (Sonnet 4.5) via Antigravity proxy
9
  - Claude (Opus 4.5) via Antigravity proxy
10
 
@@ -38,7 +38,6 @@ from typing import (
38
  Union,
39
  TYPE_CHECKING,
40
  )
41
- from urllib.parse import urlparse
42
 
43
  import httpx
44
  import litellm
@@ -81,6 +80,15 @@ BASE_URLS = [
81
  "https://cloudcode-pa.googleapis.com/v1internal", # Production fallback
82
  ]
83
 
 
 
 
 
 
 
 
 
 
84
  # Available models via Antigravity
85
  AVAILABLE_MODELS = [
86
  # Gemini models
@@ -88,6 +96,7 @@ AVAILABLE_MODELS = [
88
  "gemini-2.5-flash", # Uses -thinking variant when reasoning_effort provided
89
  "gemini-2.5-flash-lite", # Thinking budget configurable, no name change
90
  "gemini-3-pro-preview", # Internally mapped to -low/-high variant based on thinkingLevel
 
91
  # "gemini-3-pro-image", # Image generation model
92
  # "gemini-2.5-computer-use-preview-10-2025",
93
  # Claude models
@@ -104,7 +113,7 @@ DEFAULT_MAX_OUTPUT_TOKENS = 64000
104
  # When Antigravity returns an empty response (no content, no tool calls),
105
  # automatically retry up to this many attempts before giving up (minimum 1)
106
  EMPTY_RESPONSE_MAX_ATTEMPTS = max(1, _env_int("ANTIGRAVITY_EMPTY_RESPONSE_ATTEMPTS", 6))
107
- EMPTY_RESPONSE_RETRY_DELAY = _env_int("ANTIGRAVITY_EMPTY_RESPONSE_RETRY_DELAY", 2)
108
 
109
  # Model alias mappings (internal ↔ public)
110
  MODEL_ALIAS_MAP = {
@@ -132,6 +141,13 @@ FINISH_REASON_MAP = {
132
  "OTHER": "stop",
133
  }
134
 
 
 
 
 
 
 
 
135
  # Default safety settings - disable content filtering for all categories
136
  # Per CLIProxyAPI: these are attached to prevent safety blocks during API calls
137
  DEFAULT_SAFETY_SETTINGS = [
@@ -260,14 +276,23 @@ def _generate_project_id() -> str:
260
  def _normalize_type_arrays(schema: Any) -> Any:
261
  """
262
  Normalize type arrays in JSON Schema for Proto-based Antigravity API.
263
- Converts `"type": ["string", "null"]` β†’ `"type": "string"`.
264
  """
265
  if isinstance(schema, dict):
266
  normalized = {}
267
  for key, value in schema.items():
268
  if key == "type" and isinstance(value, list):
269
- non_null = [t for t in value if t != "null"]
270
- normalized[key] = non_null[0] if non_null else value[0]
 
 
 
 
 
 
 
 
 
271
  else:
272
  normalized[key] = _normalize_type_arrays(value)
273
  return normalized
@@ -276,21 +301,46 @@ def _normalize_type_arrays(schema: Any) -> Any:
276
  return schema
277
 
278
 
279
- def _recursively_parse_json_strings(obj: Any) -> Any:
 
 
 
 
280
  """
281
  Recursively parse JSON strings in nested data structures.
282
 
283
  Antigravity sometimes returns tool arguments with JSON-stringified values:
284
  {"files": "[{...}]"} instead of {"files": [{...}]}.
285
 
 
 
 
 
 
 
 
286
  Additionally handles:
287
- - Malformed double-encoded JSON (extra trailing '}' or ']')
288
- - Escaped string content (\n, \t, \", etc.)
289
  """
290
  if isinstance(obj, dict):
291
- return {k: _recursively_parse_json_strings(v) for k, v in obj.items()}
 
 
 
 
 
 
 
 
 
292
  elif isinstance(obj, list):
293
- return [_recursively_parse_json_strings(item) for item in obj]
 
 
 
 
 
294
  elif isinstance(obj, str):
295
  stripped = obj.strip()
296
 
@@ -321,6 +371,20 @@ def _recursively_parse_json_strings(obj: Any) -> Any:
321
  # If unescaping fails, continue with original processing
322
  pass
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  # Check if it looks like JSON (starts with { or [)
325
  if stripped and stripped[0] in ("{", "["):
326
  # Try standard parsing first
@@ -329,7 +393,9 @@ def _recursively_parse_json_strings(obj: Any) -> Any:
329
  ):
330
  try:
331
  parsed = json.loads(obj)
332
- return _recursively_parse_json_strings(parsed)
 
 
333
  except (json.JSONDecodeError, ValueError):
334
  pass
335
 
@@ -346,7 +412,9 @@ def _recursively_parse_json_strings(obj: Any) -> Any:
346
  f"[Antigravity] Auto-corrected malformed JSON string: "
347
  f"truncated {len(stripped) - len(cleaned)} extra chars"
348
  )
349
- return _recursively_parse_json_strings(parsed)
 
 
350
  except (json.JSONDecodeError, ValueError):
351
  pass
352
 
@@ -362,7 +430,9 @@ def _recursively_parse_json_strings(obj: Any) -> Any:
362
  f"[Antigravity] Auto-corrected malformed JSON string: "
363
  f"truncated {len(stripped) - len(cleaned)} extra chars"
364
  )
365
- return _recursively_parse_json_strings(parsed)
 
 
366
  except (json.JSONDecodeError, ValueError):
367
  pass
368
  return obj
@@ -395,7 +465,7 @@ def _inline_schema_refs(schema: Dict[str, Any]) -> Dict[str, Any]:
395
  return resolve(schema)
396
 
397
 
398
- def _clean_claude_schema(schema: Any) -> Any:
399
  """
400
  Recursively clean JSON Schema for Antigravity/Google's Proto-based API.
401
 
@@ -403,9 +473,10 @@ def _clean_claude_schema(schema: Any) -> Any:
403
  - Removes unsupported validation keywords at schema-definition level
404
  - Preserves property NAMES even if they match validation keyword names
405
  (e.g., a tool parameter named "pattern" is preserved)
406
- - Preserves additionalProperties when permissive (true or {}) for pass-through objects
407
- - Converts 'const' to 'enum' with single value (supported equivalent)
408
- - Converts 'anyOf'/'oneOf' to the first option (Claude doesn't support these)
 
409
  """
410
  if not isinstance(schema, dict):
411
  return schema
@@ -413,12 +484,10 @@ def _clean_claude_schema(schema: Any) -> Any:
413
  # Meta/structural keywords - always remove regardless of context
414
  # These are JSON Schema infrastructure, never valid property names
415
  meta_keywords = {
416
- "$schema",
417
  "$id",
418
  "$ref",
419
  "$defs",
420
  "definitions",
421
- # Note: additionalProperties is handled specially below - preserved when permissive
422
  }
423
 
424
  # Validation keywords - only remove at schema-definition level,
@@ -429,22 +498,25 @@ def _clean_claude_schema(schema: Any) -> Any:
429
  # - "default" (config tools)
430
  # - "title" (document tools)
431
  # - "minimum"/"maximum" (range tools)
432
- validation_keywords = {
 
 
 
 
433
  "minItems",
434
  "maxItems",
 
435
  "pattern",
436
  "minLength",
437
  "maxLength",
438
  "minimum",
439
  "maximum",
440
- "default",
441
  "exclusiveMinimum",
442
  "exclusiveMaximum",
443
  "multipleOf",
444
  "format",
445
  "minProperties",
446
  "maxProperties",
447
- "uniqueItems",
448
  "contentEncoding",
449
  "contentMediaType",
450
  "contentSchema",
@@ -453,45 +525,66 @@ def _clean_claude_schema(schema: Any) -> Any:
453
  "writeOnly",
454
  "examples",
455
  "title",
 
456
  }
457
 
458
  # Handle 'anyOf' by taking the first option (Claude doesn't support anyOf)
459
- if "anyOf" in schema and isinstance(schema["anyOf"], list) and schema["anyOf"]:
460
- first_option = _clean_claude_schema(schema["anyOf"][0])
461
- if isinstance(first_option, dict):
462
- return first_option
463
-
464
- # Handle 'oneOf' similarly
465
- if "oneOf" in schema and isinstance(schema["oneOf"], list) and schema["oneOf"]:
466
- first_option = _clean_claude_schema(schema["oneOf"][0])
467
- if isinstance(first_option, dict):
468
- return first_option
 
 
469
 
470
  cleaned = {}
471
- # Handle 'const' by converting to 'enum' with single value
472
- if "const" in schema:
 
473
  const_value = schema["const"]
474
  cleaned["enum"] = [const_value]
475
 
476
  for key, value in schema.items():
477
- # Always skip meta keywords and "const" (already handled above)
478
- if key in meta_keywords or key == "const":
479
  continue
480
 
481
- # Special handling for additionalProperties:
482
- # - Normalize permissive values ({} or true) to true
483
- # - Pass through false as-is
484
- # - Skip complex schema values (not supported by Antigravity's proto-based API)
485
- if key == "additionalProperties":
486
- if value is True or value == {} or (isinstance(value, dict) and not value):
487
- cleaned["additionalProperties"] = True # Normalize {} to true
488
- elif value is False:
489
- cleaned["additionalProperties"] = False # Pass through explicit false
490
- # Skip complex schema values (e.g., {"type": "string"})
491
  continue
492
 
493
- # Skip validation keywords at schema level (these are constraints, not data)
494
- if key in validation_keywords:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  continue
496
 
497
  # Special handling for "properties" - preserve property NAMES
@@ -502,17 +595,19 @@ def _clean_claude_schema(schema: Any) -> Any:
502
  for prop_name, prop_schema in value.items():
503
  # Log warning if property name matches a validation keyword
504
  # This helps debug potential issues where the old code would have dropped it
505
- if prop_name in validation_keywords:
506
  lib_logger.debug(
507
  f"[Schema] Preserving property '{prop_name}' (matches validation keyword name)"
508
  )
509
- cleaned_props[prop_name] = _clean_claude_schema(prop_schema)
510
  cleaned[key] = cleaned_props
511
  elif isinstance(value, dict):
512
- cleaned[key] = _clean_claude_schema(value)
513
  elif isinstance(value, list):
514
  cleaned[key] = [
515
- _clean_claude_schema(item) if isinstance(item, dict) else item
 
 
516
  for item in value
517
  ]
518
  else:
@@ -600,7 +695,7 @@ class AntigravityProvider(
600
 
601
  Supports:
602
  - Gemini 2.5 (Pro/Flash) with thinkingBudget
603
- - Gemini 3 (Pro/Image) with thinkingLevel
604
  - Claude Sonnet 4.5 via Antigravity proxy
605
  - Claude Opus 4.5 via Antigravity proxy
606
 
@@ -677,6 +772,10 @@ class AntigravityProvider(
677
  "gemini-3-pro-low",
678
  "gemini-3-pro-preview",
679
  ],
 
 
 
 
680
  # Gemini 2.5 Flash variants share quota
681
  "gemini-2.5-flash": [
682
  "gemini-2.5-flash",
@@ -942,6 +1041,12 @@ class AntigravityProvider(
942
  self._gemini3_enforce_strict_schema = _env_bool(
943
  "ANTIGRAVITY_GEMINI3_STRICT_SCHEMA", True
944
  )
 
 
 
 
 
 
945
  self._gemini3_system_instruction = os.getenv(
946
  "ANTIGRAVITY_GEMINI3_SYSTEM_INSTRUCTION", DEFAULT_GEMINI3_SYSTEM_INSTRUCTION
947
  )
@@ -981,6 +1086,10 @@ class AntigravityProvider(
981
  f"parallel_tool_gemini3={self._enable_parallel_tool_instruction_gemini3}"
982
  )
983
 
 
 
 
 
984
  def _load_tier_from_file(self, credential_path: str) -> Optional[str]:
985
  """
986
  Load tier from credential file's _proxy_metadata and cache it.
@@ -1946,20 +2055,35 @@ class AntigravityProvider(
1946
  Map reasoning_effort to thinking configuration.
1947
 
1948
  - Gemini 2.5 & Claude: thinkingBudget (integer tokens)
1949
- - Gemini 3: thinkingLevel (string: "low"/"high")
 
1950
  """
1951
  internal = self._alias_to_internal(model)
1952
  is_gemini_25 = "gemini-2.5" in model
1953
  is_gemini_3 = internal.startswith("gemini-3-")
 
1954
  is_claude = self._is_claude(model)
1955
 
1956
  if not (is_gemini_25 or is_gemini_3 or is_claude):
1957
  return None
1958
 
1959
- # Gemini 3: String-based thinkingLevel
 
 
 
 
 
 
 
 
 
 
 
 
1960
  if is_gemini_3:
1961
  if reasoning_effort == "low":
1962
  return {"thinkingLevel": "low", "include_thoughts": True}
 
1963
  return {"thinkingLevel": "high", "include_thoughts": True}
1964
 
1965
  # Gemini 2.5 & Claude: Integer thinkingBudget
@@ -2179,8 +2303,9 @@ class AntigravityProvider(
2179
  # f"id={tool_id}, name={func_name}"
2180
  # )
2181
 
2182
- # Add prefix for Gemini 3
2183
  if self._is_gemini_3(model) and self._enable_gemini3_tool_fix:
 
2184
  func_name = f"{self._gemini3_tool_prefix}{func_name}"
2185
 
2186
  func_part = {
@@ -2271,8 +2396,9 @@ class AntigravityProvider(
2271
  # else:
2272
  # lib_logger.debug(f"[ID Mapping] Tool response matched: id={tool_id}, name={func_name}")
2273
 
2274
- # Add prefix for Gemini 3
2275
  if self._is_gemini_3(model) and self._enable_gemini3_tool_fix:
 
2276
  func_name = f"{self._gemini3_tool_prefix}{func_name}"
2277
 
2278
  try:
@@ -2522,7 +2648,12 @@ class AntigravityProvider(
2522
  def _apply_gemini3_namespace(
2523
  self, tools: List[Dict[str, Any]]
2524
  ) -> List[Dict[str, Any]]:
2525
- """Add namespace prefix to tool names for Gemini 3."""
 
 
 
 
 
2526
  if not tools:
2527
  return tools
2528
 
@@ -2531,6 +2662,9 @@ class AntigravityProvider(
2531
  for func_decl in tool.get("functionDeclarations", []):
2532
  name = func_decl.get("name", "")
2533
  if name:
 
 
 
2534
  func_decl["name"] = f"{self._gemini3_tool_prefix}{name}"
2535
 
2536
  return modified
@@ -2541,12 +2675,13 @@ class AntigravityProvider(
2541
  """
2542
  Enforce strict JSON schema for Gemini 3 to prevent hallucinated parameters.
2543
 
2544
- Adds 'additionalProperties: false' to object schemas that don't already have it set,
2545
  which tells the model it CANNOT add properties not in the schema.
2546
 
2547
- Exceptions (leaves schema unchanged):
2548
- - Objects that already have 'additionalProperties' set (true or false)
2549
- - Objects with empty 'properties: {}' (pass-through objects like batch tool's parameters)
 
2550
  """
2551
  if not tools:
2552
  return tools
@@ -2556,7 +2691,17 @@ class AntigravityProvider(
2556
  return schema
2557
 
2558
  result = {}
 
 
2559
  for key, value in schema.items():
 
 
 
 
 
 
 
 
2560
  if isinstance(value, dict):
2561
  result[key] = enforce_strict(value)
2562
  elif isinstance(value, list):
@@ -2567,16 +2712,12 @@ class AntigravityProvider(
2567
  else:
2568
  result[key] = value
2569
 
2570
- # Add additionalProperties: false to object schemas, with exceptions:
2571
- # 1. Skip if already set (respect explicit true or false from client)
2572
- # 2. Skip if properties is empty {} (dynamic/pass-through object)
2573
  if result.get("type") == "object" and "properties" in result:
2574
- if "additionalProperties" in result:
2575
- pass # Already set - respect client's choice
2576
- elif not result.get("properties"):
2577
- pass # Empty properties - leave permissive for dynamic objects
2578
  else:
2579
- # Has defined properties and no explicit setting - enforce strict
2580
  result["additionalProperties"] = False
2581
 
2582
  return result
@@ -2686,9 +2827,15 @@ class AntigravityProvider(
2686
  return type_hint
2687
 
2688
  def _strip_gemini3_prefix(self, name: str) -> str:
2689
- """Strip the Gemini 3 namespace prefix from a tool name."""
 
 
 
 
2690
  if name and name.startswith(self._gemini3_tool_prefix):
2691
- return name[len(self._gemini3_tool_prefix) :]
 
 
2692
  return name
2693
 
2694
  def _translate_tool_choice(
@@ -2715,8 +2862,11 @@ class AntigravityProvider(
2715
  elif isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
2716
  function_name = tool_choice.get("function", {}).get("name")
2717
  if function_name:
2718
- # Add Gemini 3 prefix if needed
2719
  if is_gemini_3 and self._enable_gemini3_tool_fix:
 
 
 
2720
  function_name = f"{self._gemini3_tool_prefix}{function_name}"
2721
 
2722
  mode = "ANY" # Force a call, but only to this function
@@ -2734,13 +2884,18 @@ class AntigravityProvider(
2734
  # =========================================================================
2735
 
2736
  def _build_tools_payload(
2737
- self, tools: Optional[List[Dict[str, Any]]], _model: str
2738
  ) -> Optional[List[Dict[str, Any]]]:
2739
- """Build Gemini-format tools from OpenAI tools."""
 
 
 
 
2740
  if not tools:
2741
  return None
2742
 
2743
- gemini_tools = []
 
2744
  for tool in tools:
2745
  if tool.get("type") != "function":
2746
  continue
@@ -2758,7 +2913,11 @@ class AntigravityProvider(
2758
  schema.pop("strict", None)
2759
  # Inline $ref definitions, then strip unsupported keywords
2760
  schema = _inline_schema_refs(schema)
2761
- schema = _clean_claude_schema(schema)
 
 
 
 
2762
  schema = _normalize_type_arrays(schema)
2763
 
2764
  # Workaround: Antigravity/Gemini fails to emit functionCall
@@ -2791,9 +2950,14 @@ class AntigravityProvider(
2791
  "required": ["_confirm"],
2792
  }
2793
 
2794
- gemini_tools.append({"functionDeclarations": [func_decl]})
 
 
 
2795
 
2796
- return gemini_tools or None
 
 
2797
 
2798
  def _transform_to_antigravity_format(
2799
  self,
@@ -3051,7 +3215,9 @@ class AntigravityProvider(
3051
  return response
3052
 
3053
  def _gemini_to_openai_non_streaming(
3054
- self, response: Dict[str, Any], model: str
 
 
3055
  ) -> Dict[str, Any]:
3056
  """Convert Gemini response to OpenAI non-streaming format."""
3057
  candidates = response.get("candidates", [])
@@ -3161,7 +3327,13 @@ class AntigravityProvider(
3161
  tool_name = self._strip_gemini3_prefix(tool_name)
3162
 
3163
  raw_args = func_call.get("args", {})
3164
- parsed_args = _recursively_parse_json_strings(raw_args)
 
 
 
 
 
 
3165
 
3166
  # Strip the injected _confirm parameter ONLY if it's the sole parameter
3167
  # This ensures we only strip our injection, not legitimate user params
@@ -3274,6 +3446,7 @@ class AntigravityProvider(
3274
  headers = {
3275
  "Authorization": f"Bearer {token}",
3276
  "Content-Type": "application/json",
 
3277
  }
3278
  payload = {
3279
  "project": _generate_project_id(),
@@ -3414,6 +3587,7 @@ class AntigravityProvider(
3414
 
3415
  # Add tools
3416
  gemini_tools = self._build_tools_payload(tools, model)
 
3417
  if gemini_tools:
3418
  gemini_payload["tools"] = gemini_tools
3419
 
@@ -3423,6 +3597,7 @@ class AntigravityProvider(
3423
  gemini_payload["tools"] = self._apply_gemini3_namespace(
3424
  gemini_payload["tools"]
3425
  )
 
3426
  if self._gemini3_enforce_strict_schema:
3427
  gemini_payload["tools"] = self._enforce_strict_schema(
3428
  gemini_payload["tools"]
@@ -3459,17 +3634,13 @@ class AntigravityProvider(
3459
  if stream:
3460
  url = f"{url}?alt=sse"
3461
 
3462
- parsed = urlparse(base_url)
3463
- host = parsed.netloc or base_url.replace("https://", "").replace(
3464
- "http://", ""
3465
- ).rstrip("/")
3466
-
3467
  headers = {
3468
  "Authorization": f"Bearer {token}",
3469
  "Content-Type": "application/json",
3470
- "Host": host,
3471
- "User-Agent": "antigravity/1.11.9 windows/amd64",
3472
  "Accept": "text/event-stream" if stream else "application/json",
 
3473
  }
3474
 
3475
  # URL fallback loop - handles HTTP errors (except 429) and network errors
@@ -3480,7 +3651,12 @@ class AntigravityProvider(
3480
  if stream:
3481
  # Streaming: _streaming_with_retry handles empty response retries internally
3482
  return self._streaming_with_retry(
3483
- client, url, headers, payload, model, file_logger
 
 
 
 
 
3484
  )
3485
  else:
3486
  # Non-streaming: empty response and bare 429 retry loop
@@ -3496,7 +3672,12 @@ class AntigravityProvider(
3496
  for attempt in range(EMPTY_RESPONSE_MAX_ATTEMPTS):
3497
  try:
3498
  result = await self._handle_non_streaming(
3499
- client, url, headers, payload, model, file_logger
 
 
 
 
 
3500
  )
3501
 
3502
  # Check if we got anything - empty dict means no candidates
@@ -3771,7 +3952,12 @@ class AntigravityProvider(
3771
 
3772
  try:
3773
  async for chunk in self._handle_streaming(
3774
- client, url, headers, payload, model, file_logger
 
 
 
 
 
3775
  ):
3776
  chunk_count += 1
3777
  yield chunk # Stream immediately - true streaming preserved
 
4
 
5
  A clean, well-structured provider for Google's Antigravity API, supporting:
6
  - Gemini 2.5 (Pro/Flash) with thinkingBudget
7
+ - Gemini 3 (Pro/Flash/Image) with thinkingLevel
8
  - Claude (Sonnet 4.5) via Antigravity proxy
9
  - Claude (Opus 4.5) via Antigravity proxy
10
 
 
38
  Union,
39
  TYPE_CHECKING,
40
  )
 
41
 
42
  import httpx
43
  import litellm
 
80
  "https://cloudcode-pa.googleapis.com/v1internal", # Production fallback
81
  ]
82
 
83
+ # Required headers for Antigravity API calls
84
+ # These headers are CRITICAL for gemini-3-pro-high/low to work
85
+ # Without X-Goog-Api-Client and Client-Metadata, only gemini-3-pro-preview works
86
+ ANTIGRAVITY_HEADERS = {
87
+ "User-Agent": "antigravity/1.12.4 windows/amd64",
88
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
89
+ "Client-Metadata": '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}',
90
+ }
91
+
92
  # Available models via Antigravity
93
  AVAILABLE_MODELS = [
94
  # Gemini models
 
96
  "gemini-2.5-flash", # Uses -thinking variant when reasoning_effort provided
97
  "gemini-2.5-flash-lite", # Thinking budget configurable, no name change
98
  "gemini-3-pro-preview", # Internally mapped to -low/-high variant based on thinkingLevel
99
+ "gemini-3-flash", # New Gemini 3 Flash model (supports thinking with minBudget=32)
100
  # "gemini-3-pro-image", # Image generation model
101
  # "gemini-2.5-computer-use-preview-10-2025",
102
  # Claude models
 
113
  # When Antigravity returns an empty response (no content, no tool calls),
114
  # automatically retry up to this many attempts before giving up (minimum 1)
115
  EMPTY_RESPONSE_MAX_ATTEMPTS = max(1, _env_int("ANTIGRAVITY_EMPTY_RESPONSE_ATTEMPTS", 6))
116
+ EMPTY_RESPONSE_RETRY_DELAY = _env_int("ANTIGRAVITY_EMPTY_RESPONSE_RETRY_DELAY", 3)
117
 
118
  # Model alias mappings (internal ↔ public)
119
  MODEL_ALIAS_MAP = {
 
141
  "OTHER": "stop",
142
  }
143
 
144
+ # Gemini 3 tool name remapping
145
+ # Turned out not useful - saved for later to unfuck if needed
146
+ GEMINI3_TOOL_RENAMES = {
147
+ # "batch": "multi_tool", # "batch" triggers internal format: call:default_api:...
148
+ }
149
+ GEMINI3_TOOL_RENAMES_REVERSE = {v: k for k, v in GEMINI3_TOOL_RENAMES.items()}
150
+
151
  # Default safety settings - disable content filtering for all categories
152
  # Per CLIProxyAPI: these are attached to prevent safety blocks during API calls
153
  DEFAULT_SAFETY_SETTINGS = [
 
276
  def _normalize_type_arrays(schema: Any) -> Any:
277
  """
278
  Normalize type arrays in JSON Schema for Proto-based Antigravity API.
279
+ Converts `"type": ["string", "null"]` β†’ `"type": "string", "nullable": true`.
280
  """
281
  if isinstance(schema, dict):
282
  normalized = {}
283
  for key, value in schema.items():
284
  if key == "type" and isinstance(value, list):
285
+ types = value
286
+ if "null" in types:
287
+ normalized["nullable"] = True
288
+ remaining_types = [t for t in types if t != "null"]
289
+ if len(remaining_types) == 1:
290
+ normalized[key] = remaining_types[0]
291
+ elif len(remaining_types) > 1:
292
+ normalized[key] = remaining_types
293
+ # If no types remain, don't add "type" key
294
+ else:
295
+ normalized[key] = value[0] if len(value) == 1 else value
296
  else:
297
  normalized[key] = _normalize_type_arrays(value)
298
  return normalized
 
301
  return schema
302
 
303
 
304
+ def _recursively_parse_json_strings(
305
+ obj: Any,
306
+ schema: Optional[Dict[str, Any]] = None,
307
+ parse_json_objects: bool = False,
308
+ ) -> Any:
309
  """
310
  Recursively parse JSON strings in nested data structures.
311
 
312
  Antigravity sometimes returns tool arguments with JSON-stringified values:
313
  {"files": "[{...}]"} instead of {"files": [{...}]}.
314
 
315
+ Args:
316
+ obj: The object to process
317
+ schema: Optional JSON schema for the current level (used for schema-aware parsing)
318
+ parse_json_objects: If False (default), don't parse JSON-looking strings into objects.
319
+ This prevents corrupting string content like write tool's "content" field.
320
+ If True, parse strings that look like JSON objects/arrays.
321
+
322
  Additionally handles:
323
+ - Malformed double-encoded JSON (extra trailing '}' or ']') - only when parse_json_objects=True
324
+ - Escaped string content (\n, \t, etc.) - always processed
325
  """
326
  if isinstance(obj, dict):
327
+ # Get properties schema for looking up field types
328
+ properties_schema = schema.get("properties", {}) if schema else {}
329
+ return {
330
+ k: _recursively_parse_json_strings(
331
+ v,
332
+ properties_schema.get(k),
333
+ parse_json_objects,
334
+ )
335
+ for k, v in obj.items()
336
+ }
337
  elif isinstance(obj, list):
338
+ # Get items schema for array elements
339
+ items_schema = schema.get("items") if schema else None
340
+ return [
341
+ _recursively_parse_json_strings(item, items_schema, parse_json_objects)
342
+ for item in obj
343
+ ]
344
  elif isinstance(obj, str):
345
  stripped = obj.strip()
346
 
 
371
  # If unescaping fails, continue with original processing
372
  pass
373
 
374
+ # Only parse JSON strings if explicitly enabled
375
+ if not parse_json_objects:
376
+ return obj
377
+
378
+ # Schema-aware parsing: only parse if schema expects object/array, not string
379
+ if schema:
380
+ schema_type = schema.get("type")
381
+ if schema_type == "string":
382
+ # Schema says this should be a string - don't parse it
383
+ return obj
384
+ # Only parse if schema expects object or array
385
+ if schema_type not in ("object", "array", None):
386
+ return obj
387
+
388
  # Check if it looks like JSON (starts with { or [)
389
  if stripped and stripped[0] in ("{", "["):
390
  # Try standard parsing first
 
393
  ):
394
  try:
395
  parsed = json.loads(obj)
396
+ return _recursively_parse_json_strings(
397
+ parsed, schema, parse_json_objects
398
+ )
399
  except (json.JSONDecodeError, ValueError):
400
  pass
401
 
 
412
  f"[Antigravity] Auto-corrected malformed JSON string: "
413
  f"truncated {len(stripped) - len(cleaned)} extra chars"
414
  )
415
+ return _recursively_parse_json_strings(
416
+ parsed, schema, parse_json_objects
417
+ )
418
  except (json.JSONDecodeError, ValueError):
419
  pass
420
 
 
430
  f"[Antigravity] Auto-corrected malformed JSON string: "
431
  f"truncated {len(stripped) - len(cleaned)} extra chars"
432
  )
433
+ return _recursively_parse_json_strings(
434
+ parsed, schema, parse_json_objects
435
+ )
436
  except (json.JSONDecodeError, ValueError):
437
  pass
438
  return obj
 
465
  return resolve(schema)
466
 
467
 
468
+ def _clean_claude_schema(schema: Any, for_gemini: bool = False) -> Any:
469
  """
470
  Recursively clean JSON Schema for Antigravity/Google's Proto-based API.
471
 
 
473
  - Removes unsupported validation keywords at schema-definition level
474
  - Preserves property NAMES even if they match validation keyword names
475
  (e.g., a tool parameter named "pattern" is preserved)
476
+ - For Gemini: passes through most keywords including $schema, anyOf, oneOf, const
477
+ - For Claude: strips validation keywords, converts anyOf/oneOf to first option, const to enum
478
+ - For Gemini: passes through additionalProperties as-is
479
+ - For Claude: normalizes permissive additionalProperties to true
480
  """
481
  if not isinstance(schema, dict):
482
  return schema
 
484
  # Meta/structural keywords - always remove regardless of context
485
  # These are JSON Schema infrastructure, never valid property names
486
  meta_keywords = {
 
487
  "$id",
488
  "$ref",
489
  "$defs",
490
  "definitions",
 
491
  }
492
 
493
  # Validation keywords - only remove at schema-definition level,
 
498
  # - "default" (config tools)
499
  # - "title" (document tools)
500
  # - "minimum"/"maximum" (range tools)
501
+ #
502
+ # Keywords to strip for Claude only (Gemini accepts these):
503
+ # Claude rejects most JSON Schema validation keywords
504
+ validation_keywords_claude_only = {
505
+ "$schema",
506
  "minItems",
507
  "maxItems",
508
+ "uniqueItems",
509
  "pattern",
510
  "minLength",
511
  "maxLength",
512
  "minimum",
513
  "maximum",
 
514
  "exclusiveMinimum",
515
  "exclusiveMaximum",
516
  "multipleOf",
517
  "format",
518
  "minProperties",
519
  "maxProperties",
 
520
  "contentEncoding",
521
  "contentMediaType",
522
  "contentSchema",
 
525
  "writeOnly",
526
  "examples",
527
  "title",
528
+ "default",
529
  }
530
 
531
  # Handle 'anyOf' by taking the first option (Claude doesn't support anyOf)
532
+ # Gemini supports anyOf/oneOf, so pass through for Gemini
533
+ if not for_gemini:
534
+ if "anyOf" in schema and isinstance(schema["anyOf"], list) and schema["anyOf"]:
535
+ first_option = _clean_claude_schema(schema["anyOf"][0], for_gemini)
536
+ if isinstance(first_option, dict):
537
+ return first_option
538
+
539
+ # Handle 'oneOf' similarly
540
+ if "oneOf" in schema and isinstance(schema["oneOf"], list) and schema["oneOf"]:
541
+ first_option = _clean_claude_schema(schema["oneOf"][0], for_gemini)
542
+ if isinstance(first_option, dict):
543
+ return first_option
544
 
545
  cleaned = {}
546
+ # Handle 'const' by converting to 'enum' with single value (Claude only)
547
+ # Gemini supports const, so pass through for Gemini
548
+ if "const" in schema and not for_gemini:
549
  const_value = schema["const"]
550
  cleaned["enum"] = [const_value]
551
 
552
  for key, value in schema.items():
553
+ # Always skip meta keywords
554
+ if key in meta_keywords:
555
  continue
556
 
557
+ # Skip "const" for Claude (already converted to enum above)
558
+ if key == "const" and not for_gemini:
 
 
 
 
 
 
 
 
559
  continue
560
 
561
+ # Strip Claude-only keywords when not targeting Gemini
562
+ if key in validation_keywords_claude_only:
563
+ if for_gemini:
564
+ # Gemini accepts these - preserve them
565
+ cleaned[key] = value
566
+ # For Claude: skip - not supported
567
+ continue
568
+
569
+ # Special handling for additionalProperties:
570
+ # For Gemini: pass through as-is (Gemini accepts {}, true, false, typed schemas)
571
+ # For Claude: normalize permissive values ({} or true) to true
572
+ if key == "additionalProperties":
573
+ if for_gemini:
574
+ # Pass through additionalProperties as-is for Gemini
575
+ # Gemini accepts: true, false, {}, {"type": "string"}, etc.
576
+ cleaned["additionalProperties"] = value
577
+ else:
578
+ # Claude handling: normalize permissive values to true
579
+ if (
580
+ value is True
581
+ or value == {}
582
+ or (isinstance(value, dict) and not value)
583
+ ):
584
+ cleaned["additionalProperties"] = True # Normalize {} to true
585
+ elif value is False:
586
+ cleaned["additionalProperties"] = False
587
+ # Skip complex schema values for Claude (e.g., {"type": "string"})
588
  continue
589
 
590
  # Special handling for "properties" - preserve property NAMES
 
595
  for prop_name, prop_schema in value.items():
596
  # Log warning if property name matches a validation keyword
597
  # This helps debug potential issues where the old code would have dropped it
598
+ if prop_name in validation_keywords_claude_only:
599
  lib_logger.debug(
600
  f"[Schema] Preserving property '{prop_name}' (matches validation keyword name)"
601
  )
602
+ cleaned_props[prop_name] = _clean_claude_schema(prop_schema, for_gemini)
603
  cleaned[key] = cleaned_props
604
  elif isinstance(value, dict):
605
+ cleaned[key] = _clean_claude_schema(value, for_gemini)
606
  elif isinstance(value, list):
607
  cleaned[key] = [
608
+ _clean_claude_schema(item, for_gemini)
609
+ if isinstance(item, dict)
610
+ else item
611
  for item in value
612
  ]
613
  else:
 
695
 
696
  Supports:
697
  - Gemini 2.5 (Pro/Flash) with thinkingBudget
698
+ - Gemini 3 (Pro/Flash/Image) with thinkingLevel
699
  - Claude Sonnet 4.5 via Antigravity proxy
700
  - Claude Opus 4.5 via Antigravity proxy
701
 
 
772
  "gemini-3-pro-low",
773
  "gemini-3-pro-preview",
774
  ],
775
+ # Gemini 3 Flash (standalone, may share with 2.5 Flash - needs verification)
776
+ "gemini-3-flash": [
777
+ "gemini-3-flash",
778
+ ],
779
  # Gemini 2.5 Flash variants share quota
780
  "gemini-2.5-flash": [
781
  "gemini-2.5-flash",
 
1041
  self._gemini3_enforce_strict_schema = _env_bool(
1042
  "ANTIGRAVITY_GEMINI3_STRICT_SCHEMA", True
1043
  )
1044
+ # Toggle for JSON string parsing in tool call arguments
1045
+ # NOTE: This is possibly redundant - modern Gemini models may not need this fix.
1046
+ # Disabled by default. Enable if you see JSON-stringified values in tool args.
1047
+ self._enable_json_string_parsing = _env_bool(
1048
+ "ANTIGRAVITY_ENABLE_JSON_STRING_PARSING", False
1049
+ )
1050
  self._gemini3_system_instruction = os.getenv(
1051
  "ANTIGRAVITY_GEMINI3_SYSTEM_INSTRUCTION", DEFAULT_GEMINI3_SYSTEM_INSTRUCTION
1052
  )
 
1086
  f"parallel_tool_gemini3={self._enable_parallel_tool_instruction_gemini3}"
1087
  )
1088
 
1089
+ def _get_antigravity_headers(self) -> Dict[str, str]:
1090
+ """Return the Antigravity API headers. Used by quota tracker mixin."""
1091
+ return ANTIGRAVITY_HEADERS
1092
+
1093
  def _load_tier_from_file(self, credential_path: str) -> Optional[str]:
1094
  """
1095
  Load tier from credential file's _proxy_metadata and cache it.
 
2055
  Map reasoning_effort to thinking configuration.
2056
 
2057
  - Gemini 2.5 & Claude: thinkingBudget (integer tokens)
2058
+ - Gemini 3 Pro: thinkingLevel (string: "low"/"high")
2059
+ - Gemini 3 Flash: thinkingLevel (string: "minimal"/"low"/"medium"/"high")
2060
  """
2061
  internal = self._alias_to_internal(model)
2062
  is_gemini_25 = "gemini-2.5" in model
2063
  is_gemini_3 = internal.startswith("gemini-3-")
2064
+ is_gemini_3_flash = "gemini-3-flash" in model or "gemini-3-flash" in internal
2065
  is_claude = self._is_claude(model)
2066
 
2067
  if not (is_gemini_25 or is_gemini_3 or is_claude):
2068
  return None
2069
 
2070
+ # Gemini 3 Flash: Supports minimal/low/medium/high thinkingLevel
2071
+ if is_gemini_3_flash:
2072
+ if reasoning_effort == "disable":
2073
+ # "minimal" matches "no thinking" for most queries
2074
+ return {"thinkingLevel": "minimal", "include_thoughts": True}
2075
+ elif reasoning_effort == "low":
2076
+ return {"thinkingLevel": "low", "include_thoughts": True}
2077
+ elif reasoning_effort == "medium":
2078
+ return {"thinkingLevel": "medium", "include_thoughts": True}
2079
+ # Default to high for Flash
2080
+ return {"thinkingLevel": "high", "include_thoughts": True}
2081
+
2082
+ # Gemini 3 Pro: Only supports low/high thinkingLevel
2083
  if is_gemini_3:
2084
  if reasoning_effort == "low":
2085
  return {"thinkingLevel": "low", "include_thoughts": True}
2086
+ # medium maps to high for Pro (not supported)
2087
  return {"thinkingLevel": "high", "include_thoughts": True}
2088
 
2089
  # Gemini 2.5 & Claude: Integer thinkingBudget
 
2303
  # f"id={tool_id}, name={func_name}"
2304
  # )
2305
 
2306
+ # Add prefix for Gemini 3 (and rename problematic tools)
2307
  if self._is_gemini_3(model) and self._enable_gemini3_tool_fix:
2308
+ func_name = GEMINI3_TOOL_RENAMES.get(func_name, func_name)
2309
  func_name = f"{self._gemini3_tool_prefix}{func_name}"
2310
 
2311
  func_part = {
 
2396
  # else:
2397
  # lib_logger.debug(f"[ID Mapping] Tool response matched: id={tool_id}, name={func_name}")
2398
 
2399
+ # Add prefix for Gemini 3 (and rename problematic tools)
2400
  if self._is_gemini_3(model) and self._enable_gemini3_tool_fix:
2401
+ func_name = GEMINI3_TOOL_RENAMES.get(func_name, func_name)
2402
  func_name = f"{self._gemini3_tool_prefix}{func_name}"
2403
 
2404
  try:
 
2648
  def _apply_gemini3_namespace(
2649
  self, tools: List[Dict[str, Any]]
2650
  ) -> List[Dict[str, Any]]:
2651
+ """
2652
+ Add namespace prefix to tool names for Gemini 3.
2653
+
2654
+ Also renames certain tools that conflict with Gemini's internal behavior
2655
+ (e.g., "batch" triggers MALFORMED_FUNCTION_CALL errors).
2656
+ """
2657
  if not tools:
2658
  return tools
2659
 
 
2662
  for func_decl in tool.get("functionDeclarations", []):
2663
  name = func_decl.get("name", "")
2664
  if name:
2665
+ # Rename problematic tools first
2666
+ name = GEMINI3_TOOL_RENAMES.get(name, name)
2667
+ # Then add prefix
2668
  func_decl["name"] = f"{self._gemini3_tool_prefix}{name}"
2669
 
2670
  return modified
 
2675
  """
2676
  Enforce strict JSON schema for Gemini 3 to prevent hallucinated parameters.
2677
 
2678
+ Adds 'additionalProperties: false' to object schemas with 'properties',
2679
  which tells the model it CANNOT add properties not in the schema.
2680
 
2681
+ IMPORTANT: Preserves 'additionalProperties: true' (or {}) when explicitly
2682
+ set in the original schema. This is critical for "freeform" parameter objects
2683
+ like batch/multi_tool's nested parameters which need to accept arbitrary
2684
+ tool parameters that aren't pre-defined in the schema.
2685
  """
2686
  if not tools:
2687
  return tools
 
2691
  return schema
2692
 
2693
  result = {}
2694
+ preserved_additional_props = None
2695
+
2696
  for key, value in schema.items():
2697
+ # Preserve additionalProperties as-is if it's truthy
2698
+ # This is critical for "freeform" parameter objects like batch's
2699
+ # nested parameters which need to accept arbitrary tool parameters
2700
+ if key == "additionalProperties":
2701
+ if value is not False:
2702
+ # Preserve the original value (true, {}, {"type": "string"}, etc.)
2703
+ preserved_additional_props = value
2704
+ continue
2705
  if isinstance(value, dict):
2706
  result[key] = enforce_strict(value)
2707
  elif isinstance(value, list):
 
2712
  else:
2713
  result[key] = value
2714
 
2715
+ # Add additionalProperties: false to object schemas with properties,
2716
+ # BUT only if we didn't preserve a value from the original schema
 
2717
  if result.get("type") == "object" and "properties" in result:
2718
+ if preserved_additional_props is not None:
2719
+ result["additionalProperties"] = preserved_additional_props
 
 
2720
  else:
 
2721
  result["additionalProperties"] = False
2722
 
2723
  return result
 
2827
  return type_hint
2828
 
2829
  def _strip_gemini3_prefix(self, name: str) -> str:
2830
+ """
2831
+ Strip the Gemini 3 namespace prefix from a tool name.
2832
+
2833
+ Also reverses any tool renames that were applied to avoid Gemini conflicts.
2834
+ """
2835
  if name and name.startswith(self._gemini3_tool_prefix):
2836
+ stripped = name[len(self._gemini3_tool_prefix) :]
2837
+ # Reverse any renames
2838
+ return GEMINI3_TOOL_RENAMES_REVERSE.get(stripped, stripped)
2839
  return name
2840
 
2841
  def _translate_tool_choice(
 
2862
  elif isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
2863
  function_name = tool_choice.get("function", {}).get("name")
2864
  if function_name:
2865
+ # Add Gemini 3 prefix if needed (and rename problematic tools)
2866
  if is_gemini_3 and self._enable_gemini3_tool_fix:
2867
+ function_name = GEMINI3_TOOL_RENAMES.get(
2868
+ function_name, function_name
2869
+ )
2870
  function_name = f"{self._gemini3_tool_prefix}{function_name}"
2871
 
2872
  mode = "ANY" # Force a call, but only to this function
 
2884
  # =========================================================================
2885
 
2886
  def _build_tools_payload(
2887
+ self, tools: Optional[List[Dict[str, Any]]], model: str
2888
  ) -> Optional[List[Dict[str, Any]]]:
2889
+ """Build Gemini-format tools from OpenAI tools.
2890
+
2891
+ For Gemini models, all tools are placed in a SINGLE functionDeclarations array.
2892
+ This matches the format expected by Gemini CLI and prevents MALFORMED_FUNCTION_CALL errors.
2893
+ """
2894
  if not tools:
2895
  return None
2896
 
2897
+ function_declarations = []
2898
+
2899
  for tool in tools:
2900
  if tool.get("type") != "function":
2901
  continue
 
2913
  schema.pop("strict", None)
2914
  # Inline $ref definitions, then strip unsupported keywords
2915
  schema = _inline_schema_refs(schema)
2916
+ # For Gemini models, use for_gemini=True to:
2917
+ # - Preserve truthy additionalProperties (for freeform param objects)
2918
+ # - Strip false values (let _enforce_strict_schema add them)
2919
+ is_gemini = not self._is_claude(model)
2920
+ schema = _clean_claude_schema(schema, for_gemini=is_gemini)
2921
  schema = _normalize_type_arrays(schema)
2922
 
2923
  # Workaround: Antigravity/Gemini fails to emit functionCall
 
2950
  "required": ["_confirm"],
2951
  }
2952
 
2953
+ function_declarations.append(func_decl)
2954
+
2955
+ if not function_declarations:
2956
+ return None
2957
 
2958
+ # Return all tools in a SINGLE functionDeclarations array
2959
+ # This is the format Gemini CLI uses and prevents MALFORMED_FUNCTION_CALL errors
2960
+ return [{"functionDeclarations": function_declarations}]
2961
 
2962
  def _transform_to_antigravity_format(
2963
  self,
 
3215
  return response
3216
 
3217
  def _gemini_to_openai_non_streaming(
3218
+ self,
3219
+ response: Dict[str, Any],
3220
+ model: str,
3221
  ) -> Dict[str, Any]:
3222
  """Convert Gemini response to OpenAI non-streaming format."""
3223
  candidates = response.get("candidates", [])
 
3327
  tool_name = self._strip_gemini3_prefix(tool_name)
3328
 
3329
  raw_args = func_call.get("args", {})
3330
+
3331
+ # Optionally parse JSON strings (handles escaped control chars, malformed JSON)
3332
+ # NOTE: This is possibly very redundant
3333
+ if self._enable_json_string_parsing:
3334
+ parsed_args = _recursively_parse_json_strings(raw_args)
3335
+ else:
3336
+ parsed_args = raw_args
3337
 
3338
  # Strip the injected _confirm parameter ONLY if it's the sole parameter
3339
  # This ensures we only strip our injection, not legitimate user params
 
3446
  headers = {
3447
  "Authorization": f"Bearer {token}",
3448
  "Content-Type": "application/json",
3449
+ **ANTIGRAVITY_HEADERS,
3450
  }
3451
  payload = {
3452
  "project": _generate_project_id(),
 
3587
 
3588
  # Add tools
3589
  gemini_tools = self._build_tools_payload(tools, model)
3590
+
3591
  if gemini_tools:
3592
  gemini_payload["tools"] = gemini_tools
3593
 
 
3597
  gemini_payload["tools"] = self._apply_gemini3_namespace(
3598
  gemini_payload["tools"]
3599
  )
3600
+
3601
  if self._gemini3_enforce_strict_schema:
3602
  gemini_payload["tools"] = self._enforce_strict_schema(
3603
  gemini_payload["tools"]
 
3634
  if stream:
3635
  url = f"{url}?alt=sse"
3636
 
3637
+ # These headers are REQUIRED for gemini-3-pro-high/low to work
3638
+ # Without X-Goog-Api-Client and Client-Metadata, only gemini-3-pro-preview works
 
 
 
3639
  headers = {
3640
  "Authorization": f"Bearer {token}",
3641
  "Content-Type": "application/json",
 
 
3642
  "Accept": "text/event-stream" if stream else "application/json",
3643
+ **ANTIGRAVITY_HEADERS,
3644
  }
3645
 
3646
  # URL fallback loop - handles HTTP errors (except 429) and network errors
 
3651
  if stream:
3652
  # Streaming: _streaming_with_retry handles empty response retries internally
3653
  return self._streaming_with_retry(
3654
+ client,
3655
+ url,
3656
+ headers,
3657
+ payload,
3658
+ model,
3659
+ file_logger,
3660
  )
3661
  else:
3662
  # Non-streaming: empty response and bare 429 retry loop
 
3672
  for attempt in range(EMPTY_RESPONSE_MAX_ATTEMPTS):
3673
  try:
3674
  result = await self._handle_non_streaming(
3675
+ client,
3676
+ url,
3677
+ headers,
3678
+ payload,
3679
+ model,
3680
+ file_logger,
3681
  )
3682
 
3683
  # Check if we got anything - empty dict means no candidates
 
3952
 
3953
  try:
3954
  async for chunk in self._handle_streaming(
3955
+ client,
3956
+ url,
3957
+ headers,
3958
+ payload,
3959
+ model,
3960
+ file_logger,
3961
  ):
3962
  chunk_count += 1
3963
  yield chunk # Stream immediately - true streaming preserved
src/rotator_library/providers/gemini_cli_provider.py CHANGED
@@ -10,6 +10,7 @@ from typing import List, Dict, Any, AsyncGenerator, Union, Optional, Tuple
10
  from .provider_interface import ProviderInterface
11
  from .gemini_auth_base import GeminiAuthBase
12
  from .provider_cache import ProviderCache
 
13
  from ..model_definitions import ModelDefinitions
14
  from ..timeout_config import TimeoutConfig
15
  from ..utils.paths import get_logs_dir, get_cache_dir
@@ -116,6 +117,7 @@ HARDCODED_MODELS = [
116
  "gemini-2.5-flash",
117
  "gemini-2.5-flash-lite",
118
  "gemini-3-pro-preview",
 
119
  ]
120
 
121
  # Gemini 3 tool fix system instruction (prevents hallucination)
@@ -183,21 +185,46 @@ FINISH_REASON_MAP = {
183
  }
184
 
185
 
186
- def _recursively_parse_json_strings(obj: Any) -> Any:
 
 
 
 
187
  """
188
  Recursively parse JSON strings in nested data structures.
189
 
190
  Gemini sometimes returns tool arguments with JSON-stringified values:
191
  {"files": "[{...}]"} instead of {"files": [{...}]}.
192
 
 
 
 
 
 
 
 
193
  Additionally handles:
194
- - Malformed double-encoded JSON (extra trailing '}' or ']')
195
- - Escaped string content (\n, \t, etc.)
196
  """
197
  if isinstance(obj, dict):
198
- return {k: _recursively_parse_json_strings(v) for k, v in obj.items()}
 
 
 
 
 
 
 
 
 
199
  elif isinstance(obj, list):
200
- return [_recursively_parse_json_strings(item) for item in obj]
 
 
 
 
 
201
  elif isinstance(obj, str):
202
  stripped = obj.strip()
203
 
@@ -228,6 +255,20 @@ def _recursively_parse_json_strings(obj: Any) -> Any:
228
  # If unescaping fails, continue with original processing
229
  pass
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  # Check if it looks like JSON (starts with { or [)
232
  if stripped and stripped[0] in ("{", "["):
233
  # Try standard parsing first
@@ -236,7 +277,9 @@ def _recursively_parse_json_strings(obj: Any) -> Any:
236
  ):
237
  try:
238
  parsed = json.loads(obj)
239
- return _recursively_parse_json_strings(parsed)
 
 
240
  except (json.JSONDecodeError, ValueError):
241
  pass
242
 
@@ -253,7 +296,9 @@ def _recursively_parse_json_strings(obj: Any) -> Any:
253
  f"[GeminiCli] Auto-corrected malformed JSON string: "
254
  f"truncated {len(stripped) - len(cleaned)} extra chars"
255
  )
256
- return _recursively_parse_json_strings(parsed)
 
 
257
  except (json.JSONDecodeError, ValueError):
258
  pass
259
 
@@ -269,12 +314,41 @@ def _recursively_parse_json_strings(obj: Any) -> Any:
269
  f"[GeminiCli] Auto-corrected malformed JSON string: "
270
  f"truncated {len(stripped) - len(cleaned)} extra chars"
271
  )
272
- return _recursively_parse_json_strings(parsed)
 
 
273
  except (json.JSONDecodeError, ValueError):
274
  pass
275
  return obj
276
 
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  def _env_bool(key: str, default: bool = False) -> bool:
279
  """Get boolean from environment variable."""
280
  return os.getenv(key, str(default).lower()).lower() in ("true", "1", "yes")
@@ -510,6 +584,12 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
510
  self._gemini3_enforce_strict_schema = _env_bool(
511
  "GEMINI_CLI_GEMINI3_STRICT_SCHEMA", True
512
  )
 
 
 
 
 
 
513
 
514
  # Gemini 3 tool fix configuration
515
  self._gemini3_tool_prefix = os.getenv(
@@ -728,9 +808,15 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
728
  return model_name.startswith("gemini-3-")
729
 
730
  def _strip_gemini3_prefix(self, name: str) -> str:
731
- """Strip the Gemini 3 namespace prefix from a tool name."""
 
 
 
 
732
  if name and name.startswith(self._gemini3_tool_prefix):
733
- return name[len(self._gemini3_tool_prefix) :]
 
 
734
  return name
735
 
736
  # NOTE: _discover_project_id() and _persist_project_metadata() are inherited from GeminiAuthBase
@@ -893,8 +979,11 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
893
  tool_id = tool_call.get("id", "")
894
  func_name = tool_call["function"]["name"]
895
 
896
- # Add prefix for Gemini 3
897
  if is_gemini_3 and self._enable_gemini3_tool_fix:
 
 
 
898
  func_name = f"{self._gemini3_tool_prefix}{func_name}"
899
 
900
  func_part = {
@@ -941,8 +1030,11 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
941
  )
942
  function_name = "unknown_function"
943
 
944
- # Add prefix for Gemini 3
945
  if is_gemini_3 and self._enable_gemini3_tool_fix:
 
 
 
946
  function_name = f"{self._gemini3_tool_prefix}{function_name}"
947
 
948
  # Try to parse content as JSON first, fall back to string
@@ -1197,7 +1289,8 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1197
  Map reasoning_effort to thinking configuration.
1198
 
1199
  - Gemini 2.5: thinkingBudget (integer tokens)
1200
- - Gemini 3: thinkingLevel (string: "low"/"high")
 
1201
  """
1202
  custom_reasoning_budget = payload.get("custom_reasoning_budget", False)
1203
  reasoning_effort = payload.get("reasoning_effort")
@@ -1207,6 +1300,7 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1207
 
1208
  is_gemini_25 = "gemini-2.5" in model
1209
  is_gemini_3 = self._is_gemini_3(model)
 
1210
 
1211
  # Only apply reasoning logic to supported models
1212
  if not (is_gemini_25 or is_gemini_3):
@@ -1214,7 +1308,23 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1214
  payload.pop("custom_reasoning_budget", None)
1215
  return None
1216
 
1217
- # Gemini 3: String-based thinkingLevel
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1218
  if is_gemini_3:
1219
  # Clean up the original payload
1220
  payload.pop("reasoning_effort", None)
@@ -1222,6 +1332,7 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1222
 
1223
  if reasoning_effort == "low":
1224
  return {"thinkingLevel": "low", "include_thoughts": True}
 
1225
  return {"thinkingLevel": "high", "include_thoughts": True}
1226
 
1227
  # Gemini 2.5: Integer thinkingBudget
@@ -1309,9 +1420,13 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1309
  # Get current tool index from accumulator (default 0) and increment
1310
  current_tool_idx = accumulator.get("tool_idx", 0) if accumulator else 0
1311
 
1312
- # Get args, recursively parse any JSON strings, and strip _confirm if sole param
 
1313
  raw_args = function_call.get("args", {})
1314
- tool_args = _recursively_parse_json_strings(raw_args)
 
 
 
1315
 
1316
  # Strip _confirm ONLY if it's the sole parameter
1317
  # This ensures we only strip our injection, not legitimate user params
@@ -1542,7 +1657,8 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1542
  """
1543
  Recursively transforms a JSON schema to be compatible with the Gemini CLI endpoint.
1544
  - Converts `type: ["type", "null"]` to `type: "type", nullable: true`
1545
- - Removes unsupported properties like `strict` and `additionalProperties`.
 
1546
  """
1547
  if not isinstance(schema, dict):
1548
  return schema
@@ -1573,7 +1689,7 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1573
 
1574
  # Clean up unsupported properties
1575
  schema.pop("strict", None)
1576
- schema.pop("additionalProperties", None)
1577
 
1578
  return schema
1579
 
@@ -1581,14 +1697,29 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1581
  """
1582
  Enforce strict JSON schema for Gemini 3 to prevent hallucinated parameters.
1583
 
1584
- Adds 'additionalProperties: false' recursively to all object schemas,
1585
  which tells the model it CANNOT add properties not in the schema.
 
 
 
 
 
1586
  """
1587
  if not isinstance(schema, dict):
1588
  return schema
1589
 
1590
  result = {}
 
 
1591
  for key, value in schema.items():
 
 
 
 
 
 
 
 
1592
  if isinstance(value, dict):
1593
  result[key] = self._enforce_strict_schema(value)
1594
  elif isinstance(value, list):
@@ -1601,9 +1732,13 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1601
  else:
1602
  result[key] = value
1603
 
1604
- # Add additionalProperties: false to object schemas
 
1605
  if result.get("type") == "object" and "properties" in result:
1606
- result["additionalProperties"] = False
 
 
 
1607
 
1608
  return result
1609
 
@@ -1631,9 +1766,9 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1631
 
1632
  # Gemini CLI expects 'parametersJsonSchema' instead of 'parameters'
1633
  if "parameters" in new_function:
1634
- schema = self._gemini_cli_transform_schema(
1635
- new_function["parameters"]
1636
- )
1637
  # Workaround: Gemini fails to emit functionCall for tools
1638
  # with empty properties {}. Inject a required confirmation param.
1639
  # Using a required parameter forces the model to commit to
@@ -1664,9 +1799,10 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1664
 
1665
  # Gemini 3 specific transformations
1666
  if is_gemini_3 and self._enable_gemini3_tool_fix:
1667
- # Add namespace prefix to tool names
1668
  name = new_function.get("name", "")
1669
  if name:
 
1670
  new_function["name"] = f"{self._gemini3_tool_prefix}{name}"
1671
 
1672
  # Enforce strict schema (additionalProperties: false)
@@ -1823,8 +1959,11 @@ class GeminiCliProvider(GeminiAuthBase, ProviderInterface):
1823
  elif isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
1824
  function_name = tool_choice.get("function", {}).get("name")
1825
  if function_name:
1826
- # Add Gemini 3 prefix if needed
1827
  if is_gemini_3 and self._enable_gemini3_tool_fix:
 
 
 
1828
  function_name = f"{self._gemini3_tool_prefix}{function_name}"
1829
 
1830
  mode = "ANY" # Force a call, but only to this function
 
10
  from .provider_interface import ProviderInterface
11
  from .gemini_auth_base import GeminiAuthBase
12
  from .provider_cache import ProviderCache
13
+ from .antigravity_provider import GEMINI3_TOOL_RENAMES, GEMINI3_TOOL_RENAMES_REVERSE
14
  from ..model_definitions import ModelDefinitions
15
  from ..timeout_config import TimeoutConfig
16
  from ..utils.paths import get_logs_dir, get_cache_dir
 
117
  "gemini-2.5-flash",
118
  "gemini-2.5-flash-lite",
119
  "gemini-3-pro-preview",
120
+ "gemini-3-flash-preview",
121
  ]
122
 
123
  # Gemini 3 tool fix system instruction (prevents hallucination)
 
185
  }
186
 
187
 
188
+ def _recursively_parse_json_strings(
189
+ obj: Any,
190
+ schema: Optional[Dict[str, Any]] = None,
191
+ parse_json_objects: bool = False,
192
+ ) -> Any:
193
  """
194
  Recursively parse JSON strings in nested data structures.
195
 
196
  Gemini sometimes returns tool arguments with JSON-stringified values:
197
  {"files": "[{...}]"} instead of {"files": [{...}]}.
198
 
199
+ Args:
200
+ obj: The object to process
201
+ schema: Optional JSON schema for the current level (used for schema-aware parsing)
202
+ parse_json_objects: If False (default), don't parse JSON-looking strings into objects.
203
+ This prevents corrupting string content like write tool's "content" field.
204
+ If True, parse strings that look like JSON objects/arrays.
205
+
206
  Additionally handles:
207
+ - Malformed double-encoded JSON (extra trailing '}' or ']') - only when parse_json_objects=True
208
+ - Escaped string content (\n, \t, etc.) - always processed
209
  """
210
  if isinstance(obj, dict):
211
+ # Get properties schema for looking up field types
212
+ properties_schema = schema.get("properties", {}) if schema else {}
213
+ return {
214
+ k: _recursively_parse_json_strings(
215
+ v,
216
+ properties_schema.get(k),
217
+ parse_json_objects,
218
+ )
219
+ for k, v in obj.items()
220
+ }
221
  elif isinstance(obj, list):
222
+ # Get items schema for array elements
223
+ items_schema = schema.get("items") if schema else None
224
+ return [
225
+ _recursively_parse_json_strings(item, items_schema, parse_json_objects)
226
+ for item in obj
227
+ ]
228
  elif isinstance(obj, str):
229
  stripped = obj.strip()
230
 
 
255
  # If unescaping fails, continue with original processing
256
  pass
257
 
258
+ # Only parse JSON strings if explicitly enabled
259
+ if not parse_json_objects:
260
+ return obj
261
+
262
+ # Schema-aware parsing: only parse if schema expects object/array, not string
263
+ if schema:
264
+ schema_type = schema.get("type")
265
+ if schema_type == "string":
266
+ # Schema says this should be a string - don't parse it
267
+ return obj
268
+ # Only parse if schema expects object or array
269
+ if schema_type not in ("object", "array", None):
270
+ return obj
271
+
272
  # Check if it looks like JSON (starts with { or [)
273
  if stripped and stripped[0] in ("{", "["):
274
  # Try standard parsing first
 
277
  ):
278
  try:
279
  parsed = json.loads(obj)
280
+ return _recursively_parse_json_strings(
281
+ parsed, schema, parse_json_objects
282
+ )
283
  except (json.JSONDecodeError, ValueError):
284
  pass
285
 
 
296
  f"[GeminiCli] Auto-corrected malformed JSON string: "
297
  f"truncated {len(stripped) - len(cleaned)} extra chars"
298
  )
299
+ return _recursively_parse_json_strings(
300
+ parsed, schema, parse_json_objects
301
+ )
302
  except (json.JSONDecodeError, ValueError):
303
  pass
304
 
 
314
  f"[GeminiCli] Auto-corrected malformed JSON string: "
315
  f"truncated {len(stripped) - len(cleaned)} extra chars"
316
  )
317
+ return _recursively_parse_json_strings(
318
+ parsed, schema, parse_json_objects
319
+ )
320
  except (json.JSONDecodeError, ValueError):
321
  pass
322
  return obj
323
 
324
 
325
+ def _inline_schema_refs(schema: Dict[str, Any]) -> Dict[str, Any]:
326
+ """Inline local $ref definitions before sanitization."""
327
+ if not isinstance(schema, dict):
328
+ return schema
329
+
330
+ defs = schema.get("$defs", schema.get("definitions", {}))
331
+ if not defs:
332
+ return schema
333
+
334
+ def resolve(node, seen=()):
335
+ if not isinstance(node, dict):
336
+ return [resolve(x, seen) for x in node] if isinstance(node, list) else node
337
+ if "$ref" in node:
338
+ ref = node["$ref"]
339
+ if ref in seen: # Circular - drop it
340
+ return {k: resolve(v, seen) for k, v in node.items() if k != "$ref"}
341
+ for prefix in ("#/$defs/", "#/definitions/"):
342
+ if isinstance(ref, str) and ref.startswith(prefix):
343
+ name = ref[len(prefix) :]
344
+ if name in defs:
345
+ return resolve(copy.deepcopy(defs[name]), seen + (ref,))
346
+ return {k: resolve(v, seen) for k, v in node.items() if k != "$ref"}
347
+ return {k: resolve(v, seen) for k, v in node.items()}
348
+
349
+ return resolve(schema)
350
+
351
+
352
  def _env_bool(key: str, default: bool = False) -> bool:
353
  """Get boolean from environment variable."""
354
  return os.getenv(key, str(default).lower()).lower() in ("true", "1", "yes")
 
584
  self._gemini3_enforce_strict_schema = _env_bool(
585
  "GEMINI_CLI_GEMINI3_STRICT_SCHEMA", True
586
  )
587
+ # Toggle for JSON string parsing in tool call arguments
588
+ # NOTE: This is possibly redundant - modern Gemini models may not need this fix.
589
+ # Disabled by default. Enable if you see JSON-stringified values in tool args.
590
+ self._enable_json_string_parsing = _env_bool(
591
+ "GEMINI_CLI_ENABLE_JSON_STRING_PARSING", False
592
+ )
593
 
594
  # Gemini 3 tool fix configuration
595
  self._gemini3_tool_prefix = os.getenv(
 
808
  return model_name.startswith("gemini-3-")
809
 
810
  def _strip_gemini3_prefix(self, name: str) -> str:
811
+ """
812
+ Strip the Gemini 3 namespace prefix from a tool name.
813
+
814
+ Also reverses any tool renames that were applied to avoid Gemini conflicts.
815
+ """
816
  if name and name.startswith(self._gemini3_tool_prefix):
817
+ stripped = name[len(self._gemini3_tool_prefix) :]
818
+ # Reverse any renames
819
+ return GEMINI3_TOOL_RENAMES_REVERSE.get(stripped, stripped)
820
  return name
821
 
822
  # NOTE: _discover_project_id() and _persist_project_metadata() are inherited from GeminiAuthBase
 
979
  tool_id = tool_call.get("id", "")
980
  func_name = tool_call["function"]["name"]
981
 
982
+ # Add prefix for Gemini 3 (and rename problematic tools)
983
  if is_gemini_3 and self._enable_gemini3_tool_fix:
984
+ func_name = GEMINI3_TOOL_RENAMES.get(
985
+ func_name, func_name
986
+ )
987
  func_name = f"{self._gemini3_tool_prefix}{func_name}"
988
 
989
  func_part = {
 
1030
  )
1031
  function_name = "unknown_function"
1032
 
1033
+ # Add prefix for Gemini 3 (and rename problematic tools)
1034
  if is_gemini_3 and self._enable_gemini3_tool_fix:
1035
+ function_name = GEMINI3_TOOL_RENAMES.get(
1036
+ function_name, function_name
1037
+ )
1038
  function_name = f"{self._gemini3_tool_prefix}{function_name}"
1039
 
1040
  # Try to parse content as JSON first, fall back to string
 
1289
  Map reasoning_effort to thinking configuration.
1290
 
1291
  - Gemini 2.5: thinkingBudget (integer tokens)
1292
+ - Gemini 3 Pro: thinkingLevel (string: "low"/"high")
1293
+ - Gemini 3 Flash: thinkingLevel (string: "minimal"/"low"/"medium"/"high")
1294
  """
1295
  custom_reasoning_budget = payload.get("custom_reasoning_budget", False)
1296
  reasoning_effort = payload.get("reasoning_effort")
 
1300
 
1301
  is_gemini_25 = "gemini-2.5" in model
1302
  is_gemini_3 = self._is_gemini_3(model)
1303
+ is_gemini_3_flash = "gemini-3-flash" in model
1304
 
1305
  # Only apply reasoning logic to supported models
1306
  if not (is_gemini_25 or is_gemini_3):
 
1308
  payload.pop("custom_reasoning_budget", None)
1309
  return None
1310
 
1311
+ # Gemini 3 Flash: Supports minimal/low/medium/high thinkingLevel
1312
+ if is_gemini_3_flash:
1313
+ # Clean up the original payload
1314
+ payload.pop("reasoning_effort", None)
1315
+ payload.pop("custom_reasoning_budget", None)
1316
+
1317
+ if reasoning_effort == "disable":
1318
+ # "minimal" matches "no thinking" for most queries
1319
+ return {"thinkingLevel": "minimal", "include_thoughts": True}
1320
+ elif reasoning_effort == "low":
1321
+ return {"thinkingLevel": "low", "include_thoughts": True}
1322
+ elif reasoning_effort == "medium":
1323
+ return {"thinkingLevel": "medium", "include_thoughts": True}
1324
+ # Default to high for Flash
1325
+ return {"thinkingLevel": "high", "include_thoughts": True}
1326
+
1327
+ # Gemini 3 Pro: Only supports low/high thinkingLevel
1328
  if is_gemini_3:
1329
  # Clean up the original payload
1330
  payload.pop("reasoning_effort", None)
 
1332
 
1333
  if reasoning_effort == "low":
1334
  return {"thinkingLevel": "low", "include_thoughts": True}
1335
+ # medium maps to high for Pro (not supported)
1336
  return {"thinkingLevel": "high", "include_thoughts": True}
1337
 
1338
  # Gemini 2.5: Integer thinkingBudget
 
1420
  # Get current tool index from accumulator (default 0) and increment
1421
  current_tool_idx = accumulator.get("tool_idx", 0) if accumulator else 0
1422
 
1423
+ # Optionally parse JSON strings in tool args
1424
+ # NOTE: This is very possibly redundant
1425
  raw_args = function_call.get("args", {})
1426
+ if self._enable_json_string_parsing:
1427
+ tool_args = _recursively_parse_json_strings(raw_args)
1428
+ else:
1429
+ tool_args = raw_args
1430
 
1431
  # Strip _confirm ONLY if it's the sole parameter
1432
  # This ensures we only strip our injection, not legitimate user params
 
1657
  """
1658
  Recursively transforms a JSON schema to be compatible with the Gemini CLI endpoint.
1659
  - Converts `type: ["type", "null"]` to `type: "type", nullable: true`
1660
+ - Removes unsupported properties like `strict`.
1661
+ - Preserves `additionalProperties` for _enforce_strict_schema to handle.
1662
  """
1663
  if not isinstance(schema, dict):
1664
  return schema
 
1689
 
1690
  # Clean up unsupported properties
1691
  schema.pop("strict", None)
1692
+ # Note: additionalProperties is preserved for _enforce_strict_schema to handle
1693
 
1694
  return schema
1695
 
 
1697
  """
1698
  Enforce strict JSON schema for Gemini 3 to prevent hallucinated parameters.
1699
 
1700
+ Adds 'additionalProperties: false' to object schemas with 'properties',
1701
  which tells the model it CANNOT add properties not in the schema.
1702
+
1703
+ IMPORTANT: Preserves 'additionalProperties: true' (or {}) when explicitly
1704
+ set in the original schema. This is critical for "freeform" parameter objects
1705
+ like batch/multi_tool's nested parameters which need to accept arbitrary
1706
+ tool parameters that aren't pre-defined in the schema.
1707
  """
1708
  if not isinstance(schema, dict):
1709
  return schema
1710
 
1711
  result = {}
1712
+ preserved_additional_props = None
1713
+
1714
  for key, value in schema.items():
1715
+ # Preserve additionalProperties as-is if it's truthy
1716
+ # This is critical for "freeform" parameter objects like batch's
1717
+ # nested parameters which need to accept arbitrary tool parameters
1718
+ if key == "additionalProperties":
1719
+ if value is not False:
1720
+ # Preserve the original value (true, {}, {"type": "string"}, etc.)
1721
+ preserved_additional_props = value
1722
+ continue
1723
  if isinstance(value, dict):
1724
  result[key] = self._enforce_strict_schema(value)
1725
  elif isinstance(value, list):
 
1732
  else:
1733
  result[key] = value
1734
 
1735
+ # Add additionalProperties: false to object schemas with properties,
1736
+ # BUT only if we didn't preserve a value from the original schema
1737
  if result.get("type") == "object" and "properties" in result:
1738
+ if preserved_additional_props is not None:
1739
+ result["additionalProperties"] = preserved_additional_props
1740
+ else:
1741
+ result["additionalProperties"] = False
1742
 
1743
  return result
1744
 
 
1766
 
1767
  # Gemini CLI expects 'parametersJsonSchema' instead of 'parameters'
1768
  if "parameters" in new_function:
1769
+ # Inline $ref definitions first
1770
+ schema = _inline_schema_refs(new_function["parameters"])
1771
+ schema = self._gemini_cli_transform_schema(schema)
1772
  # Workaround: Gemini fails to emit functionCall for tools
1773
  # with empty properties {}. Inject a required confirmation param.
1774
  # Using a required parameter forces the model to commit to
 
1799
 
1800
  # Gemini 3 specific transformations
1801
  if is_gemini_3 and self._enable_gemini3_tool_fix:
1802
+ # Add namespace prefix to tool names (and rename problematic tools)
1803
  name = new_function.get("name", "")
1804
  if name:
1805
+ name = GEMINI3_TOOL_RENAMES.get(name, name)
1806
  new_function["name"] = f"{self._gemini3_tool_prefix}{name}"
1807
 
1808
  # Enforce strict schema (additionalProperties: false)
 
1959
  elif isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
1960
  function_name = tool_choice.get("function", {}).get("name")
1961
  if function_name:
1962
+ # Add Gemini 3 prefix if needed (and rename problematic tools)
1963
  if is_gemini_3 and self._enable_gemini3_tool_fix:
1964
+ function_name = GEMINI3_TOOL_RENAMES.get(
1965
+ function_name, function_name
1966
+ )
1967
  function_name = f"{self._gemini3_tool_prefix}{function_name}"
1968
 
1969
  mode = "ANY" # Force a call, but only to this function
src/rotator_library/providers/utilities/antigravity_quota_tracker.py CHANGED
@@ -8,6 +8,7 @@ has certain methods and attributes available.
8
  Required from provider:
9
  - self._get_effective_quota_groups() -> Dict[str, List[str]]
10
  - self._get_available_models() -> List[str] # User-facing model names
 
11
  - self.list_credentials(base_dir) -> List[Dict[str, Any]]
12
  - self.project_tier_cache: Dict[str, str]
13
  - self.project_id_cache: Dict[str, str]
@@ -62,6 +63,8 @@ DEFAULT_QUOTA_COSTS: Dict[str, Dict[str, float]] = {
62
  "gemini-3-pro-high": 0.25,
63
  "gemini-3-pro-low": 0.25,
64
  "gemini-3-pro-preview": 0.25,
 
 
65
  # Gemini 2.5 Flash group (0.0333% per request, ~3000 requests)
66
  "gemini-2.5-flash": 0.0333,
67
  "gemini-2.5-flash-thinking": 0.0333,
@@ -80,6 +83,8 @@ DEFAULT_QUOTA_COSTS: Dict[str, Dict[str, float]] = {
80
  "gemini-3-pro-high": 0.4,
81
  "gemini-3-pro-low": 0.4,
82
  "gemini-3-pro-preview": 0.4,
 
 
83
  # Gemini 2.5 Flash group (same as standard-tier)
84
  "gemini-2.5-flash": 0.0333,
85
  "gemini-2.5-flash-thinking": 0.0333,
@@ -349,7 +354,7 @@ class AntigravityQuotaTracker:
349
  headers = {
350
  "Authorization": f"Bearer {access_token}",
351
  "Content-Type": "application/json",
352
- "User-Agent": "antigravity/1.11.9 windows/amd64",
353
  }
354
  payload = {"project": project_id} if project_id else {}
355
 
@@ -1171,7 +1176,7 @@ class AntigravityQuotaTracker:
1171
  headers = {
1172
  "Authorization": f"Bearer {access_token}",
1173
  "Content-Type": "application/json",
1174
- "User-Agent": "antigravity/1.11.9 windows/amd64",
1175
  }
1176
 
1177
  payload = {
 
8
  Required from provider:
9
  - self._get_effective_quota_groups() -> Dict[str, List[str]]
10
  - self._get_available_models() -> List[str] # User-facing model names
11
+ - self._get_antigravity_headers() -> Dict[str, str] # API headers for requests
12
  - self.list_credentials(base_dir) -> List[Dict[str, Any]]
13
  - self.project_tier_cache: Dict[str, str]
14
  - self.project_id_cache: Dict[str, str]
 
63
  "gemini-3-pro-high": 0.25,
64
  "gemini-3-pro-low": 0.25,
65
  "gemini-3-pro-preview": 0.25,
66
+ # Gemini 3 Flash (0.25% per request, 400 requests total - separate quota pool)
67
+ "gemini-3-flash": 0.25,
68
  # Gemini 2.5 Flash group (0.0333% per request, ~3000 requests)
69
  "gemini-2.5-flash": 0.0333,
70
  "gemini-2.5-flash-thinking": 0.0333,
 
83
  "gemini-3-pro-high": 0.4,
84
  "gemini-3-pro-low": 0.4,
85
  "gemini-3-pro-preview": 0.4,
86
+ # Gemini 3 Flash (0.20% per request, 400 requests total - separate quota pool)
87
+ "gemini-3-flash": 0.20,
88
  # Gemini 2.5 Flash group (same as standard-tier)
89
  "gemini-2.5-flash": 0.0333,
90
  "gemini-2.5-flash-thinking": 0.0333,
 
354
  headers = {
355
  "Authorization": f"Bearer {access_token}",
356
  "Content-Type": "application/json",
357
+ **self._get_antigravity_headers(),
358
  }
359
  payload = {"project": project_id} if project_id else {}
360
 
 
1176
  headers = {
1177
  "Authorization": f"Bearer {access_token}",
1178
  "Content-Type": "application/json",
1179
+ **self._get_antigravity_headers(),
1180
  }
1181
 
1182
  payload = {