|
14 | 14 |
|
15 | 15 | from __future__ import annotations |
16 | 16 |
|
| 17 | +import ast |
17 | 18 | import base64 |
18 | 19 | import binascii |
19 | 20 | import copy |
|
98 | 99 | _EXCLUDED_PART_FIELD = {"inline_data": {"data"}} |
99 | 100 | _LITELLM_STRUCTURED_TYPES = {"json_object", "json_schema"} |
100 | 101 | _JSON_DECODER = json.JSONDecoder() |
| 102 | +_UNQUOTED_KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") |
101 | 103 |
|
102 | 104 | # Mapping of major MIME type prefixes to LiteLLM content types for URL blocks. |
103 | 105 | # Audio is handled separately as `input_audio` content blocks because LiteLLM |
|
122 | 124 | "content_filter": types.FinishReason.SAFETY, |
123 | 125 | } |
124 | 126 |
|
| 127 | + |
| 128 | +def _quote_unquoted_json_object_keys(value: str) -> str: |
| 129 | + """Quotes simple unquoted object keys without touching string contents.""" |
| 130 | + result = [] |
| 131 | + i = 0 |
| 132 | + in_string = False |
| 133 | + string_quote = "" |
| 134 | + escaped = False |
| 135 | + |
| 136 | + while i < len(value): |
| 137 | + char = value[i] |
| 138 | + if in_string: |
| 139 | + result.append(char) |
| 140 | + if escaped: |
| 141 | + escaped = False |
| 142 | + elif char == "\\": |
| 143 | + escaped = True |
| 144 | + elif char == string_quote: |
| 145 | + in_string = False |
| 146 | + string_quote = "" |
| 147 | + i += 1 |
| 148 | + continue |
| 149 | + |
| 150 | + if char in {'"', "'"}: |
| 151 | + in_string = True |
| 152 | + string_quote = char |
| 153 | + result.append(char) |
| 154 | + i += 1 |
| 155 | + continue |
| 156 | + |
| 157 | + if char in "{,": |
| 158 | + result.append(char) |
| 159 | + i += 1 |
| 160 | + whitespace_start = i |
| 161 | + while i < len(value) and value[i].isspace(): |
| 162 | + i += 1 |
| 163 | + result.append(value[whitespace_start:i]) |
| 164 | + |
| 165 | + key_match = _UNQUOTED_KEY_RE.match(value, i) |
| 166 | + if key_match: |
| 167 | + key_end = key_match.end() |
| 168 | + colon_index = key_end |
| 169 | + while colon_index < len(value) and value[colon_index].isspace(): |
| 170 | + colon_index += 1 |
| 171 | + if colon_index < len(value) and value[colon_index] == ":": |
| 172 | + result.append(f'"{key_match.group(0)}"') |
| 173 | + result.append(value[key_end:colon_index]) |
| 174 | + i = colon_index |
| 175 | + continue |
| 176 | + continue |
| 177 | + |
| 178 | + result.append(char) |
| 179 | + i += 1 |
| 180 | + |
| 181 | + return "".join(result) |
| 182 | + |
| 183 | + |
| 184 | +def _parse_tool_call_arguments(arguments: Any) -> Any: |
| 185 | + """Parses LiteLLM tool call arguments. |
| 186 | +
|
| 187 | + LiteLLM normally returns OpenAI-compatible tool call arguments as JSON |
| 188 | + strings, but some providers can stream a complete tool call whose finalized |
| 189 | + argument payload is a Python dict literal or has unquoted object keys. Keep |
| 190 | + strict JSON as the primary path, then repair only those complete |
| 191 | + object-literal shapes so ADK can still surface the intended function call. |
| 192 | + """ |
| 193 | + if not arguments: |
| 194 | + return {} |
| 195 | + if not isinstance(arguments, str): |
| 196 | + return arguments |
| 197 | + |
| 198 | + try: |
| 199 | + return json.loads(arguments) |
| 200 | + except json.JSONDecodeError as exc: |
| 201 | + json_error = exc |
| 202 | + |
| 203 | + try: |
| 204 | + return ast.literal_eval(arguments) |
| 205 | + except (SyntaxError, ValueError): |
| 206 | + pass |
| 207 | + |
| 208 | + repaired_arguments = _quote_unquoted_json_object_keys(arguments) |
| 209 | + if repaired_arguments != arguments: |
| 210 | + try: |
| 211 | + return json.loads(repaired_arguments) |
| 212 | + except json.JSONDecodeError: |
| 213 | + try: |
| 214 | + return ast.literal_eval(repaired_arguments) |
| 215 | + except (SyntaxError, ValueError): |
| 216 | + pass |
| 217 | + |
| 218 | + raise json_error |
| 219 | + |
| 220 | + |
125 | 221 | # File MIME types supported for upload as file content (not decoded as text). |
126 | 222 | # Note: text/* types are handled separately and decoded as text content. |
127 | 223 | # These types are uploaded as files to providers that support it. |
@@ -1727,7 +1823,7 @@ def _message_to_generate_content_response( |
1727 | 1823 | thought_signature = _extract_thought_signature_from_tool_call(tool_call) |
1728 | 1824 | part = types.Part.from_function_call( |
1729 | 1825 | name=tool_call.function.name, |
1730 | | - args=json.loads(tool_call.function.arguments or "{}"), |
| 1826 | + args=_parse_tool_call_arguments(tool_call.function.arguments), |
1731 | 1827 | ) |
1732 | 1828 | part.function_call.id = tool_call.id |
1733 | 1829 | if thought_signature: |
@@ -2281,7 +2377,7 @@ def _finalize_tool_call_response( |
2281 | 2377 | if func_data["id"]: |
2282 | 2378 | if finish_reason == "length": |
2283 | 2379 | try: |
2284 | | - json.loads(func_data["args"] or "{}") |
| 2380 | + _parse_tool_call_arguments(func_data["args"]) |
2285 | 2381 | except json.JSONDecodeError: |
2286 | 2382 | has_incomplete_tool_call_args = True |
2287 | 2383 | continue |
|
0 commit comments