Fix invalid escape warnings in tool parsers (#28370)
Co-authored-by: FAN YUCHEN <2994114386@qq.com>
This commit is contained in:
co-authored by
FAN YUCHEN
parent
84cdfde5b2
commit
ee236086db
@@ -1,4 +1,3 @@
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -17,7 +16,10 @@ from sglang.srt.function_call.core_types import (
|
||||
ToolCallItem,
|
||||
_GetInfoFunc,
|
||||
)
|
||||
from sglang.srt.function_call.utils import infer_type_from_json_schema
|
||||
from sglang.srt.function_call.utils import (
|
||||
infer_type_from_json_schema,
|
||||
safe_literal_eval,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -147,9 +149,21 @@ def parse_arguments(
|
||||
except (json.JSONDecodeError, ValueError, KeyError):
|
||||
pass
|
||||
|
||||
# Strategy 2.5: string-typed values that are not valid JSON (S1/S2 failed) —
|
||||
# strip the wrapping quotes and keep the raw bytes, backslashes included.
|
||||
# Avoids ast.literal_eval so invalid escapes neither warn nor get reinterpreted.
|
||||
if arg_type == "string":
|
||||
if (
|
||||
len(json_value) >= 2
|
||||
and json_value[0] == json_value[-1]
|
||||
and json_value[0] in {'"', "'"}
|
||||
):
|
||||
return json_value[1:-1], True
|
||||
return json_value, True
|
||||
|
||||
# Strategy 3: ast.literal_eval
|
||||
try:
|
||||
parsed_value = ast.literal_eval(json_value)
|
||||
parsed_value = safe_literal_eval(json_value)
|
||||
return parsed_value, True
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -12,7 +11,10 @@ from sglang.srt.function_call.core_types import (
|
||||
ToolCallItem,
|
||||
_GetInfoFunc,
|
||||
)
|
||||
from sglang.srt.function_call.utils import infer_type_from_json_schema
|
||||
from sglang.srt.function_call.utils import (
|
||||
infer_type_from_json_schema,
|
||||
safe_literal_eval,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -116,9 +118,21 @@ def parse_arguments(
|
||||
except (json.JSONDecodeError, ValueError, KeyError):
|
||||
pass
|
||||
|
||||
# Strategy 2.5: string-typed values that are not valid JSON (S1/S2 failed) —
|
||||
# strip the wrapping quotes and keep the raw bytes, backslashes included.
|
||||
# Avoids ast.literal_eval so invalid escapes neither warn nor get reinterpreted.
|
||||
if arg_type == "string":
|
||||
if (
|
||||
len(json_value) >= 2
|
||||
and json_value[0] == json_value[-1]
|
||||
and json_value[0] in {'"', "'"}
|
||||
):
|
||||
return json_value[1:-1], True
|
||||
return json_value, True
|
||||
|
||||
# Strategy 3: ast.literal_eval
|
||||
try:
|
||||
parsed_value = ast.literal_eval(json_value)
|
||||
parsed_value = safe_literal_eval(json_value)
|
||||
return parsed_value, True
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
|
||||
@@ -32,6 +32,7 @@ from sglang.srt.function_call.core_types import (
|
||||
ToolCallItem,
|
||||
_GetInfoFunc,
|
||||
)
|
||||
from sglang.srt.function_call.utils import safe_ast_parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -172,7 +173,7 @@ class Lfm2Detector(BaseFormatDetector):
|
||||
tool_indices = self._get_tool_indices(tools)
|
||||
|
||||
try:
|
||||
module = ast.parse(content)
|
||||
module = safe_ast_parse(content)
|
||||
parsed = getattr(module.body[0], "value", None) if module.body else None
|
||||
|
||||
if parsed is None:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -11,6 +10,7 @@ from sglang.srt.function_call.core_types import (
|
||||
StructureInfo,
|
||||
_GetInfoFunc,
|
||||
)
|
||||
from sglang.srt.function_call.utils import safe_literal_eval
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,7 +37,7 @@ class Llama32Detector(BaseFormatDetector):
|
||||
def _convert_python_dict_to_json(self, text: str) -> str:
|
||||
"""Convert Python dict strings to JSON format."""
|
||||
try:
|
||||
parsed = ast.literal_eval(text.strip())
|
||||
parsed = safe_literal_eval(text.strip())
|
||||
if isinstance(parsed, dict):
|
||||
return json.dumps(parsed, ensure_ascii=False)
|
||||
except:
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
|
||||
import ast
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
@@ -23,6 +22,7 @@ from sglang.srt.entrypoints.openai.protocol import Tool
|
||||
from sglang.srt.environ import envs
|
||||
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
|
||||
from sglang.srt.function_call.core_types import StreamingParseResult, _GetInfoFunc
|
||||
from sglang.srt.function_call.utils import safe_literal_eval
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -121,7 +121,7 @@ def _convert_param_value(
|
||||
func_name,
|
||||
)
|
||||
try:
|
||||
param_value = ast.literal_eval(param_value) # safer
|
||||
param_value = safe_literal_eval(param_value)
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
logger.warning(
|
||||
"Parsed value '%s' of parameter '%s' cannot be "
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -10,6 +9,7 @@ from sglang.srt.function_call.core_types import (
|
||||
StreamingParseResult,
|
||||
_GetInfoFunc,
|
||||
)
|
||||
from sglang.srt.function_call.utils import safe_literal_eval
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,7 +46,7 @@ def parse_arguments(json_value):
|
||||
try:
|
||||
parsed_value = json.loads(json_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed_value = ast.literal_eval(json_value)
|
||||
parsed_value = safe_literal_eval(json_value)
|
||||
return parsed_value, True
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
return json_value, False
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
from enum import Enum, auto
|
||||
@@ -12,6 +11,7 @@ from sglang.srt.function_call.core_types import (
|
||||
ToolCallItem,
|
||||
_GetInfoFunc,
|
||||
)
|
||||
from sglang.srt.function_call.utils import safe_literal_eval
|
||||
|
||||
|
||||
class _ParseState(Enum):
|
||||
@@ -50,7 +50,7 @@ class PoolsideV1Detector(BaseFormatDetector):
|
||||
String values are emitted as raw text; non-strings are JSON-encoded by
|
||||
the chat template. The parser does schema-based type coercion to round-trip
|
||||
them: schema type `string` keeps the raw value; other types attempt
|
||||
`json.loads` and fall back to `ast.literal_eval`, then to the raw string.
|
||||
`json.loads` and fall back to `safe_literal_eval`, then to the raw string.
|
||||
"""
|
||||
|
||||
# Wire-format tag tokens — constants, not per-instance.
|
||||
@@ -166,7 +166,7 @@ class PoolsideV1Detector(BaseFormatDetector):
|
||||
- no schema entry → json.loads only (conservative; don't
|
||||
ast-eval untyped values)
|
||||
- everything else (int,
|
||||
number, bool, object, …) → json.loads, then ast.literal_eval
|
||||
number, bool, object, …) → json.loads, then safe_literal_eval
|
||||
|
||||
Each decoder result is round-tripped through `json.dumps` before being
|
||||
returned; non-JSON-serializable values (sets / complex / bytes from
|
||||
@@ -179,7 +179,7 @@ class PoolsideV1Detector(BaseFormatDetector):
|
||||
if param_type in PoolsideV1Detector._STRING_TYPES:
|
||||
return raw
|
||||
|
||||
decoders = (json.loads,) if not param_type else (json.loads, ast.literal_eval)
|
||||
decoders = (json.loads,) if not param_type else (json.loads, safe_literal_eval)
|
||||
for decoder in decoders:
|
||||
try:
|
||||
result = decoder(raw)
|
||||
|
||||
@@ -12,6 +12,7 @@ from sglang.srt.function_call.core_types import (
|
||||
ToolCallItem,
|
||||
_GetInfoFunc,
|
||||
)
|
||||
from sglang.srt.function_call.utils import safe_ast_parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -72,7 +73,7 @@ class PythonicDetector(BaseFormatDetector):
|
||||
normal_text = normal_text_before + normal_text_after
|
||||
|
||||
try:
|
||||
module = ast.parse(tool_call_text)
|
||||
module = safe_ast_parse(tool_call_text)
|
||||
parsed = getattr(module.body[0], "value", None)
|
||||
if not (
|
||||
isinstance(parsed, ast.List)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -11,7 +10,10 @@ from sglang.srt.function_call.core_types import (
|
||||
ToolCallItem,
|
||||
_GetInfoFunc,
|
||||
)
|
||||
from sglang.srt.function_call.utils import infer_type_from_json_schema
|
||||
from sglang.srt.function_call.utils import (
|
||||
infer_type_from_json_schema,
|
||||
safe_literal_eval,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -164,7 +166,7 @@ class Qwen3CoderDetector(BaseFormatDetector):
|
||||
f"'{func_name}', will try other methods to parse it."
|
||||
)
|
||||
try:
|
||||
param_value = ast.literal_eval(param_value) # safer
|
||||
param_value = safe_literal_eval(param_value)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Parsed value '{param_value}' of parameter '{param_name}' cannot be converted via Python `ast.literal_eval()` in tool '{func_name}', degenerating to string."
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -11,6 +10,7 @@ from sglang.srt.function_call.core_types import (
|
||||
ToolCallItem,
|
||||
_GetInfoFunc,
|
||||
)
|
||||
from sglang.srt.function_call.utils import safe_literal_eval
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,7 +34,7 @@ def parse_arguments(value: str) -> tuple[Any, bool]:
|
||||
try:
|
||||
parsed_value = json.loads(value)
|
||||
except:
|
||||
parsed_value = ast.literal_eval(value)
|
||||
parsed_value = safe_literal_eval(value)
|
||||
return parsed_value, True
|
||||
except:
|
||||
return value, False
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import ast
|
||||
import threading
|
||||
import warnings
|
||||
from json import JSONDecodeError, JSONDecoder
|
||||
from json.decoder import WHITESPACE
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
|
||||
@@ -228,6 +231,35 @@ def _is_complete_json(input_str: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ``warnings.catch_warnings`` mutates the *process-global* warning filters and
|
||||
# is therefore not thread-safe (CPython docs). Tool-call parsing runs on the
|
||||
# request path and may execute concurrently, so the enter/eval/restore window
|
||||
# is serialized. These helpers are microsecond-cheap; the lock has no perf impact.
|
||||
_safe_ast_lock = threading.Lock()
|
||||
|
||||
|
||||
def _run_ast_quiet(fn, *args):
|
||||
"""Run an ``ast`` function with invalid-escape warnings suppressed.
|
||||
|
||||
CPython parses invalid escapes (e.g. ``"\\d+"``) with the backslash kept
|
||||
and only emits a warning, so the parsed value is already correct —
|
||||
promoting the warning to an error would drop otherwise-valid tool calls.
|
||||
|
||||
Holds ``_safe_ast_lock`` because ``catch_warnings`` touches global state."""
|
||||
with _safe_ast_lock, warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", category=SyntaxWarning)
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
return fn(*args)
|
||||
|
||||
|
||||
def safe_literal_eval(value: str) -> Any:
|
||||
return _run_ast_quiet(ast.literal_eval, value)
|
||||
|
||||
|
||||
def safe_ast_parse(source: str) -> ast.Module:
|
||||
return _run_ast_quiet(ast.parse, source)
|
||||
|
||||
|
||||
def _get_tool_schema_defs(tools: List[Tool]) -> dict:
|
||||
"""
|
||||
Get consolidated $defs from all tools, validating for conflicts.
|
||||
|
||||
Reference in New Issue
Block a user