Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ killall_sglang = "sglang.cli.killall:main"
exclude = [
"assets*",
"benchmark*",
"build*",
"docs*",
"dist*",
"playground*",
Expand All @@ -188,6 +189,7 @@ exclude = [
exclude = [
"assets*",
"benchmark*",
"build*",
"docs*",
"dist*",
"playground*",
Expand Down
36 changes: 36 additions & 0 deletions python/sglang/srt/entrypoints/openai/serving_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,10 @@ def _build_chat_response(
request=request,
)
reasoning_text, text = parser.parse_non_stream(text)
if request.return_meta_info:
self._attach_reasoning_parser_fallback_events(
ret_item["meta_info"], parser
)
except Exception as e:
logger.error(f"Reasoning parsing error: {e}")
return self.create_error_response(
Expand All @@ -1089,6 +1093,9 @@ def _build_chat_response(
request.tool_choice,
history_tool_calls_cnt,
chat_template_kwargs=getattr(request, "chat_template_kwargs", None),
meta_info=(
ret_item["meta_info"] if request.return_meta_info else None
),
)

# Strip structural special tokens that leaked through because
Expand Down Expand Up @@ -1228,6 +1235,32 @@ def _process_tool_call_id(
)
return tool_call_id

@staticmethod
def _attach_reasoning_parser_fallback_events(
meta_info: Optional[Dict[str, Any]],
parser: ReasoningParser,
) -> None:
if meta_info is None:
return
detector = getattr(parser, "detector", None)
fallback_events = getattr(detector, "fallback_events", None)
if fallback_events:
meta_info["reasoning_parser_fallback_events"] = copy.deepcopy(
fallback_events
)

@staticmethod
def _attach_tool_parser_fallback_events(
meta_info: Optional[Dict[str, Any]],
parser: FunctionCallParser,
) -> None:
if meta_info is None:
return
detector = getattr(parser, "detector", None)
fallback_events = getattr(detector, "fallback_events", None)
if fallback_events:
meta_info["tool_parser_fallback_events"] = copy.deepcopy(fallback_events)

def _process_tool_calls(
self,
text: str,
Expand All @@ -1236,6 +1269,7 @@ def _process_tool_calls(
tool_choice: Optional[Union[str, ToolChoice]] = None,
history_tool_calls_cnt: int = 0,
chat_template_kwargs: Optional[Dict[str, Any]] = None,
meta_info: Optional[Dict[str, Any]] = None,
) -> ToolCallProcessingResult:
"""Process tool calls in the response"""

Expand Down Expand Up @@ -1290,6 +1324,7 @@ def _process_tool_calls(
finish_reason["matched"] = None
try:
text, call_info_list = parser.parse_non_stream(text)
self._attach_tool_parser_fallback_events(meta_info, parser)
tool_calls = []
for call_info in call_info_list:
tool_id = self._process_tool_call_id(
Expand All @@ -1306,6 +1341,7 @@ def _process_tool_calls(
)
return ToolCallProcessingResult(tool_calls, text, finish_reason)
except Exception as e:
self._attach_tool_parser_fallback_events(meta_info, parser)
logger.error(f"Tool call parsing error: {e}")
# Return error but don't fail the whole request
return ToolCallProcessingResult(None, text, finish_reason)
Expand Down
2 changes: 2 additions & 0 deletions python/sglang/srt/function_call/function_call_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from sglang.srt.function_call.mistral_detector import MistralDetector
from sglang.srt.function_call.multi_format_detector import (
K2V3Detector,
K2V3DetectorTracking,
MultiFormatDetector,
)
from sglang.srt.function_call.pythonic_detector import PythonicDetector
Expand Down Expand Up @@ -58,6 +59,7 @@ class FunctionCallParser:
"glm47": Glm47MoeDetector,
"gpt-oss": GptOssDetector,
"k2_v3": K2V3Detector,
"k2_v3_tracking": K2V3DetectorTracking,
"kimi_k2": KimiK2Detector,
"lfm2": Lfm2Detector,
"llama3": Llama32Detector,
Expand Down
191 changes: 171 additions & 20 deletions python/sglang/srt/function_call/multi_format_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,31 @@ def parse_streaming_increment(
self._buffer += new_text
return StreamingParseResult()

def _on_json_loads_failed_before_ast(
self,
value: str,
error: Exception,
*,
tool_name: Optional[str] = None,
arg_name: Optional[str] = None,
target_type: Any = None,
) -> None:
pass

def _on_ast_literal_eval_failed_raw_string(
self,
value: str,
error: Exception,
*,
tool_name: Optional[str] = None,
arg_name: Optional[str] = None,
target_type: Any = None,
) -> None:
pass

def _on_ifm_reasoning_prefix_stripped(self, prefix: str, content: str) -> None:
pass

# IFM streaming (K2-V3 format) ------------------------------------
#
# vLLM's K2V3ToolParser does not stream the IFM dialects; the logic below is
Expand Down Expand Up @@ -889,20 +914,22 @@ def _ifm_json_calls(self, block: str, tools: List[Tool]):
)
yield name, args

@classmethod
def _ifm_prefix(cls, text: str, first_match_index: int) -> Optional[str]:
def _ifm_prefix(self, text: str, first_match_index: int) -> Optional[str]:
"""Leading content before the tool calls, with IFM reasoning stripped.

vLLM cuts the prefix at the <ifm|tool_calls> wrapper when present,
else at the first <ifm|tool_call> block, then strips any leading
reasoning-effort block. Whitespace-only content before the tool call is
preserved.
"""
group_index = text.find(cls._IFM_TOOL_CALLS_START_TOKEN)
group_index = text.find(self._IFM_TOOL_CALLS_START_TOKEN)
cut = group_index if group_index != -1 else first_match_index
if cut <= 0:
return None
content = cls._strip_ifm_reasoning_prefix(text[:cut])
prefix = text[:cut]
content = self._strip_ifm_reasoning_prefix(prefix)
if content != prefix:
self._on_ifm_reasoning_prefix_stripped(prefix, content)
return content if content != "" else None

@classmethod
Expand Down Expand Up @@ -957,9 +984,8 @@ def _json_stringify(value: Any) -> str:
return value
return json.dumps(value, ensure_ascii=False)

@classmethod
def _coerce_argument_value(
cls,
self,
value: Any,
tool_name: str,
arg_name: str,
Expand All @@ -968,21 +994,27 @@ def _coerce_argument_value(
arg_type: Optional[str] = None,
from_text: bool = False,
) -> Any:
target_type = cls._schema_arg_type(tool_name, arg_name, tools) or arg_type
if cls._arg_type_preserves_text(target_type):
if cls._arg_type_is_any(target_type):
target_type = self._schema_arg_type(tool_name, arg_name, tools) or arg_type
if self._arg_type_preserves_text(target_type):
if self._arg_type_is_any(target_type):
return value
return cls._json_stringify(value)
return self._json_stringify(value)
if isinstance(value, str) and (from_text or target_type is not None):
return cls._deserialize_glm_value(value)
return self._deserialize_glm_value(
value,
tool_name=tool_name,
arg_name=arg_name,
target_type=target_type,
)
return value

@classmethod
def _coerce_arguments(
cls, tool_name: str, arguments: dict[str, Any], tools: List[Tool]
self, tool_name: str, arguments: dict[str, Any], tools: List[Tool]
) -> dict[str, Any]:
return {
arg_name: cls._coerce_argument_value(arg_value, tool_name, arg_name, tools)
arg_name: self._coerce_argument_value(
arg_value, tool_name, arg_name, tools
)
for arg_name, arg_value in arguments.items()
}

Expand Down Expand Up @@ -1038,17 +1070,35 @@ def _json_or_string(value: str) -> Any:
r"<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>", re.DOTALL
)

@staticmethod
def _deserialize_glm_value(value: str) -> Any:
def _deserialize_glm_value(
self,
value: str,
*,
tool_name: Optional[str] = None,
arg_name: Optional[str] = None,
target_type: Any = None,
) -> Any:
value = value.strip()
try:
return json.loads(value)
except Exception:
pass
except Exception as json_error:
self._on_json_loads_failed_before_ast(
value,
json_error,
tool_name=tool_name,
arg_name=arg_name,
target_type=target_type,
)
try:
return ast.literal_eval(value)
except Exception:
pass
except Exception as ast_error:
self._on_ast_literal_eval_failed_raw_string(
value,
ast_error,
tool_name=tool_name,
arg_name=arg_name,
target_type=target_type,
)
return value

@staticmethod
Expand Down Expand Up @@ -1149,3 +1199,104 @@ def __init__(
if tool_format is None:
tool_format = chat_template_kwargs.get("tool_call_format")
super().__init__(tool_format=tool_format or "xml")


class K2V3DetectorTracking(K2V3Detector):
"""K2-V3 tool parser variant that records selected fallback events.

This detector intentionally keeps tracking opt-in so the default ``k2_v3``
parser remains unchanged. It records non-stream coercion fallbacks and IFM
reasoning-prefix cleanup while preserving the parsed output.
"""

def __init__(
self,
tool_format: Optional[str] = None,
chat_template_kwargs: Optional[dict] = None,
):
self.fallback_events: list[dict[str, Any]] = []
self._suppress_fallback_tracking = False
super().__init__(
tool_format=tool_format, chat_template_kwargs=chat_template_kwargs
)

def clear_fallback_events(self) -> None:
self.fallback_events.clear()

def _record_fallback(
self, fallback_type: str, phase: str, **details: Any
) -> None:
if self._suppress_fallback_tracking:
return
self.fallback_events.append(
{"type": fallback_type, "phase": phase, "details": details}
)

@staticmethod
def _preview_value(value: Any, limit: int = 120) -> str:
preview = str(value)
if len(preview) > limit:
return preview[: limit - 3] + "..."
return preview

def detect_and_parse(
self, text: str, tools: List[Tool]
) -> StreamingParseResult:
self.clear_fallback_events()
return super().detect_and_parse(text, tools)

def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
previous = self._suppress_fallback_tracking
self._suppress_fallback_tracking = True
try:
return super().parse_streaming_increment(new_text, tools)
finally:
self._suppress_fallback_tracking = previous

def _on_json_loads_failed_before_ast(
self,
value: str,
error: Exception,
*,
tool_name: Optional[str] = None,
arg_name: Optional[str] = None,
target_type: Any = None,
) -> None:
self._record_fallback(
"json_loads_failed_ast_literal_eval",
"coercion",
tool_name=tool_name,
arg_name=arg_name,
target_type=target_type,
value_preview=self._preview_value(value),
error=error.__class__.__name__,
)

def _on_ast_literal_eval_failed_raw_string(
self,
value: str,
error: Exception,
*,
tool_name: Optional[str] = None,
arg_name: Optional[str] = None,
target_type: Any = None,
) -> None:
self._record_fallback(
"ast_literal_eval_failed_raw_string",
"coercion",
tool_name=tool_name,
arg_name=arg_name,
target_type=target_type,
value_preview=self._preview_value(value),
error=error.__class__.__name__,
)

def _on_ifm_reasoning_prefix_stripped(self, prefix: str, content: str) -> None:
self._record_fallback(
"ifm_reasoning_prefix_stripped",
"non_stream",
tool_format=self.tool_format,
prefix_preview=self._preview_value(prefix),
)
Loading
Loading