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

fix(antigravity): πŸ› implement schema-aware JSON string parsing for tools

Browse files

This change addresses parsing issues where Gemini models (particularly Gemini 3) return stringified JSON for certain parameters, while preventing corruption of actual string arguments that resemble JSON.

- Enable `ANTIGRAVITY_ENABLE_JSON_STRING_PARSING` by default.
- Add `_build_tool_schema_map` to extract and normalize tool parameter schemas.
- Update `_extract_tool_call` to use schemas for intelligent argument parsing.
- Propagate tool schemas through both streaming and non-streaming completion flows.

src/rotator_library/providers/antigravity_provider.py CHANGED
@@ -1045,7 +1045,7 @@ class AntigravityProvider(
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
@@ -3162,7 +3162,11 @@ class AntigravityProvider(
3162
  accumulator["text_content"] += text
3163
 
3164
  if has_func:
3165
- tool_call = self._extract_tool_call(part, model, tool_idx, accumulator)
 
 
 
 
3166
 
3167
  # Store signature for each tool call (needed for parallel tool calls)
3168
  if has_sig:
@@ -3218,6 +3222,7 @@ class AntigravityProvider(
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", [])
@@ -3254,7 +3259,9 @@ class AntigravityProvider(
3254
  text_content += part["text"]
3255
 
3256
  if has_func:
3257
- tool_call = self._extract_tool_call(part, model, len(tool_calls))
 
 
3258
 
3259
  # Store signature for each tool call (needed for parallel tool calls)
3260
  if has_sig:
@@ -3309,12 +3316,38 @@ class AntigravityProvider(
3309
 
3310
  return result
3311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3312
  def _extract_tool_call(
3313
  self,
3314
  part: Dict[str, Any],
3315
  model: str,
3316
  index: int,
3317
  accumulator: Optional[Dict[str, Any]] = None,
 
3318
  ) -> Dict[str, Any]:
3319
  """Extract and format a tool call from a response part."""
3320
  func_call = part["functionCall"]
@@ -3329,9 +3362,15 @@ class AntigravityProvider(
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
 
@@ -3823,7 +3862,12 @@ class AntigravityProvider(
3823
  file_logger.log_final_response(data)
3824
 
3825
  gemini_response = self._unwrap_response(data)
3826
- openai_response = self._gemini_to_openai_non_streaming(gemini_response, model)
 
 
 
 
 
3827
 
3828
  return litellm.ModelResponse(**openai_response)
3829
 
@@ -3837,6 +3881,9 @@ class AntigravityProvider(
3837
  file_logger: Optional[AntigravityFileLogger] = None,
3838
  ) -> AsyncGenerator[litellm.ModelResponse, None]:
3839
  """Handle streaming completion."""
 
 
 
3840
  # Accumulator tracks state across chunks for caching and tool indexing
3841
  accumulator = {
3842
  "reasoning_content": "",
@@ -3847,6 +3894,7 @@ class AntigravityProvider(
3847
  "is_complete": False, # Track if we received usageMetadata
3848
  "last_usage": None, # Track last received usage for final chunk
3849
  "yielded_any": False, # Track if we yielded any real chunks
 
3850
  }
3851
 
3852
  async with client.stream(
 
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", True
1049
  )
1050
  self._gemini3_system_instruction = os.getenv(
1051
  "ANTIGRAVITY_GEMINI3_SYSTEM_INSTRUCTION", DEFAULT_GEMINI3_SYSTEM_INSTRUCTION
 
3162
  accumulator["text_content"] += text
3163
 
3164
  if has_func:
3165
+ # Get tool_schemas from accumulator for schema-aware parsing
3166
+ tool_schemas = accumulator.get("tool_schemas") if accumulator else None
3167
+ tool_call = self._extract_tool_call(
3168
+ part, model, tool_idx, accumulator, tool_schemas
3169
+ )
3170
 
3171
  # Store signature for each tool call (needed for parallel tool calls)
3172
  if has_sig:
 
3222
  self,
3223
  response: Dict[str, Any],
3224
  model: str,
3225
+ tool_schemas: Optional[Dict[str, Dict[str, Any]]] = None,
3226
  ) -> Dict[str, Any]:
3227
  """Convert Gemini response to OpenAI non-streaming format."""
3228
  candidates = response.get("candidates", [])
 
3259
  text_content += part["text"]
3260
 
3261
  if has_func:
3262
+ tool_call = self._extract_tool_call(
3263
+ part, model, len(tool_calls), tool_schemas=tool_schemas
3264
+ )
3265
 
3266
  # Store signature for each tool call (needed for parallel tool calls)
3267
  if has_sig:
 
3316
 
3317
  return result
3318
 
3319
+ def _build_tool_schema_map(
3320
+ self, tools: Optional[List[Dict[str, Any]]], model: str
3321
+ ) -> Dict[str, Dict[str, Any]]:
3322
+ """
3323
+ Build a mapping of tool name -> parameter schema from tools payload.
3324
+
3325
+ Used for schema-aware JSON string parsing to avoid corrupting
3326
+ string content that looks like JSON (e.g., write tool's content field).
3327
+ """
3328
+ if not tools:
3329
+ return {}
3330
+
3331
+ schema_map = {}
3332
+ for tool in tools:
3333
+ for func_decl in tool.get("functionDeclarations", []):
3334
+ name = func_decl.get("name", "")
3335
+ # Strip gemini3 prefix if applicable
3336
+ if self._is_gemini_3(model) and self._enable_gemini3_tool_fix:
3337
+ name = self._strip_gemini3_prefix(name)
3338
+ schema = func_decl.get("parametersJsonSchema", {})
3339
+ if name and schema:
3340
+ schema_map[name] = schema
3341
+
3342
+ return schema_map
3343
+
3344
  def _extract_tool_call(
3345
  self,
3346
  part: Dict[str, Any],
3347
  model: str,
3348
  index: int,
3349
  accumulator: Optional[Dict[str, Any]] = None,
3350
+ tool_schemas: Optional[Dict[str, Dict[str, Any]]] = None,
3351
  ) -> Dict[str, Any]:
3352
  """Extract and format a tool call from a response part."""
3353
  func_call = part["functionCall"]
 
3362
  raw_args = func_call.get("args", {})
3363
 
3364
  # Optionally parse JSON strings (handles escaped control chars, malformed JSON)
3365
+ # NOTE: Gemini 3 sometimes returns stringified arrays for array parameters
3366
+ # (e.g., batch, todowrite). Schema-aware parsing prevents corrupting string
3367
+ # content that looks like JSON (e.g., write tool's content field).
3368
  if self._enable_json_string_parsing:
3369
+ # Get schema for this tool if available
3370
+ tool_schema = tool_schemas.get(tool_name) if tool_schemas else None
3371
+ parsed_args = _recursively_parse_json_strings(
3372
+ raw_args, schema=tool_schema, parse_json_objects=True
3373
+ )
3374
  else:
3375
  parsed_args = raw_args
3376
 
 
3862
  file_logger.log_final_response(data)
3863
 
3864
  gemini_response = self._unwrap_response(data)
3865
+
3866
+ # Build tool schema map for schema-aware JSON parsing
3867
+ tool_schemas = self._build_tool_schema_map(payload.get("tools"), model)
3868
+ openai_response = self._gemini_to_openai_non_streaming(
3869
+ gemini_response, model, tool_schemas
3870
+ )
3871
 
3872
  return litellm.ModelResponse(**openai_response)
3873
 
 
3881
  file_logger: Optional[AntigravityFileLogger] = None,
3882
  ) -> AsyncGenerator[litellm.ModelResponse, None]:
3883
  """Handle streaming completion."""
3884
+ # Build tool schema map for schema-aware JSON parsing
3885
+ tool_schemas = self._build_tool_schema_map(payload.get("tools"), model)
3886
+
3887
  # Accumulator tracks state across chunks for caching and tool indexing
3888
  accumulator = {
3889
  "reasoning_content": "",
 
3894
  "is_complete": False, # Track if we received usageMetadata
3895
  "last_usage": None, # Track last received usage for final chunk
3896
  "yielded_any": False, # Track if we yielded any real chunks
3897
+ "tool_schemas": tool_schemas, # For schema-aware JSON string parsing
3898
  }
3899
 
3900
  async with client.stream(