From 2c1e2674ccca62d9c6dda72dd4a8639bd1f571eb Mon Sep 17 00:00:00 2001 From: Simo Lin Date: Sun, 25 Jan 2026 16:07:39 -0500 Subject: [PATCH] [misc] replace existing tool call code with new crate package (#17720) --- sgl-model-gateway/src/tool_parser/errors.rs | 32 - sgl-model-gateway/src/tool_parser/factory.rs | 395 -------- sgl-model-gateway/src/tool_parser/mod.rs | 24 - .../src/tool_parser/parsers/deepseek.rs | 328 ------ .../src/tool_parser/parsers/glm4_moe.rs | 340 ------- .../src/tool_parser/parsers/helpers.rs | 488 --------- .../src/tool_parser/parsers/json.rs | 305 ------ .../src/tool_parser/parsers/kimik2.rs | 348 ------- .../src/tool_parser/parsers/llama.rs | 245 ----- .../src/tool_parser/parsers/minimax_m2.rs | 549 ---------- .../src/tool_parser/parsers/mistral.rs | 287 ------ .../src/tool_parser/parsers/mod.rs | 34 - .../src/tool_parser/parsers/passthrough.rs | 55 - .../src/tool_parser/parsers/pythonic.rs | 414 -------- .../src/tool_parser/parsers/qwen.rs | 258 ----- .../src/tool_parser/parsers/qwen_coder.rs | 587 ----------- .../src/tool_parser/parsers/step3.rs | 576 ----------- .../src/tool_parser/partial_json.rs | 533 ---------- sgl-model-gateway/src/tool_parser/tests.rs | 740 -------------- sgl-model-gateway/src/tool_parser/traits.rs | 76 -- sgl-model-gateway/src/tool_parser/types.rs | 52 - sgl-model-gateway/tests/tool_parser/mod.rs | 18 - .../tests/tool_parser/tool_parser_deepseek.rs | 160 --- .../tool_parser/tool_parser_edge_cases.rs | 348 ------- .../tests/tool_parser/tool_parser_fallback.rs | 272 ----- .../tool_parser/tool_parser_glm47_moe.rs | 131 --- .../tests/tool_parser/tool_parser_glm4_moe.rs | 168 --- .../tests/tool_parser/tool_parser_json.rs | 716 ------------- .../tests/tool_parser/tool_parser_kimik2.rs | 156 --- .../tests/tool_parser/tool_parser_llama.rs | 454 --------- .../tool_parser/tool_parser_minimax_m2.rs | 779 -------------- .../tests/tool_parser/tool_parser_mistral.rs | 274 ----- .../tool_parser_mixed_edge_cases.rs | 290 ------ .../tool_parser/tool_parser_partial_json.rs | 156 --- .../tests/tool_parser/tool_parser_pythonic.rs | 517 ---------- .../tests/tool_parser/tool_parser_qwen.rs | 306 ------ .../tool_parser/tool_parser_qwen_coder.rs | 958 ------------------ .../tests/tool_parser/tool_parser_step3.rs | 237 ----- sgl-model-gateway/tests/tool_parser_tests.rs | 8 - 39 files changed, 12614 deletions(-) delete mode 100644 sgl-model-gateway/src/tool_parser/errors.rs delete mode 100644 sgl-model-gateway/src/tool_parser/factory.rs delete mode 100644 sgl-model-gateway/src/tool_parser/mod.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/deepseek.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/glm4_moe.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/helpers.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/json.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/kimik2.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/llama.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/minimax_m2.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/mistral.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/mod.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/passthrough.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/pythonic.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/qwen.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/qwen_coder.rs delete mode 100644 sgl-model-gateway/src/tool_parser/parsers/step3.rs delete mode 100644 sgl-model-gateway/src/tool_parser/partial_json.rs delete mode 100644 sgl-model-gateway/src/tool_parser/tests.rs delete mode 100644 sgl-model-gateway/src/tool_parser/traits.rs delete mode 100644 sgl-model-gateway/src/tool_parser/types.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/mod.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_deepseek.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_edge_cases.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_fallback.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_glm47_moe.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_glm4_moe.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_json.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_kimik2.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_llama.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_minimax_m2.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_mistral.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_mixed_edge_cases.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_partial_json.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_pythonic.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_qwen.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_qwen_coder.rs delete mode 100644 sgl-model-gateway/tests/tool_parser/tool_parser_step3.rs delete mode 100644 sgl-model-gateway/tests/tool_parser_tests.rs diff --git a/sgl-model-gateway/src/tool_parser/errors.rs b/sgl-model-gateway/src/tool_parser/errors.rs deleted file mode 100644 index 8a34e5f93..000000000 --- a/sgl-model-gateway/src/tool_parser/errors.rs +++ /dev/null @@ -1,32 +0,0 @@ -use thiserror::Error; - -/// Result type for tool parser operations -pub type ParserResult = Result; - -/// Errors that can occur during tool parsing -#[derive(Debug, Error)] -pub enum ParserError { - #[error("Parsing failed: {0}")] - ParsingFailed(String), - - #[error("Model not supported: {0}")] - ModelNotSupported(String), - - #[error("Parse depth exceeded: max {0}")] - DepthExceeded(usize), - - #[error("Invalid JSON: {0}")] - JsonError(#[from] serde_json::Error), - - #[error("Regex error: {0}")] - RegexError(#[from] regex::Error), - - #[error("Incomplete tool call")] - Incomplete, - - #[error("Invalid tool name: {0}")] - InvalidToolName(String), - - #[error("Token not found: {0}")] - TokenNotFound(String), -} diff --git a/sgl-model-gateway/src/tool_parser/factory.rs b/sgl-model-gateway/src/tool_parser/factory.rs deleted file mode 100644 index b67b5d87d..000000000 --- a/sgl-model-gateway/src/tool_parser/factory.rs +++ /dev/null @@ -1,395 +0,0 @@ -// Factory and pool for creating model-specific tool parsers with pooling support. - -use std::{ - collections::HashMap, - sync::{Arc, RwLock}, -}; - -use tokio::sync::Mutex; - -use crate::tool_parser::{ - parsers::{ - DeepSeekParser, Glm4MoeParser, JsonParser, KimiK2Parser, LlamaParser, MinimaxM2Parser, - MistralParser, PassthroughParser, PythonicParser, QwenCoderParser, QwenParser, Step3Parser, - }, - traits::ToolParser, -}; - -/// Type alias for pooled parser instances. -pub type PooledParser = Arc>>; - -/// Type alias for parser creator functions. -type ParserCreator = Arc Box + Send + Sync>; - -/// Registry for model-specific tool parsers with pooling support. -#[derive(Clone)] -pub struct ParserRegistry { - /// Creator functions for parsers (used when pool is empty) - creators: Arc>>, - /// Pooled parser instances for reuse - pool: Arc>>, - /// Model pattern to parser name mappings - model_mapping: Arc>>, - /// Default parser name - default_parser: Arc>, -} - -impl ParserRegistry { - /// Create a new empty registry. - pub fn new() -> Self { - Self { - creators: Arc::new(RwLock::new(HashMap::new())), - pool: Arc::new(RwLock::new(HashMap::new())), - model_mapping: Arc::new(RwLock::new(HashMap::new())), - default_parser: Arc::new(RwLock::new("passthrough".to_string())), - } - } - - /// Register a parser creator for a given parser type. - pub fn register_parser(&self, name: &str, creator: F) - where - F: Fn() -> Box + Send + Sync + 'static, - { - let mut creators = self.creators.write().unwrap(); - creators.insert(name.to_string(), Arc::new(creator)); - } - - /// Map a model name/pattern to a parser - pub fn map_model(&self, model: impl Into, parser: impl Into) { - let mut mapping = self.model_mapping.write().unwrap(); - mapping.insert(model.into(), parser.into()); - } - - /// Get a pooled parser by exact name. - /// Returns a shared parser instance from the pool, creating one if needed. - pub fn get_pooled_parser(&self, name: &str) -> Option { - // First check if we have a pooled instance - { - let pool = self.pool.read().unwrap(); - if let Some(parser) = pool.get(name) { - return Some(Arc::clone(parser)); - } - } - - // If not in pool, create one and add to pool - let creators = self.creators.read().unwrap(); - if let Some(creator) = creators.get(name) { - let parser = Arc::new(Mutex::new(creator())); - - // Add to pool for future use - let mut pool = self.pool.write().unwrap(); - pool.insert(name.to_string(), Arc::clone(&parser)); - - Some(parser) - } else { - None - } - } - - /// Check if a parser with the given name is registered. - pub fn has_parser(&self, name: &str) -> bool { - let creators = self.creators.read().unwrap(); - creators.contains_key(name) - } - - /// Create a fresh (non-pooled) parser instance by exact name. - /// Returns a new parser instance for each call - useful for streaming where state isolation is needed. - pub fn create_parser(&self, name: &str) -> Option> { - let creators = self.creators.read().unwrap(); - creators.get(name).map(|creator| creator()) - } - - /// Check if a parser can be created for a specific model without actually creating it. - /// Returns true if a parser is available (registered) for this model. - pub fn has_parser_for_model(&self, model: &str) -> bool { - // Try exact match first - { - let mapping = self.model_mapping.read().unwrap(); - if let Some(parser_name) = mapping.get(model) { - let creators = self.creators.read().unwrap(); - if creators.contains_key(parser_name) { - return true; - } - } - } - - // Try prefix matching - let model_mapping = self.model_mapping.read().unwrap(); - let best_match = model_mapping - .iter() - .filter(|(pattern, _)| { - pattern.ends_with('*') && model.starts_with(&pattern[..pattern.len() - 1]) - }) - .max_by_key(|(pattern, _)| pattern.len()); - - if let Some((_, parser_name)) = best_match { - let creators = self.creators.read().unwrap(); - if creators.contains_key(parser_name) { - return true; - } - } - - // Return false if no specific parser found for this model - // (get_pooled will still fall back to default parser) - false - } - - /// Create a fresh (non-pooled) parser instance for a specific model. - /// Returns a new parser instance for each call - useful for streaming where state isolation is needed. - pub fn create_for_model(&self, model: &str) -> Option> { - // Try exact match first - { - let mapping = self.model_mapping.read().unwrap(); - if let Some(parser_name) = mapping.get(model) { - if let Some(parser) = self.create_parser(parser_name) { - return Some(parser); - } - } - } - - // Try prefix matching with more specific patterns first - let model_mapping = self.model_mapping.read().unwrap(); - let best_match = model_mapping - .iter() - .filter(|(pattern, _)| { - pattern.ends_with('*') && model.starts_with(&pattern[..pattern.len() - 1]) - }) - .max_by_key(|(pattern, _)| pattern.len()); - - // Return the best matching parser - if let Some((_, parser_name)) = best_match { - if let Some(parser) = self.create_parser(parser_name) { - return Some(parser); - } - } - - // Fall back to default parser - let default = self.default_parser.read().unwrap().clone(); - self.create_parser(&default) - } - - /// Get parser for a specific model - pub fn get_pooled_for_model(&self, model: &str) -> Option { - // Try exact match first - { - let mapping = self.model_mapping.read().unwrap(); - if let Some(parser_name) = mapping.get(model) { - if let Some(parser) = self.get_pooled_parser(parser_name) { - return Some(parser); - } - } - } - - // Try prefix matching with more specific patterns first - let model_mapping = self.model_mapping.read().unwrap(); - let best_match = model_mapping - .iter() - .filter(|(pattern, _)| { - pattern.ends_with('*') && model.starts_with(&pattern[..pattern.len() - 1]) - }) - .max_by_key(|(pattern, _)| pattern.len()); - - // Return the best matching parser - if let Some((_, parser_name)) = best_match { - if let Some(parser) = self.get_pooled_parser(parser_name) { - return Some(parser); - } - } - - // Fall back to default parser - let default = self.default_parser.read().unwrap().clone(); - self.get_pooled_parser(&default) - } - - /// Clear the parser pool, forcing new instances to be created. - pub fn clear_pool(&self) { - let mut pool = self.pool.write().unwrap(); - pool.clear(); - } - - /// Set the default parser - pub fn set_default_parser(&self, name: impl Into) { - let mut default = self.default_parser.write().unwrap(); - *default = name.into(); - } -} - -impl Default for ParserRegistry { - fn default() -> Self { - Self::new() - } -} - -/// Factory for creating tool parsers based on model type. -#[derive(Clone)] -pub struct ParserFactory { - registry: ParserRegistry, -} - -impl ParserFactory { - /// Create a new factory with default parsers registered. - pub fn new() -> Self { - let registry = ParserRegistry::new(); - - // Register default parsers - registry.register_parser("passthrough", || Box::new(PassthroughParser::new())); - registry.register_parser("json", || Box::new(JsonParser::new())); - registry.register_parser("mistral", || Box::new(MistralParser::new())); - registry.register_parser("qwen", || Box::new(QwenParser::new())); - registry.register_parser("qwen_coder", || Box::new(QwenCoderParser::new())); - registry.register_parser("pythonic", || Box::new(PythonicParser::new())); - registry.register_parser("llama", || Box::new(LlamaParser::new())); - registry.register_parser("deepseek", || Box::new(DeepSeekParser::new())); - registry.register_parser("glm45_moe", || Box::new(Glm4MoeParser::glm45())); - registry.register_parser("glm47_moe", || Box::new(Glm4MoeParser::glm47())); - registry.register_parser("step3", || Box::new(Step3Parser::new())); - registry.register_parser("kimik2", || Box::new(KimiK2Parser::new())); - registry.register_parser("minimax_m2", || Box::new(MinimaxM2Parser::new())); - - // Register default model mappings - Self::register_default_mappings(®istry); - - Self { registry } - } - - fn register_default_mappings(registry: &ParserRegistry) { - // OpenAI models - registry.map_model("gpt-4*", "json"); - registry.map_model("gpt-3.5*", "json"); - registry.map_model("gpt-4o*", "json"); - - // Anthropic models - registry.map_model("claude-*", "json"); - - // Mistral models - registry.map_model("mistral-*", "mistral"); - registry.map_model("mixtral-*", "mistral"); - - // Qwen models (more specific patterns first - longer patterns take precedence) - // Qwen Coder models use XML format: value - registry.map_model("Qwen/Qwen3-Coder*", "qwen_coder"); - registry.map_model("Qwen3-Coder*", "qwen_coder"); - registry.map_model("qwen3-coder*", "qwen_coder"); - registry.map_model("Qwen/Qwen2.5-Coder*", "qwen_coder"); - registry.map_model("Qwen2.5-Coder*", "qwen_coder"); - registry.map_model("qwen2.5-coder*", "qwen_coder"); - // Generic Qwen models use JSON format - registry.map_model("qwen*", "qwen"); - registry.map_model("Qwen*", "qwen"); - - // Llama models - registry.map_model("llama-4*", "pythonic"); - registry.map_model("meta-llama-4*", "pythonic"); - registry.map_model("llama-3.2*", "llama"); - registry.map_model("meta-llama-3.2*", "llama"); - registry.map_model("llama-*", "json"); - registry.map_model("meta-llama-*", "json"); - - // DeepSeek models - registry.map_model("deepseek-v3*", "deepseek"); - registry.map_model("deepseek-ai/DeepSeek-V3*", "deepseek"); - registry.map_model("deepseek-*", "pythonic"); - - // GLM models - registry.map_model("glm-4.5*", "glm45_moe"); - registry.map_model("glm-4.6*", "glm45_moe"); - registry.map_model("glm-4.7*", "glm47_moe"); - registry.map_model("glm-*", "json"); - - // Step3 models - registry.map_model("step3*", "step3"); - registry.map_model("Step-3*", "step3"); - - // Kimi models - registry.map_model("kimi-k2*", "kimik2"); - registry.map_model("Kimi-K2*", "kimik2"); - registry.map_model("moonshot*/Kimi-K2*", "kimik2"); - - // MiniMax models - registry.map_model("minimax*", "minimax_m2"); - registry.map_model("MiniMax*", "minimax_m2"); - - // Other models - registry.map_model("gemini-*", "json"); - registry.map_model("palm-*", "json"); - registry.map_model("gemma-*", "json"); - } - - /// Get a pooled parser for the given model ID. - /// Returns a shared instance that can be used concurrently. - /// Falls back to passthrough parser if model is not recognized. - pub fn get_pooled(&self, model_id: &str) -> PooledParser { - self.registry - .get_pooled_for_model(model_id) - .unwrap_or_else(|| { - // Fallback to passthrough parser (no-op, returns text unchanged) - self.registry - .get_pooled_parser("passthrough") - .expect("Passthrough parser should always be registered") - }) - } - - /// Get the internal registry for custom registration. - pub fn registry(&self) -> &ParserRegistry { - &self.registry - } - - /// Clear the parser pool. - pub fn clear_pool(&self) { - self.registry.clear_pool(); - } - - /// Get a non-pooled parser for the given model ID (creates a fresh instance each time). - /// This is useful for benchmarks and testing where you want independent parser instances. - pub fn get_parser(&self, model_id: &str) -> Option> { - // Determine which parser type to use - let parser_type = { - let mapping = self.registry.model_mapping.read().unwrap(); - - // Try exact match first - if let Some(parser_name) = mapping.get(model_id) { - parser_name.clone() - } else { - // Try prefix matching - let best_match = mapping - .iter() - .filter(|(pattern, _)| { - pattern.ends_with('*') - && model_id.starts_with(&pattern[..pattern.len() - 1]) - }) - .max_by_key(|(pattern, _)| pattern.len()); - - if let Some((_, parser_name)) = best_match { - parser_name.clone() - } else { - // Fall back to default - self.registry.default_parser.read().unwrap().clone() - } - } - }; - - let creators = self.registry.creators.read().unwrap(); - creators.get(&parser_type).map(|creator| { - // Call the creator to get a Box, then convert to Arc - let boxed_parser = creator(); - Arc::from(boxed_parser) - }) - } - - /// List all registered parsers (for compatibility with old API). - pub fn list_parsers(&self) -> Vec { - self.registry - .creators - .read() - .unwrap() - .keys() - .cloned() - .collect() - } -} - -impl Default for ParserFactory { - fn default() -> Self { - Self::new() - } -} diff --git a/sgl-model-gateway/src/tool_parser/mod.rs b/sgl-model-gateway/src/tool_parser/mod.rs deleted file mode 100644 index 227d7a6a5..000000000 --- a/sgl-model-gateway/src/tool_parser/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -/// Tool parser module for handling function/tool calls in model outputs -/// -/// This module provides infrastructure for parsing tool calls from various model formats. -// Core modules -pub mod errors; -pub mod factory; -pub mod partial_json; -pub mod traits; -pub mod types; - -// Parser implementations -pub mod parsers; - -#[cfg(test)] -mod tests; - -// Re-export types used outside this module -pub use factory::{ParserFactory, PooledParser}; -pub use parsers::{ - DeepSeekParser, Glm4MoeParser, JsonParser, KimiK2Parser, LlamaParser, MinimaxM2Parser, - MistralParser, PythonicParser, QwenParser, Step3Parser, -}; -pub use traits::ToolParser; -pub use types::{FunctionCall, PartialToolCall, StreamingParseResult, ToolCall}; diff --git a/sgl-model-gateway/src/tool_parser/parsers/deepseek.rs b/sgl-model-gateway/src/tool_parser/parsers/deepseek.rs deleted file mode 100644 index d9b50f999..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/deepseek.rs +++ /dev/null @@ -1,328 +0,0 @@ -use async_trait::async_trait; -use regex::Regex; -use serde_json::Value; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::{ParserError, ParserResult}, - parsers::helpers, - traits::ToolParser, - types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem}, - }, -}; - -/// DeepSeek V3 format parser for tool calls -/// -/// Handles the DeepSeek V3 specific format that uses Unicode tokens: -/// `<|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>{name}\n```json\n{args}\n```<|tool▁call▁end|><|tool▁calls▁end|>` -/// -/// Features: -/// - Unicode token delimiters -/// - JSON arguments in code blocks -/// - Support for multiple sequential tool calls -/// -/// Reference: https://huggingface.co/deepseek-ai/DeepSeek-V3-0324?chat_template=default -pub struct DeepSeekParser { - /// Regex for extracting complete tool calls - tool_call_extractor: Regex, - /// Regex for extracting function details - func_detail_extractor: Regex, - /// Regex for matching partial tool calls during streaming - partial_tool_call_regex: Regex, - /// Regex pattern for removing completed tool calls from buffer - tool_call_end_pattern: Regex, - - /// Buffer for accumulating incomplete patterns across chunks - buffer: String, - - /// Stores complete tool call info (name and arguments) for each tool being parsed - prev_tool_call_arr: Vec, - - /// Index of currently streaming tool call (-1 means no active tool) - current_tool_id: i32, - - /// Flag for whether current tool's name has been sent to client - current_tool_name_sent: bool, - - /// Tracks raw JSON string content streamed to client for each tool's arguments - streamed_args_for_tool: Vec, -} - -impl DeepSeekParser { - /// Create a new DeepSeek parser - pub fn new() -> Self { - // Use (?s) flag for DOTALL mode to handle newlines - let tool_call_pattern = r"(?s)<|tool▁call▁begin|>.*?<|tool▁call▁end|>"; - let tool_call_extractor = Regex::new(tool_call_pattern).expect("Valid regex pattern"); - - let func_detail_pattern = r"(?s)<|tool▁call▁begin|>(.*?)<|tool▁sep|>(.*?)\n```json\n(.*?)\n```<|tool▁call▁end|>"; - let func_detail_extractor = Regex::new(func_detail_pattern).expect("Valid regex pattern"); - - // Partial pattern for streaming - uses .* (greedy) not .*? to match all partial content - let partial_pattern = r"(?s)<|tool▁call▁begin|>(.*)<|tool▁sep|>(.*)\n```json\n(.*)"; - let partial_tool_call_regex = Regex::new(partial_pattern).expect("Valid regex pattern"); - - // Pattern for removing completed tool calls - let end_pattern = r"(?s)<|tool▁call▁begin|>.*?<|tool▁call▁end|>"; - let tool_call_end_pattern = Regex::new(end_pattern).expect("Valid regex pattern"); - - Self { - tool_call_extractor, - func_detail_extractor, - partial_tool_call_regex, - tool_call_end_pattern, - buffer: String::new(), - prev_tool_call_arr: Vec::new(), - current_tool_id: -1, - current_tool_name_sent: false, - streamed_args_for_tool: Vec::new(), - } - } - - /// Parse a single tool call block - throws error if parsing fails - fn parse_tool_call(&self, block: &str) -> ParserResult { - let captures = self.func_detail_extractor.captures(block).ok_or_else(|| { - ParserError::ParsingFailed("Failed to match tool call pattern".to_string()) - })?; - - // Get function type (should be "function") - let func_type = captures.get(1).map_or("", |m| m.as_str()); - if func_type != "function" { - return Err(ParserError::ParsingFailed(format!( - "Invalid function type: {}", - func_type - ))); - } - - // Get function name - let func_name = captures.get(2).map_or("", |m| m.as_str()).trim(); - if func_name.is_empty() { - return Err(ParserError::ParsingFailed( - "Empty function name".to_string(), - )); - } - - // Get JSON arguments - let json_args = captures.get(3).map_or("{}", |m| m.as_str()).trim(); - - // Parse JSON arguments - let value = serde_json::from_str::(json_args) - .map_err(|e| ParserError::ParsingFailed(format!("Invalid JSON: {}", e)))?; - - // Create arguments object - let args = if value.is_object() { - value - } else { - // If not an object, wrap it - serde_json::json!({ "value": value }) - }; - - let arguments = - serde_json::to_string(&args).map_err(|e| ParserError::ParsingFailed(e.to_string()))?; - - Ok(ToolCall { - function: FunctionCall { - name: func_name.to_string(), - arguments, - }, - }) - } -} - -impl Default for DeepSeekParser { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl ToolParser for DeepSeekParser { - async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec)> { - if !self.has_tool_markers(text) { - return Ok((text.to_string(), vec![])); - } - - // Find where tool calls begin - let idx = text.find("<|tool▁calls▁begin|>").unwrap(); - let normal_text = text[..idx].to_string(); - - // Try to extract tool calls, log warnings for failures - let mut tools = Vec::new(); - for mat in self.tool_call_extractor.find_iter(text) { - match self.parse_tool_call(mat.as_str()) { - Ok(tool) => tools.push(tool), - Err(e) => { - tracing::debug!("Failed to parse tool call: {}", e); - continue; - } - } - } - - // If no tools were successfully parsed despite having markers, return entire text as fallback - if tools.is_empty() { - return Ok((text.to_string(), vec![])); - } - - Ok((normal_text, tools)) - } - - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult { - self.buffer.push_str(chunk); - let current_text = &self.buffer.clone(); - - // Check if we have a tool call (either the start token or individual tool call) - let has_tool_call = - self.has_tool_markers(current_text) || current_text.contains("<|tool▁call▁begin|>"); - - if !has_tool_call { - // No tool markers detected - return all buffered content as normal text - // Strip out end tokens if present - let mut normal_text = std::mem::take(&mut self.buffer); - for e_token in ["<|tool▁calls▁end|>", "```", "<|tool▁call▁end|>"] { - normal_text = normal_text.replace(e_token, ""); - } - return Ok(StreamingParseResult { - normal_text, - calls: vec![], - }); - } - - // Build tool indices for validation - let tool_indices = helpers::get_tool_indices(tools); - - let mut calls: Vec = Vec::new(); - - // Try to match the partial tool call pattern - if let Some(captures) = self.partial_tool_call_regex.captures(current_text) { - let func_name = captures.get(2).map_or("", |m| m.as_str()).trim(); - let func_args_raw = captures.get(3).map_or("", |m| m.as_str()).trim(); - - // Validate tool name - if !tool_indices.contains_key(func_name) { - // Invalid tool name - skip this tool, preserve indexing for next tool - tracing::debug!("Invalid tool name '{}' - skipping", func_name); - helpers::reset_current_tool_state( - &mut self.buffer, - &mut self.current_tool_name_sent, - &mut self.streamed_args_for_tool, - &self.prev_tool_call_arr, - ); - return Ok(StreamingParseResult::default()); - } - - // Initialize state if this is the first tool call - if self.current_tool_id == -1 { - self.current_tool_id = 0; - self.prev_tool_call_arr = Vec::new(); - self.streamed_args_for_tool = vec![String::new()]; - } - - // Ensure we have enough entries in our tracking arrays - helpers::ensure_capacity( - self.current_tool_id, - &mut self.prev_tool_call_arr, - &mut self.streamed_args_for_tool, - ); - - // Send tool name if not sent yet - if !self.current_tool_name_sent { - calls.push(ToolCallItem { - tool_index: self.current_tool_id as usize, - name: Some(func_name.to_string()), - parameters: String::new(), - }); - self.current_tool_name_sent = true; - - // Store the tool call info for serving layer completions endpoint - let tool_id = self.current_tool_id as usize; - if self.prev_tool_call_arr.len() <= tool_id { - self.prev_tool_call_arr - .resize_with(tool_id + 1, || Value::Null); - } - self.prev_tool_call_arr[tool_id] = serde_json::json!({ - "name": func_name, - "arguments": {}, - }); - } else { - // Compute incremental diff - let tool_id = self.current_tool_id as usize; - let last_sent = self - .streamed_args_for_tool - .get(tool_id) - .map(|s| s.as_str()) - .unwrap_or(""); - - let argument_diff = func_args_raw - .strip_prefix(last_sent) - .unwrap_or(func_args_raw); - - if !argument_diff.is_empty() { - calls.push(ToolCallItem { - tool_index: tool_id, - name: None, - parameters: argument_diff.to_string(), - }); - if tool_id < self.streamed_args_for_tool.len() { - self.streamed_args_for_tool[tool_id].push_str(argument_diff); - } - } - - // Check if JSON is complete - if helpers::is_complete_json(func_args_raw) { - // Update the stored arguments - if let Ok(parsed_args) = serde_json::from_str::(func_args_raw) { - let tool_id = self.current_tool_id as usize; - if tool_id < self.prev_tool_call_arr.len() { - if let Some(obj) = self.prev_tool_call_arr[tool_id].as_object_mut() { - obj.insert("arguments".to_string(), parsed_args); - } - } - } - - // Find the end of the current tool call and remove only that part from buffer - if let Some(mat) = self.tool_call_end_pattern.find(current_text) { - // Remove the completed tool call from buffer, keep any remaining content - self.buffer = current_text[mat.end()..].to_string(); - } else { - self.buffer.clear(); - } - - let result = StreamingParseResult { - normal_text: String::new(), - calls, - }; - - self.current_tool_id += 1; - self.current_tool_name_sent = false; - return Ok(result); - } - } - } - - Ok(StreamingParseResult { - normal_text: String::new(), - calls, - }) - } - - fn has_tool_markers(&self, text: &str) -> bool { - text.contains("<|tool▁calls▁begin|>") - } - - fn get_unstreamed_tool_args(&self) -> Option> { - 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.current_tool_name_sent = false; - self.streamed_args_for_tool.clear(); - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/glm4_moe.rs b/sgl-model-gateway/src/tool_parser/parsers/glm4_moe.rs deleted file mode 100644 index e72b3b81c..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/glm4_moe.rs +++ /dev/null @@ -1,340 +0,0 @@ -use async_trait::async_trait; -use regex::Regex; -use serde_json::Value; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::{ParserError, ParserResult}, - parsers::helpers, - traits::ToolParser, - types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem}, - }, -}; - -/// GLM-4 MoE format parser for tool calls -/// -/// Handles both GLM-4 MoE and GLM-4.7 MoE formats: -/// - GLM-4: `{name}\n{key}\n{value}\n` -/// - GLM-4.7: `{name}{key}{value}` -/// -/// Features: -/// - XML-style tags for tool calls -/// - Key-value pairs for arguments -/// - Support for multiple sequential tool calls -pub struct Glm4MoeParser { - /// Regex for extracting complete tool calls - tool_call_extractor: Regex, - /// Regex for extracting function details - func_detail_extractor: Regex, - /// Regex for extracting argument key-value pairs - arg_extractor: Regex, - - /// Buffer for accumulating incomplete patterns across chunks - buffer: String, - - /// Stores complete tool call info (name and arguments) for each tool being parsed - prev_tool_call_arr: Vec, - - /// Index of currently streaming tool call (-1 means no active tool) - current_tool_id: i32, - - /// Tracks raw JSON string content streamed to client for each tool's arguments - streamed_args_for_tool: Vec, - - /// Token configuration - bot_token: &'static str, - eot_token: &'static str, -} - -impl Glm4MoeParser { - /// Create a new generic GLM MoE parser with a custom func_detail_extractor pattern - /// - /// # Arguments - /// - `func_detail_pattern`: Regex pattern for extracting function name and arguments - /// - For GLM-4: `r"(?s)([^\n]*)\n(.*)"` - /// - For GLM-4.7: `r"(?s)\s*([^<\s]+)\s*(.*?)"` - pub(crate) fn new(func_detail_pattern: &str) -> Self { - // Use (?s) flag for DOTALL mode to handle newlines - let tool_call_pattern = r"(?s).*?"; - 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)(.*?)\s*(.*?)"; - 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: "", - eot_token: "", - } - } - - /// Create a new GLM-4.5/4.6 MoE parser (with newline-based format) - pub fn glm45() -> Self { - Self::new(r"(?s)([^\n]*)\n(.*)") - } - - /// Create a new GLM-4.7 MoE parser (with whitespace-based format) - pub fn glm47() -> Self { - Self::new(r"(?s)\s*([^<\s]+)\s*(.*?)") - } - - /// Parse arguments from key-value pairs - fn parse_arguments(&self, args_text: &str) -> ParserResult> { - 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()).trim(); - - // Try to parse the value as JSON first, fallback to string - let value = if let Ok(json_val) = serde_json::from_str::(value_str) { - json_val - } else { - // Try parsing as Python literal (similar to Python's ast.literal_eval) - if value_str == "true" || value_str == "True" { - Value::Bool(true) - } else if value_str == "false" || value_str == "False" { - Value::Bool(false) - } else if value_str == "null" || value_str == "None" { - Value::Null - } else if let Ok(num) = value_str.parse::() { - Value::Number(num.into()) - } else if let Ok(num) = value_str.parse::() { - if let Some(n) = serde_json::Number::from_f64(num) { - Value::Number(n) - } else { - Value::String(value_str.to_string()) - } - } else { - Value::String(value_str.to_string()) - } - }; - - arguments.insert(key.to_string(), value); - } - - Ok(arguments) - } - - /// Parse a single tool call block - fn parse_tool_call(&self, block: &str) -> ParserResult> { - if let Some(captures) = self.func_detail_extractor.captures(block) { - // Get function name - let func_name = captures.get(1).map_or("", |m| m.as_str()).trim(); - - // Get arguments text - let args_text = captures.get(2).map_or("", |m| m.as_str()); - - // Parse arguments - let arguments = self.parse_arguments(args_text)?; - - 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) - } - } - - /// Parse all tool calls from text (shared logic for complete and incremental parsing) - fn parse_tool_calls_from_text(&self, text: &str) -> ParserResult> { - let mut tools = Vec::new(); - - for mat in self.tool_call_extractor.find_iter(text) { - match self.parse_tool_call(mat.as_str()) { - Ok(Some(tool)) => tools.push(tool), - Ok(None) => continue, - Err(e) => { - tracing::debug!("Failed to parse tool call: {}", e); - continue; - } - } - } - - Ok(tools) - } -} - -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)> { - // Check if text contains GLM-4 MoE format - if !self.has_tool_markers(text) { - return Ok((text.to_string(), vec![])); - } - - // Find where tool calls begin - let idx = text.find("").unwrap(); - let normal_text = text[..idx].to_string(); - - // Parse all tool calls using shared helper - let tools = self.parse_tool_calls_from_text(text)?; - - // If no tools were successfully parsed despite having markers, return entire text as fallback - if tools.is_empty() { - return Ok((text.to_string(), vec![])); - } - - Ok((normal_text, tools)) - } - - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult { - // Python logic: Wait for complete tool call, then parse it all at once - self.buffer.push_str(chunk); - let current_text = &self.buffer.clone(); - - // Check if we have bot_token - let start = current_text.find(self.bot_token); - if start.is_none() { - self.buffer.clear(); - // If we're in the middle of streaming (current_tool_id > 0), don't return text - let normal_text = if self.current_tool_id > 0 { - String::new() - } else { - current_text.clone() - }; - return Ok(StreamingParseResult { - normal_text, - calls: vec![], - }); - } - - // Check if we have eot_token (end of tool call) - let end = current_text.find(self.eot_token); - if let Some(end_pos) = end { - // We have a complete tool call! - - // Initialize state if this is the first tool call - if self.current_tool_id == -1 { - self.current_tool_id = 0; - self.prev_tool_call_arr = Vec::new(); - self.streamed_args_for_tool = vec![String::new()]; - } - - // Ensure we have enough entries in our tracking arrays - helpers::ensure_capacity( - self.current_tool_id, - &mut self.prev_tool_call_arr, - &mut self.streamed_args_for_tool, - ); - - // Parse the complete block using shared helper - let block_end = end_pos + self.eot_token.len(); - let parsed_tools = self.parse_tool_calls_from_text(¤t_text[..block_end])?; - - // Extract normal text before tool calls - 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() - }; - - // Build tool indices for validation - let tool_indices = helpers::get_tool_indices(tools); - - let mut calls = Vec::new(); - - if !parsed_tools.is_empty() { - // Take the first tool and convert to ToolCallItem - let tool_call = &parsed_tools[0]; - let tool_id = self.current_tool_id as usize; - - // Validate tool name - if !tool_indices.contains_key(&tool_call.function.name) { - // Invalid tool name - skip this tool, preserve indexing for next tool - tracing::debug!("Invalid tool name '{}' - skipping", tool_call.function.name); - helpers::reset_current_tool_state( - &mut self.buffer, - &mut false, // glm45_moe/glm47_moe doesn't track name_sent per tool - &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(), - }); - - // Store in tracking arrays - if self.prev_tool_call_arr.len() <= tool_id { - self.prev_tool_call_arr - .resize_with(tool_id + 1, || Value::Null); - } - - // Parse parameters as JSON and store - if let Ok(args) = serde_json::from_str::(&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] = tool_call.function.arguments.clone(); - - self.current_tool_id += 1; - } - - // Remove processed portion from buffer - self.buffer = current_text[block_end..].to_string(); - return Ok(StreamingParseResult { normal_text, calls }); - } - - // No complete tool call yet - return normal text before start token - let start_pos = start.unwrap(); - 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> { - 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(); - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/helpers.rs b/sgl-model-gateway/src/tool_parser/parsers/helpers.rs deleted file mode 100644 index 6c073794f..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/helpers.rs +++ /dev/null @@ -1,488 +0,0 @@ -use std::collections::HashMap; - -use serde_json::Value; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::{ParserError, ParserResult}, - types::{StreamingParseResult, ToolCallItem}, - }, -}; - -/// Get a mapping of tool names to their indices -pub fn get_tool_indices(tools: &[Tool]) -> HashMap { - tools - .iter() - .enumerate() - .map(|(i, tool)| (tool.function.name.clone(), i)) - .collect() -} - -/// Find the common prefix of two strings -/// Used for incremental argument streaming when partial JSON returns different intermediate states -pub fn find_common_prefix(s1: &str, s2: &str) -> String { - s1.chars() - .zip(s2.chars()) - .take_while(|(c1, c2)| c1 == c2) - .map(|(c1, _)| c1) - .collect() -} - -/// Get unstreamed tool call arguments -/// Returns tool call items for arguments that have been parsed but not yet streamed -/// This ensures tool calls are properly completed even if the model generates final arguments in the last chunk -pub fn get_unstreamed_args( - prev_tool_call_arr: &[Value], - streamed_args_for_tool: &[String], -) -> Option> { - // Check if we have tool calls being tracked - if prev_tool_call_arr.is_empty() || streamed_args_for_tool.is_empty() { - return None; - } - - // Get the last tool call that was being processed - let tool_index = prev_tool_call_arr.len() - 1; - if tool_index >= streamed_args_for_tool.len() { - return None; - } - - // Get expected vs actual arguments - let expected_args = prev_tool_call_arr[tool_index].get("arguments")?; - let expected_str = serde_json::to_string(expected_args).ok()?; - let actual_str = &streamed_args_for_tool[tool_index]; - - // Check if there are remaining arguments to send - let remaining = if expected_str.starts_with(actual_str) { - &expected_str[actual_str.len()..] - } else { - return None; - }; - - if remaining.is_empty() { - return None; - } - - // Return the remaining arguments as a ToolCallItem - Some(vec![ToolCallItem { - tool_index, - name: None, // No name for argument deltas - parameters: remaining.to_string(), - }]) -} - -/// Check if a buffer ends with a partial occurrence of a token -/// Returns Some(length) if there's a partial match, None otherwise -pub fn ends_with_partial_token(buffer: &str, token: &str) -> Option { - if buffer.is_empty() || token.is_empty() { - return None; - } - - (1..token.len()).find(|&i| buffer.ends_with(&token[..i])) -} - -/// Reset state for the current tool being parsed (used when skipping invalid tools). -/// This preserves the parser's overall state (current_tool_id, prev_tool_call_arr) -/// but clears the state specific to the current incomplete tool. -pub fn reset_current_tool_state( - buffer: &mut String, - current_tool_name_sent: &mut bool, - streamed_args_for_tool: &mut Vec, - prev_tool_call_arr: &[Value], -) { - buffer.clear(); - *current_tool_name_sent = false; - - // Only pop if we added an entry for the current (invalid) tool - // streamed_args_for_tool should match prev_tool_call_arr length for completed tools - if streamed_args_for_tool.len() > prev_tool_call_arr.len() { - streamed_args_for_tool.pop(); - } -} - -/// Reset the entire parser state (used at the start of a new request). -/// Clears all accumulated tool calls and resets all state to initial values. -pub fn reset_parser_state( - buffer: &mut String, - prev_tool_call_arr: &mut Vec, - current_tool_id: &mut i32, - current_tool_name_sent: &mut bool, - streamed_args_for_tool: &mut Vec, -) { - buffer.clear(); - prev_tool_call_arr.clear(); - *current_tool_id = -1; - *current_tool_name_sent = false; - streamed_args_for_tool.clear(); -} - -/// Ensure arrays have capacity for the given tool ID -pub fn ensure_capacity( - current_tool_id: i32, - prev_tool_call_arr: &mut Vec, - streamed_args_for_tool: &mut Vec, -) { - if current_tool_id < 0 { - return; - } - let needed = (current_tool_id + 1) as usize; - - if prev_tool_call_arr.len() < needed { - prev_tool_call_arr.resize_with(needed, || Value::Null); - } - if streamed_args_for_tool.len() < needed { - streamed_args_for_tool.resize_with(needed, String::new); - } -} - -/// Check if a string contains complete, valid JSON -pub fn is_complete_json(input: &str) -> bool { - serde_json::from_str::(input).is_ok() -} - -/// Normalize the arguments/parameters field in a tool call object. -/// If the object has "parameters" but not "arguments", copy parameters to arguments. -/// -/// # Background -/// Different LLM formats use different field names: -/// - Llama and JSON parsers use "parameters" (correct per JSON Schema spec) -/// - Mistral and Qwen use "arguments" -/// -/// This function normalizes to "arguments" for consistent downstream processing. -pub fn normalize_arguments_field(mut obj: Value) -> Value { - if obj.get("arguments").is_none() { - if let Some(params) = obj.get("parameters").cloned() { - if let Value::Object(ref mut map) = obj { - map.insert("arguments".to_string(), params); - } - } - } - obj -} - -/// Handle the entire JSON tool call streaming process for JSON-based parsers. -/// -/// This unified function handles all aspects of streaming tool calls: -/// - Parsing partial JSON from the buffer -/// - Validating tool names against available tools -/// - Streaming tool names (Case 1) -/// - Streaming tool arguments (Case 2) -/// - Managing parser state and buffer updates -/// -/// Used by JSON, Llama, Mistral, and Qwen parsers. -/// -/// # Parameters -/// - `current_text`: The current buffered text being parsed -/// - `start_idx`: Start index of JSON content in current_text -/// - `partial_json`: Mutable reference to partial JSON parser -/// - `tool_indices`: Map of valid tool names to their indices -/// - `buffer`: Mutable parser buffer -/// - `current_tool_id`: Mutable current tool index (-1 means no active tool) -/// - `current_tool_name_sent`: Mutable flag for whether current tool's name was sent -/// - `streamed_args_for_tool`: Mutable accumulator of streamed arguments per tool -/// - `prev_tool_call_arr`: Mutable array of previous tool call states -/// -/// # Returns -/// - `Ok(StreamingParseResult)` with any tool call items to stream -/// - `Err(ParserError)` if JSON parsing or serialization fails -#[allow(clippy::too_many_arguments)] -pub(crate) fn handle_json_tool_streaming( - current_text: &str, - start_idx: usize, - partial_json: &mut crate::tool_parser::partial_json::PartialJson, - tool_indices: &HashMap, - buffer: &mut String, - current_tool_id: &mut i32, - current_tool_name_sent: &mut bool, - streamed_args_for_tool: &mut Vec, - prev_tool_call_arr: &mut Vec, -) -> ParserResult { - // Check if we have content to parse - if start_idx >= current_text.len() { - return Ok(StreamingParseResult::default()); - } - - // Extract JSON string from current position - let json_str = ¤t_text[start_idx..]; - - // When current_tool_name_sent is false, don't allow partial strings to avoid - // parsing incomplete tool names as empty strings - let allow_partial_strings = *current_tool_name_sent; - - // Parse partial JSON - let (obj, end_idx) = match partial_json.parse_value(json_str, allow_partial_strings) { - Ok(result) => result, - Err(_) => { - return Ok(StreamingParseResult::default()); - } - }; - - // Check if JSON is complete - validate only the parsed portion - // Ensure end_idx is on a valid UTF-8 character boundary - let safe_end_idx = if json_str.is_char_boundary(end_idx) { - end_idx - } else { - // Find the nearest valid character boundary before end_idx - (0..end_idx) - .rev() - .find(|&i| json_str.is_char_boundary(i)) - .unwrap_or(0) - }; - let is_complete = serde_json::from_str::(&json_str[..safe_end_idx]).is_ok(); - - // Validate tool name if present - if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { - if !tool_indices.contains_key(name) { - // Invalid tool name - skip this tool, preserve indexing for next tool - tracing::debug!("Invalid tool name '{}' - skipping", name); - reset_current_tool_state( - buffer, - current_tool_name_sent, - streamed_args_for_tool, - prev_tool_call_arr, - ); - return Ok(StreamingParseResult::default()); - } - } - - // Normalize parameters/arguments field - let current_tool_call = normalize_arguments_field(obj); - - let mut result = StreamingParseResult::default(); - - // Case 1: Handle tool name streaming - if !*current_tool_name_sent { - if let Some(function_name) = current_tool_call.get("name").and_then(|v| v.as_str()) { - if tool_indices.contains_key(function_name) { - // Initialize if first tool - if *current_tool_id == -1 { - *current_tool_id = 0; - streamed_args_for_tool.push(String::new()); - } else if *current_tool_id as usize >= streamed_args_for_tool.len() { - // Ensure capacity for subsequent tools - ensure_capacity(*current_tool_id, prev_tool_call_arr, streamed_args_for_tool); - } - - // Send tool name with empty parameters - *current_tool_name_sent = true; - result.calls.push(ToolCallItem { - tool_index: *current_tool_id as usize, - name: Some(function_name.to_string()), - parameters: String::new(), - }); - } - } - } - // Case 2: Handle streaming arguments - else if let Some(cur_arguments) = current_tool_call.get("arguments") { - let tool_id = *current_tool_id as usize; - let sent = streamed_args_for_tool - .get(tool_id) - .map(|s| s.len()) - .unwrap_or(0); - let cur_args_json = serde_json::to_string(cur_arguments) - .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; - - // Get prev_arguments (matches Python's structure) - let prev_arguments = if tool_id < prev_tool_call_arr.len() { - prev_tool_call_arr[tool_id].get("arguments") - } else { - None - }; - - // Calculate diff: everything after we've already sent - let mut argument_diff = None; - - if is_complete { - // Python: argument_diff = cur_args_json[sent:] - // Rust needs bounds check (Python returns "" automatically) - argument_diff = if sent < cur_args_json.len() { - Some(cur_args_json[sent..].to_string()) - } else { - Some(String::new()) - }; - } else if let Some(prev_args) = prev_arguments { - let prev_args_json = serde_json::to_string(prev_args) - .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; - - if cur_args_json != prev_args_json { - let prefix = find_common_prefix(&prev_args_json, &cur_args_json); - argument_diff = if sent < prefix.len() { - Some(prefix[sent..].to_string()) - } else { - Some(String::new()) - }; - } - } - - // Send diff if present - if let Some(diff) = argument_diff { - if !diff.is_empty() { - if tool_id < streamed_args_for_tool.len() { - streamed_args_for_tool[tool_id].push_str(&diff); - } - result.calls.push(ToolCallItem { - tool_index: tool_id, - name: None, - parameters: diff, - }); - } - } - - // Update prev_tool_call_arr with current state - if *current_tool_id >= 0 { - ensure_capacity(*current_tool_id, prev_tool_call_arr, streamed_args_for_tool); - - if tool_id < prev_tool_call_arr.len() { - prev_tool_call_arr[tool_id] = current_tool_call; - } - } - - // If complete, advance to next tool - if is_complete { - *buffer = current_text[start_idx + end_idx..].to_string(); - *current_tool_name_sent = false; - *current_tool_id += 1; - } - } - - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_ends_with_partial_token() { - assert!(ends_with_partial_token("hello <|py", "<|python_tag|>").is_some()); - assert!(ends_with_partial_token("hello <|python_tag", "<|python_tag|>").is_some()); - assert!(ends_with_partial_token("hello <|python_tag|>", "<|python_tag|>").is_none()); - assert!(ends_with_partial_token("", "<|python_tag|>").is_none()); - assert!(ends_with_partial_token("hello world", "<|python_tag|>").is_none()); - } - - #[test] - fn test_reset_current_tool_state() { - let mut buffer = String::from("partial json"); - let mut current_tool_name_sent = true; - let mut streamed_args = vec!["tool0_args".to_string(), "tool1_partial".to_string()]; - let prev_tools = vec![serde_json::json!({"name": "tool0"})]; - - reset_current_tool_state( - &mut buffer, - &mut current_tool_name_sent, - &mut streamed_args, - &prev_tools, - ); - - assert_eq!(buffer, ""); - assert!(!current_tool_name_sent); - assert_eq!(streamed_args.len(), 1); // Popped the partial tool1 args - assert_eq!(streamed_args[0], "tool0_args"); - } - - #[test] - fn test_reset_current_tool_state_no_pop_when_synced() { - let mut buffer = String::from("partial json"); - let mut current_tool_name_sent = true; - let mut streamed_args = vec!["tool0_args".to_string()]; - let prev_tools = vec![serde_json::json!({"name": "tool0"})]; - - reset_current_tool_state( - &mut buffer, - &mut current_tool_name_sent, - &mut streamed_args, - &prev_tools, - ); - - assert_eq!(buffer, ""); - assert!(!current_tool_name_sent); - assert_eq!(streamed_args.len(), 1); // No pop, lengths matched - } - - #[test] - fn test_reset_parser_state() { - let mut buffer = String::from("some buffer"); - let mut prev_tools = vec![serde_json::json!({"name": "tool0"})]; - let mut current_tool_id = 5; - let mut current_tool_name_sent = true; - let mut streamed_args = vec!["args".to_string()]; - - reset_parser_state( - &mut buffer, - &mut prev_tools, - &mut current_tool_id, - &mut current_tool_name_sent, - &mut streamed_args, - ); - - assert_eq!(buffer, ""); - assert_eq!(prev_tools.len(), 0); - assert_eq!(current_tool_id, -1); - assert!(!current_tool_name_sent); - assert_eq!(streamed_args.len(), 0); - } - - #[test] - fn test_ensure_capacity() { - let mut prev_tools = vec![]; - let mut streamed_args = vec![]; - - ensure_capacity(2, &mut prev_tools, &mut streamed_args); - - assert_eq!(prev_tools.len(), 3); - assert_eq!(streamed_args.len(), 3); - assert_eq!(prev_tools[0], Value::Null); - assert_eq!(streamed_args[0], ""); - } - - #[test] - fn test_ensure_capacity_negative_id() { - let mut prev_tools = vec![]; - let mut streamed_args = vec![]; - - ensure_capacity(-1, &mut prev_tools, &mut streamed_args); - - // Should not resize for negative ID - assert_eq!(prev_tools.len(), 0); - assert_eq!(streamed_args.len(), 0); - } - - #[test] - fn test_is_complete_json() { - assert!(is_complete_json(r#"{"name": "test"}"#)); - assert!(is_complete_json("[1, 2, 3]")); - assert!(is_complete_json("42")); - assert!(is_complete_json("true")); - assert!(!is_complete_json(r#"{"name": "#)); - assert!(!is_complete_json("[1, 2,")); - } - - #[test] - fn test_normalize_arguments_field() { - // Case 1: Has parameters, no arguments - let obj = serde_json::json!({ - "name": "test", - "parameters": {"key": "value"} - }); - let normalized = normalize_arguments_field(obj); - assert_eq!( - normalized.get("arguments").unwrap(), - &serde_json::json!({"key": "value"}) - ); - - // Case 2: Already has arguments - let obj = serde_json::json!({ - "name": "test", - "arguments": {"key": "value"} - }); - let normalized = normalize_arguments_field(obj.clone()); - assert_eq!(normalized, obj); - - // Case 3: No parameters or arguments - let obj = serde_json::json!({"name": "test"}); - let normalized = normalize_arguments_field(obj.clone()); - assert_eq!(normalized, obj); - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/json.rs b/sgl-model-gateway/src/tool_parser/parsers/json.rs deleted file mode 100644 index 7c4be2f45..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/json.rs +++ /dev/null @@ -1,305 +0,0 @@ -use async_trait::async_trait; -use serde_json::Value; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::{ParserError, ParserResult}, - parsers::helpers, - partial_json::PartialJson, - traits::ToolParser, - types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem}, - }, -}; - -/// JSON format parser for tool calls -/// -/// Handles pure JSON formats for function calling: -/// - Single tool call: {"name": "fn", "arguments": {...}} -/// - Multiple tool calls: [{"name": "fn1", "arguments": {...}}, ...] -/// - With parameters instead of arguments: {"name": "fn", "parameters": {...}} -pub struct JsonParser { - /// Parser for handling incomplete JSON during streaming - partial_json: PartialJson, - - /// Buffer for accumulating incomplete patterns across chunks - buffer: String, - - /// Stores complete tool call info (name and arguments) for each tool being parsed - prev_tool_call_arr: Vec, - - /// Index of currently streaming tool call (-1 means no active tool) - current_tool_id: i32, - - /// Flag for whether current tool's name has been sent to client - current_tool_name_sent: bool, - - /// Tracks raw JSON string content streamed to client for each tool's arguments - streamed_args_for_tool: Vec, - - /// Separator between multiple tool calls - tool_call_separator: &'static str, - - /// Track whether we're parsing array format `[...]` vs single object `{...}` - is_array_format: bool, - - /// Track whether we've already stripped the closing ] bracket (for array format) - array_closed: bool, -} - -impl JsonParser { - /// Create a new JSON parser - pub fn new() -> Self { - Self { - partial_json: PartialJson::default(), - buffer: String::new(), - prev_tool_call_arr: Vec::new(), - current_tool_id: -1, - current_tool_name_sent: false, - streamed_args_for_tool: Vec::new(), - tool_call_separator: ",", - is_array_format: false, - array_closed: false, - } - } - - /// Try to extract a first valid JSON object or array from text that may contain other content - /// Returns (json_string, normal_text) where normal_text is text before and after the JSON - fn extract_json_from_text(&self, text: &str) -> Option<(String, String)> { - let mut in_string = false; - let mut escape = false; - let mut stack: Vec = Vec::with_capacity(8); - let mut start: Option = None; - - for (i, ch) in text.char_indices() { - if escape { - escape = false; - continue; - } - - match ch { - '\\' if in_string => escape = true, - '"' => in_string = !in_string, - _ if in_string => {} - '{' | '[' => { - if start.is_none() { - start = Some(i); - } - stack.push(ch); - } - '}' | ']' => { - let Some(open) = stack.pop() else { - // Stray closer - reset and continue looking for next valid JSON - start = None; - continue; - }; - - let valid = (open == '{' && ch == '}') || (open == '[' && ch == ']'); - if !valid { - // Mismatch - reset and continue looking - start = None; - stack.clear(); - continue; - } - - if stack.is_empty() { - let s = start.unwrap(); - let e = i + ch.len_utf8(); - let potential_json = &text[s..e]; - - // Validate that this is actually valid JSON before returning - if serde_json::from_str::(potential_json).is_ok() { - let json = potential_json.to_string(); - let normal = format!("{}{}", &text[..s], &text[e..]); - return Some((json, normal)); - } else { - // Not valid JSON, reset and continue looking - start = None; - continue; - } - } - } - _ => {} - } - } - None - } - - /// Parse a single JSON object into a ToolCall - fn parse_single_object(&self, obj: &Value) -> ParserResult> { - // Check if this looks like a tool call - let name = obj - .get("name") - .or_else(|| obj.get("function")) - .and_then(|v| v.as_str()); - - if let Some(name) = name { - // Get arguments - support both "arguments" and "parameters" keys - let empty_obj = Value::Object(serde_json::Map::new()); - let args = obj - .get("arguments") - .or_else(|| obj.get("parameters")) - .unwrap_or(&empty_obj); - - // Convert arguments to JSON string - let arguments = serde_json::to_string(args) - .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; - - Ok(Some(ToolCall { - function: FunctionCall { - name: name.to_string(), - arguments, - }, - })) - } else { - Ok(None) - } - } - - /// Parse JSON value(s) into tool calls - fn parse_json_value(&self, value: &Value) -> ParserResult> { - let mut tools = Vec::new(); - - match value { - Value::Array(arr) => { - // Parse each element in the array - for item in arr { - if let Some(tool) = self.parse_single_object(item)? { - tools.push(tool); - } - } - } - Value::Object(_) => { - // Single tool call - if let Some(tool) = self.parse_single_object(value)? { - tools.push(tool); - } - } - _ => { - // Not a valid tool call format - return Ok(vec![]); - } - } - - Ok(tools) - } -} - -impl Default for JsonParser { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl ToolParser for JsonParser { - async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec)> { - // Always use extract_json_from_text to handle both pure JSON and mixed content - if let Some((extracted_json, normal_text)) = self.extract_json_from_text(text) { - let parsed = serde_json::from_str::(&extracted_json) - .map_err(|e| ParserError::ParsingFailed(e.to_string())) - .and_then(|v| self.parse_json_value(&v)); - - match parsed { - Ok(tools) => return Ok((normal_text, tools)), - Err(e) => tracing::debug!("parse_complete failed: {:?}", e), - } - } - - // No valid JSON found, return original text as normal text - Ok((text.to_string(), vec![])) - } - - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult { - // Append new text to buffer - self.buffer.push_str(chunk); - let current_text = &self.buffer.clone(); - - // Determine format on first parse (array vs single object) - if self.current_tool_id == -1 && self.has_tool_markers(current_text) { - self.is_array_format = current_text.trim().starts_with('['); - } - - // Check if current_text has tool_call - // Once array is closed, don't treat [ or { as tool markers - let has_tool_start = (!self.array_closed && self.has_tool_markers(current_text)) - || (self.current_tool_id > 0 && current_text.starts_with(self.tool_call_separator)); - - if !has_tool_start { - let mut normal_text = self.buffer.clone(); - self.buffer.clear(); - - // Strip ] only once (the closing bracket of JSON array format) - // Only for array format and only if we haven't already closed it - if self.is_array_format - && !self.array_closed - && self.current_tool_id > 0 - && normal_text.starts_with("]") - { - normal_text = normal_text.strip_prefix("]").unwrap().to_string(); - self.array_closed = true; - } - - return Ok(StreamingParseResult { - normal_text, - calls: vec![], - }); - } - - // Build tool indices - let tool_indices = helpers::get_tool_indices(tools); - - // Determine start index for JSON parsing - // JSON can start with [ (array) or { (single object) - let start_idx = if let Some(bracket_pos) = current_text.find('[') { - let brace_pos = current_text.find('{'); - match brace_pos { - Some(bp) => bp, - _ => bracket_pos, - } - } else if let Some(brace_pos) = current_text.find('{') { - brace_pos - } else if self.current_tool_id > 0 && current_text.starts_with(self.tool_call_separator) { - self.tool_call_separator.len() - } else { - 0 - }; - - helpers::handle_json_tool_streaming( - current_text, - start_idx, - &mut self.partial_json, - &tool_indices, - &mut self.buffer, - &mut self.current_tool_id, - &mut self.current_tool_name_sent, - &mut self.streamed_args_for_tool, - &mut self.prev_tool_call_arr, - ) - } - - fn has_tool_markers(&self, text: &str) -> bool { - let trimmed = text.trim(); - trimmed.starts_with('[') || trimmed.starts_with('{') - } - - fn get_unstreamed_tool_args(&self) -> Option> { - helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool) - } - - fn reset(&mut self) { - helpers::reset_parser_state( - &mut self.buffer, - &mut self.prev_tool_call_arr, - &mut self.current_tool_id, - &mut self.current_tool_name_sent, - &mut self.streamed_args_for_tool, - ); - self.is_array_format = false; - self.array_closed = false; - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/kimik2.rs b/sgl-model-gateway/src/tool_parser/parsers/kimik2.rs deleted file mode 100644 index 948788be3..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/kimik2.rs +++ /dev/null @@ -1,348 +0,0 @@ -use async_trait::async_trait; -use regex::Regex; -use serde_json::Value; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::ParserResult, - parsers::helpers, - traits::ToolParser, - types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem}, - }, -}; - -/// Kimi K2 format parser for tool calls -/// -/// Handles the Kimi K2 specific format: -/// `<|tool_calls_section_begin|><|tool_call_begin|>functions.{name}:{index}<|tool_call_argument_begin|>{json_args}<|tool_call_end|><|tool_calls_section_end|>` -/// -/// Features: -/// - Token-based delimiters -/// - Function calls with explicit indexing -/// - JSON arguments -/// -/// Reference: https://huggingface.co/moonshotai/Kimi-K2-Instruct/blob/main/docs/tool_call_guidance.md -pub struct KimiK2Parser { - /// Regex for extracting complete tool calls - tool_call_extractor: Regex, - /// Regex for extracting partial tool calls (streaming) - stream_tool_call_extractor: Regex, - /// Regex pattern for removing completed tool calls from buffer - tool_call_end_pattern: Regex, - /// Robust parser for ids like "functions.search:0" or fallback "search:0" - tool_call_id_regex: Regex, - - /// Buffer for accumulating incomplete patterns across chunks - buffer: String, - - /// Stores complete tool call info (name and arguments) for each tool being parsed - prev_tool_call_arr: Vec, - - /// Index of currently streaming tool call (-1 means no active tool) - current_tool_id: i32, - - /// Flag for whether current tool's name has been sent to client - current_tool_name_sent: bool, - - /// Tracks raw JSON string content streamed to client for each tool's arguments - streamed_args_for_tool: Vec, - - /// Tracks the last arguments sent for incremental diffing - last_arguments: String, -} - -impl KimiK2Parser { - /// Create a new Kimi K2 parser - pub fn new() -> Self { - // Pattern for complete tool calls - let tool_call_pattern = r"<\|tool_call_begin\|>\s*(?P[\w\.]+:\d+)\s*<\|tool_call_argument_begin\|>\s*(?P\{.*?\})\s*<\|tool_call_end\|>"; - let tool_call_extractor = Regex::new(tool_call_pattern).expect("Valid regex pattern"); - - // Pattern for streaming (partial) tool calls - let stream_pattern = r"<\|tool_call_begin\|>\s*(?P[\w\.]+:\d+)\s*<\|tool_call_argument_begin\|>\s*(?P\{.*)"; - let stream_tool_call_extractor = Regex::new(stream_pattern).expect("Valid regex pattern"); - - // Pattern for removing completed tool calls - let end_pattern = r"<\|tool_call_begin\|>.*?<\|tool_call_end\|>"; - let tool_call_end_pattern = Regex::new(end_pattern).expect("Valid regex pattern"); - - // Robust parser for ids like "functions.search:0" or fallback "search:0" - let id_pattern = r"^(?:functions\.)?(?P[\w\.]+):(?P\d+)$"; - let tool_call_id_regex = Regex::new(id_pattern).expect("Valid regex pattern"); - - Self { - tool_call_extractor, - stream_tool_call_extractor, - tool_call_end_pattern, - tool_call_id_regex, - buffer: String::new(), - prev_tool_call_arr: Vec::new(), - current_tool_id: -1, - current_tool_name_sent: false, - streamed_args_for_tool: Vec::new(), - last_arguments: String::new(), - } - } - - /// Parse function ID to extract name and index - fn parse_function_id(&self, id: &str) -> Option<(String, usize)> { - if let Some(captures) = self.tool_call_id_regex.captures(id) { - let name = captures.name("name")?.as_str().to_string(); - let index = captures.name("index")?.as_str().parse::().ok()?; - Some((name, index)) - } else { - None - } - } -} - -impl Default for KimiK2Parser { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl ToolParser for KimiK2Parser { - async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec)> { - if !self.has_tool_markers(text) { - return Ok((text.to_string(), vec![])); - } - - // Find where tool calls begin - let idx = text.find("<|tool_calls_section_begin|>").unwrap(); - let normal_text = text[..idx].to_string(); - - // Try to extract tool calls - let mut tools = Vec::new(); - for captures in self.tool_call_extractor.captures_iter(text) { - if let (Some(id_match), Some(args_match)) = ( - captures.name("tool_call_id"), - captures.name("function_arguments"), - ) { - let function_id = id_match.as_str(); - let function_args = args_match.as_str(); - - // Parse function ID - if let Some((func_name, _index)) = self.parse_function_id(function_id) { - // Try to parse JSON arguments - match serde_json::from_str::(function_args) { - Ok(_) => { - tools.push(ToolCall { - function: FunctionCall { - name: func_name, - arguments: function_args.to_string(), - }, - }); - } - Err(e) => { - tracing::debug!( - "Failed to parse JSON arguments for {}: {}", - func_name, - e - ); - continue; - } - } - } else { - tracing::debug!("Failed to parse function ID: {}", function_id); - continue; - } - } - } - - // If no tools were successfully parsed despite having markers, return entire text as fallback - if tools.is_empty() { - return Ok((text.to_string(), vec![])); - } - - Ok((normal_text, tools)) - } - - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult { - self.buffer.push_str(chunk); - let current_text = &self.buffer.clone(); - - // Check if we have a tool call (either the start token or individual tool call) - let has_tool_call = - self.has_tool_markers(current_text) || current_text.contains("<|tool_call_begin|>"); - - if !has_tool_call { - // No tool markers detected - return all buffered content as normal text - let mut normal_text = std::mem::take(&mut self.buffer); - // Remove end tokens if present - for e_token in ["<|tool_calls_section_end|>", "<|tool_call_end|>"] { - normal_text = normal_text.replace(e_token, ""); - } - return Ok(StreamingParseResult { - normal_text, - calls: vec![], - }); - } - - // Build tool indices for validation - let tool_indices = helpers::get_tool_indices(tools); - - let mut calls: Vec = Vec::new(); - - // Try to match streaming pattern - if let Some(captures) = self.stream_tool_call_extractor.captures(current_text) { - if let (Some(id_match), Some(args_match)) = ( - captures.name("tool_call_id"), - captures.name("function_arguments"), - ) { - let function_id = id_match.as_str(); - let function_args = args_match.as_str(); - - // Parse function ID - if let Some((func_name, _index)) = self.parse_function_id(function_id) { - // Validate tool name - if !tool_indices.contains_key(&func_name) { - // Invalid tool name - skip this tool, preserve indexing for next tool - tracing::debug!("Invalid tool name '{}' - skipping", func_name); - helpers::reset_current_tool_state( - &mut self.buffer, - &mut self.current_tool_name_sent, - &mut self.streamed_args_for_tool, - &self.prev_tool_call_arr, - ); - return Ok(StreamingParseResult::default()); - } - - // Initialize state if this is the first tool call - if self.current_tool_id == -1 { - self.current_tool_id = 0; - self.prev_tool_call_arr = Vec::new(); - self.streamed_args_for_tool = vec![String::new()]; - } - - // Ensure we have enough entries in our tracking arrays - helpers::ensure_capacity( - self.current_tool_id, - &mut self.prev_tool_call_arr, - &mut self.streamed_args_for_tool, - ); - - // Send tool name if not sent yet - if !self.current_tool_name_sent { - calls.push(ToolCallItem { - tool_index: self.current_tool_id as usize, - name: Some(func_name.clone()), - parameters: String::new(), - }); - self.current_tool_name_sent = true; - - // Store the tool call info for serving layer completions endpoint - let tool_id = self.current_tool_id as usize; - if self.prev_tool_call_arr.len() <= tool_id { - self.prev_tool_call_arr - .resize_with(tool_id + 1, || Value::Null); - } - self.prev_tool_call_arr[tool_id] = serde_json::json!({ - "name": func_name, - "arguments": {}, - }); - } else { - // Compute incremental diff - let argument_diff = if function_args.starts_with(&self.last_arguments) { - &function_args[self.last_arguments.len()..] - } else { - function_args - }; - - // Split by end token before sending (like Python does) - let parsed_args_diff = - if let Some(pos) = argument_diff.find("<|tool_call_end|>") { - &argument_diff[..pos] - } else { - argument_diff - }; - - if !parsed_args_diff.is_empty() { - calls.push(ToolCallItem { - tool_index: self.current_tool_id as usize, - name: None, - parameters: parsed_args_diff.to_string(), - }); - // Note: Python adds full diff to _last_arguments, not just parsed part - self.last_arguments.push_str(argument_diff); - let tool_id = self.current_tool_id as usize; - if tool_id < self.streamed_args_for_tool.len() { - self.streamed_args_for_tool[tool_id].push_str(parsed_args_diff); - } - } - - // Check completeness - split by end token first - let parsed_args = if let Some(pos) = function_args.find("<|tool_call_end|>") - { - &function_args[..pos] - } else { - function_args - }; - - if helpers::is_complete_json(parsed_args) { - // Update the stored arguments - if let Ok(parsed_args_value) = - serde_json::from_str::(parsed_args) - { - let tool_id = self.current_tool_id as usize; - if tool_id < self.prev_tool_call_arr.len() { - if let Some(obj) = - self.prev_tool_call_arr[tool_id].as_object_mut() - { - obj.insert("arguments".to_string(), parsed_args_value); - } - } - } - - // Find the end of the current tool call and remove only that part from buffer - if let Some(mat) = self.tool_call_end_pattern.find(current_text) { - // Remove the completed tool call from buffer, keep any remaining content - self.buffer = current_text[mat.end()..].to_string(); - } else { - self.buffer.clear(); - } - - let result = StreamingParseResult { - normal_text: String::new(), - calls, - }; - - self.current_tool_id += 1; - self.last_arguments.clear(); - self.current_tool_name_sent = false; - return Ok(result); - } - } - } - } - } - - Ok(StreamingParseResult { - normal_text: String::new(), - calls, - }) - } - - fn has_tool_markers(&self, text: &str) -> bool { - text.contains("<|tool_calls_section_begin|>") - } - - fn get_unstreamed_tool_args(&self) -> Option> { - 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.current_tool_name_sent = false; - self.streamed_args_for_tool.clear(); - self.last_arguments.clear(); - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/llama.rs b/sgl-model-gateway/src/tool_parser/parsers/llama.rs deleted file mode 100644 index 9d47414b3..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/llama.rs +++ /dev/null @@ -1,245 +0,0 @@ -use async_trait::async_trait; -use serde_json::Value; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::{ParserError, ParserResult}, - parsers::helpers, - partial_json::PartialJson, - traits::ToolParser, - types::{FunctionCall, StreamingParseResult, ToolCall}, - }, -}; - -/// Llama 3.2 format parser for tool calls -/// -/// Handles the Llama 3.2 specific format: -/// `<|python_tag|>{"name": "func", "parameters": {...}}` -/// -/// Also supports plain JSON without the python_tag prefix -pub struct LlamaParser { - /// Parser for handling incomplete JSON during streaming - partial_json: PartialJson, - - /// Buffer for accumulating incomplete patterns across chunks - buffer: String, - - /// Stores complete tool call info (name and arguments) for each tool being parsed - prev_tool_call_arr: Vec, - - /// Index of currently streaming tool call (-1 means no active tool) - current_tool_id: i32, - - /// Flag for whether current tool's name has been sent to client - current_tool_name_sent: bool, - - /// Tracks raw JSON string content streamed to client for each tool's arguments - streamed_args_for_tool: Vec, - - /// Token configuration - bot_token: &'static str, - tool_call_separator: &'static str, -} - -impl LlamaParser { - /// Create a new Llama parser - pub fn new() -> Self { - Self { - partial_json: PartialJson::default(), - buffer: String::new(), - prev_tool_call_arr: Vec::new(), - current_tool_id: -1, - current_tool_name_sent: false, - streamed_args_for_tool: Vec::new(), - bot_token: "<|python_tag|>", - tool_call_separator: ";", - } - } - - /// Extract content after python_tag token - fn extract_content_after_python_tag(&self, text: &str) -> Option<(String, String)> { - const PYTHON_TAG: &str = "<|python_tag|>"; - - if let Some(tag_pos) = text.find(PYTHON_TAG) { - let normal_text = text[..tag_pos].to_string(); - let json_content = text[tag_pos + PYTHON_TAG.len()..].to_string(); - Some((normal_text, json_content)) - } else { - None - } - } - - /// Parse a single JSON object into a ToolCall (Llama format: name + parameters) - fn parse_single_object(&self, obj: &Value) -> ParserResult> { - // Llama format only: {"name": "function_name", "parameters": {...}} - let name = obj.get("name").and_then(|v| v.as_str()); - - if let Some(name) = name { - // Llama uses "parameters" key - let empty_obj = Value::Object(serde_json::Map::new()); - let parameters = obj.get("parameters").unwrap_or(&empty_obj); - - // Convert parameters to JSON string - let arguments = serde_json::to_string(parameters) - .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; - - Ok(Some(ToolCall { - function: FunctionCall { - name: name.to_string(), - arguments, - }, - })) - } else { - Ok(None) - } - } - - /// Parse semicolon-separated JSON objects - fn parse_semicolon_separated(&self, content: &str) -> ParserResult> { - let mut all_tools = Vec::new(); - - // Split by semicolon and parse each JSON object - for part in content.split(';') { - let trimmed = part.trim(); - if trimmed.is_empty() { - continue; - } - - // Try to parse this part as a single JSON object - match serde_json::from_str::(trimmed) { - Ok(value) => { - if let Some(tool) = self.parse_single_object(&value)? { - all_tools.push(tool); - } - } - Err(e) => { - // Skip invalid JSON parts in semicolon-separated list - tracing::debug!("Failed to parse tool call: {}", e); - } - } - } - - Ok(all_tools) - } -} - -impl Default for LlamaParser { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl ToolParser for LlamaParser { - async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec)> { - // Extract normal text and JSON content - let (normal_text, json_content) = - if let Some((normal, json)) = self.extract_content_after_python_tag(text) { - (normal, json) - } else if text.trim_start().starts_with('{') { - (String::new(), text.to_string()) - } else { - // No JSON structure found - return Ok((text.to_string(), vec![])); - }; - - // Parse the JSON content (may contain semicolon-separated objects) - let tools = if json_content.contains(';') { - self.parse_semicolon_separated(&json_content)? - } else { - // Try single JSON object - let parsed = serde_json::from_str::(json_content.trim()) - .map_err(|e| ParserError::ParsingFailed(e.to_string())) - .and_then(|v| { - self.parse_single_object(&v) - .map(|opt| opt.map_or_else(Vec::new, |tool| vec![tool])) - }); - - parsed.unwrap_or_else(|e| { - tracing::debug!("Failed to parse tool call: {:?}", e); - vec![] - }) - }; - - // If we couldn't parse any tools, return the original text - if tools.is_empty() { - return Ok((text.to_string(), vec![])); - } - - Ok((normal_text, tools)) - } - - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult { - // Append new text to buffer - self.buffer.push_str(chunk); - let current_text = &self.buffer.clone(); - - // Check if current_text has tool_call - let has_tool_start = self.has_tool_markers(current_text) - || (self.current_tool_id > 0 && current_text.starts_with(self.tool_call_separator)); - - if !has_tool_start { - // Only clear buffer if we're sure no tool call is starting - if helpers::ends_with_partial_token(&self.buffer, self.bot_token).is_none() { - let normal_text = self.buffer.clone(); - self.buffer.clear(); - - return Ok(StreamingParseResult { - normal_text, - calls: vec![], - }); - } else { - // Might be partial bot_token, keep buffering - return Ok(StreamingParseResult::default()); - } - } - - // Build tool indices - let tool_indices = helpers::get_tool_indices(tools); - - // Determine start index for JSON parsing - let start_idx = if let Some(pos) = current_text.find(self.bot_token) { - pos + self.bot_token.len() - } else if self.current_tool_id > 0 && current_text.starts_with(self.tool_call_separator) { - self.tool_call_separator.len() - } else { - 0 - }; - - helpers::handle_json_tool_streaming( - current_text, - start_idx, - &mut self.partial_json, - &tool_indices, - &mut self.buffer, - &mut self.current_tool_id, - &mut self.current_tool_name_sent, - &mut self.streamed_args_for_tool, - &mut self.prev_tool_call_arr, - ) - } - - fn has_tool_markers(&self, text: &str) -> bool { - // Llama format if contains python_tag or starts with JSON object - text.contains("<|python_tag|>") || text.trim_start().starts_with('{') - } - - fn get_unstreamed_tool_args(&self) -> Option> { - helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool) - } - - fn reset(&mut self) { - helpers::reset_parser_state( - &mut self.buffer, - &mut self.prev_tool_call_arr, - &mut self.current_tool_id, - &mut self.current_tool_name_sent, - &mut self.streamed_args_for_tool, - ); - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/minimax_m2.rs b/sgl-model-gateway/src/tool_parser/parsers/minimax_m2.rs deleted file mode 100644 index 269085c6b..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/minimax_m2.rs +++ /dev/null @@ -1,549 +0,0 @@ -use std::{collections::HashMap, fmt::Write as FmtWrite}; - -use async_trait::async_trait; -use regex::Regex; -use serde_json::Value; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::{ParserError, ParserResult}, - parsers::helpers, - traits::ToolParser, - types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem}, - }, -}; - -/// MiniMax M2 format parser for tool calls -/// -/// Handles the MiniMax M2 specific format: -/// `value` -/// -/// Features: -/// - Namespaced XML tags (`minimax:tool_call`) -/// - Function wrapped in `` tags -/// - Parameters as `value` -/// - Incremental JSON streaming for parameters -/// -/// Reference: https://huggingface.co/MiniMaxAI/MiniMax-M2?chat_template=default -pub struct MinimaxM2Parser { - // Regex patterns - tool_call_extractor: Regex, - invoke_extractor: Regex, - param_extractor: Regex, - - // Streaming state - buffer: String, - prev_tool_call_arr: Vec, - current_tool_id: i32, - streamed_args_for_tool: Vec, - current_function_name: String, - current_parameters: HashMap, - in_tool_call: bool, - function_name_sent: bool, - waiting_for_tool_call_end: bool, - - // Token configuration - tool_call_start_token: &'static str, - tool_call_end_token: &'static str, - invoke_end_token: &'static str, -} - -impl MinimaxM2Parser { - /// Parse a value from string with consistent logic - #[inline] - fn parse_value(text: &str) -> Value { - // Try parsing as common literals first - match text { - "true" | "True" => return Value::Bool(true), - "false" | "False" => return Value::Bool(false), - "null" | "None" => return Value::Null, - _ => {} - } - - // Try parsing as number - if let Ok(num) = text.parse::() { - return Value::Number(num.into()); - } - - if let Ok(num) = text.parse::() { - if let Some(n) = serde_json::Number::from_f64(num) { - return Value::Number(n); - } - } - - // Default to string - Value::String(text.to_string()) - } - - /// Create a new MiniMax M2 parser - pub fn new() -> Self { - // Use (?s) flag for DOTALL mode to handle newlines - let tool_call_pattern = r"(?s).*?"; - let tool_call_extractor = Regex::new(tool_call_pattern).expect("Valid regex pattern"); - - let invoke_pattern = r#"(?s)(.*?)"#; - let invoke_extractor = Regex::new(invoke_pattern).expect("Valid regex pattern"); - - let param_pattern = r#"(?s)(.*?)"#; - let param_extractor = Regex::new(param_pattern).expect("Valid regex pattern"); - - Self { - tool_call_extractor, - invoke_extractor, - param_extractor, - buffer: String::new(), - prev_tool_call_arr: Vec::new(), - current_tool_id: -1, - streamed_args_for_tool: Vec::new(), - current_function_name: String::new(), - current_parameters: HashMap::new(), - in_tool_call: false, - function_name_sent: false, - waiting_for_tool_call_end: false, - tool_call_start_token: "", - tool_call_end_token: "", - invoke_end_token: "", - } - } - - /// Parse parameters from parameter tags - fn parse_parameters(&self, params_text: &str) -> ParserResult> { - let mut parameters = serde_json::Map::new(); - - for capture in self.param_extractor.captures_iter(params_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()); - - // Decode XML entities and parse value - let decoded_value = self.decode_xml_entities(value_str); - - // Note: We keep JSON-like strings as strings (not parsed JSON) - // This matches the behavior of other parsers like GLM4 MOE - let value = Self::parse_value(&decoded_value); - - parameters.insert(key.to_string(), value); - } - - Ok(parameters) - } - - /// Decode common XML entities - fn decode_xml_entities(&self, text: &str) -> String { - text.replace("<", "<") - .replace(">", ">") - .replace("&", "&") - .replace(""", "\"") - .replace("'", "'") - } - - /// Parse a single tool call block - fn parse_tool_call(&self, block: &str) -> ParserResult> { - if let Some(captures) = self.invoke_extractor.captures(block) { - // Get function name from invoke tag attribute - let func_name = captures.get(1).map_or("", |m| m.as_str()).trim(); - - // Get parameters text - let params_text = captures.get(2).map_or("", |m| m.as_str()); - - // Parse parameters - let parameters = self.parse_parameters(params_text)?; - - let arguments_str = serde_json::to_string(¶meters) - .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; - - Ok(Some(ToolCall { - function: FunctionCall { - name: func_name.to_string(), - arguments: arguments_str, - }, - })) - } else { - Ok(None) - } - } - - /// Parse all tool calls from text and return first valid position - fn parse_tool_calls_from_text( - &self, - text: &str, - ) -> ParserResult<(Vec, Option)> { - let mut tools = Vec::new(); - let mut first_valid_pos = None; - - for mat in self.tool_call_extractor.find_iter(text) { - match self.parse_tool_call(mat.as_str()) { - Ok(Some(tool)) => { - if first_valid_pos.is_none() { - first_valid_pos = Some(mat.start()); - } - tools.push(tool); - } - Ok(None) => continue, - Err(e) => { - tracing::debug!("Failed to parse tool call: {}", e); - continue; - } - } - } - - Ok((tools, first_valid_pos)) - } - - /// Parse and stream parameters incrementally - fn parse_and_stream_parameters(&mut self, text: &str, _tools: &[Tool]) -> Vec { - let mut calls = Vec::new(); - - // Find all complete parameter patterns in the buffer - let param_matches: Vec<_> = self - .param_extractor - .captures_iter(text) - .map(|cap| { - let name = cap.get(1).map_or("", |m| m.as_str()).trim().to_string(); - let value_str = cap.get(2).map_or("", |m| m.as_str()); - let decoded = self.decode_xml_entities(value_str); - - // Try parsing as JSON first (for nested objects/arrays) - let value = if decoded.starts_with('{') || decoded.starts_with('[') { - if let Ok(json_val) = serde_json::from_str::(&decoded) { - json_val - } else { - Self::parse_value(&decoded) - } - } else { - Self::parse_value(&decoded) - }; - - (name, value) - }) - .collect(); - - // Build new parameters map - let mut new_params = HashMap::new(); - for (name, value) in param_matches { - new_params.insert(name, value); - } - - // If we have new parameters that weren't in current_parameters, stream them - if !new_params.is_empty() && new_params != self.current_parameters { - let tool_id = self.current_tool_id as usize; - - // Ensure we have enough capacity - while self.streamed_args_for_tool.len() <= tool_id { - self.streamed_args_for_tool.push(String::new()); - } - - // Build incremental JSON with single allocation - if self.current_parameters.is_empty() { - // First parameters - start JSON object but don't close it - let mut json_fragment = String::with_capacity(256); - json_fragment.push('{'); - - let mut first = true; - for (key, value) in &new_params { - if !first { - json_fragment.push_str(", "); - } - write!( - &mut json_fragment, - "{}: {}", - serde_json::to_string(key).unwrap(), - serde_json::to_string(value).unwrap() - ) - .unwrap(); - first = false; - } - - calls.push(ToolCallItem { - tool_index: tool_id, - name: None, - parameters: json_fragment.clone(), - }); - - self.streamed_args_for_tool[tool_id] = json_fragment; - } else { - // Additional parameters - add them incrementally - let new_keys: Vec<_> = new_params - .keys() - .filter(|k| !self.current_parameters.contains_key(*k)) - .collect(); - - if !new_keys.is_empty() { - let mut json_fragment = String::with_capacity(128); - - for key in new_keys { - let value = &new_params[key]; - write!( - &mut json_fragment, - ", {}: {}", - serde_json::to_string(key).unwrap(), - serde_json::to_string(value).unwrap() - ) - .unwrap(); - } - - calls.push(ToolCallItem { - tool_index: tool_id, - name: None, - parameters: json_fragment.clone(), - }); - - self.streamed_args_for_tool[tool_id].push_str(&json_fragment); - } - } - - // Update current parameters - self.current_parameters = new_params; - - // Update prev_tool_call_arr - while self.prev_tool_call_arr.len() <= tool_id { - self.prev_tool_call_arr.push(Value::Null); - } - self.prev_tool_call_arr[tool_id] = serde_json::json!({ - "name": self.current_function_name, - "arguments": self.current_parameters, - }); - } - - calls - } -} - -impl Default for MinimaxM2Parser { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl ToolParser for MinimaxM2Parser { - async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec)> { - // Check if text contains MiniMax M2 format - if !self.has_tool_markers(text) { - return Ok((text.to_string(), vec![])); - } - - // Parse all tool calls and get first valid position - let (tools, first_valid_tool_pos) = self.parse_tool_calls_from_text(text)?; - - // If no tools were successfully parsed, return entire text as fallback - if tools.is_empty() { - return Ok((text.to_string(), vec![])); - } - - // Determine what text to return as normal_text - let normal_text = if let Some(pos) = first_valid_tool_pos { - // Return text up to the first valid tool call - text[..pos].to_string() - } else { - // No valid tool calls found, return entire text - text.to_string() - }; - - Ok((normal_text, tools)) - } - - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult { - self.buffer.push_str(chunk); - let mut normal_text = String::new(); - let mut calls = Vec::new(); - - // Build tool indices for validation - let tool_indices = helpers::get_tool_indices(tools); - - loop { - // If we're waiting for the tool call end tag, check for it first - if self.waiting_for_tool_call_end { - if let Some(end_pos) = self.buffer.find(self.tool_call_end_token) { - // Complete tool call found - self.buffer = - self.buffer[end_pos + self.tool_call_end_token.len()..].to_string(); - self.in_tool_call = false; - self.waiting_for_tool_call_end = false; - self.function_name_sent = false; - self.current_function_name.clear(); - self.current_parameters.clear(); - self.current_tool_id += 1; - continue; - } else { - // End tag not complete yet, wait for more text - break; - } - } - - // If we're not in a tool call and don't see a start token, return normal text - if !self.in_tool_call && !self.buffer.contains(self.tool_call_start_token) { - // Check if buffer might contain a partial start token at the end - if let Some(partial_len) = - helpers::ends_with_partial_token(&self.buffer, self.tool_call_start_token) - { - // Return everything except the potential partial token - let end = self.buffer.len() - partial_len; - normal_text = self.buffer[..end].to_string(); - self.buffer = self.buffer[end..].to_string(); - } else { - // No partial token, return all as normal text - normal_text = self.buffer.clone(); - self.buffer.clear(); - } - break; - } - - // Look for tool call start - if !self.in_tool_call { - if let Some(start) = self.buffer.find(self.tool_call_start_token) { - normal_text = self.buffer[..start].to_string(); - self.buffer = - self.buffer[start + self.tool_call_start_token.len()..].to_string(); - - self.in_tool_call = true; - self.function_name_sent = false; - self.current_function_name.clear(); - self.current_parameters.clear(); - - continue; - } else { - // No start token found - break; - } - } - - // We're in a tool call, try to parse function name if not sent yet - if !self.function_name_sent { - // Use regex to extract function name from pattern - // Check if we have enough text to match the invoke pattern - if let Some(captures) = self.invoke_extractor.captures(&self.buffer) { - let function_name = captures - .get(1) - .map_or("", |m| m.as_str()) - .trim() - .to_string(); - - // Validate function name - if tool_indices.contains_key(&function_name) { - self.current_function_name = function_name.clone(); - self.function_name_sent = true; - - // Initialize tool call tracking - if self.current_tool_id == -1 { - self.current_tool_id = 0; - } - - // Ensure tracking arrays are large enough - helpers::ensure_capacity( - self.current_tool_id, - &mut self.prev_tool_call_arr, - &mut self.streamed_args_for_tool, - ); - - // Send tool name with empty parameters - calls.push(ToolCallItem { - tool_index: self.current_tool_id as usize, - name: Some(function_name), - parameters: String::new(), - }); - - // Find the position after the opening invoke tag (after the >) - // We only want to remove up to the opening tag, not the full match - if let Some(pos) = self.buffer.find('>') { - self.buffer = self.buffer[pos + 1..].to_string(); - } - continue; - } else { - // Invalid function name, reset state - tracing::debug!("Invalid function name: {}", function_name); - self.in_tool_call = false; - normal_text.push_str(&self.buffer); - self.buffer.clear(); - break; - } - } - // No complete invoke pattern found yet, wait for more text - break; - } - - // Parse parameters incrementally - if self.function_name_sent { - // Process parameters and get any calls to emit - // Note: We need to be careful here - parse_and_stream_parameters needs - // to work with the buffer but we can't pass &self.buffer directly - // due to borrow checker. Instead, we'll refactor slightly. - // For now, keep the clone but mark it as a TODO for future optimization - let buffer_copy = self.buffer.clone(); // TODO: Optimize this - let parameter_calls = self.parse_and_stream_parameters(&buffer_copy, tools); - calls.extend(parameter_calls); - - // Check if tool call is complete ( found) - if let Some(invoke_end) = self.buffer.find(self.invoke_end_token) { - // Add closing brace to complete the JSON object - let tool_id = self.current_tool_id as usize; - if tool_id < self.streamed_args_for_tool.len() { - let current_streamed = &self.streamed_args_for_tool[tool_id]; - if !current_streamed.is_empty() && !current_streamed.ends_with('}') { - // Count opening and closing braces to check if JSON is complete - let open_braces = current_streamed.matches('{').count(); - let close_braces = current_streamed.matches('}').count(); - if open_braces > close_braces { - calls.push(ToolCallItem { - tool_index: tool_id, - name: None, - parameters: "}".to_string(), - }); - self.streamed_args_for_tool[tool_id].push('}'); - } - } - } - - // Move buffer past the - self.buffer = - self.buffer[invoke_end + self.invoke_end_token.len()..].to_string(); - - // Check if we have the closing - if let Some(end_pos) = self.buffer.find(self.tool_call_end_token) { - // Complete tool call found - self.buffer = - self.buffer[end_pos + self.tool_call_end_token.len()..].to_string(); - self.in_tool_call = false; - self.function_name_sent = false; - self.current_function_name.clear(); - self.current_parameters.clear(); - self.current_tool_id += 1; - continue; - } else { - // End tag not complete yet, mark that we're waiting for it - self.waiting_for_tool_call_end = true; - break; - } - } - // Tool call not complete yet, wait for more text - break; - } - } - - Ok(StreamingParseResult { normal_text, calls }) - } - - fn has_tool_markers(&self, text: &str) -> bool { - text.contains(self.tool_call_start_token) - } - - fn get_unstreamed_tool_args(&self) -> Option> { - 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(); - self.current_function_name.clear(); - self.current_parameters.clear(); - self.in_tool_call = false; - self.function_name_sent = false; - self.waiting_for_tool_call_end = false; - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/mistral.rs b/sgl-model-gateway/src/tool_parser/parsers/mistral.rs deleted file mode 100644 index 2b36ce15b..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/mistral.rs +++ /dev/null @@ -1,287 +0,0 @@ -use async_trait::async_trait; -use serde_json::Value; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::{ParserError, ParserResult}, - parsers::helpers, - partial_json::PartialJson, - traits::ToolParser, - types::{FunctionCall, StreamingParseResult, ToolCall}, - }, -}; - -/// Mistral format parser for tool calls -/// -/// Handles the Mistral-specific format: -/// `[TOOL_CALLS] [{"name": "func", "arguments": {...}}, ...]` -/// -/// Reference: https://huggingface.co/mistralai/Mistral-7B-Instruct-v0.3?chat_template=default -pub struct MistralParser { - /// Parser for handling incomplete JSON during streaming - partial_json: PartialJson, - - /// Buffer for accumulating incomplete patterns across chunks - buffer: String, - - /// Stores complete tool call info (name and arguments) for each tool being parsed - prev_tool_call_arr: Vec, - - /// Index of currently streaming tool call (-1 means no active tool) - current_tool_id: i32, - - /// Flag for whether current tool's name has been sent to client - current_tool_name_sent: bool, - - /// Tracks raw JSON string content streamed to client for each tool's arguments - streamed_args_for_tool: Vec, - - /// Token configuration - bot_token: &'static str, - eot_token: &'static str, - tool_call_separator: &'static str, - - /// Track whether we've already stripped the closing ] bracket - array_closed: bool, -} - -impl MistralParser { - /// Create a new Mistral parser - pub fn new() -> Self { - Self { - partial_json: PartialJson::default(), - buffer: String::new(), - prev_tool_call_arr: Vec::new(), - current_tool_id: -1, - current_tool_name_sent: false, - streamed_args_for_tool: Vec::new(), - bot_token: "[TOOL_CALLS] [", - eot_token: "]", - tool_call_separator: ", ", - array_closed: false, - } - } - - fn extract_json_array_with_pos<'a>(&self, text: &'a str) -> Option<(usize, &'a str)> { - const BOT_TOKEN: &str = "[TOOL_CALLS] ["; - - // Find the start of the token - let start_idx = text.find(BOT_TOKEN)?; - - // Start from the opening bracket after [TOOL_CALLS] - // The -1 is to include the opening bracket that's part of the token - let json_start = start_idx + BOT_TOKEN.len() - 1; - - let mut bracket_count = 0; - let mut in_string = false; - let mut escape_next = false; - - let bytes = text.as_bytes(); - - for i in json_start..text.len() { - let char = bytes[i]; - - if escape_next { - escape_next = false; - continue; - } - - if char == b'\\' { - escape_next = true; - continue; - } - - if char == b'"' && !escape_next { - in_string = !in_string; - continue; - } - - if !in_string { - if char == b'[' { - bracket_count += 1; - } else if char == b']' { - bracket_count -= 1; - if bracket_count == 0 { - // Found the matching closing bracket - return Some((start_idx, &text[json_start..=i])); - } - } - } - } - - // Incomplete array (no matching closing bracket found) - None - } - - /// Parse tool calls from a JSON array - fn parse_json_array(&self, json_str: &str) -> ParserResult> { - let value: Value = serde_json::from_str(json_str) - .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; - - let mut tools = Vec::new(); - - if let Value::Array(arr) = value { - for item in arr.iter() { - if let Some(tool) = self.parse_single_object(item)? { - tools.push(tool); - } - } - } else { - // Single object case (shouldn't happen with Mistral format, but handle it) - if let Some(tool) = self.parse_single_object(&value)? { - tools.push(tool); - } - } - - Ok(tools) - } - - /// Parse a single JSON object into a ToolCall - fn parse_single_object(&self, obj: &Value) -> ParserResult> { - let name = obj.get("name").and_then(|v| v.as_str()); - - if let Some(name) = name { - // Get arguments - Mistral uses "arguments" key - let empty_obj = Value::Object(serde_json::Map::new()); - let args = obj.get("arguments").unwrap_or(&empty_obj); - - // Convert arguments to JSON string - let arguments = serde_json::to_string(args) - .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; - - Ok(Some(ToolCall { - function: FunctionCall { - name: name.to_string(), - arguments, - }, - })) - } else { - Ok(None) - } - } -} - -impl Default for MistralParser { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl ToolParser for MistralParser { - async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec)> { - // Check if text contains Mistral format - if !self.has_tool_markers(text) { - return Ok((text.to_string(), vec![])); - } - - // Extract JSON array from Mistral format with position - if let Some((start_idx, json_array)) = self.extract_json_array_with_pos(text) { - // Extract normal text before BOT_TOKEN - let normal_text_before = if start_idx > 0 { - text[..start_idx].to_string() - } else { - String::new() - }; - - match self.parse_json_array(json_array) { - Ok(tools) => Ok((normal_text_before, tools)), - Err(e) => { - // If JSON parsing fails, return the original text as normal text - tracing::debug!("Failed to parse tool call: {}", e); - Ok((text.to_string(), vec![])) - } - } - } else { - // Markers present but no complete array found - Ok((text.to_string(), vec![])) - } - } - - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult { - // Append new text to buffer - self.buffer.push_str(chunk); - let current_text = &self.buffer.clone(); - - // Check if current_text has tool_call - let has_tool_start = self.has_tool_markers(current_text) - || (self.current_tool_id > 0 && current_text.starts_with(self.tool_call_separator)); - - if !has_tool_start { - // Only clear buffer if we're sure no tool call is starting - if helpers::ends_with_partial_token(&self.buffer, self.bot_token).is_none() { - let mut normal_text = self.buffer.clone(); - self.buffer.clear(); - - // Strip ] only once (the closing bracket of [TOOL_CALLS] array) - // current_tool_id > 0 means we've parsed at least one tool - if !self.array_closed - && self.current_tool_id > 0 - && normal_text.starts_with(self.eot_token) - { - normal_text = normal_text - .strip_prefix(self.eot_token) - .unwrap() - .to_string(); - self.array_closed = true; - } - - return Ok(StreamingParseResult { - normal_text, - calls: vec![], - }); - } else { - // Might be partial bot_token, keep buffering - return Ok(StreamingParseResult::default()); - } - } - - // Build tool indices - let tool_indices = helpers::get_tool_indices(tools); - - // Determine start index for JSON parsing - let start_idx = if let Some(pos) = current_text.find(self.bot_token) { - pos + self.bot_token.len() - } else if self.current_tool_id > 0 && current_text.starts_with(self.tool_call_separator) { - self.tool_call_separator.len() - } else { - 0 - }; - - helpers::handle_json_tool_streaming( - current_text, - start_idx, - &mut self.partial_json, - &tool_indices, - &mut self.buffer, - &mut self.current_tool_id, - &mut self.current_tool_name_sent, - &mut self.streamed_args_for_tool, - &mut self.prev_tool_call_arr, - ) - } - - fn has_tool_markers(&self, text: &str) -> bool { - text.contains("[TOOL_CALLS]") - } - - fn get_unstreamed_tool_args(&self) -> Option> { - helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool) - } - - fn reset(&mut self) { - helpers::reset_parser_state( - &mut self.buffer, - &mut self.prev_tool_call_arr, - &mut self.current_tool_id, - &mut self.current_tool_name_sent, - &mut self.streamed_args_for_tool, - ); - self.array_closed = false; - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/mod.rs b/sgl-model-gateway/src/tool_parser/parsers/mod.rs deleted file mode 100644 index 42a20c231..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -/// Parser implementations for different model formats -/// -/// This module contains concrete parser implementations for various model-specific -/// tool/function call formats. -// Individual parser modules -pub mod deepseek; -pub mod glm4_moe; -pub mod json; -pub mod kimik2; -pub mod llama; -pub mod minimax_m2; -pub mod mistral; -pub mod passthrough; -pub mod pythonic; -pub mod qwen; -pub mod qwen_coder; -pub mod step3; - -// Shared helpers and utilities -pub mod helpers; - -// Re-export parser types for convenience -pub use deepseek::DeepSeekParser; -pub use glm4_moe::Glm4MoeParser; -pub use json::JsonParser; -pub use kimik2::KimiK2Parser; -pub use llama::LlamaParser; -pub use minimax_m2::MinimaxM2Parser; -pub use mistral::MistralParser; -pub(crate) use passthrough::PassthroughParser; -pub use pythonic::PythonicParser; -pub use qwen::QwenParser; -pub use qwen_coder::QwenCoderParser; -pub use step3::Step3Parser; diff --git a/sgl-model-gateway/src/tool_parser/parsers/passthrough.rs b/sgl-model-gateway/src/tool_parser/parsers/passthrough.rs deleted file mode 100644 index c6b438e9a..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/passthrough.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Passthrough parser that returns text unchanged -//! -//! This parser is used as a fallback for unknown models where no specific -//! tool call parsing should be performed. It simply returns the input text -//! with no tool calls detected. - -use async_trait::async_trait; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::ParserResult, - traits::ToolParser, - types::{StreamingParseResult, ToolCall, ToolCallItem}, - }, -}; - -/// Passthrough parser that returns text unchanged with no tool calls -#[derive(Default)] -pub(crate) struct PassthroughParser; - -impl PassthroughParser { - pub fn new() -> Self { - Self - } -} - -#[async_trait] -impl ToolParser for PassthroughParser { - async fn parse_complete(&self, output: &str) -> ParserResult<(String, Vec)> { - // Return text unchanged with no tool calls - Ok((output.to_string(), vec![])) - } - - async fn parse_incremental( - &mut self, - chunk: &str, - _tools: &[Tool], - ) -> ParserResult { - // Return chunk unchanged with no tool calls - Ok(StreamingParseResult { - normal_text: chunk.to_string(), - calls: vec![], - }) - } - - fn has_tool_markers(&self, _text: &str) -> bool { - // Passthrough never detects tool calls - false - } - - fn get_unstreamed_tool_args(&self) -> Option> { - None - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/pythonic.rs b/sgl-model-gateway/src/tool_parser/parsers/pythonic.rs deleted file mode 100644 index eb98dd30e..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/pythonic.rs +++ /dev/null @@ -1,414 +0,0 @@ -use std::sync::OnceLock; - -/// Pythonic format parser for tool calls -/// -/// Handles Python function call syntax within square brackets: -/// ```text -/// [tool1(arg1=val1, arg2=val2), tool2(arg1=val3)] -/// ``` -/// -/// This format is used by Llama models and uses Python literals -/// rather than JSON for arguments. -/// Reference: https://huggingface.co/meta-llama/Llama-4-Scout-17B-16E-Instruct?chat_template=default -use async_trait::async_trait; -use num_traits::ToPrimitive; -use regex::Regex; -use rustpython_parser::{ - ast::{Constant, Expr, Mod, UnaryOp}, - parse, Mode, -}; -use serde_json::{Map, Number, Value}; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::{ParserError, ParserResult}, - parsers::helpers, - traits::ToolParser, - types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem}, - }, -}; - -static PYTHONIC_BLOCK_REGEX: OnceLock = OnceLock::new(); - -/// Lazily compiled regex that locates pythonic tool call blocks. -fn pythonic_block_regex() -> &'static Regex { - PYTHONIC_BLOCK_REGEX.get_or_init(|| { - // Matches one or more function calls inside a list. The `(?s)` flag allows - // newlines inside argument lists while keeping the pattern anchored to - // identifiers followed by parentheses, preventing plain lists like - // `[1, 2, 3]` from matching. - Regex::new(r"(?s)\[\s*[A-Za-z_]\w*\s*\(.*?\)\s*(?:,\s*[A-Za-z_]\w*\s*\(.*?\)\s*)*\]") - .expect("pythonic tool call regex must compile") - }) -} - -/// Parser for Pythonic tool call format -pub struct PythonicParser { - /// Buffer for accumulating chunks - buffer: String, -} - -impl Default for PythonicParser { - fn default() -> Self { - Self::new() - } -} - -impl PythonicParser { - /// Create a new Pythonic parser - pub fn new() -> Self { - Self { - buffer: String::new(), - } - } - - /// Extract the first pythonic tool call block and return it along with the - /// surrounding "normal" content. - fn extract_tool_calls(&self, text: &str) -> Option<(String, String)> { - pythonic_block_regex().find(text).map(|mat| { - let block = mat.as_str().to_string(); - let normal = format!("{}{}", &text[..mat.start()], &text[mat.end()..]); - (block, normal) - }) - } - - /// Strip special tokens that Llama models might output - fn strip_special_tokens(text: &str) -> String { - text.replace("<|python_start|>", "") - .replace("<|python_end|>", "") - } - - fn parse_tool_call_block(&self, block: &str) -> ParserResult> { - let expr = parse_python_expression(block)?; - match expr { - Expr::List(list_expr) => list_expr - .elts - .into_iter() - .enumerate() - .map(|(idx, call_expr)| build_tool_call(call_expr, idx)) - .collect(), - _ => Err(ParserError::ParsingFailed( - "Expected a list of function calls in pythonic tool call".to_string(), - )), - } - } -} - -#[async_trait] -impl ToolParser for PythonicParser { - async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec)> { - let cleaned = Self::strip_special_tokens(text); - - if let Some((tool_calls_text, normal_text)) = self.extract_tool_calls(&cleaned) { - match self.parse_tool_call_block(&tool_calls_text) { - Ok(calls) => { - if calls.is_empty() { - // No tools successfully parsed despite having markers - Ok((text.to_string(), vec![])) - } else { - Ok((normal_text, calls)) - } - } - Err(e) => { - // Log and return entire text as fallback - tracing::debug!("Failed to parse pythonic tool calls: {}", e); - Ok((text.to_string(), vec![])) - } - } - } else { - Ok((text.to_string(), vec![])) - } - } - - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult { - self.buffer.push_str(chunk); - - let cleaned = Self::strip_special_tokens(&self.buffer); - - // Look for opening bracket - if let Some(start) = cleaned.find('[') { - let normal_text = if start > 0 { - cleaned[..start].to_string() - } else { - String::new() - }; - - // Look for matching closing bracket - if let Some(end) = find_matching_bracket(&cleaned, start) { - // Found complete tool call - extract it and parse using parse_complete - let call_text = &cleaned[start..=end]; - - match self.parse_complete(call_text).await { - Ok((_, calls)) => { - // Update buffer with remaining text after tool call - let remaining_text = &cleaned[end + 1..]; - self.buffer = remaining_text.to_string(); - - // Validate tool names and convert ToolCall to ToolCallItem - let tool_indices = helpers::get_tool_indices(tools); - let items: Vec = calls - .into_iter() - .enumerate() - .filter_map(|(idx, tool)| { - if !tool_indices.contains_key(&tool.function.name) { - tracing::debug!( - "Invalid tool name '{}' - skipping", - tool.function.name - ); - return None; - } - - Some(ToolCallItem { - tool_index: idx, - name: Some(tool.function.name), - parameters: tool.function.arguments, - }) - }) - .collect(); - - return Ok(StreamingParseResult { - normal_text, - calls: items, - }); - } - Err(e) => { - tracing::debug!("Failed to parse pythonic tool call: {}", e); - // Clear buffer on error - self.buffer.clear(); - return Ok(StreamingParseResult::default()); - } - } - } else { - // We have an opening bracket but no closing bracket yet - // Put back everything from the bracket onwards - self.buffer = cleaned[start..].to_string(); - - if !normal_text.is_empty() { - return Ok(StreamingParseResult { - normal_text, - calls: vec![], - }); - } - - // Still accumulating a potential tool call - return Ok(StreamingParseResult::default()); - } - } - - // No tool call bracket found - self.buffer.clear(); - Ok(StreamingParseResult { - normal_text: cleaned, - calls: vec![], - }) - } - - fn has_tool_markers(&self, text: &str) -> bool { - let cleaned = Self::strip_special_tokens(text); - if pythonic_block_regex().is_match(&cleaned) { - return true; - } - - false - } -} - -/// Find the matching closing bracket for the opening bracket at start position. -/// Properly handles nested brackets. -fn find_matching_bracket(buffer: &str, start: usize) -> Option { - let mut bracket_count = 0; - let chars: Vec = buffer.chars().collect(); - - for (i, &ch) in chars.iter().enumerate().skip(start) { - if ch == '[' { - bracket_count += 1; - } else if ch == ']' { - bracket_count -= 1; - if bracket_count == 0 { - return Some(i); - } - } - } - None // No matching bracket found -} - -fn parse_python_expression(source: &str) -> ParserResult { - let module = parse(source, Mode::Expression, "") - .map_err(|err| ParserError::ParsingFailed(err.to_string()))?; - - match module { - Mod::Expression(expr_mod) => Ok(*expr_mod.body), - _ => Err(ParserError::ParsingFailed( - "Expected a Python expression".to_string(), - )), - } -} - -fn build_tool_call(expr: Expr, _index: usize) -> ParserResult { - match expr { - Expr::Call(call_expr) => { - if !call_expr.args.is_empty() { - return Err(ParserError::ParsingFailed( - "Positional arguments are not supported in pythonic tool calls".to_string(), - )); - } - - let function_name = match *call_expr.func { - Expr::Name(name_expr) => name_expr.id.to_string(), - _ => { - return Err(ParserError::ParsingFailed( - "Unsupported function reference in pythonic tool call".to_string(), - )) - } - }; - - let mut arguments_map = Map::with_capacity(call_expr.keywords.len()); - for keyword in call_expr.keywords { - let arg_name = keyword.arg.ok_or_else(|| { - ParserError::ParsingFailed( - "pythonic tool calls do not support **kwargs".to_string(), - ) - })?; - let value_json = expression_to_json(&keyword.value)?; - arguments_map.insert(arg_name.to_string(), value_json); - } - - let arguments_json = Value::Object(arguments_map); - let arguments_string = serde_json::to_string(&arguments_json)?; - - Ok(ToolCall { - function: FunctionCall { - name: function_name, - arguments: arguments_string, - }, - }) - } - _ => Err(ParserError::ParsingFailed( - "Expected function calls inside pythonic tool call list".to_string(), - )), - } -} - -fn expression_to_json(expr: &Expr) -> ParserResult { - match expr { - Expr::Constant(expr_constant) => constant_to_json(&expr_constant.value), - Expr::List(list_expr) => collect_sequence(&list_expr.elts).map(Value::Array), - Expr::Tuple(tuple_expr) => collect_sequence(&tuple_expr.elts).map(Value::Array), - Expr::Dict(dict_expr) => { - collect_dict(&dict_expr.keys, &dict_expr.values).map(Value::Object) - } - Expr::UnaryOp(unary_expr) => match unary_expr.op { - UnaryOp::USub => match unary_expr.operand.as_ref() { - Expr::Constant(const_expr) => negate_constant(&const_expr.value), - _ => Err(ParserError::ParsingFailed( - "Unsupported unary operand in pythonic tool call".to_string(), - )), - }, - UnaryOp::UAdd => expression_to_json(unary_expr.operand.as_ref()), - _ => Err(ParserError::ParsingFailed(format!( - "Unsupported unary operator in pythonic tool call: {:?}", - unary_expr.op - ))), - }, - Expr::Name(name_expr) => Ok(Value::String(name_expr.id.to_string())), - _ => Err(ParserError::ParsingFailed(format!( - "Unsupported expression in pythonic tool call: {:?}", - expr - ))), - } -} - -fn constant_to_json(constant: &Constant) -> ParserResult { - match constant { - Constant::None => Ok(Value::Null), - Constant::Bool(b) => Ok(Value::Bool(*b)), - Constant::Int(value) => Ok(integer_constant_to_value(value, false)), - Constant::Float(f) => Number::from_f64(*f).map(Value::Number).ok_or_else(|| { - ParserError::ParsingFailed("Invalid float literal in pythonic tool call".to_string()) - }), - Constant::Str(s) => Ok(Value::String(s.clone())), - Constant::Bytes(bytes) => Ok(Value::String(String::from_utf8_lossy(bytes).into_owned())), - Constant::Tuple(values) => constant_tuple_to_array(values).map(Value::Array), - Constant::Ellipsis | Constant::Complex { .. } => Err(ParserError::ParsingFailed( - "Unsupported literal in pythonic tool call".to_string(), - )), - } -} - -fn negate_constant(constant: &Constant) -> ParserResult { - match constant { - Constant::Int(value) => Ok(integer_constant_to_value(value, true)), - Constant::Float(f) => Number::from_f64(-f).map(Value::Number).ok_or_else(|| { - ParserError::ParsingFailed("Invalid float literal in pythonic tool call".to_string()) - }), - _ => Err(ParserError::ParsingFailed( - "Unsupported unary operand in pythonic tool call".to_string(), - )), - } -} - -fn value_to_key_string(value: Value) -> ParserResult { - match value { - Value::String(s) => Ok(s), - Value::Number(num) => Ok(num.to_string()), - Value::Bool(b) => Ok(b.to_string()), - Value::Null => Ok("null".to_string()), - other => Err(ParserError::ParsingFailed(format!( - "Unsupported key type in pythonic tool call: {:?}", - other - ))), - } -} - -fn collect_sequence(elements: &[Expr]) -> ParserResult> { - elements.iter().map(expression_to_json).collect() -} - -fn collect_dict(keys: &[Option], values: &[Expr]) -> ParserResult> { - let mut map = Map::with_capacity(keys.len()); - for (key_expr, value_expr) in keys.iter().zip(values.iter()) { - let key_expr = key_expr.as_ref().ok_or_else(|| { - ParserError::ParsingFailed("pythonic tool calls do not support **kwargs".to_string()) - })?; - let key_value = expression_to_json(key_expr)?; - let key = value_to_key_string(key_value)?; - let value_json = expression_to_json(value_expr)?; - map.insert(key, value_json); - } - Ok(map) -} - -fn constant_tuple_to_array(values: &[Constant]) -> ParserResult> { - values.iter().map(constant_to_json).collect() -} - -fn integer_constant_to_value(value: &T, negate: bool) -> Value -where - T: ToPrimitive + std::fmt::Display, -{ - if let Some(mut i) = value.to_i64() { - if negate { - i = -i; - } - return Value::Number(Number::from(i)); - } - - if negate { - if let Some(u) = value.to_u64() { - if u <= i64::MAX as u64 { - return Value::Number(Number::from(-(u as i64))); - } - return Value::String(format!("-{}", value)); - } - Value::String(format!("-{}", value)) - } else if let Some(u) = value.to_u64() { - Value::Number(Number::from(u)) - } else { - Value::String(value.to_string()) - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/qwen.rs b/sgl-model-gateway/src/tool_parser/parsers/qwen.rs deleted file mode 100644 index 26b71ad14..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/qwen.rs +++ /dev/null @@ -1,258 +0,0 @@ -use async_trait::async_trait; -use regex::Regex; -use serde_json::Value; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::{ParserError, ParserResult}, - parsers::helpers, - partial_json::PartialJson, - traits::ToolParser, - types::{FunctionCall, StreamingParseResult, ToolCall}, - }, -}; - -/// Qwen format parser for tool calls -/// -/// Handles the Qwen 2.5/3 specific format: -/// `\n{"name": "func", "arguments": {...}}\n` -/// -/// Features: -/// - Tool Call Tags: `` and `` wrap each individual call -/// - Each individual call is separated by `\n` -/// - Function Call Object: JSON object with "name" and "arguments" fields -/// -/// Reference: https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct?chat_template=default -pub struct QwenParser { - /// Parser for handling incomplete JSON during streaming - partial_json: PartialJson, - - /// Regex for extracting tool calls in parse_complete - extractor: Regex, - - /// Buffer for accumulating incomplete patterns across chunks - buffer: String, - - /// Stores complete tool call info (name and arguments) for each tool being parsed - prev_tool_call_arr: Vec, - - /// Index of currently streaming tool call (-1 means no active tool) - current_tool_id: i32, - - /// Flag for whether current tool's name has been sent to client - current_tool_name_sent: bool, - - /// Tracks raw JSON string content streamed to client for each tool's arguments - streamed_args_for_tool: Vec, - - /// Buffer for normal text that might precede partial end tokens - normal_text_buffer: String, - - /// Token configuration - /// Start/end tokens for each individual tool call (not the entire sequence) - individual_tool_start_token: &'static str, - individual_tool_end_token: &'static str, - tool_call_separator: &'static str, -} - -impl QwenParser { - /// Create a new Qwen parser - pub fn new() -> Self { - // Use (?s) flag for DOTALL mode to handle newlines - let pattern = r"(?s)\n(.*?)\n"; - let extractor = Regex::new(pattern).expect("Valid regex pattern"); - - Self { - partial_json: PartialJson::default(), - extractor, - buffer: String::new(), - prev_tool_call_arr: Vec::new(), - current_tool_id: -1, - current_tool_name_sent: false, - streamed_args_for_tool: Vec::new(), - normal_text_buffer: String::new(), - individual_tool_start_token: "\n", - individual_tool_end_token: "\n", - tool_call_separator: "\n", - } - } - - /// Parse a single JSON object into a ToolCall - fn parse_single_object(&self, obj: &Value) -> ParserResult> { - let name = obj.get("name").and_then(|v| v.as_str()); - - if let Some(name) = name { - // Get arguments - Qwen uses "arguments" key - let empty_obj = Value::Object(serde_json::Map::new()); - let args = obj.get("arguments").unwrap_or(&empty_obj); - - // Convert arguments to JSON string - let arguments = serde_json::to_string(args) - .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; - - Ok(Some(ToolCall { - function: FunctionCall { - name: name.to_string(), - arguments, - }, - })) - } else { - Ok(None) - } - } -} - -impl Default for QwenParser { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl ToolParser for QwenParser { - async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec)> { - // Check if text contains Qwen format - if !self.has_tool_markers(text) { - return Ok((text.to_string(), vec![])); - } - - // Find where the first tool call begins - let idx = text.find("").unwrap(); // Safe because has_tool_markers checked - let normal_text = text[..idx].to_string(); - - // Extract tool calls - let mut tools = Vec::new(); - for captures in self.extractor.captures_iter(text) { - if let Some(json_str) = captures.get(1) { - let parsed = serde_json::from_str::(json_str.as_str().trim()) - .map_err(|e| ParserError::ParsingFailed(e.to_string())) - .and_then(|v| self.parse_single_object(&v)); - - match parsed { - Ok(Some(tool)) => tools.push(tool), - Ok(None) => continue, - Err(e) => { - tracing::debug!("Failed to parse tool call: {:?}", e); - continue; - } - } - } - } - - // If no tools were successfully parsed despite having markers, return entire text as fallback - if tools.is_empty() { - return Ok((text.to_string(), vec![])); - } - - Ok((normal_text, tools)) - } - - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult { - // Append new text to buffer - self.buffer.push_str(chunk); - let current_text = &self.buffer.clone(); - - // Check if current_text has tool_call - let has_tool_start = self.has_tool_markers(current_text) - || (self.current_tool_id > 0 && current_text.starts_with(self.tool_call_separator)); - - if !has_tool_start { - // Only clear buffer if we're sure no tool call is starting - if helpers::ends_with_partial_token(&self.buffer, self.individual_tool_start_token) - .is_none() - { - let normal_text = self.buffer.clone(); - self.buffer.clear(); - - return Ok(StreamingParseResult { - normal_text, - calls: vec![], - }); - } else { - // Might be partial individual_tool_start_token, keep buffering - return Ok(StreamingParseResult::default()); - } - } - - // Build tool indices - let tool_indices = helpers::get_tool_indices(tools); - - // Determine start index for JSON parsing - let start_idx = if let Some(pos) = current_text.find(self.individual_tool_start_token) { - pos + self.individual_tool_start_token.len() - } else if self.current_tool_id > 0 && current_text.starts_with(self.tool_call_separator) { - self.tool_call_separator.len() - } else { - 0 - }; - - let mut result = helpers::handle_json_tool_streaming( - current_text, - start_idx, - &mut self.partial_json, - &tool_indices, - &mut self.buffer, - &mut self.current_tool_id, - &mut self.current_tool_name_sent, - &mut self.streamed_args_for_tool, - &mut self.prev_tool_call_arr, - )?; - - // Qwen-specific: Handle partial end tokens in normal text - // After tool calls complete, normal text might contain partial "" tags - if !result.normal_text.is_empty() { - self.normal_text_buffer.push_str(&result.normal_text); - - // Check if buffer contains complete end token (without leading newline) - let end_token_without_newline = &self.individual_tool_end_token[1..]; // "" - if self.normal_text_buffer.contains(end_token_without_newline) { - // Complete end token found - clean it and return - let cleaned_text = self - .normal_text_buffer - .replace(end_token_without_newline, ""); - self.normal_text_buffer.clear(); - result.normal_text = cleaned_text; - } else { - // Check if buffer might contain partial end token at the end - if let Some(partial_match_len) = helpers::ends_with_partial_token( - &self.normal_text_buffer, - end_token_without_newline, - ) { - // Keep potential partial match in buffer, return the rest - let split_point = self.normal_text_buffer.len() - partial_match_len; - result.normal_text = self.normal_text_buffer[..split_point].to_string(); - self.normal_text_buffer = self.normal_text_buffer[split_point..].to_string(); - } else { - // No partial match, return all buffered text - result.normal_text = self.normal_text_buffer.clone(); - self.normal_text_buffer.clear(); - } - } - } - - Ok(result) - } - - fn has_tool_markers(&self, text: &str) -> bool { - text.contains("") - } - - fn get_unstreamed_tool_args(&self) -> Option> { - helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool) - } - - fn reset(&mut self) { - helpers::reset_parser_state( - &mut self.buffer, - &mut self.prev_tool_call_arr, - &mut self.current_tool_id, - &mut self.current_tool_name_sent, - &mut self.streamed_args_for_tool, - ); - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/qwen_coder.rs b/sgl-model-gateway/src/tool_parser/parsers/qwen_coder.rs deleted file mode 100644 index 00f246f60..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/qwen_coder.rs +++ /dev/null @@ -1,587 +0,0 @@ -use async_trait::async_trait; -use regex::Regex; -use serde_json::Value; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::{ParserError, ParserResult}, - parsers::helpers, - traits::ToolParser, - types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem}, - }, -}; - -/// Qwen Coder format parser for tool calls -/// -/// Handles the Qwen Coder specific XML format: -/// `\n\nvalue\n\n` -/// -/// Features: -/// - Tool Call Tags: `` and `` wrap each individual call -/// - XML-style function declaration: `` -/// - XML-style parameters: `value` -/// -/// Reference: https://huggingface.co/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8?chat_template=default -pub struct QwenCoderParser { - /// Regex for extracting tool calls in parse_complete - extractor: Regex, - - /// Buffer for accumulating incomplete patterns across chunks - buffer: String, - - /// Stores complete tool call info (name and arguments) for each tool being parsed - prev_tool_call_arr: Vec, - - /// Index of currently streaming tool call (-1 means no active tool) - current_tool_id: i32, - - /// Flag for whether current tool's name has been sent to client - current_tool_name_sent: bool, - - /// Tracks raw JSON string content streamed to client for each tool's arguments - streamed_args_for_tool: Vec, - - /// Token configuration - tool_call_start_token: &'static str, - tool_call_end_token: &'static str, - - /// XML format streaming state - in_tool_call: bool, - current_function_name: String, - current_parameters: serde_json::Map, - - /// Precompiled regex patterns for XML format parsing - xml_function_pattern: Regex, - xml_param_pattern: Regex, -} - -/// Decode HTML entities in a string (equivalent to Python's html.unescape) -/// -/// Handles common HTML entities like & < > " ' and numeric entities -fn html_unescape(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut chars = s.chars().peekable(); - - while let Some(c) = chars.next() { - if c == '&' { - let mut entity = String::new(); - let mut consumed_semicolon = false; - while let Some(&next) = chars.peek() { - if next == ';' { - chars.next(); - consumed_semicolon = true; - break; - } - if next.is_alphanumeric() || next == '#' { - entity.push(chars.next().unwrap()); - } else { - break; - } - } - - let decoded = match entity.as_str() { - "amp" => "&", - "lt" => "<", - "gt" => ">", - "quot" => "\"", - "apos" => "'", - "nbsp" => "\u{00A0}", - s if s.starts_with('#') => { - let num_str = &s[1..]; - let code_point = if num_str.starts_with('x') || num_str.starts_with('X') { - u32::from_str_radix(&num_str[1..], 16).ok() - } else { - num_str.parse::().ok() - }; - if let Some(cp) = code_point { - if let Some(ch) = char::from_u32(cp) { - result.push(ch); - continue; - } - } - // Invalid numeric entity, reconstruct original - result.push('&'); - result.push_str(&entity); - if consumed_semicolon { - result.push(';'); - } - continue; - } - _ => { - // Unknown entity, reconstruct original - result.push('&'); - result.push_str(&entity); - if consumed_semicolon { - result.push(';'); - } - continue; - } - }; - result.push_str(decoded); - } else { - result.push(c); - } - } - - result -} - -/// Parse a raw parameter value, similar to Python's _safe_val -/// -/// 1. Decode HTML entities -/// 2. Try to parse as JSON (numbers, booleans, null, objects, arrays) -/// 3. Fall back to string if JSON parsing fails -fn safe_val(raw: &str) -> Value { - let unescaped = html_unescape(raw.trim()); - - // Try JSON parsing first - if let Ok(v) = serde_json::from_str::(&unescaped) { - return v; - } - - // Handle Python-style literals (True, False, None) - match unescaped.as_str() { - "True" => return Value::Bool(true), - "False" => return Value::Bool(false), - "None" => return Value::Null, - _ => {} - } - - // Fall back to string - Value::String(unescaped) -} - -impl QwenCoderParser { - /// Create a new Qwen Coder parser - pub fn new() -> Self { - // Support XML format: \n\nvalue\n\n - let pattern = r"(?s)\s*(.*?)\s*"; - let extractor = Regex::new(pattern).expect("Valid regex pattern"); - - // Precompile XML format regex patterns for performance - let xml_function_pattern = - Regex::new(r"]+)>").expect("Valid XML function pattern"); - let xml_param_pattern = Regex::new(r"(?s)]+)>(.*?)") - .expect("Valid XML parameter pattern"); - - Self { - extractor, - buffer: String::new(), - prev_tool_call_arr: Vec::new(), - current_tool_id: -1, - current_tool_name_sent: false, - streamed_args_for_tool: Vec::new(), - tool_call_start_token: "", - tool_call_end_token: "", - in_tool_call: false, - current_function_name: String::new(), - current_parameters: serde_json::Map::new(), - xml_function_pattern, - xml_param_pattern, - } - } - - /// Parse XML format tool call: value - fn parse_xml_format(&self, content: &str) -> ParserResult> { - let function_captures = self - .xml_function_pattern - .captures(content) - .ok_or_else(|| ParserError::ParsingFailed("No function name found".to_string()))?; - - let function_name = function_captures - .get(1) - .ok_or_else(|| ParserError::ParsingFailed("Function name capture failed".to_string()))? - .as_str() - .trim() - .to_string(); - - if function_name.is_empty() { - return Ok(None); - } - - let mut parameters = serde_json::Map::new(); - - for cap in self.xml_param_pattern.captures_iter(content) { - if let (Some(key_match), Some(value_match)) = (cap.get(1), cap.get(2)) { - let key = key_match.as_str().trim().to_string(); - let value = value_match.as_str(); - let json_value = safe_val(value); - parameters.insert(key, json_value); - } - } - - let arguments = serde_json::to_string(¶meters) - .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; - - Ok(Some(ToolCall { - function: FunctionCall { - name: function_name, - arguments, - }, - })) - } - - /// Parse and stream complete parameters from buffer - /// Returns tool call items to emit (similar to Python's _parse_and_stream_parameters) - fn parse_and_stream_parameters(&mut self) -> ParserResult> { - let mut calls: Vec = vec![]; - - // Find all complete parameter patterns in buffer - let mut new_params = serde_json::Map::new(); - for cap in self.xml_param_pattern.captures_iter(&self.buffer) { - if let (Some(key_match), Some(value_match)) = (cap.get(1), cap.get(2)) { - let key = key_match.as_str().trim().to_string(); - let value = value_match.as_str(); - let json_value = safe_val(value); - new_params.insert(key, json_value); - } - } - - // Calculate parameter diff and stream updates - if new_params != self.current_parameters { - let current_args = &mut self.streamed_args_for_tool[self.current_tool_id as usize]; - - if self.current_parameters.is_empty() { - // First parameter(s) - build JSON fragment (without closing brace) - let mut items = Vec::new(); - for (key, value) in &new_params { - let key_json = - serde_json::to_string(key).unwrap_or_else(|_| format!("\"{}\"", key)); - let value_json = serde_json::to_string(value).unwrap_or_default(); - items.push(format!("{}: {}", key_json, value_json)); - } - let json_fragment = format!("{{{}", items.join(", ")); - - calls.push(ToolCallItem { - tool_index: self.current_tool_id as usize, - name: None, - parameters: json_fragment.clone(), - }); - *current_args = json_fragment; - } else { - // Additional parameters - add them incrementally - let new_keys: Vec<_> = new_params - .keys() - .filter(|k| !self.current_parameters.contains_key(*k)) - .collect(); - - if !new_keys.is_empty() { - let mut continuation_parts = Vec::new(); - for key in new_keys { - if let Some(value) = new_params.get(key) { - let key_json = serde_json::to_string(key) - .unwrap_or_else(|_| format!("\"{}\"", key)); - let value_json = serde_json::to_string(value).unwrap_or_default(); - continuation_parts.push(format!("{}: {}", key_json, value_json)); - } - } - - let json_fragment = format!(", {}", continuation_parts.join(", ")); - - calls.push(ToolCallItem { - tool_index: self.current_tool_id as usize, - name: None, - parameters: json_fragment.clone(), - }); - current_args.push_str(&json_fragment); - } - } - - // Update current state - self.current_parameters = new_params.clone(); - if let Some(tool_obj) = - self.prev_tool_call_arr[self.current_tool_id as usize].as_object_mut() - { - tool_obj.insert("arguments".to_string(), Value::Object(new_params)); - } - } - - Ok(calls) - } - - /// Reset streaming state for next tool call - fn reset_streaming_state(&mut self) { - self.in_tool_call = false; - self.current_tool_name_sent = false; - self.current_function_name.clear(); - self.current_parameters.clear(); - } -} - -impl Default for QwenCoderParser { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl ToolParser for QwenCoderParser { - async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec)> { - // Check if text contains Qwen Coder format - if !self.has_tool_markers(text) { - return Ok((text.to_string(), vec![])); - } - - // Find where the first tool call begins - let idx = text.find(self.tool_call_start_token).unwrap(); - let normal_text = text[..idx].to_string(); - - // Extract tool calls - let mut tools = Vec::new(); - for captures in self.extractor.captures_iter(text) { - if let Some(content_str) = captures.get(1) { - let content = content_str.as_str().trim(); - - match self.parse_xml_format(content) { - Ok(Some(tool)) => tools.push(tool), - Ok(None) => continue, - Err(e) => { - tracing::warn!("Failed to parse XML tool call: {:?}", e); - continue; - } - } - } - } - - // If no tools were successfully parsed despite having markers, return entire text - if tools.is_empty() { - return Ok((text.to_string(), vec![])); - } - - Ok((normal_text, tools)) - } - - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult { - self.buffer.push_str(chunk); - - let mut normal_text = String::new(); - let mut calls: Vec = vec![]; - - // Build tool indices for validation - let tool_indices = helpers::get_tool_indices(tools); - - loop { - // If we're not in a tool call and don't see a start token, return normal text - if !self.in_tool_call && !self.buffer.contains(self.tool_call_start_token) { - // Check for partial start token - if helpers::ends_with_partial_token(&self.buffer, self.tool_call_start_token) - .is_none() - { - normal_text.push_str(&self.buffer); - self.buffer.clear(); - } - break; - } - - // Look for tool call start - if !self.in_tool_call { - if let Some(s) = self.buffer.find(self.tool_call_start_token) { - normal_text.push_str(&self.buffer[..s]); - self.buffer = self.buffer[s + self.tool_call_start_token.len()..].to_string(); - self.in_tool_call = true; - self.current_tool_name_sent = false; - self.current_function_name.clear(); - self.current_parameters.clear(); - continue; - } else { - break; - } - } - - // We're in a tool call, try to parse function name if not sent yet - if !self.current_tool_name_sent { - if let Some(captures) = self.xml_function_pattern.captures(&self.buffer) { - if let Some(name_match) = captures.get(1) { - let function_name = name_match.as_str().trim().to_string(); - - // Validate function name - if tool_indices.contains_key(&function_name) { - self.current_function_name = function_name.clone(); - self.current_tool_name_sent = true; - - // Initialize tool call tracking - if self.current_tool_id == -1 { - self.current_tool_id = 0; - } - - // Ensure tracking arrays are large enough - helpers::ensure_capacity( - self.current_tool_id, - &mut self.prev_tool_call_arr, - &mut self.streamed_args_for_tool, - ); - - // Store tool call info - self.prev_tool_call_arr[self.current_tool_id as usize] = serde_json::json!({ - "name": function_name, - "arguments": {} - }); - - // Send tool name - calls.push(ToolCallItem { - tool_index: self.current_tool_id as usize, - name: Some(function_name), - parameters: String::new(), - }); - - // Remove processed function declaration from buffer - self.buffer = self.buffer[captures.get(0).unwrap().end()..].to_string(); - continue; - } else { - // Invalid function name, reset state - tracing::warn!("Invalid function name: {}", function_name); - self.reset_streaming_state(); - normal_text.push_str(&self.buffer); - self.buffer.clear(); - break; - } - } - } else { - // Function name not complete yet, wait for more text - break; - } - } - - // Parse parameters (only complete ones) - if self.current_tool_name_sent { - let param_calls = self.parse_and_stream_parameters()?; - calls.extend(param_calls); - - // Check if tool call is complete - if let Some(end_pos) = self.buffer.find(self.tool_call_end_token) { - // Close JSON object if we have parameters - let current_args = &self.streamed_args_for_tool[self.current_tool_id as usize]; - if !current_args.is_empty() { - // Count braces to check if JSON is complete - let open_braces = current_args.matches('{').count(); - let close_braces = current_args.matches('}').count(); - if open_braces > close_braces { - calls.push(ToolCallItem { - tool_index: self.current_tool_id as usize, - name: None, - parameters: "}".to_string(), - }); - self.streamed_args_for_tool[self.current_tool_id as usize].push('}'); - } - } - - // Complete the tool call - self.buffer = - self.buffer[end_pos + self.tool_call_end_token.len()..].to_string(); - self.reset_streaming_state(); - self.current_tool_id += 1; - continue; - } else { - // Tool call not complete yet, wait for more text - break; - } - } - - break; - } - - Ok(StreamingParseResult { normal_text, calls }) - } - - fn has_tool_markers(&self, text: &str) -> bool { - text.contains(self.tool_call_start_token) - } - - fn get_unstreamed_tool_args(&self) -> Option> { - helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool) - } - - fn reset(&mut self) { - helpers::reset_parser_state( - &mut self.buffer, - &mut self.prev_tool_call_arr, - &mut self.current_tool_id, - &mut self.current_tool_name_sent, - &mut self.streamed_args_for_tool, - ); - self.reset_streaming_state(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_html_unescape_basic() { - assert_eq!(html_unescape("&"), "&"); - assert_eq!(html_unescape("<"), "<"); - assert_eq!(html_unescape(">"), ">"); - assert_eq!(html_unescape("""), "\""); - assert_eq!(html_unescape("'"), "'"); - } - - #[test] - fn test_html_unescape_numeric() { - assert_eq!(html_unescape("<"), "<"); - assert_eq!(html_unescape("<"), "<"); - assert_eq!(html_unescape("<"), "<"); - } - - #[test] - fn test_html_unescape_mixed() { - assert_eq!( - html_unescape("Hello & World <tag>"), - "Hello & World " - ); - } - - #[test] - fn test_html_unescape_unknown() { - // Unknown entities with semicolon should be preserved as-is - assert_eq!(html_unescape("&unknown;"), "&unknown;"); - // Unterminated entities should NOT have semicolon added - assert_eq!(html_unescape("&foo bar"), "&foo bar"); - assert_eq!(html_unescape("&"), "&"); - assert_eq!(html_unescape("& "), "& "); - } - - #[test] - fn test_safe_val_json() { - assert_eq!(safe_val("42"), Value::Number(42.into())); - assert_eq!(safe_val("1.5"), serde_json::json!(1.5)); - assert_eq!(safe_val("true"), Value::Bool(true)); - assert_eq!(safe_val("false"), Value::Bool(false)); - assert_eq!(safe_val("null"), Value::Null); - assert_eq!( - safe_val(r#"{"key": "value"}"#), - serde_json::json!({"key": "value"}) - ); - assert_eq!(safe_val(r#"[1, 2, 3]"#), serde_json::json!([1, 2, 3])); - } - - #[test] - fn test_safe_val_python_literals() { - assert_eq!(safe_val("True"), Value::Bool(true)); - assert_eq!(safe_val("False"), Value::Bool(false)); - assert_eq!(safe_val("None"), Value::Null); - } - - #[test] - fn test_safe_val_string_fallback() { - assert_eq!( - safe_val("hello world"), - Value::String("hello world".to_string()) - ); - assert_eq!(safe_val(" spaces "), Value::String("spaces".to_string())); - } - - #[test] - fn test_safe_val_html_entities() { - assert_eq!(safe_val("<div>"), Value::String("
".to_string())); - assert_eq!( - safe_val("Tom & Jerry"), - Value::String("Tom & Jerry".to_string()) - ); - } -} diff --git a/sgl-model-gateway/src/tool_parser/parsers/step3.rs b/sgl-model-gateway/src/tool_parser/parsers/step3.rs deleted file mode 100644 index e4beb4574..000000000 --- a/sgl-model-gateway/src/tool_parser/parsers/step3.rs +++ /dev/null @@ -1,576 +0,0 @@ -use std::collections::HashMap; - -use async_trait::async_trait; -use regex::Regex; -use serde_json::Value; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::{ParserError, ParserResult}, - parsers::helpers, - traits::ToolParser, - types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem}, - }, -}; - -/// Step3 format parser for tool calls -/// -/// Handles the Step3 specific format with steptml XML: -/// `<|tool_calls_begin|><|tool_call_begin|>function<|tool_sep|>{v}<|tool_call_end|><|tool_calls_end|>` -/// -/// Features: -/// - Unicode token delimiters -/// - StepTML XML format for invocations -/// - Support for multiple sequential tool calls -pub struct Step3Parser { - /// Regex for extracting tool call blocks - tool_call_extractor: Regex, - /// Regex for extracting steptml invocations - invoke_extractor: Regex, - /// Regex for extracting parameters - param_extractor: Regex, - - /// Buffer for accumulating chunks - buffer: String, - - /// Token configuration - bot_token: &'static str, - eot_token: &'static str, - tool_call_begin: &'static str, - tool_call_end: &'static str, - tool_sep: &'static str, - - /// Streaming state variables (mirrors Python's Step3Detector) - in_tool_block: bool, - tool_block_finished: bool, - current_function_name: String, - current_parameters: serde_json::Map, - in_tool_call: bool, - function_name_sent: bool, - - /// Standard state machine fields - prev_tool_call_arr: Vec, - current_tool_id: i32, - streamed_args_for_tool: Vec, -} - -impl Step3Parser { - /// Create a new Step3 parser - pub fn new() -> Self { - // Pattern for individual tool calls - let tool_call_pattern = r"(?s)<|tool_call_begin|>.*?<|tool_call_end|>"; - let tool_call_extractor = Regex::new(tool_call_pattern).expect("Valid regex pattern"); - - // Pattern for steptml invocations - let invoke_pattern = r#"(?s)(.+?)"#; - let invoke_extractor = Regex::new(invoke_pattern).expect("Valid regex pattern"); - - // Pattern for steptml parameters - using non-greedy match for values to handle < characters - let param_pattern = r#"(?s)(.+?)"#; - let param_extractor = Regex::new(param_pattern).expect("Valid regex pattern"); - - Self { - tool_call_extractor, - invoke_extractor, - param_extractor, - - buffer: String::new(), - - bot_token: "<|tool_calls_begin|>", - eot_token: "<|tool_calls_end|>", - tool_call_begin: "<|tool_call_begin|>", - tool_call_end: "<|tool_call_end|>", - tool_sep: "<|tool_sep|>", - - // Streaming state variables - in_tool_block: false, - tool_block_finished: false, - current_function_name: String::new(), - current_parameters: serde_json::Map::new(), - in_tool_call: false, - function_name_sent: false, - - // Standard state machine fields - prev_tool_call_arr: Vec::new(), - current_tool_id: -1, - streamed_args_for_tool: Vec::new(), - } - } - - /// Reset streaming state for the next tool call - fn reset_streaming_state(&mut self) { - self.in_tool_call = false; - self.function_name_sent = false; - self.current_function_name.clear(); - self.current_parameters.clear(); - } - - /// Parse partial tool call for streaming scenarios (mirrors Python's _parse_partial_tool_call) - fn parse_partial_tool_call( - &mut self, - tool_indices: &HashMap, - ) -> ParserResult { - let mut calls = Vec::new(); - - // Check if we have tool_sep (means we're past the type declaration) - if !self.buffer.contains(self.tool_sep) { - return Ok(StreamingParseResult { - normal_text: String::new(), - calls, - }); - } - - // Clone the buffer to avoid borrow conflicts - let buffer_clone = self.buffer.clone(); - let parts: Vec<&str> = buffer_clone.splitn(2, self.tool_sep).collect(); - if parts.len() != 2 { - return Ok(StreamingParseResult { - normal_text: String::new(), - calls, - }); - } - - let type_part = parts[0].trim(); - let invoke_part = parts[1]; - - // Check if it's a function type - if type_part != "function" { - // Invalid tool type, skip this tool call - self.reset_streaming_state(); - return Ok(StreamingParseResult { - normal_text: String::new(), - calls, - }); - } - - // Try to extract function name if not sent yet - if !self.function_name_sent { - if let Some(captures) = self.invoke_extractor.captures(invoke_part) { - let func_name = captures.get(1).map_or("", |m| m.as_str()).trim(); - - // Validate function name - if tool_indices.contains_key(func_name) { - self.current_function_name = func_name.to_string(); - self.function_name_sent = true; - - // Initialize tool tracking - if self.current_tool_id == -1 { - self.current_tool_id = 0; - } - - // Ensure tracking arrays are large enough - helpers::ensure_capacity( - self.current_tool_id, - &mut self.prev_tool_call_arr, - &mut self.streamed_args_for_tool, - ); - - // Store tool call info - let tool_id = self.current_tool_id as usize; - self.prev_tool_call_arr[tool_id] = serde_json::json!({ - "name": func_name, - "arguments": {}, - }); - - // Send tool name with empty parameters - calls.push(ToolCallItem { - tool_index: self.current_tool_id as usize, - name: Some(func_name.to_string()), - parameters: String::new(), - }); - } else { - // Invalid function name - tracing::debug!("Invalid function name: {}", func_name); - self.reset_streaming_state(); - return Ok(StreamingParseResult { - normal_text: String::new(), - calls, - }); - } - } else { - // Function name not complete yet - return Ok(StreamingParseResult { - normal_text: String::new(), - calls, - }); - } - } - - // Parse parameters incrementally - if self.function_name_sent { - // Extract all complete parameters - let mut new_params = serde_json::Map::new(); - for capture in self.param_extractor.captures_iter(invoke_part) { - let param_name = capture.get(1).map_or("", |m| m.as_str()).trim(); - let param_value_str = capture.get(2).map_or("", |m| m.as_str()).trim(); - - // Try to parse the value as JSON first, fallback to string - let param_value = - if let Ok(json_val) = serde_json::from_str::(param_value_str) { - json_val - } else { - // Try parsing as Python literal - if param_value_str == "true" || param_value_str == "True" { - Value::Bool(true) - } else if param_value_str == "false" || param_value_str == "False" { - Value::Bool(false) - } else if param_value_str == "null" || param_value_str == "None" { - Value::Null - } else if let Ok(num) = param_value_str.parse::() { - Value::Number(num.into()) - } else if let Ok(num) = param_value_str.parse::() { - if let Some(n) = serde_json::Number::from_f64(num) { - Value::Number(n) - } else { - Value::String(param_value_str.to_string()) - } - } else { - Value::String(param_value_str.to_string()) - } - }; - - new_params.insert(param_name.to_string(), param_value); - } - - // Check if we have new parameters to stream - if new_params != self.current_parameters { - // Build the JSON content without the closing brace for streaming - let diff = if self.current_parameters.is_empty() { - // First parameters - send opening brace and content - let params_content = - serde_json::to_string(&new_params).unwrap_or_else(|_| "{}".to_string()); - if params_content.len() > 2 { - // Send everything except the closing brace - params_content[..params_content.len() - 1].to_string() - } else { - "{".to_string() - } - } else { - // Subsequent parameters - calculate the incremental diff - let old_json = serde_json::to_string(&self.current_parameters) - .unwrap_or_else(|_| "{}".to_string()); - let new_json = - serde_json::to_string(&new_params).unwrap_or_else(|_| "{}".to_string()); - - // Remove closing braces for comparison - let old_without_brace = &old_json[..old_json.len() - 1]; - let new_without_brace = &new_json[..new_json.len() - 1]; - - // The new content should extend the old content - new_without_brace - .strip_prefix(old_without_brace) - .map(|s| s.to_string()) - .unwrap_or_default() - }; - - if !diff.is_empty() { - calls.push(ToolCallItem { - tool_index: self.current_tool_id as usize, - name: None, - parameters: diff.clone(), - }); - let tool_id = self.current_tool_id as usize; - if tool_id < self.streamed_args_for_tool.len() { - self.streamed_args_for_tool[tool_id].push_str(&diff); - } - } - - // Update current state - self.current_parameters = new_params.clone(); - let tool_id = self.current_tool_id as usize; - if tool_id < self.prev_tool_call_arr.len() { - if let Some(obj) = self.prev_tool_call_arr[tool_id].as_object_mut() { - obj.insert("arguments".to_string(), Value::Object(new_params)); - } - } - } - - // Check if tool call is complete - if self.buffer.contains(self.tool_call_end) { - // Send closing brace if we've sent any parameters - let tool_id = self.current_tool_id as usize; - if tool_id < self.streamed_args_for_tool.len() - && !self.streamed_args_for_tool[tool_id].is_empty() - { - calls.push(ToolCallItem { - tool_index: self.current_tool_id as usize, - name: None, - parameters: "}".to_string(), - }); - self.streamed_args_for_tool[tool_id].push('}'); - } - - // Find the end position - if let Some(end_idx) = self.buffer.find(self.tool_call_end) { - // Remove the processed tool call from buffer - self.buffer = self.buffer[end_idx + self.tool_call_end.len()..].to_string(); - } - - // Reset state for next tool call - self.reset_streaming_state(); - self.current_tool_id += 1; - } - } - - Ok(StreamingParseResult { - normal_text: String::new(), - calls, - }) - } - - /// Parse parameters from steptml format - fn parse_steptml_parameters( - &self, - params_text: &str, - ) -> ParserResult> { - let mut parameters = serde_json::Map::new(); - - for capture in self.param_extractor.captures_iter(params_text) { - let param_name = capture.get(1).map_or("", |m| m.as_str()).trim(); - let param_value_str = capture.get(2).map_or("", |m| m.as_str()).trim(); - - // Try to parse the value as JSON first, fallback to string - let param_value = if let Ok(json_val) = serde_json::from_str::(param_value_str) { - json_val - } else { - // Try parsing as Python literal - if param_value_str == "true" || param_value_str == "True" { - Value::Bool(true) - } else if param_value_str == "false" || param_value_str == "False" { - Value::Bool(false) - } else if param_value_str == "null" || param_value_str == "None" { - Value::Null - } else if let Ok(num) = param_value_str.parse::() { - Value::Number(num.into()) - } else if let Ok(num) = param_value_str.parse::() { - if let Some(n) = serde_json::Number::from_f64(num) { - Value::Number(n) - } else { - Value::String(param_value_str.to_string()) - } - } else { - Value::String(param_value_str.to_string()) - } - }; - - parameters.insert(param_name.to_string(), param_value); - } - - Ok(parameters) - } - - /// Parse a single tool call block - fn parse_tool_call(&self, block: &str) -> ParserResult> { - // Check if it contains function marker and tool separator - if !block.contains("function") || !block.contains("<|tool_sep|>") { - return Ok(None); - } - - // Split by tool separator - let parts: Vec<&str> = block.split("<|tool_sep|>").collect(); - if parts.len() != 2 { - return Ok(None); - } - - // Check if it's a function type - if !parts[0].contains("function") { - return Ok(None); - } - - let invoke_part = parts[1]; - - // Extract steptml invoke - if let Some(captures) = self.invoke_extractor.captures(invoke_part) { - let func_name = captures.get(1).map_or("", |m| m.as_str()).trim(); - - // Validate function name is not empty - if func_name.is_empty() { - return Ok(None); - } - - let params_text = captures.get(2).map_or("", |m| m.as_str()); - - // Parse parameters - let parameters = self.parse_steptml_parameters(params_text)?; - - let arguments_str = serde_json::to_string(¶meters) - .map_err(|e| ParserError::ParsingFailed(e.to_string()))?; - - Ok(Some(ToolCall { - function: FunctionCall { - name: func_name.to_string(), - arguments: arguments_str, - }, - })) - } else { - Ok(None) - } - } -} - -impl Default for Step3Parser { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl ToolParser for Step3Parser { - async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec)> { - if !self.has_tool_markers(text) { - return Ok((text.to_string(), vec![])); - } - - // Find where tool calls begin - let idx = text.find("<|tool_calls_begin|>").unwrap(); - let normal_text = text[..idx].to_string(); - - // Extract tool calls - let mut tools = Vec::new(); - for mat in self.tool_call_extractor.find_iter(text) { - match self.parse_tool_call(mat.as_str()) { - Ok(Some(tool)) => tools.push(tool), - Ok(None) => continue, - Err(e) => { - tracing::debug!("Failed to parse tool call: {}", e); - continue; - } - } - } - - // If no tools were successfully parsed despite having markers, return entire text as fallback - if tools.is_empty() { - return Ok((text.to_string(), vec![])); - } - - Ok((normal_text, tools)) - } - - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult { - self.buffer.push_str(chunk); - - // Build tool indices for validation - let tool_indices = helpers::get_tool_indices(tools); - - // Stage 1: If we've finished the tool block, everything is normal text - if self.tool_block_finished { - let normal_text = std::mem::take(&mut self.buffer); - return Ok(StreamingParseResult { - normal_text, - calls: vec![], - }); - } - - // Stage 2: Check if tool block hasn't started yet - if !self.in_tool_block { - if self.buffer.contains(self.bot_token) { - let idx = self.buffer.find(self.bot_token).unwrap(); - let normal_text = self.buffer[..idx].to_string(); - self.buffer = self.buffer[idx + self.bot_token.len()..].to_string(); - self.in_tool_block = true; - return Ok(StreamingParseResult { - normal_text, - calls: vec![], - }); - } else { - // Check if we might have a partial bot_token - if helpers::ends_with_partial_token(&self.buffer, self.bot_token).is_some() { - return Ok(StreamingParseResult::default()); // Wait for more text - } else { - let normal_text = std::mem::take(&mut self.buffer); - return Ok(StreamingParseResult { - normal_text, - calls: vec![], - }); - } - } - } - - // We're inside the tool block - let mut calls = Vec::new(); - - // Stage 3: Check if tool block is ending - if self.buffer.contains(self.eot_token) { - let idx = self.buffer.find(self.eot_token).unwrap(); - - // If we're in the middle of a tool call, we need to handle it - if self.in_tool_call { - // The buffer before eot_token might contain the end of the current tool call - let before_eot = &self.buffer[..idx]; - if before_eot.contains(self.tool_call_end) { - // Parse this final tool call - let result = self.parse_partial_tool_call(&tool_indices)?; - calls.extend(result.calls); - } else { - // Incomplete tool call - log warning - tracing::warn!("Tool block ended with incomplete tool call"); - } - } - - let remaining = self.buffer[idx + self.eot_token.len()..].to_string(); - self.buffer.clear(); - self.tool_block_finished = true; - - // Reset any partial tool call state - self.reset_streaming_state(); - - return Ok(StreamingParseResult { - normal_text: remaining, - calls, - }); - } - - // Stage 4: Check if we're in a tool call or need to start one - if !self.in_tool_call { - if self.buffer.contains(self.tool_call_begin) { - let idx = self.buffer.find(self.tool_call_begin).unwrap(); - // Remove any content before tool call begin (shouldn't happen but be safe) - self.buffer = self.buffer[idx + self.tool_call_begin.len()..].to_string(); - self.in_tool_call = true; - self.function_name_sent = false; - self.current_function_name.clear(); - self.current_parameters.clear(); - // Fall through to parse the partial tool call - } else { - // Wait for tool call to begin - return Ok(StreamingParseResult::default()); - } - } - - // Stage 5: Parse partial tool call - if self.in_tool_call { - return self.parse_partial_tool_call(&tool_indices); - } - - Ok(StreamingParseResult::default()) - } - - fn has_tool_markers(&self, text: &str) -> bool { - text.contains(self.bot_token) - } - - fn get_unstreamed_tool_args(&self) -> Option> { - helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool) - } - - fn reset(&mut self) { - // Reset standard state - self.buffer.clear(); - self.prev_tool_call_arr.clear(); - self.current_tool_id = -1; - self.streamed_args_for_tool.clear(); - - // Reset Step3-specific fields - self.in_tool_block = false; - self.tool_block_finished = false; - self.current_function_name.clear(); - self.current_parameters.clear(); - self.in_tool_call = false; - self.function_name_sent = false; - } -} diff --git a/sgl-model-gateway/src/tool_parser/partial_json.rs b/sgl-model-gateway/src/tool_parser/partial_json.rs deleted file mode 100644 index ee1f90144..000000000 --- a/sgl-model-gateway/src/tool_parser/partial_json.rs +++ /dev/null @@ -1,533 +0,0 @@ -use serde_json::{Map, Value}; - -use crate::tool_parser::{ - errors::{ParserError, ParserResult}, - traits::PartialJsonParser, -}; - -/// Parser for incomplete JSON -pub struct PartialJson { - /// Maximum depth for nested structures - max_depth: usize, - /// Whether to allow incomplete values - allow_incomplete: bool, -} - -impl PartialJson { - /// Create a new partial JSON parser - pub fn new(max_depth: usize, allow_incomplete: bool) -> Self { - Self { - max_depth, - allow_incomplete, - } - } - - /// Parse potentially incomplete JSON, returning parsed value and consumed bytes - /// - /// # Arguments - /// * `input` - The JSON string to parse - /// * `allow_partial_strings` - When false, incomplete strings cause parsing to stop - /// (matches Python's Allow.ALL & ~Allow.STR behavior) - pub fn parse_value( - &self, - input: &str, - allow_partial_strings: bool, - ) -> ParserResult<(Value, usize)> { - let mut parser = Parser::new( - input, - self.max_depth, - self.allow_incomplete, - allow_partial_strings, - ); - let value = parser.parse_value(0)?; - Ok((value, parser.position)) - } -} - -impl Default for PartialJson { - fn default() -> Self { - Self::new(32, true) - } -} - -impl PartialJsonParser for PartialJson { - fn parse(&self, input: &str) -> ParserResult<(Value, usize)> { - // Default to allowing partial strings - self.parse_value(input, true) - } - - fn is_complete(&self, input: &str) -> bool { - // Try to parse as complete JSON - serde_json::from_str::(input).is_ok() - } - - fn max_depth(&self) -> usize { - self.max_depth - } -} - -/// Internal parser state -struct Parser<'a> { - chars: std::iter::Peekable>, - position: usize, - max_depth: usize, - allow_incomplete: bool, - allow_partial_strings: bool, -} - -impl<'a> Parser<'a> { - fn new( - input: &'a str, - max_depth: usize, - allow_incomplete: bool, - allow_partial_strings: bool, - ) -> Self { - Self { - chars: input.chars().peekable(), - position: 0, - max_depth, - allow_incomplete, - allow_partial_strings, - } - } - - fn peek(&mut self) -> Option { - self.chars.peek().copied() - } - - fn advance(&mut self) { - if self.chars.next().is_some() { - self.position += 1; - } - } - - fn skip_whitespace(&mut self) { - while let Some(ch) = self.peek() { - if ch.is_whitespace() { - self.advance(); - } else { - break; - } - } - } - - fn parse_value(&mut self, depth: usize) -> ParserResult { - if depth > self.max_depth { - return Err(ParserError::DepthExceeded(self.max_depth)); - } - - self.skip_whitespace(); - - match self.peek() { - Some('{') => self.parse_object(depth + 1), - Some('[') => self.parse_array(depth + 1), - Some('"') => self.parse_string(), - Some('t') | Some('f') => self.parse_bool(), - Some('n') => self.parse_null(), - Some(c) if c == '-' || c.is_ascii_digit() => self.parse_number(), - _ => { - if self.allow_incomplete { - Ok(Value::Null) - } else { - Err(ParserError::ParsingFailed("Unexpected character".into())) - } - } - } - } - - fn parse_object(&mut self, depth: usize) -> ParserResult { - if depth > self.max_depth { - return Err(ParserError::DepthExceeded(self.max_depth)); - } - - let mut object = Map::new(); - - // Consume '{' - self.advance(); - self.skip_whitespace(); - - // Check for empty object - if self.peek() == Some('}') { - self.advance(); - return Ok(Value::Object(object)); - } - - loop { - // Parse key - let key = match self.parse_string() { - Ok(Value::String(s)) => s, - Err(_) if self.allow_incomplete => { - // Incomplete object - return Ok(Value::Object(object)); - } - Err(e) => return Err(e), - _ => return Err(ParserError::ParsingFailed("Expected string key".into())), - }; - - self.skip_whitespace(); - - // Expect ':' - if self.peek() != Some(':') { - if self.allow_incomplete { - // Add null value for incomplete pair - object.insert(key, Value::Null); - return Ok(Value::Object(object)); - } - return Err(ParserError::ParsingFailed("Expected ':'".into())); - } - self.advance(); - self.skip_whitespace(); - - // Parse value (keep same depth - we already incremented in parse_object) - let value = match self.parse_value(depth) { - Ok(v) => v, - Err(_) if self.allow_incomplete => { - // When allow_partial_strings is false, don't add the key with Null - // Just return the object without this incomplete key-value pair - // This matches Python's behavior: Allow.ALL & ~Allow.STR - if self.allow_partial_strings { - // Add null for incomplete value - object.insert(key, Value::Null); - } - return Ok(Value::Object(object)); - } - Err(e) => return Err(e), - }; - - object.insert(key, value); - self.skip_whitespace(); - - match self.peek() { - Some(',') => { - self.advance(); - self.skip_whitespace(); - // Check for trailing comma - if self.peek() == Some('}') { - self.advance(); - return Ok(Value::Object(object)); - } - } - Some('}') => { - self.advance(); - return Ok(Value::Object(object)); - } - None if self.allow_incomplete => { - return Ok(Value::Object(object)); - } - _ => { - if self.allow_incomplete { - return Ok(Value::Object(object)); - } - return Err(ParserError::ParsingFailed("Expected ',' or '}'".into())); - } - } - } - } - - fn parse_array(&mut self, depth: usize) -> ParserResult { - if depth > self.max_depth { - return Err(ParserError::DepthExceeded(self.max_depth)); - } - - let mut array = Vec::new(); - - // Consume '[' - self.advance(); - self.skip_whitespace(); - - // Check for empty array - if self.peek() == Some(']') { - self.advance(); - return Ok(Value::Array(array)); - } - - loop { - // Parse value (keep same depth - we already incremented in parse_object) - let value = match self.parse_value(depth) { - Ok(v) => v, - Err(_) if self.allow_incomplete => { - return Ok(Value::Array(array)); - } - Err(e) => return Err(e), - }; - - array.push(value); - self.skip_whitespace(); - - match self.peek() { - Some(',') => { - self.advance(); - self.skip_whitespace(); - // Check for trailing comma - if self.peek() == Some(']') { - self.advance(); - return Ok(Value::Array(array)); - } - } - Some(']') => { - self.advance(); - return Ok(Value::Array(array)); - } - None if self.allow_incomplete => { - return Ok(Value::Array(array)); - } - _ => { - if self.allow_incomplete { - return Ok(Value::Array(array)); - } - return Err(ParserError::ParsingFailed("Expected ',' or ']'".into())); - } - } - } - } - - fn parse_string(&mut self) -> ParserResult { - if self.peek() != Some('"') { - return Err(ParserError::ParsingFailed("Expected '\"'".into())); - } - - // Consume opening quote - self.advance(); - - let mut string = String::new(); - let mut escaped = false; - - while let Some(ch) = self.peek() { - if escaped { - // Handle escape sequences - let escaped_char = match ch { - '"' | '\\' | '/' => ch, - 'b' => '\u{0008}', - 'f' => '\u{000C}', - 'n' => '\n', - 'r' => '\r', - 't' => '\t', - 'u' => { - // Unicode escape - self.advance(); - let hex = self.parse_unicode_escape()?; - string.push(hex); - escaped = false; - continue; - } - _ => ch, // Invalid escape, but be lenient - }; - string.push(escaped_char); - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if ch == '"' { - // End of string - self.advance(); - return Ok(Value::String(string)); - } else { - string.push(ch); - } - self.advance(); - } - - // Incomplete string - if self.allow_incomplete && self.allow_partial_strings { - Ok(Value::String(string)) - } else { - Err(ParserError::ParsingFailed("Unterminated string".into())) - } - } - - fn parse_unicode_escape(&mut self) -> ParserResult { - let mut hex = String::new(); - for _ in 0..4 { - if let Some(ch) = self.peek() { - if ch.is_ascii_hexdigit() { - hex.push(ch); - self.advance(); - } else { - break; - } - } else { - break; - } - } - - if hex.len() == 4 { - u32::from_str_radix(&hex, 16) - .ok() - .and_then(char::from_u32) - .ok_or_else(|| ParserError::ParsingFailed("Invalid unicode escape".into())) - } else if self.allow_incomplete { - Ok('\u{FFFD}') // Replacement character - } else { - Err(ParserError::ParsingFailed( - "Incomplete unicode escape".into(), - )) - } - } - - fn parse_number(&mut self) -> ParserResult { - let mut number = String::new(); - - // Handle negative sign - if self.peek() == Some('-') { - number.push('-'); - self.advance(); - } - - // Parse integer part - if self.peek() == Some('0') { - number.push('0'); - self.advance(); - } else { - while let Some(ch) = self.peek() { - if ch.is_ascii_digit() { - number.push(ch); - self.advance(); - } else { - break; - } - } - } - - // Parse decimal part - if self.peek() == Some('.') { - number.push('.'); - self.advance(); - - while let Some(ch) = self.peek() { - if ch.is_ascii_digit() { - number.push(ch); - self.advance(); - } else { - break; - } - } - } - - // Parse exponent - if let Some(ch) = self.peek() { - if ch == 'e' || ch == 'E' { - number.push(ch); - self.advance(); - - if let Some(sign) = self.peek() { - if sign == '+' || sign == '-' { - number.push(sign); - self.advance(); - } - } - - while let Some(ch) = self.peek() { - if ch.is_ascii_digit() { - number.push(ch); - self.advance(); - } else { - break; - } - } - } - } - - // Try to parse as integer first, then as float - if let Ok(n) = number.parse::() { - Ok(Value::Number(serde_json::Number::from(n))) - } else if let Ok(n) = number.parse::() { - Ok(Value::Number( - serde_json::Number::from_f64(n).unwrap_or_else(|| serde_json::Number::from(0)), - )) - } else if self.allow_incomplete { - Ok(Value::Number(serde_json::Number::from(0))) - } else { - Err(ParserError::ParsingFailed("Invalid number".into())) - } - } - - fn parse_bool(&mut self) -> ParserResult { - let mut word = String::new(); - - // Peek at upcoming characters to validate it looks like a boolean - let mut temp_chars = self.chars.clone(); - while let Some(&ch) = temp_chars.peek() { - if ch.is_alphabetic() && word.len() < 5 { - // "false" is 5 chars - word.push(ch); - temp_chars.next(); - } else { - break; - } - } - - // Check if it's a valid boolean prefix - let is_valid = word == "true" - || word == "false" - || (self.allow_incomplete && ("true".starts_with(&word) || "false".starts_with(&word))); - - if !is_valid { - return Err(ParserError::ParsingFailed("Invalid boolean".into())); - } - - // Now actually consume the characters - word.clear(); - while let Some(ch) = self.peek() { - if ch.is_alphabetic() { - word.push(ch); - self.advance(); - } else { - break; - } - } - - match word.as_str() { - "true" => Ok(Value::Bool(true)), - "false" => Ok(Value::Bool(false)), - partial if self.allow_incomplete => { - if "true".starts_with(partial) { - Ok(Value::Bool(true)) - } else if "false".starts_with(partial) { - Ok(Value::Bool(false)) - } else { - Err(ParserError::ParsingFailed("Invalid boolean".into())) - } - } - _ => Err(ParserError::ParsingFailed("Invalid boolean".into())), - } - } - - fn parse_null(&mut self) -> ParserResult { - let mut word = String::new(); - - // Peek at upcoming characters to validate it looks like "null" - let mut temp_chars = self.chars.clone(); - while let Some(&ch) = temp_chars.peek() { - if ch.is_alphabetic() && word.len() < 4 { - // "null" is 4 chars - word.push(ch); - temp_chars.next(); - } else { - break; - } - } - - // Check if it's a valid null prefix - let is_valid = word == "null" || (self.allow_incomplete && "null".starts_with(&word)); - - if !is_valid { - return Err(ParserError::ParsingFailed("Invalid null".into())); - } - - // Now actually consume the characters - word.clear(); - while let Some(ch) = self.peek() { - if ch.is_alphabetic() { - word.push(ch); - self.advance(); - } else { - break; - } - } - - if word == "null" || (self.allow_incomplete && "null".starts_with(&word)) { - Ok(Value::Null) - } else { - Err(ParserError::ParsingFailed("Invalid null".into())) - } - } -} diff --git a/sgl-model-gateway/src/tool_parser/tests.rs b/sgl-model-gateway/src/tool_parser/tests.rs deleted file mode 100644 index 5cfdb3746..000000000 --- a/sgl-model-gateway/src/tool_parser/tests.rs +++ /dev/null @@ -1,740 +0,0 @@ -use super::*; -use crate::{ - protocols::common::{Function, Tool}, - tool_parser::{ - parsers::{JsonParser, QwenCoderParser}, - partial_json::PartialJson, - traits::ToolParser, - }, -}; - -#[tokio::test] -async fn test_tool_parser_factory() { - let factory = ParserFactory::new(); - - // Test that we can get a pooled parser - let pooled_parser = factory.get_pooled("gpt-4"); - let parser = pooled_parser.lock().await; - assert!(parser.has_tool_markers(r#"{"name": "test", "arguments": {}}"#)); -} - -#[tokio::test] -async fn test_tool_parser_factory_model_mapping() { - let factory = ParserFactory::new(); - - // Test model mapping - factory.registry().map_model("test-model", "json"); - - // Get parser for the test model - let pooled_parser = factory.get_pooled("test-model"); - let parser = pooled_parser.lock().await; - assert!(parser.has_tool_markers(r#"{"name": "test", "arguments": {}}"#)); -} - -#[test] -fn test_tool_call_serialization() { - let tool_call = ToolCall { - function: FunctionCall { - name: "search".to_string(), - arguments: r#"{"query": "rust programming"}"#.to_string(), - }, - }; - - let json = serde_json::to_string(&tool_call).unwrap(); - assert!(json.contains("search")); - assert!(json.contains("rust programming")); - - let parsed: ToolCall = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.function.name, "search"); - assert_eq!( - parsed.function.arguments, - r#"{"query": "rust programming"}"# - ); -} - -#[test] -fn test_partial_json_parser() { - let parser = PartialJson::default(); - - let input = r#"{"name": "test", "value": 42}"#; - let (value, consumed) = parser.parse_value(input, true).unwrap(); - assert_eq!(value["name"], "test"); - assert_eq!(value["value"], 42); - assert_eq!(consumed, input.len()); - - let input = r#"{"name": "test", "value": "#; - let (value, _consumed) = parser.parse_value(input, true).unwrap(); - assert_eq!(value["name"], "test"); - assert!(value["value"].is_null()); - - let input = r#"{"name": "tes"#; - let (value, _consumed) = parser.parse_value(input, true).unwrap(); - assert_eq!(value["name"], "tes"); - - let input = r#"[1, 2, "#; - let (value, _consumed) = parser.parse_value(input, true).unwrap(); - assert!(value.is_array()); - assert_eq!(value[0], 1); - assert_eq!(value[1], 2); -} - -#[test] -fn test_partial_json_depth_limit() { - // max_depth of 3 allows nesting up to 3 levels - // Set allow_incomplete to false to get errors instead of partial results - let parser = PartialJson::new(3, false); - - // This should work (simple object) - let input = r#"{"a": 1}"#; - let result = parser.parse_value(input, true); - assert!(result.is_ok()); - - // This should work (nested to depth 3) - let input = r#"{"a": {"b": {"c": 1}}}"#; - let result = parser.parse_value(input, true); - assert!(result.is_ok()); - - // This should fail (nested to depth 4, exceeds limit) - let input = r#"{"a": {"b": {"c": {"d": 1}}}}"#; - let result = parser.parse_value(input, true); - assert!(result.is_err()); -} - -// NOTE: test_stream_result_variants removed - StreamResult enum replaced by StreamingParseResult - -#[test] -fn test_partial_tool_call() { - let mut partial = PartialToolCall { - name: None, - arguments_buffer: String::new(), - start_position: 0, - name_sent: false, - streamed_args: String::new(), - }; - - // Set name - partial.name = Some("test_function".to_string()); - assert_eq!(partial.name.as_ref().unwrap(), "test_function"); - - // Append arguments - partial.arguments_buffer.push_str(r#"{"key": "value"}"#); - assert_eq!(partial.arguments_buffer, r#"{"key": "value"}"#); - - // Update streaming state - partial.name_sent = true; - partial.streamed_args = r#"{"key": "#.to_string(); - assert!(partial.name_sent); - assert_eq!(partial.streamed_args, r#"{"key": "#); -} - -#[tokio::test] -async fn test_json_parser_complete_single() { - let parser = JsonParser::new(); - - let input = r#"{"name": "get_weather", "arguments": {"location": "San Francisco", "units": "celsius"}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_weather"); - assert!(tools[0].function.arguments.contains("San Francisco")); - assert!(tools[0].function.arguments.contains("celsius")); -} - -#[tokio::test] -async fn test_json_parser_complete_array() { - let parser = JsonParser::new(); - - let input = r#"[ - {"name": "get_weather", "arguments": {"location": "SF"}}, - {"name": "get_news", "arguments": {"query": "technology"}} - ]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].function.name, "get_weather"); - assert_eq!(tools[1].function.name, "get_news"); -} - -#[tokio::test] -async fn test_json_parser_with_parameters() { - let parser = JsonParser::new(); - - let input = r#"{"name": "calculate", "parameters": {"x": 10, "y": 20, "operation": "add"}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "calculate"); - assert!(tools[0].function.arguments.contains("10")); - assert!(tools[0].function.arguments.contains("20")); - assert!(tools[0].function.arguments.contains("add")); -} - -// Tests removed - TokenConfig no longer supported in JsonParser - -#[tokio::test] -async fn test_multiline_json_array() { - let parser = JsonParser::new(); - - let input = r#"[ - { - "name": "function1", - "arguments": { - "param1": "value1", - "param2": 42 - } - }, - { - "name": "function2", - "parameters": { - "data": [1, 2, 3], - "flag": false - } - } -]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].function.name, "function1"); - assert_eq!(tools[1].function.name, "function2"); - assert!(tools[0].function.arguments.contains("value1")); - assert!(tools[1].function.arguments.contains("[1,2,3]")); -} - -#[test] -fn test_json_parser_format_detection() { - let parser = JsonParser::new(); - - // Should detect valid tool call formats - assert!(parser.has_tool_markers(r#"{"name": "test", "arguments": {}}"#)); - assert!(parser.has_tool_markers(r#"{"name": "test", "parameters": {"x": 1}}"#)); - assert!(parser.has_tool_markers(r#"[{"name": "test"}]"#)); - - // Should not detect non-tool formats - assert!(!parser.has_tool_markers("plain text")); -} - -#[tokio::test] -async fn test_factory_with_json_parser() { - let factory = ParserFactory::new(); - - // Should get JSON parser for OpenAI models - let pooled_parser = factory.get_pooled("gpt-4-turbo"); - let parser = pooled_parser.lock().await; - - let input = r#"{"name": "test", "arguments": {"x": 1}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "test"); -} - -#[tokio::test] -async fn test_json_parser_invalid_input() { - let parser = JsonParser::new(); - - // Invalid JSON should return empty results - assert_eq!(parser.parse_complete("not json").await.unwrap().1.len(), 0); - assert_eq!(parser.parse_complete("{invalid}").await.unwrap().1.len(), 0); - assert_eq!(parser.parse_complete("").await.unwrap().1.len(), 0); -} - -#[tokio::test] -async fn test_json_parser_empty_arguments() { - let parser = JsonParser::new(); - - // Tool call with no arguments - let input = r#"{"name": "get_time"}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_time"); - assert_eq!(tools[0].function.arguments, "{}"); -} - -#[cfg(test)] -mod failure_cases { - use super::*; - - #[tokio::test] - async fn test_malformed_tool_missing_name() { - let parser = JsonParser::new(); - - // Missing name field - let input = r#"{"arguments": {"x": 1}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0, "Should return empty for tool without name"); - - // Empty name - let input = r#"{"name": "", "arguments": {"x": 1}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1, "Should accept empty name string"); - assert_eq!(tools[0].function.name, ""); - } - - #[tokio::test] - async fn test_invalid_arguments_json() { - let parser = JsonParser::new(); - - // Arguments is a string instead of object - let input = r#"{"name": "test", "arguments": "not an object"}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - // Should serialize the string as JSON - assert!(tools[0].function.arguments.contains("not an object")); - - // Arguments is a number - let input = r#"{"name": "test", "arguments": 42}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.arguments, "42"); - - // Arguments is null - let input = r#"{"name": "test", "arguments": null}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.arguments, "null"); - } - - // Test removed - wrapper token functionality moved to specific parsers - - #[tokio::test] - async fn test_invalid_json_structures() { - let parser = JsonParser::new(); - - // Trailing comma - let input = r#"{"name": "test", "arguments": {"x": 1,}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0, "Should reject JSON with trailing comma"); - - // Missing quotes on keys - let input = r#"{name: "test", arguments: {}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0, "Should reject invalid JSON syntax"); - - // Unclosed object - let input = r#"{"name": "test", "arguments": {"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0, "Should reject incomplete JSON"); - } -} - -#[cfg(test)] -mod edge_cases { - use super::*; - - #[tokio::test] - async fn test_unicode_in_names_and_arguments() { - let parser = JsonParser::new(); - - // Unicode in function name - let input = r#"{"name": "获取天气", "arguments": {"location": "北京"}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "获取天气"); - assert!(tools[0].function.arguments.contains("北京")); - - // Emoji in arguments - let input = r#"{"name": "send_message", "arguments": {"text": "Hello 👋 World 🌍"}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert!(tools[0].function.arguments.contains("👋")); - assert!(tools[0].function.arguments.contains("🌍")); - } - - #[tokio::test] - async fn test_escaped_characters() { - let parser = JsonParser::new(); - - // Escaped quotes in arguments - let input = r#"{"name": "echo", "arguments": {"text": "He said \"hello\""}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert!(tools[0].function.arguments.contains(r#"\"hello\""#)); - - // Escaped backslashes - let input = r#"{"name": "path", "arguments": {"dir": "C:\\Users\\test"}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert!(tools[0].function.arguments.contains("\\\\")); - - // Newlines and tabs - let input = r#"{"name": "format", "arguments": {"text": "line1\nline2\ttabbed"}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert!(tools[0].function.arguments.contains("\\n")); - assert!(tools[0].function.arguments.contains("\\t")); - } - - #[tokio::test] - async fn test_very_large_payloads() { - let parser = JsonParser::new(); - - // Large arguments object - let mut large_args = r#"{"name": "process", "arguments": {"#.to_string(); - for i in 0..1000 { - large_args.push_str(&format!(r#""field_{}": "value_{}","#, i, i)); - } - large_args.push_str(r#""final": "value"}}"#); - - let (_normal_text, tools) = parser.parse_complete(&large_args).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "process"); - assert!(tools[0].function.arguments.contains("field_999")); - - // Large array of tool calls - let mut large_array = "[".to_string(); - for i in 0..100 { - if i > 0 { - large_array.push(','); - } - large_array.push_str(&format!(r#"{{"name": "func_{}", "arguments": {{}}}}"#, i)); - } - large_array.push(']'); - - let (_normal_text, tools) = parser.parse_complete(&large_array).await.unwrap(); - assert_eq!(tools.len(), 100); - assert_eq!(tools[99].function.name, "func_99"); - } - - #[tokio::test] - async fn test_mixed_array_tools_and_non_tools() { - let parser = JsonParser::new(); - - // Array with both tool calls and non-tool objects - let input = r#"[ - {"name": "tool1", "arguments": {}}, - {"not_a_tool": "just_data"}, - {"name": "tool2", "parameters": {"x": 1}}, - {"key": "value", "another": "field"} - ]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2, "Should only parse valid tool calls"); - assert_eq!(tools[0].function.name, "tool1"); - assert_eq!(tools[1].function.name, "tool2"); - } - - #[tokio::test] - async fn test_duplicate_keys_in_json() { - let parser = JsonParser::new(); - - // JSON with duplicate keys (last one wins in most parsers) - let input = r#"{"name": "first", "name": "second", "arguments": {"x": 1, "x": 2}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!( - tools[0].function.name, "second", - "Last duplicate key should win" - ); - assert!( - tools[0].function.arguments.contains("2"), - "Last duplicate value should win" - ); - } - - #[tokio::test] - async fn test_null_values_in_arguments() { - let parser = JsonParser::new(); - - // Null values in arguments - let input = r#"{"name": "test", "arguments": {"required": "value", "optional": null}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert!(tools[0].function.arguments.contains("null")); - - // Array with null - let input = r#"{"name": "test", "arguments": {"items": [1, null, "three"]}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert!(tools[0].function.arguments.contains("null")); - } - - #[tokio::test] - async fn test_special_json_values() { - let parser = JsonParser::new(); - - // Boolean values - let input = r#"{"name": "toggle", "arguments": {"enabled": true, "disabled": false}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert!(tools[0].function.arguments.contains("true")); - assert!(tools[0].function.arguments.contains("false")); - - // Numbers (including float and negative) - let input = r#"{"name": "calc", "arguments": {"int": 42, "float": 3.14, "negative": -17}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert!(tools[0].function.arguments.contains("42")); - assert!(tools[0].function.arguments.contains("3.14")); - assert!(tools[0].function.arguments.contains("-17")); - - // Empty arrays and objects - let input = r#"{"name": "test", "arguments": {"empty_arr": [], "empty_obj": {}}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert!(tools[0].function.arguments.contains("[]")); - assert!(tools[0].function.arguments.contains("{}")); - } - - #[tokio::test] - async fn test_function_field_alternative() { - let parser = JsonParser::new(); - - // Using "function" instead of "name" - let input = r#"{"function": "test_func", "arguments": {"x": 1}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "test_func"); - - // Both "name" and "function" present (name should take precedence) - let input = r#"{"name": "primary", "function": "secondary", "arguments": {}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "primary"); - } - - #[tokio::test] - async fn test_whitespace_handling() { - let parser = JsonParser::new(); - - // Extra whitespace everywhere - let input = r#" { - "name" : "test" , - "arguments" : { - "key" : "value" - } - } "#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "test"); - - // Minified JSON (no whitespace) - let input = r#"{"name":"compact","arguments":{"a":1,"b":2}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "compact"); - } -} - -#[cfg(test)] -mod stress_tests { - use super::*; - - #[tokio::test] - async fn test_deeply_nested_arguments() { - let parser = JsonParser::new(); - - // Deeply nested structure - let input = r#"{ - "name": "nested", - "arguments": { - "level1": { - "level2": { - "level3": { - "level4": { - "level5": { - "value": "deep" - } - } - } - } - } - } - }"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert!(tools[0].function.arguments.contains("deep")); - } - - #[tokio::test] - async fn test_concurrent_parser_usage() { - let parser = std::sync::Arc::new(JsonParser::new()); - - let mut handles = vec![]; - - for i in 0..10 { - let parser_clone = parser.clone(); - let handle = tokio::spawn(async move { - let input = format!(r#"{{"name": "func_{}", "arguments": {{}}}}"#, i); - let (_normal_text, tools) = parser_clone.parse_complete(&input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, format!("func_{}", i)); - }); - handles.push(handle); - } - - for handle in handles { - handle.await.unwrap(); - } - } -} - -#[cfg(test)] -mod qwen_coder_tests { - use super::*; - - fn create_test_tools() -> Vec { - vec![Tool { - tool_type: "function".to_string(), - function: Function { - name: "get_weather".to_string(), - description: Some("Get weather information".to_string()), - parameters: serde_json::json!({ - "type": "object", - "properties": { - "city": {"type": "string"}, - "units": {"type": "string"} - } - }), - strict: None, - }, - }] - } - - #[tokio::test] - async fn test_qwen_coder_incremental_parameter_streaming() { - let mut parser = QwenCoderParser::new(); - let tools = create_test_tools(); - - let chunks = [ - "", - r#""#, - r#"Paris"#, - r#"metric"#, - "", - ]; - - let mut all_calls = Vec::new(); - - // Process each chunk - for (i, chunk) in chunks.iter().enumerate() { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - println!("Chunk {}: {:?}", i, chunk); - println!(" Calls: {:?}", result.calls); - println!(" Normal text: {:?}", result.normal_text); - - for call in &result.calls { - all_calls.push(call.clone()); - } - } - - // Verify the final result - // We should have: - // 1. Tool name call (from chunk 2) - // 2. First parameter call: {"city": "Paris"} - // 3. Second parameter call: , "units": "metric" - // Final result should be: {"city": "Paris", "units": "metric"} - - assert!(!all_calls.is_empty(), "Should have at least one call"); - - // Check that we have the tool name - let name_call = all_calls.iter().find(|c| c.name.is_some()); - assert!(name_call.is_some(), "Should have tool name call"); - assert_eq!(name_call.unwrap().name.as_ref().unwrap(), "get_weather"); - - // Check parameter calls - let param_calls: Vec<_> = all_calls.iter().filter(|c| c.name.is_none()).collect(); - assert!(!param_calls.is_empty(), "Should have parameter calls"); - - // Verify final arguments format by concatenating all parameter fragments - let params_str: String = param_calls.iter().map(|c| c.parameters.as_str()).collect(); - println!("Final streamed args: {}", params_str); - - // Should contain both city and units parameters - assert!(params_str.contains("city"), "Should contain city parameter"); - assert!(params_str.contains("Paris"), "Should contain Paris value"); - assert!( - params_str.contains("units"), - "Should contain units parameter" - ); - assert!(params_str.contains("metric"), "Should contain metric value"); - } - - #[tokio::test] - async fn test_qwen_coder_incremental_parameter_streaming_with_partial_values() { - let mut parser = QwenCoderParser::new(); - let tools = create_test_tools(); - - // Test with parameter values that arrive in multiple chunks - // This tests the buffering logic for partial XML tags - let chunks = [ - "", - r#"Paris"#, - r#"metric"#, - "", - ]; - - let mut all_calls = Vec::new(); - - // Process each chunk - for (i, chunk) in chunks.iter().enumerate() { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - println!("Chunk {}: {:?}", i, chunk); - println!(" Calls: {:?}", result.calls); - - for call in &result.calls { - all_calls.push(call.clone()); - } - } - - // Verify we got the tool name - let name_call = all_calls.iter().find(|c| c.name.is_some()); - assert!(name_call.is_some(), "Should have tool name call"); - assert_eq!(name_call.unwrap().name.as_ref().unwrap(), "get_weather"); - - // Verify we got parameter calls - let param_calls: Vec<_> = all_calls.iter().filter(|c| c.name.is_none()).collect(); - assert!(!param_calls.is_empty(), "Should have parameter calls"); - - // Verify final arguments by concatenating all parameter fragments - let params_str: String = param_calls.iter().map(|c| c.parameters.as_str()).collect(); - assert!(params_str.contains("city"), "Should contain city parameter"); - assert!(params_str.contains("Paris"), "Should contain Paris value"); - } - - #[tokio::test] - async fn test_qwen_coder_nested_json_parameter() { - let mut parser = QwenCoderParser::new(); - let tools = vec![Tool { - tool_type: "function".to_string(), - function: Function { - name: "test_function".to_string(), - description: None, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "nested": {"type": "object"} - } - }), - strict: None, - }, - }]; - - let chunks = vec![ - "", - r#""#, - r#"{"key": "value"}"#, - "", - ]; - - let mut all_calls = Vec::new(); - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - for call in result.calls { - all_calls.push(call); - } - } - - // Verify nested JSON is parsed correctly via collected calls - let param_calls: Vec<_> = all_calls.iter().filter(|c| c.name.is_none()).collect(); - assert!(!param_calls.is_empty(), "Should have parameter calls"); - - // The first parameter call should contain the nested JSON - let params_str: String = param_calls.iter().map(|c| c.parameters.as_str()).collect(); - assert!( - params_str.contains("nested"), - "Should contain nested parameter" - ); - } -} diff --git a/sgl-model-gateway/src/tool_parser/traits.rs b/sgl-model-gateway/src/tool_parser/traits.rs deleted file mode 100644 index 51421f20f..000000000 --- a/sgl-model-gateway/src/tool_parser/traits.rs +++ /dev/null @@ -1,76 +0,0 @@ -use async_trait::async_trait; - -use crate::{ - protocols::common::Tool, - tool_parser::{ - errors::ParserResult, - types::{StreamingParseResult, ToolCall}, - }, -}; - -/// Core trait for all tool parsers -#[async_trait] -pub trait ToolParser: Send + Sync { - /// Parse complete tool calls from final output - /// Returns (remaining_normal_text, tool_calls) tuple - async fn parse_complete(&self, output: &str) -> ParserResult<(String, Vec)>; - - /// Parse tool calls from model output (streaming) - /// Parsers now maintain internal state, so self is mutable - /// - /// # Arguments - /// * `chunk` - New text chunk from model output - /// * `tools` - List of available tools for validation - async fn parse_incremental( - &mut self, - chunk: &str, - tools: &[Tool], - ) -> ParserResult; - - /// Check if text contains tool calls in this parser's format - fn has_tool_markers(&self, text: &str) -> bool; - - /// Optionally expose a token-aware parser implementation. - /// Default returns `None`, meaning the parser only supports text input. - fn as_token_parser(&self) -> Option<&dyn TokenToolParser> { - None - } - - /// Get unstreamed tool call arguments - /// Returns tool call items for arguments that have been parsed but not yet streamed - fn get_unstreamed_tool_args(&self) -> Option> { - None - } - - /// Reset the parser state for reuse across requests. - /// This should clear all buffers and reset state to initial values. - fn reset(&mut self) { - // Default no-op implementation - } -} - -/// Trait for partial JSON parsing -pub trait PartialJsonParser: Send + Sync { - /// Parse potentially incomplete JSON - fn parse(&self, input: &str) -> ParserResult<(serde_json::Value, usize)>; - - /// Check if JSON is complete - fn is_complete(&self, input: &str) -> bool; - - /// Get the maximum parsing depth - fn max_depth(&self) -> usize; -} - -#[async_trait] -pub trait TokenToolParser: ToolParser { - /// Parse complete tool calls when provided with raw token IDs. - async fn parse_complete_tokens(&self, tokens: &[u32]) -> ParserResult<(String, Vec)>; - - /// Streaming parser entrypoint for token chunks. - /// Parsers maintain internal state, so self is mutable - async fn parse_incremental_tokens( - &mut self, - tokens: &[u32], - tools: &[Tool], - ) -> ParserResult; -} diff --git a/sgl-model-gateway/src/tool_parser/types.rs b/sgl-model-gateway/src/tool_parser/types.rs deleted file mode 100644 index e716da88a..000000000 --- a/sgl-model-gateway/src/tool_parser/types.rs +++ /dev/null @@ -1,52 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Parsed tool call from model output -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ToolCall { - /// Function call details - pub function: FunctionCall, -} - -/// Function call within a tool call -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct FunctionCall { - /// Name of the function to call - pub name: String, - /// Arguments as JSON string - pub arguments: String, -} - -/// Simple partial tool call for streaming -#[derive(Debug, Clone)] -pub struct PartialToolCall { - /// Tool name (if parsed) - pub name: Option, - /// Buffer for accumulating arguments - pub arguments_buffer: String, - /// Start position in the input buffer - pub start_position: usize, - /// Whether the name has been sent (for streaming) - pub name_sent: bool, - /// Arguments already streamed - pub streamed_args: String, -} - -/// Result of streaming parse operation (matches Python StreamingParseResult) -#[derive(Debug, Clone, Default)] -pub struct StreamingParseResult { - /// Normal text that's not part of tool calls - pub normal_text: String, - /// Tool call items parsed from the chunk - pub calls: Vec, -} - -/// Simple encapsulation of parsed tool call for streaming (matches Python ToolCallItem) -#[derive(Debug, Clone)] -pub struct ToolCallItem { - /// Tool index in the array - pub tool_index: usize, - /// Tool name (only present on first chunk) - pub name: Option, - /// Incremental JSON arguments - pub parameters: String, -} diff --git a/sgl-model-gateway/tests/tool_parser/mod.rs b/sgl-model-gateway/tests/tool_parser/mod.rs deleted file mode 100644 index b8187f3df..000000000 --- a/sgl-model-gateway/tests/tool_parser/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Tool parser integration tests - -pub mod tool_parser_deepseek; -pub mod tool_parser_edge_cases; -pub mod tool_parser_fallback; -pub mod tool_parser_glm47_moe; -pub mod tool_parser_glm4_moe; -pub mod tool_parser_json; -pub mod tool_parser_kimik2; -pub mod tool_parser_llama; -pub mod tool_parser_minimax_m2; -pub mod tool_parser_mistral; -pub mod tool_parser_mixed_edge_cases; -pub mod tool_parser_partial_json; -pub mod tool_parser_pythonic; -pub mod tool_parser_qwen; -pub mod tool_parser_qwen_coder; -pub mod tool_parser_step3; diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_deepseek.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_deepseek.rs deleted file mode 100644 index facdb1639..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_deepseek.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! DeepSeek V3 Parser Integration Tests - -use smg::tool_parser::{DeepSeekParser, ToolParser}; - -use crate::common::create_test_tools; - -#[tokio::test] -async fn test_deepseek_complete_parsing() { - let parser = DeepSeekParser::new(); - - let input = r#"Let me help you with that. -<|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>get_weather -```json -{"location": "Tokyo", "units": "celsius"} -```<|tool▁call▁end|><|tool▁calls▁end|> -The weather in Tokyo is..."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, "Let me help you with that.\n"); - assert_eq!(tools[0].function.name, "get_weather"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["location"], "Tokyo"); - assert_eq!(args["units"], "celsius"); -} - -#[tokio::test] -async fn test_deepseek_multiple_tools() { - let parser = DeepSeekParser::new(); - - let input = r#"<|tool▁calls▁begin|> -<|tool▁call▁begin|>function<|tool▁sep|>search -```json -{"query": "rust programming"} -```<|tool▁call▁end|> -<|tool▁call▁begin|>function<|tool▁sep|>translate -```json -{"text": "Hello World", "to": "ja"} -```<|tool▁call▁end|> -<|tool▁calls▁end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].function.name, "search"); - assert_eq!(tools[1].function.name, "translate"); -} - -#[tokio::test] -async fn test_deepseek_streaming() { - let tools = create_test_tools(); - - let mut parser = DeepSeekParser::new(); - - // Simulate streaming chunks - let chunks = vec![ - "<|tool▁calls▁begin|><|tool▁call▁begin|>", - "function<|tool▁sep|>get_weather\n", - "```json\n", - r#"{"location": "#, - r#""Beijing", "#, - r#""units": "metric"}"#, - "\n```<|tool▁call▁end|><|tool▁calls▁end|>", - ]; - - let mut found_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - found_name = true; - } - } - } - - assert!(found_name, "Should have found tool name during streaming"); -} - -#[tokio::test] -async fn test_deepseek_nested_json() { - let parser = DeepSeekParser::new(); - - let input = r#"<|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>process -```json -{ - "data": { - "nested": { - "deep": [1, 2, 3] - } - } -} -```<|tool▁call▁end|><|tool▁calls▁end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "process"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert!(args["data"]["nested"]["deep"].is_array()); -} - -#[test] -fn test_deepseek_format_detection() { - let parser = DeepSeekParser::new(); - - // Should detect DeepSeek format - assert!(parser.has_tool_markers("<|tool▁calls▁begin|>")); - assert!(parser.has_tool_markers("text with <|tool▁calls▁begin|> marker")); - - // Should not detect other formats - assert!(!parser.has_tool_markers("[TOOL_CALLS]")); - assert!(!parser.has_tool_markers("")); - assert!(!parser.has_tool_markers("plain text")); -} - -#[tokio::test] -async fn test_deepseek_malformed_json_handling() { - let parser = DeepSeekParser::new(); - - // Malformed JSON should be skipped - let input = r#"<|tool▁calls▁begin|> -<|tool▁call▁begin|>function<|tool▁sep|>broken -```json -{invalid json} -```<|tool▁call▁end|> -<|tool▁call▁begin|>function<|tool▁sep|>valid -```json -{"key": "value"} -```<|tool▁call▁end|> -<|tool▁calls▁end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - // Only the valid tool call should be parsed - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "valid"); -} - -#[tokio::test] -async fn test_multiple_tool_calls() { - let parser = DeepSeekParser::new(); - - let input = r#"<|tool▁calls▁begin|> -<|tool▁call▁begin|>function<|tool▁sep|>get_weather -```json -{"location": "Tokyo"} -```<|tool▁call▁end|> -<|tool▁call▁begin|>function<|tool▁sep|>get_weather -```json -{"location": "Paris"} -```<|tool▁call▁end|> -<|tool▁calls▁end|><|end▁of▁sentence|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].function.name, "get_weather"); - assert_eq!(tools[1].function.name, "get_weather"); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_edge_cases.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_edge_cases.rs deleted file mode 100644 index 5e16a9f2a..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_edge_cases.rs +++ /dev/null @@ -1,348 +0,0 @@ -//! Edge Cases and Error Handling Tests -//! -//! Tests for malformed input, edge cases, and error recovery - -use smg::tool_parser::{JsonParser, MistralParser, PythonicParser, QwenParser, ToolParser}; - -use crate::common::create_test_tools; - -#[tokio::test] -async fn test_empty_input() { - // Test that all parsers handle empty input correctly - let json_parser = JsonParser::new(); - let (_normal_text, tools) = json_parser.parse_complete("").await.unwrap(); - assert_eq!( - tools.len(), - 0, - "JSON parser should return empty for empty input" - ); - - let mistral_parser = MistralParser::new(); - let (_normal_text, tools) = mistral_parser.parse_complete("").await.unwrap(); - assert_eq!( - tools.len(), - 0, - "Mistral parser should return empty for empty input" - ); - - let qwen_parser = QwenParser::new(); - let (_normal_text, tools) = qwen_parser.parse_complete("").await.unwrap(); - assert_eq!( - tools.len(), - 0, - "Qwen parser should return empty for empty input" - ); - - let pythonic_parser = PythonicParser::new(); - let (_normal_text, tools) = pythonic_parser.parse_complete("").await.unwrap(); - assert_eq!( - tools.len(), - 0, - "Pythonic parser should return empty for empty input" - ); -} - -#[tokio::test] -async fn test_plain_text_no_tools() { - let plain_text = "This is just a regular response with no tool calls whatsoever."; - - let json_parser = JsonParser::new(); - assert_eq!( - json_parser - .parse_complete(plain_text) - .await - .unwrap() - .1 - .len(), - 0 - ); - - let mistral_parser = MistralParser::new(); - assert_eq!( - mistral_parser - .parse_complete(plain_text) - .await - .unwrap() - .1 - .len(), - 0 - ); - - let qwen_parser = QwenParser::new(); - assert_eq!( - qwen_parser - .parse_complete(plain_text) - .await - .unwrap() - .1 - .len(), - 0 - ); - - let pythonic_parser = PythonicParser::new(); - assert_eq!( - pythonic_parser - .parse_complete(plain_text) - .await - .unwrap() - .1 - .len(), - 0 - ); -} - -#[tokio::test] -async fn test_incomplete_json() { - let json_parser = JsonParser::new(); - - let incomplete_cases = vec![ - r#"{"name": "test""#, // Missing closing brace - r#"{"name": "test", "arguments":"#, // Incomplete arguments - r#"{"name": "test", "arguments": {"#, // Incomplete nested object - ]; - - for input in incomplete_cases { - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!( - tools.len(), - 0, - "Should not parse incomplete JSON: {}", - input - ); - } - - // This case might actually parse because [{"name": "test"}] is complete - // The trailing comma suggests more items but the first item is valid - let _result = json_parser - .parse_complete(r#"[{"name": "test"},"#) - .await - .unwrap(); - // This could parse the first element or return empty - implementation dependent -} - -#[tokio::test] -async fn test_malformed_mistral() { - let parser = MistralParser::new(); - - let malformed_cases = vec![ - "[TOOL_CALLS]", // Missing array - "[TOOL_CALLS] {", // Not an array - "[TOOL_CALLS] [", // Incomplete array - "[TOOL_CALLS] [{]", // Invalid JSON in array - "[TOOL_CALLS] [{\"name\": }]", // Invalid value - ]; - - for input in malformed_cases { - // Parser might return error or empty vec for malformed input - if let Ok((_normal_text, tools)) = parser.parse_complete(input).await { - assert_eq!( - tools.len(), - 0, - "Should not parse malformed Mistral: {}", - input - ); - } - // Error is also acceptable for malformed input - } -} - -#[tokio::test] -async fn test_missing_required_fields() { - let json_parser = JsonParser::new(); - - // Missing name field - let input = r#"{"arguments": {"x": 1}}"#; - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0, "Should not parse without name field"); - - // Name is not a string - let input = r#"{"name": 123, "arguments": {}}"#; - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0, "Should not parse with non-string name"); -} - -#[tokio::test] -async fn test_very_long_strings() { - let json_parser = JsonParser::new(); - - let long_string = "x".repeat(10000); - let input = format!( - r#"{{"name": "test", "arguments": {{"data": "{}"}}}}"#, - long_string - ); - - let (_normal_text, tools) = json_parser.parse_complete(&input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "test"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["data"].as_str().unwrap().len(), 10000); -} - -#[tokio::test] -async fn test_unicode_edge_cases() { - let json_parser = JsonParser::new(); - - // Various Unicode characters including emojis, CJK, RTL text - let input = r#"{"name": "translate", "arguments": {"text": "Hello 世界 🌍 مرحبا עולם"}}"#; - - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], "Hello 世界 🌍 مرحبا עולם"); -} - -#[tokio::test] -async fn test_nested_brackets_in_strings() { - let mistral_parser = MistralParser::new(); - let input = r#"[TOOL_CALLS] [{"name": "echo", "arguments": {"text": "Array: [1, 2, 3]"}}]"#; - let (_normal_text, tools) = mistral_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], "Array: [1, 2, 3]"); - - let pythonic_parser = PythonicParser::new(); - let input = r#"[echo(text="List: [a, b, c]")]"#; - let (_normal_text, tools) = pythonic_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], "List: [a, b, c]"); -} - -#[tokio::test] -async fn test_multiple_formats_in_text() { - let json_parser = JsonParser::new(); - let input = r#" - Here's some text with [TOOL_CALLS] that shouldn't trigger. - {"name": "actual_tool", "arguments": {}} - And some more text with tags. - "#; - - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "actual_tool"); -} - -#[tokio::test] -async fn test_escaped_characters() { - let json_parser = JsonParser::new(); - - let input = r#"{"name": "write", "arguments": {"content": "Line 1\nLine 2\r\nLine 3\tTabbed\\Backslash\"Quote"}}"#; - - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - let content = args["content"].as_str().unwrap(); - assert!(content.contains('\n')); - assert!(content.contains('\t')); - assert!(content.contains('\\')); - assert!(content.contains('"')); -} - -#[tokio::test] -async fn test_numeric_edge_cases() { - let json_parser = JsonParser::new(); - - let input = r#"{ - "name": "calculate", - "arguments": { - "int": 42, - "float": 123.456, - "scientific": 1.23e-4, - "negative": -999, - "zero": 0, - "large": 9007199254740991 - } - }"#; - - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["int"], 42); - assert_eq!(args["float"], 123.456); - assert_eq!(args["scientific"], 0.000123); - assert_eq!(args["negative"], -999); - assert_eq!(args["zero"], 0); - assert_eq!(args["large"], 9007199254740991i64); -} - -#[tokio::test] -async fn test_null_and_boolean_values() { - let json_parser = JsonParser::new(); - - let input = r#"{ - "name": "configure", - "arguments": { - "enabled": true, - "disabled": false, - "optional": null - } - }"#; - - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["enabled"], true); - assert_eq!(args["disabled"], false); - assert_eq!(args["optional"], serde_json::Value::Null); -} - -#[tokio::test] -async fn test_partial_token_at_buffer_boundary() { - let mut parser = QwenParser::new(); - - let tools = create_test_tools(); - - // Send exactly "\n" - let result = parser.parse_incremental("\n{\"name\": \"test\", \"arguments\": {}}\n", - &tools, - ) - .await - .unwrap(); - - // Should successfully parse after completing - if !result.calls.is_empty() { - if let Some(name) = &result.calls[0].name { - assert_eq!(name, "test"); - } - } -} - -#[tokio::test] -async fn test_exact_prefix_lengths() { - let mut parser = QwenParser::new(); - - let tools = create_test_tools(); - - let test_cases = vec![ - ("<", 1), // 1-char prefix - ("", 11), // 11-char prefix (full start without \n) - ]; - - for (prefix, expected_len) in test_cases { - let result = parser.parse_incremental(prefix, &tools).await.unwrap(); - assert!( - result.calls.is_empty(), - "Prefix '{}' (len {}) should be incomplete", - prefix, - expected_len - ); - // Buffer is now internal to parser - can't assert on it - } -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_fallback.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_fallback.rs deleted file mode 100644 index 181cf1874..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_fallback.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! Tests for tool parser fallback behavior -//! -//! When tool call parsing fails, the original text should be preserved as normal text -//! rather than being lost. This ensures graceful degradation. - -use smg::tool_parser::{ - DeepSeekParser, JsonParser, LlamaParser, MistralParser, QwenParser, ToolParser, -}; - -#[tokio::test] -async fn test_json_parser_invalid_json_returns_as_normal_text() { - let parser = JsonParser::new(); - - // Malformed JSON should be returned as normal text (note: commas may be processed) - let input = r#"{"name": "test", "arguments": invalid json here}"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!( - normal_text, - r#"{"name": "test", "arguments": invalid json here}"# - ); - - // Plain text with no JSON structure should be returned as normal text - let input = "This is just plain text that should not be parsed as a tool call"; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); - - // Text that looks like it might have JSON but doesn't should be returned as normal text - let input = "The user said: {something} but it's not valid JSON"; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); -} - -#[tokio::test] -async fn test_qwen_parser_invalid_format_returns_as_normal_text() { - let parser = QwenParser::new(); - - // Missing closing tag - let input = r#" -{"name": "test", "arguments": {}} -This text is missing the closing tag"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should preserve original text when no valid tools found - - // Malformed JSON inside valid tags - let input = r#" -{"name": "test", "arguments": invalid} -"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - // When JSON parsing fails but tags are present, it should preserve the original text - assert_eq!(normal_text, input); - - // Plain text without any tool markers - let input = "This is a regular response without any tool calls."; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should return original text when no markers found -} - -#[tokio::test] -async fn test_llama_parser_invalid_format_returns_as_normal_text() { - let parser = LlamaParser::new(); - - // Invalid JSON after python_tag - let input = r#"<|python_tag|>{"name": "test", "arguments": invalid}"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should preserve original text when parsing fails - - // Plain text without markers or JSON - let input = "Just explaining something without any function calls."; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should return original text - - // Text with python_tag but completely invalid content - let input = r#"Here's my response <|python_tag|>not even close to JSON"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should preserve everything when parsing fails -} - -#[tokio::test] -async fn test_mistral_parser_invalid_format_returns_as_normal_text() { - let parser = MistralParser::new(); - - // Missing closing bracket - let input = r#"[TOOL_CALLS] [{"name": "test", "arguments": {}"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should preserve original text when parsing fails - - // Invalid JSON in tool calls section - let input = r#"[TOOL_CALLS] [{"name": invalid json}]"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should preserve original text when parsing fails - - // Plain text - let input = "No tool calls here, just regular text."; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should return original text -} - -#[tokio::test] -async fn test_deepseek_parser_invalid_format_returns_as_normal_text() { - let parser = DeepSeekParser::new(); - - // Invalid JSON in tool call - let input = r#"Some text<|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>test -```json -{"name": "test", "arguments": malformed} -```<|tool▁call▁end|><|tool▁calls▁end|>"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should preserve original text when parsing fails - - // Missing function marker - let input = r#"<|tool▁calls▁begin|><|tool▁call▁begin|>notfunction<|tool▁sep|>test -```json -{"x": 1} -```<|tool▁call▁end|><|tool▁calls▁end|>"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should return original text when parsing fails - - // No tool markers at all - let input = "Regular response without any special markers."; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should return original text -} - -#[tokio::test] -async fn test_mixed_valid_and_invalid_content() { - let parser = QwenParser::new(); - - // Text with one valid tool call and one invalid - let input = r#"Let me help you with that. - -{"name": "valid_tool", "arguments": {"x": 1}} - -And here's another one: - -{"name": "invalid_tool", "arguments": malformed} - -That's all!"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); // Should extract the valid tool - assert_eq!(tools[0].function.name, "valid_tool"); - // Normal text should contain text before the first tool call - assert_eq!(normal_text, "Let me help you with that.\n"); -} - -#[tokio::test] -async fn test_partial_tool_markers() { - // Test cases where tool markers are incomplete or cut off - - let parser = QwenParser::new(); - let input = "\nThis looks like it might be a tool call but it's not"; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); - - let parser = MistralParser::new(); - let input = "[TOOL_CALLS] But then nothing follows..."; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); - - let parser = LlamaParser::new(); - let input = "Starting a response <|python_tag|> but no JSON"; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); -} - -#[tokio::test] -async fn test_escaped_json_like_content() { - // Test that JSON-like content in regular text doesn't get parsed as tools - - let parser = JsonParser::new(); - let input = r#"The user typed: {"name": "example"} but this is just quoted text"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - // JsonParser should extract the valid JSON and return normal text - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "example"); - assert_eq!(normal_text, "The user typed: but this is just quoted text"); - - let parser = QwenParser::new(); - let input = r#"The syntax is: -{"name": "example"} - - that's how you format it"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - // This actually contains valid tool call syntax, so it should parse - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "example"); -} - -#[tokio::test] -async fn test_unicode_and_special_chars_in_failed_parsing() { - let parser = QwenParser::new(); - - // Unicode in malformed tool calls - let input = r#" -{"name": "测试", "arguments": 🚀 invalid} -"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - // Should handle Unicode properly in the fallback text - malformed content should be preserved - assert_eq!(normal_text, input); - - // Special characters that might confuse parsers - let input = r#"Response: {"name": "test\n\t", "arguments": {"]}"}"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - // This might or might not parse depending on JSON handling of escape sequences - if tools.is_empty() { - assert!(!normal_text.is_empty() || normal_text == input); - } -} - -#[tokio::test] -async fn test_very_long_invalid_input() { - let parser = JsonParser::new(); - - // Generate a very long string that looks like it might be JSON but isn't - let mut input = String::from("{\"name\": \"test\", \"arguments\": {"); - for i in 0..1000 { - input.push_str(&format!("\"field{}\": \"value{}\", ", i, i)); - } - input.push_str("\"final\": incomplete"); // Don't close the JSON properly - - let (normal_text, tools) = parser.parse_complete(&input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Invalid JSON should be returned as normal text -} - -#[tokio::test] -async fn test_almost_valid_tool_calls() { - // Test tool calls that are almost valid but have small issues - - let parser = JsonParser::new(); - - // Missing closing quote should be returned as normal text - let input = r#"{"name": "test", "arguments": {"key": "value}}"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!( - normal_text, - r#"{"name": "test", "arguments": {"key": "value}}"# - ); - - // Extra comma - let input = r#"{"name": "test", "arguments": {},}"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - // Some JSON parsers might accept trailing commas - if tools.is_empty() { - assert_eq!(normal_text, r#"{"name": "test", "arguments": {},}"#); - } - - // Wrong quote types - let input = r#"{'name': 'test', 'arguments': {}}"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); // Standard JSON requires double quotes - assert_eq!(normal_text, r#"{'name': 'test', 'arguments': {}}"#); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_glm47_moe.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_glm47_moe.rs deleted file mode 100644 index 4ae03b5a5..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_glm47_moe.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! GLM-4.7 MoE Parser Integration Tests - -use smg::tool_parser::{Glm4MoeParser, ToolParser}; - -use crate::common::create_test_tools; - -#[tokio::test] -async fn test_glm47_complete_parsing() { - let parser = Glm4MoeParser::glm47(); - - let input = r#"Let me search for that. -get_weathercityBeijingdate2024-12-25 -The weather will be..."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, "Let me search for that.\n"); - assert_eq!(tools[0].function.name, "get_weather"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["city"], "Beijing"); - assert_eq!(args["date"], "2024-12-25"); -} - -#[tokio::test] -async fn test_glm47_multiple_tools() { - let parser = Glm4MoeParser::glm47(); - - let input = r#"searchqueryrust tutorialstranslatetextHello Worldtarget_langzh"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(normal_text, ""); - assert_eq!(tools[0].function.name, "search"); - assert_eq!(tools[1].function.name, "translate"); -} - -#[tokio::test] -async fn test_glm47_type_conversion() { - let parser = Glm4MoeParser::glm47(); - - let input = r#"processcount42rate1.5enabledtruedatanulltextstring value"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, ""); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["count"], 42); - assert_eq!(args["rate"], 1.5); - assert_eq!(args["enabled"], true); - assert_eq!(args["data"], serde_json::Value::Null); - assert_eq!(args["text"], "string value"); -} - -#[tokio::test] -async fn test_glm47_streaming() { - let mut parser = Glm4MoeParser::glm47(); - - let tools = create_test_tools(); - - // Simulate streaming chunks - let chunks = vec![ - "", - "get_weather", - "city", - "Shanghai", - "units", - "celsius", - "", - ]; - - let mut found_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - found_name = true; - } - } - } - - assert!(found_name, "Should have found tool name during streaming"); -} - -#[test] -fn test_glm47_format_detection() { - let parser = Glm4MoeParser::glm47(); - - // Should detect GLM-4 format - assert!(parser.has_tool_markers("")); - assert!(parser.has_tool_markers("text with marker")); - - // Should not detect other formats - assert!(!parser.has_tool_markers("[TOOL_CALLS]")); - assert!(!parser.has_tool_markers("<|tool▁calls▁begin|>")); - assert!(!parser.has_tool_markers("plain text")); -} - -#[tokio::test] -async fn test_python_literals() { - let parser = Glm4MoeParser::glm47(); - - let input = r#"test_funcbool_trueTruebool_falseFalsenone_valNone"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "test_func"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["bool_true"], true); - assert_eq!(args["bool_false"], false); - assert_eq!(args["none_val"], serde_json::Value::Null); -} - -#[tokio::test] -async fn test_glm47_nested_json_in_arg_values() { - let parser = Glm4MoeParser::glm47(); - - let input = r#"processdata{"nested": {"key": "value"}}list[1, 2, 3]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert!(args["data"].is_object()); - assert!(args["list"].is_array()); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_glm4_moe.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_glm4_moe.rs deleted file mode 100644 index 8cdd55de9..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_glm4_moe.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! GLM-4 MoE Parser Integration Tests - -use smg::tool_parser::{Glm4MoeParser, ToolParser}; - -use crate::common::create_test_tools; - -#[tokio::test] -async fn test_glm4_complete_parsing() { - let parser = Glm4MoeParser::glm45(); - - let input = r#"Let me search for that. -get_weather -city -Beijing -date -2024-12-25 - -The weather will be..."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, "Let me search for that.\n"); - assert_eq!(tools[0].function.name, "get_weather"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["city"], "Beijing"); - assert_eq!(args["date"], "2024-12-25"); -} - -#[tokio::test] -async fn test_glm4_multiple_tools() { - let parser = Glm4MoeParser::glm45(); - - let input = r#"search -query -rust tutorials - -translate -text -Hello World -target_lang -zh -"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(normal_text, ""); - assert_eq!(tools[0].function.name, "search"); - assert_eq!(tools[1].function.name, "translate"); -} - -#[tokio::test] -async fn test_glm4_type_conversion() { - let parser = Glm4MoeParser::glm45(); - - let input = r#"process -count -42 -rate -1.5 -enabled -true -data -null -text -string value -"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, ""); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["count"], 42); - assert_eq!(args["rate"], 1.5); - assert_eq!(args["enabled"], true); - assert_eq!(args["data"], serde_json::Value::Null); - assert_eq!(args["text"], "string value"); -} - -#[tokio::test] -async fn test_glm4_streaming() { - let mut parser = Glm4MoeParser::glm45(); - - let tools = create_test_tools(); - - // Simulate streaming chunks - let chunks = vec![ - "", - "get_weather\n", - "city\n", - "Shanghai\n", - "units\n", - "celsius\n", - "", - ]; - - let mut found_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - found_name = true; - } - } - } - - assert!(found_name, "Should have found tool name during streaming"); -} - -#[test] -fn test_glm4_format_detection() { - let parser = Glm4MoeParser::glm45(); - - // Should detect GLM-4 format - assert!(parser.has_tool_markers("")); - assert!(parser.has_tool_markers("text with marker")); - - // Should not detect other formats - assert!(!parser.has_tool_markers("[TOOL_CALLS]")); - assert!(!parser.has_tool_markers("<|tool▁calls▁begin|>")); - assert!(!parser.has_tool_markers("plain text")); -} - -#[tokio::test] -async fn test_python_literals() { - let parser = Glm4MoeParser::glm45(); - - let input = r#"test_func -bool_true -True -bool_false -False -none_val -None -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "test_func"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["bool_true"], true); - assert_eq!(args["bool_false"], false); - assert_eq!(args["none_val"], serde_json::Value::Null); -} - -#[tokio::test] -async fn test_glm4_nested_json_in_arg_values() { - let parser = Glm4MoeParser::glm45(); - - let input = r#"process -data -{"nested": {"key": "value"}} -list -[1, 2, 3] -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert!(args["data"].is_object()); - assert!(args["list"].is_array()); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_json.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_json.rs deleted file mode 100644 index 8f77a350a..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_json.rs +++ /dev/null @@ -1,716 +0,0 @@ -//! JSON Parser Integration Tests -//! -//! Tests for the JSON parser which handles OpenAI, Claude, and generic JSON formats - -use serde_json::json; -use smg::tool_parser::{JsonParser, ToolParser}; - -use crate::common::{create_test_tools, streaming_helpers::*}; - -#[tokio::test] -async fn test_simple_json_tool_call() { - let parser = JsonParser::new(); - let input = r#"{"name": "get_weather", "arguments": {"location": "San Francisco"}}"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, ""); - assert_eq!(tools[0].function.name, "get_weather"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["location"], "San Francisco"); -} - -#[tokio::test] -async fn test_json_array_of_tools() { - let parser = JsonParser::new(); - let input = r#"Hello, here are the results: [ - {"name": "get_weather", "arguments": {"location": "SF"}}, - {"name": "search", "arguments": {"query": "news"}} - ]"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(normal_text, "Hello, here are the results: "); - assert_eq!(tools[0].function.name, "get_weather"); - assert_eq!(tools[1].function.name, "search"); -} - -#[tokio::test] -async fn test_json_with_parameters_key() { - let parser = JsonParser::new(); - let input = r#"{"name": "calculate", "parameters": {"x": 10, "y": 20}}"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, ""); - assert_eq!(tools[0].function.name, "calculate"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["x"], 10); - assert_eq!(args["y"], 20); -} - -#[tokio::test] -async fn test_json_extraction_from_text() { - let parser = JsonParser::new(); - let input = r#"I'll help you with that. {"name": "search", "arguments": {"query": "rust"}} Let me search for that."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!( - normal_text, - "I'll help you with that. Let me search for that." - ); - assert_eq!(tools[0].function.name, "search"); -} - -#[tokio::test] -async fn test_json_with_nested_objects() { - let parser = JsonParser::new(); - let input = r#"{ - "name": "update_config", - "arguments": { - "settings": { - "theme": "dark", - "language": "en", - "notifications": { - "email": true, - "push": false - } - } - } - }"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, ""); - assert_eq!(tools[0].function.name, "update_config"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["settings"]["theme"], "dark"); - assert_eq!(args["settings"]["notifications"]["email"], true); -} - -#[tokio::test] -async fn test_json_with_special_characters() { - let parser = JsonParser::new(); - let input = r#"{"name": "echo", "arguments": {"text": "Line 1\nLine 2\tTabbed", "path": "C:\\Users\\test"}}"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, ""); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], "Line 1\nLine 2\tTabbed"); - assert_eq!(args["path"], "C:\\Users\\test"); -} - -#[tokio::test] -async fn test_json_with_unicode() { - let parser = JsonParser::new(); - let input = r#"{"name": "translate", "arguments": {"text": "Hello 世界 🌍", "emoji": "😊"}}"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, ""); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], "Hello 世界 🌍"); - assert_eq!(args["emoji"], "😊"); -} - -#[tokio::test] -async fn test_json_empty_arguments() { - let parser = JsonParser::new(); - let input = r#"{"name": "ping", "arguments": {}}"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, ""); - assert_eq!(tools[0].function.name, "ping"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args, json!({})); -} - -#[tokio::test] -async fn test_json_invalid_format() { - let parser = JsonParser::new(); - - // Missing closing brace - let input = r#"{"name": "test", "arguments": {"key": "value""#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!( - normal_text, - "{\"name\": \"test\", \"arguments\": {\"key\": \"value\"" - ); - - // Not JSON at all - let input = "This is just plain text"; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); -} - -#[tokio::test] -async fn test_json_format_detection() { - let parser = JsonParser::new(); - - assert!(parser.has_tool_markers(r#"{"name": "test", "arguments": {}}"#)); - assert!(parser.has_tool_markers(r#"[{"name": "test"}]"#)); - assert!(!parser.has_tool_markers("plain text")); -} - -// Streaming tests for JSON array format -#[tokio::test] -async fn test_json_array_streaming_required_mode() { - use smg::protocols::common::Tool; - - // Test that simulates the exact streaming pattern from required mode - let mut parser = JsonParser::new(); - - // Define test tools - let tools = vec![Tool { - tool_type: "function".to_string(), - function: smg::protocols::common::Function { - name: "get_weather".to_string(), - description: Some("Get weather".to_string()), - parameters: serde_json::json!({}), - strict: None, - }, - }]; - - // Simulate the EXACT chunks from the debug log - let chunks = vec![ - "[{", - " \"", - "name", - "\":", - " \"", - "get", - "_weather", - "\",", - " \"", - "parameters", - "\":", - " {", - " \"", - "city", - "\":", - " \"", - "Paris", - "\"", - " }", - " }]", - ]; - - let mut all_results = Vec::new(); - let mut all_normal_text = String::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - all_results.extend(result.calls); - all_normal_text.push_str(&result.normal_text); - } - - // We should have gotten tool call chunks - assert!( - !all_results.is_empty(), - "Should have emitted tool call chunks" - ); - - // Should not have emitted any normal text (including the closing ]) - assert_eq!( - all_normal_text, "", - "Should not emit normal text for JSON array format" - ); - - // Check that we got the function name - let has_name = all_results - .iter() - .any(|item| item.name.as_ref().is_some_and(|n| n == "get_weather")); - assert!(has_name, "Should have emitted function name"); - - // Check that we got the parameters - let has_params = all_results.iter().any(|item| !item.parameters.is_empty()); - assert!(has_params, "Should have emitted parameters"); -} - -#[tokio::test] -async fn test_json_array_multiple_tools_streaming() { - use smg::protocols::common::Tool; - - // Test with multiple tools in array - let mut parser = JsonParser::new(); - - let tools = vec![ - Tool { - tool_type: "function".to_string(), - function: smg::protocols::common::Function { - name: "get_weather".to_string(), - description: Some("Get weather".to_string()), - parameters: serde_json::json!({}), - strict: None, - }, - }, - Tool { - tool_type: "function".to_string(), - function: smg::protocols::common::Function { - name: "get_news".to_string(), - description: Some("Get news".to_string()), - parameters: serde_json::json!({}), - strict: None, - }, - }, - ]; - - // Split into smaller, more realistic chunks - let chunks = vec![ - "[{", - "\"name\":", - "\"get_weather\"", - ",\"parameters\":", - "{\"city\":", - "\"SF\"}", - "}", - ",", - "{\"name\":", - "\"get_news\"", - ",\"parameters\":", - "{\"topic\":", - "\"tech\"}", - "}]", - ]; - - let mut all_results = Vec::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - all_results.extend(result.calls); - } - - // Should have gotten tool calls for both functions - let has_weather = all_results - .iter() - .any(|item| item.name.as_ref().is_some_and(|n| n == "get_weather")); - let has_news = all_results - .iter() - .any(|item| item.name.as_ref().is_some_and(|n| n == "get_news")); - - assert!(has_weather, "Should have get_weather tool call"); - assert!(has_news, "Should have get_news tool call"); -} - -#[tokio::test] -async fn test_json_array_closing_bracket_separate_chunk() { - use smg::protocols::common::Tool; - - // Test case where the closing ] comes as a separate chunk - let mut parser = JsonParser::new(); - - let tools = vec![Tool { - tool_type: "function".to_string(), - function: smg::protocols::common::Function { - name: "get_weather".to_string(), - description: Some("Get weather".to_string()), - parameters: json!({}), - strict: None, - }, - }]; - - // Closing ] as separate chunk, followed by normal text - let chunks = vec![ - "[{", - "\"", - "name", - "\":", - "\"", - "get", - "_weather", - "\",", - "\"", - "parameters", - "\":", - "{", - "\"", - "city", - "\":", - "\"", - "Paris", - "\"", - "}", - "}", - "]", - " Here's", - " the", - " weather", - " info", - ]; - - let mut all_normal_text = String::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - all_normal_text.push_str(&result.normal_text); - } - - // Should emit only the third chunk as normal text, NOT the ] - assert_eq!( - all_normal_text, " Here's the weather info", - "Should emit only normal text without ], got: '{}'", - all_normal_text - ); -} - -#[tokio::test] -async fn test_json_single_object_with_trailing_text() { - use smg::protocols::common::Tool; - - // Test single object format (no array) with trailing text - let mut parser = JsonParser::new(); - - let tools = vec![Tool { - tool_type: "function".to_string(), - function: smg::protocols::common::Function { - name: "get_weather".to_string(), - description: Some("Get weather".to_string()), - parameters: serde_json::json!({}), - strict: None, - }, - }]; - - let chunks = vec![ - "{", - "\"", - "name", - "\":", - "\"", - "get_weather", - "\",", - "\"", - "parameters", - "\":", - "{", - "\"city", - "\":", - "\"Paris", - "\"}", - "}", - " Here's", - " the", - " weather", - ]; - - let mut all_normal_text = String::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - all_normal_text.push_str(&result.normal_text); - } - - // Should emit the trailing text as normal_text (no ] to strip for single object) - assert_eq!( - all_normal_text, " Here's the weather", - "Should emit normal text for single object format, got: '{}'", - all_normal_text - ); -} - -#[tokio::test] -async fn test_json_single_object_with_bracket_in_text() { - use smg::protocols::common::Tool; - - // Test that ] in normal text is NOT stripped for single object format - let mut parser = JsonParser::new(); - - let tools = vec![Tool { - tool_type: "function".to_string(), - function: smg::protocols::common::Function { - name: "get_weather".to_string(), - description: Some("Get weather".to_string()), - parameters: serde_json::json!({}), - strict: None, - }, - }]; - - let chunks = vec![ - "{", - "\"name", - "\":", - "\"get_weather", - "\",", - "\"parameters", - "\":", - "{", - "\"city", - "\":", - "\"Paris", - "\"}", - "}", - "]", - " Here's", - " the", - " weather", - ]; - - let mut all_normal_text = String::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - all_normal_text.push_str(&result.normal_text); - } - - // For single object format, ] should NOT be stripped (it's part of normal text) - assert_eq!( - all_normal_text, "] Here's the weather", - "Should preserve ] in normal text for single object format, got: '{}'", - all_normal_text - ); -} - -#[tokio::test] -async fn test_json_array_bracket_in_text_after_tools() { - use smg::protocols::common::Tool; - - // Test that ] in normal text AFTER array tools is preserved - let mut parser = JsonParser::new(); - - let tools = vec![Tool { - tool_type: "function".to_string(), - function: smg::protocols::common::Function { - name: "get_weather".to_string(), - description: Some("Get weather".to_string()), - parameters: serde_json::json!({}), - strict: None, - }, - }]; - - let chunks = vec![ - "[", - "{", - "\"name", - "\":", - "\"get_weather", - "\",", - "\"parameters", - "\":", - "{", - "\"city", - "\":", - "\"Paris", - "\"}", - "}", - "]", - " Array", - " notation:", - " arr", - "[", - "0", - "]", - ]; - - let mut all_normal_text = String::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - all_normal_text.push_str(&result.normal_text); - } - - // Should preserve ] in normal text after array tools complete - assert_eq!( - all_normal_text, " Array notation: arr[0]", - "Should preserve ] in normal text after array tools, got: '{}'", - all_normal_text - ); -} -// ============================================================================= -// REALISTIC STREAMING TESTS -// ============================================================================= - -#[tokio::test] -async fn test_json_bug_incomplete_tool_name_string() { - let tools = create_test_tools(); - let mut parser = JsonParser::new(); - - // This exact sequence triggered the bug: - // Parser receives {"name": " and must NOT parse it as empty name - let chunks = vec![ - r#"{"#, - r#"""#, - r#"name"#, - r#"""#, - r#":"#, - r#" "#, - r#"""#, // ← Critical moment: parser has {"name": " - // At this point, partial_json should NOT allow incomplete strings - // when current_tool_name_sent=false - r#"search"#, // Use valid tool name from create_test_tools() - r#"""#, - r#", "#, - r#"""#, - r#"arguments"#, - r#"""#, - r#": {"#, - r#"""#, - r#"query"#, - r#"""#, - r#": "#, - r#"""#, - r#"rust programming"#, - r#"""#, - r#"}}"#, - ]; - - let mut got_tool_name = false; - let mut saw_empty_name = false; - - for chunk in chunks.iter() { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(name) = &call.name { - if name.is_empty() { - saw_empty_name = true; - } - if name == "search" { - got_tool_name = true; - } - } - } - } - - assert!( - !saw_empty_name, - "Parser should NEVER return empty tool name" - ); - assert!(got_tool_name, "Should have parsed tool name correctly"); -} - -#[tokio::test] -async fn test_json_realistic_chunks_simple_tool() { - let tools = create_test_tools(); - let mut parser = JsonParser::new(); - - let input = r#"{"name": "get_weather", "arguments": {"city": "Paris"}}"#; - let chunks = create_realistic_chunks(input); - - assert!(chunks.len() > 10, "Should have many small chunks"); - - let mut got_tool_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(&chunk, &tools).await.unwrap(); - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - got_tool_name = true; - } - } - } - - assert!(got_tool_name, "Should have parsed tool name"); -} - -#[tokio::test] -async fn test_json_strategic_chunks_with_quotes() { - let tools = create_test_tools(); - let mut parser = JsonParser::new(); - - let input = r#"{"name": "search", "arguments": {"query": "rust programming"}}"#; - let chunks = create_strategic_chunks(input); - - // Strategic chunks break after quotes and colons - assert!(chunks.iter().any(|c| c.ends_with('"'))); - - let mut got_tool_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(&chunk, &tools).await.unwrap(); - for call in result.calls { - if call.name.is_some() { - got_tool_name = true; - } - } - } - - assert!(got_tool_name, "Should have parsed tool name"); -} - -#[tokio::test] -async fn test_json_incremental_arguments_streaming() { - let tools = create_test_tools(); - let mut parser = JsonParser::new(); - - let input = r#"{"name": "search", "arguments": {"query": "test", "limit": 10}}"#; - let chunks = create_realistic_chunks(input); - - let mut tool_name_sent = false; - let mut got_arguments = false; - - for chunk in chunks { - let result = parser.parse_incremental(&chunk, &tools).await.unwrap(); - for call in result.calls { - if call.name.is_some() { - tool_name_sent = true; - } - if tool_name_sent && !call.parameters.is_empty() { - got_arguments = true; - } - } - } - - assert!(tool_name_sent, "Should have sent tool name"); - assert!(got_arguments, "Should have sent arguments"); -} - -#[tokio::test] -async fn test_json_very_long_url_in_arguments() { - let tools = create_test_tools(); - let mut parser = JsonParser::new(); - - // Simulate long URL arriving in many chunks - let long_url = "https://example.com/very/long/path/".to_string() + &"segment/".repeat(50); - let input = format!( - r#"{{"name": "search", "arguments": {{"query": "{}"}}}}"#, - long_url - ); - let chunks = create_realistic_chunks(&input); - - assert!(chunks.len() > 100, "Long URL should create many chunks"); - - let mut got_tool_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(&chunk, &tools).await.unwrap(); - for call in result.calls { - if call.name.is_some() { - got_tool_name = true; - } - } - } - - assert!(got_tool_name, "Should have parsed tool name"); -} - -#[tokio::test] -async fn test_json_unicode() { - let tools = create_test_tools(); - let mut parser = JsonParser::new(); - - let input = r#"{"name": "search", "arguments": {"query": "Hello 世界 🌍"}}"#; - let chunks = create_realistic_chunks(input); - - let mut got_tool_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(&chunk, &tools).await.unwrap(); - for call in result.calls { - if call.name.is_some() { - got_tool_name = true; - } - } - } - - assert!(got_tool_name, "Should have parsed with unicode"); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_kimik2.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_kimik2.rs deleted file mode 100644 index 2ecfab6ac..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_kimik2.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Kimi K2 Parser Integration Tests - -use smg::tool_parser::{KimiK2Parser, ToolParser}; - -use crate::common::create_test_tools; - -#[tokio::test] -async fn test_kimik2_complete_parsing() { - let parser = KimiK2Parser::new(); - - let input = r#"Let me help you with that. -<|tool_calls_section_begin|> -<|tool_call_begin|>functions.get_weather:0<|tool_call_argument_begin|>{"location": "Tokyo", "units": "celsius"}<|tool_call_end|> -<|tool_calls_section_end|> -The weather in Tokyo is..."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, "Let me help you with that.\n"); - assert_eq!(tools[0].function.name, "get_weather"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["location"], "Tokyo"); - assert_eq!(args["units"], "celsius"); -} - -#[tokio::test] -async fn test_kimik2_multiple_tools() { - let parser = KimiK2Parser::new(); - - let input = r#"<|tool_calls_section_begin|> -<|tool_call_begin|>functions.search:0<|tool_call_argument_begin|>{"query": "rust tutorials"}<|tool_call_end|> -<|tool_call_begin|>functions.translate:1<|tool_call_argument_begin|>{"text": "Hello", "to": "ja"}<|tool_call_end|> -<|tool_calls_section_end|>"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(normal_text, ""); - assert_eq!(tools[0].function.name, "search"); - assert_eq!(tools[1].function.name, "translate"); -} - -#[tokio::test] -async fn test_kimik2_with_whitespace() { - let parser = KimiK2Parser::new(); - - let input = r#"<|tool_calls_section_begin|> -<|tool_call_begin|> functions.test:0 <|tool_call_argument_begin|> {"key": "value", "num": 42} <|tool_call_end|> -<|tool_calls_section_end|>"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, ""); - assert_eq!(tools[0].function.name, "test"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["key"], "value"); - assert_eq!(args["num"], 42); -} - -#[tokio::test] -async fn test_kimik2_streaming() { - let tools = create_test_tools(); - - let mut parser = KimiK2Parser::new(); - - // Simulate streaming chunks - let chunks = vec![ - "<|tool_calls_section_begin|>\n", - "<|tool_call_begin|>functions.", - "calculate:0", - "<|tool_call_argument_begin|>", - r#"{"x": 10, "#, - r#""y": 20}"#, - "<|tool_call_end|>\n", - "<|tool_calls_section_end|>", - ]; - - let mut found_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "calculate"); - found_name = true; - } - } - } - - assert!(found_name, "Should have found tool name during streaming"); -} - -#[test] -fn test_kimik2_format_detection() { - let parser = KimiK2Parser::new(); - - // Should detect Kimi K2 format - assert!(parser.has_tool_markers("<|tool_calls_section_begin|>")); - assert!(parser.has_tool_markers("text with <|tool_calls_section_begin|> marker")); - - // Should not detect other formats - assert!(!parser.has_tool_markers("[TOOL_CALLS]")); - assert!(!parser.has_tool_markers("")); - assert!(!parser.has_tool_markers("plain text")); -} - -#[tokio::test] -async fn test_kimik2_sequential_indices() { - let parser = KimiK2Parser::new(); - - let input = r#"<|tool_calls_section_begin|> -<|tool_call_begin|>functions.first:0<|tool_call_argument_begin|>{"param": "a"}<|tool_call_end|> -<|tool_call_begin|>functions.second:1<|tool_call_argument_begin|>{"param": "b"}<|tool_call_end|> -<|tool_call_begin|>functions.third:2<|tool_call_argument_begin|>{"param": "c"}<|tool_call_end|> -<|tool_calls_section_end|>"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 3); - assert_eq!(normal_text, ""); - assert_eq!(tools[0].function.name, "first"); - assert_eq!(tools[1].function.name, "second"); - assert_eq!(tools[2].function.name, "third"); -} - -#[tokio::test] -async fn test_function_index_extraction() { - let parser = KimiK2Parser::new(); - - let input = r#"Text before tool calls. -<|tool_calls_section_begin|> -<|tool_call_begin|>functions.search:0<|tool_call_argument_begin|>{"query": "rust"}<|tool_call_end|> -<|tool_call_begin|>functions.calc:1<|tool_call_argument_begin|>{"x": 10}<|tool_call_end|> -<|tool_calls_section_end|>"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(normal_text, "Text before tool calls.\n"); - assert_eq!(tools[0].function.name, "search"); - assert_eq!(tools[1].function.name, "calc"); - // TODO: Verify indices are preserved: 0 and 1 -} - -#[tokio::test] -async fn test_namespace_extraction() { - let parser = KimiK2Parser::new(); - - let input = r#"<|tool_calls_section_begin|> -<|tool_call_begin|>api.tools.search:0<|tool_call_argument_begin|>{"q": "test"}<|tool_call_end|> -<|tool_calls_section_end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "api.tools.search"); // Includes full namespace -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_llama.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_llama.rs deleted file mode 100644 index a6a37ad58..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_llama.rs +++ /dev/null @@ -1,454 +0,0 @@ -//! Llama Parser Integration Tests -//! -//! Tests for the Llama parser which handles <|python_tag|> format and plain JSON - -use smg::tool_parser::{LlamaParser, ToolParser}; - -use crate::common::{create_test_tools, streaming_helpers::*}; - -#[tokio::test] -async fn test_llama_python_tag_format() { - let parser = LlamaParser::new(); - let input = r#"Here are some results: <|python_tag|>{"name": "search", "parameters": {"query": "weather"}}"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "search"); - assert_eq!(normal_text, "Here are some results: "); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["query"], "weather"); -} - -#[tokio::test] -async fn test_llama_with_semicolon_separation() { - let parser = LlamaParser::new(); - - let input = r#"<|python_tag|>{"name": "tool1", "parameters": {}};{"name": "tool2", "parameters": {"y": 2}}"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].function.name, "tool1"); - assert_eq!(tools[1].function.name, "tool2"); - assert_eq!(normal_text, ""); -} - -#[tokio::test] -async fn test_llama_no_tool_calls() { - let parser = LlamaParser::new(); - - let input = "This is just plain text with no tool calls"; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); -} - -#[tokio::test] -async fn test_llama_plain_json_fallback() { - let parser = LlamaParser::new(); - let input = r#"{"name": "calculate", "parameters": {"x": 5, "y": 10}}"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "calculate"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["x"], 5); - assert_eq!(args["y"], 10); -} - -#[tokio::test] -async fn test_llama_with_text_before() { - let parser = LlamaParser::new(); - let input = r#"Let me help you with that. <|python_tag|>{"name": "get_time", "parameters": {"timezone": "UTC"}}"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, "Let me help you with that. "); - assert_eq!(tools[0].function.name, "get_time"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["timezone"], "UTC"); -} - -#[tokio::test] -async fn test_llama_with_nested_json() { - let parser = LlamaParser::new(); - let input = r#"<|python_tag|>{ - "name": "update_settings", - "parameters": { - "preferences": { - "theme": "dark", - "language": "en" - }, - "notifications": true - } - }"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "update_settings"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["preferences"]["theme"], "dark"); - assert_eq!(args["notifications"], true); -} - -#[tokio::test] -async fn test_llama_empty_arguments() { - let parser = LlamaParser::new(); - - // With python_tag - let input = r#"<|python_tag|>{"name": "ping", "parameters": {}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "ping"); - - // Plain JSON - let input = r#"{"name": "ping", "parameters": {}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "ping"); -} - -#[tokio::test] -async fn test_llama_format_detection() { - let parser = LlamaParser::new(); - - assert!(parser.has_tool_markers(r#"<|python_tag|>{"name": "test"}"#)); - assert!(parser.has_tool_markers(r#"{"name": "test", "parameters": {}}"#)); - assert!(!parser.has_tool_markers("plain text")); -} - -#[tokio::test] -async fn test_llama_invalid_json_after_tag() { - let parser = LlamaParser::new(); - - let input = r#"<|python_tag|>{"name": invalid}"#; - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, "<|python_tag|>{\"name\": invalid}"); -} - -#[tokio::test] -async fn test_llama_real_world_output() { - let parser = LlamaParser::new(); - - // Actual output from Llama 3.2 model - simplified for testing - let input = r#"I'll search for that information for you. - -<|python_tag|>{"name": "web_search", "parameters": {"query": "Llama 3.2 model capabilities", "num_results": 5, "search_type": "recent"}}"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "web_search"); - - let formatted_input = r#"<|python_tag|>{ - "name": "get_current_time", - "parameters": { - "timezone": "America/New_York", - "format": "ISO8601" - } -}"#; - - let (_normal_text, tools2) = parser.parse_complete(formatted_input).await.unwrap(); - assert_eq!(tools2.len(), 1); - assert_eq!(tools2[0].function.name, "get_current_time"); -} - -#[tokio::test] -async fn test_single_json() { - let parser = LlamaParser::new(); - let text = r#"{"name": "get_weather", "parameters": {"city": "Paris"}}"#; - - let (_normal_text, tools) = parser.parse_complete(text).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_weather"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["city"], "Paris"); -} - -#[tokio::test] -async fn test_multiple_json_with_separator() { - let parser = LlamaParser::new(); - let text = r#"<|python_tag|>{"name": "get_weather", "parameters": {"city": "Paris"}};{"name": "get_tourist_attractions", "parameters": {"city": "Paris"}}"#; - - let (_normal_text, tools) = parser.parse_complete(text).await.unwrap(); - // Note: Current implementation may only parse the first one due to semicolon handling - assert!(!tools.is_empty()); - assert_eq!(tools[0].function.name, "get_weather"); -} - -#[tokio::test] -async fn test_json_with_trailing_text() { - let parser = LlamaParser::new(); - // Valid JSON with trailing text - LlamaParser doesn't support this mixed format - let text = r#"{"name": "get_weather", "parameters": {}} Some follow-up text"#; - - let (normal_text, tools) = parser.parse_complete(text).await.unwrap(); - // LlamaParser expects pure JSON or <|python_tag|> format, not JSON with trailing text - // So this returns as normal text - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, text); -} - -#[tokio::test] -async fn test_invalid_then_valid_json() { - let parser = LlamaParser::new(); - let text = - r#"{"name": "get_weather", "parameters": {{"name": "get_weather", "parameters": {}}"#; - - let (_normal_text, tools) = parser.parse_complete(text).await.unwrap(); - // Should parse at least one valid JSON - if !tools.is_empty() { - assert_eq!(tools[0].function.name, "get_weather"); - } -} - -#[tokio::test] -async fn test_plain_text_only() { - let parser = LlamaParser::new(); - let text = "This is just plain explanation text."; - - let (_normal_text, tools) = parser.parse_complete(text).await.unwrap(); - assert_eq!(tools.len(), 0); -} - -#[tokio::test] -async fn test_with_python_tag_prefix() { - let parser = LlamaParser::new(); - let text = r#"Some intro. <|python_tag|>{"name": "get_weather", "parameters": {}}"#; - - let (_normal_text, tools) = parser.parse_complete(text).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_weather"); -} - -// STREAMING TESTS - -#[tokio::test] -async fn test_llama_streaming_simple() { - let tools = create_test_tools(); - - let mut parser = LlamaParser::new(); - - // Send complete JSON at once - let full_json = r#"<|python_tag|>{"name": "search", "parameters": {"query": "weather"}}"#; - - let result = parser.parse_incremental(full_json, &tools).await.unwrap(); - - assert!( - !result.calls.is_empty(), - "Expected tool call for complete JSON input" - ); - assert_eq!(result.calls[0].name.as_ref().unwrap(), "search"); -} - -#[tokio::test] -async fn test_llama_streaming_partial() { - let tools = create_test_tools(); - - let mut parser = LlamaParser::new(); - - // Stream in chunks - let chunks = vec![ - r#"<|python"#, - r#"_tag|>{"name": "#, - r#""calculate", "#, - r#""parameters": {"x": 10}"#, - r#"}"#, - ]; - - let mut got_complete = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - if !result.calls.is_empty() { - if let Some(name) = &result.calls[0].name { - assert_eq!(name, "calculate"); - got_complete = true; - } - } - } - - assert!(got_complete, "Should have completed parsing"); -} - -#[tokio::test] -async fn test_llama_streaming_plain_json() { - let tools = create_test_tools(); - - let mut parser = LlamaParser::new(); - - // Stream plain JSON without python_tag - let chunks = vec![ - r#"{"name": "#, - r#""search", "#, - r#""parameters": "#, - r#"{"query": "#, - r#""test"}}"#, - ]; - - let mut got_complete = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - if !result.calls.is_empty() { - if let Some(name) = &result.calls[0].name { - assert_eq!(name, "search"); - got_complete = true; - } - } - } - - assert!(got_complete, "Should have completed parsing"); -} - -#[tokio::test] -async fn test_llama_streaming_with_text_before() { - let tools = create_test_tools(); - - let mut parser = LlamaParser::new(); - - let chunks = vec![ - r#"Let me help you. "#, - r#"<|python_tag|>"#, - r#"{"name": "get_time","#, - r#" "parameters": {"#, - r#""timezone": "UTC"}}"#, - ]; - - let mut got_complete = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - if !result.calls.is_empty() { - if let Some(name) = &result.calls[0].name { - assert_eq!(name, "get_time"); - got_complete = true; - } - } - } - - assert!(got_complete, "Should have completed parsing"); -} - -#[tokio::test] -async fn test_llama_streaming_multiple_tools() { - let tools = create_test_tools(); - - let mut parser = LlamaParser::new(); - - let text = - r#"<|python_tag|>{"name": "func1", "parameters": {}};{"name": "func2", "parameters": {}}"#; - - let result = parser.parse_incremental(text, &tools).await.unwrap(); - - // Should get first tool complete - assert!( - !result.calls.is_empty(), - "Expected first tool to be complete" - ); - if let Some(name) = &result.calls[0].name { - assert_eq!(name, "func1"); - } - - // Process remaining buffer to get second tool - let result2 = parser.parse_incremental("", &tools).await.unwrap(); - if !result2.calls.is_empty() { - if let Some(name) = &result2.calls[0].name { - assert_eq!(name, "func2"); - } - } -} - -#[tokio::test] -async fn test_llama_streaming_multiple_tools_chunked() { - let mut parser = LlamaParser::new(); - - let tools = create_test_tools(); - - // First chunk - incomplete first JSON - let chunk1 = r#"<|python_tag|>{"name": "get_weather", "parameters""#; - let result1 = parser.parse_incremental(chunk1, &tools).await.unwrap(); - if !result1.calls.is_empty() { - if let Some(name) = &result1.calls[0].name { - assert_eq!(name, "get_weather"); - } - } - - // Second chunk - complete first JSON and separator - let chunk2 = r#": {"city": "Paris"}};{"name": "#; - let result2 = parser.parse_incremental(chunk2, &tools).await.unwrap(); - - // Should get parameters for first tool (name already sent in result1) - if !result2.calls.is_empty() { - let args: serde_json::Value = serde_json::from_str(&result2.calls[0].parameters).unwrap(); - assert_eq!(args["city"], "Paris"); - } - - let chunk3 = r#""get_time", "parameters": {"timezone": "UTC"}}"#; - let result3 = parser.parse_incremental(chunk3, &tools).await.unwrap(); - if !result3.calls.is_empty() { - if let Some(name) = &result3.calls[0].name { - assert_eq!(name, "get_time"); - } - } -} - -// ============================================================================= -// REALISTIC STREAMING TESTS -// ============================================================================= - -#[tokio::test] -async fn test_llama_realistic_chunks_with_python_tag() { - let tools = create_test_tools(); - let mut parser = LlamaParser::new(); - - let input = r#"<|python_tag|>{"name": "calculate", "parameters": {"x": 10, "y": 20}}"#; - let chunks = create_realistic_chunks(input); - - assert!(chunks.len() > 15, "Should have many small chunks"); - - let mut got_tool_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(&chunk, &tools).await.unwrap(); - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "calculate"); - got_tool_name = true; - } - } - } - - assert!(got_tool_name, "Should have parsed tool name"); -} - -#[tokio::test] -async fn test_llama_python_tag_arrives_in_parts() { - let tools = create_test_tools(); - let mut parser = LlamaParser::new(); - - // Python tag itself arrives in small chunks - let chunks = vec![ - "<|p", "yth", "on_", "tag", "|>{", r#"""#, "na", r#"me""#, ": ", r#"""#, "sea", "rch", - r#"""#, ", ", r#"""#, "par", "ame", "ter", "s", r#"""#, ": {", r#"""#, "q", r#"""#, ": ", - r#"""#, "tes", "t", r#"""#, "}}", - ]; - - let mut got_tool_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "search"); - got_tool_name = true; - } - } - } - - assert!(got_tool_name, "Should have parsed tool name"); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_minimax_m2.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_minimax_m2.rs deleted file mode 100644 index 714fe1392..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_minimax_m2.rs +++ /dev/null @@ -1,779 +0,0 @@ -//! MiniMax M2 Parser Integration Tests - -use smg::tool_parser::{MinimaxM2Parser, ToolParser}; - -use crate::common::create_test_tools; - -#[tokio::test] -async fn test_minimax_complete_parsing() { - let parser = MinimaxM2Parser::new(); - - let input = r#"Let me search for that. - - -Beijing -2024-12-25 - - -The weather will be..."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, "Let me search for that.\n"); - assert_eq!(tools[0].function.name, "get_weather"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["city"], "Beijing"); - assert_eq!(args["date"], "2024-12-25"); -} - -#[tokio::test] -async fn test_minimax_multiple_tools() { - let parser = MinimaxM2Parser::new(); - - let input = r#" - -rust tutorials - - - - -Hello World -zh - -"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(normal_text, ""); - assert_eq!(tools[0].function.name, "search"); - assert_eq!(tools[1].function.name, "translate"); -} - -#[tokio::test] -async fn test_minimax_type_conversion() { - let parser = MinimaxM2Parser::new(); - - let input = r#" - -42 -1.5 -true -null -string value - -"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, ""); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["count"], 42); - assert_eq!(args["rate"], 1.5); - assert_eq!(args["enabled"], true); - assert_eq!(args["data"], serde_json::Value::Null); - assert_eq!(args["text"], "string value"); -} - -#[tokio::test] -async fn test_minimax_streaming_basic() { - let mut parser = MinimaxM2Parser::new(); - - let tools = create_test_tools(); - - // Simulate streaming chunks - let chunks = vec![ - "", - r#""#, - r#"Shanghai"#, - r#"celsius"#, - "", - "", - ]; - - let mut found_name = false; - let mut found_params = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - found_name = true; - } - if !call.parameters.is_empty() { - found_params = true; - } - } - } - - assert!(found_name, "Should have found tool name during streaming"); - assert!(found_params, "Should have streamed parameters"); -} - -#[test] -fn test_minimax_format_detection() { - let parser = MinimaxM2Parser::new(); - - // Should detect MiniMax format - assert!(parser.has_tool_markers("")); - assert!(parser.has_tool_markers("text with marker")); - - // Should not detect other formats - assert!(!parser.has_tool_markers("")); // GLM4 format - assert!(!parser.has_tool_markers("[TOOL_CALLS]")); - assert!(!parser.has_tool_markers("<|tool▁calls▁begin|>")); - assert!(!parser.has_tool_markers("plain text")); -} - -#[tokio::test] -async fn test_minimax_python_literals() { - let parser = MinimaxM2Parser::new(); - - let input = r#" - -True -False -None - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "test_func"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["bool_true"], true); - assert_eq!(args["bool_false"], false); - assert_eq!(args["none_val"], serde_json::Value::Null); -} - -#[tokio::test] -async fn test_minimax_nested_json_in_parameters() { - let parser = MinimaxM2Parser::new(); - - let input = r#" - -{"nested": {"key": "value"}} -[1, 2, 3] - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - // JSON-like strings are kept as strings, not parsed as JSON - // This matches the behavior of other parsers like GLM4 MOE - assert!(args["data"].is_string()); - assert_eq!(args["data"], r#"{"nested": {"key": "value"}}"#); - assert!(args["list"].is_string()); - assert_eq!(args["list"], "[1, 2, 3]"); -} - -#[tokio::test] -async fn test_minimax_xml_entities() { - let parser = MinimaxM2Parser::new(); - - let input = r#" - -<div>content</div> -Quote: "hello" -if (a && b) { } - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["html"], "
content
"); - assert_eq!(args["text"], "Quote: \"hello\""); - assert_eq!(args["code"], "if (a && b) { }"); -} - -#[tokio::test] -async fn test_minimax_streaming_partial_tags() { - let mut parser = MinimaxM2Parser::new(); - let tools = create_test_tools(); - - // Chunks split mid-tag - let chunks = vec![ - "Bei"#, - "jing
", - ]; - - let mut found_name = false; - let mut buffer = String::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - buffer.push_str(&result.normal_text); - - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - found_name = true; - } - } - } - - assert!( - found_name, - "Should have parsed function name from partial chunks" - ); - assert_eq!(buffer, ""); -} - -#[tokio::test] -async fn test_minimax_streaming_incremental_json() { - let mut parser = MinimaxM2Parser::new(); - let tools = create_test_tools(); - - let chunks = vec![ - "", - r#""#, - r#"Paris"#, - r#"metric"#, - "", - ]; - - let mut json_fragments = Vec::new(); - let mut found_function = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(_name) = call.name { - found_function = true; - } - if !call.parameters.is_empty() { - json_fragments.push(call.parameters.clone()); - } - } - } - - assert!(found_function); - - // Verify JSON was built incrementally - assert!(!json_fragments.is_empty()); - - // First fragment should start with opening brace - if let Some(first) = json_fragments.first() { - assert!( - first.starts_with('{'), - "First JSON fragment should start with '{{': {}", - first - ); - } - - // Last fragment should be closing brace - if let Some(last) = json_fragments.last() { - assert!( - last.contains('}'), - "Last JSON fragment should contain '}}': {}", - last - ); - } -} - -#[tokio::test] -async fn test_minimax_multiple_tools_boundary() { - let mut parser = MinimaxM2Parser::new(); - let tools = create_test_tools(); - - // Tool boundary at chunk boundary - let chunks = vec![ - r#"Tokyo"#, - r#"weather forecast"#, - ]; - - let mut tool_names = Vec::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(name) = call.name { - tool_names.push(name); - } - } - } - - assert_eq!(tool_names.len(), 2); - assert_eq!(tool_names[0], "get_weather"); - assert_eq!(tool_names[1], "search"); -} - -#[tokio::test] -async fn test_minimax_invalid_function_name() { - let mut parser = MinimaxM2Parser::new(); - let tools = create_test_tools(); - - let chunks = vec![ - "", - r#""#, - r#"value"#, - "", - ]; - - let mut found_invalid = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - // Invalid function should be skipped - for call in result.calls { - if let Some(name) = call.name { - if name == "invalid_function" { - found_invalid = true; - } - } - } - } - - assert!(!found_invalid, "Invalid function should not be parsed"); -} - -#[tokio::test] -async fn test_minimax_empty_parameters() { - let parser = MinimaxM2Parser::new(); - - let input = r#" - - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "simple_func"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args, serde_json::json!({})); -} - -#[tokio::test] -async fn test_minimax_multiline_parameter_values() { - let parser = MinimaxM2Parser::new(); - - let input = r#" - -line1 -line2 -line3 -你好世界 🌍 - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["multiline"], "line1\nline2\nline3"); - assert_eq!(args["unicode"], "你好世界 🌍"); -} - -#[tokio::test] -async fn test_minimax_nested_xml_like_content() { - let parser = MinimaxM2Parser::new(); - - let input = r#" - -Hello -{"key": "nested"} - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["template"], "Hello"); - - // The nested JSON with XML-like content - let config = - serde_json::from_str::(args["config"].as_str().unwrap()).unwrap(); - assert_eq!(config["key"], "nested"); -} - -#[tokio::test] -async fn test_minimax_streaming_state_reset() { - let mut parser = MinimaxM2Parser::new(); - let tools = create_test_tools(); - - // First tool - let chunks1 = vec![ - r#""#, - r#"London"#, - "", - ]; - - for chunk in chunks1 { - parser.parse_incremental(chunk, &tools).await.unwrap(); - } - - // Second tool - state should be reset - let chunks2 = vec![ - r#""#, - r#"rust"#, - "", - ]; - - let mut second_tool_name = None; - for chunk in chunks2 { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - for call in result.calls { - if let Some(name) = call.name { - second_tool_name = Some(name); - } - } - } - - assert_eq!(second_tool_name, Some("search".to_string())); -} - -#[tokio::test] -async fn test_minimax_many_parameters() { - let parser = MinimaxM2Parser::new(); - - let mut params_xml = String::new(); - for i in 1..=20 { - params_xml.push_str(&format!( - r#"value{} -"#, - i, i - )); - } - - let input = format!( - r#" - -{} - -"#, - params_xml - ); - - let (_normal_text, tools) = parser.parse_complete(&input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "complex_func"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - - // Verify all 20 parameters are parsed - for i in 1..=20 { - let key = format!("param{}", i); - let expected_value = format!("value{}", i); - assert_eq!(args[key], expected_value); - } -} - -#[tokio::test] -async fn test_minimax_character_by_character_streaming() { - // Test character-by-character streaming to simulate real-world streaming - let mut parser = MinimaxM2Parser::new(); - let tools = create_test_tools(); - - let complete_text = r#"Let me help you. - -Seattle -celsius - - Here are the results."#; - - let mut content_collected = String::new(); - let mut tool_name_found = false; - let mut parameters_found = Vec::new(); - - // Stream character by character - feed only one character at a time - for i in 0..complete_text.len() { - let delta = &complete_text[i..i + 1]; - let result = parser.parse_incremental(delta, &tools).await.unwrap(); - content_collected.push_str(&result.normal_text); - - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - tool_name_found = true; - } - if !call.parameters.is_empty() && !parameters_found.contains(&call.parameters) { - parameters_found.push(call.parameters.clone()); - } - } - } - - assert!( - tool_name_found, - "Should find tool name during character-by-character streaming" - ); - assert!( - !parameters_found.is_empty(), - "Should find parameters during streaming" - ); - - // Should have initial content and final content - assert!(content_collected.contains("Let me help you.")); - assert!(content_collected.contains("Here are the results.")); -} - -#[tokio::test] -async fn test_minimax_content_before_and_after_tool_calls() { - let parser = MinimaxM2Parser::new(); - - let input = r#"I'll analyze the weather for you now. - - -Boston -MA - - -Based on the analysis, here's what I found."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // Verify tool extraction - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_weather"); - - // Verify content preservation (only text before tool call is returned) - assert!(normal_text.contains("I'll analyze the weather for you now.")); - // Text after tool call is not included in parse_complete - assert!(!normal_text.contains("Based on the analysis, here's what I found.")); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["city"], "Boston"); - assert_eq!(args["state"], "MA"); -} - -#[tokio::test] -async fn test_minimax_incomplete_tool_call() { - let parser = MinimaxM2Parser::new(); - - // Incomplete tool call - missing closing tag - let input = r#" - -Chicago"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // Should not extract incomplete tool calls - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should return as normal text -} - -#[tokio::test] -async fn test_minimax_malformed_invoke_tag() { - let parser = MinimaxM2Parser::new(); - - // Malformed invoke tag - missing name attribute - let input = r#" - -Miami - -"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // Should not extract tool calls with malformed invoke tags - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); -} - -#[tokio::test] -async fn test_minimax_streaming_with_invalid_function_progressive() { - let mut parser = MinimaxM2Parser::new(); - let tools = create_test_tools(); - - // Progressive chunks building an invalid function call - let chunks = vec![ - "", - r#""#, - r#"value"#, - "", - "", - ]; - - let mut all_normal_text = String::new(); - let mut found_valid_tool = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - all_normal_text.push_str(&result.normal_text); - - for call in result.calls { - if let Some(name) = call.name { - // Should not get here for invalid function - if tools.iter().any(|t| t.function.name == name) { - found_valid_tool = true; - } - } - } - } - - assert!( - !found_valid_tool, - "Invalid function should not be parsed as tool call" - ); - // The invalid tool call should be returned as normal text - assert!(all_normal_text.contains("invalid_function")); -} - -#[tokio::test] -async fn test_minimax_rapid_streaming_bursts() { - // Test handling of rapid streaming bursts (multiple chunks at once) - let mut parser = MinimaxM2Parser::new(); - let tools = create_test_tools(); - - let chunks = vec![ - "", - "rust programming", - "", - ]; - - let mut found_function = false; - let mut parameters = Vec::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "search"); - found_function = true; - } - if !call.parameters.is_empty() { - parameters.push(call.parameters.clone()); - } - } - } - - assert!(found_function); - - // Verify that parameters were streamed correctly - let final_params = parameters.join(""); - assert!(final_params.contains("rust programming")); -} - -#[tokio::test] -async fn test_minimax_special_characters_in_values() { - let parser = MinimaxM2Parser::new(); - - let input = r#" - -Special chars: @#$%^&*() -🦀 Rust 🚀 -"double" and 'single' quotes - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], "Special chars: @#$%^&*()"); - assert_eq!(args["emoji"], "🦀 Rust 🚀"); - assert_eq!(args["quotes"], "\"double\" and 'single' quotes"); -} - -#[tokio::test] -async fn test_minimax_whitespace_handling() { - let parser = MinimaxM2Parser::new(); - - // Test with various whitespace scenarios - let input = r#" - - spaces around - - Line 1 - Line 2 - - tab separated - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - // Values should preserve internal whitespace but may trim edges based on parser design - assert!(args["newlines"].as_str().unwrap().contains("Line 1")); - assert!(args["newlines"].as_str().unwrap().contains("Line 2")); - assert_eq!(args["tabs"], "\ttab\tseparated\t"); -} - -#[tokio::test] -async fn test_minimax_no_tools() { - // Test input with no tool calls at all - let parser = MinimaxM2Parser::new(); - - let input = r#"This is just a normal response without any tool calls. -I can provide information directly without using any tools. -Even if I mention function names like get_weather or search, -they are not actual tool calls unless properly formatted."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // No tools should be extracted - assert_eq!( - tools.len(), - 0, - "Should not extract any tools from plain text" - ); - - // All content should be returned as normal text - assert_eq!( - normal_text, input, - "All content should be returned as normal text when no tools present" - ); -} - -#[tokio::test] -async fn test_minimax_invalid_json_in_parameters() { - // Test handling of invalid JSON in parameter values - let parser = MinimaxM2Parser::new(); - - let input = r#" - -{"key": "value"} -{invalid json: no quotes} -[1, 2, unclosed -Some text {"partial": json} more text - -"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // Tool should still be extracted despite invalid JSON in parameters - assert_eq!( - tools.len(), - 1, - "Should extract tool even with invalid JSON in parameters" - ); - assert_eq!(tools[0].function.name, "process"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - - // Parameters are stored as strings, not parsed as JSON - // Even invalid JSON should be preserved as string values - assert!(args["valid"].is_string()); - assert_eq!(args["valid"], r#"{"key": "value"}"#); - - assert!(args["invalid"].is_string()); - assert_eq!(args["invalid"], "{invalid json: no quotes}"); - - assert!(args["broken"].is_string()); - assert_eq!(args["broken"], "[1, 2, unclosed"); - - assert!(args["mixed"].is_string()); - assert_eq!(args["mixed"], r#"Some text {"partial": json} more text"#); - - assert_eq!(normal_text, ""); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_mistral.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_mistral.rs deleted file mode 100644 index 6bc928b2b..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_mistral.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! Mistral Parser Integration Tests -//! -//! Tests for the Mistral parser which handles [TOOL_CALLS] format - -use serde_json::json; -use smg::tool_parser::{MistralParser, ToolParser}; - -#[tokio::test] -async fn test_mistral_single_tool() { - let parser = MistralParser::new(); - let input = r#"Let me search for that. -[TOOL_CALLS] [{"name": "search_web", "arguments": {"query": "latest news", "max_results": 5}}]"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, "Let me search for that.\n"); - assert_eq!(tools[0].function.name, "search_web"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["query"], "latest news"); - assert_eq!(args["max_results"], 5); -} - -#[tokio::test] -async fn test_mistral_multiple_tools() { - let parser = MistralParser::new(); - let input = r#"I'll help you with both tasks. -[TOOL_CALLS] [ - {"name": "get_weather", "arguments": {"city": "Tokyo", "units": "celsius"}}, - {"name": "search_news", "arguments": {"query": "AI developments", "limit": 10}} -]"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(normal_text, "I'll help you with both tasks.\n"); - - assert_eq!(tools[0].function.name, "get_weather"); - let args0: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args0["city"], "Tokyo"); - - assert_eq!(tools[1].function.name, "search_news"); - let args1: serde_json::Value = serde_json::from_str(&tools[1].function.arguments).unwrap(); - assert_eq!(args1["query"], "AI developments"); -} - -#[tokio::test] -async fn test_mistral_nested_json() { - let parser = MistralParser::new(); - let input = r#"Processing complex data. -[TOOL_CALLS] [{"name": "process_data", "arguments": {"config": {"nested": {"value": [1, 2, 3]}}, "enabled": true}}]"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, "Processing complex data.\n"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["config"]["nested"]["value"], json!([1, 2, 3])); - assert_eq!(args["enabled"], true); -} - -#[tokio::test] -async fn test_mistral_with_text_after() { - let parser = MistralParser::new(); - let input = r#"[TOOL_CALLS] [{"name": "test", "arguments": {}}] - -And here's some text after the tool call that should be ignored."#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "test"); -} - -#[tokio::test] -async fn test_mistral_empty_arguments() { - let parser = MistralParser::new(); - let input = r#"[TOOL_CALLS] [{"name": "ping", "arguments": {}}]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "ping"); -} - -#[tokio::test] -async fn test_mistral_with_brackets_in_strings() { - let parser = MistralParser::new(); - let input = r#"[TOOL_CALLS] [{"name": "echo", "arguments": {"text": "Array notation: arr[0] = value[1]"}}]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], "Array notation: arr[0] = value[1]"); -} - -#[tokio::test] -async fn test_mistral_format_detection() { - let parser = MistralParser::new(); - - assert!(parser.has_tool_markers("[TOOL_CALLS] [")); - assert!(parser.has_tool_markers("Some text [TOOL_CALLS] [")); - assert!(!parser.has_tool_markers("Just plain text")); - assert!(!parser.has_tool_markers("[{\"name\": \"test\"}]")); // JSON array without TOOL_CALLS -} - -#[tokio::test] -async fn test_mistral_malformed_json() { - let parser = MistralParser::new(); - - // Missing closing bracket - let input = r#"[TOOL_CALLS] [{"name": "test", "arguments": {}"#; - if let Ok((_normal_text, tools)) = parser.parse_complete(input).await { - assert_eq!(tools.len(), 0); - } - // Error is also acceptable for malformed input - - // Invalid JSON inside - let input = r#"[TOOL_CALLS] [{"name": invalid}]"#; - if let Ok((_normal_text, tools)) = parser.parse_complete(input).await { - assert_eq!(tools.len(), 0); - } - // Error is also acceptable for malformed input -} - -#[tokio::test] -async fn test_mistral_real_world_output() { - let parser = MistralParser::new(); - - // Actual output from Mistral model - let input = r#"I'll search for information about Rust programming and check the weather in San Francisco. - -[TOOL_CALLS] [ - { - "name": "web_search", - "arguments": { - "query": "Rust programming language features 2024", - "max_results": 3, - "include_snippets": true - } - }, - { - "name": "get_weather", - "arguments": { - "location": "San Francisco, CA", - "units": "fahrenheit", - "include_forecast": false - } - } -] - -Let me execute these searches for you."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(normal_text, "I'll search for information about Rust programming and check the weather in San Francisco.\n\n"); - assert_eq!(tools[0].function.name, "web_search"); - assert_eq!(tools[1].function.name, "get_weather"); -} - -#[tokio::test] -async fn test_mistral_streaming_closing_bracket() { - use smg::protocols::common::Tool; - - // Test that closing ] is stripped for Mistral array format - let mut parser = MistralParser::new(); - - let tools = vec![Tool { - tool_type: "function".to_string(), - function: smg::protocols::common::Function { - name: "get_weather".to_string(), - description: Some("Get weather".to_string()), - parameters: json!({}), - strict: None, - }, - }]; - - let chunks = vec![ - "[TOOL_CALLS] ", - "[{", - "\"", - "name", - "\":", - "\"", - "get", - "_weather", - "\",", - "\"", - "arguments", - "\":", - "{", - "\"", - "city", - "\":", - "\"", - "Paris", - "\"", - "}", - "}", - "]", - " Here's", - " the weather", - " info", - ]; - - let mut all_normal_text = String::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - all_normal_text.push_str(&result.normal_text); - } - - // Should emit only the third chunk as normal text, NOT the ] - assert_eq!( - all_normal_text, " Here's the weather info", - "Should not emit ] for Mistral array format, got: '{}'", - all_normal_text - ); -} - -#[tokio::test] -async fn test_mistral_streaming_bracket_in_text_after_tools() { - use smg::protocols::common::Tool; - - // Test that ] in normal text AFTER tool calls is preserved - let mut parser = MistralParser::new(); - - let tools = vec![Tool { - tool_type: "function".to_string(), - function: smg::protocols::common::Function { - name: "get_weather".to_string(), - description: Some("Get weather".to_string()), - parameters: json!({}), - strict: None, - }, - }]; - - let chunks = vec![ - "[TOOL_CALLS] ", - "[", - "{", - "\"name", - "\":", - "\"get_weather", - "\",", - "\"arguments", - "\":", - "{\"", - "city", - "\":", - "\"Paris", - "\"}", - "}", - "]", - " Array", - " notation:", - " arr", - "[", - "0", - "]", - ]; - - let mut all_normal_text = String::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - all_normal_text.push_str(&result.normal_text); - } - - // Should preserve ] in normal text after tools complete - assert_eq!( - all_normal_text, " Array notation: arr[0]", - "Should preserve ] in normal text after tools, got: '{}'", - all_normal_text - ); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_mixed_edge_cases.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_mixed_edge_cases.rs deleted file mode 100644 index 8dc7571aa..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_mixed_edge_cases.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! Mixed Format and Additional Edge Case Tests -//! -//! Tests for edge cases across parsers and mixed format scenarios - -use serde_json::json; -use smg::tool_parser::{ - JsonParser, LlamaParser, MistralParser, PythonicParser, QwenParser, ToolParser, -}; - -use crate::common::create_test_tools; - -#[tokio::test] -async fn test_mixed_formats_in_text() { - let json_parser = JsonParser::new(); - let input = r#" - Some text with [TOOL_CALLS] marker that shouldn't trigger. - Also has tags and [function()] syntax. - But here's the actual JSON: {"name": "test", "arguments": {}} - "#; - - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "test"); - - // Mistral parser should ignore JSON and other formats - let mistral_parser = MistralParser::new(); - let input = r#" - {"name": "fake"} [function()] - [TOOL_CALLS] [{"name": "real", "arguments": {}}] - "#; - - let (_normal_text, tools) = mistral_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "real"); -} - -#[tokio::test] -async fn test_format_markers_in_string_content() { - let pythonic_parser = PythonicParser::new(); - let input = r#"[echo(text="Use [TOOL_CALLS] and in text")]"#; - - let (_normal_text, tools) = pythonic_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], "Use [TOOL_CALLS] and in text"); - - let qwen_parser = QwenParser::new(); - let input = r#" -{"name": "log", "arguments": {"msg": "Found [function()] pattern"}} -"#; - - let (_normal_text, tools) = qwen_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["msg"], "Found [function()] pattern"); -} - -#[tokio::test] -async fn test_deeply_nested_json_structures() { - let json_parser = JsonParser::new(); - - let input = r#"{ - "name": "deep_process", - "arguments": { - "level1": { - "level2": { - "level3": { - "level4": { - "level5": { - "data": [1, 2, [3, [4, 5]]] - } - } - } - } - } - } - }"#; - - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "deep_process"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert!(args["level1"]["level2"]["level3"]["level4"]["level5"]["data"].is_array()); -} - -#[tokio::test] -async fn test_multiple_sequential_calls_different_formats() { - // Simulate a scenario where different parts of text have different formats - // (though each parser will only recognize its own format) - - let llama_parser = LlamaParser::new(); - - // Llama parser currently only returns the first tool found - let input = r#"First call: <|python_tag|>{"name": "call1", "arguments": {}}"#; - - let (_normal_text, tools) = llama_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "call1"); - - let input2 = r#"{"name": "call2", "arguments": {"x": 1}}"#; - let (_normal_text2, tools2) = llama_parser.parse_complete(input2).await.unwrap(); - assert_eq!(tools2.len(), 1); - assert_eq!(tools2[0].function.name, "call2"); -} - -#[tokio::test] -async fn test_empty_and_whitespace_variations() { - let json_parser = JsonParser::new(); - - // Various whitespace scenarios - let cases = vec![ - r#" {"name":"compact","arguments":{}} "#, - r#" - - {"name": "spaced", "arguments": {}} - - "#, - r#" {"name": "tabbed", "arguments": {}} "#, // tabs - ]; - - for input in cases { - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1, "Should parse regardless of whitespace"); - } -} - -#[tokio::test] -async fn test_special_json_values() { - let json_parser = JsonParser::new(); - - let input = r#"{ - "name": "test_special", - "arguments": { - "float_e": 1.23e10, - "float_neg_e": 1.23e-10, - "hex_like": "0x1234", - "very_long_num": 99999999999999999999, - "special_strings": ["", " ", "\u0000", "\u001f"], - "escaped": "\\n\\r\\t\\\"\\\\", - "unicode": "\u4e2d\u6587" - } - }"#; - - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "test_special"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert!(args["special_strings"].is_array()); - assert!(args["escaped"].is_string()); -} - -#[tokio::test] -async fn test_parser_recovery_after_invalid_input() { - let mut parser = JsonParser::new(); - let tools = create_test_tools(); - - // Send invalid JSON first - let _ = parser.parse_incremental(r#"{"broken": "#, &tools).await; - - // Create a new parser instance for clean state - let mut parser2 = JsonParser::new(); - let result = parser2 - .parse_incremental(r#"{"name": "valid", "arguments": {}}"#, &tools) - .await - .unwrap(); - - if !result.calls.is_empty() { - if let Some(name) = &result.calls[0].name { - assert_eq!(name, "valid"); - } - } -} - -#[tokio::test] -async fn test_boundary_cases_for_extraction() { - let json_parser = JsonParser::new(); - - // JSON at the very beginning - let input = r#"{"name": "start", "arguments": {}} and then text"#; - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "start"); - - // JSON at the very end - let input = r#"Some text first {"name": "end", "arguments": {}}"#; - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "end"); - - // Multiple JSON objects in text (should find first valid one) - let input = - r#"Text {"name": "first", "arguments": {}} more {"name": "second", "arguments": {}}"#; - let (_normal_text, tools) = json_parser.parse_complete(input).await.unwrap(); - assert!(!tools.is_empty()); - assert_eq!(tools[0].function.name, "first"); -} - -#[tokio::test] -async fn test_pythonic_edge_cases() { - let parser = PythonicParser::new(); - - // Function name with underscores and numbers - let input = r#"[func_name_2(param_1="value")]"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "func_name_2"); - - // Empty string argument - let input = r#"[process(text="")]"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], ""); -} - -#[tokio::test] -async fn test_mistral_with_pretty_json() { - let parser = MistralParser::new(); - - // Pretty-printed JSON in Mistral format - let input = r#"[TOOL_CALLS] [ - { - "name": "formatted", - "arguments": { - "nested": { - "key": "value" - }, - "array": [ - 1, - 2, - 3 - ] - } - } - ]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "formatted"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["nested"]["key"], "value"); - assert_eq!(args["array"], json!([1, 2, 3])); -} - -#[tokio::test] -async fn test_qwen_with_cdata_like_content() { - let parser = QwenParser::new(); - - // Note: QwenParser expects exactly "\n" with the newline - let input = r#" -{"name": "process", "arguments": {"xml": ""}} -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "process"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["xml"], ""); -} - -#[tokio::test] -async fn test_extremely_long_function_names() { - let parser = PythonicParser::new(); - - let long_name = "very_long_function_name_that_might_appear_in_generated_code_somewhere"; - let input = format!(r#"[{}(param="value")]"#, long_name); - - let (_normal_text, tools) = parser.parse_complete(&input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, long_name); -} - -#[tokio::test] -async fn test_json_with_duplicate_keys() { - let parser = JsonParser::new(); - - // JSON with duplicate keys (last one should win per JSON spec) - let input = r#"{"name": "test", "arguments": {"key": "first", "key": "second"}}"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - // JSON parsers typically keep the last value for duplicate keys - assert_eq!(args["key"], "second"); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_partial_json.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_partial_json.rs deleted file mode 100644 index 4d6aaa666..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_partial_json.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Partial JSON Parser Tests -//! -//! Tests for the partial JSON parser with allow_partial_strings flag behavior - -use smg::tool_parser::partial_json::PartialJson; - -#[test] -fn test_partial_string_flag_disallows_incomplete_strings() { - // Test case from the bug report: {"name": " - // With allow_partial_strings=false, should return {} (stop before incomplete string) - let parser = PartialJson::new(32, true); - let input = r#"{"name": ""#; - - let result = parser.parse_value(input, false); - assert!(result.is_ok()); - - let (obj, consumed) = result.unwrap(); - - // Should parse just the opening brace and stop at the incomplete string - assert!(obj.is_object()); - let obj_map = obj.as_object().unwrap(); - - // Should have empty object (stopped before parsing incomplete "name" key) - assert!( - obj_map.is_empty() || !obj_map.contains_key("name"), - "Should not parse incomplete string key, got: {:?}", - obj_map - ); - - // Should consume characters up to the incomplete string - assert!(consumed <= input.len()); -} - -#[test] -fn test_partial_string_flag_allows_incomplete_strings() { - // Test case: {"name": " - // With allow_partial_strings=true, should parse the incomplete string - let parser = PartialJson::new(32, true); - let input = r#"{"name": ""#; - - let result = parser.parse_value(input, true); - assert!(result.is_ok()); - - let (obj, consumed) = result.unwrap(); - - // Should parse the object with incomplete string value - assert!(obj.is_object()); - let obj_map = obj.as_object().unwrap(); - - // With allow_partial_strings=true, should parse "name" key with empty string value - assert!( - obj_map.contains_key("name"), - "Should parse incomplete string with allow_partial_strings=true" - ); - - assert_eq!(consumed, input.len()); -} - -#[test] -fn test_partial_string_flag_complete_json() { - // Test case: {"name": "test"} - // Both flags should parse complete JSON the same way - let input = r#"{"name": "test"}"#; - - let parser = PartialJson::new(32, true); - let result1 = parser.parse_value(input, false); - assert!(result1.is_ok()); - let (obj1, consumed1) = result1.unwrap(); - - let result2 = parser.parse_value(input, true); - assert!(result2.is_ok()); - let (obj2, consumed2) = result2.unwrap(); - - // Both should parse the same complete JSON - assert_eq!(obj1, obj2); - assert_eq!(consumed1, consumed2); - assert_eq!(consumed1, input.len()); - - // Check the parsed value - assert!(obj1.is_object()); - let obj_map = obj1.as_object().unwrap(); - assert_eq!(obj_map.get("name").and_then(|v| v.as_str()), Some("test")); -} - -#[test] -fn test_backward_compatibility_default() { - // Test that default PartialJson still allows partial strings (backward compatible) - let parser = PartialJson::default(); - let input = r#"{"name": ""#; - - let result = parser.parse_value(input, true); - assert!(result.is_ok()); - - let (obj, _) = result.unwrap(); - assert!(obj.is_object()); - - // Default behavior should allow partial strings - let obj_map = obj.as_object().unwrap(); - assert!( - obj_map.contains_key("name"), - "Default should allow partial strings for backward compatibility" - ); -} - -#[test] -fn test_partial_string_in_nested_object() { - // Test case: {"tool": {"name": " - let parser = PartialJson::new(32, true); - let input = r#"{"tool": {"name": ""#; - - let result = parser.parse_value(input, false); - assert!(result.is_ok()); - - let (obj, _) = result.unwrap(); - assert!(obj.is_object()); - - // With allow_partial_strings=false, should stop before incomplete nested string - let obj_map = obj.as_object().unwrap(); - if let Some(tool) = obj_map.get("tool") { - if let Some(tool_map) = tool.as_object() { - assert!( - !tool_map.contains_key("name") - || tool_map.get("name").and_then(|v| v.as_str()).is_none(), - "Should not parse incomplete nested string" - ); - } - } -} - -#[test] -fn test_bug_fix_exact_scenario() { - // This test verifies the exact bug scenario from the issue: - // buffer = "{\"name\": \"" - // flags = Allow.ALL & ~Allow.STR - // Python returns: Parsed object: {}, consumed length: 10 - - let parser = PartialJson::new(32, true); - let input = r#"{"name": ""#; - - let result = parser.parse_value(input, false); - assert!(result.is_ok()); - - let (obj, consumed) = result.unwrap(); - - // Should return empty object (not {"name": null} or {"name": ""}) - assert!(obj.is_object()); - let obj_map = obj.as_object().unwrap(); - assert!( - obj_map.is_empty(), - "Expected empty object, got: {:?}. This matches Python behavior with Allow.ALL & ~Allow.STR", - obj_map - ); - - // Should consume all characters (10 bytes) - assert_eq!(consumed, 10, "Should consume all 10 characters"); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_pythonic.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_pythonic.rs deleted file mode 100644 index d92018b0a..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_pythonic.rs +++ /dev/null @@ -1,517 +0,0 @@ -//! Pythonic Parser Integration Tests -//! -//! Tests for the Pythonic parser which handles Python function call syntax - -use serde_json::json; -use smg::tool_parser::{PythonicParser, ToolParser}; - -use crate::common::create_test_tools; - -#[tokio::test] -async fn test_pythonic_single_function() { - let parser = PythonicParser::new(); - let input = r#"[get_weather(city="London", units="celsius")]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_weather"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["city"], "London"); - assert_eq!(args["units"], "celsius"); -} - -#[tokio::test] -async fn test_pythonic_multiple_functions() { - let parser = PythonicParser::new(); - let input = - r#"[search_web(query="Rust programming", max_results=5), get_time(timezone="UTC")]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].function.name, "search_web"); - assert_eq!(tools[1].function.name, "get_time"); - - let args0: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args0["query"], "Rust programming"); - assert_eq!(args0["max_results"], 5); -} - -#[tokio::test] -async fn test_pythonic_with_python_literals() { - let parser = PythonicParser::new(); - let input = r#"[configure(enabled=True, disabled=False, optional=None)]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["enabled"], true); - assert_eq!(args["disabled"], false); - assert_eq!(args["optional"], json!(null)); -} - -#[tokio::test] -async fn test_pythonic_with_lists_and_dicts() { - let parser = PythonicParser::new(); - let input = - r#"[process_data(items=[1, 2, 3], config={"key": "value", "nested": {"deep": True}})]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["items"], json!([1, 2, 3])); - assert_eq!(args["config"]["key"], "value"); - assert_eq!(args["config"]["nested"]["deep"], true); -} - -#[tokio::test] -async fn test_pythonic_with_special_tokens() { - let parser = PythonicParser::new(); - - // Llama 4 sometimes outputs these tokens - let input = r#"<|python_start|>[calculate(x=10, y=20)]<|python_end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "calculate"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["x"], 10); - assert_eq!(args["y"], 20); -} - -#[tokio::test] -async fn test_pythonic_with_nested_parentheses() { - let parser = PythonicParser::new(); - let input = r#"[math_eval(expression="(2 + 3) * (4 - 1)", round_to=2)]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["expression"], "(2 + 3) * (4 - 1)"); - assert_eq!(args["round_to"], 2); -} - -#[tokio::test] -async fn test_pythonic_with_escaped_quotes() { - let parser = PythonicParser::new(); - let input = r#"[echo(text="She said \"Hello\" to him")]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], "She said \"Hello\" to him"); -} - -#[tokio::test] -async fn test_pythonic_empty_arguments() { - let parser = PythonicParser::new(); - let input = r#"[ping()]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "ping"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args, json!({})); -} - -#[tokio::test] -async fn test_pythonic_format_detection() { - let parser = PythonicParser::new(); - - assert!(!parser.has_tool_markers("[function_name(")); // Incomplete - assert!(parser.has_tool_markers("[get_weather(city=\"NYC\")]")); - assert!(!parser.has_tool_markers("Just plain text")); - assert!(!parser.has_tool_markers("{\"name\": \"test\"}")); // JSON -} - -#[tokio::test] -async fn test_pythonic_invalid_syntax() { - let parser = PythonicParser::new(); - - // Missing closing bracket - let input = r#"[function(arg=value"#; - if let Ok((_normal_text, tools)) = parser.parse_complete(input).await { - assert_eq!(tools.len(), 0); - } - // Error is also acceptable for invalid syntax - - // Invalid Python syntax - empty parameter name - // Note: The parser currently accepts this invalid syntax and returns a result - // This is a known limitation of the current implementation - let input = r#"[function(=value)]"#; - if let Ok((_normal_text, tools)) = parser.parse_complete(input).await { - // The parser incorrectly accepts this, returning 1 result - // We'll accept this behavior for now but note it's not ideal - assert!(tools.len() <= 1, "Should parse at most one function"); - } - // Error would be the correct behavior -} - -#[tokio::test] -async fn test_pythonic_real_world_llama4() { - let parser = PythonicParser::new(); - - // Actual output from Llama 4 model - let input = r#"I'll help you with multiple tasks. Let me search for information and perform calculations. - -[web_search(query="latest Rust features", max_results=3, safe_search=True), - calculate(expression="42 * 3.14159", precision=2), - get_weather(city="San Francisco", units="fahrenheit", include_forecast=False)] - -These functions will provide the information you need."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 3); - assert_eq!(normal_text, "I'll help you with multiple tasks. Let me search for information and perform calculations.\n\n\n\nThese functions will provide the information you need."); - assert_eq!(tools[0].function.name, "web_search"); - assert_eq!(tools[1].function.name, "calculate"); - assert_eq!(tools[2].function.name, "get_weather"); - - let args0: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args0["query"], "latest Rust features"); - assert_eq!(args0["safe_search"], true); -} - -#[tokio::test] -async fn test_pythonic_nested_brackets_in_lists() { - let parser = PythonicParser::new(); - - let input = r#"[process_matrix(data=[[1, 2], [3, 4]], labels=["row[0]", "row[1]"])]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "process_matrix"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["data"], json!([[1, 2], [3, 4]])); - assert_eq!(args["labels"], json!(["row[0]", "row[1]"])); -} - -#[tokio::test] -async fn test_pythonic_nested_brackets_in_dicts() { - let parser = PythonicParser::new(); - - let input = - r#"[analyze(config={"patterns": ["[a-z]+", "[0-9]+"], "nested": {"list": [1, [2, 3]]}})]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "analyze"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["config"]["patterns"], json!(["[a-z]+", "[0-9]+"])); - assert_eq!(args["config"]["nested"]["list"], json!([1, [2, 3]])); -} - -#[tokio::test] -async fn test_pythonic_mixed_quotes() { - let parser = PythonicParser::new(); - - let input = r#"[format_text(single='Hello', double="World", mixed="It's \"quoted\"")]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "format_text"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["single"], "Hello"); - assert_eq!(args["double"], "World"); - assert_eq!(args["mixed"], "It's \"quoted\""); -} - -#[tokio::test] -async fn test_pythonic_complex_nesting() { - let parser = PythonicParser::new(); - - let input = r#"[transform( - matrix=[[1, [2, 3]], [4, [5, [6, 7]]]], - operations=[{"type": "scale", "factor": [2, 3]}, {"type": "rotate", "angle": 90}], - metadata={"tags": ["nested[0]", "nested[1]"], "config": {"depth": [1, 2, 3]}} - )]"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "transform"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert!(args["matrix"].is_array()); - assert!(args["operations"].is_array()); - assert_eq!(args["operations"][0]["type"], "scale"); - assert_eq!(args["metadata"]["config"]["depth"], json!([1, 2, 3])); -} - -#[tokio::test] -async fn test_parse_streaming_no_brackets() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - let text = "This is just normal text without any tool calls."; - let result = parser.parse_incremental(text, &tools).await.unwrap(); - - // Expected - no tool calls found - assert!(result.calls.is_empty()); -} - -#[tokio::test] -async fn test_parse_streaming_complete_tool_call() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - let text = "Here's a tool call: [get_weather(location='New York', unit='celsius')]"; - let result = parser.parse_incremental(text, &tools).await.unwrap(); - - assert!(!result.calls.is_empty(), "Should parse complete tool call"); - assert_eq!(result.calls[0].name.as_ref().unwrap(), "get_weather"); - let args: serde_json::Value = serde_json::from_str(&result.calls[0].parameters).unwrap(); - assert_eq!(args["location"], "New York"); - assert_eq!(args["unit"], "celsius"); -} - -#[tokio::test] -async fn test_parse_streaming_text_before_tool_call() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - let text = "This is some text before [get_weather(location='London')]"; - let result = parser.parse_incremental(text, &tools).await.unwrap(); - - assert!(!result.calls.is_empty(), "Should parse tool call"); - assert_eq!(result.calls[0].name.as_ref().unwrap(), "get_weather"); - let args: serde_json::Value = serde_json::from_str(&result.calls[0].parameters).unwrap(); - assert_eq!(args["location"], "London"); -} - -#[tokio::test] -async fn test_parse_streaming_partial_tool_call() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - // First chunk with opening bracket but no closing bracket - let text1 = "Let me check the weather: [get_weather(location="; - let result1 = parser.parse_incremental(text1, &tools).await.unwrap(); - - // First chunk should be incomplete - assert!( - result1.calls.is_empty(), - "First chunk should not return tool call" - ); - - // Second chunk completing the tool call - let text2 = "'Paris')]"; - let result2 = parser.parse_incremental(text2, &tools).await.unwrap(); - - assert!( - !result2.calls.is_empty(), - "Second chunk should complete tool call" - ); - assert_eq!(result2.calls[0].name.as_ref().unwrap(), "get_weather"); - let args: serde_json::Value = serde_json::from_str(&result2.calls[0].parameters).unwrap(); - assert_eq!(args["location"], "Paris"); -} - -#[tokio::test] -async fn test_parse_streaming_bracket_without_text_before() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - let text = "[search(query='python programming')]"; - let result = parser.parse_incremental(text, &tools).await.unwrap(); - - assert!(!result.calls.is_empty(), "Should parse tool call"); - assert_eq!(result.calls[0].name.as_ref().unwrap(), "search"); - let args: serde_json::Value = serde_json::from_str(&result.calls[0].parameters).unwrap(); - assert_eq!(args["query"], "python programming"); -} - -#[tokio::test] -async fn test_parse_streaming_text_after_tool_call() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - // First chunk with complete tool call and some text after - let text = "[get_weather(location='Tokyo')] Here's the forecast:"; - let result = parser.parse_incremental(text, &tools).await.unwrap(); - - assert!(!result.calls.is_empty(), "Should parse tool call"); - assert_eq!(result.calls[0].name.as_ref().unwrap(), "get_weather"); - // Text after tool call is handled by parser internally -} - -#[tokio::test] -async fn test_parse_streaming_multiple_tool_calls() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - let text = "[get_weather(location='Berlin'), search(query='restaurants')]"; - - // Current implementation may handle this as a single parse - let result = parser.parse_incremental(text, &tools).await.unwrap(); - - // The parser should handle multiple tools in one bracket pair - // This test is flexible about the implementation behavior - if !result.calls.is_empty() { - // Parser found at least one tool - assert!(result.calls[0].name.is_some()); - } - // Also acceptable if parser returns empty waiting for more context -} - -#[tokio::test] -async fn test_parse_streaming_opening_bracket_only() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - let text = "Let's try this: ["; - let result = parser.parse_incremental(text, &tools).await.unwrap(); - - // Should be incomplete - no complete tool call - assert!( - result.calls.is_empty(), - "Should not return tool call for partial bracket" - ); -} - -#[tokio::test] -async fn test_parse_streaming_nested_brackets() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - let text = "[get_weather(location='New York', unit='celsius', data=[1, 2, 3])]"; - let result = parser.parse_incremental(text, &tools).await.unwrap(); - - assert!( - !result.calls.is_empty(), - "Should parse tool call with nested brackets" - ); - assert_eq!(result.calls[0].name.as_ref().unwrap(), "get_weather"); - let args: serde_json::Value = serde_json::from_str(&result.calls[0].parameters).unwrap(); - assert_eq!(args["location"], "New York"); - assert_eq!(args["unit"], "celsius"); - assert_eq!(args["data"], json!([1, 2, 3])); -} - -#[tokio::test] -async fn test_parse_streaming_nested_brackets_dict() { - let mut parser = PythonicParser::new(); - let tools = create_test_tools(); - - let text = r#"[search(query='test', config={'options': [1, 2], 'nested': {'key': 'value'}})]"#; - let result = parser.parse_incremental(text, &tools).await.unwrap(); - - assert!( - !result.calls.is_empty(), - "Should parse tool call with nested dict" - ); - assert_eq!(result.calls[0].name.as_ref().unwrap(), "search"); - let args: serde_json::Value = serde_json::from_str(&result.calls[0].parameters).unwrap(); - assert_eq!(args["query"], "test"); - assert_eq!(args["config"]["options"], json!([1, 2])); - assert_eq!(args["config"]["nested"]["key"], "value"); -} - -#[tokio::test] -async fn test_parse_streaming_multiple_tools_with_nested_brackets() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - let text = - "[get_weather(location='Paris', data=[10, 20]), search(query='test', filters=['a', 'b'])]"; - let result = parser.parse_incremental(text, &tools).await.unwrap(); - - // Should parse tools successfully - if !result.calls.is_empty() { - // At least gets the first tool - assert!(result.calls[0].name.is_some()); - } -} - -#[tokio::test] -async fn test_parse_streaming_partial_nested_brackets() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - // First chunk with nested brackets but incomplete - let text1 = "Here's a call: [get_weather(location='Tokyo', data=[1, 2"; - let result1 = parser.parse_incremental(text1, &tools).await.unwrap(); - - // First chunk should be incomplete - assert!(result1.calls.is_empty(), "First chunk should not complete"); - - // Second chunk completing the nested brackets - let text2 = ", 3])]"; - let result2 = parser.parse_incremental(text2, &tools).await.unwrap(); - - assert!( - !result2.calls.is_empty(), - "Second chunk should complete tool call" - ); - assert_eq!(result2.calls[0].name.as_ref().unwrap(), "get_weather"); - let args: serde_json::Value = serde_json::from_str(&result2.calls[0].parameters).unwrap(); - assert_eq!(args["location"], "Tokyo"); - assert_eq!(args["data"], json!([1, 2, 3])); -} - -#[tokio::test] -async fn test_parse_streaming_with_python_start_and_end_token() { - let mut parser = PythonicParser::new(); - - let tools = create_test_tools(); - - let chunks = vec![ - "Here's a call: ", - "<|python_", - "start|>[get_weather(location=", - "'Tokyo', data=[1, 2", - ", 3])]<|python_end|>", - ]; - - let mut got_tool = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - if !result.calls.is_empty() { - if let Some(name) = &result.calls[0].name { - assert_eq!(name, "get_weather"); - let args: serde_json::Value = - serde_json::from_str(&result.calls[0].parameters).unwrap(); - assert_eq!(args["location"], "Tokyo"); - assert_eq!(args["data"], json!([1, 2, 3])); - got_tool = true; - } - } - } - - assert!(got_tool, "Should have parsed the tool call"); -} - -#[tokio::test] -async fn test_detect_and_parse_with_python_start_and_end_token() { - let parser = PythonicParser::new(); - - let text = "User wants to get the weather in Mars. <|python_start|>[get_weather(location='Mars', unit='celsius')]<|python_end|> In this way we will get the weather in Mars."; - let (_normal_text, tools) = parser.parse_complete(text).await.unwrap(); - - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_weather"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["location"], "Mars"); - assert_eq!(args["unit"], "celsius"); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_qwen.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_qwen.rs deleted file mode 100644 index 8b2f6eb2d..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_qwen.rs +++ /dev/null @@ -1,306 +0,0 @@ -//! Qwen Parser Integration Tests -//! -//! Tests for the Qwen parser which handles ... format - -use serde_json::json; -use smg::tool_parser::{QwenParser, ToolParser}; - -use crate::common::{create_test_tools, streaming_helpers::*}; - -#[tokio::test] -async fn test_qwen_single_tool() { - let parser = QwenParser::new(); - let input = r#" -{"name": "get_weather", "arguments": {"city": "Beijing", "units": "celsius"}} -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_weather"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["city"], "Beijing"); - assert_eq!(args["units"], "celsius"); -} - -#[tokio::test] -async fn test_qwen_multiple_sequential_tools() { - let parser = QwenParser::new(); - let input = r#"Let me help you with that. - -{"name": "search", "arguments": {"query": "Qwen model"}} - - -{"name": "translate", "arguments": {"text": "Hello", "to": "zh"}} -"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(normal_text, "Let me help you with that.\n"); - assert_eq!(tools[0].function.name, "search"); - assert_eq!(tools[1].function.name, "translate"); -} - -#[tokio::test] -async fn test_qwen_pretty_printed_json() { - let parser = QwenParser::new(); - let input = r#" -{ - "name": "create_document", - "arguments": { - "title": "Test Document", - "content": "This is a test", - "metadata": { - "author": "Qwen", - "tags": ["test", "example"] - } - } -} -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "create_document"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["metadata"]["author"], "Qwen"); - assert_eq!(args["metadata"]["tags"], json!(["test", "example"])); -} - -#[tokio::test] -async fn test_qwen_with_text_between() { - let parser = QwenParser::new(); - let input = r#"First, let me search for information. - -{"name": "search", "arguments": {"query": "test"}} - - -Now I'll translate something. - - -{"name": "translate", "arguments": {"text": "world", "to": "es"}} - -Done!"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(normal_text, "First, let me search for information.\n"); - assert_eq!(tools[0].function.name, "search"); - assert_eq!(tools[1].function.name, "translate"); -} - -#[tokio::test] -async fn test_qwen_empty_arguments() { - let parser = QwenParser::new(); - let input = r#" -{"name": "get_time", "arguments": {}} -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_time"); -} - -#[tokio::test] -async fn test_qwen_with_newlines_in_strings() { - let parser = QwenParser::new(); - let input = r#" -{"name": "write_file", "arguments": {"content": "Line 1\nLine 2\nLine 3", "path": "/tmp/test.txt"}} -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["content"], "Line 1\nLine 2\nLine 3"); -} - -#[tokio::test] -async fn test_qwen_format_detection() { - let parser = QwenParser::new(); - - assert!(parser.has_tool_markers("")); - assert!(parser.has_tool_markers("Some text \n{")); - assert!(!parser.has_tool_markers("Just plain text")); - assert!(!parser.has_tool_markers("{\"name\": \"test\"}")); // Plain JSON -} - -#[tokio::test] -async fn test_qwen_incomplete_tags() { - let parser = QwenParser::new(); - - // Missing closing tag - let input = r#" -{"name": "test", "arguments": {}}"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - - // Missing opening tag - let input = r#"{"name": "test", "arguments": {}} -"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); -} - -#[tokio::test] -async fn test_qwen_real_world_output() { - let parser = QwenParser::new(); - - // Actual output from Qwen model - let input = r#"I'll help you search for information and perform calculations. - - -{ - "name": "web_search", - "arguments": { - "query": "quantum computing breakthroughs 2024", - "language": "en", - "region": "us", - "safe_search": true - } -} - - -Let me also calculate something for you: - - -{ - "name": "calculator", - "arguments": { - "expression": "sqrt(144) + 3^2", - "precision": 2 - } -} - - -These tools will provide the information you need."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!( - normal_text, - "I'll help you search for information and perform calculations.\n\n" - ); - assert_eq!(tools[0].function.name, "web_search"); - assert_eq!(tools[1].function.name, "calculator"); - - let args0: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args0["query"], "quantum computing breakthroughs 2024"); - assert_eq!(args0["safe_search"], true); -} - -#[tokio::test] -async fn test_buffer_drain_optimization() { - let mut parser = QwenParser::new(); - - let tools = create_test_tools(); - - // First chunk - incomplete tool call - let chunk1 = "\n{\"name\": \"test1\", "; - let _result = parser.parse_incremental(chunk1, &tools).await.unwrap(); - // The important thing is buffer accumulation works - - // Complete first tool and start second - let chunk2 = "\"arguments\": {}}\n\n{\"name\": \"test2\", "; - let result = parser.parse_incremental(chunk2, &tools).await.unwrap(); - - if !result.calls.is_empty() { - if let Some(_name) = &result.calls[0].name { - assert_eq!(result.calls[0].name.as_ref().unwrap(), "test1"); - // After consuming the first tool, buffer is managed internally - } - } - - // Complete the second tool - let chunk3 = "\"arguments\": {\"x\": 1}}\n"; - let result = parser.parse_incremental(chunk3, &tools).await.unwrap(); - - if !result.calls.is_empty() { - if let Some(_name) = &result.calls[0].name { - assert_eq!(result.calls[0].name.as_ref().unwrap(), "test2"); - // Buffer is managed internally - } - } -} - -#[tokio::test] -async fn test_buffer_efficiency_with_multiple_tools() { - let mut parser = QwenParser::new(); - - let tools = create_test_tools(); - - // Send multiple complete tools at once - let input = r#" -{"name": "tool1", "arguments": {"a": 1}} - -{"name": "tool2", "arguments": {"b": 2}} - -{"name": "tool3", "arguments": {"c": 3}} -"#; - - // This should efficiently process tools using drain() without creating new strings - let result = parser.parse_incremental(input, &tools).await.unwrap(); - - // In Phase 2, this will likely parse only the first tool - // The important thing is that drain() doesn't cause any issues - if !result.calls.is_empty() { - if let Some(name) = &result.calls[0].name { - assert!(["tool1", "tool2", "tool3"].contains(&name.as_str())); - } - } -} - -// ============================================================================= -// REALISTIC STREAMING TESTS -// ============================================================================= - -#[tokio::test] -async fn test_qwen_realistic_chunks_with_xml_tags() { - let tools = create_test_tools(); - let mut parser = QwenParser::new(); - - let input = "\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Tokyo\"}}\n"; - let chunks = create_realistic_chunks(input); - - assert!(chunks.len() > 20, "Should have many small chunks"); - - let mut got_tool_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(&chunk, &tools).await.unwrap(); - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - got_tool_name = true; - } - } - } - - assert!(got_tool_name, "Should have parsed tool name"); -} - -#[tokio::test] -async fn test_qwen_xml_tag_arrives_in_parts() { - let tools = create_test_tools(); - let mut parser = QwenParser::new(); - - let chunks = vec![ - "\n", "{", r#"""#, "na", "me", r#"""#, ": ", r#"""#, "tra", "nsl", - "ate", r#"""#, ", ", r#"""#, "arg", "ume", "nts", r#"""#, ": {", r#"""#, "tex", "t", - r#"""#, ": ", r#"""#, "hel", "lo", r#"""#, "}}\n", "
", - ]; - - let mut got_tool_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "translate"); - got_tool_name = true; - } - } - } - - assert!(got_tool_name, "Should have parsed tool name"); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_qwen_coder.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_qwen_coder.rs deleted file mode 100644 index 548b00f9d..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_qwen_coder.rs +++ /dev/null @@ -1,958 +0,0 @@ -//! Qwen Coder Parser Integration Tests -//! -//! Tests for the Qwen Coder parser which handles XML format: -//! \n\nvalue\n\n - -use serde_json::json; -use smg::tool_parser::{parsers::QwenCoderParser, traits::ToolParser}; - -use crate::common::{create_test_tools, streaming_helpers::*}; - -#[tokio::test] -async fn test_qwen_coder_single_tool() { - let parser = QwenCoderParser::new(); - let input = r#" - -Beijing -celsius - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_weather"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["city"], "Beijing"); - assert_eq!(args["units"], "celsius"); -} - -#[tokio::test] -async fn test_qwen_coder_multiple_sequential_tools() { - let parser = QwenCoderParser::new(); - let input = r#"Let me help you with that. - - -Qwen model - - - - -Hello -zh - -"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(normal_text, "Let me help you with that.\n"); - assert_eq!(tools[0].function.name, "search"); - assert_eq!(tools[1].function.name, "translate"); -} - -#[tokio::test] -async fn test_qwen_coder_nested_json_in_parameters() { - let parser = QwenCoderParser::new(); - let input = r#" - -{"nested": {"value": [1, 2, 3]}} -true - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "process_data"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - // JSON values should be parsed - assert_eq!(args["config"]["nested"]["value"], json!([1, 2, 3])); - assert_eq!(args["enabled"], true); -} - -#[tokio::test] -async fn test_qwen_coder_string_parameters() { - let parser = QwenCoderParser::new(); - let input = r#" - -Hello World -42 - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], "Hello World"); - // JSON numbers should be parsed as numbers (consistent with Python's json.loads) - assert_eq!(args["number"], 42); -} - -#[tokio::test] -async fn test_qwen_coder_empty_arguments() { - let parser = QwenCoderParser::new(); - let input = r#" - - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_time"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args, json!({})); -} - -#[tokio::test] -async fn test_qwen_coder_multiline_parameter_values() { - let parser = QwenCoderParser::new(); - let input = r#" - -Line 1 -Line 2 -Line 3 -/tmp/test.txt - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["content"], "Line 1\nLine 2\nLine 3"); - assert_eq!(args["path"], "/tmp/test.txt"); -} - -#[tokio::test] -async fn test_qwen_coder_format_detection() { - let parser = QwenCoderParser::new(); - - assert!(parser.has_tool_markers("")); - assert!(parser.has_tool_markers("Some text ")); - assert!(!parser.has_tool_markers("Just plain text")); - assert!(!parser.has_tool_markers("")); // Without tool_call tags -} - -#[tokio::test] -async fn test_qwen_coder_incomplete_tags() { - let parser = QwenCoderParser::new(); - - // Missing closing tag - let input = r#" - -Beijing"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); - - // Missing opening tag - let input = r#"Beijing - -"#; - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); -} - -#[tokio::test] -async fn test_qwen_coder_streaming_basic() { - let mut parser = QwenCoderParser::new(); - let tools = create_test_tools(); - - // Simulate streaming chunks - let chunks = vec![ - "", - r#""#, - r#"Shanghai"#, - r#"celsius"#, - "", - "", - ]; - - let mut found_name = false; - let mut found_params = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - found_name = true; - } - if !call.parameters.is_empty() { - found_params = true; - } - } - } - - assert!(found_name, "Should have found tool name during streaming"); - assert!(found_params, "Should have streamed parameters"); -} - -#[tokio::test] -async fn test_qwen_coder_streaming_incremental_json() { - let mut parser = QwenCoderParser::new(); - let tools = create_test_tools(); - - let chunks = vec![ - "", - r#""#, - r#"Paris"#, - r#"metric"#, - "", - ]; - - let mut json_fragments = Vec::new(); - let mut found_function = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(_name) = call.name { - found_function = true; - } - if !call.parameters.is_empty() { - json_fragments.push(call.parameters.clone()); - } - } - } - - assert!(found_function); - - // Verify JSON was built incrementally - assert!(!json_fragments.is_empty()); - - // First fragment should start with opening brace - if let Some(first) = json_fragments.first() { - assert!( - first.starts_with('{'), - "First JSON fragment should start with '{{': {}", - first - ); - } -} - -#[tokio::test] -async fn test_qwen_coder_streaming_partial_tags() { - let mut parser = QwenCoderParser::new(); - let tools = create_test_tools(); - - // Chunks split mid-tag - let chunks = vec![ - "Bei"#, - "jing", - ]; - - let mut found_name = false; - let mut buffer = String::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - buffer.push_str(&result.normal_text); - - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - found_name = true; - } - } - } - - assert!( - found_name, - "Should have parsed function name from partial chunks" - ); -} - -#[tokio::test] -async fn test_qwen_coder_multiple_tools_boundary() { - let mut parser = QwenCoderParser::new(); - let tools = create_test_tools(); - - // Tool boundary at chunk boundary - let chunks = vec![ - r#"Tokyo"#, - r#"weather forecast"#, - ]; - - let mut tool_names = Vec::new(); - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - for call in result.calls { - if let Some(name) = call.name { - tool_names.push(name); - } - } - } - - assert_eq!(tool_names.len(), 2); - assert_eq!(tool_names[0], "get_weather"); - assert_eq!(tool_names[1], "search"); -} - -#[tokio::test] -async fn test_qwen_coder_invalid_function_name() { - let mut parser = QwenCoderParser::new(); - let tools = create_test_tools(); - - let chunks = vec![ - "", - r#""#, - r#"value"#, - "", - ]; - - let mut found_invalid = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - // Invalid function should be skipped - for call in result.calls { - if let Some(name) = call.name { - if name == "invalid_function" { - found_invalid = true; - } - } - } - } - - assert!(!found_invalid, "Invalid function should not be parsed"); -} - -#[tokio::test] -async fn test_qwen_coder_type_conversion() { - let parser = QwenCoderParser::new(); - - let input = r#" - -42 -1.5 -true -null -string value - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - // JSON values should be parsed - assert_eq!(args["count"], 42); - assert_eq!(args["rate"], 1.5); - assert_eq!(args["enabled"], true); - assert_eq!(args["data"], serde_json::Value::Null); - assert_eq!(args["text"], "string value"); -} - -#[tokio::test] -async fn test_qwen_coder_special_characters_in_values() { - let parser = QwenCoderParser::new(); - - let input = r#" - -Special chars: @#$%^&*() -🦀 Rust 🚀 -"double" and 'single' quotes - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["text"], "Special chars: @#$%^&*()"); - assert_eq!(args["emoji"], "🦀 Rust 🚀"); - assert_eq!(args["quotes"], "\"double\" and 'single' quotes"); -} - -#[tokio::test] -async fn test_qwen_coder_whitespace_handling() { - let parser = QwenCoderParser::new(); - - // Test with various whitespace scenarios - let input = r#" - - spaces around - - Line 1 - Line 2 - - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - // Values should preserve internal whitespace but trim edges - assert_eq!(args["trimmed"], "spaces around"); - assert!(args["newlines"].as_str().unwrap().contains("Line 1")); - assert!(args["newlines"].as_str().unwrap().contains("Line 2")); -} - -#[tokio::test] -async fn test_qwen_coder_no_tools() { - // Test input with no tool calls at all - let parser = QwenCoderParser::new(); - - let input = r#"This is just a normal response without any tool calls. -I can provide information directly without using any tools. -Even if I mention function names like get_weather or search, -they are not actual tool calls unless properly formatted."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // No tools should be extracted - assert_eq!( - tools.len(), - 0, - "Should not extract any tools from plain text" - ); - - // All content should be returned as normal text - assert_eq!( - normal_text, input, - "All content should be returned as normal text when no tools present" - ); -} - -#[tokio::test] -async fn test_qwen_coder_streaming_state_reset() { - let mut parser = QwenCoderParser::new(); - let tools = create_test_tools(); - - // First tool - let chunks1 = vec![ - r#""#, - r#"London"#, - "", - ]; - - for chunk in chunks1 { - parser.parse_incremental(chunk, &tools).await.unwrap(); - } - - // Second tool - state should be reset - let chunks2 = vec![ - r#""#, - r#"rust"#, - "", - ]; - - let mut second_tool_name = None; - for chunk in chunks2 { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - for call in result.calls { - if let Some(name) = call.name { - second_tool_name = Some(name); - } - } - } - - assert_eq!(second_tool_name, Some("search".to_string())); -} - -#[tokio::test] -async fn test_qwen_coder_realistic_chunks() { - let tools = create_test_tools(); - let mut parser = QwenCoderParser::new(); - - let input = r#" - -Tokyo -celsius - -"#; - let chunks = create_realistic_chunks(input); - - assert!(chunks.len() > 20, "Should have many small chunks"); - - let mut got_tool_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(&chunk, &tools).await.unwrap(); - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - got_tool_name = true; - } - } - } - - assert!(got_tool_name, "Should have parsed tool name"); -} - -#[tokio::test] -async fn test_qwen_coder_xml_tag_arrives_in_parts() { - let tools = create_test_tools(); - let mut parser = QwenCoderParser::new(); - - let chunks = vec![ - "", "", "", "Tok", "yo", "", "", "", - ]; - - let mut got_tool_name = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - for call in result.calls { - if let Some(name) = call.name { - assert_eq!(name, "get_weather"); - got_tool_name = true; - } - } - } - - assert!(got_tool_name, "Should have parsed tool name"); -} - -#[tokio::test] -async fn test_qwen_coder_content_before_and_after_tool_calls() { - let parser = QwenCoderParser::new(); - - let input = r#"I'll analyze the weather for you now. - - -Boston -MA - - -Based on the analysis, here's what I found."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // Verify tool extraction - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_weather"); - - // Verify content preservation (only text before tool call is returned) - assert!(normal_text.contains("I'll analyze the weather for you now.")); - // Text after tool call is not included in parse_complete - assert!(!normal_text.contains("Based on the analysis, here's what I found.")); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["city"], "Boston"); - assert_eq!(args["state"], "MA"); -} - -#[tokio::test] -async fn test_qwen_coder_incomplete_tool_call() { - let parser = QwenCoderParser::new(); - - // Incomplete tool call - missing closing tag - let input = r#" - -Chicago"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // Should not extract incomplete tool calls - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); // Should return as normal text -} - -#[tokio::test] -async fn test_qwen_coder_malformed_function_tag() { - let parser = QwenCoderParser::new(); - - // Malformed function tag - missing name attribute - let input = r#" - -Miami - -"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // Should not extract tool calls with malformed function tags - assert_eq!(tools.len(), 0); - assert_eq!(normal_text, input); -} - -#[tokio::test] -async fn test_qwen_coder_many_parameters() { - let parser = QwenCoderParser::new(); - - let mut params_xml = String::new(); - for i in 1..=20 { - params_xml.push_str(&format!( - r#"value{} -"#, - i, i - )); - } - - let input = format!( - r#" - -{} - -"#, - params_xml - ); - - let (_normal_text, tools) = parser.parse_complete(&input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "complex_func"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - - // Verify all 20 parameters are parsed - for i in 1..=20 { - let key = format!("param{}", i); - let expected_value = format!("value{}", i); - assert_eq!(args[key], expected_value); - } -} - -// ============================================================================ -// Edge Case Tests -// ============================================================================ - -#[tokio::test] -async fn test_qwen_coder_malformed_xml_missing_parameter_close() { - let parser = QwenCoderParser::new(); - - // Missing closing tag - parser regex won't match incomplete parameter - let input = r#" - -Beijing - -"#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // The parser extracts the tool call but with empty arguments since - // the parameter block is malformed (no ) - // This is acceptable behavior - we extract what we can - if tools.is_empty() { - // If no tools extracted, input returned as normal text - assert_eq!(normal_text, input); - } else { - // If tool extracted, it should have the function name - assert_eq!(tools[0].function.name, "get_weather"); - } -} - -#[tokio::test] -async fn test_qwen_coder_malformed_xml_unclosed_function() { - let parser = QwenCoderParser::new(); - - // Missing closing tag - let input = r#" - -Beijing -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // Parser should still extract the tool since it has complete tool_call tags - // and the function name + parameters are present - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "get_weather"); -} - -#[tokio::test] -async fn test_qwen_coder_malformed_xml_nested_tool_calls() { - let parser = QwenCoderParser::new(); - - // Nested tool_call tags (invalid) - let input = r#" - - - - - - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - - // Should handle gracefully - may parse first complete tool_call - // The exact behavior depends on regex matching - assert!(tools.len() <= 1); -} - -#[tokio::test] -async fn test_qwen_coder_unicode_parameter_names() { - let parser = QwenCoderParser::new(); - - // Unicode characters in parameter names (Chinese, Japanese, emoji) - let input = r#" - -北京 -晴れ -🌍🌎🌏 - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "process"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["城市"], "北京"); - assert_eq!(args["天気"], "晴れ"); - assert_eq!(args["emoji_key"], "🌍🌎🌏"); -} - -#[tokio::test] -async fn test_qwen_coder_unicode_function_name() { - let parser = QwenCoderParser::new(); - - // Unicode function name - let input = r#" - -上海 - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "获取天气"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["location"], "上海"); -} - -#[tokio::test] -async fn test_qwen_coder_very_large_parameter_value() { - let parser = QwenCoderParser::new(); - - // Generate a large parameter value (100KB) - let large_value: String = "x".repeat(100_000); - - let input = format!( - r#" - -{} - -"#, - large_value - ); - - let (_normal_text, tools) = parser.parse_complete(&input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "process_large"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["data"].as_str().unwrap().len(), 100_000); -} - -#[tokio::test] -async fn test_qwen_coder_very_large_nested_json_parameter() { - let parser = QwenCoderParser::new(); - - // Generate moderately nested JSON structure (10 levels to avoid stack overflow) - let mut nested_json = String::from(r#"{"level": 0}"#); - for i in 1..=10 { - nested_json = format!(r#"{{"level": {}, "child": {}}}"#, i, nested_json); - } - - let input = format!( - r#" - -{} - -"#, - nested_json - ); - - let (_normal_text, tools) = parser.parse_complete(&input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "process_nested"); - - // Verify the nested JSON was parsed correctly - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert!(args["config"].is_object()); - assert_eq!(args["config"]["level"], 10); -} - -#[tokio::test] -async fn test_qwen_coder_streaming_malformed_recovery() { - let mut parser = QwenCoderParser::new(); - let tools = create_test_tools(); - - // First: malformed tool call (invalid function name) - // Second: valid tool call - let chunks = vec![ - r#"1"#, - r#"Tokyo"#, - ]; - - let mut valid_tool_found = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - for call in result.calls { - if let Some(name) = call.name { - if name == "get_weather" { - valid_tool_found = true; - } - } - } - } - - assert!( - valid_tool_found, - "Should recover and parse valid tool after invalid one" - ); -} - -#[tokio::test] -async fn test_qwen_coder_parameter_with_xml_like_content() { - let parser = QwenCoderParser::new(); - - // Parameter value contains XML-like content that shouldn't be parsed as tags - let input = r#" - -
Hello
- - -
"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert!(args["html_content"] - .as_str() - .unwrap() - .contains("
")); - assert!(args["xml_snippet"] - .as_str() - .unwrap() - .contains(" - - - -value - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["empty"], ""); - assert_eq!(args["whitespace"], ""); // Trimmed - assert_eq!(args["normal"], "value"); -} - -// ============================================================================ -// HTML Entity and Python Literal Tests -// ============================================================================ - -#[tokio::test] -async fn test_qwen_coder_html_entity_decoding() { - let parser = QwenCoderParser::new(); - - // Test HTML entities in parameter values - let input = r#" - -Tom & Jerry -5 < 10 && 10 > 5 -"Hello" & 'World' - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["ampersand"], "Tom & Jerry"); - assert_eq!(args["comparison"], "5 < 10 && 10 > 5"); - assert_eq!(args["quotes"], "\"Hello\" & 'World'"); -} - -#[tokio::test] -async fn test_qwen_coder_html_numeric_entities() { - let parser = QwenCoderParser::new(); - - // Test numeric HTML entities - let input = r#" - -<tag> -<tag> - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["decimal"], ""); - assert_eq!(args["hex"], ""); -} - -#[tokio::test] -async fn test_qwen_coder_python_literals() { - let parser = QwenCoderParser::new(); - - // Test Python-style literals (True, False, None) - let input = r#" - -True -False -None -true -false -null - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - // Python literals should be converted - assert_eq!(args["py_true"], true); - assert_eq!(args["py_false"], false); - assert_eq!(args["py_none"], serde_json::Value::Null); - // JSON literals should also work - assert_eq!(args["json_true"], true); - assert_eq!(args["json_false"], false); - assert_eq!(args["json_null"], serde_json::Value::Null); -} - -#[tokio::test] -async fn test_qwen_coder_mixed_html_and_json() { - let parser = QwenCoderParser::new(); - - // Test HTML entities within JSON structures - let input = r#" - -price < 100 && rating > 4 -{"operator": "&&", "escape": true} - -"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["query"], "price < 100 && rating > 4"); - // JSON with HTML entity inside - the entity gets decoded first, then JSON parsed - assert!(args["config"].is_object()); - assert_eq!(args["config"]["operator"], "&&"); -} diff --git a/sgl-model-gateway/tests/tool_parser/tool_parser_step3.rs b/sgl-model-gateway/tests/tool_parser/tool_parser_step3.rs deleted file mode 100644 index 333c41a07..000000000 --- a/sgl-model-gateway/tests/tool_parser/tool_parser_step3.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! Step3 Parser Integration Tests - -use smg::tool_parser::{Step3Parser, ToolParser}; - -use crate::common::create_test_tools; - -#[tokio::test] -async fn test_step3_complete_parsing() { - let parser = Step3Parser::new(); - - let input = r#"Let me help you. -<|tool_calls_begin|> -<|tool_call_begin|>function<|tool_sep|> -rust programming -10 -<|tool_call_end|> -<|tool_calls_end|> -Here are the results..."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, "Let me help you.\n"); - assert_eq!(tools[0].function.name, "search"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["query"], "rust programming"); - assert_eq!(args["limit"], 10); -} - -#[tokio::test] -async fn test_step3_multiple_tools() { - let parser = Step3Parser::new(); - - let input = r#"<|tool_calls_begin|> -<|tool_call_begin|>function<|tool_sep|> -Tokyo -<|tool_call_end|> -<|tool_call_begin|>function<|tool_sep|> -tech -5 -<|tool_call_end|> -<|tool_calls_end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].function.name, "get_weather"); - assert_eq!(tools[1].function.name, "get_news"); -} - -#[tokio::test] -async fn test_step3_type_conversion() { - let parser = Step3Parser::new(); - - let input = r#"<|tool_calls_begin|> -<|tool_call_begin|>function<|tool_sep|> -100 -2.5 -true -null -hello world -<|tool_call_end|> -<|tool_calls_end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["count"], 100); - assert_eq!(args["rate"], 2.5); - assert_eq!(args["active"], true); - assert_eq!(args["optional"], serde_json::Value::Null); - assert_eq!(args["text"], "hello world"); -} - -#[tokio::test] -async fn test_step3_streaming() { - let mut parser = Step3Parser::new(); - - let tools = create_test_tools(); - - // Simulate streaming chunks - let chunks = vec![ - "<|tool_calls_begin|>\n", - "<|tool_call_begin|>function", - "<|tool_sep|>", - "\n10", - "\n20", - "\n<|tool_call_end|>", - "\n<|tool_calls_end|>", - ]; - - let mut found_complete = false; - - for chunk in chunks { - let result = parser.parse_incremental(chunk, &tools).await.unwrap(); - - if !result.calls.is_empty() { - if let Some(name) = &result.calls[0].name { - assert_eq!(name, "calc"); - found_complete = true; - } - } - } - - assert!(found_complete); -} - -#[test] -fn test_step3_format_detection() { - let parser = Step3Parser::new(); - - // Should detect Step3 format - assert!(parser.has_tool_markers("<|tool_calls_begin|>")); - assert!(parser.has_tool_markers("text with <|tool_calls_begin|> marker")); - - // Should not detect other formats - assert!(!parser.has_tool_markers("[TOOL_CALLS]")); - assert!(!parser.has_tool_markers("")); - assert!(!parser.has_tool_markers("plain text")); -} - -#[tokio::test] -async fn test_step3_nested_steptml() { - let parser = Step3Parser::new(); - - let input = r#"<|tool_calls_begin|> -<|tool_call_begin|>function<|tool_sep|> -{"nested": {"key": "value"}} -[1, 2, 3] -<|tool_call_end|> -<|tool_calls_end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "config"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert!(args["settings"].is_object()); - assert!(args["array"].is_array()); -} - -#[tokio::test] -async fn test_step3_python_literals() { - let parser = Step3Parser::new(); - - let input = r#"<|tool_calls_begin|> -<|tool_call_begin|>function<|tool_sep|> -True -False -None -<|tool_call_end|> -<|tool_calls_end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["bool_true"], true); - assert_eq!(args["bool_false"], false); - assert_eq!(args["none_value"], serde_json::Value::Null); -} - -#[tokio::test] -async fn test_steptml_format() { - let parser = Step3Parser::new(); - - let input = r#"Text before. -<|tool_calls_begin|> -<|tool_call_begin|>function<|tool_sep|> -rust lang -10 -<|tool_call_end|> -<|tool_calls_end|>Text after."#; - - let (normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(normal_text, "Text before.\n"); - assert_eq!(tools[0].function.name, "search"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["query"], "rust lang"); - assert_eq!(args["limit"], 10); - // TODO: Verify normal text extraction -} - -#[tokio::test] -async fn test_json_parameter_values() { - let parser = Step3Parser::new(); - - let input = r#"<|tool_calls_begin|> -<|tool_call_begin|>function<|tool_sep|> -{"nested": {"value": true}} -[1, 2, 3] -<|tool_call_end|> -<|tool_calls_end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert!(args["settings"].is_object()); - assert!(args["items"].is_array()); -} - -#[tokio::test] -async fn test_step3_parameter_with_angle_brackets() { - let parser = Step3Parser::new(); - - let input = r#"<|tool_calls_begin|> -<|tool_call_begin|>function<|tool_sep|> -a < b && b > c -comparison test -<|tool_call_end|> -<|tool_calls_end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "compare"); - - let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap(); - assert_eq!(args["expression"], "a < b && b > c"); - assert_eq!(args["context"], "comparison test"); -} - -#[tokio::test] -async fn test_step3_empty_function_name() { - let parser = Step3Parser::new(); - - let input = r#"<|tool_calls_begin|> -<|tool_call_begin|>function<|tool_sep|> -value -<|tool_call_end|> -<|tool_calls_end|>"#; - - let (_normal_text, tools) = parser.parse_complete(input).await.unwrap(); - assert_eq!(tools.len(), 0); // Should reject empty function name -} diff --git a/sgl-model-gateway/tests/tool_parser_tests.rs b/sgl-model-gateway/tests/tool_parser_tests.rs deleted file mode 100644 index c4ce57d68..000000000 --- a/sgl-model-gateway/tests/tool_parser_tests.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Tool parser integration tests - -#[path = "common/mod.rs"] -pub mod common; - -mod tool_parser; - -pub use tool_parser::*;