[Rust Frontend] Add MiMo V2.5 parser support (#57933)

Co-authored-by: Codex <noreply@openai.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Sep 22, 2026 at 12:27 UTC ff3c9cba3f459085efdac7e8b7fed35bdb0151a2
13 files changed +251 -36
rust/src/chat/src/lib.rs
+2 -2
@@ -396,7 +396,7 @@ mod tests {
396 )
397 .unwrap_err();
398
399 - expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, granite4, hermes, hy_v3, hy_v4, inkling, internlm, kimi_k2, kimi_k3, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
399 + expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, granite4, hermes, hy_v3, hy_v4, inkling, internlm, kimi_k2, kimi_k3, llama3_json, llama4_json, mimo, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
400 }
401
402 #[test]
@@ -407,6 +407,6 @@ mod tests {
407 )
408 .unwrap_err();
409
410 - expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, hy_v3, hy_v4, inkling, kimi, kimi_k2, kimi_k3, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
410 + expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, hy_v3, hy_v4, inkling, kimi, kimi_k2, kimi_k3, mimo, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
411 }
412 }
rust/src/chat/src/output/default/structural_tag.rs
+30 -1
@@ -52,7 +52,9 @@ pub(super) fn apply_structural_tag_constraint(
52 builder,
53 &tools,
54 tool_choice,
55 - StructuralTagOptions::default().with_reasoning(false),
55 + StructuralTagOptions::default()
56 + .with_reasoning(false)
57 + .with_parallel_tool_calls(request.parallel_tool_calls()),
58 )
59 .and_then(|tag| tag.to_json_string())
60 .map_err(|error| Error::StructuralTag {
@@ -152,6 +154,33 @@ mod tests {
154 .expect("structured outputs should be set")
155 }
156
157 + #[test]
158 + fn tool_grammar_honors_parallel_call_policy() {
159 + for parallel in [false, true] {
160 + let tools = vec![chat_tool("lookup", Some(true))];
161 + let mut request = ChatRequest {
162 + tool_context: ResolvedToolContext::new(
163 + &[],
164 + tools,
165 + Some(ChatToolChoice::Auto),
166 + parallel,
167 + )
168 + .unwrap(),
169 + ..ChatRequest::for_test()
170 + };
171 + let parser = qwen3_coder_parser(request.tools());
172 + apply_structural_tag_constraint(
173 + &mut request,
174 + parser.structural_tag_builder(),
175 + ToolStrictLevel::Auto,
176 + )
177 + .unwrap();
178 + let tag = structural_tag_value(&request);
179 + assert_eq!(tag["format"]["type"], "triggered_tags");
180 + assert_eq!(tag["format"]["stop_after_first"], !parallel);
181 + }
182 + }
183 +
184 #[test]
185 fn auto_strict_tool_choice_builds_structural_tag() {
186 let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]);
rust/src/chat/src/parser/reasoning/mod.rs
+7 -4
@@ -8,10 +8,10 @@ use std::sync::{Arc, LazyLock};
8 pub use vllm_parser::reasoning::{
9 CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DeepSeekV3ReasoningParser,
10 DeepSeekV4ReasoningParser, DeepSeekV41ReasoningParser, Glm45ReasoningParser,
11 - Glm47ReasoningParser, KimiK2ReasoningParser, KimiReasoningParser, MiniMaxM2ReasoningParser,
12 - MiniMaxM3ReasoningParser, NemotronV3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta,
13 - ReasoningError, ReasoningParser, SeedOssReasoningParser, Step3ReasoningParser,
14 - Step3p5ReasoningParser,
11 + Glm47ReasoningParser, KimiK2ReasoningParser, KimiReasoningParser, MiMoReasoningParser,
12 + MiniMaxM2ReasoningParser, MiniMaxM3ReasoningParser, NemotronV3ReasoningParser,
13 + Qwen3ReasoningParser, ReasoningDelta, ReasoningError, ReasoningParser, SeedOssReasoningParser,
14 + Step3ReasoningParser, Step3p5ReasoningParser,
15 };
16 use vllm_tokenizer::DynTokenizer;
17
@@ -28,6 +28,7 @@ pub mod names {
28 pub const GLM47: &str = "glm47";
29 pub const KIMI: &str = "kimi";
30 pub const KIMI_K2: &str = "kimi_k2";
31 + pub const MIMO: &str = "mimo";
32 pub const MINIMAX_M2: &str = "minimax_m2";
33 pub const MINIMAX_M3: &str = "minimax_m3";
34 pub const NEMOTRON_V3: &str = "nemotron_v3";
@@ -69,6 +70,7 @@ impl ReasoningParserFactory {
70 .register_parser::<Glm47ReasoningParser>(names::GLM47)
71 .register_parser::<KimiReasoningParser>(names::KIMI)
72 .register_parser::<KimiK2ReasoningParser>(names::KIMI_K2)
73 + .register_parser::<MiMoReasoningParser>(names::MIMO)
74 .register_parser::<MiniMaxM2ReasoningParser>(names::MINIMAX_M2)
75 .register_parser::<MiniMaxM3ReasoningParser>(names::MINIMAX_M3)
76 .register_parser::<NemotronV3ReasoningParser>(names::NEMOTRON_V3)
@@ -84,6 +86,7 @@ impl ReasoningParserFactory {
86 .register_pattern("deepseek_v4", names::DEEPSEEK_V4)
87 .register_pattern("deepseek-v3", names::DEEPSEEK_V3)
88 .register_pattern("qwq", names::DEEPSEEK_R1)
89 + .register_pattern("mimo-v2", names::MIMO)
90 .register_pattern("qwen3", names::QWEN3)
91 .register_pattern("glm-5", names::GLM47)
92 .register_pattern("glm-4.7", names::GLM47)
rust/src/chat/src/parser/tool/mod.rs
+4 -1
@@ -8,7 +8,7 @@ use std::sync::{Arc, LazyLock};
8 pub use vllm_parser::tool::{
9 DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser,
10 DeepSeekV41ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser,
11 - HermesToolParser, Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser,
11 + HermesToolParser, Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, MiMoToolParser,
12 MinimaxM2ToolParser, MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser,
13 Qwen3CoderToolParser, Qwen3XmlToolParser, SeedOssToolParser, ToolParser, ToolParserError,
14 };
@@ -33,6 +33,7 @@ pub mod names {
33 pub const KIMI_K2: &str = "kimi_k2";
34 pub const LLAMA3_JSON: &str = "llama3_json";
35 pub const LLAMA4_JSON: &str = "llama4_json";
36 + pub const MIMO: &str = "mimo";
37 pub const MINIMAX_M2: &str = "minimax_m2";
38 pub const MINIMAX_M3: &str = "minimax_m3";
39 pub const MISTRAL: &str = "mistral";
@@ -76,6 +77,7 @@ impl ToolParserFactory {
77 .register_parser::<KimiK2ToolParser>(names::KIMI_K2)
78 .register_parser::<Llama3JsonToolParser>(names::LLAMA3_JSON)
79 .register_parser::<Llama3JsonToolParser>(names::LLAMA4_JSON)
80 + .register_parser::<MiMoToolParser>(names::MIMO)
81 .register_parser::<MinimaxM2ToolParser>(names::MINIMAX_M2)
82 .register_parser::<MinimaxM3ToolParser>(names::MINIMAX_M3)
83 .register_parser::<MistralToolParser>(names::MISTRAL)
@@ -87,6 +89,7 @@ impl ToolParserFactory {
89 factory
90 .register_pattern("mistral-", names::MISTRAL)
91 .register_pattern("mixtral-", names::MISTRAL)
92 + .register_pattern("mimo-v2", names::MIMO)
93 .register_pattern("qwen3-coder", names::QWEN3_CODER)
94 .register_pattern("qwen3.5", names::QWEN3_CODER)
95 .register_pattern("qwq", names::HERMES)
rust/src/chat/src/parser/tool/tests.rs
+19
@@ -240,3 +240,22 @@ fn factory_new_registers_phi4_mini_json_by_name() {
240 assert!(factory.contains(names::PHI4_MINI_JSON));
241 factory.create(names::PHI4_MINI_JSON, &[]).unwrap();
242 }
243 +
244 +#[test]
245 +fn factory_parses_mimo_parameter_tags() {
246 + let factory = ToolParserFactory::new();
247 + for model in ["XiaomiMiMo/MiMo-V2.5", "XiaomiMiMo/MiMo-V2.5-Pro"] {
248 + assert_eq!(factory.resolve_name_for_model(model), Some(names::MIMO));
249 + let mut parser = factory.create_for_model(model, &[]).unwrap();
250 + let mut output = ToolParserOutput::default();
251 + parser.parse_into("<tool_call>\n<function=lookup>\n<parameter=query>\n杭州\n</parameter>\n</function>\n</tool_call>", &mut output).unwrap();
252 + output.append(parser.finish().unwrap());
253 + let output = output.coalesce();
254 + assert_eq!(output.calls().len(), 1);
255 + assert_eq!(output.calls()[0].name.as_deref(), Some("lookup"));
256 + assert_eq!(
257 + serde_json::from_str::<serde_json::Value>(&output.calls()[0].arguments).unwrap(),
258 + serde_json::json!({"query":"\n杭州\n"})
259 + );
260 + }
261 +}
rust/src/chat/tests/roundtrip.rs
+14
@@ -141,6 +141,19 @@ impl RoundtripCase {
141 }
142 }
143
144 + /// MiMo V2.6 uses bare reasoning and Qwen Coder tool tags.
145 + fn mimo_v26() -> Self {
146 + Self {
147 + model_id: "XiaomiMiMo/MiMo-V2.6-Flash-RL",
148 + assistant_stop_suffix: "<|im_end|>",
149 + tool_call_parser: ParserSelection::Auto,
150 + reasoning_parser: ParserSelection::Auto,
151 + thinking_behavior: ThinkingBehavior::Toggleable { default: true },
152 + json_fmt: compact_json_fmt(),
153 + sort_json_keys: false,
154 + }
155 + }
156 +
157 /// MiniMax M2.5 XML invoke format with `<think>` reasoning tags.
158 fn minimax_m25() -> Self {
159 Self {
@@ -395,6 +408,7 @@ macro_rules! roundtrip_tests {
408 roundtrip_tests! {
409 qwen3 => [reasoning_and_content, tool_call_mix],
410 qwen35 => [reasoning_and_content, tool_call_mix],
411 + mimo_v26 => [reasoning_and_content, tool_call_mix],
412 minimax_m25 => [reasoning_and_content, tool_call_mix],
413 minimax_m3 => [reasoning_and_content, tool_call_mix],
414 deepseek_v4 => [reasoning_and_content, tool_call_mix],
rust/src/parser/src/reasoning/mod.rs
+2
@@ -61,6 +61,8 @@ pub type Glm47ReasoningParser = DeepSeekV3ReasoningParser;
61 // TODO: kimi k2 may implicitly end reasoning by starting a tool call section
62 // using <|tool_calls_section_begin|>, we should support that.
63 pub type KimiK2ReasoningParser = DeepSeekV3ReasoningParser;
64 +/// MiMo V2 uses bare `<think>...</think>` delimiters.
65 +pub type MiMoReasoningParser = DeepSeekV3ReasoningParser;
66 /// MiniMax M2 currently shares the Qwen3 reasoning parser.
67 pub type MiniMaxM2ReasoningParser = Qwen3ReasoningParser;
68
rust/src/parser/src/tool/mimo.rs new
+91
@@ -0,0 +1,91 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use super::qwen_coder::{Qwen3CoderToolParser, QwenCoderConfig};
5 +use super::{Result, StructuralTagBuilder, Tool, ToolParser, ToolParserOutput};
6 +
7 +const MIMO_CONFIG: QwenCoderConfig = QwenCoderConfig {
8 + parser_name: "MiMo",
9 + tool_call_start: "<tool_call>",
10 + first_tool_call_start: "<tool_call>",
11 + next_tool_call_start: "<tool_call>",
12 + tool_call_end: "</tool_call>",
13 + trim_parameter_newlines: false,
14 +};
15 +
16 +/// MiMo V2 uses Qwen Coder's XML grammar with unframed content and parameter values.
17 +pub struct MiMoToolParser {
18 + inner: Qwen3CoderToolParser,
19 +}
20 +
21 +impl ToolParser for MiMoToolParser {
22 + fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>> {
23 + Ok(Box::new(Self {
24 + inner: Qwen3CoderToolParser::with_config(tools, MIMO_CONFIG),
25 + }))
26 + }
27 +
28 + fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
29 + self.inner.structural_tag_builder()
30 + }
31 +
32 + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
33 + self.inner.parse_into(chunk, output)
34 + }
35 +
36 + fn finish(&mut self) -> Result<ToolParserOutput> {
37 + self.inner.finish()
38 + }
39 +
40 + fn reset(&mut self) -> String {
41 + self.inner.reset()
42 + }
43 +}
44 +
45 +#[cfg(test)]
46 +mod tests {
47 + use super::*;
48 + use crate::tool::ToolParserTestExt as _;
49 + use crate::tool::test_utils::{collect_stream, split_by_chars};
50 + use crate::tool::tests::assert_tool_framing_preserves_body_whitespace;
51 + use serde_json::{Value, json};
52 +
53 + #[test]
54 + fn bare_tool_boundaries_preserve_body_whitespace() {
55 + assert_tool_framing_preserves_body_whitespace::<MiMoToolParser>(
56 + "",
57 + "<tool_call>\n<function=get_weather>\n</function>\n</tool_call>",
58 + );
59 + }
60 +
61 + #[test]
62 + fn adjacent_calls_preserve_parameter_whitespace_and_scalar_types_across_chunks() {
63 + let tools = [Tool {
64 + name: "convert".into(),
65 + description: None,
66 + strict: None,
67 + parameters: json!({"type": "object", "properties": {
68 + "flag": {"type": "boolean"}, "empty": {"type": ["string", "null"]},
69 + "text": {"type": "string"}, "literal": {"type": "string"}, "payload": {"type": "object"}
70 + }}),
71 + }];
72 + let call = "<tool_call>\n<function=convert>\n<parameter=flag>True</parameter>\n<parameter=empty>None</parameter>\n<parameter=text>\n value\n</parameter>\n<parameter=literal>None</parameter>\n<parameter=payload>{\"city\":\"杭州\"}</parameter>\n</function>\n</tool_call>";
73 + let input = format!("answer\n\n{call}{call}");
74 + let expected = MiMoToolParser::create(&tools).unwrap().parse_complete(&input).unwrap();
75 + assert_eq!(expected.normal_text(), "answer\n\n");
76 + assert_eq!(expected.calls().len(), 2);
77 + for call in expected.calls() {
78 + assert_eq!(
79 + serde_json::from_str::<Value>(&call.arguments).unwrap(),
80 + json!({"flag": true, "empty": null, "text": "\n value\n", "literal": "None", "payload": {"city": "杭州"}})
81 + );
82 + }
83 + for size in 1..=input.chars().count() {
84 + let mut parser = MiMoToolParser::create(&tools).unwrap();
85 + assert_eq!(
86 + collect_stream(parser.as_mut(), &split_by_chars(&input, size)),
87 + expected
88 + );
89 + }
90 + }
91 +}
rust/src/parser/src/tool/mod.rs
+2
@@ -11,6 +11,7 @@ mod glm_xml;
11 mod hy;
12 pub(crate) mod json;
13 mod kimi_k2;
14 +mod mimo;
15 mod minimax_m2;
16 mod minimax_m3;
17 mod parameters;
@@ -30,6 +31,7 @@ pub use json::{
31 MistralToolParser, Phi4MiniJsonToolParser, Qwen3XmlToolParser,
32 };
33 pub use kimi_k2::KimiK2ToolParser;
34 +pub use mimo::MiMoToolParser;
35 pub use minimax_m2::MinimaxM2ToolParser;
36 pub use minimax_m3::MinimaxM3ToolParser;
37 pub use qwen_coder::Qwen3CoderToolParser;
rust/src/parser/src/tool/parameters.rs
+18 -9
@@ -284,13 +284,19 @@ impl JsonParamType {
284 }
285 }
286
287 +/// Recognize JSON and Python null spellings emitted by chat templates.
288 +fn is_null_literal(value: &str) -> bool {
289 + value.eq_ignore_ascii_case("null") || value.eq_ignore_ascii_case("none")
290 +}
291 +
292 /// Convert one parameter input to a normalized JSON value.
293 fn convert_with_optional_schema(param_type: Option<&JsonParamType>, input: &ParamInput) -> Value {
294 // Coerce the literal text `null` to JSON null, except for `string`-typed
295 // params, where it must stay the string "null": a model emitting the literal
296 // text "null" for a string field means the string, not a missing value.
297 + // Python-style `None` follows the same schema coercion rules.
298 if let ParamInput::Text(value) = input
293 - && value.eq_ignore_ascii_case("null")
299 + && is_null_literal(value)
300 && param_type != Some(&JsonParamType::String)
301 {
302 return Value::Null;
@@ -333,7 +339,7 @@ fn try_convert_text_value(param_type: &JsonParamType, value: &str) -> Option<Val
339 // For composite types with string input, simply interpret the string as JSON.
340 serde_json::from_str(value).ok()
341 }
336 - JsonParamType::Null => value.eq_ignore_ascii_case("null").then_some(Value::Null),
342 + JsonParamType::Null => is_null_literal(value).then_some(Value::Null),
343 JsonParamType::OneOf(types) => {
344 types.iter().find_map(|param_type| try_convert_text_value(param_type, value))
345 }
@@ -710,11 +716,12 @@ mod tests {
716 }
717 }));
718
713 - assert_eq!(params.convert("name", text("null")), json!("null"));
714 - assert_eq!(params.convert("name", text("NULL")), json!("NULL"));
715 - // Non-string and schema-less params are unchanged: "null" -> null.
716 - assert_eq!(params.convert("count", text("null")), json!(null));
717 - assert_eq!(params.convert("anything", text("null")), json!(null));
719 + for literal in ["null", "NULL", "None", "none", "NONE"] {
720 + assert_eq!(params.convert("name", text(literal)), json!(literal));
721 + // Non-string and schema-less params share the same null coercion.
722 + assert_eq!(params.convert("count", text(literal)), json!(null));
723 + assert_eq!(params.convert("anything", text(literal)), json!(null));
724 + }
725 }
726
727 #[test]
@@ -731,9 +738,11 @@ mod tests {
738 }
739 }));
740
734 - assert_eq!(params.convert("mode", text("null")), json!(null));
741 + for literal in ["null", "NULL", "None", "none", "NONE"] {
742 + assert_eq!(params.convert("mode", text(literal)), json!(null));
743 + assert_eq!(params.convert("color", text(literal)), json!(literal));
744 + }
745 assert_eq!(params.convert("mode", text("auto")), json!("auto"));
736 - assert_eq!(params.convert("color", text("null")), json!("null"));
746 }
747
748 #[test]
rust/src/parser/src/tool/qwen_coder.rs
+27 -14
@@ -21,9 +21,9 @@ const PARAMETER_END: &str = "</parameter>";
21
22 /// Model-specific configuration for the shared Qwen Coder grammar.
23 ///
24 -/// Only the tool-call wrapper tokens vary across models that reuse this
25 -/// grammar; the inner `<function=...>` / `<parameter=...>` tags are always
26 -/// byte-identical. Seed-OSS, for example, wraps the same body in
24 +/// Models may vary the tool-call wrapper and parameter newline framing;
25 +/// the inner `<function=...>` / `<parameter=...>` tags are byte-identical.
26 +/// Seed-OSS, for example, wraps the same body in
27 /// `<seed:tool_call>` / `</seed:tool_call>`.
28 #[derive(Debug, Clone, Copy)]
29 pub(crate) struct QwenCoderConfig {
@@ -35,6 +35,8 @@ pub(crate) struct QwenCoderConfig {
35 pub(crate) next_tool_call_start: &'static str,
36 /// Marker that closes a tool-call block.
37 pub(crate) tool_call_end: &'static str,
38 + /// Consume one template-inserted newline at each parameter value boundary.
39 + pub(crate) trim_parameter_newlines: bool,
40 }
41
42 const QWEN_CODER_CONFIG: QwenCoderConfig = QwenCoderConfig {
@@ -43,6 +45,7 @@ const QWEN_CODER_CONFIG: QwenCoderConfig = QwenCoderConfig {
45 first_tool_call_start: "\n\n<tool_call>",
46 next_tool_call_start: "\n<tool_call>",
47 tool_call_end: TOOL_CALL_END,
48 + trim_parameter_newlines: true,
49 };
50
51 type QwenCoderInput<'i> = Partial<&'i str>;
@@ -200,7 +203,7 @@ fn parse_next_qwen_coder_event(
203 match mode {
204 QwenCoderMode::Text => parse_text_event(input, config, after_tool_call),
205 QwenCoderMode::ToolCall { end_marker_scan } => {
203 - tool_call_event(input, end_marker_scan, config.tool_call_end)
206 + tool_call_event(input, end_marker_scan, config)
207 }
208 }
209 }
@@ -231,26 +234,26 @@ fn parse_text_event(
234 fn tool_call_event(
235 input: &mut QwenCoderInput<'_>,
236 end_marker_scan: &mut MarkerScanState,
234 - tool_call_end: &'static str,
237 + config: QwenCoderConfig,
238 ) -> ModalResult<QwenCoderEvent> {
239 let (body,) = seq!(
240 _: ws0,
238 - take_until_marker(tool_call_end, end_marker_scan),
239 - _: literal(tool_call_end),
241 + take_until_marker(config.tool_call_end, end_marker_scan),
242 + _: literal(config.tool_call_end),
243 )
244 .parse_next(input)?;
245
243 - parse_tool_call_body(body)
246 + parse_tool_call_body(body, config.trim_parameter_newlines)
247 }
248
249 /// Parse a Qwen Coder function block.
247 -fn function_event(input: &mut &str) -> ModalResult<QwenCoderEvent> {
250 +fn function_event(input: &mut &str, trim_parameter_newlines: bool) -> ModalResult<QwenCoderEvent> {
251 let (name, raw_params) = seq!(
252 _: literal(FUNCTION_START),
253 take_until(1.., ">"),
254 _: ">",
255 _: ws0,
253 - repeat(0.., terminated(parameter, ws0)),
256 + repeat(0.., terminated(|input: &mut &str| parameter(input, trim_parameter_newlines), ws0)),
257 _: literal(FUNCTION_END),
258 )
259 .parse_next(input)?;
@@ -262,23 +265,33 @@ fn function_event(input: &mut &str) -> ModalResult<QwenCoderEvent> {
265 }
266
267 /// Parse a Qwen Coder parameter block.
265 -fn parameter(input: &mut &str) -> ModalResult<(String, String)> {
268 +fn parameter(input: &mut &str, trim_newlines: bool) -> ModalResult<(String, String)> {
269 let (name, value) = seq!(
270 _: literal(PARAMETER_START),
271 take_until(1.., ">"),
272 _: ">",
270 - take_until(0.., PARAMETER_END).map(trim_one_wrapping_newline),
273 + take_until(0.., PARAMETER_END),
274 _: literal(PARAMETER_END),
275 )
276 .parse_next(input)?;
277
278 + let value = if trim_newlines {
279 + trim_one_wrapping_newline(value)
280 + } else {
281 + value
282 + };
283 Ok((name.to_string(), value.to_string()))
284 }
285
286 /// Parse a Qwen Coder tool-call body.
279 -fn parse_tool_call_body(body: &str) -> ModalResult<QwenCoderEvent> {
287 +fn parse_tool_call_body(body: &str, trim_parameter_newlines: bool) -> ModalResult<QwenCoderEvent> {
288 let mut input = body;
281 - delimited(ws0, function_event, (ws0, eof)).parse_next(&mut input)
289 + delimited(
290 + ws0,
291 + |input: &mut &str| function_event(input, trim_parameter_newlines),
292 + (ws0, eof),
293 + )
294 + .parse_next(&mut input)
295 }
296
297 /// Trim a single leading and trailing newline from a parameter value.
rust/src/parser/src/tool/seed_oss.rs
+1
@@ -10,6 +10,7 @@ const SEED_OSS_CONFIG: QwenCoderConfig = QwenCoderConfig {
10 first_tool_call_start: "\n\n<seed:tool_call>",
11 next_tool_call_start: "\n<seed:tool_call>",
12 tool_call_end: "</seed:tool_call>",
13 + trim_parameter_newlines: true,
14 };
15
16 /// Tool parser for Seed-OSS XML-style tool calls.
rust/src/text/src/backend/hf/model_files.rs
+34 -5
@@ -183,11 +183,7 @@ async fn resolve_remote_model_files(
183 download_if_present(&repo, model_id, &siblings, "video_preprocessor_config.json").await?;
184 let processor_config_path =
185 download_if_present(&repo, model_id, &siblings, "processor_config.json").await?;
186 - let chat_template_name = siblings
187 - .contains("chat_template.json")
188 - .then_some("chat_template.json")
189 - .or_else(|| siblings.contains("chat_template.jinja").then_some("chat_template.jinja"))
190 - .or_else(|| siblings.iter().copied().find(|name| name.ends_with(".jinja")));
186 + let chat_template_name = remote_chat_template_name(&siblings);
187 let chat_template_path = match chat_template_name {
188 Some(name) => Some(download_known_file(&repo, model_id, name).await?),
189 None => None,
@@ -445,6 +441,22 @@ pub(super) fn is_tiktoken_file(path: &std::path::Path) -> bool {
441 .is_some_and(|name| name == "tiktoken.model" || name.ends_with(".tiktoken"))
442 }
443
444 +/// Select a root-level template, keeping auxiliary tokenizer templates separate.
445 +fn remote_chat_template_name<'a>(
446 + siblings: &std::collections::BTreeSet<&'a str>,
447 +) -> Option<&'a str> {
448 + siblings
449 + .contains("chat_template.json")
450 + .then_some("chat_template.json")
451 + .or_else(|| siblings.contains("chat_template.jinja").then_some("chat_template.jinja"))
452 + .or_else(|| {
453 + siblings
454 + .iter()
455 + .copied()
456 + .find(|name| !name.contains('/') && name.ends_with(".jinja"))
457 + })
458 +}
459 +
460 /// Chat templates are sometimes stored as dedicated .jinja files rather than as
461 /// a fixed-name config entry, so we scan the cached model dir.
462 fn discover_chat_template_in_dir(dir: &std::path::Path) -> Option<PathBuf> {
@@ -475,6 +487,23 @@ mod tests {
487
488 use super::{ResolvedModelFiles, TokenizerSource};
489
490 + #[test]
491 + fn remote_chat_template_selection_stays_with_main_tokenizer() {
492 + let mut siblings = std::collections::BTreeSet::from([
493 + "audio_tokenizer/chat_template.jinja",
494 + "tokenizer_config.json",
495 + ]);
496 + assert_eq!(super::remote_chat_template_name(&siblings), None);
497 + for name in [
498 + "template.jinja",
499 + "chat_template.jinja",
500 + "chat_template.json",
501 + ] {
502 + siblings.insert(name);
503 + assert_eq!(super::remote_chat_template_name(&siblings), Some(name));
504 + }
505 + }
506 +
507 #[tokio::test]
508 async fn overrides_keep_configs_alive_across_clones_and_isolate_instances() {
509 let source = tempdir().unwrap();