[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();