From ff3c9cba3f459085efdac7e8b7fed35bdb0151a2 Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Tue, 22 Sep 2026 12:27:09 +0800 Subject: [PATCH] [Rust Frontend] Add MiMo V2.5 parser support (#57933) Co-authored-by: Codex Signed-off-by: Bugen Zhao --- rust/src/chat/src/lib.rs | 4 +- .../chat/src/output/default/structural_tag.rs | 31 ++++++- rust/src/chat/src/parser/reasoning/mod.rs | 11 ++- rust/src/chat/src/parser/tool/mod.rs | 5 +- rust/src/chat/src/parser/tool/tests.rs | 19 ++++ rust/src/chat/tests/roundtrip.rs | 14 +++ rust/src/parser/src/reasoning/mod.rs | 2 + rust/src/parser/src/tool/mimo.rs | 91 +++++++++++++++++++ rust/src/parser/src/tool/mod.rs | 2 + rust/src/parser/src/tool/parameters.rs | 27 ++++-- rust/src/parser/src/tool/qwen_coder.rs | 41 ++++++--- rust/src/parser/src/tool/seed_oss.rs | 1 + rust/src/text/src/backend/hf/model_files.rs | 39 +++++++- 13 files changed, 251 insertions(+), 36 deletions(-) create mode 100644 rust/src/parser/src/tool/mimo.rs diff --git a/rust/src/chat/src/lib.rs b/rust/src/chat/src/lib.rs index d1a34455d2..806554b5a5 100644 --- a/rust/src/chat/src/lib.rs +++ b/rust/src/chat/src/lib.rs @@ -396,7 +396,7 @@ mod tests { ) .unwrap_err(); - 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()); + 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()); } #[test] @@ -407,6 +407,6 @@ mod tests { ) .unwrap_err(); - 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()); + 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()); } } diff --git a/rust/src/chat/src/output/default/structural_tag.rs b/rust/src/chat/src/output/default/structural_tag.rs index 116cd53a4a..0a115d17df 100644 --- a/rust/src/chat/src/output/default/structural_tag.rs +++ b/rust/src/chat/src/output/default/structural_tag.rs @@ -52,7 +52,9 @@ pub(super) fn apply_structural_tag_constraint( builder, &tools, tool_choice, - StructuralTagOptions::default().with_reasoning(false), + StructuralTagOptions::default() + .with_reasoning(false) + .with_parallel_tool_calls(request.parallel_tool_calls()), ) .and_then(|tag| tag.to_json_string()) .map_err(|error| Error::StructuralTag { @@ -152,6 +154,33 @@ mod tests { .expect("structured outputs should be set") } + #[test] + fn tool_grammar_honors_parallel_call_policy() { + for parallel in [false, true] { + let tools = vec![chat_tool("lookup", Some(true))]; + let mut request = ChatRequest { + tool_context: ResolvedToolContext::new( + &[], + tools, + Some(ChatToolChoice::Auto), + parallel, + ) + .unwrap(), + ..ChatRequest::for_test() + }; + let parser = qwen3_coder_parser(request.tools()); + apply_structural_tag_constraint( + &mut request, + parser.structural_tag_builder(), + ToolStrictLevel::Auto, + ) + .unwrap(); + let tag = structural_tag_value(&request); + assert_eq!(tag["format"]["type"], "triggered_tags"); + assert_eq!(tag["format"]["stop_after_first"], !parallel); + } + } + #[test] fn auto_strict_tool_choice_builds_structural_tag() { let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]); diff --git a/rust/src/chat/src/parser/reasoning/mod.rs b/rust/src/chat/src/parser/reasoning/mod.rs index c283b3056f..9dfb4fdb90 100644 --- a/rust/src/chat/src/parser/reasoning/mod.rs +++ b/rust/src/chat/src/parser/reasoning/mod.rs @@ -8,10 +8,10 @@ use std::sync::{Arc, LazyLock}; pub use vllm_parser::reasoning::{ CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DeepSeekV3ReasoningParser, DeepSeekV4ReasoningParser, DeepSeekV41ReasoningParser, Glm45ReasoningParser, - Glm47ReasoningParser, KimiK2ReasoningParser, KimiReasoningParser, MiniMaxM2ReasoningParser, - MiniMaxM3ReasoningParser, NemotronV3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta, - ReasoningError, ReasoningParser, SeedOssReasoningParser, Step3ReasoningParser, - Step3p5ReasoningParser, + Glm47ReasoningParser, KimiK2ReasoningParser, KimiReasoningParser, MiMoReasoningParser, + MiniMaxM2ReasoningParser, MiniMaxM3ReasoningParser, NemotronV3ReasoningParser, + Qwen3ReasoningParser, ReasoningDelta, ReasoningError, ReasoningParser, SeedOssReasoningParser, + Step3ReasoningParser, Step3p5ReasoningParser, }; use vllm_tokenizer::DynTokenizer; @@ -28,6 +28,7 @@ pub mod names { pub const GLM47: &str = "glm47"; pub const KIMI: &str = "kimi"; pub const KIMI_K2: &str = "kimi_k2"; + pub const MIMO: &str = "mimo"; pub const MINIMAX_M2: &str = "minimax_m2"; pub const MINIMAX_M3: &str = "minimax_m3"; pub const NEMOTRON_V3: &str = "nemotron_v3"; @@ -69,6 +70,7 @@ impl ReasoningParserFactory { .register_parser::(names::GLM47) .register_parser::(names::KIMI) .register_parser::(names::KIMI_K2) + .register_parser::(names::MIMO) .register_parser::(names::MINIMAX_M2) .register_parser::(names::MINIMAX_M3) .register_parser::(names::NEMOTRON_V3) @@ -84,6 +86,7 @@ impl ReasoningParserFactory { .register_pattern("deepseek_v4", names::DEEPSEEK_V4) .register_pattern("deepseek-v3", names::DEEPSEEK_V3) .register_pattern("qwq", names::DEEPSEEK_R1) + .register_pattern("mimo-v2", names::MIMO) .register_pattern("qwen3", names::QWEN3) .register_pattern("glm-5", names::GLM47) .register_pattern("glm-4.7", names::GLM47) diff --git a/rust/src/chat/src/parser/tool/mod.rs b/rust/src/chat/src/parser/tool/mod.rs index 26a2ecc4c7..9f3ab4f516 100644 --- a/rust/src/chat/src/parser/tool/mod.rs +++ b/rust/src/chat/src/parser/tool/mod.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, LazyLock}; pub use vllm_parser::tool::{ DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser, DeepSeekV41ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser, - HermesToolParser, Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, + HermesToolParser, Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, MiMoToolParser, MinimaxM2ToolParser, MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser, Qwen3CoderToolParser, Qwen3XmlToolParser, SeedOssToolParser, ToolParser, ToolParserError, }; @@ -33,6 +33,7 @@ pub mod names { pub const KIMI_K2: &str = "kimi_k2"; pub const LLAMA3_JSON: &str = "llama3_json"; pub const LLAMA4_JSON: &str = "llama4_json"; + pub const MIMO: &str = "mimo"; pub const MINIMAX_M2: &str = "minimax_m2"; pub const MINIMAX_M3: &str = "minimax_m3"; pub const MISTRAL: &str = "mistral"; @@ -76,6 +77,7 @@ impl ToolParserFactory { .register_parser::(names::KIMI_K2) .register_parser::(names::LLAMA3_JSON) .register_parser::(names::LLAMA4_JSON) + .register_parser::(names::MIMO) .register_parser::(names::MINIMAX_M2) .register_parser::(names::MINIMAX_M3) .register_parser::(names::MISTRAL) @@ -87,6 +89,7 @@ impl ToolParserFactory { factory .register_pattern("mistral-", names::MISTRAL) .register_pattern("mixtral-", names::MISTRAL) + .register_pattern("mimo-v2", names::MIMO) .register_pattern("qwen3-coder", names::QWEN3_CODER) .register_pattern("qwen3.5", names::QWEN3_CODER) .register_pattern("qwq", names::HERMES) diff --git a/rust/src/chat/src/parser/tool/tests.rs b/rust/src/chat/src/parser/tool/tests.rs index 24984c0731..69022e550d 100644 --- a/rust/src/chat/src/parser/tool/tests.rs +++ b/rust/src/chat/src/parser/tool/tests.rs @@ -240,3 +240,22 @@ fn factory_new_registers_phi4_mini_json_by_name() { assert!(factory.contains(names::PHI4_MINI_JSON)); factory.create(names::PHI4_MINI_JSON, &[]).unwrap(); } + +#[test] +fn factory_parses_mimo_parameter_tags() { + let factory = ToolParserFactory::new(); + for model in ["XiaomiMiMo/MiMo-V2.5", "XiaomiMiMo/MiMo-V2.5-Pro"] { + assert_eq!(factory.resolve_name_for_model(model), Some(names::MIMO)); + let mut parser = factory.create_for_model(model, &[]).unwrap(); + let mut output = ToolParserOutput::default(); + parser.parse_into("\n\n\n杭州\n\n\n", &mut output).unwrap(); + output.append(parser.finish().unwrap()); + let output = output.coalesce(); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("lookup")); + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + serde_json::json!({"query":"\n杭州\n"}) + ); + } +} diff --git a/rust/src/chat/tests/roundtrip.rs b/rust/src/chat/tests/roundtrip.rs index 9cba9f0aa2..7f969264a0 100644 --- a/rust/src/chat/tests/roundtrip.rs +++ b/rust/src/chat/tests/roundtrip.rs @@ -141,6 +141,19 @@ impl RoundtripCase { } } + /// MiMo V2.6 uses bare reasoning and Qwen Coder tool tags. + fn mimo_v26() -> Self { + Self { + model_id: "XiaomiMiMo/MiMo-V2.6-Flash-RL", + assistant_stop_suffix: "<|im_end|>", + tool_call_parser: ParserSelection::Auto, + reasoning_parser: ParserSelection::Auto, + thinking_behavior: ThinkingBehavior::Toggleable { default: true }, + json_fmt: compact_json_fmt(), + sort_json_keys: false, + } + } + /// MiniMax M2.5 XML invoke format with `` reasoning tags. fn minimax_m25() -> Self { Self { @@ -395,6 +408,7 @@ macro_rules! roundtrip_tests { roundtrip_tests! { qwen3 => [reasoning_and_content, tool_call_mix], qwen35 => [reasoning_and_content, tool_call_mix], + mimo_v26 => [reasoning_and_content, tool_call_mix], minimax_m25 => [reasoning_and_content, tool_call_mix], minimax_m3 => [reasoning_and_content, tool_call_mix], deepseek_v4 => [reasoning_and_content, tool_call_mix], diff --git a/rust/src/parser/src/reasoning/mod.rs b/rust/src/parser/src/reasoning/mod.rs index 670695f7b0..057053d695 100644 --- a/rust/src/parser/src/reasoning/mod.rs +++ b/rust/src/parser/src/reasoning/mod.rs @@ -61,6 +61,8 @@ pub type Glm47ReasoningParser = DeepSeekV3ReasoningParser; // TODO: kimi k2 may implicitly end reasoning by starting a tool call section // using <|tool_calls_section_begin|>, we should support that. pub type KimiK2ReasoningParser = DeepSeekV3ReasoningParser; +/// MiMo V2 uses bare `...` delimiters. +pub type MiMoReasoningParser = DeepSeekV3ReasoningParser; /// MiniMax M2 currently shares the Qwen3 reasoning parser. pub type MiniMaxM2ReasoningParser = Qwen3ReasoningParser; diff --git a/rust/src/parser/src/tool/mimo.rs b/rust/src/parser/src/tool/mimo.rs new file mode 100644 index 0000000000..2844efc1f5 --- /dev/null +++ b/rust/src/parser/src/tool/mimo.rs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +use super::qwen_coder::{Qwen3CoderToolParser, QwenCoderConfig}; +use super::{Result, StructuralTagBuilder, Tool, ToolParser, ToolParserOutput}; + +const MIMO_CONFIG: QwenCoderConfig = QwenCoderConfig { + parser_name: "MiMo", + tool_call_start: "", + first_tool_call_start: "", + next_tool_call_start: "", + tool_call_end: "", + trim_parameter_newlines: false, +}; + +/// MiMo V2 uses Qwen Coder's XML grammar with unframed content and parameter values. +pub struct MiMoToolParser { + inner: Qwen3CoderToolParser, +} + +impl ToolParser for MiMoToolParser { + fn create(tools: &[Tool]) -> Result> { + Ok(Box::new(Self { + inner: Qwen3CoderToolParser::with_config(tools, MIMO_CONFIG), + })) + } + + fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> { + self.inner.structural_tag_builder() + } + + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { + self.inner.parse_into(chunk, output) + } + + fn finish(&mut self) -> Result { + self.inner.finish() + } + + fn reset(&mut self) -> String { + self.inner.reset() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tool::ToolParserTestExt as _; + use crate::tool::test_utils::{collect_stream, split_by_chars}; + use crate::tool::tests::assert_tool_framing_preserves_body_whitespace; + use serde_json::{Value, json}; + + #[test] + fn bare_tool_boundaries_preserve_body_whitespace() { + assert_tool_framing_preserves_body_whitespace::( + "", + "\n\n\n", + ); + } + + #[test] + fn adjacent_calls_preserve_parameter_whitespace_and_scalar_types_across_chunks() { + let tools = [Tool { + name: "convert".into(), + description: None, + strict: None, + parameters: json!({"type": "object", "properties": { + "flag": {"type": "boolean"}, "empty": {"type": ["string", "null"]}, + "text": {"type": "string"}, "literal": {"type": "string"}, "payload": {"type": "object"} + }}), + }]; + let call = "\n\nTrue\nNone\n\n value\n\nNone\n{\"city\":\"杭州\"}\n\n"; + let input = format!("answer\n\n{call}{call}"); + let expected = MiMoToolParser::create(&tools).unwrap().parse_complete(&input).unwrap(); + assert_eq!(expected.normal_text(), "answer\n\n"); + assert_eq!(expected.calls().len(), 2); + for call in expected.calls() { + assert_eq!( + serde_json::from_str::(&call.arguments).unwrap(), + json!({"flag": true, "empty": null, "text": "\n value\n", "literal": "None", "payload": {"city": "杭州"}}) + ); + } + for size in 1..=input.chars().count() { + let mut parser = MiMoToolParser::create(&tools).unwrap(); + assert_eq!( + collect_stream(parser.as_mut(), &split_by_chars(&input, size)), + expected + ); + } + } +} diff --git a/rust/src/parser/src/tool/mod.rs b/rust/src/parser/src/tool/mod.rs index 57bbb59528..2feba68525 100644 --- a/rust/src/parser/src/tool/mod.rs +++ b/rust/src/parser/src/tool/mod.rs @@ -11,6 +11,7 @@ mod glm_xml; mod hy; pub(crate) mod json; mod kimi_k2; +mod mimo; mod minimax_m2; mod minimax_m3; mod parameters; @@ -30,6 +31,7 @@ pub use json::{ MistralToolParser, Phi4MiniJsonToolParser, Qwen3XmlToolParser, }; pub use kimi_k2::KimiK2ToolParser; +pub use mimo::MiMoToolParser; pub use minimax_m2::MinimaxM2ToolParser; pub use minimax_m3::MinimaxM3ToolParser; pub use qwen_coder::Qwen3CoderToolParser; diff --git a/rust/src/parser/src/tool/parameters.rs b/rust/src/parser/src/tool/parameters.rs index 913ad815e5..2ae26cd894 100644 --- a/rust/src/parser/src/tool/parameters.rs +++ b/rust/src/parser/src/tool/parameters.rs @@ -284,13 +284,19 @@ impl JsonParamType { } } +/// Recognize JSON and Python null spellings emitted by chat templates. +fn is_null_literal(value: &str) -> bool { + value.eq_ignore_ascii_case("null") || value.eq_ignore_ascii_case("none") +} + /// Convert one parameter input to a normalized JSON value. fn convert_with_optional_schema(param_type: Option<&JsonParamType>, input: &ParamInput) -> Value { // Coerce the literal text `null` to JSON null, except for `string`-typed // params, where it must stay the string "null": a model emitting the literal // text "null" for a string field means the string, not a missing value. + // Python-style `None` follows the same schema coercion rules. if let ParamInput::Text(value) = input - && value.eq_ignore_ascii_case("null") + && is_null_literal(value) && param_type != Some(&JsonParamType::String) { return Value::Null; @@ -333,7 +339,7 @@ fn try_convert_text_value(param_type: &JsonParamType, value: &str) -> Option value.eq_ignore_ascii_case("null").then_some(Value::Null), + JsonParamType::Null => is_null_literal(value).then_some(Value::Null), JsonParamType::OneOf(types) => { types.iter().find_map(|param_type| try_convert_text_value(param_type, value)) } @@ -710,11 +716,12 @@ mod tests { } })); - assert_eq!(params.convert("name", text("null")), json!("null")); - assert_eq!(params.convert("name", text("NULL")), json!("NULL")); - // Non-string and schema-less params are unchanged: "null" -> null. - assert_eq!(params.convert("count", text("null")), json!(null)); - assert_eq!(params.convert("anything", text("null")), json!(null)); + for literal in ["null", "NULL", "None", "none", "NONE"] { + assert_eq!(params.convert("name", text(literal)), json!(literal)); + // Non-string and schema-less params share the same null coercion. + assert_eq!(params.convert("count", text(literal)), json!(null)); + assert_eq!(params.convert("anything", text(literal)), json!(null)); + } } #[test] @@ -731,9 +738,11 @@ mod tests { } })); - assert_eq!(params.convert("mode", text("null")), json!(null)); + for literal in ["null", "NULL", "None", "none", "NONE"] { + assert_eq!(params.convert("mode", text(literal)), json!(null)); + assert_eq!(params.convert("color", text(literal)), json!(literal)); + } assert_eq!(params.convert("mode", text("auto")), json!("auto")); - assert_eq!(params.convert("color", text("null")), json!("null")); } #[test] diff --git a/rust/src/parser/src/tool/qwen_coder.rs b/rust/src/parser/src/tool/qwen_coder.rs index 5bfaa764f6..e233f4720c 100644 --- a/rust/src/parser/src/tool/qwen_coder.rs +++ b/rust/src/parser/src/tool/qwen_coder.rs @@ -21,9 +21,9 @@ const PARAMETER_END: &str = ""; /// Model-specific configuration for the shared Qwen Coder grammar. /// -/// Only the tool-call wrapper tokens vary across models that reuse this -/// grammar; the inner `` / `` tags are always -/// byte-identical. Seed-OSS, for example, wraps the same body in +/// Models may vary the tool-call wrapper and parameter newline framing; +/// the inner `` / `` tags are byte-identical. +/// Seed-OSS, for example, wraps the same body in /// `` / ``. #[derive(Debug, Clone, Copy)] pub(crate) struct QwenCoderConfig { @@ -35,6 +35,8 @@ pub(crate) struct QwenCoderConfig { pub(crate) next_tool_call_start: &'static str, /// Marker that closes a tool-call block. pub(crate) tool_call_end: &'static str, + /// Consume one template-inserted newline at each parameter value boundary. + pub(crate) trim_parameter_newlines: bool, } const QWEN_CODER_CONFIG: QwenCoderConfig = QwenCoderConfig { @@ -43,6 +45,7 @@ const QWEN_CODER_CONFIG: QwenCoderConfig = QwenCoderConfig { first_tool_call_start: "\n\n", next_tool_call_start: "\n", tool_call_end: TOOL_CALL_END, + trim_parameter_newlines: true, }; type QwenCoderInput<'i> = Partial<&'i str>; @@ -200,7 +203,7 @@ fn parse_next_qwen_coder_event( match mode { QwenCoderMode::Text => parse_text_event(input, config, after_tool_call), QwenCoderMode::ToolCall { end_marker_scan } => { - tool_call_event(input, end_marker_scan, config.tool_call_end) + tool_call_event(input, end_marker_scan, config) } } } @@ -231,26 +234,26 @@ fn parse_text_event( fn tool_call_event( input: &mut QwenCoderInput<'_>, end_marker_scan: &mut MarkerScanState, - tool_call_end: &'static str, + config: QwenCoderConfig, ) -> ModalResult { let (body,) = seq!( _: ws0, - take_until_marker(tool_call_end, end_marker_scan), - _: literal(tool_call_end), + take_until_marker(config.tool_call_end, end_marker_scan), + _: literal(config.tool_call_end), ) .parse_next(input)?; - parse_tool_call_body(body) + parse_tool_call_body(body, config.trim_parameter_newlines) } /// Parse a Qwen Coder function block. -fn function_event(input: &mut &str) -> ModalResult { +fn function_event(input: &mut &str, trim_parameter_newlines: bool) -> ModalResult { let (name, raw_params) = seq!( _: literal(FUNCTION_START), take_until(1.., ">"), _: ">", _: ws0, - repeat(0.., terminated(parameter, ws0)), + repeat(0.., terminated(|input: &mut &str| parameter(input, trim_parameter_newlines), ws0)), _: literal(FUNCTION_END), ) .parse_next(input)?; @@ -262,23 +265,33 @@ fn function_event(input: &mut &str) -> ModalResult { } /// Parse a Qwen Coder parameter block. -fn parameter(input: &mut &str) -> ModalResult<(String, String)> { +fn parameter(input: &mut &str, trim_newlines: bool) -> ModalResult<(String, String)> { let (name, value) = seq!( _: literal(PARAMETER_START), take_until(1.., ">"), _: ">", - take_until(0.., PARAMETER_END).map(trim_one_wrapping_newline), + take_until(0.., PARAMETER_END), _: literal(PARAMETER_END), ) .parse_next(input)?; + let value = if trim_newlines { + trim_one_wrapping_newline(value) + } else { + value + }; Ok((name.to_string(), value.to_string())) } /// Parse a Qwen Coder tool-call body. -fn parse_tool_call_body(body: &str) -> ModalResult { +fn parse_tool_call_body(body: &str, trim_parameter_newlines: bool) -> ModalResult { let mut input = body; - delimited(ws0, function_event, (ws0, eof)).parse_next(&mut input) + delimited( + ws0, + |input: &mut &str| function_event(input, trim_parameter_newlines), + (ws0, eof), + ) + .parse_next(&mut input) } /// Trim a single leading and trailing newline from a parameter value. diff --git a/rust/src/parser/src/tool/seed_oss.rs b/rust/src/parser/src/tool/seed_oss.rs index 160297523e..3635998a03 100644 --- a/rust/src/parser/src/tool/seed_oss.rs +++ b/rust/src/parser/src/tool/seed_oss.rs @@ -10,6 +10,7 @@ const SEED_OSS_CONFIG: QwenCoderConfig = QwenCoderConfig { first_tool_call_start: "\n\n", next_tool_call_start: "\n", tool_call_end: "", + trim_parameter_newlines: true, }; /// Tool parser for Seed-OSS XML-style tool calls. diff --git a/rust/src/text/src/backend/hf/model_files.rs b/rust/src/text/src/backend/hf/model_files.rs index 3ffd7b2bb7..a4707672a2 100644 --- a/rust/src/text/src/backend/hf/model_files.rs +++ b/rust/src/text/src/backend/hf/model_files.rs @@ -183,11 +183,7 @@ async fn resolve_remote_model_files( download_if_present(&repo, model_id, &siblings, "video_preprocessor_config.json").await?; let processor_config_path = download_if_present(&repo, model_id, &siblings, "processor_config.json").await?; - let chat_template_name = siblings - .contains("chat_template.json") - .then_some("chat_template.json") - .or_else(|| siblings.contains("chat_template.jinja").then_some("chat_template.jinja")) - .or_else(|| siblings.iter().copied().find(|name| name.ends_with(".jinja"))); + let chat_template_name = remote_chat_template_name(&siblings); let chat_template_path = match chat_template_name { Some(name) => Some(download_known_file(&repo, model_id, name).await?), None => None, @@ -445,6 +441,22 @@ pub(super) fn is_tiktoken_file(path: &std::path::Path) -> bool { .is_some_and(|name| name == "tiktoken.model" || name.ends_with(".tiktoken")) } +/// Select a root-level template, keeping auxiliary tokenizer templates separate. +fn remote_chat_template_name<'a>( + siblings: &std::collections::BTreeSet<&'a str>, +) -> Option<&'a str> { + siblings + .contains("chat_template.json") + .then_some("chat_template.json") + .or_else(|| siblings.contains("chat_template.jinja").then_some("chat_template.jinja")) + .or_else(|| { + siblings + .iter() + .copied() + .find(|name| !name.contains('/') && name.ends_with(".jinja")) + }) +} + /// Chat templates are sometimes stored as dedicated .jinja files rather than as /// a fixed-name config entry, so we scan the cached model dir. fn discover_chat_template_in_dir(dir: &std::path::Path) -> Option { @@ -475,6 +487,23 @@ mod tests { use super::{ResolvedModelFiles, TokenizerSource}; + #[test] + fn remote_chat_template_selection_stays_with_main_tokenizer() { + let mut siblings = std::collections::BTreeSet::from([ + "audio_tokenizer/chat_template.jinja", + "tokenizer_config.json", + ]); + assert_eq!(super::remote_chat_template_name(&siblings), None); + for name in [ + "template.jinja", + "chat_template.jinja", + "chat_template.json", + ] { + siblings.insert(name); + assert_eq!(super::remote_chat_template_name(&siblings), Some(name)); + } + } + #[tokio::test] async fn overrides_keep_configs_alive_across_clones_and_isolate_instances() { let source = tempdir().unwrap();