main
py 1,223 lines 42.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import ast
5 import json
6 import keyword as _python_keyword
7 import math
8 import warnings
9 from dataclasses import dataclass
10 from json import JSONDecodeError, JSONDecoder
11 from typing import Any, TypeAlias
12
13 import partial_json_parser
14 from openai.types.responses import (
15 FunctionTool,
16 NamespaceTool,
17 ToolChoiceFunction,
18 )
19 from openai.types.responses.tool import Tool as ResponsesTool
20 from partial_json_parser.core.options import Allow
21
22 from vllm.entrypoints.generate.base.protocol import (
23 DeltaFunctionCall,
24 DeltaToolCall,
25 FunctionCall,
26 ToolCall,
27 )
28 from vllm.entrypoints.openai.chat_completion.protocol import (
29 ChatCompletionNamedToolChoiceParam,
30 ChatCompletionToolsParam,
31 )
32 from vllm.logger import init_logger
33
34 Tool: TypeAlias = ChatCompletionToolsParam | ResponsesTool
35
36 logger = init_logger(__name__)
37
38
39 def safe_literal_eval(text: str):
40 with warnings.catch_warnings():
41 warnings.simplefilter("ignore", SyntaxWarning)
42 return ast.literal_eval(text)
43
44
45 def partial_tag_overlap(text: str, tag: str) -> int:
46 """Length of the longest prefix of *tag* that matches a suffix of *text*.
47
48 E.g. text ending in ``"<tool_"`` returns 6 when tag is ``"<tool_call>"``.
49 Returns 0 when there is no overlap.
50 """
51 max_check = min(len(tag) - 1, len(text))
52 for k in range(max_check, 0, -1):
53 if text.endswith(tag[:k]):
54 return k
55 return 0
56
57
58 def find_common_prefix(s1: str, s2: str) -> str:
59 """Finds a common prefix that is shared between two strings, if there is one.
60 Order of arguments is NOT important.
61
62 This function is provided as a UTILITY for extracting information from JSON
63 generated by partial_json_parser, to help in ensuring that the right tokens
64 are returned in streaming, so that close-quotes, close-brackets and
65 close-braces are not returned prematurely.
66
67 e.g. find_common_prefix('{"fruit": "ap"}', '{"fruit": "apple"}') ->
68 '{"fruit": "ap'
69 """
70 prefix = ""
71 min_length = min(len(s1), len(s2))
72 for i in range(0, min_length):
73 if s1[i] == s2[i]:
74 prefix += s1[i]
75 else:
76 break
77 return prefix
78
79
80 def find_common_suffix(s1: str, s2: str) -> str:
81 """Finds a common suffix shared between two strings, if there is one. Order of
82 arguments is NOT important.
83 Stops when the suffix ends OR it hits an alphanumeric character
84
85 e.g. find_common_suffix('{"fruit": "ap"}', '{"fruit": "apple"}') -> '"}'
86 """
87 suffix = ""
88 min_length = min(len(s1), len(s2))
89 for i in range(1, min_length + 1):
90 if s1[-i] == s2[-i] and not s1[-i].isalnum():
91 suffix = s1[-i] + suffix
92 else:
93 break
94 return suffix
95
96
97 def extract_intermediate_diff(curr: str, old: str) -> str:
98 """Given two strings, extract the difference in the middle between two strings
99 that are known to have a common prefix and/or suffix.
100
101 This function is provided as a UTILITY for extracting information from JSON
102 generated by partial_json_parser, to help in ensuring that the right tokens
103 are returned in streaming, so that close-quotes, close-brackets and
104 close-braces are not returned prematurely. The order of arguments IS
105 important - the new version of the partially-parsed JSON must be the first
106 argument, and the secnod argument must be from the previous generation.
107
108 What it returns, is tokens that should be streamed to the client.
109
110 e.g. extract_intermediate_diff('{"fruit": "apple"}', '{"fruit": "ap"}')
111 -> 'ple'
112
113 """
114 suffix = find_common_suffix(curr, old)
115
116 old = old[::-1].replace(suffix[::-1], "", 1)[::-1]
117 prefix = find_common_prefix(curr, old)
118 diff = curr
119 if len(suffix):
120 diff = diff[::-1].replace(suffix[::-1], "", 1)[::-1]
121
122 if len(prefix):
123 # replace the prefix only once in case it's mirrored
124 diff = diff.replace(prefix, "", 1)
125
126 return diff
127
128
129 # partial_json_parser doesn't support extra data and
130 # JSONDecoder.raw_decode doesn't support partial JSON
131 def partial_json_loads(input_str: str, flags: Allow) -> tuple[Any, int]:
132 try:
133 return (partial_json_parser.loads(input_str, flags), len(input_str))
134 except JSONDecodeError as e:
135 if "Extra data" in e.msg:
136 dec = JSONDecoder()
137 return dec.raw_decode(input_str)
138 raise
139
140
141 def is_complete_json(input_str: str) -> bool:
142 try:
143 json.loads(input_str)
144 return True
145 except JSONDecodeError:
146 return False
147
148
149 def _is_json_finite(obj: Any) -> bool:
150 """Whether *obj* can be serialized to valid JSON.
151
152 ``json.dumps(..., allow_nan=False)`` raises ``ValueError`` on any
153 non-finite float (``inf``/``-inf``/``nan``) anywhere in the value, so this
154 detects non-finite floats nested inside parsed lists/dicts too.
155 """
156 try:
157 json.dumps(obj, allow_nan=False)
158 return True
159 except (ValueError, TypeError):
160 return False
161
162
163 def consume_space(i: int, s: str) -> int:
164 while i < len(s) and s[i].isspace():
165 i += 1
166 return i
167
168
169 _NAMESPACE_TOOL_SEPARATOR = "__"
170
171
172 @dataclass(frozen=True)
173 class ResponsesToolCallName:
174 name: str
175 namespace: str | None = None
176
177
178 def flat_namespace_tool_name(namespace: str, name: str) -> str:
179 return f"{namespace}{_NAMESPACE_TOOL_SEPARATOR}{name}"
180
181
182 def iter_response_function_tool_info(
183 tool: ResponsesTool,
184 ) -> list[tuple[str, dict[str, Any] | None]]:
185 if isinstance(tool, FunctionTool):
186 return [(tool.name, tool.parameters)]
187 if not isinstance(tool, NamespaceTool):
188 return []
189
190 namespace = tool.name
191 return [
192 (
193 flat_namespace_tool_name(namespace, namespaced_tool.name),
194 namespaced_tool.parameters,
195 )
196 for namespaced_tool in tool.tools
197 if namespaced_tool.type == "function"
198 ]
199
200
201 def iter_response_function_tool_dicts(
202 tools: list[ResponsesTool],
203 ) -> list[dict[str, Any]]:
204 function_tools: list[dict[str, Any]] = []
205 for tool in tools:
206 if isinstance(tool, NamespaceTool):
207 namespace = tool.name
208 for namespaced_tool in tool.tools:
209 if namespaced_tool.type != "function":
210 continue
211 tool_dict = namespaced_tool.model_dump()
212 tool_dict["name"] = flat_namespace_tool_name(
213 namespace, namespaced_tool.name
214 )
215 function_tools.append(tool_dict)
216 elif isinstance(tool, FunctionTool):
217 function_tools.append(tool.model_dump())
218 return function_tools
219
220
221 def build_responses_tool_call_name_map(
222 tools: list[ResponsesTool] | None,
223 ) -> dict[str, ResponsesToolCallName]:
224 if not tools:
225 return {}
226
227 name_map: dict[str, ResponsesToolCallName] = {}
228 for tool in tools:
229 if not isinstance(tool, NamespaceTool):
230 continue
231 namespace = tool.name
232 for namespaced_tool in tool.tools:
233 if namespaced_tool.type != "function":
234 continue
235 flat_name = flat_namespace_tool_name(namespace, namespaced_tool.name)
236 name_map[flat_name] = ResponsesToolCallName(
237 name=namespaced_tool.name,
238 namespace=namespace,
239 )
240 return name_map
241
242
243 def resolve_responses_tool_call_name(
244 name: str,
245 tools: list[ResponsesTool] | None = None,
246 tool_call_name_map: dict[str, ResponsesToolCallName] | None = None,
247 ) -> ResponsesToolCallName:
248 name_map = tool_call_name_map
249 if name_map is None:
250 name_map = build_responses_tool_call_name_map(tools)
251 return name_map.get(name, ResponsesToolCallName(name=name))
252
253
254 def _is_function_tool(tool: Tool) -> bool:
255 return isinstance(tool, (FunctionTool, ChatCompletionToolsParam))
256
257
258 def _extract_tool_info(
259 tool: Tool,
260 ) -> tuple[str, dict[str, Any] | None]:
261 if isinstance(tool, FunctionTool):
262 return tool.name, tool.parameters
263 elif isinstance(tool, ChatCompletionToolsParam):
264 return tool.function.name, tool.function.parameters
265 else:
266 raise TypeError(f"Unsupported tool type: {type(tool)}")
267
268
269 def find_tool_properties(
270 tools: list[Tool] | None,
271 tool_name: str,
272 ) -> dict[str, Any]:
273 """Find a tool by name and return its properties dict, or {}."""
274 if not tools:
275 return {}
276 for tool in tools:
277 if isinstance(tool, (FunctionTool, NamespaceTool)):
278 for name, params in iter_response_function_tool_info(tool):
279 if name == tool_name:
280 return (params or {}).get("properties", {})
281 continue
282 if not _is_function_tool(tool):
283 continue
284 name, params = _extract_tool_info(tool)
285 if name == tool_name:
286 return (params or {}).get("properties", {})
287 return {}
288
289
290 def find_tool_name(
291 tools: list[Tool] | None,
292 tool_name: str,
293 ) -> bool:
294 """Return whether a function tool with *tool_name* exists."""
295 if not tools:
296 return False
297 for tool in tools:
298 if isinstance(tool, (FunctionTool, NamespaceTool)):
299 for name, _ in iter_response_function_tool_info(tool):
300 if name == tool_name:
301 return True
302 continue
303 if not _is_function_tool(tool):
304 continue
305 name, _ = _extract_tool_info(tool)
306 if name == tool_name:
307 return True
308 return False
309
310
311 def _params_or_empty_object(params: dict[str, Any] | None) -> dict[str, Any]:
312 # Empty/missing parameters still constrain arguments to a JSON object.
313 return params if params else {"type": "object", "properties": {}}
314
315
316 def _get_tool_schema_from_name_and_params(
317 name: str, params: dict[str, Any] | None
318 ) -> dict:
319 return {
320 "properties": {
321 "name": {"type": "string", "enum": [name]},
322 "parameters": _params_or_empty_object(params),
323 },
324 "required": ["name", "parameters"],
325 }
326
327
328 def _get_tool_schema_from_tool(tool: Tool) -> dict:
329 name, params = _extract_tool_info(tool)
330 return _get_tool_schema_from_name_and_params(name, params)
331
332
333 def _get_tool_schema_defs(
334 tools: list[Tool],
335 ) -> dict:
336 all_defs: dict[str, dict[str, Any]] = {}
337 for tool in tools:
338 _, params = _extract_tool_info(tool)
339 if params is None:
340 continue
341 defs = params.pop("$defs", {})
342 for def_name, def_schema in defs.items():
343 if def_name in all_defs and all_defs[def_name] != def_schema:
344 raise ValueError(
345 f"Tool definition '{def_name}' has multiple schemas, "
346 "which is not supported."
347 )
348 all_defs[def_name] = def_schema
349 return all_defs
350
351
352 def _get_json_schema_from_tools(
353 tools: list[Tool],
354 parallel_tool_calls: bool | None = None,
355 ) -> dict:
356 fn_tool_schemas: list[dict[str, Any]] = []
357 fn_tools: list[Tool] = []
358 for tool in tools:
359 if isinstance(tool, (FunctionTool, NamespaceTool)):
360 fn_tool_schemas.extend(
361 _get_tool_schema_from_name_and_params(name, params)
362 for name, params in iter_response_function_tool_info(tool)
363 )
364 if isinstance(tool, FunctionTool):
365 fn_tools.append(tool)
366 elif _is_function_tool(tool):
367 fn_tool_schemas.append(_get_tool_schema_from_tool(tool))
368 fn_tools.append(tool)
369 json_schema: dict[str, Any] = {
370 "type": "array",
371 "minItems": 1,
372 "items": {
373 "type": "object",
374 "anyOf": fn_tool_schemas,
375 },
376 }
377 # Only an explicit `false` opts out of parallel tool calls; `None` means the
378 # field was never set and the default (true) applies.
379 if parallel_tool_calls is False:
380 json_schema["maxItems"] = 1
381 json_schema_defs = _get_tool_schema_defs(fn_tools)
382 if json_schema_defs:
383 json_schema["$defs"] = json_schema_defs
384 return json_schema
385
386
387 def get_json_schema_from_tools(
388 tool_choice: str | ToolChoiceFunction | ChatCompletionNamedToolChoiceParam,
389 tools: list[Tool] | None,
390 parallel_tool_calls: bool | None = None,
391 ) -> str | dict | None:
392 # tool_choice: "none"
393 if tool_choice in ("none", None) or tools is None:
394 return None
395 # tool_choice: Forced Function (Responses)
396 if (not isinstance(tool_choice, str)) and isinstance(
397 tool_choice, ToolChoiceFunction
398 ):
399 tool_name = tool_choice.name
400 responses_tool_map: dict[str, dict[str, Any] | None] = {}
401 for tool in tools:
402 if not isinstance(tool, (FunctionTool, NamespaceTool)):
403 continue
404 for name, params in iter_response_function_tool_info(tool):
405 responses_tool_map[name] = params
406 if "__" in name:
407 responses_tool_map.setdefault(name.rsplit("__", 1)[1], params)
408 if tool_name not in responses_tool_map:
409 raise ValueError(f"Tool '{tool_name}' has not been passed in `tools`.")
410 return _params_or_empty_object(responses_tool_map[tool_name])
411 # tool_choice: Forced Function (ChatCompletion)
412 if (not isinstance(tool_choice, str)) and isinstance(
413 tool_choice, ChatCompletionNamedToolChoiceParam
414 ):
415 tool_name = tool_choice.function.name
416 chat_tool_map: dict[str, ChatCompletionToolsParam] = {
417 tool.function.name: tool
418 for tool in tools
419 if isinstance(tool, ChatCompletionToolsParam)
420 }
421 if tool_name not in chat_tool_map:
422 raise ValueError(f"Tool '{tool_name}' has not been passed in `tools`.")
423 return _params_or_empty_object(chat_tool_map[tool_name].function.parameters)
424 # tool_choice: "required"
425 if tool_choice == "required":
426 return _get_json_schema_from_tools(tools, parallel_tool_calls)
427 # tool_choice: "auto"
428 return None
429
430
431 # ---------------------------------------------------------------------------
432 # Shared utilities for pythonic-style tool call parsers
433 # (PythonicToolParser, Llama4PythonicToolParser, Olmo3PythonicToolParser)
434 # ---------------------------------------------------------------------------
435
436
437 class UnexpectedAstError(Exception):
438 """Raised when the AST structure does not match the expected
439 pythonic tool call format."""
440
441 pass
442
443
444 _JSON_NAME_LITERALS = {
445 "null": None,
446 "true": True,
447 "false": False,
448 }
449
450
451 def get_parameter_value(val: ast.expr) -> Any:
452 """Extract a Python literal value from an AST expression node.
453
454 Handles constants, dicts, lists, and JSON-style name literals
455 (null, true, false) that some models produce instead of Python
456 literals (None, True, False).
457
458 Raises:
459 UnexpectedAstError: If the AST node is not a supported literal type.
460
461 """
462 if isinstance(val, ast.Constant):
463 if val.value is None or isinstance(val.value, (str, int, float)):
464 return val.value
465 # bytes/Ellipsis/complex constants have no JSON representation and
466 # would otherwise surface as a TypeError deep inside json.dumps;
467 # reject them explicitly like other unsupported nodes.
468 logger.warning(
469 "Non-JSON-representable constant in tool call arguments: %s",
470 ast.dump(val),
471 )
472 raise UnexpectedAstError("Tool call arguments must be JSON values")
473 elif isinstance(val, ast.Dict):
474 if not all(isinstance(k, ast.Constant) for k in val.keys):
475 logger.warning(
476 "Dict argument keys are not all literals: %s",
477 ast.dump(val),
478 )
479 raise UnexpectedAstError("Dict tool call arguments must have literal keys")
480 return {
481 k.value: get_parameter_value(v) # type: ignore
482 for k, v in zip(val.keys, val.values)
483 }
484 elif isinstance(val, ast.List):
485 return [get_parameter_value(v) for v in val.elts]
486 elif isinstance(val, ast.Tuple):
487 # JSON has no tuple type; a tuple argument (e.g. ``size=(800, 600)``)
488 # is treated as a list so it round-trips through ``json.dumps``.
489 # Without this the whole call is dropped.
490 return [get_parameter_value(v) for v in val.elts]
491 elif isinstance(val, ast.Set):
492 # JSON has no set type either; a set argument (e.g.
493 # ``tags={'a', 'b'}``) is treated as a list, preserving source order.
494 return [get_parameter_value(v) for v in val.elts]
495 elif isinstance(val, ast.JoinedStr) and all(
496 isinstance(part, ast.Constant) for part in val.values
497 ):
498 # An f-string without placeholders (``f'hello'``) is a plain string
499 # constant, but Python parses it as JoinedStr rather than Constant;
500 # without this branch the whole call is dropped. F-strings with real
501 # placeholders still fall through to the raise below.
502 return "".join(
503 str(part.value) # type: ignore
504 for part in val.values
505 )
506 elif isinstance(val, ast.Name) and val.id in _JSON_NAME_LITERALS:
507 return _JSON_NAME_LITERALS[val.id]
508 elif isinstance(val, ast.UnaryOp) and isinstance(val.op, (ast.USub, ast.UAdd)):
509 # A negative (or explicitly positive) number is parsed by Python as a
510 # unary operation over a numeric constant, e.g. ``-1`` becomes
511 # ``UnaryOp(USub, Constant(1))`` rather than a plain ``Constant(-1)``.
512 # These are extremely common tool arguments (negative longitudes,
513 # offsets, deltas); without this branch the whole call is dropped.
514 # Restrict to numeric operands so ``not``/``~`` and other expressions
515 # still raise below.
516 operand = get_parameter_value(val.operand)
517 if isinstance(operand, (int, float)) and not isinstance(operand, bool):
518 return -operand if isinstance(val.op, ast.USub) else operand
519 logger.warning(
520 "Unsupported unary operand in tool call arguments: %s",
521 ast.dump(val),
522 )
523 raise UnexpectedAstError("Tool call arguments must be literals")
524 else:
525 logger.warning(
526 "Unsupported AST node type in tool call arguments: %s",
527 ast.dump(val),
528 )
529 raise UnexpectedAstError("Tool call arguments must be literals")
530
531
532 def _ast_callable_dotted_name(node: ast.expr) -> str:
533 """Return the dotted name for a call target, walking ``ast.Attribute``
534 chains so ``a.b.c(...)`` becomes ``"a.b.c"``.
535
536 Raises:
537 UnexpectedAstError: If the chain does not bottom out in an
538 ``ast.Name`` (e.g. subscript or call expression as receiver).
539
540 """
541 parts: list[str] = []
542 current: ast.expr = node
543 while isinstance(current, ast.Attribute):
544 parts.append(current.attr)
545 current = current.value
546 if not isinstance(current, ast.Name):
547 raise UnexpectedAstError("Invalid tool call name")
548 parts.append(current.id)
549 return ".".join(reversed(parts))
550
551
552 def handle_single_tool(call: ast.Call) -> ToolCall:
553 """Convert a single AST function call node into a ToolCall object.
554
555 Accepts both bare names (``foo(...)``) and dotted attribute chains
556 (``a.b.c(...)``); the resulting tool call ``name`` field preserves the
557 dotted form.
558
559 Raises:
560 UnexpectedAstError: If the call target is neither a simple name
561 nor a chain of attribute accesses bottoming out in a name.
562
563 """
564 if not isinstance(call.func, (ast.Name, ast.Attribute)):
565 logger.warning(
566 "Tool call has non-simple function name: %s",
567 ast.dump(call.func),
568 )
569 raise UnexpectedAstError("Invalid tool call name")
570 function_name = _ast_callable_dotted_name(call.func)
571 arguments = {}
572 for keyword in call.keywords:
573 arguments[keyword.arg] = get_parameter_value(keyword.value)
574 return ToolCall(
575 type="function",
576 function=FunctionCall(
577 name=function_name,
578 arguments=json.dumps(arguments, ensure_ascii=False),
579 ),
580 )
581
582
583 def escape_ctrl_chars_in_strings(text: str) -> str:
584 """Escape literal control chars inside string literals of pythonic text.
585
586 Models emitting pythonic tool calls frequently place raw newlines inside a
587 string argument (e.g. ``exec(command='line1\\nline2')`` written with a real
588 line break). That is invalid Python — ``ast.parse`` fails with "unterminated
589 string literal" — so the call would be dropped even though the intent is
590 unambiguous. A NUL byte is worse: ``ast.parse`` rejects it anywhere in the
591 source with ``ValueError``. Escaping ``\\n``/``\\r``/``\\t``/``\\x00`` only
592 *inside* string literals makes the text parseable while preserving the
593 argument value exactly (the escape sequences evaluate back to the original
594 control chars).
595
596 Text outside string literals is returned unchanged.
597 """
598 out: list[str] = []
599 quote: str | None = None
600 index, length = 0, len(text)
601 while index < length:
602 char = text[index]
603 if quote is None:
604 if char in {"'", '"'}:
605 quote = char
606 out.append(char)
607 elif char == "\\" and index + 1 < length:
608 out.append(char)
609 out.append(text[index + 1])
610 index += 2
611 continue
612 elif char == quote:
613 quote = None
614 out.append(char)
615 elif char == "\n":
616 out.append("\\n")
617 elif char == "\r":
618 out.append("\\r")
619 elif char == "\t":
620 out.append("\\t")
621 elif char == "\x00":
622 out.append("\\x00")
623 else:
624 out.append(char)
625 index += 1
626 return "".join(out)
627
628
629 _RESERVED_KW_SUFFIX = "_pyreservedkw_"
630
631
632 def rename_reserved_kwargs(text: str) -> tuple[str, bool]:
633 """Rename Python-keyword parameter names so pythonic tool text parses.
634
635 Tools legitimately name parameters ``from``, ``in``, ``class`` — but
636 ``memory_get(from=1)`` is a Python ``SyntaxError``, so the whole call
637 would be dropped. Rename ``from=`` to ``from_pyreservedkw_=`` (outside
638 string literals only, and only in keyword-argument position: preceded by
639 ``(`` or ``,`` and followed by a single ``=``), parse, then restore the
640 original name with :func:`restore_reserved_kwarg_names`.
641
642 Returns (rewritten_text, changed). Keyword *values* (``x=True``) and
643 keywords inside string arguments are never touched.
644 """
645 out: list[str] = []
646 quote: str | None = None
647 changed = False
648 last_sig = ""
649 index, length = 0, len(text)
650 while index < length:
651 char = text[index]
652 if quote is not None:
653 out.append(char)
654 if char == "\\" and index + 1 < length:
655 out.append(text[index + 1])
656 index += 2
657 continue
658 if char == quote:
659 quote = None
660 index += 1
661 continue
662 if char in {"'", '"'}:
663 quote = char
664 out.append(char)
665 last_sig = char
666 index += 1
667 continue
668 if char.isalpha() or char == "_":
669 end = index
670 while end < length and (text[end].isalnum() or text[end] == "_"):
671 end += 1
672 name = text[index:end]
673 look = end
674 while look < length and text[look].isspace():
675 look += 1
676 if (
677 _python_keyword.iskeyword(name)
678 and look < length
679 and text[look] == "="
680 and (look + 1 >= length or text[look + 1] != "=")
681 and last_sig in {"(", ","}
682 ):
683 out.append(name + _RESERVED_KW_SUFFIX)
684 changed = True
685 else:
686 out.append(name)
687 last_sig = name[-1]
688 index = end
689 continue
690 out.append(char)
691 if not char.isspace():
692 last_sig = char
693 index += 1
694 return "".join(out), changed
695
696
697 def restore_reserved_kwarg_names(arguments: dict) -> dict:
698 """Undo :func:`rename_reserved_kwargs` on a decoded arguments dict.
699
700 Only keys that carry the rename suffix *and* whose stem is a Python
701 keyword are restored, making this an exact inverse of the rename.
702 """
703 restored = {}
704 for key, value in arguments.items():
705 if (
706 isinstance(key, str)
707 and key.endswith(_RESERVED_KW_SUFFIX)
708 and _python_keyword.iskeyword(key[: -len(_RESERVED_KW_SUFFIX)])
709 ):
710 restored[key[: -len(_RESERVED_KW_SUFFIX)]] = value
711 else:
712 restored[key] = value
713 return restored
714
715
716 def normalize_leading_zero_ints(text: str) -> str:
717 """Strip leading zeros from decimal integer literals so the text parses.
718
719 Models emit zero-padded integers (``month=07``), which Python rejects
720 ("leading zeros in decimal integer literals are not permitted"), so the
721 whole call would be dropped. Rewrite ``07`` to ``7`` outside string
722 literals only. Tokens that are already valid Python are left alone:
723 all-zero literals (``00``), floats and fractional parts (``07.5``,
724 ``1.07``), exponents (``1e07``, consumed as a name run), and ``0x``/
725 ``0o``/``0b`` prefixes (the digit run stops at the prefix letter).
726 """
727 out: list[str] = []
728 quote: str | None = None
729 index, length = 0, len(text)
730 while index < length:
731 char = text[index]
732 if quote is not None:
733 out.append(char)
734 if char == "\\" and index + 1 < length:
735 out.append(text[index + 1])
736 index += 2
737 continue
738 if char == quote:
739 quote = None
740 index += 1
741 continue
742 if char in {"'", '"'}:
743 quote = char
744 out.append(char)
745 index += 1
746 continue
747 if char.isalpha() or char == "_":
748 end = index
749 while end < length and (text[end].isalnum() or text[end] == "_"):
750 end += 1
751 out.append(text[index:end])
752 index = end
753 continue
754 if char.isdigit():
755 end = index
756 while end < length and (text[end].isdigit() or text[end] == "_"):
757 end += 1
758 token = text[index:end]
759 digits = token.replace("_", "")
760 follower = text[end] if end < length else ""
761 preceded_by_dot = index > 0 and text[index - 1] == "."
762 if (
763 digits[0] == "0"
764 and digits.strip("0")
765 and not preceded_by_dot
766 and follower not in {".", "e", "E", "j", "J"}
767 ):
768 out.append(str(int(digits)))
769 else:
770 out.append(token)
771 index = end
772 continue
773 out.append(char)
774 index += 1
775 return "".join(out)
776
777
778 _QUOTE_FOLLOWERS = {",", ")", "]", "}", ":"}
779
780
781 def escape_nested_quotes_in_strings(text: str) -> tuple[str, bool]:
782 """Close a broken string literal at the only closing quote that works.
783
784 Models emitting shell commands frequently nest unescaped same-style
785 quotes inside a string argument — ``command='sed -n '360,450p' f.py'``,
786 or a quoted ``python3 -c`` payload that itself contains quoted strings —
787 which Python reads as juxtaposed garbage, so the call is dropped even
788 though the intent is unambiguous. A string is treated as broken when
789 its first unescaped quote cannot syntactically close it (what follows
790 is none of ``,``, ``)``, ``]``, ``}``, ``:``). For a broken string,
791 every syntactically plausible closing quote is tried: interior quotes
792 escaped, the rest of the text kept verbatim, and the result (with
793 control chars escaped) validated with ``ast.parse``. Exactly one
794 candidate parsing means recovery — the decoded value is exactly the
795 text the model wrote. Zero or several parsing candidates means the
796 nesting is genuinely ambiguous and the text is returned unchanged
797 rather than guessed at.
798
799 Returns (rewritten_text, changed); run the result through
800 escape_ctrl_chars_in_strings before parsing — quotes chosen here can
801 move raw control chars inside the string.
802 """
803
804 def unescaped_quotes(start: int, quote: str) -> list[int]:
805 positions = []
806 j = start
807 while j < len(text):
808 if text[j] == "\\":
809 j += 2
810 continue
811 if text[j] == quote:
812 positions.append(j)
813 j += 1
814 return positions
815
816 def is_closer(pos: int) -> bool:
817 k = pos + 1
818 while k < len(text) and text[k].isspace():
819 k += 1
820 return k < len(text) and text[k] in _QUOTE_FOLLOWERS
821
822 prefix: list[str] = []
823 index = 0
824 while index < len(text):
825 char = text[index]
826 if char not in {"'", '"'}:
827 prefix.append(char)
828 index += 1
829 continue
830 quotes = unescaped_quotes(index + 1, char)
831 if not quotes:
832 return text, False
833 if is_closer(quotes[0]):
834 # The normal reading closes this string; move past it.
835 prefix.append(text[index : quotes[0] + 1])
836 index = quotes[0] + 1
837 continue
838 winners = []
839 for close in (j for j in quotes if is_closer(j)):
840 interior: list[str] = []
841 for j in range(index + 1, close):
842 if text[j] == char and not _is_escaped(text, j):
843 interior.append("\\")
844 interior.append(text[j])
845 candidate = "".join(
846 ["".join(prefix), char, "".join(interior), char, text[close + 1 :]]
847 )
848 with warnings.catch_warnings():
849 warnings.simplefilter("ignore", SyntaxWarning)
850 try:
851 ast.parse(escape_ctrl_chars_in_strings(candidate))
852 except (SyntaxError, ValueError):
853 continue
854 winners.append(candidate)
855 if len(winners) == 1:
856 return winners[0], True
857 return text, False
858 return text, False
859
860
861 def contains_broken_string_literal(text: str) -> bool:
862 """Whether some string literal's first closing quote cannot close it.
863
864 Streaming guard companion to escape_nested_quotes_in_strings:
865 completion-based partial parses of nested-quote text read the string as
866 implicit concatenation and stream argument prefixes that can never be
867 retracted. Callers should withhold tool deltas while this returns True
868 and let the requote recovery run on the final text instead. A string
869 whose closing quote has not arrived yet is NOT broken (normal
870 streaming); a closing quote at the very end of the text counts as
871 broken only because its follower is still unknown.
872 """
873 index = 0
874 while index < len(text):
875 char = text[index]
876 if char == "\\":
877 index += 2
878 continue
879 if char not in {"'", '"'}:
880 index += 1
881 continue
882 j = index + 1
883 close = -1
884 while j < len(text):
885 if text[j] == "\\":
886 j += 2
887 continue
888 if text[j] == char:
889 close = j
890 break
891 j += 1
892 if close == -1:
893 return False
894 k = close + 1
895 while k < len(text) and text[k].isspace():
896 k += 1
897 if k >= len(text) or text[k] not in _QUOTE_FOLLOWERS:
898 return True
899 index = close + 1
900 return False
901
902
903 def _is_escaped(text: str, index: int) -> bool:
904 """Whether the character at ``index`` is backslash-escaped.
905
906 A character is escaped iff it is preceded by an *odd* number of consecutive
907 backslashes. Checking only the single preceding character is wrong for even
908 runs: in ``'ab\\'`` the closing quote follows an escaped backslash (``\\\\``)
909 and is therefore NOT escaped — it closes the string. Common in regex/code
910 arguments such as ``r'\\\\b'``.
911 """
912 backslashes = 0
913 j = index - 1
914 while j >= 0 and text[j] == "\\":
915 backslashes += 1
916 j -= 1
917 return backslashes % 2 == 1
918
919
920 def make_valid_python(text: str) -> tuple[str, str] | None:
921 """Attempt to close all open brackets/quotes to make partial Python valid.
922
923 Used during streaming to parse incomplete tool call expressions by
924 appending the necessary closing characters.
925
926 Returns:
927 A tuple of (completed_text, added_suffix) if the text can be
928 made valid, or None if the text is too incomplete to complete
929 meaningfully (e.g. mid-parameter-name or mid-dict-key).
930
931 Raises:
932 UnexpectedAstError: If mismatched brackets or parentheses
933 are detected.
934
935 """
936 bracket_stack: list[str] = []
937 for index, char in enumerate(text):
938 # Inside a string literal only an unescaped matching quote is
939 # significant; brackets are literal text. Without this guard a bracket
940 # in a string argument (e.g. `cmd='grep -F "]"'`) corrupts the bracket
941 # stack and the whole tool call is rejected as mismatched.
942 if bracket_stack and bracket_stack[-1] in {"'", '"'}:
943 if char == bracket_stack[-1] and not _is_escaped(text, index):
944 bracket_stack.pop()
945 continue
946 if char in {"[", "(", "{"}:
947 bracket_stack.append(char)
948 elif char == "]":
949 if not bracket_stack or bracket_stack.pop() != "[":
950 raise UnexpectedAstError("Mismatched square brackets")
951 elif char == ")":
952 if not bracket_stack or bracket_stack.pop() != "(":
953 raise UnexpectedAstError("Mismatched parentheses")
954 elif char == "}":
955 if not bracket_stack or bracket_stack.pop() != "{":
956 raise UnexpectedAstError("Mismatched curly braces")
957 elif char in {"'", '"'}:
958 bracket_stack.append(char)
959
960 text = text.rstrip()
961 if text.endswith("=") or text.endswith(":"):
962 return None
963 if bracket_stack and bracket_stack[-1] == "{":
964 trailing_dict_text = text[: text.rfind("{")]
965 num_keys = trailing_dict_text.count(":")
966 num_values = trailing_dict_text.count(",")
967 if num_keys <= num_values:
968 return None
969 if bracket_stack and bracket_stack[-1] == "(":
970 trailing_params_text = text[: text.rfind("(")]
971 num_full_param_names = trailing_params_text.count("=")
972 num_full_param_values = trailing_params_text.count(",")
973 if num_full_param_names <= num_full_param_values:
974 return None
975 if text.endswith(","):
976 text = text[:-1]
977 if (
978 bracket_stack
979 and bracket_stack[-1] == "["
980 and not text.endswith("[")
981 and not text.endswith(")")
982 ):
983 return None
984
985 _CLOSING = {"[": "]", "(": ")", "{": "}", "'": "'", '"': '"'}
986 added_text = ""
987 for char in reversed(bracket_stack):
988 added_text += _CLOSING[char]
989
990 candidate = text + added_text
991
992 # Streaming partial text can land in shapes the bracket-counting
993 # heuristics above don't catch. Two failure modes:
994 # 1. Mid-key inside a dict (`..., "k`) closes to `..., "k"}` — a
995 # syntactically invalid mixed dict/set.
996 # 2. A bare string inside a dict (`{"k`) closes to `{"k"}` — valid
997 # Python but a *set* literal that is really a truncated dict.
998 # Validate the candidate parses and has a body, and treat Set nodes as
999 # incomplete — but only when this completion added a `}` itself; a set
1000 # already closed in the model text is a genuine set argument, not an
1001 # artifact. Callers whose models emit raw control chars inside string
1002 # arguments must escape them (see escape_ctrl_chars_in_strings) before
1003 # calling.
1004 try:
1005 module = ast.parse(candidate)
1006 except SyntaxError:
1007 return None
1008 if not module.body:
1009 return None
1010 if "}" in added_text:
1011 for node in ast.walk(module):
1012 if isinstance(node, ast.Set):
1013 return None
1014
1015 return candidate, added_text
1016
1017
1018 def extract_types_from_schema(schema: Any) -> list[str]:
1019 """Extract all possible type strings from a JSON Schema definition.
1020
1021 Handles ``type`` (string or list), ``enum`` value inference, and
1022 recursive ``anyOf``/``oneOf``/``allOf``. Returns ``["string"]``
1023 when no type information can be determined.
1024 """
1025 if schema is None or not isinstance(schema, dict):
1026 return ["string"]
1027
1028 types: set[str] = set()
1029
1030 if "type" in schema:
1031 type_value = schema["type"]
1032 if isinstance(type_value, str):
1033 types.add(type_value)
1034 elif isinstance(type_value, list):
1035 for t in type_value:
1036 if isinstance(t, str):
1037 types.add(t)
1038
1039 if "enum" in schema and isinstance(schema["enum"], list) and schema["enum"]:
1040 for value in schema["enum"]:
1041 if value is None:
1042 types.add("null")
1043 elif isinstance(value, bool):
1044 types.add("boolean")
1045 elif isinstance(value, int):
1046 types.add("integer")
1047 elif isinstance(value, float):
1048 types.add("number")
1049 elif isinstance(value, str):
1050 types.add("string")
1051 elif isinstance(value, list):
1052 types.add("array")
1053 elif isinstance(value, dict):
1054 types.add("object")
1055
1056 for choice_field in ("anyOf", "oneOf", "allOf"):
1057 if choice_field in schema and isinstance(schema[choice_field], list):
1058 for choice in schema[choice_field]:
1059 types.update(extract_types_from_schema(choice))
1060
1061 return list(types) if types else ["string"]
1062
1063
1064 _TYPE_ALIASES: dict[str, str] = {
1065 "str": "string",
1066 "text": "string",
1067 "varchar": "string",
1068 "char": "string",
1069 "enum": "string",
1070 "int": "integer",
1071 "int32": "integer",
1072 "int64": "integer",
1073 "uint": "integer",
1074 "uint32": "integer",
1075 "uint64": "integer",
1076 "long": "integer",
1077 "short": "integer",
1078 "unsigned": "integer",
1079 "float": "number",
1080 "float32": "number",
1081 "float64": "number",
1082 "double": "number",
1083 "bool": "boolean",
1084 "dict": "object",
1085 "arr": "array",
1086 "list": "array",
1087 "sequence": "array",
1088 }
1089
1090
1091 def coerce_to_schema_type(value: str, schema_type: str | list[str]) -> Any:
1092 """Best-effort coercion of a raw string value to a JSON Schema type.
1093
1094 Tries each type in priority order (null > integer > number > boolean >
1095 object > array > string) and returns the first successful coercion.
1096 Falls back to the original string when no coercion succeeds.
1097
1098 Args:
1099 value: The raw string value from the model output.
1100 schema_type: One or more JSON Schema type strings
1101 (e.g. ``"string"`` or ``["string", "null"]``).
1102
1103 """
1104 if isinstance(schema_type, str):
1105 schema_type = [schema_type]
1106
1107 normalized_types = {
1108 _TYPE_ALIASES.get(key, key) for t in schema_type for key in [t.strip().lower()]
1109 }
1110
1111 # Priority: null > integer > number > boolean > object > array > string
1112 type_priority = [
1113 "null",
1114 "integer",
1115 "number",
1116 "boolean",
1117 "object",
1118 "array",
1119 "string",
1120 ]
1121
1122 for candidate_type in type_priority:
1123 if candidate_type not in normalized_types:
1124 continue
1125
1126 if candidate_type == "null":
1127 if value.lower() == "null":
1128 return None
1129 continue
1130 if candidate_type == "string":
1131 return value
1132 if candidate_type == "integer":
1133 try:
1134 return int(value)
1135 except (ValueError, TypeError):
1136 continue
1137 if candidate_type == "number":
1138 try:
1139 val = float(value)
1140 except (ValueError, TypeError):
1141 continue
1142 if not math.isfinite(val):
1143 # inf/-inf/nan are not valid JSON numbers. Fall through so
1144 # the value is preserved as a string instead of crashing
1145 # (int(float("inf")) raises OverflowError) or emitting
1146 # invalid JSON (json.dumps(inf) -> "Infinity").
1147 continue
1148 return val if val != int(val) else int(val)
1149 if candidate_type == "boolean":
1150 lower_val = value.lower().strip()
1151 if lower_val in ("true", "1"):
1152 return True
1153 if lower_val in ("false", "0"):
1154 return False
1155 continue
1156 if candidate_type in ("object", "array"):
1157 try:
1158 parsed = json.loads(value)
1159 except (json.JSONDecodeError, ValueError, TypeError):
1160 continue
1161 if _is_json_finite(parsed):
1162 return parsed
1163 # Non-finite floats (e.g. "[1e999]" -> [inf]) cannot be
1164 # serialized back to valid JSON; preserve the raw string.
1165 continue
1166
1167 try:
1168 parsed = json.loads(value)
1169 except (json.JSONDecodeError, ValueError):
1170 return value
1171 # Reject non-finite results (e.g. json.loads("1e999") -> inf, or nested
1172 # inf/nan inside a parsed list/dict) which json.dumps would render as
1173 # invalid JSON (Infinity/NaN). Preserve the raw string instead.
1174 if not _is_json_finite(parsed):
1175 return value
1176 return parsed
1177
1178
1179 def compute_tool_delta(
1180 previously_sent_args: str,
1181 new_call: ToolCall,
1182 index: int,
1183 withheld_suffix: str,
1184 ) -> DeltaToolCall | None:
1185 """Compute the incremental delta between previously streamed arguments
1186 and the current tool call state.
1187
1188 Returns:
1189 A DeltaToolCall with only the new argument characters, or None
1190 if there is no difference from what was previously sent.
1191
1192 """
1193 new_call_args = new_call.function.arguments
1194 if withheld_suffix:
1195 if not new_call_args.endswith(withheld_suffix):
1196 msg = (
1197 f"Tool call arguments '{new_call_args}' do not end with "
1198 f"expected withheld suffix '{withheld_suffix}'"
1199 )
1200 logger.error(msg)
1201 raise ValueError(msg)
1202 new_call_args = new_call_args[: -len(withheld_suffix)]
1203 if not previously_sent_args:
1204 return DeltaToolCall(
1205 id=new_call.id,
1206 type="function",
1207 index=index,
1208 function=DeltaFunctionCall(
1209 name=new_call.function.name,
1210 arguments=new_call_args,
1211 ),
1212 )
1213
1214 arg_diff = new_call_args[len(previously_sent_args) :]
1215 return (
1216 DeltaToolCall(
1217 id=None,
1218 index=index,
1219 function=DeltaFunctionCall(arguments=arg_diff),
1220 )
1221 if arg_diff
1222 else None
1223 )