use std::collections::HashMap;
use async_trait::async_trait;
use openai_protocol::common::Tool;
use regex::Regex;
use serde_json::Value;
use crate::{
errors::{ParserError, ParserResult},
parsers::helpers,
traits::ToolParser,
types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
};
pub struct Glm4MoeParser {
tool_call_extractor: Regex,
func_detail_extractor: Regex,
arg_extractor: Regex,
buffer: String,
prev_tool_call_arr: Vec<Value>,
current_tool_id: i32,
streamed_args_for_tool: Vec<String>,
bot_token: &'static str,
eot_token: &'static str,
}
impl Glm4MoeParser {
#[expect(
clippy::expect_used,
reason = "regex patterns are compile-time string literals"
)]
pub(crate) fn new(func_detail_pattern: &str) -> Self {
let tool_call_pattern = r"(?s)<tool_call>.*?</tool_call>";
let tool_call_extractor = Regex::new(tool_call_pattern).expect("Valid regex pattern");
let func_detail_extractor = Regex::new(func_detail_pattern).expect("Valid regex pattern");
let arg_pattern = r"(?s)<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>";
let arg_extractor = Regex::new(arg_pattern).expect("Valid regex pattern");
Self {
tool_call_extractor,
func_detail_extractor,
arg_extractor,
buffer: String::new(),
prev_tool_call_arr: Vec::new(),
current_tool_id: -1,
streamed_args_for_tool: Vec::new(),
bot_token: "<tool_call>",
eot_token: "</tool_call>",
}
}
pub fn glm45() -> Self {
Self::new(r"(?s)<tool_call>([^\n]*)\n(.*)</tool_call>")
}
pub fn glm47() -> Self {
Self::new(r"(?s)<tool_call>\s*([^<\s]+)\s*(.*?)</tool_call>")
}
pub fn build_structural_tag(tools: &[Tool], at_least_one: bool) -> Value {
let tags: Vec<Value> = tools
.iter()
.filter(|tool| !tool.function.name.is_empty())
.map(|tool| {
serde_json::json!({
"type": "tag",
"begin": format!("<tool_call>{}", tool.function.name),
"content": {
"type": "json_schema",
"json_schema": tool.function.parameters,
"style": "glm_xml",
},
"end": "</tool_call>",
})
})
.collect();
serde_json::json!({
"format": {
"type": "triggered_tags",
"triggers": ["<tool_call>"],
"tags": tags,
"at_least_one": at_least_one,
}
})
}
pub fn reasoning_prefix() -> Value {
serde_json::json!({
"type": "tag",
"begin": "",
"content": {
"type": "any_text",
"excludes": [
"<think>", "</think>",
"<tool_call>", "</tool_call>",
"<arg_key>", "</arg_key>", "<arg_value>", "</arg_value>",
],
},
"end": "</think>",
})
}
fn parse_arguments(
&self,
args_text: &str,
param_types: &HashMap<String, String>,
) -> serde_json::Map<String, Value> {
let mut arguments = serde_json::Map::new();
for capture in self.arg_extractor.captures_iter(args_text) {
let key = capture.get(1).map_or("", |m| m.as_str()).trim();
let value_str = capture.get(2).map_or("", |m| m.as_str());
let value =
helpers::coerce_by_schema_type(value_str, param_types.get(key).map(String::as_str))
.unwrap_or_else(|| infer_value(value_str.trim()));
arguments.insert(key.to_string(), value);
}
arguments
}
fn parse_tool_call(&self, block: &str, tools: &[Tool]) -> ParserResult<Option<ToolCall>> {
if let Some(captures) = self.func_detail_extractor.captures(block) {
let func_name = captures.get(1).map_or("", |m| m.as_str()).trim();
let args_text = captures.get(2).map_or("", |m| m.as_str());
let param_types = helpers::param_types_for_function(tools, func_name);
let arguments = self.parse_arguments(args_text, ¶m_types);
let arguments_str = serde_json::to_string(&arguments)
.map_err(|e| ParserError::ParsingFailed(e.to_string()))?;
Ok(Some(ToolCall {
function: FunctionCall {
name: func_name.to_string(),
arguments: arguments_str,
},
}))
} else {
Ok(None)
}
}
fn parse_tool_calls_from_text(&self, text: &str, tools: &[Tool]) -> Vec<ToolCall> {
let mut parsed = Vec::new();
for mat in self.tool_call_extractor.find_iter(text) {
match self.parse_tool_call(mat.as_str(), tools) {
Ok(Some(tool)) => parsed.push(tool),
Ok(None) => continue,
Err(e) => {
tracing::debug!("Failed to parse tool call: {}", e);
continue;
}
}
}
parsed
}
}
impl Glm4MoeParser {
fn parse_complete_inner(
&self,
text: &str,
tools: &[Tool],
) -> ParserResult<(String, Vec<ToolCall>)> {
if !self.has_tool_markers(text) {
return Ok((text.to_string(), vec![]));
}
let idx = text
.find("<tool_call>")
.ok_or_else(|| ParserError::ParsingFailed("tool call marker not found".to_string()))?;
let normal_text = text[..idx].to_string();
let parsed = self.parse_tool_calls_from_text(text, tools);
if parsed.is_empty() {
return Ok((text.to_string(), vec![]));
}
Ok((normal_text, parsed))
}
}
fn infer_value(value_str: &str) -> Value {
if let Ok(json_val) = serde_json::from_str::<Value>(value_str) {
return json_val;
}
match value_str {
"true" | "True" => Value::Bool(true),
"false" | "False" => Value::Bool(false),
"null" | "None" => Value::Null,
_ => {
if let Ok(num) = value_str.parse::<i64>() {
Value::Number(num.into())
} else if let Ok(num) = value_str.parse::<f64>() {
serde_json::Number::from_f64(num)
.map_or_else(|| Value::String(value_str.to_string()), Value::Number)
} else {
Value::String(value_str.to_string())
}
}
}
}
impl Default for Glm4MoeParser {
fn default() -> Self {
Self::glm45()
}
}
#[async_trait]
impl ToolParser for Glm4MoeParser {
async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
self.parse_complete_inner(text, &[])
}
async fn parse_complete_with_tools(
&self,
text: &str,
tools: &[Tool],
) -> ParserResult<(String, Vec<ToolCall>)> {
self.parse_complete_inner(text, tools)
}
async fn parse_incremental(
&mut self,
chunk: &str,
tools: &[Tool],
) -> ParserResult<StreamingParseResult> {
self.buffer.push_str(chunk);
let current_text = &self.buffer.clone();
let start = current_text.find(self.bot_token);
if start.is_none() {
self.buffer.clear();
let normal_text = if self.current_tool_id > 0 {
String::new()
} else {
current_text.clone()
};
return Ok(StreamingParseResult {
normal_text,
calls: vec![],
});
}
let end = current_text.find(self.eot_token);
if let Some(end_pos) = end {
if self.current_tool_id == -1 {
self.current_tool_id = 0;
self.prev_tool_call_arr = Vec::new();
self.streamed_args_for_tool = vec![String::new()];
}
helpers::ensure_capacity(
self.current_tool_id,
&mut self.prev_tool_call_arr,
&mut self.streamed_args_for_tool,
);
let block_end = end_pos + self.eot_token.len();
let parsed_tools = self.parse_tool_calls_from_text(¤t_text[..block_end], tools);
let idx = current_text.find(self.bot_token);
let normal_text = if let Some(pos) = idx {
current_text[..pos].trim().to_string()
} else {
String::new()
};
let tool_indices = helpers::get_tool_indices(tools);
let mut calls = Vec::new();
if !parsed_tools.is_empty() {
let tool_call = &parsed_tools[0];
let tool_id = self.current_tool_id as usize;
if !tool_indices.contains_key(&tool_call.function.name) {
tracing::debug!("Invalid tool name '{}' - skipping", tool_call.function.name);
helpers::reset_current_tool_state(
&mut self.buffer,
&mut false, &mut self.streamed_args_for_tool,
&self.prev_tool_call_arr,
);
return Ok(StreamingParseResult::default());
}
calls.push(ToolCallItem {
tool_index: tool_id,
name: Some(tool_call.function.name.clone()),
parameters: tool_call.function.arguments.clone(),
});
if self.prev_tool_call_arr.len() <= tool_id {
self.prev_tool_call_arr
.resize_with(tool_id + 1, || Value::Null);
}
if let Ok(args) = serde_json::from_str::<Value>(&tool_call.function.arguments) {
self.prev_tool_call_arr[tool_id] = serde_json::json!({
"name": tool_call.function.name,
"arguments": args,
});
}
if self.streamed_args_for_tool.len() <= tool_id {
self.streamed_args_for_tool
.resize_with(tool_id + 1, String::new);
}
self.streamed_args_for_tool[tool_id].clone_from(&tool_call.function.arguments);
self.current_tool_id += 1;
}
self.buffer = current_text[block_end..].to_string();
return Ok(StreamingParseResult { normal_text, calls });
}
let Some(start_pos) = start else {
return Ok(StreamingParseResult::default());
};
let normal_text = current_text[..start_pos].to_string();
self.buffer = current_text[start_pos..].to_string();
Ok(StreamingParseResult {
normal_text,
calls: vec![],
})
}
fn has_tool_markers(&self, text: &str) -> bool {
text.contains(self.bot_token)
}
fn get_unstreamed_tool_args(&self) -> Option<Vec<ToolCallItem>> {
helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool)
}
fn reset(&mut self) {
self.buffer.clear();
self.prev_tool_call_arr.clear();
self.current_tool_id = -1;
self.streamed_args_for_tool.clear();
}
}
#[cfg(test)]
mod tests {
use openai_protocol::common::Function;
use super::*;
fn tool(name: &str, strict: Option<bool>, parameters: Value) -> Tool {
Tool {
tool_type: "function".to_string(),
function: Function {
name: name.to_string(),
description: None,
parameters,
strict,
},
}
}
#[test]
fn structural_tag_mirrors_the_xgrammar_glm_4_7_model() {
let schema =
serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}});
let tools = vec![
tool("get_weather", None, schema.clone()),
tool("lookup", Some(false), schema.clone()),
tool("ping", None, serde_json::json!({})),
tool("", None, schema.clone()),
];
let tag = Glm4MoeParser::build_structural_tag(&tools, true);
let format = &tag["format"];
assert_eq!(format["type"], "triggered_tags");
assert_eq!(format["triggers"], serde_json::json!(["<tool_call>"]));
assert_eq!(format["at_least_one"], true);
let tags = format["tags"].as_array().unwrap();
assert_eq!(tags.len(), 3, "a nameless tool has no tag");
assert_eq!(tags[0]["begin"], "<tool_call>get_weather");
assert_eq!(tags[0]["end"], "</tool_call>");
assert_eq!(tags[0]["content"]["type"], "json_schema");
assert_eq!(tags[0]["content"]["style"], "glm_xml");
assert_eq!(tags[0]["content"]["json_schema"], schema);
assert_eq!(
tags[1]["content"]["json_schema"], schema,
"strict is not a constraint switch here, as in the other builders"
);
assert_eq!(
tags[2]["content"]["json_schema"],
serde_json::json!({}),
"a no-argument tool keeps its empty schema"
);
let tag = Glm4MoeParser::build_structural_tag(&tools, false);
assert_eq!(tag["format"]["at_least_one"], false);
}
#[test]
fn reasoning_prefix_mirrors_the_xgrammar_glm_4_7_reasoning_block() {
let prefix = Glm4MoeParser::reasoning_prefix();
assert_eq!(prefix["type"], "tag");
assert_eq!(prefix["begin"], "");
assert_eq!(prefix["end"], "</think>");
assert_eq!(prefix["content"]["type"], "any_text");
let excludes = prefix["content"]["excludes"].as_array().unwrap();
for token in [
"<think>",
"</think>",
"<tool_call>",
"</tool_call>",
"<arg_key>",
"</arg_key>",
"<arg_value>",
"</arg_value>",
] {
assert!(
excludes.contains(&Value::String(token.to_string())),
"{token} must not appear inside the reasoning block"
);
}
}
fn tool_with_props(props: Value) -> Vec<Tool> {
vec![Tool {
tool_type: "function".to_string(),
function: Function {
name: "f".to_string(),
description: None,
parameters: serde_json::json!({"type": "object", "properties": props}),
strict: None,
},
}]
}
#[tokio::test]
async fn test_schema_aware_coercion_keeps_strings() {
let tools = tool_with_props(serde_json::json!({
"limit": {"type": "string"},
"flag": {"type": "string"},
"coords": {"type": "string"},
"count": {"type": "integer"},
}));
let text = "<tool_call>f\n\
<arg_key>limit</arg_key>\n<arg_value>4</arg_value>\n\
<arg_key>flag</arg_key>\n<arg_value>true</arg_value>\n\
<arg_key>coords</arg_key>\n<arg_value>[60,30]</arg_value>\n\
<arg_key>count</arg_key>\n<arg_value>5</arg_value>\n\
</tool_call>";
let (_, calls) = Glm4MoeParser::glm45()
.parse_complete_with_tools(text, &tools)
.await
.unwrap();
assert_eq!(calls.len(), 1);
let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args["limit"], Value::String("4".to_string()));
assert_eq!(args["flag"], Value::String("true".to_string()));
assert_eq!(args["coords"], Value::String("[60,30]".to_string()));
assert_eq!(args["count"], Value::Number(5.into()));
}
#[tokio::test]
async fn test_streaming_schema_aware_coercion() {
let tools = tool_with_props(serde_json::json!({
"limit": {"type": "string"},
"count": {"type": "integer"},
}));
let text = "<tool_call>f\n\
<arg_key>limit</arg_key>\n<arg_value>4</arg_value>\n\
<arg_key>count</arg_key>\n<arg_value>5</arg_value>\n\
</tool_call>";
let result = Glm4MoeParser::glm45()
.parse_incremental(text, &tools)
.await
.unwrap();
let args: Value = serde_json::from_str(&result.calls[0].parameters).unwrap();
assert_eq!(args["limit"], Value::String("4".to_string()));
assert_eq!(args["count"], Value::Number(5.into()));
}
#[tokio::test]
async fn test_declared_strings_preserve_whitespace() {
let tools = tool_with_props(serde_json::json!({
"code": {"type": "string"},
"needle": {"type": "string"},
"blank": {"type": "string"},
"quoted": {"type": "string"},
"count": {"type": "integer"},
}));
let args_text = concat!(
"<arg_key>code</arg_key><arg_value> return 1\n</arg_value>",
"<arg_key>needle</arg_key><arg_value> total = 1\n</arg_value>",
"<arg_key>blank</arg_key><arg_value> \t\n</arg_value>",
"<arg_key>quoted</arg_key><arg_value> \" return 1\\n\" </arg_value>",
"<arg_key>count</arg_key><arg_value> 42\n</arg_value>",
"<arg_key>unknown</arg_key><arg_value> legacy text\n</arg_value>",
);
let expected = serde_json::json!({
"code": " return 1\n",
"needle": " total = 1\n",
"blank": " \t\n",
"quoted": " return 1\n",
"count": 42,
"unknown": "legacy text",
});
for (make_parser, name_separator) in [
(Glm4MoeParser::glm45 as fn() -> Glm4MoeParser, "\n"),
(Glm4MoeParser::glm47 as fn() -> Glm4MoeParser, ""),
] {
let text = format!("<tool_call>f{name_separator}{args_text}</tool_call>");
let (_, calls) = make_parser()
.parse_complete_with_tools(&text, &tools)
.await
.unwrap();
assert_eq!(calls.len(), 1);
let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
assert_eq!(args, expected);
let mut parser = make_parser();
let start = "<tool_call>".len();
let result = parser
.parse_incremental(&text[..start], &tools)
.await
.unwrap();
assert!(result.calls.is_empty());
let mut arguments = String::new();
for chunk in text.as_bytes()[start..].chunks(7) {
let result = parser
.parse_incremental(std::str::from_utf8(chunk).unwrap(), &tools)
.await
.unwrap();
for call in result.calls {
arguments.push_str(&call.parameters);
}
}
let args: Value = serde_json::from_str(&arguments).unwrap();
assert_eq!(args, expected);
}
}
}