Spaces:
Paused
feat(antigravity): ✨Gemini auto-unfuck: implement auto-recovery for malformed function calls
Browse filesThis commit introduces a comprehensive handling mechanism for `MALFORMED_FUNCTION_CALL` errors occasionally produced by Gemini models, allowing the system to recover from invalid JSON tool arguments.
- **Auto-Fix Logic**: Implements a heuristic JSON repair engine (`_analyze_json_error`) to locally correct common syntax errors like unquoted keys, single quotes, and trailing commas.
- **Retry Strategy**: Adds a retry loop that injects corrective error messages and schema hints back into the conversation if local auto-fix fails.
- **System Prompt**: Updates model instructions to explicitly emphasize strict JSON syntax requirements (double quotes).
- **Logging**: Adds dedicated logging methods to track malformed requests, retry payloads, and auto-fix results for debugging.
- **Configuration**: Introduces `MALFORMED_CALL_MAX_RETRIES` and `MALFORMED_CALL_RETRY_DELAY` settings.
|
@@ -55,6 +55,25 @@ if TYPE_CHECKING:
|
|
| 55 |
from ..usage_manager import UsageManager
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
# =============================================================================
|
| 59 |
# CONFIGURATION CONSTANTS
|
| 60 |
# =============================================================================
|
|
@@ -115,6 +134,12 @@ DEFAULT_MAX_OUTPUT_TOKENS = 64000
|
|
| 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 = {
|
| 120 |
"rev19-uic3-1p": "gemini-2.5-computer-use-preview-10-2025",
|
|
@@ -215,6 +240,10 @@ VIOLATION OF THESE RULES WILL CAUSE IMMEDIATE SYSTEM FAILURE.
|
|
| 215 |
d. For arrays, verify you're providing the correct item structure
|
| 216 |
e. Do NOT add parameters that don't exist in the schema
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
## COMMON FAILURE PATTERNS TO AVOID
|
| 219 |
|
| 220 |
- Using 'path' when schema says 'filePath' (or vice versa)
|
|
@@ -223,6 +252,9 @@ VIOLATION OF THESE RULES WILL CAUSE IMMEDIATE SYSTEM FAILURE.
|
|
| 223 |
- Omitting required nested fields in array items
|
| 224 |
- Adding 'additionalProperties' that the schema doesn't define
|
| 225 |
- Guessing parameter names from similar tools you know from training
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
## REMEMBER
|
| 228 |
Your training data about function calling is OUTDATED for this environment.
|
|
@@ -659,10 +691,34 @@ class AntigravityFileLogger:
|
|
| 659 |
"error.log", f"[{datetime.utcnow().isoformat()}] {error_message}"
|
| 660 |
)
|
| 661 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
def log_final_response(self, response: Dict[str, Any]) -> None:
|
| 663 |
"""Log the final response."""
|
| 664 |
self._write_json("final_response.json", response)
|
| 665 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
def _write_json(self, filename: str, data: Dict[str, Any]) -> None:
|
| 667 |
if not self.enabled or not self.log_dir:
|
| 668 |
return
|
|
@@ -2838,6 +2894,387 @@ class AntigravityProvider(
|
|
| 2838 |
return GEMINI3_TOOL_RENAMES_REVERSE.get(stripped, stripped)
|
| 2839 |
return name
|
| 2840 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2841 |
def _translate_tool_choice(
|
| 2842 |
self, tool_choice: Union[str, Dict[str, Any]], model: str = ""
|
| 2843 |
) -> Optional[Dict[str, Any]]:
|
|
@@ -3665,6 +4102,10 @@ class AntigravityProvider(
|
|
| 3665 |
)
|
| 3666 |
file_logger.log_request(payload)
|
| 3667 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3668 |
# Make API call
|
| 3669 |
base_url = self._get_base_url()
|
| 3670 |
endpoint = ":streamGenerateContent" if stream else ":generateContent"
|
|
@@ -3682,6 +4123,11 @@ class AntigravityProvider(
|
|
| 3682 |
**ANTIGRAVITY_HEADERS,
|
| 3683 |
}
|
| 3684 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3685 |
# URL fallback loop - handles HTTP errors (except 429) and network errors
|
| 3686 |
# by switching to fallback URLs. Empty response retry is handled separately
|
| 3687 |
# inside _streaming_with_retry (streaming) or the inner loop (non-streaming).
|
|
@@ -3696,9 +4142,16 @@ class AntigravityProvider(
|
|
| 3696 |
payload,
|
| 3697 |
model,
|
| 3698 |
file_logger,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3699 |
)
|
| 3700 |
else:
|
| 3701 |
-
# Non-streaming: empty response
|
| 3702 |
empty_error_msg = (
|
| 3703 |
"The model returned an empty response after multiple attempts. "
|
| 3704 |
"This may indicate a temporary service issue. Please try again."
|
|
@@ -3746,6 +4199,101 @@ class AntigravityProvider(
|
|
| 3746 |
|
| 3747 |
return result
|
| 3748 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3749 |
except httpx.HTTPStatusError as e:
|
| 3750 |
if e.response.status_code == 429:
|
| 3751 |
# Check if this is a bare 429 (no retry info) vs real quota exhaustion
|
|
@@ -3772,16 +4320,19 @@ class AntigravityProvider(
|
|
| 3772 |
)
|
| 3773 |
# Re-raise all HTTP errors (429 with retry info, or other errors)
|
| 3774 |
raise
|
| 3775 |
-
|
| 3776 |
-
|
| 3777 |
-
|
| 3778 |
-
|
| 3779 |
-
|
| 3780 |
-
|
| 3781 |
-
|
| 3782 |
-
|
| 3783 |
-
|
| 3784 |
-
|
|
|
|
|
|
|
|
|
|
| 3785 |
|
| 3786 |
except httpx.HTTPStatusError as e:
|
| 3787 |
# 429 = Rate limit/quota exhausted - tied to credential, not URL
|
|
@@ -3863,6 +4414,11 @@ class AntigravityProvider(
|
|
| 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(
|
|
@@ -3879,8 +4435,14 @@ class AntigravityProvider(
|
|
| 3879 |
payload: Dict[str, Any],
|
| 3880 |
model: str,
|
| 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 |
|
|
@@ -3895,6 +4457,8 @@ class AntigravityProvider(
|
|
| 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(
|
|
@@ -3919,7 +4483,12 @@ class AntigravityProvider(
|
|
| 3919 |
|
| 3920 |
async for line in response.aiter_lines():
|
| 3921 |
if file_logger:
|
| 3922 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3923 |
|
| 3924 |
if line.startswith("data: "):
|
| 3925 |
data_str = line[6:]
|
|
@@ -3929,6 +4498,18 @@ class AntigravityProvider(
|
|
| 3929 |
try:
|
| 3930 |
chunk = json.loads(data_str)
|
| 3931 |
gemini_chunk = self._unwrap_response(chunk)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3932 |
openai_chunk = self._gemini_to_openai_chunk(
|
| 3933 |
gemini_chunk, model, accumulator
|
| 3934 |
)
|
|
@@ -3940,6 +4521,13 @@ class AntigravityProvider(
|
|
| 3940 |
file_logger.log_error(f"Parse error: {data_str[:100]}")
|
| 3941 |
continue
|
| 3942 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3943 |
# Only emit synthetic final chunk if we actually received real data
|
| 3944 |
# If no data was received, the caller will detect zero chunks and retry
|
| 3945 |
if accumulator.get("yielded_any"):
|
|
@@ -3978,13 +4566,24 @@ class AntigravityProvider(
|
|
| 3978 |
payload: Dict[str, Any],
|
| 3979 |
model: str,
|
| 3980 |
file_logger: Optional[AntigravityFileLogger] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3981 |
) -> AsyncGenerator[litellm.ModelResponse, None]:
|
| 3982 |
"""
|
| 3983 |
-
Wrapper around _handle_streaming that retries on empty responses
|
|
|
|
| 3984 |
|
| 3985 |
If the stream yields zero chunks (Antigravity returned nothing) or encounters
|
| 3986 |
a bare 429 (no retry info), retry up to EMPTY_RESPONSE_MAX_ATTEMPTS times
|
| 3987 |
before giving up.
|
|
|
|
|
|
|
|
|
|
| 3988 |
"""
|
| 3989 |
empty_error_msg = (
|
| 3990 |
"The model returned an empty response after multiple attempts. "
|
|
@@ -3995,17 +4594,25 @@ class AntigravityProvider(
|
|
| 3995 |
"This may indicate a temporary service issue. Please try again."
|
| 3996 |
)
|
| 3997 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3998 |
for attempt in range(EMPTY_RESPONSE_MAX_ATTEMPTS):
|
| 3999 |
chunk_count = 0
|
| 4000 |
|
| 4001 |
try:
|
|
|
|
|
|
|
| 4002 |
async for chunk in self._handle_streaming(
|
| 4003 |
client,
|
| 4004 |
url,
|
| 4005 |
headers,
|
| 4006 |
-
|
| 4007 |
model,
|
| 4008 |
file_logger,
|
|
|
|
| 4009 |
):
|
| 4010 |
chunk_count += 1
|
| 4011 |
yield chunk # Stream immediately - true streaming preserved
|
|
@@ -4030,6 +4637,105 @@ class AntigravityProvider(
|
|
| 4030 |
message=empty_error_msg,
|
| 4031 |
)
|
| 4032 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4033 |
except httpx.HTTPStatusError as e:
|
| 4034 |
if e.response.status_code == 429:
|
| 4035 |
# Check if this is a bare 429 (no retry info) vs real quota exhaustion
|
|
|
|
| 55 |
from ..usage_manager import UsageManager
|
| 56 |
|
| 57 |
|
| 58 |
+
# =============================================================================
|
| 59 |
+
# INTERNAL EXCEPTIONS
|
| 60 |
+
# =============================================================================
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class _MalformedFunctionCallDetected(Exception):
|
| 64 |
+
"""
|
| 65 |
+
Internal exception raised when MALFORMED_FUNCTION_CALL is detected.
|
| 66 |
+
|
| 67 |
+
Signals the retry logic to inject corrective messages and retry.
|
| 68 |
+
Not intended to be raised to callers.
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
def __init__(self, finish_message: str, raw_response: Dict[str, Any]):
|
| 72 |
+
self.finish_message = finish_message
|
| 73 |
+
self.raw_response = raw_response
|
| 74 |
+
super().__init__(finish_message)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
# =============================================================================
|
| 78 |
# CONFIGURATION CONSTANTS
|
| 79 |
# =============================================================================
|
|
|
|
| 134 |
EMPTY_RESPONSE_MAX_ATTEMPTS = max(1, _env_int("ANTIGRAVITY_EMPTY_RESPONSE_ATTEMPTS", 6))
|
| 135 |
EMPTY_RESPONSE_RETRY_DELAY = _env_int("ANTIGRAVITY_EMPTY_RESPONSE_RETRY_DELAY", 3)
|
| 136 |
|
| 137 |
+
# Malformed function call retry configuration
|
| 138 |
+
# When Gemini 3 returns MALFORMED_FUNCTION_CALL (invalid JSON syntax in tool args),
|
| 139 |
+
# inject corrective messages and retry up to this many times
|
| 140 |
+
MALFORMED_CALL_MAX_RETRIES = max(1, _env_int("ANTIGRAVITY_MALFORMED_CALL_RETRIES", 2))
|
| 141 |
+
MALFORMED_CALL_RETRY_DELAY = _env_int("ANTIGRAVITY_MALFORMED_CALL_DELAY", 1)
|
| 142 |
+
|
| 143 |
# Model alias mappings (internal ↔ public)
|
| 144 |
MODEL_ALIAS_MAP = {
|
| 145 |
"rev19-uic3-1p": "gemini-2.5-computer-use-preview-10-2025",
|
|
|
|
| 240 |
d. For arrays, verify you're providing the correct item structure
|
| 241 |
e. Do NOT add parameters that don't exist in the schema
|
| 242 |
|
| 243 |
+
7. **JSON SYNTAX**: Function call arguments must be valid JSON.
|
| 244 |
+
- All keys MUST be double-quoted: {"key":"value"} not {key:"value"}
|
| 245 |
+
- Use double quotes for strings, not single quotes
|
| 246 |
+
|
| 247 |
## COMMON FAILURE PATTERNS TO AVOID
|
| 248 |
|
| 249 |
- Using 'path' when schema says 'filePath' (or vice versa)
|
|
|
|
| 252 |
- Omitting required nested fields in array items
|
| 253 |
- Adding 'additionalProperties' that the schema doesn't define
|
| 254 |
- Guessing parameter names from similar tools you know from training
|
| 255 |
+
- Using unquoted keys: {key:"value"} instead of {"key":"value"}
|
| 256 |
+
- Writing JSON as text in your response instead of making an actual function call
|
| 257 |
+
- Using single quotes instead of double quotes for strings
|
| 258 |
|
| 259 |
## REMEMBER
|
| 260 |
Your training data about function calling is OUTDATED for this environment.
|
|
|
|
| 691 |
"error.log", f"[{datetime.utcnow().isoformat()}] {error_message}"
|
| 692 |
)
|
| 693 |
|
| 694 |
+
def log_malformed_retry_request(
|
| 695 |
+
self, retry_num: int, payload: Dict[str, Any]
|
| 696 |
+
) -> None:
|
| 697 |
+
"""Log a malformed call retry request payload in the same folder."""
|
| 698 |
+
self._write_json(f"malformed_retry_{retry_num}_request.json", payload)
|
| 699 |
+
|
| 700 |
+
def log_malformed_retry_response(self, retry_num: int, chunk: str) -> None:
|
| 701 |
+
"""Append a chunk to the malformed retry response log."""
|
| 702 |
+
self._append_text(f"malformed_retry_{retry_num}_response.log", chunk)
|
| 703 |
+
|
| 704 |
def log_final_response(self, response: Dict[str, Any]) -> None:
|
| 705 |
"""Log the final response."""
|
| 706 |
self._write_json("final_response.json", response)
|
| 707 |
|
| 708 |
+
def log_malformed_autofix(
|
| 709 |
+
self, tool_name: str, raw_args: str, fixed_json: str
|
| 710 |
+
) -> None:
|
| 711 |
+
"""Log details of an auto-fixed malformed function call."""
|
| 712 |
+
self._write_json(
|
| 713 |
+
"malformed_autofix.json",
|
| 714 |
+
{
|
| 715 |
+
"tool_name": tool_name,
|
| 716 |
+
"raw_args": raw_args,
|
| 717 |
+
"fixed_json": fixed_json,
|
| 718 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 719 |
+
},
|
| 720 |
+
)
|
| 721 |
+
|
| 722 |
def _write_json(self, filename: str, data: Dict[str, Any]) -> None:
|
| 723 |
if not self.enabled or not self.log_dir:
|
| 724 |
return
|
|
|
|
| 2894 |
return GEMINI3_TOOL_RENAMES_REVERSE.get(stripped, stripped)
|
| 2895 |
return name
|
| 2896 |
|
| 2897 |
+
# =========================================================================
|
| 2898 |
+
# MALFORMED FUNCTION CALL HANDLING
|
| 2899 |
+
# =========================================================================
|
| 2900 |
+
|
| 2901 |
+
def _check_for_malformed_call(self, response: Dict[str, Any]) -> Optional[str]:
|
| 2902 |
+
"""
|
| 2903 |
+
Check if response contains MALFORMED_FUNCTION_CALL.
|
| 2904 |
+
|
| 2905 |
+
Returns finishMessage if malformed, None otherwise.
|
| 2906 |
+
"""
|
| 2907 |
+
candidates = response.get("candidates", [])
|
| 2908 |
+
if not candidates:
|
| 2909 |
+
return None
|
| 2910 |
+
|
| 2911 |
+
candidate = candidates[0]
|
| 2912 |
+
if candidate.get("finishReason") == "MALFORMED_FUNCTION_CALL":
|
| 2913 |
+
return candidate.get("finishMessage", "Unknown malformed call error")
|
| 2914 |
+
|
| 2915 |
+
return None
|
| 2916 |
+
|
| 2917 |
+
def _parse_malformed_call_message(
|
| 2918 |
+
self, finish_message: str, model: str
|
| 2919 |
+
) -> Optional[Dict[str, Any]]:
|
| 2920 |
+
"""
|
| 2921 |
+
Parse MALFORMED_FUNCTION_CALL finishMessage to extract tool info.
|
| 2922 |
+
|
| 2923 |
+
Input format: "Malformed function call: call:namespace:tool_name{raw_args}"
|
| 2924 |
+
|
| 2925 |
+
Returns:
|
| 2926 |
+
{"tool_name": "read", "prefixed_name": "gemini3_read",
|
| 2927 |
+
"raw_args": "{filePath: \"...\"}"}
|
| 2928 |
+
or None if unparseable
|
| 2929 |
+
"""
|
| 2930 |
+
import re
|
| 2931 |
+
|
| 2932 |
+
# Pattern: "Malformed function call: call:namespace:tool_name{args}"
|
| 2933 |
+
pattern = r"Malformed function call:\s*call:[^:]+:([^{]+)(\{.+\})$"
|
| 2934 |
+
match = re.match(pattern, finish_message, re.DOTALL)
|
| 2935 |
+
|
| 2936 |
+
if not match:
|
| 2937 |
+
lib_logger.warning(
|
| 2938 |
+
f"[Antigravity] Could not parse MALFORMED_FUNCTION_CALL: {finish_message[:100]}"
|
| 2939 |
+
)
|
| 2940 |
+
return None
|
| 2941 |
+
|
| 2942 |
+
prefixed_name = match.group(1).strip() # "gemini3_read"
|
| 2943 |
+
raw_args = match.group(2) # "{filePath: \"...\"}"
|
| 2944 |
+
|
| 2945 |
+
# Strip our prefix to get original tool name
|
| 2946 |
+
tool_name = self._strip_gemini3_prefix(prefixed_name)
|
| 2947 |
+
|
| 2948 |
+
return {
|
| 2949 |
+
"tool_name": tool_name,
|
| 2950 |
+
"prefixed_name": prefixed_name,
|
| 2951 |
+
"raw_args": raw_args,
|
| 2952 |
+
}
|
| 2953 |
+
|
| 2954 |
+
def _analyze_json_error(self, raw_args: str) -> Dict[str, Any]:
|
| 2955 |
+
"""
|
| 2956 |
+
Analyze malformed JSON to detect specific errors and attempt to fix it.
|
| 2957 |
+
|
| 2958 |
+
Combines json.JSONDecodeError with heuristic pattern detection
|
| 2959 |
+
to provide actionable error information.
|
| 2960 |
+
|
| 2961 |
+
Returns:
|
| 2962 |
+
{
|
| 2963 |
+
"json_error": str or None, # Python's JSON error message
|
| 2964 |
+
"json_position": int or None, # Position of error
|
| 2965 |
+
"issues": List[str], # Human-readable issues detected
|
| 2966 |
+
"unquoted_keys": List[str], # Specific unquoted key names
|
| 2967 |
+
"fixed_json": str or None, # Corrected JSON if we could fix it
|
| 2968 |
+
}
|
| 2969 |
+
"""
|
| 2970 |
+
import re as re_module
|
| 2971 |
+
|
| 2972 |
+
result = {
|
| 2973 |
+
"json_error": None,
|
| 2974 |
+
"json_position": None,
|
| 2975 |
+
"issues": [],
|
| 2976 |
+
"unquoted_keys": [],
|
| 2977 |
+
"fixed_json": None,
|
| 2978 |
+
}
|
| 2979 |
+
|
| 2980 |
+
# Option 1: Try json.loads to get exact error
|
| 2981 |
+
try:
|
| 2982 |
+
json.loads(raw_args)
|
| 2983 |
+
return result # Valid JSON, no errors
|
| 2984 |
+
except json.JSONDecodeError as e:
|
| 2985 |
+
result["json_error"] = e.msg
|
| 2986 |
+
result["json_position"] = e.pos
|
| 2987 |
+
|
| 2988 |
+
# Option 2: Heuristic pattern detection for specific issues
|
| 2989 |
+
# Detect unquoted keys: {word: or ,word:
|
| 2990 |
+
unquoted_key_pattern = r"[{,]\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:"
|
| 2991 |
+
unquoted_keys = re_module.findall(unquoted_key_pattern, raw_args)
|
| 2992 |
+
if unquoted_keys:
|
| 2993 |
+
result["unquoted_keys"] = unquoted_keys
|
| 2994 |
+
if len(unquoted_keys) == 1:
|
| 2995 |
+
result["issues"].append(f"Unquoted key: '{unquoted_keys[0]}'")
|
| 2996 |
+
else:
|
| 2997 |
+
result["issues"].append(
|
| 2998 |
+
f"Unquoted keys: {', '.join(repr(k) for k in unquoted_keys)}"
|
| 2999 |
+
)
|
| 3000 |
+
|
| 3001 |
+
# Detect single quotes
|
| 3002 |
+
if "'" in raw_args:
|
| 3003 |
+
result["issues"].append("Single quotes used instead of double quotes")
|
| 3004 |
+
|
| 3005 |
+
# Detect trailing comma
|
| 3006 |
+
if re_module.search(r",\s*[}\]]", raw_args):
|
| 3007 |
+
result["issues"].append("Trailing comma before closing bracket")
|
| 3008 |
+
|
| 3009 |
+
# Option 3: Try to fix the JSON and validate
|
| 3010 |
+
fixed = raw_args
|
| 3011 |
+
# Add quotes around unquoted keys
|
| 3012 |
+
fixed = re_module.sub(
|
| 3013 |
+
r"([{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:",
|
| 3014 |
+
r'\1"\2":',
|
| 3015 |
+
fixed,
|
| 3016 |
+
)
|
| 3017 |
+
# Replace single quotes with double quotes
|
| 3018 |
+
fixed = fixed.replace("'", '"')
|
| 3019 |
+
# Remove trailing commas
|
| 3020 |
+
fixed = re_module.sub(r",(\s*[}\]])", r"\1", fixed)
|
| 3021 |
+
|
| 3022 |
+
try:
|
| 3023 |
+
# Validate the fix works
|
| 3024 |
+
parsed = json.loads(fixed)
|
| 3025 |
+
# Use compact JSON format (matches what model should produce)
|
| 3026 |
+
result["fixed_json"] = json.dumps(parsed, separators=(",", ":"))
|
| 3027 |
+
except json.JSONDecodeError:
|
| 3028 |
+
# First fix didn't work - try more aggressive cleanup
|
| 3029 |
+
pass
|
| 3030 |
+
|
| 3031 |
+
# Option 4: If first attempt failed, try more aggressive fixes
|
| 3032 |
+
if result["fixed_json"] is None:
|
| 3033 |
+
try:
|
| 3034 |
+
# Normalize all whitespace (collapse newlines/multiple spaces)
|
| 3035 |
+
aggressive_fix = re_module.sub(r"\s+", " ", fixed)
|
| 3036 |
+
# Try parsing again
|
| 3037 |
+
parsed = json.loads(aggressive_fix)
|
| 3038 |
+
result["fixed_json"] = json.dumps(parsed, separators=(",", ":"))
|
| 3039 |
+
lib_logger.debug(
|
| 3040 |
+
"[Antigravity] Fixed malformed JSON with aggressive whitespace normalization"
|
| 3041 |
+
)
|
| 3042 |
+
except json.JSONDecodeError:
|
| 3043 |
+
pass
|
| 3044 |
+
|
| 3045 |
+
# Option 5: If still failing, try fixing unquoted string values
|
| 3046 |
+
if result["fixed_json"] is None:
|
| 3047 |
+
try:
|
| 3048 |
+
# Some models produce unquoted string values like {key: value}
|
| 3049 |
+
# Try to quote values that look like unquoted strings
|
| 3050 |
+
# Match : followed by unquoted word (not a number, bool, null, or object/array)
|
| 3051 |
+
aggressive_fix = re_module.sub(
|
| 3052 |
+
r":\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*([,}\]])",
|
| 3053 |
+
r': "\1"\2',
|
| 3054 |
+
fixed,
|
| 3055 |
+
)
|
| 3056 |
+
parsed = json.loads(aggressive_fix)
|
| 3057 |
+
result["fixed_json"] = json.dumps(parsed, separators=(",", ":"))
|
| 3058 |
+
lib_logger.debug(
|
| 3059 |
+
"[Antigravity] Fixed malformed JSON by quoting unquoted string values"
|
| 3060 |
+
)
|
| 3061 |
+
except json.JSONDecodeError:
|
| 3062 |
+
# All fixes failed, leave as None
|
| 3063 |
+
pass
|
| 3064 |
+
|
| 3065 |
+
return result
|
| 3066 |
+
|
| 3067 |
+
def _build_malformed_call_retry_messages(
|
| 3068 |
+
self,
|
| 3069 |
+
parsed_call: Dict[str, Any],
|
| 3070 |
+
tool_schema: Optional[Dict[str, Any]],
|
| 3071 |
+
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
| 3072 |
+
"""
|
| 3073 |
+
Build synthetic Gemini-format messages for malformed call retry.
|
| 3074 |
+
|
| 3075 |
+
Returns: (assistant_message, user_message) in Gemini format
|
| 3076 |
+
"""
|
| 3077 |
+
tool_name = parsed_call["tool_name"]
|
| 3078 |
+
raw_args = parsed_call["raw_args"]
|
| 3079 |
+
|
| 3080 |
+
# Analyze the JSON error and try to fix it
|
| 3081 |
+
error_info = self._analyze_json_error(raw_args)
|
| 3082 |
+
|
| 3083 |
+
# Assistant message: Show what it tried to do
|
| 3084 |
+
assistant_msg = {
|
| 3085 |
+
"role": "model",
|
| 3086 |
+
"parts": [{"text": f"I'll call the '{tool_name}' function."}],
|
| 3087 |
+
}
|
| 3088 |
+
|
| 3089 |
+
# Build a concise error message
|
| 3090 |
+
if error_info["fixed_json"]:
|
| 3091 |
+
# We successfully fixed the JSON - show the corrected version
|
| 3092 |
+
error_text = f"""[FUNCTION CALL ERROR - INVALID JSON]
|
| 3093 |
+
|
| 3094 |
+
Your call to '{tool_name}' failed. All JSON keys must be double-quoted.
|
| 3095 |
+
|
| 3096 |
+
INVALID: {raw_args}
|
| 3097 |
+
|
| 3098 |
+
CORRECTED: {error_info["fixed_json"]}
|
| 3099 |
+
|
| 3100 |
+
Retry the function call now using the corrected JSON above. Output ONLY the tool call, no text."""
|
| 3101 |
+
else:
|
| 3102 |
+
# Couldn't auto-fix - give hints
|
| 3103 |
+
error_text = f"""[FUNCTION CALL ERROR - INVALID JSON]
|
| 3104 |
+
|
| 3105 |
+
Your call to '{tool_name}' failed due to malformed JSON.
|
| 3106 |
+
|
| 3107 |
+
You provided: {raw_args}
|
| 3108 |
+
|
| 3109 |
+
Fix: All JSON keys must be double-quoted. Example: {{"key":"value"}} not {{key:"value"}}
|
| 3110 |
+
|
| 3111 |
+
Analyze what you did wrong, correct it, and retry the function call. Output ONLY the tool call, no text."""
|
| 3112 |
+
|
| 3113 |
+
# Add schema if available (strip $schema reference)
|
| 3114 |
+
if tool_schema:
|
| 3115 |
+
clean_schema = {k: v for k, v in tool_schema.items() if k != "$schema"}
|
| 3116 |
+
schema_str = json.dumps(clean_schema, separators=(",", ":"))
|
| 3117 |
+
error_text += f"\n\nSchema: {schema_str}"
|
| 3118 |
+
|
| 3119 |
+
user_msg = {"role": "user", "parts": [{"text": error_text}]}
|
| 3120 |
+
|
| 3121 |
+
return assistant_msg, user_msg
|
| 3122 |
+
|
| 3123 |
+
def _build_malformed_fallback_response(
|
| 3124 |
+
self, model: str, error_details: str
|
| 3125 |
+
) -> litellm.ModelResponse:
|
| 3126 |
+
"""
|
| 3127 |
+
Build error response when malformed call retries are exhausted.
|
| 3128 |
+
|
| 3129 |
+
Uses finish_reason=None to indicate the response didn't complete normally,
|
| 3130 |
+
allowing clients to detect the incomplete state and potentially retry.
|
| 3131 |
+
"""
|
| 3132 |
+
return litellm.ModelResponse(
|
| 3133 |
+
**{
|
| 3134 |
+
"id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
|
| 3135 |
+
"object": "chat.completion",
|
| 3136 |
+
"created": int(time.time()),
|
| 3137 |
+
"model": model,
|
| 3138 |
+
"choices": [
|
| 3139 |
+
{
|
| 3140 |
+
"index": 0,
|
| 3141 |
+
"message": {
|
| 3142 |
+
"role": "assistant",
|
| 3143 |
+
"content": (
|
| 3144 |
+
"[TOOL CALL ERROR] I attempted to call a function but "
|
| 3145 |
+
"repeatedly produced malformed syntax. This may be a model issue.\n\n"
|
| 3146 |
+
f"Last error: {error_details}\n\n"
|
| 3147 |
+
"Please try rephrasing your request or try a different approach."
|
| 3148 |
+
),
|
| 3149 |
+
},
|
| 3150 |
+
"finish_reason": None,
|
| 3151 |
+
}
|
| 3152 |
+
],
|
| 3153 |
+
}
|
| 3154 |
+
)
|
| 3155 |
+
|
| 3156 |
+
def _build_fixed_tool_call_response(
|
| 3157 |
+
self,
|
| 3158 |
+
model: str,
|
| 3159 |
+
parsed_call: Dict[str, Any],
|
| 3160 |
+
error_info: Dict[str, Any],
|
| 3161 |
+
) -> Optional[litellm.ModelResponse]:
|
| 3162 |
+
"""
|
| 3163 |
+
Build a synthetic valid tool call response from auto-fixed malformed JSON.
|
| 3164 |
+
|
| 3165 |
+
When Gemini 3 produces malformed JSON (e.g., unquoted keys), this method
|
| 3166 |
+
takes the auto-corrected JSON from _analyze_json_error() and builds a
|
| 3167 |
+
proper OpenAI-format tool call response.
|
| 3168 |
+
|
| 3169 |
+
Returns None if the JSON couldn't be fixed.
|
| 3170 |
+
"""
|
| 3171 |
+
fixed_json = error_info.get("fixed_json")
|
| 3172 |
+
if not fixed_json:
|
| 3173 |
+
return None
|
| 3174 |
+
|
| 3175 |
+
# Validate the fixed JSON is actually valid
|
| 3176 |
+
try:
|
| 3177 |
+
json.loads(fixed_json)
|
| 3178 |
+
except json.JSONDecodeError:
|
| 3179 |
+
return None
|
| 3180 |
+
|
| 3181 |
+
tool_name = parsed_call["tool_name"]
|
| 3182 |
+
tool_id = f"call_{uuid.uuid4().hex[:24]}"
|
| 3183 |
+
|
| 3184 |
+
return litellm.ModelResponse(
|
| 3185 |
+
**{
|
| 3186 |
+
"id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
|
| 3187 |
+
"object": "chat.completion",
|
| 3188 |
+
"created": int(time.time()),
|
| 3189 |
+
"model": model,
|
| 3190 |
+
"choices": [
|
| 3191 |
+
{
|
| 3192 |
+
"index": 0,
|
| 3193 |
+
"message": {
|
| 3194 |
+
"role": "assistant",
|
| 3195 |
+
"content": None,
|
| 3196 |
+
"tool_calls": [
|
| 3197 |
+
{
|
| 3198 |
+
"id": tool_id,
|
| 3199 |
+
"type": "function",
|
| 3200 |
+
"function": {
|
| 3201 |
+
"name": tool_name,
|
| 3202 |
+
"arguments": fixed_json,
|
| 3203 |
+
},
|
| 3204 |
+
}
|
| 3205 |
+
],
|
| 3206 |
+
},
|
| 3207 |
+
"finish_reason": "tool_calls",
|
| 3208 |
+
}
|
| 3209 |
+
],
|
| 3210 |
+
}
|
| 3211 |
+
)
|
| 3212 |
+
|
| 3213 |
+
def _build_fixed_tool_call_chunk(
|
| 3214 |
+
self,
|
| 3215 |
+
model: str,
|
| 3216 |
+
parsed_call: Dict[str, Any],
|
| 3217 |
+
error_info: Dict[str, Any],
|
| 3218 |
+
response_id: Optional[str] = None,
|
| 3219 |
+
) -> Optional[litellm.ModelResponse]:
|
| 3220 |
+
"""
|
| 3221 |
+
Build a streaming chunk with the auto-fixed tool call.
|
| 3222 |
+
|
| 3223 |
+
Similar to _build_fixed_tool_call_response but uses streaming format:
|
| 3224 |
+
- object: "chat.completion.chunk" instead of "chat.completion"
|
| 3225 |
+
- delta: {...} instead of message: {...}
|
| 3226 |
+
- tool_calls items include "index" field
|
| 3227 |
+
|
| 3228 |
+
Args:
|
| 3229 |
+
response_id: Optional original response ID to maintain stream continuity
|
| 3230 |
+
|
| 3231 |
+
Returns None if the JSON couldn't be fixed.
|
| 3232 |
+
"""
|
| 3233 |
+
fixed_json = error_info.get("fixed_json")
|
| 3234 |
+
if not fixed_json:
|
| 3235 |
+
return None
|
| 3236 |
+
|
| 3237 |
+
# Validate the fixed JSON is actually valid
|
| 3238 |
+
try:
|
| 3239 |
+
json.loads(fixed_json)
|
| 3240 |
+
except json.JSONDecodeError:
|
| 3241 |
+
return None
|
| 3242 |
+
|
| 3243 |
+
tool_name = parsed_call["tool_name"]
|
| 3244 |
+
tool_id = f"call_{uuid.uuid4().hex[:24]}"
|
| 3245 |
+
# Use original response ID if provided, otherwise generate new one
|
| 3246 |
+
chunk_id = response_id or f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
| 3247 |
+
|
| 3248 |
+
return litellm.ModelResponse(
|
| 3249 |
+
**{
|
| 3250 |
+
"id": chunk_id,
|
| 3251 |
+
"object": "chat.completion.chunk",
|
| 3252 |
+
"created": int(time.time()),
|
| 3253 |
+
"model": model,
|
| 3254 |
+
"choices": [
|
| 3255 |
+
{
|
| 3256 |
+
"index": 0,
|
| 3257 |
+
"delta": {
|
| 3258 |
+
"role": "assistant",
|
| 3259 |
+
"content": None,
|
| 3260 |
+
"tool_calls": [
|
| 3261 |
+
{
|
| 3262 |
+
"index": 0,
|
| 3263 |
+
"id": tool_id,
|
| 3264 |
+
"type": "function",
|
| 3265 |
+
"function": {
|
| 3266 |
+
"name": tool_name,
|
| 3267 |
+
"arguments": fixed_json,
|
| 3268 |
+
},
|
| 3269 |
+
}
|
| 3270 |
+
],
|
| 3271 |
+
},
|
| 3272 |
+
"finish_reason": "tool_calls",
|
| 3273 |
+
}
|
| 3274 |
+
],
|
| 3275 |
+
}
|
| 3276 |
+
)
|
| 3277 |
+
|
| 3278 |
def _translate_tool_choice(
|
| 3279 |
self, tool_choice: Union[str, Dict[str, Any]], model: str = ""
|
| 3280 |
) -> Optional[Dict[str, Any]]:
|
|
|
|
| 4102 |
)
|
| 4103 |
file_logger.log_request(payload)
|
| 4104 |
|
| 4105 |
+
# Pre-build tool schema map for malformed call handling
|
| 4106 |
+
# This maps original tool names (without prefix) to their schemas
|
| 4107 |
+
tool_schemas = self._build_tool_schema_map(gemini_payload.get("tools"), model)
|
| 4108 |
+
|
| 4109 |
# Make API call
|
| 4110 |
base_url = self._get_base_url()
|
| 4111 |
endpoint = ":streamGenerateContent" if stream else ":generateContent"
|
|
|
|
| 4123 |
**ANTIGRAVITY_HEADERS,
|
| 4124 |
}
|
| 4125 |
|
| 4126 |
+
# Track malformed call retries (separate from empty response retries)
|
| 4127 |
+
malformed_retry_count = 0
|
| 4128 |
+
# Keep a mutable reference to gemini_contents for retry injection
|
| 4129 |
+
current_gemini_contents = gemini_contents
|
| 4130 |
+
|
| 4131 |
# URL fallback loop - handles HTTP errors (except 429) and network errors
|
| 4132 |
# by switching to fallback URLs. Empty response retry is handled separately
|
| 4133 |
# inside _streaming_with_retry (streaming) or the inner loop (non-streaming).
|
|
|
|
| 4142 |
payload,
|
| 4143 |
model,
|
| 4144 |
file_logger,
|
| 4145 |
+
tool_schemas,
|
| 4146 |
+
current_gemini_contents,
|
| 4147 |
+
gemini_payload,
|
| 4148 |
+
project_id,
|
| 4149 |
+
max_tokens,
|
| 4150 |
+
reasoning_effort,
|
| 4151 |
+
tool_choice,
|
| 4152 |
)
|
| 4153 |
else:
|
| 4154 |
+
# Non-streaming: empty response, bare 429, and malformed call retry
|
| 4155 |
empty_error_msg = (
|
| 4156 |
"The model returned an empty response after multiple attempts. "
|
| 4157 |
"This may indicate a temporary service issue. Please try again."
|
|
|
|
| 4199 |
|
| 4200 |
return result
|
| 4201 |
|
| 4202 |
+
except _MalformedFunctionCallDetected as e:
|
| 4203 |
+
# Handle MALFORMED_FUNCTION_CALL - try auto-fix first
|
| 4204 |
+
parsed = self._parse_malformed_call_message(
|
| 4205 |
+
e.finish_message, model
|
| 4206 |
+
)
|
| 4207 |
+
|
| 4208 |
+
if parsed:
|
| 4209 |
+
# Try to auto-fix the malformed JSON
|
| 4210 |
+
error_info = self._analyze_json_error(
|
| 4211 |
+
parsed["raw_args"]
|
| 4212 |
+
)
|
| 4213 |
+
|
| 4214 |
+
if error_info.get("fixed_json"):
|
| 4215 |
+
# Auto-fix successful - build synthetic response
|
| 4216 |
+
lib_logger.info(
|
| 4217 |
+
f"[Antigravity] Auto-fixed malformed function call for "
|
| 4218 |
+
f"'{parsed['tool_name']}' from {model}"
|
| 4219 |
+
)
|
| 4220 |
+
|
| 4221 |
+
# Log the auto-fix details
|
| 4222 |
+
if file_logger:
|
| 4223 |
+
file_logger.log_malformed_autofix(
|
| 4224 |
+
parsed["tool_name"],
|
| 4225 |
+
parsed["raw_args"],
|
| 4226 |
+
error_info["fixed_json"],
|
| 4227 |
+
)
|
| 4228 |
+
|
| 4229 |
+
fixed_response = (
|
| 4230 |
+
self._build_fixed_tool_call_response(
|
| 4231 |
+
model, parsed, error_info
|
| 4232 |
+
)
|
| 4233 |
+
)
|
| 4234 |
+
if fixed_response:
|
| 4235 |
+
return fixed_response
|
| 4236 |
+
|
| 4237 |
+
# Auto-fix failed - retry by asking model to fix its JSON
|
| 4238 |
+
# Each retry response will also attempt auto-fix first
|
| 4239 |
+
if malformed_retry_count < MALFORMED_CALL_MAX_RETRIES:
|
| 4240 |
+
malformed_retry_count += 1
|
| 4241 |
+
lib_logger.warning(
|
| 4242 |
+
f"[Antigravity] MALFORMED_FUNCTION_CALL from {model}, "
|
| 4243 |
+
f"retry {malformed_retry_count}/{MALFORMED_CALL_MAX_RETRIES}: "
|
| 4244 |
+
f"{e.finish_message[:100]}..."
|
| 4245 |
+
)
|
| 4246 |
+
|
| 4247 |
+
if parsed:
|
| 4248 |
+
# Get schema for the failed tool
|
| 4249 |
+
tool_schema = tool_schemas.get(parsed["tool_name"])
|
| 4250 |
+
|
| 4251 |
+
# Build corrective messages
|
| 4252 |
+
assistant_msg, user_msg = (
|
| 4253 |
+
self._build_malformed_call_retry_messages(
|
| 4254 |
+
parsed, tool_schema
|
| 4255 |
+
)
|
| 4256 |
+
)
|
| 4257 |
+
|
| 4258 |
+
# Inject into conversation
|
| 4259 |
+
current_gemini_contents = list(
|
| 4260 |
+
current_gemini_contents
|
| 4261 |
+
)
|
| 4262 |
+
current_gemini_contents.append(assistant_msg)
|
| 4263 |
+
current_gemini_contents.append(user_msg)
|
| 4264 |
+
|
| 4265 |
+
# Rebuild payload with modified contents
|
| 4266 |
+
gemini_payload_copy = copy.deepcopy(gemini_payload)
|
| 4267 |
+
gemini_payload_copy["contents"] = (
|
| 4268 |
+
current_gemini_contents
|
| 4269 |
+
)
|
| 4270 |
+
payload = self._transform_to_antigravity_format(
|
| 4271 |
+
gemini_payload_copy,
|
| 4272 |
+
model,
|
| 4273 |
+
project_id,
|
| 4274 |
+
max_tokens,
|
| 4275 |
+
reasoning_effort,
|
| 4276 |
+
tool_choice,
|
| 4277 |
+
)
|
| 4278 |
+
|
| 4279 |
+
# Log the retry request in the same folder
|
| 4280 |
+
if file_logger:
|
| 4281 |
+
file_logger.log_malformed_retry_request(
|
| 4282 |
+
malformed_retry_count, payload
|
| 4283 |
+
)
|
| 4284 |
+
|
| 4285 |
+
await asyncio.sleep(MALFORMED_CALL_RETRY_DELAY)
|
| 4286 |
+
break # Break inner loop to retry with modified payload
|
| 4287 |
+
else:
|
| 4288 |
+
# Auto-fix failed and retries disabled/exceeded - return fallback
|
| 4289 |
+
lib_logger.warning(
|
| 4290 |
+
f"[Antigravity] MALFORMED_FUNCTION_CALL could not be auto-fixed "
|
| 4291 |
+
f"for {model}: {e.finish_message[:100]}..."
|
| 4292 |
+
)
|
| 4293 |
+
return self._build_malformed_fallback_response(
|
| 4294 |
+
model, e.finish_message
|
| 4295 |
+
)
|
| 4296 |
+
|
| 4297 |
except httpx.HTTPStatusError as e:
|
| 4298 |
if e.response.status_code == 429:
|
| 4299 |
# Check if this is a bare 429 (no retry info) vs real quota exhaustion
|
|
|
|
| 4320 |
)
|
| 4321 |
# Re-raise all HTTP errors (429 with retry info, or other errors)
|
| 4322 |
raise
|
| 4323 |
+
else:
|
| 4324 |
+
# For loop completed normally (no break) - should not happen
|
| 4325 |
+
# This means we exhausted EMPTY_RESPONSE_MAX_ATTEMPTS without success
|
| 4326 |
+
lib_logger.error(
|
| 4327 |
+
f"[Antigravity] Unexpected exit from retry loop for {model}"
|
| 4328 |
+
)
|
| 4329 |
+
raise EmptyResponseError(
|
| 4330 |
+
provider="antigravity",
|
| 4331 |
+
model=model,
|
| 4332 |
+
message=empty_error_msg,
|
| 4333 |
+
)
|
| 4334 |
+
# If we broke out of the for loop (malformed retry), continue while loop
|
| 4335 |
+
continue
|
| 4336 |
|
| 4337 |
except httpx.HTTPStatusError as e:
|
| 4338 |
# 429 = Rate limit/quota exhausted - tied to credential, not URL
|
|
|
|
| 4414 |
|
| 4415 |
gemini_response = self._unwrap_response(data)
|
| 4416 |
|
| 4417 |
+
# Check for MALFORMED_FUNCTION_CALL before conversion
|
| 4418 |
+
malformed_msg = self._check_for_malformed_call(gemini_response)
|
| 4419 |
+
if malformed_msg:
|
| 4420 |
+
raise _MalformedFunctionCallDetected(malformed_msg, gemini_response)
|
| 4421 |
+
|
| 4422 |
# Build tool schema map for schema-aware JSON parsing
|
| 4423 |
tool_schemas = self._build_tool_schema_map(payload.get("tools"), model)
|
| 4424 |
openai_response = self._gemini_to_openai_non_streaming(
|
|
|
|
| 4435 |
payload: Dict[str, Any],
|
| 4436 |
model: str,
|
| 4437 |
file_logger: Optional[AntigravityFileLogger] = None,
|
| 4438 |
+
malformed_retry_num: Optional[int] = None,
|
| 4439 |
) -> AsyncGenerator[litellm.ModelResponse, None]:
|
| 4440 |
+
"""Handle streaming completion.
|
| 4441 |
+
|
| 4442 |
+
Args:
|
| 4443 |
+
malformed_retry_num: If set, log response chunks to malformed_retry_N_response.log
|
| 4444 |
+
instead of the main response_stream.log
|
| 4445 |
+
"""
|
| 4446 |
# Build tool schema map for schema-aware JSON parsing
|
| 4447 |
tool_schemas = self._build_tool_schema_map(payload.get("tools"), model)
|
| 4448 |
|
|
|
|
| 4457 |
"last_usage": None, # Track last received usage for final chunk
|
| 4458 |
"yielded_any": False, # Track if we yielded any real chunks
|
| 4459 |
"tool_schemas": tool_schemas, # For schema-aware JSON string parsing
|
| 4460 |
+
"malformed_call": None, # Track MALFORMED_FUNCTION_CALL if detected
|
| 4461 |
+
"response_id": None, # Track original response ID for synthetic chunks
|
| 4462 |
}
|
| 4463 |
|
| 4464 |
async with client.stream(
|
|
|
|
| 4483 |
|
| 4484 |
async for line in response.aiter_lines():
|
| 4485 |
if file_logger:
|
| 4486 |
+
if malformed_retry_num is not None:
|
| 4487 |
+
file_logger.log_malformed_retry_response(
|
| 4488 |
+
malformed_retry_num, line
|
| 4489 |
+
)
|
| 4490 |
+
else:
|
| 4491 |
+
file_logger.log_response_chunk(line)
|
| 4492 |
|
| 4493 |
if line.startswith("data: "):
|
| 4494 |
data_str = line[6:]
|
|
|
|
| 4498 |
try:
|
| 4499 |
chunk = json.loads(data_str)
|
| 4500 |
gemini_chunk = self._unwrap_response(chunk)
|
| 4501 |
+
|
| 4502 |
+
# Capture response ID from first chunk for synthetic responses
|
| 4503 |
+
if not accumulator.get("response_id"):
|
| 4504 |
+
accumulator["response_id"] = gemini_chunk.get("responseId")
|
| 4505 |
+
|
| 4506 |
+
# Check for MALFORMED_FUNCTION_CALL
|
| 4507 |
+
malformed_msg = self._check_for_malformed_call(gemini_chunk)
|
| 4508 |
+
if malformed_msg:
|
| 4509 |
+
# Store for retry handler, don't yield anything more
|
| 4510 |
+
accumulator["malformed_call"] = malformed_msg
|
| 4511 |
+
break
|
| 4512 |
+
|
| 4513 |
openai_chunk = self._gemini_to_openai_chunk(
|
| 4514 |
gemini_chunk, model, accumulator
|
| 4515 |
)
|
|
|
|
| 4521 |
file_logger.log_error(f"Parse error: {data_str[:100]}")
|
| 4522 |
continue
|
| 4523 |
|
| 4524 |
+
# Check if we detected a malformed call - raise exception for retry handler
|
| 4525 |
+
if accumulator.get("malformed_call"):
|
| 4526 |
+
raise _MalformedFunctionCallDetected(
|
| 4527 |
+
accumulator["malformed_call"],
|
| 4528 |
+
{"accumulator": accumulator},
|
| 4529 |
+
)
|
| 4530 |
+
|
| 4531 |
# Only emit synthetic final chunk if we actually received real data
|
| 4532 |
# If no data was received, the caller will detect zero chunks and retry
|
| 4533 |
if accumulator.get("yielded_any"):
|
|
|
|
| 4566 |
payload: Dict[str, Any],
|
| 4567 |
model: str,
|
| 4568 |
file_logger: Optional[AntigravityFileLogger] = None,
|
| 4569 |
+
tool_schemas: Optional[Dict[str, Dict[str, Any]]] = None,
|
| 4570 |
+
gemini_contents: Optional[List[Dict[str, Any]]] = None,
|
| 4571 |
+
gemini_payload: Optional[Dict[str, Any]] = None,
|
| 4572 |
+
project_id: Optional[str] = None,
|
| 4573 |
+
max_tokens: Optional[int] = None,
|
| 4574 |
+
reasoning_effort: Optional[str] = None,
|
| 4575 |
+
tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
|
| 4576 |
) -> AsyncGenerator[litellm.ModelResponse, None]:
|
| 4577 |
"""
|
| 4578 |
+
Wrapper around _handle_streaming that retries on empty responses, bare 429s,
|
| 4579 |
+
and MALFORMED_FUNCTION_CALL errors.
|
| 4580 |
|
| 4581 |
If the stream yields zero chunks (Antigravity returned nothing) or encounters
|
| 4582 |
a bare 429 (no retry info), retry up to EMPTY_RESPONSE_MAX_ATTEMPTS times
|
| 4583 |
before giving up.
|
| 4584 |
+
|
| 4585 |
+
If MALFORMED_FUNCTION_CALL is detected, inject corrective messages and retry
|
| 4586 |
+
up to MALFORMED_CALL_MAX_RETRIES times.
|
| 4587 |
"""
|
| 4588 |
empty_error_msg = (
|
| 4589 |
"The model returned an empty response after multiple attempts. "
|
|
|
|
| 4594 |
"This may indicate a temporary service issue. Please try again."
|
| 4595 |
)
|
| 4596 |
|
| 4597 |
+
# Track malformed call retries (separate from empty response retries)
|
| 4598 |
+
malformed_retry_count = 0
|
| 4599 |
+
current_gemini_contents = gemini_contents
|
| 4600 |
+
current_payload = payload
|
| 4601 |
+
|
| 4602 |
for attempt in range(EMPTY_RESPONSE_MAX_ATTEMPTS):
|
| 4603 |
chunk_count = 0
|
| 4604 |
|
| 4605 |
try:
|
| 4606 |
+
# Pass malformed_retry_count to log response to separate file
|
| 4607 |
+
retry_num = malformed_retry_count if malformed_retry_count > 0 else None
|
| 4608 |
async for chunk in self._handle_streaming(
|
| 4609 |
client,
|
| 4610 |
url,
|
| 4611 |
headers,
|
| 4612 |
+
current_payload,
|
| 4613 |
model,
|
| 4614 |
file_logger,
|
| 4615 |
+
malformed_retry_num=retry_num,
|
| 4616 |
):
|
| 4617 |
chunk_count += 1
|
| 4618 |
yield chunk # Stream immediately - true streaming preserved
|
|
|
|
| 4637 |
message=empty_error_msg,
|
| 4638 |
)
|
| 4639 |
|
| 4640 |
+
except _MalformedFunctionCallDetected as e:
|
| 4641 |
+
# Handle MALFORMED_FUNCTION_CALL - try auto-fix first
|
| 4642 |
+
parsed = self._parse_malformed_call_message(e.finish_message, model)
|
| 4643 |
+
|
| 4644 |
+
if parsed:
|
| 4645 |
+
# Try to auto-fix the malformed JSON
|
| 4646 |
+
error_info = self._analyze_json_error(parsed["raw_args"])
|
| 4647 |
+
|
| 4648 |
+
if error_info.get("fixed_json"):
|
| 4649 |
+
# Auto-fix successful - build synthetic response
|
| 4650 |
+
lib_logger.info(
|
| 4651 |
+
f"[Antigravity] Auto-fixed malformed function call for "
|
| 4652 |
+
f"'{parsed['tool_name']}' from {model} (streaming)"
|
| 4653 |
+
)
|
| 4654 |
+
|
| 4655 |
+
# Log the auto-fix details
|
| 4656 |
+
if file_logger:
|
| 4657 |
+
file_logger.log_malformed_autofix(
|
| 4658 |
+
parsed["tool_name"],
|
| 4659 |
+
parsed["raw_args"],
|
| 4660 |
+
error_info["fixed_json"],
|
| 4661 |
+
)
|
| 4662 |
+
|
| 4663 |
+
# Extract response_id from accumulator in exception
|
| 4664 |
+
response_id = None
|
| 4665 |
+
if e.raw_response and isinstance(e.raw_response, dict):
|
| 4666 |
+
acc = e.raw_response.get("accumulator", {})
|
| 4667 |
+
response_id = acc.get("response_id")
|
| 4668 |
+
|
| 4669 |
+
# Use chunk format for streaming with original response ID
|
| 4670 |
+
fixed_chunk = self._build_fixed_tool_call_chunk(
|
| 4671 |
+
model, parsed, error_info, response_id=response_id
|
| 4672 |
+
)
|
| 4673 |
+
if fixed_chunk:
|
| 4674 |
+
yield fixed_chunk
|
| 4675 |
+
return
|
| 4676 |
+
|
| 4677 |
+
# Auto-fix failed - retry by asking model to fix its JSON
|
| 4678 |
+
# Each retry response will also attempt auto-fix first
|
| 4679 |
+
if malformed_retry_count < MALFORMED_CALL_MAX_RETRIES:
|
| 4680 |
+
malformed_retry_count += 1
|
| 4681 |
+
lib_logger.warning(
|
| 4682 |
+
f"[Antigravity] MALFORMED_FUNCTION_CALL from {model} (streaming), "
|
| 4683 |
+
f"retry {malformed_retry_count}/{MALFORMED_CALL_MAX_RETRIES}: "
|
| 4684 |
+
f"{e.finish_message[:100]}..."
|
| 4685 |
+
)
|
| 4686 |
+
|
| 4687 |
+
if parsed and gemini_payload is not None:
|
| 4688 |
+
# Get schema for the failed tool
|
| 4689 |
+
tool_schema = (
|
| 4690 |
+
tool_schemas.get(parsed["tool_name"])
|
| 4691 |
+
if tool_schemas
|
| 4692 |
+
else None
|
| 4693 |
+
)
|
| 4694 |
+
|
| 4695 |
+
# Build corrective messages
|
| 4696 |
+
assistant_msg, user_msg = (
|
| 4697 |
+
self._build_malformed_call_retry_messages(
|
| 4698 |
+
parsed, tool_schema
|
| 4699 |
+
)
|
| 4700 |
+
)
|
| 4701 |
+
|
| 4702 |
+
# Inject into conversation
|
| 4703 |
+
current_gemini_contents = list(current_gemini_contents or [])
|
| 4704 |
+
current_gemini_contents.append(assistant_msg)
|
| 4705 |
+
current_gemini_contents.append(user_msg)
|
| 4706 |
+
|
| 4707 |
+
# Rebuild payload with modified contents
|
| 4708 |
+
gemini_payload_copy = copy.deepcopy(gemini_payload)
|
| 4709 |
+
gemini_payload_copy["contents"] = current_gemini_contents
|
| 4710 |
+
current_payload = self._transform_to_antigravity_format(
|
| 4711 |
+
gemini_payload_copy,
|
| 4712 |
+
model,
|
| 4713 |
+
project_id or "",
|
| 4714 |
+
max_tokens,
|
| 4715 |
+
reasoning_effort,
|
| 4716 |
+
tool_choice,
|
| 4717 |
+
)
|
| 4718 |
+
|
| 4719 |
+
# Log the retry request in the same folder
|
| 4720 |
+
if file_logger:
|
| 4721 |
+
file_logger.log_malformed_retry_request(
|
| 4722 |
+
malformed_retry_count, current_payload
|
| 4723 |
+
)
|
| 4724 |
+
|
| 4725 |
+
await asyncio.sleep(MALFORMED_CALL_RETRY_DELAY)
|
| 4726 |
+
continue # Retry with modified payload
|
| 4727 |
+
else:
|
| 4728 |
+
# Auto-fix failed and retries disabled/exceeded - yield fallback response
|
| 4729 |
+
lib_logger.warning(
|
| 4730 |
+
f"[Antigravity] MALFORMED_FUNCTION_CALL could not be auto-fixed "
|
| 4731 |
+
f"for {model} (streaming): {e.finish_message[:100]}..."
|
| 4732 |
+
)
|
| 4733 |
+
fallback = self._build_malformed_fallback_response(
|
| 4734 |
+
model, e.finish_message
|
| 4735 |
+
)
|
| 4736 |
+
yield fallback
|
| 4737 |
+
return
|
| 4738 |
+
|
| 4739 |
except httpx.HTTPStatusError as e:
|
| 4740 |
if e.response.status_code == 429:
|
| 4741 |
# Check if this is a bare 429 (no retry info) vs real quota exhaustion
|