94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
642 lines
26 KiB
Python
642 lines
26 KiB
Python
import ast
|
|
import json
|
|
import logging
|
|
import re
|
|
from enum import Enum
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from sglang.srt.entrypoints.openai.protocol import Tool
|
|
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
|
|
from sglang.srt.function_call.core_types import (
|
|
StreamingParseResult,
|
|
ToolCallItem,
|
|
_GetInfoFunc,
|
|
)
|
|
from sglang.srt.function_call.utils import infer_type_from_json_schema
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class StreamState(str, Enum):
|
|
"""State machine states for XML to JSON streaming conversion."""
|
|
|
|
INIT = "INIT"
|
|
BETWEEN = "BETWEEN"
|
|
IN_KEY = "IN_KEY"
|
|
WAITING_VALUE = "WAITING_VALUE"
|
|
IN_VALUE = "IN_VALUE"
|
|
|
|
|
|
def get_argument_type(
|
|
func_name: str, arg_key: str, defined_tools: List[Tool]
|
|
) -> Optional[str]:
|
|
"""Get the expected type of a function argument from tool definitions.
|
|
|
|
Supports complex JSON Schema definitions including:
|
|
- Direct type field (including type arrays)
|
|
- anyOf/oneOf: parameter can be any of multiple types
|
|
- enum: parameter must be one of enum values
|
|
- allOf: parameter must satisfy all type definitions
|
|
- properties: inferred as object type
|
|
- items: inferred as array type
|
|
|
|
Args:
|
|
func_name: Name of the function/tool
|
|
arg_key: Name of the argument
|
|
defined_tools: List of available tools
|
|
|
|
Returns:
|
|
The type string (e.g., 'string', 'number', 'object') or None if not found
|
|
"""
|
|
name2tool = {tool.function.name: tool for tool in defined_tools}
|
|
if func_name not in name2tool:
|
|
return None
|
|
tool = name2tool[func_name]
|
|
properties = (tool.function.parameters or {}).get("properties", {})
|
|
if not isinstance(properties, dict):
|
|
properties = {}
|
|
if arg_key not in properties:
|
|
return None
|
|
|
|
# Use new type inference function for complex JSON Schema support
|
|
return infer_type_from_json_schema(properties[arg_key])
|
|
|
|
|
|
def _convert_to_number(value: str) -> Any:
|
|
"""Convert string to appropriate number type (int or float).
|
|
|
|
Args:
|
|
value: String value to convert
|
|
|
|
Returns:
|
|
Converted number or original string if conversion fails
|
|
"""
|
|
try:
|
|
if "." in value or "e" in value.lower():
|
|
return float(value)
|
|
else:
|
|
return int(value)
|
|
except (ValueError, AttributeError):
|
|
return value
|
|
|
|
|
|
def parse_arguments(
|
|
json_value: str, arg_type: Optional[str] = None
|
|
) -> Tuple[Any, bool]:
|
|
"""Parse argument value with multiple fallback strategies.
|
|
|
|
Args:
|
|
json_value: Raw string value to parse
|
|
arg_type: Expected type hint ('string', 'number', 'object', etc.)
|
|
|
|
Returns:
|
|
Tuple of (parsed_value, is_valid_json)
|
|
"""
|
|
# Strategy 1: Direct JSON parsing
|
|
try:
|
|
parsed_value = json.loads(json_value)
|
|
|
|
# Type coercion for number type
|
|
if arg_type == "number" and isinstance(parsed_value, str):
|
|
parsed_value = _convert_to_number(parsed_value)
|
|
|
|
return parsed_value, True
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass
|
|
|
|
# Strategy 2: Unescape and parse
|
|
try:
|
|
wrapped = json.loads('{"tmp": "' + json_value + '"}')
|
|
parsed_value = json.loads(wrapped["tmp"])
|
|
|
|
if arg_type == "number" and isinstance(parsed_value, str):
|
|
parsed_value = _convert_to_number(parsed_value)
|
|
|
|
return parsed_value, True
|
|
except (json.JSONDecodeError, ValueError, KeyError):
|
|
pass
|
|
|
|
# Strategy 3: ast.literal_eval
|
|
try:
|
|
parsed_value = ast.literal_eval(json_value)
|
|
return parsed_value, True
|
|
except (ValueError, SyntaxError):
|
|
pass
|
|
|
|
# Strategy 4: Treat as string
|
|
try:
|
|
quoted_value = json.dumps(str(json_value))
|
|
return json.loads(quoted_value), True
|
|
except (json.JSONDecodeError, ValueError):
|
|
return json_value, False
|
|
|
|
|
|
class Glm4MoeDetector(BaseFormatDetector):
|
|
"""
|
|
Detector for GLM-4.5 and GLM-4.6 models.
|
|
Assumes function call format (with actual newlines):
|
|
<tool_call>get_weather
|
|
<arg_key>city</arg_key>
|
|
<arg_value>北京</arg_value>
|
|
<arg_key>date</arg_key>
|
|
<arg_value>2024-06-27</arg_value>
|
|
</tool_call>
|
|
|
|
Or with literal \n characters (escaped as \\n in the output):
|
|
<tool_call>get_weather\n<arg_key>city</arg_key>\n<arg_value>北京</arg_value>\n</tool_call>
|
|
|
|
Uses a streaming state machine to convert XML to JSON incrementally for maximum speed.
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.bot_token = "<tool_call>"
|
|
self.eot_token = "</tool_call>"
|
|
self.func_call_regex = r"<tool_call>.*?</tool_call>"
|
|
self.func_detail_regex = re.compile(
|
|
r"<tool_call>(.*?)(?:\\n|\n)(.*)</tool_call>", re.DOTALL
|
|
)
|
|
self.func_arg_regex = re.compile(
|
|
r"<arg_key>(.*?)</arg_key>(?:\\n|\s)*<arg_value>(.*?)</arg_value>",
|
|
re.DOTALL,
|
|
)
|
|
self._last_arguments = ""
|
|
self.current_tool_id = -1
|
|
self.current_tool_name_sent = False
|
|
self._streamed_raw_length = 0
|
|
self._reset_streaming_state()
|
|
|
|
def _reset_streaming_state(self) -> None:
|
|
"""Reset the streaming state machine for a new tool call."""
|
|
self._stream_state = StreamState.INIT
|
|
self._current_key = ""
|
|
self._current_value = ""
|
|
self._xml_tag_buffer = ""
|
|
self._is_first_param = True
|
|
self._value_started = False
|
|
self._cached_value_type: Optional[str] = (
|
|
None # Cache the value type for consistency
|
|
)
|
|
|
|
def has_tool_call(self, text: str) -> bool:
|
|
"""Check if the text contains a glm-4.5 / glm-4.6 format tool call."""
|
|
return self.bot_token in text
|
|
|
|
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
|
|
"""
|
|
One-time parsing: Detects and parses tool calls in the provided text.
|
|
|
|
:param text: The complete text to parse.
|
|
:param tools: List of available tools.
|
|
:return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
|
|
"""
|
|
idx = text.find(self.bot_token)
|
|
normal_text = text[:idx].strip() if idx != -1 else text
|
|
if self.bot_token not in text:
|
|
return StreamingParseResult(normal_text=normal_text, calls=[])
|
|
match_result_list = re.findall(self.func_call_regex, text, re.DOTALL)
|
|
calls = []
|
|
try:
|
|
for match_result in match_result_list:
|
|
# Get function name
|
|
func_detail = self.func_detail_regex.search(match_result)
|
|
if func_detail is None:
|
|
continue
|
|
func_name = func_detail.group(1) if func_detail.group(1) else ""
|
|
func_args = func_detail.group(2) if func_detail.group(2) else ""
|
|
pairs = self.func_arg_regex.findall(func_args)
|
|
|
|
# Parse arguments using shared method
|
|
arguments = self._parse_argument_pairs(pairs, func_name, tools)
|
|
|
|
# construct match_result for parse_base_json
|
|
match_result = {"name": func_name, "parameters": arguments}
|
|
calls.extend(self.parse_base_json(match_result, tools))
|
|
return StreamingParseResult(normal_text=normal_text, calls=calls)
|
|
except Exception as e:
|
|
logger.error(f"Error in detect_and_parse: {e}", exc_info=True)
|
|
# return the normal text if parsing fails
|
|
return StreamingParseResult(normal_text=text)
|
|
|
|
def _get_value_type(self, func_name: str, key: str, tools: List[Tool]) -> str:
|
|
"""Get parameter type from tool definition, with fallback to auto-detection.
|
|
|
|
Args:
|
|
func_name: Name of the function
|
|
key: Parameter name
|
|
tools: List of available tools
|
|
|
|
Returns:
|
|
Type string: 'string', 'number', 'object', 'array', or 'boolean'
|
|
"""
|
|
arg_type = get_argument_type(func_name, key, tools)
|
|
if arg_type:
|
|
return arg_type
|
|
|
|
# Improved auto-detection type from value (best effort)
|
|
value_content = self._current_value.strip() if self._current_value else ""
|
|
|
|
if not value_content:
|
|
return "string"
|
|
|
|
# Try to parse as valid JSON first
|
|
try:
|
|
parsed = json.loads(value_content)
|
|
if isinstance(parsed, dict):
|
|
return "object"
|
|
elif isinstance(parsed, list):
|
|
return "array"
|
|
elif isinstance(parsed, bool):
|
|
return "boolean"
|
|
elif isinstance(parsed, (int, float)):
|
|
return "number"
|
|
# For string values, check if they look like numbers
|
|
elif isinstance(parsed, str):
|
|
if parsed.isdigit() or (
|
|
parsed.startswith("-") and parsed[1:].isdigit()
|
|
):
|
|
return "number"
|
|
return "string"
|
|
except json.JSONDecodeError:
|
|
# Not valid JSON, try heuristic detection
|
|
first_char = value_content[0] if value_content else ""
|
|
|
|
if first_char.isdigit() or first_char in ["-", "."]:
|
|
return "number"
|
|
elif first_char in ["{", "["]:
|
|
return "object"
|
|
elif first_char in ['"', "'"]:
|
|
return "string"
|
|
|
|
# Default to string (safest fallback)
|
|
return "string"
|
|
|
|
def _format_value_complete(self, value: str, value_type: str) -> str:
|
|
"""Format complete value based on type.
|
|
|
|
Args:
|
|
value: Raw value string
|
|
value_type: Expected type ('string', 'number', 'object')
|
|
|
|
Returns:
|
|
Properly formatted JSON value string
|
|
"""
|
|
if value_type == "string":
|
|
# Ensure proper JSON string formatting with quotes
|
|
return json.dumps(value, ensure_ascii=False)
|
|
elif value_type == "number":
|
|
try:
|
|
num = _convert_to_number(value.strip())
|
|
return str(num)
|
|
except (ValueError, AttributeError):
|
|
# Fallback to string if not a valid number
|
|
logger.warning(
|
|
f"Failed to parse '{value}' as number, treating as string"
|
|
)
|
|
return json.dumps(str(value), ensure_ascii=False)
|
|
else:
|
|
# For object/array types, return as-is (should already be valid JSON)
|
|
return value
|
|
|
|
def _process_xml_to_json_streaming(
|
|
self, raw_increment: str, func_name: str, tools: List[Tool]
|
|
) -> str:
|
|
"""Convert XML increment to JSON streaming output using state machine.
|
|
|
|
This method processes XML fragments character by character and converts them
|
|
to JSON format incrementally. It maintains state across calls to handle
|
|
partial XML tags and values.
|
|
|
|
Args:
|
|
raw_increment: New XML content to process
|
|
func_name: Name of the function being called
|
|
tools: List of available tools for type inference
|
|
|
|
Returns:
|
|
JSON string increment to append to the output
|
|
"""
|
|
json_output = ""
|
|
|
|
for char in raw_increment:
|
|
self._xml_tag_buffer += char
|
|
|
|
if self._stream_state in [StreamState.INIT, StreamState.BETWEEN]:
|
|
if self._xml_tag_buffer.endswith("<arg_key>"):
|
|
self._stream_state = StreamState.IN_KEY
|
|
self._current_key = ""
|
|
self._xml_tag_buffer = ""
|
|
json_output += "{" if self._is_first_param else ", "
|
|
self._is_first_param = False
|
|
|
|
elif self._stream_state == StreamState.IN_KEY:
|
|
if self._xml_tag_buffer.endswith("</arg_key>"):
|
|
self._current_key = self._xml_tag_buffer[:-10].strip()
|
|
self._xml_tag_buffer = ""
|
|
self._stream_state = StreamState.WAITING_VALUE
|
|
json_output += (
|
|
json.dumps(self._current_key, ensure_ascii=False) + ": "
|
|
)
|
|
|
|
elif self._stream_state == StreamState.WAITING_VALUE:
|
|
if self._xml_tag_buffer.endswith("<arg_value>"):
|
|
self._stream_state = StreamState.IN_VALUE
|
|
self._current_value = ""
|
|
self._xml_tag_buffer = ""
|
|
self._value_started = False
|
|
# Determine and cache the value type at the start
|
|
self._cached_value_type = self._get_value_type(
|
|
func_name, self._current_key, tools
|
|
)
|
|
|
|
elif self._stream_state == StreamState.IN_VALUE:
|
|
if self._xml_tag_buffer.endswith("</arg_value>"):
|
|
final_value = self._xml_tag_buffer[:-12]
|
|
self._current_value += final_value
|
|
|
|
# Use cached value type for consistency
|
|
value_type = self._cached_value_type or "string"
|
|
|
|
if self._value_started:
|
|
# Output any remaining content
|
|
if final_value:
|
|
if value_type == "string":
|
|
json_output += json.dumps(
|
|
final_value, ensure_ascii=False
|
|
)[1:-1]
|
|
else:
|
|
json_output += final_value
|
|
# Always output closing quote for string type when value was started
|
|
if value_type == "string":
|
|
json_output += '"'
|
|
else:
|
|
# Value was never started (empty or complete in one chunk)
|
|
json_output += self._format_value_complete(
|
|
self._current_value, value_type
|
|
)
|
|
|
|
self._xml_tag_buffer = ""
|
|
self._stream_state = StreamState.BETWEEN
|
|
self._current_value = ""
|
|
self._value_started = False
|
|
self._cached_value_type = None # Reset cached type
|
|
else:
|
|
closing_tag = "</arg_value>"
|
|
is_potential_closing = len(self._xml_tag_buffer) <= len(
|
|
closing_tag
|
|
) and closing_tag.startswith(self._xml_tag_buffer)
|
|
|
|
if not is_potential_closing:
|
|
content = self._xml_tag_buffer
|
|
# Use cached value type for consistency
|
|
value_type = self._cached_value_type or "string"
|
|
|
|
if value_type == "string":
|
|
if not self._value_started:
|
|
json_output += '"'
|
|
self._value_started = True
|
|
if content:
|
|
json_output += json.dumps(content, ensure_ascii=False)[
|
|
1:-1
|
|
]
|
|
self._current_value += content
|
|
self._xml_tag_buffer = ""
|
|
elif value_type == "number":
|
|
if content:
|
|
if not self._value_started:
|
|
self._value_started = True
|
|
json_output += content
|
|
self._current_value += content
|
|
self._xml_tag_buffer = ""
|
|
else:
|
|
# For object/array types, output as-is
|
|
if content:
|
|
if not self._value_started:
|
|
self._value_started = True
|
|
json_output += content
|
|
self._current_value += content
|
|
self._xml_tag_buffer = ""
|
|
|
|
return json_output
|
|
|
|
def parse_streaming_increment(
|
|
self, new_text: str, tools: List[Tool]
|
|
) -> StreamingParseResult:
|
|
"""
|
|
Streaming incremental parsing tool calls for GLM-4.5 and GLM-4.6 format.
|
|
Uses a state machine to convert XML to JSON incrementally for true character-by-character streaming.
|
|
Outputs JSON increments immediately as XML data arrives.
|
|
"""
|
|
self._buffer += new_text
|
|
current_text = self._buffer
|
|
|
|
# Check if we have a tool call
|
|
has_tool_call = self.bot_token in current_text
|
|
|
|
if not has_tool_call:
|
|
# Check if buffer could be the start of a tool call
|
|
# Keep buffer if it could be a partial match of bot_token
|
|
is_potential_start = any(
|
|
self.bot_token.startswith(current_text[-i:])
|
|
for i in range(1, min(len(current_text), len(self.bot_token)) + 1)
|
|
)
|
|
|
|
if not is_potential_start:
|
|
# Not a potential tool call, return as normal text
|
|
# Must return the entire buffer (current_text), not just new_text,
|
|
# because buffer may contain previously accumulated characters like '<'
|
|
# that turned out not to be part of a tool call
|
|
output_text = current_text
|
|
self._buffer = ""
|
|
if self.eot_token in output_text:
|
|
output_text = output_text.replace(self.eot_token, "")
|
|
return StreamingParseResult(normal_text=output_text)
|
|
else:
|
|
# Could be start of tool call, keep buffering
|
|
return StreamingParseResult(normal_text="", calls=[])
|
|
|
|
if not hasattr(self, "_tool_indices"):
|
|
self._tool_indices = self._get_tool_indices(tools)
|
|
|
|
calls: list[ToolCallItem] = []
|
|
try:
|
|
# Try to match a partial or complete tool call
|
|
partial_match = re.search(
|
|
pattern=r"<tool_call>(.*?)(?:\\n|\n)(.*?)(</tool_call>|$)",
|
|
string=current_text,
|
|
flags=re.DOTALL,
|
|
)
|
|
if partial_match:
|
|
func_name_raw = partial_match.group(1)
|
|
func_args_raw = partial_match.group(2)
|
|
is_tool_end = partial_match.group(3)
|
|
|
|
# Only proceed if we have a non-empty function name
|
|
if func_name_raw is None or not func_name_raw.strip():
|
|
# If we only have the start token without a function name,
|
|
# continue buffering until we get more content
|
|
return StreamingParseResult(normal_text="", calls=[])
|
|
|
|
func_name = func_name_raw.strip()
|
|
func_args_raw = func_args_raw.strip() if func_args_raw else ""
|
|
|
|
# Initialize state if this is the first tool call
|
|
if self.current_tool_id == -1:
|
|
self.current_tool_id = 0
|
|
self.prev_tool_call_arr = []
|
|
self.streamed_args_for_tool = [""]
|
|
self._streamed_raw_length = 0
|
|
self.current_tool_name_sent = False
|
|
self._reset_streaming_state()
|
|
|
|
# Ensure we have enough entries in our tracking arrays
|
|
while len(self.prev_tool_call_arr) <= self.current_tool_id:
|
|
self.prev_tool_call_arr.append({})
|
|
while len(self.streamed_args_for_tool) <= self.current_tool_id:
|
|
self.streamed_args_for_tool.append("")
|
|
|
|
# Send tool name first if not sent yet
|
|
if not self.current_tool_name_sent:
|
|
calls.append(
|
|
ToolCallItem(
|
|
tool_index=self.current_tool_id,
|
|
name=func_name,
|
|
parameters="",
|
|
)
|
|
)
|
|
self.current_tool_name_sent = True
|
|
self._streamed_raw_length = 0
|
|
self._reset_streaming_state()
|
|
# Store the tool call info
|
|
self.prev_tool_call_arr[self.current_tool_id] = {
|
|
"name": func_name,
|
|
"arguments": {},
|
|
}
|
|
else:
|
|
# Process XML to JSON streaming
|
|
current_raw_length = len(func_args_raw)
|
|
|
|
if current_raw_length > self._streamed_raw_length:
|
|
# Get the new raw XML content
|
|
raw_increment = func_args_raw[self._streamed_raw_length :]
|
|
|
|
# Convert XML increment to JSON increment using state machine
|
|
json_increment = self._process_xml_to_json_streaming(
|
|
raw_increment, func_name, tools
|
|
)
|
|
|
|
# CRITICAL: Update streamed length BEFORE checking json_increment
|
|
# Even if json_increment is empty, the input has been consumed by the state machine
|
|
self._streamed_raw_length = current_raw_length
|
|
|
|
if json_increment:
|
|
calls.append(
|
|
ToolCallItem(
|
|
tool_index=self.current_tool_id,
|
|
name=None,
|
|
parameters=json_increment,
|
|
)
|
|
)
|
|
self._last_arguments += json_increment
|
|
self.streamed_args_for_tool[
|
|
self.current_tool_id
|
|
] += json_increment
|
|
|
|
if is_tool_end == self.eot_token:
|
|
if self._is_first_param:
|
|
empty_object = "{}"
|
|
calls.append(
|
|
ToolCallItem(
|
|
tool_index=self.current_tool_id,
|
|
name=None,
|
|
parameters=empty_object,
|
|
)
|
|
)
|
|
self._last_arguments += empty_object
|
|
elif not self._last_arguments.endswith("}"):
|
|
closing_brace = "}"
|
|
calls.append(
|
|
ToolCallItem(
|
|
tool_index=self.current_tool_id,
|
|
name=None,
|
|
parameters=closing_brace,
|
|
)
|
|
)
|
|
self._last_arguments += closing_brace
|
|
self.streamed_args_for_tool[
|
|
self.current_tool_id
|
|
] += closing_brace
|
|
|
|
try:
|
|
pairs = self.func_arg_regex.findall(func_args_raw)
|
|
if pairs:
|
|
arguments = self._parse_argument_pairs(
|
|
pairs, func_name, tools
|
|
)
|
|
self.prev_tool_call_arr[self.current_tool_id][
|
|
"arguments"
|
|
] = arguments
|
|
except Exception as e:
|
|
logger.debug(
|
|
f"Failed to parse arguments: {e}", exc_info=True
|
|
)
|
|
|
|
# Remove the completed tool call from buffer
|
|
self._buffer = current_text[partial_match.end(3) :]
|
|
|
|
result = StreamingParseResult(normal_text="", calls=calls)
|
|
self.current_tool_id += 1
|
|
self._last_arguments = ""
|
|
self.current_tool_name_sent = False
|
|
self._streamed_raw_length = 0
|
|
self._reset_streaming_state()
|
|
return result
|
|
|
|
return StreamingParseResult(normal_text="", calls=calls)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in parse_streaming_increment: {e}", exc_info=True)
|
|
return StreamingParseResult(normal_text=current_text)
|
|
|
|
def _parse_argument_pairs(
|
|
self, pairs: List[Tuple[str, str]], func_name: str, tools: List[Tool]
|
|
) -> Dict[str, Any]:
|
|
"""Parse argument key-value pairs with type coercion.
|
|
|
|
Args:
|
|
pairs: List of (key, value) tuples from regex matching
|
|
func_name: Name of the function
|
|
tools: List of available tools
|
|
|
|
Returns:
|
|
Dictionary of parsed arguments
|
|
"""
|
|
arguments = {}
|
|
for arg_key, arg_value in pairs:
|
|
arg_key = arg_key.strip()
|
|
arg_type = get_argument_type(func_name, arg_key, tools)
|
|
parsed_value, is_good_json = parse_arguments(arg_value, arg_type)
|
|
|
|
if arg_type == "string":
|
|
# Only convert to string if explicitly defined as string type
|
|
if isinstance(parsed_value, str):
|
|
arguments[arg_key] = parsed_value
|
|
elif isinstance(parsed_value, (dict, list)):
|
|
# If parsed as dict/list but schema says string, convert to JSON string
|
|
arguments[arg_key] = json.dumps(parsed_value, ensure_ascii=False)
|
|
else:
|
|
arguments[arg_key] = str(parsed_value)
|
|
elif arg_type is None:
|
|
# If type is not defined, keep the parsed value as-is
|
|
arguments[arg_key] = parsed_value if is_good_json else arg_value
|
|
else:
|
|
# For other types (number, object, array, etc.), use parsed value
|
|
arguments[arg_key] = parsed_value if is_good_json else arg_value
|
|
|
|
return arguments
|
|
|
|
def supports_structural_tag(self) -> bool:
|
|
return False
|
|
|
|
def structure_info(self) -> _GetInfoFunc:
|
|
raise NotImplementedError()
|