diff --git a/crates/reasoning_parser/src/factory.rs b/crates/reasoning_parser/src/factory.rs
index eeda6cbd13..598df10da4 100644
--- a/crates/reasoning_parser/src/factory.rs
+++ b/crates/reasoning_parser/src/factory.rs
@@ -208,35 +208,45 @@ impl ParserFactory {
let config = ParserConfig {
think_start_token: "".to_string(),
think_end_token: "".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
- always_in_reasoning: false,
+ ..Default::default()
};
Box::new(BaseReasoningParser::new(config).with_model_type("deepseek_v31".to_string()))
});
+ let kimi_tool_markers = vec!["<|tool_calls_section_begin|>".to_string()];
+
// Register Kimi-K2.5 parser (standard think tokens, always_in_reasoning=false)
- registry.register_parser("kimi_k25", || {
- let config = ParserConfig {
- think_start_token: "".to_string(),
- think_end_token: "".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
- always_in_reasoning: false,
- };
- Box::new(BaseReasoningParser::new(config).with_model_type("kimi_k25".to_string()))
+ registry.register_parser("kimi_k25", {
+ let markers = kimi_tool_markers.clone();
+ move || {
+ let config = ParserConfig {
+ think_start_token: "".to_string(),
+ think_end_token: "".to_string(),
+ stream_reasoning: true,
+ max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
+ always_in_reasoning: false,
+ tool_section_start_markers: markers.clone(),
+ };
+ Box::new(BaseReasoningParser::new(config).with_model_type("kimi_k25".to_string()))
+ }
});
// Register Kimi-K2-Thinking parser (standard think tokens, always_in_reasoning=true)
- registry.register_parser("kimi_thinking", || {
- let config = ParserConfig {
- think_start_token: "".to_string(),
- think_end_token: "".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
- always_in_reasoning: true,
- };
- Box::new(BaseReasoningParser::new(config).with_model_type("kimi_thinking".to_string()))
+ registry.register_parser("kimi_thinking", {
+ let markers = kimi_tool_markers;
+ move || {
+ let config = ParserConfig {
+ think_start_token: "".to_string(),
+ think_end_token: "".to_string(),
+ stream_reasoning: true,
+ max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
+ always_in_reasoning: true,
+ tool_section_start_markers: markers.clone(),
+ };
+ Box::new(
+ BaseReasoningParser::new(config).with_model_type("kimi_thinking".to_string()),
+ )
+ }
});
// Register model patterns
@@ -293,9 +303,7 @@ impl ParserFactory {
let config = ParserConfig {
think_start_token: String::new(),
think_end_token: String::new(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
- always_in_reasoning: false,
+ ..Default::default()
};
Box::new(
BaseReasoningParser::new(config).with_model_type("passthrough".to_string()),
@@ -320,9 +328,7 @@ impl ParserFactory {
let config = ParserConfig {
think_start_token: String::new(),
think_end_token: String::new(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
- always_in_reasoning: false,
+ ..Default::default()
};
Box::new(BaseReasoningParser::new(config).with_model_type("passthrough".to_string()))
}
diff --git a/crates/reasoning_parser/src/parsers/base.rs b/crates/reasoning_parser/src/parsers/base.rs
index 6214acd695..0db48c6267 100644
--- a/crates/reasoning_parser/src/parsers/base.rs
+++ b/crates/reasoning_parser/src/parsers/base.rs
@@ -41,6 +41,15 @@ impl BaseReasoningParser {
|| (self.config.think_end_token.starts_with(text)
&& self.config.think_end_token != text)
}
+
+ /// Find the earliest tool-section start marker in `text`.
+ fn find_tool_section_start(&self, text: &str) -> Option {
+ self.config
+ .tool_section_start_markers
+ .iter()
+ .filter_map(|marker| text.find(marker.as_str()))
+ .min()
+ }
}
impl ReasoningParser for BaseReasoningParser {
@@ -63,7 +72,11 @@ impl ReasoningParser for BaseReasoningParser {
.to_string();
if !processed_text.contains(&self.config.think_end_token) {
- // Assume reasoning was truncated before end token
+ if let Some(tool_pos) = self.find_tool_section_start(&processed_text) {
+ let reasoning_text = processed_text[..tool_pos].trim().to_string();
+ let normal_text = processed_text[tool_pos..].to_string();
+ return Ok(ParserResult::new(normal_text, reasoning_text));
+ }
return Ok(ParserResult::reasoning(processed_text));
}
@@ -133,7 +146,13 @@ impl ReasoningParser for BaseReasoningParser {
// Continue with reasoning content
if self.in_reasoning && self.config.stream_reasoning {
- // Stream the content immediately
+ if let Some(tool_pos) = self.find_tool_section_start(¤t_text) {
+ let reasoning_text = current_text[..tool_pos].trim().to_string();
+ let normal_text = current_text[tool_pos..].to_string();
+ self.buffer.clear();
+ self.in_reasoning = false;
+ return Ok(ParserResult::new(normal_text, reasoning_text));
+ }
let reasoning_text = current_text;
self.buffer.clear();
Ok(ParserResult::reasoning(reasoning_text))
@@ -188,6 +207,7 @@ mod tests {
stream_reasoning,
max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
always_in_reasoning,
+ ..Default::default()
};
BaseReasoningParser::new(config)
}
@@ -368,4 +388,55 @@ mod tests {
_ => panic!("Expected BufferOverflow error"),
}
}
+
+ fn create_parser_with_markers() -> BaseReasoningParser {
+ let config = ParserConfig {
+ think_start_token: "".to_string(),
+ think_end_token: "".to_string(),
+ tool_section_start_markers: vec!["<|tool_calls_section_begin|>".to_string()],
+ ..Default::default()
+ };
+ BaseReasoningParser::new(config)
+ }
+
+ #[test]
+ fn test_tool_marker_stops_reasoning_non_streaming() {
+ let mut parser = create_parser_with_markers();
+ let input = "thinking here<|tool_calls_section_begin|>tool call data";
+ let result = parser.detect_and_parse_reasoning(input).unwrap();
+ assert_eq!(result.reasoning_text, "thinking here");
+ assert_eq!(
+ result.normal_text,
+ "<|tool_calls_section_begin|>tool call data"
+ );
+ }
+
+ #[test]
+ fn test_tool_marker_stops_reasoning_streaming() {
+ let mut parser = create_parser_with_markers();
+ let r1 = parser
+ .parse_reasoning_streaming_incremental("reasoning ")
+ .unwrap();
+ assert_eq!(r1.reasoning_text, "reasoning ");
+ assert!(parser.is_in_reasoning());
+
+ let r2 = parser
+ .parse_reasoning_streaming_incremental("more<|tool_calls_section_begin|>tool data")
+ .unwrap();
+ assert_eq!(r2.reasoning_text, "more");
+ assert_eq!(r2.normal_text, "<|tool_calls_section_begin|>tool data");
+ assert!(!parser.is_in_reasoning());
+ }
+
+ #[test]
+ fn test_no_markers_does_not_stop_reasoning() {
+ let mut parser = create_test_parser(false, true);
+ let input = "thinking<|tool_calls_section_begin|>stuff";
+ let result = parser.detect_and_parse_reasoning(input).unwrap();
+ assert_eq!(
+ result.reasoning_text,
+ "thinking<|tool_calls_section_begin|>stuff"
+ );
+ assert_eq!(result.normal_text, "");
+ }
}
diff --git a/crates/reasoning_parser/src/parsers/cohere_cmd.rs b/crates/reasoning_parser/src/parsers/cohere_cmd.rs
index 1d04acde22..f2b738eb12 100644
--- a/crates/reasoning_parser/src/parsers/cohere_cmd.rs
+++ b/crates/reasoning_parser/src/parsers/cohere_cmd.rs
@@ -5,7 +5,7 @@
use crate::{
parsers::BaseReasoningParser,
- traits::{ParseError, ParserConfig, ParserResult, ReasoningParser, DEFAULT_MAX_BUFFER_SIZE},
+ traits::{ParseError, ParserConfig, ParserResult, ReasoningParser},
};
/// Cohere Command model reasoning parser.
@@ -22,9 +22,7 @@ impl CohereCmdParser {
let config = ParserConfig {
think_start_token: "<|START_THINKING|>".to_string(),
think_end_token: "<|END_THINKING|>".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
- always_in_reasoning: false,
+ ..Default::default()
};
Self {
diff --git a/crates/reasoning_parser/src/parsers/deepseek_r1.rs b/crates/reasoning_parser/src/parsers/deepseek_r1.rs
index 60036ed7d5..d937446f85 100644
--- a/crates/reasoning_parser/src/parsers/deepseek_r1.rs
+++ b/crates/reasoning_parser/src/parsers/deepseek_r1.rs
@@ -4,7 +4,7 @@
use crate::{
parsers::BaseReasoningParser,
- traits::{ParseError, ParserConfig, ParserResult, ReasoningParser, DEFAULT_MAX_BUFFER_SIZE},
+ traits::{ParseError, ParserConfig, ParserResult, ReasoningParser},
};
/// DeepSeek-R1 reasoning parser.
@@ -21,9 +21,8 @@ impl DeepSeekR1Parser {
let config = ParserConfig {
think_start_token: "".to_string(),
think_end_token: "".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
always_in_reasoning: true,
+ ..Default::default()
};
Self {
diff --git a/crates/reasoning_parser/src/parsers/glm45.rs b/crates/reasoning_parser/src/parsers/glm45.rs
index 450e7b208e..dcc4128c64 100644
--- a/crates/reasoning_parser/src/parsers/glm45.rs
+++ b/crates/reasoning_parser/src/parsers/glm45.rs
@@ -3,7 +3,7 @@
use crate::{
parsers::BaseReasoningParser,
- traits::{ParseError, ParserConfig, ParserResult, ReasoningParser, DEFAULT_MAX_BUFFER_SIZE},
+ traits::{ParseError, ParserConfig, ParserResult, ReasoningParser},
};
/// GLM45 reasoning parser.
@@ -20,9 +20,7 @@ impl Glm45Parser {
let config = ParserConfig {
think_start_token: "".to_string(),
think_end_token: "".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
- always_in_reasoning: false,
+ ..Default::default()
};
Self {
diff --git a/crates/reasoning_parser/src/parsers/kimi.rs b/crates/reasoning_parser/src/parsers/kimi.rs
index 0894ed675a..441f697f4e 100644
--- a/crates/reasoning_parser/src/parsers/kimi.rs
+++ b/crates/reasoning_parser/src/parsers/kimi.rs
@@ -3,7 +3,7 @@
use crate::{
parsers::BaseReasoningParser,
- traits::{ParseError, ParserConfig, ParserResult, ReasoningParser, DEFAULT_MAX_BUFFER_SIZE},
+ traits::{ParseError, ParserConfig, ParserResult, ReasoningParser},
};
/// Kimi reasoning parser.
@@ -20,9 +20,7 @@ impl KimiParser {
let config = ParserConfig {
think_start_token: "◁think▷".to_string(),
think_end_token: "◁/think▷".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
- always_in_reasoning: false,
+ ..Default::default()
};
Self {
diff --git a/crates/reasoning_parser/src/parsers/minimax.rs b/crates/reasoning_parser/src/parsers/minimax.rs
index 7687d0bdfd..d4cd88e51e 100644
--- a/crates/reasoning_parser/src/parsers/minimax.rs
+++ b/crates/reasoning_parser/src/parsers/minimax.rs
@@ -4,7 +4,7 @@
use crate::{
parsers::BaseReasoningParser,
- traits::{ParseError, ParserConfig, ParserResult, ReasoningParser, DEFAULT_MAX_BUFFER_SIZE},
+ traits::{ParseError, ParserConfig, ParserResult, ReasoningParser},
};
/// MiniMax M2 reasoning parser.
@@ -21,9 +21,8 @@ impl MiniMaxParser {
let config = ParserConfig {
think_start_token: "".to_string(),
think_end_token: "".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
always_in_reasoning: true,
+ ..Default::default()
};
Self {
diff --git a/crates/reasoning_parser/src/parsers/nano_v3.rs b/crates/reasoning_parser/src/parsers/nano_v3.rs
index 40ce06b753..1dd2e03266 100644
--- a/crates/reasoning_parser/src/parsers/nano_v3.rs
+++ b/crates/reasoning_parser/src/parsers/nano_v3.rs
@@ -10,7 +10,7 @@
use crate::{
parsers::BaseReasoningParser,
- traits::{ParseError, ParserConfig, ParserResult, ReasoningParser, DEFAULT_MAX_BUFFER_SIZE},
+ traits::{ParseError, ParserConfig, ParserResult, ReasoningParser},
};
/// NanoV3 / Nemotron reasoning parser.
@@ -24,9 +24,7 @@ impl NanoV3Parser {
let config = ParserConfig {
think_start_token: "".to_string(),
think_end_token: "".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
- always_in_reasoning: false,
+ ..Default::default()
};
Self {
diff --git a/crates/reasoning_parser/src/parsers/qwen3.rs b/crates/reasoning_parser/src/parsers/qwen3.rs
index bdd283c024..3ed0273098 100644
--- a/crates/reasoning_parser/src/parsers/qwen3.rs
+++ b/crates/reasoning_parser/src/parsers/qwen3.rs
@@ -4,7 +4,7 @@
use crate::{
parsers::BaseReasoningParser,
- traits::{ParseError, ParserConfig, ParserResult, ReasoningParser, DEFAULT_MAX_BUFFER_SIZE},
+ traits::{ParseError, ParserConfig, ParserResult, ReasoningParser},
};
/// Qwen3 reasoning parser.
@@ -21,9 +21,7 @@ impl Qwen3Parser {
let config = ParserConfig {
think_start_token: "".to_string(),
think_end_token: "".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
- always_in_reasoning: false,
+ ..Default::default()
};
Self {
@@ -84,9 +82,8 @@ impl QwenThinkingParser {
let config = ParserConfig {
think_start_token: "".to_string(),
think_end_token: "".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
always_in_reasoning: true,
+ ..Default::default()
};
Self {
diff --git a/crates/reasoning_parser/src/parsers/step3.rs b/crates/reasoning_parser/src/parsers/step3.rs
index 009b740dc3..b3b153c27e 100644
--- a/crates/reasoning_parser/src/parsers/step3.rs
+++ b/crates/reasoning_parser/src/parsers/step3.rs
@@ -3,7 +3,7 @@
use crate::{
parsers::BaseReasoningParser,
- traits::{ParseError, ParserConfig, ParserResult, ReasoningParser, DEFAULT_MAX_BUFFER_SIZE},
+ traits::{ParseError, ParserConfig, ParserResult, ReasoningParser},
};
/// Step3 reasoning parser.
@@ -20,9 +20,8 @@ impl Step3Parser {
let config = ParserConfig {
think_start_token: "".to_string(),
think_end_token: "".to_string(),
- stream_reasoning: true,
- max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
always_in_reasoning: true,
+ ..Default::default()
};
Self {
diff --git a/crates/reasoning_parser/src/traits.rs b/crates/reasoning_parser/src/traits.rs
index 47689936c6..11aa21c840 100644
--- a/crates/reasoning_parser/src/traits.rs
+++ b/crates/reasoning_parser/src/traits.rs
@@ -127,6 +127,13 @@ pub struct ParserConfig {
/// For models with a template thinking toggle, this should be `false` —
/// the runtime will call `mark_reasoning_started()` when appropriate.
pub always_in_reasoning: bool,
+
+ /// Tokens that mark the start of a tool-call section in the model's output.
+ ///
+ /// When the model skips the reasoning end token and jumps straight to
+ /// tool calls, the reasoning parser uses these markers to stop consuming
+ /// content as reasoning and return it as normal text for the tool parser.
+ pub tool_section_start_markers: Vec,
}
impl Default for ParserConfig {
@@ -136,7 +143,8 @@ impl Default for ParserConfig {
think_end_token: "".to_string(),
stream_reasoning: true,
max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
- always_in_reasoning: false, // Default to false (explicit reasoning)
+ always_in_reasoning: false,
+ tool_section_start_markers: Vec::new(),
}
}
}
diff --git a/crates/tool_parser/src/parsers/kimik2.rs b/crates/tool_parser/src/parsers/kimik2.rs
index 18e2f3e50a..491b526c7b 100644
--- a/crates/tool_parser/src/parsers/kimik2.rs
+++ b/crates/tool_parser/src/parsers/kimik2.rs
@@ -57,16 +57,15 @@ impl KimiK2Parser {
reason = "regex patterns are compile-time string literals"
)]
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\|>";
+ // (?s) for multi-line JSON args. Delimiter group is optional (trailing ?) to
+ // be lenient with models that omit it between the ID and JSON body.
+ let tool_call_pattern = r"(?s)<\|tool_call_begin\|>\s*(?P[\w\.]+:\d+)\s*(?:<\|tool_call_argument_begin\|>\s*|<\|func_start\|>\s*)?(?P\{.*?\})\s*(?:<\|tool_call_end\|>|<\|func_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_pattern = r"(?s)<\|tool_call_begin\|>\s*(?P[\w\.]+:\d+)\s*(?:<\|tool_call_argument_begin\|>\s*|<\|func_start\|>\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 end_pattern = r"(?s)<\|tool_call_begin\|>.*?(?:<\|tool_call_end\|>|<\|func_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"
@@ -181,7 +180,11 @@ impl ToolParser for KimiK2Parser {
// 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|>"] {
+ for e_token in [
+ "<|tool_calls_section_end|>",
+ "<|tool_call_end|>",
+ "<|func_end|>",
+ ] {
normal_text = normal_text.replace(e_token, "");
}
return Ok(StreamingParseResult {
@@ -242,13 +245,18 @@ impl ToolParser for KimiK2Parser {
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
- };
+ let end_pos = [
+ argument_diff.find("<|tool_call_end|>"),
+ argument_diff.find("<|func_end|>"),
+ ]
+ .into_iter()
+ .flatten()
+ .min();
+ let parsed_args_diff = if let Some(pos) = end_pos {
+ &argument_diff[..pos]
+ } else {
+ argument_diff
+ };
if !parsed_args_diff.is_empty() {
calls.push(ToolCallItem {
@@ -264,9 +272,14 @@ impl ToolParser for KimiK2Parser {
}
}
- // Check completeness - split by end token first
- let parsed_args = if let Some(pos) = function_args.find("<|tool_call_end|>")
- {
+ let end_pos2 = [
+ function_args.find("<|tool_call_end|>"),
+ function_args.find("<|func_end|>"),
+ ]
+ .into_iter()
+ .flatten()
+ .min();
+ let parsed_args = if let Some(pos) = end_pos2 {
&function_args[..pos]
} else {
function_args
diff --git a/model_gateway/src/routers/grpc/regular/processor.rs b/model_gateway/src/routers/grpc/regular/processor.rs
index a9f927dde0..8d6239df88 100644
--- a/model_gateway/src/routers/grpc/regular/processor.rs
+++ b/model_gateway/src/routers/grpc/regular/processor.rs
@@ -148,7 +148,20 @@ impl ResponseProcessor {
_ => false,
};
- if used_json_schema {
+ let has_native_parser = self
+ .configured_tool_parser
+ .as_deref()
+ .is_some_and(|p| p != "json");
+
+ if has_native_parser && tool_parser_available {
+ (tool_calls, processed_text) = self
+ .parse_tool_calls(
+ &processed_text,
+ &original_request.model,
+ history_tool_calls_count,
+ )
+ .await;
+ } else if used_json_schema {
(tool_calls, processed_text) = utils::parse_json_schema_response(
&processed_text,
original_request.tool_choice.as_ref(),
@@ -621,7 +634,22 @@ impl ResponseProcessor {
Some(messages::ToolChoice::Tool { .. } | messages::ToolChoice::Any { .. })
);
- if used_json_schema {
+ let has_native_parser = self
+ .configured_tool_parser
+ .as_deref()
+ .is_some_and(|p| p != "json");
+
+ if has_native_parser && tool_parser_available {
+ (tool_calls, processed_text) = self
+ .parse_tool_calls(
+ &processed_text,
+ &messages_request.model,
+ utils::message_utils::get_history_tool_calls_count_messages(
+ &messages_request,
+ ),
+ )
+ .await;
+ } else if used_json_schema {
// Bridge Messages ToolChoice to Chat ToolChoice for reuse
let chat_tool_choice = messages_request
.tool_choice
diff --git a/model_gateway/src/routers/grpc/regular/streaming.rs b/model_gateway/src/routers/grpc/regular/streaming.rs
index a0e391cd1a..27f958d348 100644
--- a/model_gateway/src/routers/grpc/regular/streaming.rs
+++ b/model_gateway/src/routers/grpc/regular/streaming.rs
@@ -377,8 +377,12 @@ impl StreamingProcessor {
&& tool_choice_enabled
&& (tool_parser_available || used_json_schema)
{
- let tool_chunks = if is_specific_function {
- // Handle specific function case - emit tool call deltas with arguments
+ let force_native_parser = self
+ .configured_tool_parser
+ .as_deref()
+ .is_some_and(|p| p != "json")
+ && tool_parser_available;
+ let tool_chunks = if is_specific_function && !force_native_parser {
Self::process_specific_function_stream(
&delta,
index,
@@ -391,7 +395,6 @@ impl StreamingProcessor {
history_tool_calls_count,
)
} else {
- // Use incremental parser for regular/required modes
self.process_tool_calls_stream(
&delta,
index,
@@ -403,7 +406,7 @@ impl StreamingProcessor {
created,
system_fingerprint,
history_tool_calls_count,
- used_json_schema,
+ used_json_schema && !force_native_parser,
)
.await
};
@@ -1636,10 +1639,16 @@ impl StreamingProcessor {
.map(message_utils::extract_chat_tools)
.unwrap_or_default();
+ let force_native_parser = self
+ .configured_tool_parser
+ .as_deref()
+ .is_some_and(|p| p != "json")
+ && tool_parser_available;
+
// Create fresh streaming tool parser (not pooled — streaming parsers maintain state)
let mut streaming_tool_parser: Option> =
if has_tools && tool_choice_enabled && (tool_parser_available || used_json_schema) {
- let parser_name = if used_json_schema {
+ let parser_name = if used_json_schema && !force_native_parser {
Some("json")
} else {
self.configured_tool_parser.as_deref()
@@ -1753,8 +1762,7 @@ impl StreamingProcessor {
// Tool call handling: incremental streaming parser
if !in_reasoning && streaming_tool_parser.is_some() {
- if is_specific_function {
- // Specific function: entire output is arguments for one tool
+ if is_specific_function && !force_native_parser {
if !has_tool_calls {
has_tool_calls = true;
// Close text block if open before starting tool block