Spaces:
Paused
Paused
Mirrowel commited on
Commit Β·
50ee93b
1
Parent(s): e4ee46f
fix(antigravity): π implement schema-aware JSON string parsing for tools
Browse filesThis 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",
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
| 3333 |
if self._enable_json_string_parsing:
|
| 3334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|