Files
freetoken/python/freetoken/server/function_call_parser.py
T

2780 lines
116 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Adapted from LightLLM [https://github.com/ModelTC/lightllm/blob/main/lightllm/server/function_call_parser.py]
# Copyright 2025 ModelTC Team
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import ast
import json
import os
import logging
import re
from abc import ABC, abstractmethod
from json import JSONDecodeError, JSONDecoder
from json.decoder import WHITESPACE
from typing import Any, Dict, List, Optional, Tuple, Type
import partial_json_parser
from partial_json_parser.core.exceptions import MalformedJSON
from partial_json_parser.core.options import Allow
from pydantic import BaseModel
from .api_models import Function, Tool
try:
import orjson
except ModuleNotFoundError: # FreeToken does not require orjson.
class _OrjsonCompat:
@staticmethod
def loads(value: str) -> Any:
return json.loads(value)
orjson = _OrjsonCompat()
logger = logging.getLogger(__name__)
_TRUE_ENV_VALUES = {"ON", "TRUE", "1", "YES"}
_LIGHTLLM_ENABLE_TOOL_NAME_CHECK = os.getenv("LIGHTLLM_ENABLE_TOOL_NAME_CHECK")
if _LIGHTLLM_ENABLE_TOOL_NAME_CHECK is not None:
FORWARD_UNKNOWN_TOOLS = _LIGHTLLM_ENABLE_TOOL_NAME_CHECK.upper() not in _TRUE_ENV_VALUES
else:
FORWARD_UNKNOWN_TOOLS = (
os.getenv("FREETOKEN_FORWARD_UNKNOWN_TOOLS", os.getenv("SGLANG_FORWARD_UNKNOWN_TOOLS", "True")).upper()
in _TRUE_ENV_VALUES
)
def _should_forward_unknown_tool(name: Any) -> bool:
return FORWARD_UNKNOWN_TOOLS or (isinstance(name, str) and ":" in name)
TOOLS_TAG_LIST = [
"<|plugin|>",
"<|tool_call>",
"<|tool_call_begin|>",
"<|channel|>",
"<function=",
"<tool_call>",
"<minimax:tool_call>",
"<|python_tag|>",
"[TOOL_CALLS]",
"<|DSML|function_calls>",
"<|DSML|tool_calls>",
"<|DSML|invoke",
]
class ToolCallItem(BaseModel):
"""Simple encapsulation of the parsed ToolCall result for easier usage in streaming contexts."""
tool_index: int
name: Optional[str] = None
parameters: str # JSON string
class StreamingParseResult:
"""Result of streaming incremental parsing."""
def __init__(self, normal_text: str = "", calls: Optional[List[ToolCallItem]] = None):
self.normal_text = normal_text
self.calls = calls or []
def _first_existing_pos(text: str, tokens: List[str]) -> int:
positions = [text.find(token) for token in tokens if token in text]
return min(positions) if positions else -1
def _find_common_prefix(s1: str, s2: str) -> str:
prefix = ""
min_length = min(len(s1), len(s2))
for i in range(0, min_length):
if s1[i] == s2[i]:
prefix += s1[i]
else:
break
return prefix
def _partial_json_loads(input_str: str, flags: Allow) -> Tuple[Any, int]:
"""
Parse incomplete or partial JSON strings commonly encountered during streaming.
Args:
input_str (str): The potentially incomplete JSON string to parse.
flags (Allow): Bitwise flags controlling what types of partial data are allowed.
Common flags include:
- Allow.STR: Allow partial strings (e.g., '"hello wo' -> 'hello wo')
- Allow.OBJ: Allow partial objects (e.g., '{"key":' -> {'key': None})
- Allow.ARR: Allow partial arrays (e.g., '[1, 2,' -> [1, 2])
- Allow.ALL: Allow all types of partial data
Returns:
Tuple[Any, int]: A tuple containing:
- parsed_object: The Python object parsed from the JSON
- consumed_length: Number of characters consumed from input_str
"""
try:
return (partial_json_parser.loads(input_str, flags), len(input_str))
except (JSONDecodeError, IndexError) as e:
msg = getattr(e, "msg", str(e))
if "Extra data" in msg or "pop from empty list" in msg:
start = WHITESPACE.match(input_str, 0).end()
obj, end = JSONDecoder().raw_decode(input_str, start)
return obj, end
raise
def _is_complete_json(input_str: str) -> bool:
try:
orjson.loads(input_str)
return True
except JSONDecodeError:
return False
def _parse_loose_json_value(value: str) -> Any:
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError, ValueError):
if value.lower() == "true":
return True
if value.lower() == "false":
return False
if value.lower() == "null":
return None
return value
def _parse_first_json_value(text: str) -> Any:
decoder = JSONDecoder()
for idx, char in enumerate(text.strip()):
if char not in "[{":
continue
try:
value, _ = decoder.raw_decode(text.strip(), idx)
return value
except JSONDecodeError:
return None
return None
def _split_top_level(text: str, delimiter: str) -> List[str]:
parts: List[str] = []
start = 0
depth = 0
in_gemma_string = False
i = 0
while i < len(text):
if text.startswith('<|"|>', i):
in_gemma_string = not in_gemma_string
i += len('<|"|>')
continue
if not in_gemma_string:
if text[i] in "[{":
depth += 1
elif text[i] in "]}":
depth -= 1
elif text[i] == delimiter and depth == 0:
parts.append(text[start:i])
start = i + 1
i += 1
parts.append(text[start:])
return parts
def _parse_gemma_value(value: str) -> Any:
value = value.strip()
if value.startswith('<|"|>') and value.endswith('<|"|>'):
return value[len('<|"|>') : -len('<|"|>')]
if value == "true":
return True
if value == "false":
return False
if value in ("null", "none", "None"):
return None
if value.startswith("{") and value.endswith("}"):
return _parse_gemma_call_args(value[1:-1])
if value.startswith("[") and value.endswith("]"):
return [_parse_gemma_value(item) for item in _split_top_level(value[1:-1], ",")]
try:
return int(value)
except ValueError:
pass
try:
return float(value)
except ValueError:
return value
def _parse_gemma_call_args(text: str) -> Dict[str, Any]:
args: Dict[str, Any] = {}
for item in _split_top_level(text, ","):
if not item.strip():
continue
key, sep, value = item.partition(":")
if not sep:
continue
args[key.strip()] = _parse_gemma_value(value.strip())
return args
class BaseFormatDetector(ABC):
"""Base class providing two sets of interfaces: one-time and streaming incremental."""
# Detectors whose parse_streaming_increment is not incremental-safe (e.g. re-emit
# already-released text) set this False; the serving layer then falls back to
# buffering the whole generation and calling parse_non_stream at the end.
supports_streaming = True
# Whether emitted argument fragments always concatenate to a prefix of the
# call's FINAL arguments JSON. Adapters whose clients concatenate fragments
# (Anthropic input_json_delta, OpenAI tool_calls deltas) only stream fragments
# when this holds; otherwise they send the full arguments once at close.
args_fragments_prefix_stable = True
# The marker that UNIQUELY opens a tool-call block in this wire format, or None when
# no such marker exists (gpt-oss: <|channel|> opens every channel header, tool call or
# not; DSV32/DSV4: the DSML opener is a multi-piece composite). Consumed by the
# scheduler's special-token checkpoint, which only uses it when the tokenizer encodes
# it as a single token. Often equals bot_token, but declared separately because
# bot_token is a parse trigger, not a uniqueness claim.
toolcall_opener: str | None = None
def __init__(self):
# Streaming state management
# Buffer for accumulating incomplete patterns that arrive across multiple streaming chunks
self._buffer = ""
# Stores complete tool call info (name and arguments) for each tool being parsed.
# Used by serving layer for completion handling when streaming ends.
# Format: [{"name": str, "arguments": dict}, ...]
self.prev_tool_call_arr: List[Dict] = []
# Index of currently streaming tool call. Starts at -1 (no active tool),
# increments as each tool completes. Tracks which tool's arguments are streaming.
self.current_tool_id: int = -1
# Flag for whether current tool's name has been sent to client.
# Tool names sent first with empty parameters, then arguments stream incrementally.
self.current_tool_name_sent: bool = False
# Tracks raw JSON string content streamed to client for each tool's arguments.
# Critical for serving layer to calculate remaining content when streaming ends.
# Each index corresponds to a tool_id. Example: ['{"location": "San Francisco"', '{"temp": 72']
self.streamed_args_for_tool: List[str] = []
# Token configuration (override in subclasses)
self.bot_token = ""
self.eot_token = ""
self.tool_call_separator = ", "
def _get_tool_indices(self, tools: List[Tool]) -> Dict[str, int]:
"""
Get a mapping of tool names to their indices in the tools list.
This utility method creates a dictionary mapping function names to their
indices in the tools list, which is commonly needed for tool validation
and ToolCallItem creation.
Args:
tools: List of available tools
Returns:
Dictionary mapping tool names to their indices
"""
return {tool.function.name: i for i, tool in enumerate(tools) if tool.function.name}
def parse_base_json(self, action: Any, tools: List[Tool]) -> List[ToolCallItem]:
tool_indices = self._get_tool_indices(tools)
if not isinstance(action, list):
action = [action]
results = []
for act in action:
name = act.get("name")
if not (name and name in tool_indices):
logger.warning(f"Model attempted to call undefined function: {name}")
if not _should_forward_unknown_tool(name):
continue
results.append(
ToolCallItem(
tool_index=tool_indices.get(name, -1),
name=name,
parameters=json.dumps(
act.get("parameters") or act.get("arguments", {}),
ensure_ascii=False,
),
)
)
return results
@abstractmethod
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
Parses the text in one go. Returns success=True if the format matches, otherwise False.
Note that leftover_text here represents "content that this parser will not consume further".
"""
action = orjson.loads(text)
return StreamingParseResult(calls=self.parse_base_json(action, tools))
def _ends_with_partial_token(self, buffer: str, bot_token: str) -> int:
"""
Check if buffer ends with a partial bot_token.
Return the length of the partial bot_token.
For some format, the bot_token is not a token in model's vocabulary, such as
`[TOOL_CALLS] [` in Mistral.
"""
for i in range(1, min(len(buffer) + 1, len(bot_token))):
if bot_token.startswith(buffer[-i:]):
return i
return 0
def _get_param_config(self, func_name: str, tools: List[Tool]) -> Dict:
"""Extract the parameter properties (JSON schema) for one tool."""
for tool in tools:
if tool.function.name == func_name and tool.function.parameters:
params = tool.function.parameters
if isinstance(params, dict) and "properties" in params:
return params["properties"]
elif isinstance(params, dict):
return params
return {}
def _convert_param_value(self, value: str, param_name: str, param_config: Dict, func_name: str) -> Any:
"""Convert parameter value based on schema type. Safe alternative to eval()."""
if value.lower() == "null":
return None
if param_name not in param_config:
return value
prop = param_config.get(param_name, {})
param_type = str(prop.get("type", "string")).strip().lower() if isinstance(prop, dict) else "string"
if param_type in ("string", "str", "enum"):
return value
elif param_type.startswith("int") or param_type == "integer":
try:
return int(value)
except (ValueError, TypeError):
return value
elif param_type in ("number", "float", "double"):
try:
fv = float(value)
return int(fv) if fv == int(fv) else fv
except (ValueError, TypeError):
return value
elif param_type in ("boolean", "bool"):
return value.lower() == "true"
elif param_type in ("object", "array"):
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError, ValueError):
try:
return ast.literal_eval(value)
except (ValueError, SyntaxError, TypeError):
return value
return value
def _schema_param_type(self, param_name: str, param_config: Dict, missing: str = "string") -> str:
"""Normalized schema type for a parameter; ``missing`` when undeclared."""
if param_name not in param_config:
return missing
prop = param_config.get(param_name, {})
if not isinstance(prop, dict):
return "string"
return str(prop.get("type", "string")).strip().lower()
@staticmethod
def _json_escape_chunk(text: str) -> str:
"""JSON-string-escape a fragment of a value (escaping is per-character, so
chunk boundaries are safe)."""
return json.dumps(text, ensure_ascii=False)[1:-1]
def block_close_tokens(self) -> tuple:
"""Tokens that end a tool block — used to locate text AFTER the last call
in one-shot parsing (detect_and_parse only keeps text before the first)."""
return (self.eot_token,) if self.eot_token else ()
def finish_streaming(self) -> str:
"""End-of-stream drain: return residual buffered text that should be surfaced
as normal content. Suppressed when the buffer holds an unfinished tool call
(raw tool markup must not leak into content), or when it is only markup
debris (closing tag / bare separator) left behind by completed calls."""
residual, self._buffer = self._buffer, ""
if not residual:
return ""
if self.has_tool_call(residual) or self.current_tool_name_sent:
return ""
if self.eot_token and self.eot_token in residual:
residual = residual.replace(self.eot_token, "")
if self.prev_tool_call_arr and residual.strip() in ("", self.tool_call_separator.strip()):
return ""
return residual
def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> StreamingParseResult:
"""
Streaming incremental parsing with tool validation.
This base implementation works best with formats where:
1. bot_token is followed immediately by JSON (e.g., bot_token + JSON_array)
2. JSON can be parsed incrementally using partial_json_loads
3. Multiple tool calls are separated by "; " or ", "
Examples of incompatible formats (need custom implementation, may reuse some logic from this class):
- Each tool call is wrapped in a separate block: See Qwen25Detector
- Multiple separate blocks: [TOOL_CALLS] [...] \n [TOOL_CALLS] [...]
- Tool call is Pythonic style
For incompatible formats, detectors should override this method with custom logic.
"""
# Append new text to buffer
self._buffer += new_text
current_text = self._buffer
# The current_text has tool_call if it is the start of a new tool call sequence
# or it is the start of a new tool call after a tool call separator, when there is a previous tool call
if not (
self.has_tool_call(current_text)
or (self.current_tool_id > 0 and current_text.startswith(self.tool_call_separator))
):
# Only clear buffer if we're sure no tool call is starting
if not self._ends_with_partial_token(self._buffer, self.bot_token):
normal_text = self._buffer
self._buffer = ""
if self.eot_token in normal_text:
normal_text = normal_text.replace(self.eot_token, "")
return StreamingParseResult(normal_text=normal_text)
else:
# Might be partial bot_token, keep buffering
return StreamingParseResult()
# Build tool indices if not already built
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
flags = Allow.ALL if self.current_tool_name_sent else Allow.ALL & ~Allow.STR
try:
try:
tool_call_pos = current_text.find(self.bot_token)
if tool_call_pos > 0:
# Normal text precedes the tool tag in this buffer (text and tag
# landed in one chunk): release it before parsing the call so it
# isn't silently dropped when the buffer is later trimmed.
prefix = current_text[:tool_call_pos]
self._buffer = current_text[tool_call_pos:]
if self.eot_token and self.eot_token in prefix:
prefix = prefix.replace(self.eot_token, "")
return StreamingParseResult(normal_text=prefix)
if tool_call_pos != -1:
start_idx = tool_call_pos + len(self.bot_token)
elif self.current_tool_id > 0 and current_text.startswith(self.tool_call_separator):
start_idx = len(self.tool_call_separator)
else:
start_idx = 0
if start_idx >= len(current_text):
return StreamingParseResult()
try:
obj, end_idx = _partial_json_loads(current_text[start_idx:], flags)
except MalformedJSON:
if tool_call_pos == -1 and not self._ends_with_partial_token(
self._buffer, self.bot_token
):
after = current_text[start_idx:].lstrip()
if after and after[0] not in "{[":
# Reached via the separator heuristic after a completed
# call, but what follows can never become another call's
# JSON: trailing normal text — release it instead of
# holding forever. A bare separator / whitespace tail
# stays held (the next call's JSON may still arrive).
normal_text = self._buffer
self._buffer = ""
if self.eot_token and self.eot_token in normal_text:
normal_text = normal_text.replace(self.eot_token, "")
return StreamingParseResult(normal_text=normal_text)
return StreamingParseResult()
is_current_complete = _is_complete_json(current_text[start_idx : start_idx + end_idx])
# Validate tool name if present
if (
"name" in obj
and obj["name"] not in self._tool_indices
and not _should_forward_unknown_tool(obj["name"])
):
# Invalid tool name - reset state
self._buffer = ""
self.current_tool_id = -1
self.current_tool_name_sent = False
if self.streamed_args_for_tool:
self.streamed_args_for_tool.pop()
return StreamingParseResult()
# Handle parameters/arguments consistency
# NOTE: we assume here that the obj is always partial of a single tool call
if "parameters" in obj:
assert "arguments" not in obj, "model generated both parameters and arguments"
obj["arguments"] = obj["parameters"]
current_tool_call = obj
except MalformedJSON:
return StreamingParseResult()
if not current_tool_call:
return StreamingParseResult()
# Case 1: Handle tool name streaming
# This happens when we encounter a tool but haven't sent its name yet
if not self.current_tool_name_sent:
function_name = current_tool_call.get("name")
if function_name and (
function_name in self._tool_indices or _should_forward_unknown_tool(function_name)
):
# If this is a new tool (current_tool_id was -1), initialize it
if self.current_tool_id == -1:
self.current_tool_id = 0
self.streamed_args_for_tool.append("")
# If this is a subsequent tool, ensure streamed_args_for_tool is large enough
elif self.current_tool_id >= len(self.streamed_args_for_tool):
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
# Send the tool name with empty parameters
res = StreamingParseResult(
calls=[
ToolCallItem(
tool_index=self.current_tool_id,
name=function_name,
parameters="",
)
],
)
self.current_tool_name_sent = True
else:
res = StreamingParseResult()
# Case 2: Handle streaming arguments
# This happens when we've already sent the tool name and now need to stream arguments incrementally
else:
cur_arguments = current_tool_call.get("arguments")
res = StreamingParseResult()
# NOTE: `is not None`, not truthiness — an empty-arguments call
# ({}) must still take the completion path below so its buffer is
# consumed and the call closes exactly once (vLLM's hermes parser
# gates on JSON completeness for the same reason).
if cur_arguments is not None:
# Calculate how much of the arguments we've already streamed
sent = len(self.streamed_args_for_tool[self.current_tool_id])
cur_args_json = json.dumps(cur_arguments, ensure_ascii=False)
prev_arguments = None
if self.current_tool_id < len(self.prev_tool_call_arr):
prev_arguments = self.prev_tool_call_arr[self.current_tool_id].get("arguments")
argument_diff = None
# If the current tool's JSON is complete, send all remaining arguments
if is_current_complete:
argument_diff = cur_args_json[sent:]
completing_tool_id = self.current_tool_id # Save the ID of the tool that's completing
# Only remove the processed portion, keep unprocessed content;
# also consume the block's closing tag so it can't jam the
# tool_call_separator heuristic on the next increment.
self._buffer = current_text[start_idx + end_idx :]
if self.eot_token:
after = self._buffer.lstrip()
if after.startswith(self.eot_token):
self._buffer = after[len(self.eot_token):]
if self.current_tool_id < len(self.prev_tool_call_arr):
self.prev_tool_call_arr[self.current_tool_id].clear()
self.current_tool_name_sent = False
self.streamed_args_for_tool[self.current_tool_id] = ""
self.current_tool_id += 1
# If the tool is still being parsed, send incremental changes
elif prev_arguments:
prev_args_json = json.dumps(prev_arguments, ensure_ascii=False)
if cur_args_json != prev_args_json:
prefix = _find_common_prefix(prev_args_json, cur_args_json)
argument_diff = prefix[sent:]
# Send the argument diff if there's something new
if argument_diff is not None:
# Use the correct tool_index: completing_tool_id for completed tools,
# current_tool_id for ongoing
tool_index_to_use = completing_tool_id if is_current_complete else self.current_tool_id
res = StreamingParseResult(
calls=[
ToolCallItem(
tool_index=tool_index_to_use,
parameters=argument_diff,
)
],
)
if not is_current_complete:
self.streamed_args_for_tool[self.current_tool_id] += argument_diff
# Update prev_tool_call_arr with current state
if self.current_tool_id >= 0:
# Ensure prev_tool_call_arr is large enough
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
self.prev_tool_call_arr[self.current_tool_id] = current_tool_call
return res
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}")
return StreamingParseResult()
class InvokeParamStreamMixin:
"""Value-level streaming for invoke/parameter block formats (qwen3_coder,
minimax — the same shape vLLM's ParserEngine covers): text outside blocks
streams live; parameter VALUES whose schema type is string stream char-by-char
as JSON-escaped, prefix-stable fragments; typed values buffer until the
parameter closes so their JSON form is schema-correct.
Subclasses define the grammar via class attributes:
_ps_outer_open/_ps_outer_close wrapper block tokens (may equal invoke tokens)
_ps_invoke_open_prefix/_ps_invoke_open_re (group 1 = name)/_ps_invoke_close
_ps_param_open_prefix/_ps_param_open_re (group 1 = key)/_ps_param_close
_ps_trim chars trimmed around values
_ps_trim_single True: at most ONE leading/trailing trim char (qwen3_coder)
_ps_missing_type schema type assumed for undeclared params ("string" streams,
"loose" buffers and loose-parses — per-family legacy typing)
"""
_ps_outer_open: str = ""
_ps_outer_close: str = ""
_ps_trim: str = "\n"
_ps_trim_single: bool = False
_ps_missing_type: str = "string"
def _ps_reset(self) -> None:
self._ps_mode = "idle"
self._ps_key = ""
self._ps_lead = "{"
self._ps_emitted_any = False
self._ps_lead_trimmed = False
self._ps_param_config: Dict = {}
def _ps_convert_value(self, key: str, raw: str) -> Any:
if key in self._ps_param_config or self._ps_missing_type != "loose":
return self._convert_param_value(raw, key, self._ps_param_config, "")
return _parse_loose_json_value(raw)
def _ps_trim_leading(self, text: str) -> str:
if self._ps_trim_single:
return text[1:] if text[:1] and text[:1] in self._ps_trim else text
return text.lstrip(self._ps_trim)
def _ps_trim_trailing(self, text: str) -> str:
if self._ps_trim_single:
return text[:-1] if text[-1:] and text[-1:] in self._ps_trim else text
return text.rstrip(self._ps_trim)
def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> StreamingParseResult:
self._buffer += new_text
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
if not hasattr(self, "_ps_mode"):
self._ps_reset()
normal_parts: List[str] = []
calls: List[ToolCallItem] = []
def _emit(fragment: str) -> None:
if fragment:
self.streamed_args_for_tool[self.current_tool_id] += fragment
calls.append(
ToolCallItem(tool_index=self.current_tool_id, name=None, parameters=fragment)
)
def _update_prev() -> None:
ledger = self.streamed_args_for_tool[self.current_tool_id]
for probe in (ledger, ledger + "}"):
try:
parsed = json.loads(probe)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(parsed, dict):
self.prev_tool_call_arr[self.current_tool_id]["arguments"] = parsed
return
while True:
buf = self._buffer
if not buf:
break
mode = self._ps_mode
if mode == "idle":
if calls:
break # text after a call defers to the next step (wire order)
pos = buf.find(self._ps_outer_open)
if pos == -1:
hold = self._ends_with_partial_token(buf, self._ps_outer_open)
release = buf[: len(buf) - hold] if hold else buf
if release:
normal_parts.append(release)
self._buffer = buf[len(release):]
break
if pos > 0:
normal_parts.append(buf[:pos])
self._buffer = buf[pos:]
continue
self._buffer = buf[len(self._ps_outer_open):]
self._ps_mode = "block"
continue
if mode == "block":
inv = buf.find(self._ps_invoke_open_prefix)
close = buf.find(self._ps_outer_close) if self._ps_outer_close else -1
if close != -1 and (inv == -1 or close < inv):
self._buffer = buf[close + len(self._ps_outer_close):]
self._ps_mode = "idle"
continue
if inv != -1:
m = self._ps_invoke_open_re.search(buf, inv)
if m is None:
break # invoke tag still streaming
func_name = m.group(1).strip()
if self.current_tool_id == -1:
self.current_tool_id = 0
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("")
self._buffer = buf[m.end():]
if func_name in self._tool_indices or _should_forward_unknown_tool(func_name):
calls.append(
ToolCallItem(
tool_index=self.current_tool_id, name=func_name, parameters=""
)
)
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
self._args_started = False
self._ps_param_config = self._get_param_config(func_name, tools)
self._ps_mode = "invoke"
else:
logger.warning(f"Model attempted to call undefined function: {func_name}")
self._ps_mode = "invoke_skip"
continue
hold = max(
self._ends_with_partial_token(buf, self._ps_invoke_open_prefix),
self._ends_with_partial_token(buf, self._ps_outer_close)
if self._ps_outer_close
else 0,
)
if len(buf) - hold > 0:
self._buffer = buf[len(buf) - hold:] # inter-invoke whitespace
break
if mode in ("invoke", "invoke_skip"):
p = buf.find(self._ps_param_open_prefix)
e = buf.find(self._ps_invoke_close)
if e != -1 and (p == -1 or e < p):
if mode == "invoke":
_emit("}" if self._args_started else "{}")
_update_prev()
self.current_tool_id += 1
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
self._buffer = buf[e + len(self._ps_invoke_close):]
self._ps_mode = "block"
continue
if p != -1:
m = self._ps_param_open_re.search(buf, p)
if m is None:
break # parameter tag still streaming
self._buffer = buf[m.end():]
if mode == "invoke_skip":
self._ps_mode = "pskip"
continue
self._ps_key = m.group(1).strip()
lead = "{" if not self._args_started else ","
self._args_started = True
ptype = self._schema_param_type(
self._ps_key, self._ps_param_config, self._ps_missing_type
)
if ptype in ("string", "str", "enum"):
_emit(lead + json.dumps(self._ps_key, ensure_ascii=False) + ':"')
self._ps_emitted_any = False
self._ps_lead_trimmed = False
self._ps_mode = "pstr"
else:
self._ps_lead = lead
self._ps_mode = "pbuf"
continue
hold = max(
self._ends_with_partial_token(buf, self._ps_param_open_prefix),
self._ends_with_partial_token(buf, self._ps_invoke_close),
)
if len(buf) - hold > 0:
self._buffer = buf[len(buf) - hold:] # whitespace between parameters
break
if mode == "pstr":
if not self._ps_lead_trimmed:
trimmed = self._ps_trim_leading(buf)
if trimmed != buf:
self._buffer = trimmed
if self._ps_trim_single or trimmed:
self._ps_lead_trimmed = bool(trimmed) or self._ps_trim_single
continue
if buf:
self._ps_lead_trimmed = True
end = buf.find(self._ps_param_close)
if end == -1:
hold = self._ends_with_partial_token(buf, self._ps_param_close)
safe = buf[: len(buf) - hold] if hold else buf
keep = len(safe) - len(safe.rstrip(self._ps_trim))
emit_now = safe[: len(safe) - keep]
if emit_now:
_emit(self._json_escape_chunk(emit_now))
self._ps_emitted_any = True
self._buffer = buf[len(emit_now):]
break
tail = self._ps_trim_trailing(buf[:end])
_emit(self._json_escape_chunk(tail) + '"')
_update_prev()
self._buffer = buf[end + len(self._ps_param_close):]
self._ps_mode = "invoke"
continue
if mode in ("pbuf", "pskip"):
end = buf.find(self._ps_param_close)
if end == -1:
break # hold the whole value until the parameter closes
if mode == "pbuf":
raw = self._ps_trim_trailing(self._ps_trim_leading(buf[:end]))
converted = self._ps_convert_value(self._ps_key, raw)
_emit(
self._ps_lead
+ json.dumps(self._ps_key, ensure_ascii=False)
+ ":"
+ json.dumps(converted, ensure_ascii=False)
)
_update_prev()
self._buffer = buf[end + len(self._ps_param_close):]
self._ps_mode = "invoke" if mode == "pbuf" else "invoke_skip"
continue
return StreamingParseResult(normal_text="".join(normal_parts), calls=calls)
def finish_streaming(self) -> str:
residual, self._buffer = self._buffer, ""
mode = getattr(self, "_ps_mode", "idle")
self._ps_reset()
if mode != "idle" or (self._ps_outer_open and self._ps_outer_open in residual):
return ""
if self.prev_tool_call_arr and residual.strip() == "":
return ""
return residual
class Qwen25Detector(BaseFormatDetector):
"""
Detector for Qwen 2.5 and Qwen 3 model function call format.
Format Structure:
```
<tool_call>\n{"name":"func1", "arguments":{...}}\n
</tool_call>\n<tool_call>\n{"name":"func2", "arguments":{...}}\n</tool_call>
```
Key Components:
- Tool Call Tags: `<tool_call>` and `</tool_call>` wrap each individual call
- Function Call Object: JSON object with "name" and "arguments" fields
Reference: https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct?chat_template=default
"""
toolcall_opener = "<tool_call>"
def __init__(self):
"""
Initializes the detector with necessary state variables.
"""
super().__init__()
self.bot_token = "<tool_call>"
self.eot_token = "</tool_call>"
self.tool_call_separator = "\n"
self._normal_text_buffer = "" # Buffer for handling partial end tokens
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a Qwen 2.5 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=[])
# Find all <tool_call>\n...\n</tool_call> blocks
pattern = rf"{re.escape(self.bot_token)}(.*?){re.escape(self.eot_token)}"
match_result_list = re.findall(pattern, text, re.DOTALL)
calls = []
for match_result in match_result_list:
try:
parsed_call = json.loads(match_result.strip())
calls.extend(self.parse_base_json(parsed_call, tools))
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse JSON part: {match_result}, JSON parse error: {str(e)}")
continue
return StreamingParseResult(normal_text=normal_text, calls=calls)
def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> StreamingParseResult:
"""
Streaming incremental parsing for Qwen 2.5 tool calls.
Uses base class implementation with buffering to handle partial end tokens.
"""
result = super().parse_streaming_increment(new_text, tools)
# Handle partial end tokens that are streamed character by character
if result.normal_text:
self._normal_text_buffer += result.normal_text
# Check if buffer contains complete end token (without leading newline)
end_token_without_newline = self.eot_token # "</tool_call>"
if end_token_without_newline in self._normal_text_buffer:
cleaned_text = self._normal_text_buffer.replace(end_token_without_newline, "")
self._normal_text_buffer = ""
result.normal_text = cleaned_text
else:
# Check if buffer might contain partial end token at the end
partial_match_len = self._ends_with_partial_token(self._normal_text_buffer, end_token_without_newline)
if partial_match_len:
# Keep potential partial match in buffer, return the rest
result.normal_text = self._normal_text_buffer[:-partial_match_len]
self._normal_text_buffer = self._normal_text_buffer[-partial_match_len:]
else:
# No partial match, return all buffered text
result.normal_text = self._normal_text_buffer
self._normal_text_buffer = ""
return result
def finish_streaming(self) -> str:
residual = super().finish_streaming()
held, self._normal_text_buffer = self._normal_text_buffer, ""
return held + residual
class MistralDetector(BaseFormatDetector):
"""
Detector for Mistral model function call format.
The Mistral format uses a simple bracket-delimited structure with JSON arrays
containing function call objects.
Format Structure:
```
[TOOL_CALLS] [{"name": "function_name", "arguments": {json_args}}, ...]
```
Reference: https://huggingface.co/mistralai/Mistral-7B-Instruct-v0.3?chat_template=default
"""
def __init__(self):
"""
Initializes the detector with necessary state variables.
"""
super().__init__()
self.bot_token = "[TOOL_CALLS] ["
self.eot_token = "]"
self.tool_call_regex = re.compile(r"\[{.*}\]", re.DOTALL)
self.tool_call_separator = ", "
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a Mistral 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=[])
# Extract the JSON array part from [TOOL_CALLS] [...]
# Use bracket counting to properly handle nested brackets in JSON content
json_array_str = self._extract_json_array(text)
if not json_array_str:
return StreamingParseResult(normal_text=normal_text, calls=[])
calls = []
try:
function_call_arr = json.loads(json_array_str)
# Handle both single object and array of objects
if not isinstance(function_call_arr, list):
function_call_arr = [function_call_arr]
calls = self.parse_base_json(function_call_arr, tools)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse JSON part: {json_array_str}, JSON parse error: {str(e)}")
return StreamingParseResult(normal_text=normal_text, calls=calls)
def _extract_json_array(self, text: str) -> str:
"""
Extract the JSON array part using bracket counting to handle nested brackets.
:param text: The complete text containing [TOOL_CALLS] [...]
:return: The JSON array string or None if not found
"""
start_idx = text.find(self.bot_token)
if start_idx == -1:
return None
# Start from the opening bracket after [TOOL_CALLS]
json_start = start_idx + len(self.bot_token) - 1 # -1 to include the opening bracket
bracket_count = 0
in_string = False
escape_next = False
for i in range(json_start, len(text)):
char = text[i]
if escape_next:
escape_next = False
continue
if char == "\\":
escape_next = True
continue
if char == '"' and not escape_next:
in_string = not in_string
continue
if not in_string:
if char == "[":
bracket_count += 1
elif char == "]":
bracket_count -= 1
if bracket_count == 0:
return text[json_start : i + 1]
return None
class Llama32Detector(BaseFormatDetector):
"""
Detector for Llama 3.2 models with json tool call format.
Format Structure:
```
<python_tag>{"name":"xxx", "arguments":{...}}
```
"""
toolcall_opener = "<|python_tag|>"
def __init__(self):
super().__init__()
self.bot_token = "<|python_tag|>"
# NOTE: technically Llama3.2 doesn't support well with parallel tool calls
# They need specific prompt engineering to support parallel tool calls
# Here we use ';' as the separator, which might have compatibility issues
# if users define to use a different separator in their prompt
self.tool_call_separator = ";"
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a Llama 3.2 format tool call."""
# depending on the prompt format the Llama model may or may not
# prefix the output with the <|python_tag|> token
return "<|python_tag|>" in text or text.startswith("{")
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""Parse function calls from text, handling multiple JSON objects."""
if "<|python_tag|>" not in text and not text.startswith("{"):
return StreamingParseResult(normal_text=text, calls=[])
if "<|python_tag|>" in text:
normal_text, action_text = text.split("<|python_tag|>", maxsplit=1)
else:
normal_text, action_text = "", text
decoder = json.JSONDecoder()
idx = 0
safe_idx = idx # the index of the last valid JSON object
all_actions = []
action_text_len = len(action_text)
while idx < action_text_len:
try:
obj, end = decoder.raw_decode(action_text[idx:])
all_actions.append(obj)
idx += end + len(self.tool_call_separator)
safe_idx = idx
except json.JSONDecodeError as e:
# Find where next `{"name"` appears and try again
logger.warning(f"Failed to parse JSON part: {action_text[idx:]}, JSON parse error: {str(e)}")
next_obj_start = action_text.find('{"name":', idx + 1)
if next_obj_start == -1:
break
idx = next_obj_start
continue
# Only process if we found valid JSON objects
calls = self.parse_base_json(all_actions, tools) if all_actions else []
# Use safe_idx to avoid idx containing the last part of an invalid JSON object
trailing_text = action_text[safe_idx:].strip() if safe_idx < action_text_len else ""
return StreamingParseResult(normal_text=normal_text + trailing_text, calls=calls)
class Glm47Detector(BaseFormatDetector):
"""
Detector for GLM-4.7/GLM-4.7-Flash model function call format.
The GLM-4.7 format uses an XML-style envelope with arg_key/arg_value pairs
instead of JSON arguments.
Format Structure:
```
<tool_call>function_name
<arg_key>param1</arg_key>
<arg_value>value1</arg_value>
<arg_key>param2</arg_key>
<arg_value>value2</arg_value>
</tool_call>
```
Example:
```
<tool_call>tool_brave_web_search_post
<arg_key>query</arg_key>
<arg_value>test search</arg_value>
<arg_key>count</arg_key>
<arg_value>5</arg_value>
</tool_call>
```
Key Components:
- Tool Call Tags: `<tool_call>` and `</tool_call>` wrap each individual call
- Function Name: Appears on the first line after `<tool_call>`
- Arguments: Pairs of `<arg_key>name</arg_key>` and `<arg_value>value</arg_value>`
Reference: https://github.com/vllm-project/vllm/blob/main/vllm/tool_parsers/glm4_moe_tool_parser.py
"""
toolcall_opener = "<tool_call>"
def __init__(self):
super().__init__()
self.bot_token = "<tool_call>"
self.eot_token = "</tool_call>"
self.tool_call_separator = "\n"
# Regex patterns for parsing GLM-4.7 tool calls
# Match complete tool call blocks
self.func_call_regex = re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL)
# Extract function name and arguments from a tool call block
# Function name can be followed by newline OR directly by <arg_key>
# Pattern: <tool_call>function_name(\n|<arg_key>)...
self.func_detail_regex = re.compile(
r"<tool_call>([^<\n]+?)(?:\n|(?=<arg_key>)|(?=</tool_call>))(.*?)</tool_call>", re.DOTALL
)
# Extract arg_key/arg_value pairs
self.func_arg_regex = re.compile(r"<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>", re.DOTALL)
self._last_arguments = ""
self._normal_text_buffer = ""
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a GLM-4.7 format tool call."""
return self.bot_token in text
def _parse_xml_arguments(self, arg_text: str, param_config: Dict | None = None, func_name: str = "") -> dict:
"""
Parse XML-style arguments into a dictionary.
Args:
arg_text: The text containing <arg_key>/<arg_value> pairs
Returns:
Dictionary of argument name to value
"""
if not arg_text:
return {}
args = {}
matches = self.func_arg_regex.findall(arg_text)
for key, value in matches:
key = key.strip()
value = value.strip()
if param_config and key in param_config:
# Schema-first: the declared type wins (a string-typed "5" stays "5").
args[key] = self._convert_param_value(value, key, param_config, func_name)
continue
# Undeclared parameter: legacy loose typing.
try:
parsed_value = json.loads(value)
args[key] = parsed_value
except (json.JSONDecodeError, ValueError):
args[key] = value
return args
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: StreamingParseResult with normal_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=[])
tool_indices = self._get_tool_indices(tools)
calls = []
# Find all <tool_call>...</tool_call> blocks
match_result_list = self.func_call_regex.findall(text)
for match_result in match_result_list:
try:
# Extract function name and arguments
func_detail = self.func_detail_regex.search(match_result)
if not func_detail:
logger.warning(f"Failed to parse GLM-4.7 tool call: {match_result}")
continue
func_name = func_detail.group(1).strip()
arg_text = func_detail.group(2) if func_detail.group(2) else ""
# Validate function name
if func_name not in tool_indices and not _should_forward_unknown_tool(func_name):
logger.warning(f"Model attempted to call undefined function: {func_name}")
continue
# Parse XML arguments to JSON (schema-first typing, loose fallback)
func_args = self._parse_xml_arguments(
arg_text, self._get_param_config(func_name, tools), func_name
)
calls.append(
ToolCallItem(
tool_index=tool_indices.get(func_name, len(calls)),
name=func_name,
parameters=json.dumps(func_args, ensure_ascii=False),
)
)
except Exception as e:
logger.warning(f"Failed to parse GLM-4.7 tool call: {match_result}, error: {str(e)}")
continue
return StreamingParseResult(normal_text=normal_text, calls=calls)
_G_KEY_OPEN = "<arg_key>"
_G_KEY_CLOSE = "</arg_key>"
_G_VAL_OPEN = "<arg_value>"
_G_VAL_CLOSE = "</arg_value>"
def _g_reset(self) -> None:
self._g_mode = "idle" # idle|name|invoke|invoke_skip|key|key_skip|preval|preval_skip|pstr|pbuf|pskip
self._g_key = ""
self._g_lead = "{"
self._g_config: Dict = {}
self._g_lead_trimmed = False
def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> StreamingParseResult:
"""Value-level streaming for GLM-4.7: text outside blocks streams live;
string-typed <arg_value> content streams char-by-char as JSON-escaped
prefix-stable fragments; typed values buffer until the value closes."""
self._buffer += new_text
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
if not hasattr(self, "_g_mode"):
self._g_reset()
normal_parts: List[str] = []
calls: List[ToolCallItem] = []
def _emit(fragment: str) -> None:
if fragment:
self.streamed_args_for_tool[self.current_tool_id] += fragment
calls.append(
ToolCallItem(tool_index=self.current_tool_id, name=None, parameters=fragment)
)
def _update_prev() -> None:
ledger = self.streamed_args_for_tool[self.current_tool_id]
for probe in (ledger, ledger + "}"):
try:
parsed = json.loads(probe)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(parsed, dict):
self.prev_tool_call_arr[self.current_tool_id]["arguments"] = parsed
return
while True:
buf = self._buffer
if not buf:
break
mode = self._g_mode
if mode == "idle":
if calls:
break # text after a call defers to the next step (wire order)
pos = buf.find(self.bot_token)
if pos == -1:
hold = self._ends_with_partial_token(buf, self.bot_token)
release = buf[: len(buf) - hold] if hold else buf
if release:
normal_parts.append(release)
self._buffer = buf[len(release):]
break
if pos > 0:
normal_parts.append(buf[:pos])
self._buffer = buf[pos:]
continue
self._buffer = buf[len(self.bot_token):]
self._g_mode = "name"
continue
if mode == "name":
# The function name runs until a newline, the first <arg_key>, or
# the closing tag (whichever comes first).
ends = [p for p in (buf.find("\n"), buf.find(self._G_KEY_OPEN), buf.find(self.eot_token)) if p != -1]
if not ends:
break # name still streaming (names are short — hold)
cut = min(ends)
func_name = buf[:cut].strip()
self._buffer = buf[cut + 1:] if buf[cut] == "\n" else buf[cut:]
if self.current_tool_id == -1:
self.current_tool_id = 0
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("")
if func_name and (
func_name in self._tool_indices or _should_forward_unknown_tool(func_name)
):
calls.append(
ToolCallItem(tool_index=self.current_tool_id, name=func_name, parameters="")
)
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
self._args_started = False
self._g_config = self._get_param_config(func_name, tools)
self._g_mode = "invoke"
else:
logger.warning(f"Model attempted to call undefined function: {func_name}")
self._g_mode = "invoke_skip"
continue
if mode in ("invoke", "invoke_skip"):
k = buf.find(self._G_KEY_OPEN)
e = buf.find(self.eot_token)
if e != -1 and (k == -1 or e < k):
if mode == "invoke":
_emit("}" if self._args_started else "{}")
_update_prev()
self.current_tool_id += 1
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
self._buffer = buf[e + len(self.eot_token):]
self._g_mode = "idle"
continue
if k != -1:
self._buffer = buf[k + len(self._G_KEY_OPEN):]
self._g_mode = "key" if mode == "invoke" else "key_skip"
continue
hold = max(
self._ends_with_partial_token(buf, self._G_KEY_OPEN),
self._ends_with_partial_token(buf, self.eot_token),
)
if len(buf) - hold > 0:
self._buffer = buf[len(buf) - hold:] # whitespace between elements
break
if mode in ("key", "key_skip"):
end = buf.find(self._G_KEY_CLOSE)
if end == -1:
break # keys are short — hold until complete
self._g_key = buf[:end].strip()
self._buffer = buf[end + len(self._G_KEY_CLOSE):]
self._g_mode = "preval" if mode == "key" else "preval_skip"
continue
if mode in ("preval", "preval_skip"):
v = buf.find(self._G_VAL_OPEN)
if v == -1:
hold = self._ends_with_partial_token(buf, self._G_VAL_OPEN)
if len(buf) - hold > 0:
self._buffer = buf[len(buf) - hold:] # whitespace between key and value
break
self._buffer = buf[v + len(self._G_VAL_OPEN):]
if mode == "preval_skip":
self._g_mode = "pskip"
continue
lead = "{" if not self._args_started else ","
self._args_started = True
ptype = self._schema_param_type(self._g_key, self._g_config, "loose")
if ptype in ("string", "str", "enum"):
_emit(lead + json.dumps(self._g_key, ensure_ascii=False) + ':"')
self._g_lead_trimmed = False
self._g_mode = "pstr"
else:
self._g_lead = lead
self._g_mode = "pbuf"
continue
if mode == "pstr":
if not self._g_lead_trimmed:
trimmed = buf.lstrip()
if trimmed != buf:
self._buffer = trimmed
continue
self._g_lead_trimmed = True
end = buf.find(self._G_VAL_CLOSE)
if end == -1:
hold = self._ends_with_partial_token(buf, self._G_VAL_CLOSE)
safe = buf[: len(buf) - hold] if hold else buf
keep = len(safe) - len(safe.rstrip())
emit_now = safe[: len(safe) - keep]
if emit_now:
_emit(self._json_escape_chunk(emit_now))
self._buffer = buf[len(emit_now):]
break
tail = buf[:end].rstrip()
_emit(self._json_escape_chunk(tail) + '"')
_update_prev()
self._buffer = buf[end + len(self._G_VAL_CLOSE):]
self._g_mode = "invoke"
continue
if mode in ("pbuf", "pskip"):
end = buf.find(self._G_VAL_CLOSE)
if end == -1:
break # hold the whole value until it closes
if mode == "pbuf":
raw = buf[:end].strip()
if self._g_key in self._g_config:
converted = self._convert_param_value(raw, self._g_key, self._g_config, "")
else:
try:
converted = json.loads(raw)
except (json.JSONDecodeError, ValueError):
converted = raw
_emit(
self._g_lead
+ json.dumps(self._g_key, ensure_ascii=False)
+ ":"
+ json.dumps(converted, ensure_ascii=False)
)
_update_prev()
self._buffer = buf[end + len(self._G_VAL_CLOSE):]
self._g_mode = "invoke" if mode == "pbuf" else "invoke_skip"
continue
return StreamingParseResult(normal_text="".join(normal_parts), calls=calls)
def finish_streaming(self) -> str:
residual, self._buffer = self._buffer, ""
mode = getattr(self, "_g_mode", "idle")
self._g_reset()
if mode != "idle" or self.bot_token in residual:
return ""
if self.eot_token in residual:
residual = residual.replace(self.eot_token, "")
if self.prev_tool_call_arr and residual.strip() == "":
return ""
return residual
class DeepSeekV32Detector(BaseFormatDetector):
"""
Detector for DeepSeek V3.2 model function call format using DSML
(DeepSeek Markup Language).
Format Structure:
```
<|DSML|function_calls>
<|DSML|invoke name="get_weather">
<|DSML|parameter name="location" string="true">Hangzhou</|DSML|parameter>
<|DSML|parameter name="date" string="true">2024-01-16</|DSML|parameter>
</|DSML|invoke>
</|DSML|function_calls>
```
Key Components:
- Function Calls Block: `<|DSML|function_calls>` ... `</|DSML|function_calls>`
- Individual Invocation: `<|DSML|invoke name="func">` ... `</|DSML|invoke>`
- Parameters: `<|DSML|parameter name="key" string="true|false">value</|DSML|parameter>`
- string="true": value is plain text (will be JSON-escaped)
- string="false": value is JSON (numbers, booleans, arrays, objects)
- Supports multiple parallel tool calls
Reference: https://huggingface.co/deepseek-ai/DeepSeek-V3.2
"""
def __init__(self):
super().__init__()
self.dsml_token = "|DSML|"
self.bot_token = f"<{self.dsml_token}function_calls>"
self.eot_token = f"</{self.dsml_token}function_calls>"
self.alt_bot_token = f"<{self.dsml_token}tool_calls>"
self.alt_eot_token = f"</{self.dsml_token}tool_calls>"
self.invoke_start_prefix = f"<{self.dsml_token}invoke"
self.invoke_end_token = f"</{self.dsml_token}invoke>"
self.param_end_token = f"</{self.dsml_token}parameter>"
# Regex for complete invoke extraction
_de = re.escape(self.dsml_token)
self.invoke_regex = re.compile(
rf'<{_de}invoke\s+name="([^"]+)"\s*>(.*?)</{_de}invoke>',
re.DOTALL,
)
# Regex for parameter extraction
self.param_regex = re.compile(
rf'<{_de}parameter\s+name="([^"]+)"(?:\s+string="(true|false)")?\s*>(.*?)</{_de}parameter>',
re.DOTALL,
)
# Regex for partial invoke (name known, body still streaming)
self.partial_invoke_regex = re.compile(
rf'<{_de}invoke\s+name="([^"]+)"\s*>(.*)',
re.DOTALL,
)
# Streaming state machine tag regexes (anchored matches over the buffer).
self.invoke_open_regex = re.compile(rf'<{_de}invoke\s+name="([^"]+)"\s*>')
self.param_open_regex = re.compile(
rf'<{_de}parameter\s+name="([^"]+)"(?:\s+string="(true|false)")?\s*>'
)
self._last_arguments = ""
self._accumulated_params: List[tuple] = []
self._in_function_calls = False # Track if we're inside a function_calls block
# Streaming state machine (vLLM deepseekv32-parser style):
# idle | block | invoke | invoke_skip | pstr | pbuf | pskip
self._ds_mode = "idle"
self._args_started = False
self._param_name = ""
self._param_lead = "{"
def block_close_tokens(self) -> tuple:
return (self.eot_token, self.alt_eot_token)
def has_tool_call(self, text: str) -> bool:
return self.bot_token in text or self.alt_bot_token in text
def _param_fragment(self, index: int, name: str, is_str: str, value: str) -> str:
"""Prefix-stable arguments fragment for one closed DSML parameter: the
object opener (or separator) plus ``"name": value``, serialized exactly as
json.dumps of the full dict would, so concatenated fragments + the closing
brace equal the final arguments JSON byte-for-byte."""
if is_str == "true":
parsed: Any = value
else:
try:
parsed = json.loads(value)
except (json.JSONDecodeError, ValueError):
parsed = value
lead = "{" if index == 0 else ", "
return (
lead
+ json.dumps(name, ensure_ascii=False)
+ ": "
+ json.dumps(parsed, ensure_ascii=False)
)
def _dsml_params_to_json(self, params: List[tuple]) -> str:
"""Convert DSML parameter tuples (name, is_str, value) to a JSON arguments string."""
args = {}
for name, is_str, value in params:
if is_str == "true":
args[name] = value
else:
try:
args[name] = json.loads(value)
except (json.JSONDecodeError, ValueError):
args[name] = value
return json.dumps(args, ensure_ascii=False)
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""One-time parsing for DSML format tool calls."""
idx = _first_existing_pos(text, [self.bot_token, self.alt_bot_token])
normal_text = text[:idx].strip() if idx != -1 else text
if idx == -1:
return StreamingParseResult(normal_text=normal_text, calls=[])
tool_indices = self._get_tool_indices(tools)
calls = []
invoke_matches = self.invoke_regex.findall(text)
for func_name, invoke_body in invoke_matches:
if func_name not in tool_indices and not _should_forward_unknown_tool(func_name):
logger.warning(f"Model attempted to call undefined function: {func_name}")
continue
param_matches = self.param_regex.findall(invoke_body)
args_json = self._dsml_params_to_json(param_matches)
calls.append(
ToolCallItem(
tool_index=tool_indices.get(func_name, len(calls)),
name=func_name,
parameters=args_json,
)
)
return StreamingParseResult(normal_text=normal_text, calls=calls)
def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> StreamingParseResult:
"""Streaming incremental parsing for DSML tool calls, modeled on vLLM's
deepseekv32 parser: text outside blocks streams live; ``string="true"``
parameter VALUES stream char-by-char as JSON-escaped, prefix-stable
argument fragments (``{"key":"`` at parameter open, escaped value chars,
``"`` at close, ``}`` at invoke close); non-string values buffer until the
parameter closes because their JSON form needs the complete text."""
self._buffer += new_text
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
normal_parts: List[str] = []
calls: List[ToolCallItem] = []
param_open_token = f"<{self.dsml_token}parameter"
def _emit_args(fragment: str) -> None:
if fragment:
self.streamed_args_for_tool[self.current_tool_id] += fragment
calls.append(
ToolCallItem(
tool_index=self.current_tool_id, name=None, parameters=fragment
)
)
def _update_prev_args() -> None:
ledger = self.streamed_args_for_tool[self.current_tool_id]
for probe in (ledger, ledger + "}"):
try:
parsed = json.loads(probe)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(parsed, dict):
self.prev_tool_call_arr[self.current_tool_id]["arguments"] = parsed
return
while True:
buf = self._buffer
if not buf:
break
if self._ds_mode == "idle":
if calls:
break # text after a call defers to the next step (wire order)
pos = _first_existing_pos(buf, [self.bot_token, self.alt_bot_token])
if pos == -1:
hold = max(
self._ends_with_partial_token(buf, self.bot_token),
self._ends_with_partial_token(buf, self.alt_bot_token),
)
release = buf[: len(buf) - hold] if hold else buf
if release:
for e_token in (self.eot_token, self.alt_eot_token, self.invoke_end_token):
if e_token in release:
release = release.replace(e_token, "")
if release:
normal_parts.append(release)
self._buffer = buf[len(buf) - hold:] if hold else ""
break
if pos > 0:
normal_parts.append(buf[:pos])
self._buffer = buf[pos:]
continue
opener = self.bot_token if buf.startswith(self.bot_token) else self.alt_bot_token
self._buffer = buf[len(opener):]
self._in_function_calls = True
self._ds_mode = "block"
continue
if self._ds_mode == "block":
inv = buf.find(self.invoke_start_prefix)
close = _first_existing_pos(buf, [self.eot_token, self.alt_eot_token])
if close != -1 and (inv == -1 or close < inv):
matched = (
self.eot_token
if buf[close : close + len(self.eot_token)] == self.eot_token
else self.alt_eot_token
)
self._buffer = buf[close + len(matched):]
self._in_function_calls = False
self._ds_mode = "idle"
continue
if inv != -1:
m = self.invoke_open_regex.search(buf, inv)
if m is None:
break # invoke tag still streaming
func_name = m.group(1)
if self.current_tool_id == -1:
self.current_tool_id = 0
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("")
self._buffer = buf[m.end():]
if func_name in self._tool_indices or _should_forward_unknown_tool(func_name):
calls.append(
ToolCallItem(
tool_index=self.current_tool_id, name=func_name, parameters=""
)
)
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
self._args_started = False
self._ds_mode = "invoke"
else:
logger.warning(f"Model attempted to call undefined function: {func_name}")
self._ds_mode = "invoke_skip"
continue
hold = max(
self._ends_with_partial_token(buf, self.invoke_start_prefix),
self._ends_with_partial_token(buf, self.eot_token),
self._ends_with_partial_token(buf, self.alt_eot_token),
)
if len(buf) - hold > 0:
self._buffer = buf[len(buf) - hold:] # inter-invoke whitespace
break
if self._ds_mode in ("invoke", "invoke_skip"):
p = buf.find(param_open_token)
e = buf.find(self.invoke_end_token)
if e != -1 and (p == -1 or e < p):
if self._ds_mode == "invoke":
_emit_args("}" if self._args_started else "{}")
_update_prev_args()
self.current_tool_id += 1
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
self._buffer = buf[e + len(self.invoke_end_token):]
self._ds_mode = "block"
continue
if p != -1:
m = self.param_open_regex.search(buf, p)
if m is None:
break # parameter tag still streaming
self._buffer = buf[m.end():]
if self._ds_mode == "invoke_skip":
self._ds_mode = "pskip"
continue
self._param_name = m.group(1)
lead = "{" if not self._args_started else ","
self._args_started = True
if m.group(2) == "true":
_emit_args(lead + json.dumps(self._param_name, ensure_ascii=False) + ':"')
self._ds_mode = "pstr"
else:
self._param_lead = lead
self._ds_mode = "pbuf"
continue
hold = max(
self._ends_with_partial_token(buf, param_open_token),
self._ends_with_partial_token(buf, self.invoke_end_token),
)
if len(buf) - hold > 0:
self._buffer = buf[len(buf) - hold:] # whitespace between parameters
break
if self._ds_mode == "pstr":
end = buf.find(self.param_end_token)
if end == -1:
hold = self._ends_with_partial_token(buf, self.param_end_token)
emit_len = len(buf) - hold
if emit_len > 0:
_emit_args(json.dumps(buf[:emit_len], ensure_ascii=False)[1:-1])
self._buffer = buf[emit_len:]
break
_emit_args(json.dumps(buf[:end], ensure_ascii=False)[1:-1] + '"')
self._buffer = buf[end + len(self.param_end_token):]
_update_prev_args()
self._ds_mode = "invoke"
continue
if self._ds_mode in ("pbuf", "pskip"):
end = buf.find(self.param_end_token)
if end == -1:
break # hold the whole value until the parameter closes
if self._ds_mode == "pbuf":
value = buf[:end]
try:
parsed: Any = json.loads(value)
except (json.JSONDecodeError, ValueError):
parsed = value
_emit_args(
self._param_lead
+ json.dumps(self._param_name, ensure_ascii=False)
+ ":"
+ json.dumps(parsed, ensure_ascii=False)
)
_update_prev_args()
self._buffer = buf[end + len(self.param_end_token):]
self._ds_mode = "invoke" if self._ds_mode == "pbuf" else "invoke_skip"
continue
return StreamingParseResult(normal_text="".join(normal_parts), calls=calls)
def finish_streaming(self) -> str:
residual, self._buffer = self._buffer, ""
mode, self._ds_mode = self._ds_mode, "idle"
self._in_function_calls = False
if mode != "idle" or self.has_tool_call(residual):
return ""
for e_token in (self.eot_token, self.alt_eot_token, self.invoke_end_token):
if e_token in residual:
residual = residual.replace(e_token, "")
if self.prev_tool_call_arr and residual.strip() == "":
return ""
return residual
class Qwen3CoderDetector(InvokeParamStreamMixin, BaseFormatDetector):
toolcall_opener = "<tool_call>"
_ps_trim = "\n"
_ps_trim_single = True
_ps_missing_type = "string"
"""
Detector for Qwen3-Coder XML-style function call format.
Format Structure:
```
<tool_call>
<function=function_name>
<parameter=param1>
value1
</parameter>
<parameter=param2>
value2
</parameter>
</function>
</tool_call>
```
Key differences from Qwen25Detector (JSON-based):
- Parameters are XML key-value pairs, not JSON objects
- Function name is embedded in the <function=> tag attribute
- Values need schema-aware type conversion (string by default)
Reference: https://docs.vllm.ai/projects/recipes/en/latest/Qwen/Qwen3-Coder-480B-A35B.html
"""
def __init__(self):
super().__init__()
self.bot_token = "<tool_call>"
self.eot_token = "</tool_call>"
self.tool_call_separator = "\n"
# Regex patterns
self.tool_call_block_regex = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL)
self.function_regex = re.compile(r"<function=(.*?)</function>|<function=(.*)$", re.DOTALL)
self.parameter_regex = re.compile(
r"<parameter=(.*?)(?:</parameter>|(?=<parameter=)|(?=</function>)|$)", re.DOTALL
)
self._normal_text_buffer = ""
# InvokeParamStreamMixin grammar
self._ps_outer_open = "<tool_call>"
self._ps_outer_close = "</tool_call>"
self._ps_invoke_open_prefix = "<function="
self._ps_invoke_open_re = re.compile(r"<function=([^>]*)>")
self._ps_invoke_close = "</function>"
self._ps_param_open_prefix = "<parameter="
self._ps_param_open_re = re.compile(r"<parameter=([^>]*)>")
self._ps_param_close = "</parameter>"
self._ps_reset()
def has_tool_call(self, text: str) -> bool:
return "<function=" in text or self.bot_token in text
def _parse_function_call(self, function_str: str, tools: List[Tool]) -> Optional[ToolCallItem]:
"""Parse a single <function=name>...</function> block into a ToolCallItem."""
try:
end_index = function_str.index(">")
except ValueError:
return None
func_name = function_str[:end_index].strip()
tool_indices = self._get_tool_indices(tools)
if func_name not in tool_indices and not _should_forward_unknown_tool(func_name):
logger.warning(f"Model attempted to call undefined function: {func_name}")
return None
parameters_text = function_str[end_index + 1 :]
param_config = self._get_param_config(func_name, tools)
param_dict = {}
for match in self.parameter_regex.findall(parameters_text):
try:
idx = match.index(">")
except ValueError:
continue
param_name = match[:idx].strip()
param_value = match[idx + 1 :]
# Strip leading/trailing newlines from value
if param_value.startswith("\n"):
param_value = param_value[1:]
if param_value.endswith("\n"):
param_value = param_value[:-1]
param_dict[param_name] = self._convert_param_value(param_value, param_name, param_config, func_name)
return ToolCallItem(
tool_index=tool_indices.get(func_name, 0),
name=func_name,
parameters=json.dumps(param_dict, ensure_ascii=False),
)
def _build_partial_arguments_json(self, func_name: str, partial_body: str, tools: List[Tool]) -> Optional[str]:
"""Build the current argument JSON from a partial XML tool-call body."""
param_matches = self.parameter_regex.findall(partial_body)
if not param_matches:
return None
param_config = self._get_param_config(func_name, tools)
param_dict = {}
has_visible_value = False
for match in param_matches:
try:
idx = match.index(">")
except ValueError:
continue
param_name = match[:idx].strip()
param_value = match[idx + 1 :]
if param_value.startswith("\n"):
param_value = param_value[1:]
if param_value.endswith("\n"):
param_value = param_value[:-1]
if param_value.strip():
has_visible_value = True
elif (
f"<parameter={param_name}>" in partial_body
and f"<parameter={param_name}>{param_value}</parameter>" in partial_body
):
# Closed empty-string parameter. We can safely emit it.
has_visible_value = True
else:
# Parameter tag is present but its value has not started streaming yet.
continue
param_dict[param_name] = self._convert_param_value(param_value, param_name, param_config, func_name)
if not param_dict and not has_visible_value:
return None
return json.dumps(param_dict, ensure_ascii=False)
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if "<function=" not in text:
return StreamingParseResult(normal_text=normal_text, calls=[])
# Extract function blocks from tool_call blocks (or raw text as fallback)
tool_call_blocks = self.tool_call_block_regex.findall(text)
if not tool_call_blocks:
tool_call_blocks = [text]
calls = []
for block in tool_call_blocks:
func_matches = self.function_regex.findall(block)
for match in func_matches:
func_str = match[0] if match[0] else match[1]
item = self._parse_function_call(func_str, tools)
if item:
item.tool_index = len(calls)
calls.append(item)
return StreamingParseResult(normal_text=normal_text, calls=calls)
class Gemma4Detector(BaseFormatDetector):
"""FreeToken serving adapter for Gemma4's compact tool-call format."""
toolcall_opener = "<|tool_call>"
def __init__(self):
super().__init__()
self.bot_token = "<|tool_call>"
self.eot_token = "<tool_call|>"
self.call_regex = re.compile(
r"<\|tool_call>\s*call:([A-Za-z_][\w.:-]*)\{(.*?)\}<tool_call\|>",
re.DOTALL,
)
def has_tool_call(self, text: str) -> bool:
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if idx == -1:
return StreamingParseResult(normal_text=normal_text, calls=[])
tool_indices = self._get_tool_indices(tools)
calls: List[ToolCallItem] = []
for func_name, arg_text in self.call_regex.findall(text):
if func_name not in tool_indices and not _should_forward_unknown_tool(func_name):
logger.warning(f"Model attempted to call undefined function: {func_name}")
continue
calls.append(
ToolCallItem(
tool_index=len(calls),
name=func_name,
parameters=json.dumps(_parse_gemma_call_args(arg_text), ensure_ascii=False),
)
)
return StreamingParseResult(normal_text=normal_text, calls=calls)
_G4_QUOTE = '<|"|>'
def _g4_reset(self) -> None:
self._g4_mode = "idle" # idle|header|key|dispatch|pstr|vbuf|await_eot|swallow
self._g4_key = ""
self._g4_scanned = 0
self._g4_depth = 0
self._g4_in_str = False
def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> StreamingParseResult:
"""Value-level streaming for Gemma4's compact call syntax: text outside
blocks streams live; ``<|"|>``-quoted string values stream char-by-char as
JSON-escaped prefix-stable fragments; unquoted values (numbers, booleans,
nested objects/arrays) buffer to their top-level ``,``/``}`` terminator and
are typed exactly like the non-streaming parser."""
self._buffer += new_text
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
if not hasattr(self, "_g4_mode"):
self._g4_reset()
normal_parts: List[str] = []
calls: List[ToolCallItem] = []
Q = self._G4_QUOTE
def _emit(fragment: str) -> None:
if fragment:
self.streamed_args_for_tool[self.current_tool_id] += fragment
calls.append(
ToolCallItem(tool_index=self.current_tool_id, name=None, parameters=fragment)
)
def _update_prev() -> None:
ledger = self.streamed_args_for_tool[self.current_tool_id]
for probe in (ledger, ledger + "}"):
try:
parsed = json.loads(probe)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(parsed, dict):
self.prev_tool_call_arr[self.current_tool_id]["arguments"] = parsed
return
while True:
buf = self._buffer
if not buf:
break
mode = self._g4_mode
if mode == "idle":
if calls:
break # text after a call defers to the next step (wire order)
pos = buf.find(self.bot_token)
if pos == -1:
hold = self._ends_with_partial_token(buf, self.bot_token)
release = buf[: len(buf) - hold] if hold else buf
if release:
normal_parts.append(release)
self._buffer = buf[len(release):]
break
if pos > 0:
normal_parts.append(buf[:pos])
self._buffer = buf[pos:]
continue
self._buffer = buf[len(self.bot_token):]
self._g4_mode = "header"
continue
if mode == "header":
brace = buf.find("{")
if brace == -1:
break # header still streaming (short — hold)
header = buf[:brace].strip()
self._buffer = buf[brace + 1:]
func_name = header[len("call:"):].strip() if header.startswith("call:") else ""
if self.current_tool_id == -1:
self.current_tool_id = 0
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("")
if func_name and (
func_name in self._tool_indices or _should_forward_unknown_tool(func_name)
):
calls.append(
ToolCallItem(tool_index=self.current_tool_id, name=func_name, parameters="")
)
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
self._args_started = False
self._g4_mode = "key"
else:
logger.warning(f"Model attempted to call undefined function: {func_name!r}")
self._g4_mode = "swallow"
continue
if mode == "key":
stripped = buf.lstrip()
if stripped != buf:
self._buffer = stripped
continue
if buf.startswith("}"):
_emit("}" if self._args_started else "{}")
_update_prev()
self.current_tool_id += 1
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
self._buffer = buf[1:]
self._g4_mode = "await_eot"
continue
colon = buf.find(":")
if colon == -1:
break # key still streaming (short — hold)
self._g4_key = buf[:colon].strip()
self._buffer = buf[colon + 1:]
self._g4_mode = "dispatch"
continue
if mode == "dispatch":
stripped = buf.lstrip()
if stripped != buf:
self._buffer = stripped
continue
lead = "{" if not self._args_started else ","
if buf.startswith(Q):
self._args_started = True
_emit(lead + json.dumps(self._g4_key, ensure_ascii=False) + ':"')
self._buffer = buf[len(Q):]
self._g4_mode = "pstr"
continue
if self._ends_with_partial_token(buf, Q) == len(buf):
break # could still become the opening quote marker
self._args_started = True
self._g4_lead = lead
self._g4_scanned = 0
self._g4_depth = 0
self._g4_in_str = False
self._g4_mode = "vbuf"
continue
if mode == "pstr":
end = buf.find(Q)
if end == -1:
hold = self._ends_with_partial_token(buf, Q)
emit_now = buf[: len(buf) - hold] if hold else buf
if emit_now:
_emit(self._json_escape_chunk(emit_now))
self._buffer = buf[len(emit_now):]
break
_emit(self._json_escape_chunk(buf[:end]) + '"')
_update_prev()
self._buffer = buf[end + len(Q):]
self._g4_mode = "key_sep"
continue
if mode == "key_sep":
stripped = buf.lstrip()
if stripped != buf:
self._buffer = stripped
continue
if buf.startswith(","):
self._buffer = buf[1:]
self._g4_mode = "key"
continue
if buf.startswith("}"):
self._g4_mode = "key" # key state handles the close
continue
break # separator still streaming
if mode == "vbuf":
# Scan for the top-level , or } terminator, quote/depth aware.
hold = self._ends_with_partial_token(buf, Q)
limit = len(buf) - hold
i = self._g4_scanned
term = -1
while i < limit:
if buf.startswith(Q, i):
self._g4_in_str = not self._g4_in_str
i += len(Q)
continue
ch = buf[i]
if not self._g4_in_str:
if ch in "{[":
self._g4_depth += 1
elif ch in "]}" and self._g4_depth > 0:
self._g4_depth -= 1
elif self._g4_depth == 0 and ch in ",}":
term = i
break
i += 1
if term == -1:
self._g4_scanned = i
break # value still streaming — keep buffering
raw = buf[:term].strip()
converted = _parse_gemma_value(raw)
_emit(
self._g4_lead
+ json.dumps(self._g4_key, ensure_ascii=False)
+ ":"
+ json.dumps(converted, ensure_ascii=False)
)
_update_prev()
self._g4_scanned = 0
if buf[term] == ",":
self._buffer = buf[term + 1:]
self._g4_mode = "key"
else:
self._buffer = buf[term:]
self._g4_mode = "key" # key state emits the close on '}'
continue
if mode == "await_eot":
pos = buf.find(self.eot_token)
if pos == -1:
hold = self._ends_with_partial_token(buf, self.eot_token)
if len(buf) - hold > 0 and buf[: len(buf) - hold].strip() == "":
self._buffer = buf[len(buf) - hold:]
break
if hold:
break
# No closing marker and non-whitespace content: treat as done.
self._g4_mode = "idle"
continue
self._buffer = buf[pos + len(self.eot_token):]
self._g4_mode = "idle"
continue
if mode == "swallow":
pos = buf.find(self.eot_token)
if pos == -1:
hold = self._ends_with_partial_token(buf, self.eot_token)
self._buffer = buf[len(buf) - hold:] if hold else ""
break
self._buffer = buf[pos + len(self.eot_token):]
self._g4_mode = "idle"
continue
return StreamingParseResult(normal_text="".join(normal_parts), calls=calls)
def finish_streaming(self) -> str:
residual, self._buffer = self._buffer, ""
mode = getattr(self, "_g4_mode", "idle")
self._g4_reset()
if mode != "idle" or self.bot_token in residual:
return ""
if self.eot_token in residual:
residual = residual.replace(self.eot_token, "")
if self.prev_tool_call_arr and residual.strip() == "":
return ""
return residual
class MiniMaxDetector(InvokeParamStreamMixin, BaseFormatDetector):
toolcall_opener = "<minimax:tool_call>"
_ps_trim = "\n"
_ps_trim_single = False
_ps_missing_type = "loose"
"""FreeToken serving adapter for MiniMax-M2 XML tool-call blocks.
LightLLM routes MiniMax mostly through reasoning parser/template handling; FreeToken's
OpenAI-compatible server consumes the final ``<minimax:tool_call>`` block here.
"""
def __init__(self):
super().__init__()
self.bot_token = "<minimax:tool_call>"
self.eot_token = "</minimax:tool_call>"
self.invoke_regex = re.compile(r'<invoke\s+name="([^"]+)"\s*>(.*?)</invoke>', re.DOTALL)
self.param_regex = re.compile(r'<parameter\s+name="([^"]+)"\s*>(.*?)</parameter>', re.DOTALL)
# InvokeParamStreamMixin grammar
self._ps_outer_open = "<minimax:tool_call>"
self._ps_outer_close = "</minimax:tool_call>"
self._ps_invoke_open_prefix = "<invoke"
self._ps_invoke_open_re = re.compile(r'<invoke\s+name="([^"]+)"\s*>')
self._ps_invoke_close = "</invoke>"
self._ps_param_open_prefix = "<parameter"
self._ps_param_open_re = re.compile(r'<parameter\s+name="([^"]+)"\s*>')
self._ps_param_close = "</parameter>"
self._ps_reset()
def has_tool_call(self, text: str) -> bool:
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if idx == -1:
return StreamingParseResult(normal_text=normal_text, calls=[])
tool_indices = self._get_tool_indices(tools)
calls: List[ToolCallItem] = []
for func_name, invoke_body in self.invoke_regex.findall(text):
if func_name not in tool_indices and not _should_forward_unknown_tool(func_name):
logger.warning(f"Model attempted to call undefined function: {func_name}")
continue
args = {}
config = self._get_param_config(func_name, tools)
for name, value in self.param_regex.findall(invoke_body):
key = name.strip()
raw = value.strip("\n")
if key in config:
args[key] = self._convert_param_value(raw, key, config, func_name)
else:
args[key] = _parse_loose_json_value(raw)
calls.append(
ToolCallItem(
tool_index=len(calls),
name=func_name,
parameters=json.dumps(args, ensure_ascii=False),
)
)
return StreamingParseResult(normal_text=normal_text, calls=calls)
class GptOssDetector(BaseFormatDetector):
"""FreeToken serving adapter for Harmony ``to=functions.*`` tool calls.
LightLLM preserves these Harmony blocks in its reasoning parser. FreeToken's current
OpenAI-compatible path does not run a separate reasoning parser before tool parsing, so
this detector extracts the function call directly from the preserved Harmony text.
Streaming is a small channel state machine (vLLM's harmony rail is token-id
driven via StreamableParser; this is the text-marker translation since only
detokenized text is available here, with the same semantics): text outside
harmony blocks streams live; a ``commentary ... to=functions.NAME`` header
opens a call whose body bytes (``<|constrain|>json`` — the raw arguments
JSON) stream as prefix-stable fragments until a closing boundary; blocks of
other channels are swallowed (they only reach this detector when no reasoning
parser runs upstream, matching detect_and_parse which drops them too).
"""
_BLOCK_OPENERS = ("<|start|>", "<|channel|>")
_CLOSING_TOKENS = ("<|end|>", "<|return|>", "<|call|>")
_name_regex = re.compile(r"to=functions\.([^\s<]+)")
def __init__(self):
super().__init__()
self.bot_token = "<|channel|>"
self.eot_token = "<|end|>"
self.call_regex = re.compile(
r"<\|channel\|>commentary\s+to=functions\.([^\s<]+)"
r".*?<\|message\|>(.*?)(?=<\|end\|>|<\|start\|>|$)",
re.DOTALL,
)
self._mode = "text" # "text" | "tool_body" | "skip_body"
self._args_acc = ""
def has_tool_call(self, text: str) -> bool:
return "to=functions." in text and "<|message|>" in text
def block_close_tokens(self) -> tuple:
return self._CLOSING_TOKENS
@staticmethod
def _partial_suffix(text: str, tokens) -> int:
best = 0
for tok in tokens:
for k in range(min(len(tok) - 1, len(text)), best, -1):
if text.endswith(tok[:k]):
best = k
break
return best
@staticmethod
def _earliest(text: str, tokens):
best_pos, best_tok = -1, None
for tok in tokens:
pos = text.find(tok)
if pos != -1 and (best_pos == -1 or pos < best_pos):
best_pos, best_tok = pos, tok
return best_pos, best_tok
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
idx = _first_existing_pos(text, ["<|start|>", "<|channel|>"])
normal_text = text[:idx].strip() if idx != -1 else text
if not self.has_tool_call(text):
return StreamingParseResult(normal_text=normal_text, calls=[])
tool_indices = self._get_tool_indices(tools)
calls: List[ToolCallItem] = []
for func_name, payload in self.call_regex.findall(text):
if func_name not in tool_indices and not _should_forward_unknown_tool(func_name):
logger.warning(f"Model attempted to call undefined function: {func_name}")
continue
args = _parse_first_json_value(payload)
if args is None:
continue
calls.append(
ToolCallItem(
tool_index=len(calls),
name=func_name,
parameters=json.dumps(args, ensure_ascii=False),
)
)
return StreamingParseResult(normal_text=normal_text, calls=calls)
def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> StreamingParseResult:
self._buffer += new_text
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
normal_parts: List[str] = []
calls: List[ToolCallItem] = []
while True:
buf = self._buffer
if not buf:
break
if self._mode == "text":
if calls:
break # text after a call defers to the next step (wire order)
pos, _tok = self._earliest(buf, self._BLOCK_OPENERS)
if pos == -1:
hold = self._partial_suffix(buf, self._BLOCK_OPENERS)
release = buf[: len(buf) - hold] if hold else buf
if release:
normal_parts.append(release)
self._buffer = buf[len(release):]
break
if pos > 0:
normal_parts.append(buf[:pos])
self._buffer = buf[pos:]
continue
msg = buf.find("<|message|>")
if msg == -1:
break # header still streaming
header = buf[:msg]
self._buffer = buf[msg + len("<|message|>"):]
m = self._name_regex.search(header) if "commentary" in header else None
func_name = m.group(1) if m else None
if func_name and (
func_name in self._tool_indices or _should_forward_unknown_tool(func_name)
):
if self.current_tool_id == -1:
self.current_tool_id = 0
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("")
calls.append(
ToolCallItem(
tool_index=self.current_tool_id, name=func_name, parameters=""
)
)
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
self._args_acc = ""
self._mode = "tool_body"
else:
self._mode = "skip_body"
continue
# Body modes: stream (tool) or swallow (other channels) until a boundary.
boundaries = self._CLOSING_TOKENS + self._BLOCK_OPENERS
pos, tok = self._earliest(buf, boundaries)
if pos == -1:
hold = self._partial_suffix(buf, boundaries)
emit_len = len(buf) - hold
if emit_len > 0:
piece = buf[:emit_len]
self._buffer = buf[emit_len:]
if self._mode == "tool_body":
self._args_acc += piece
self.streamed_args_for_tool[self.current_tool_id] += piece
calls.append(
ToolCallItem(
tool_index=self.current_tool_id, name=None, parameters=piece
)
)
break
piece = buf[:pos]
if tok in self._CLOSING_TOKENS:
self._buffer = buf[pos + len(tok):]
else:
self._buffer = buf[pos:] # an opener belongs to the NEXT block
if self._mode == "tool_body":
if piece:
self._args_acc += piece
self.streamed_args_for_tool[self.current_tool_id] += piece
calls.append(
ToolCallItem(
tool_index=self.current_tool_id, name=None, parameters=piece
)
)
parsed_args = _parse_first_json_value(self._args_acc)
self.prev_tool_call_arr[self.current_tool_id]["arguments"] = (
parsed_args if isinstance(parsed_args, dict) else {}
)
self.streamed_args_for_tool[self.current_tool_id] = ""
self.current_tool_id += 1
self._args_acc = ""
self._mode = "text"
return StreamingParseResult(normal_text="".join(normal_parts), calls=calls)
def finish_streaming(self) -> str:
residual, self._buffer = self._buffer, ""
mode, self._mode = self._mode, "text"
if mode != "text" or any(op in residual for op in self._BLOCK_OPENERS):
return ""
return residual
class FunctionCallParser:
"""
Parser for function/tool calls in model outputs.
This class handles both streaming and non-streaming parsing of function calls using a detector.
In streaming scenarios, each time new_text is received, it calls detector.parse_streaming_increment
and returns the resulting normal_text and calls to the upper layer (or SSE).
"""
ToolCallParserEnum: Dict[str, Type[BaseFormatDetector]] = {
"deepseekv32": DeepSeekV32Detector,
"gemma4": Gemma4Detector,
"gpt-oss": GptOssDetector,
"gpt_oss": GptOssDetector,
"glm47": Glm47Detector,
"llama3": Llama32Detector,
"minimax": MiniMaxDetector,
"mistral": MistralDetector,
"qwen": Qwen25Detector,
"qwen25": Qwen25Detector,
"qwen3_coder": Qwen3CoderDetector,
}
def __init__(self, tools: List[Tool], tool_call_parser: str):
detector: Type[BaseFormatDetector] = None
detector_class = self.ToolCallParserEnum.get(tool_call_parser)
if detector_class:
detector = detector_class()
else:
raise ValueError(f"Unsupported tool_call_parser: {tool_call_parser}")
self.detector = detector
self.tools = _coerce_tools(tools)
def has_tool_call(self, text: str) -> bool:
"""
Check if the given text contains a tool call in the format supported by this parser.
This delegates to the detector's implementation.
Args:
text: The text to check for tool calls
Returns:
True if the text contains a tool call, False otherwise
"""
if not self.tools:
return False
return self.detector.has_tool_call(text)
def parse_non_stream(self, full_text: str) -> StreamingParseResult:
"""
One-time parsing of the full text to extract tool calls.
Args:
full_text: The complete text to parse
Returns:
StreamingParseResult with normal_text and parsed calls.
"""
if not self.tools:
return StreamingParseResult(normal_text=full_text, calls=[])
parsed_result = self.detector.detect_and_parse(full_text, self.tools)
tool_call_list = parsed_result.calls
if tool_call_list:
# Keep text after the LAST tool block: the streaming path emits it, so
# one-shot parsing must agree instead of silently dropping it.
tail_start = -1
for tok in self.detector.block_close_tokens():
pos = full_text.rfind(tok)
if pos != -1:
tail_start = max(tail_start, pos + len(tok))
if tail_start != -1:
tail = full_text[tail_start:]
normal = parsed_result.normal_text or ""
# Only plain text: an unterminated final block would make the
# "last closer" precede it and leak markup into content.
if tail.strip() and tail.strip() not in normal and not self.detector.has_tool_call(tail):
parsed_result.normal_text = normal + tail
return parsed_result
else:
return StreamingParseResult(normal_text=full_text, calls=[])
def parse_stream_chunk(self, chunk_text: str) -> Tuple[str, list[ToolCallItem]]:
"""
Streaming incremental parsing of chunks of text as they arrive.
Args:
chunk_text: The new chunk of text to parse
Returns:
A tuple containing:
- The normal text that should be displayed to the user
- A list of tool calls parsed from the chunk
"""
normal_parts: list[str] = []
final_calls: list[ToolCallItem] = []
for kind, payload in self.parse_stream_events(chunk_text):
if kind == "text":
normal_parts.append(payload)
else:
final_calls.extend(payload)
return "".join(normal_parts), final_calls
def parse_stream_events(self, chunk_text: str) -> list[tuple[str, Any]]:
"""Ordered streaming parse: ``[("text", str) | ("calls", [ToolCallItem]), ...]``
segments in generation order. Detectors return text-before-calls within one
increment; segments across drain iterations keep the true interleaving
(pre-text, call, trailing text) that a flat (text, calls) pair loses."""
if not self.tools:
return [("text", chunk_text)] if chunk_text else []
segments: list[tuple[str, Any]] = []
# Drain loop: an increment may return after consuming only part of its buffer
# (e.g. one complete invoke of several, leaving the rest and the closing tag
# behind). Re-feed "" until the detector stops making progress.
text = chunk_text
for _ in range(16):
sp_result = self.detector.parse_streaming_increment(text, self.tools)
if sp_result.normal_text:
segments.append(("text", sp_result.normal_text))
if sp_result.calls:
segments.append(("calls", sp_result.calls))
if text == "" and not sp_result.calls and not sp_result.normal_text:
break
text = ""
return segments
def recover_truncated_call(self) -> List[ToolCallItem]:
"""Best-effort parse of a tool call cut off by end-of-generation while its
tag block was still open: append the closing tag and re-parse. Consumes the
detector buffer on success; returns [] when nothing recoverable."""
det = self.detector
buf = det._buffer
if not buf or not det.eot_token or not det.has_tool_call(buf):
return []
try:
parsed = det.detect_and_parse(buf + det.eot_token, self.tools)
except Exception: # noqa: BLE001 — recovery is best-effort by definition
return []
if parsed.calls:
det._buffer = ""
return parsed.calls
def parse_streaming_increment(self, new_text: str) -> StreamingParseResult:
normal_text, calls = self.parse_stream_chunk(new_text)
return StreamingParseResult(normal_text=normal_text, calls=calls)
def supports_streaming(self) -> bool:
"""Whether the underlying detector can parse incrementally; when False the
serving layer must buffer the full generation and use parse_non_stream."""
return bool(self.detector.supports_streaming)
def args_fragments_prefix_stable(self) -> bool:
"""Whether streamed argument fragments always concatenate to a prefix of the
final arguments JSON (safe for clients that concatenate fragments)."""
return bool(self.detector.args_fragments_prefix_stable)
def finish_stream(self) -> str:
"""End-of-stream drain: residual buffered text that is plain content (empty
when the buffer holds unfinished tool-call markup)."""
return self.detector.finish_streaming()
def unstreamed_arguments(self, tool_ordinal: int) -> str | None:
"""Best-effort complete arguments JSON for a call whose argument stream was cut
short (generation truncated mid-call), from the detector's partial-parse state."""
arr = self.detector.prev_tool_call_arr
if 0 <= tool_ordinal < len(arr):
args = arr[tool_ordinal].get("arguments")
if args:
return json.dumps(args, ensure_ascii=False)
return None
SUPPORTED_TOOL_CALL_PARSERS = list(FunctionCallParser.ToolCallParserEnum.keys())
def toolcall_opener_for(tool_call_parser: str) -> str | None:
"""The configured parser's unique tool-call opening marker, or None when the format has
no single unique opener (see ``BaseFormatDetector.toolcall_opener``)."""
detector = FunctionCallParser.ToolCallParserEnum.get(tool_call_parser)
return detector.toolcall_opener if detector is not None else None
def _coerce_tools(tools: List[Any] | None) -> List[Tool]:
return [_coerce_tool(tool) for tool in tools or []]
def _coerce_tool(tool: Any) -> Tool:
if isinstance(tool, Tool):
return tool
if isinstance(tool, dict):
return Tool.model_validate(tool)
function = getattr(tool, "function", None)
if function is None:
raise TypeError(f"Unsupported tool schema: {tool!r}")
return Tool(
type=getattr(tool, "type", "function"),
function=Function(
name=getattr(function, "name", None),
description=getattr(function, "description", None),
parameters=getattr(function, "parameters", None),
),
)