[Bugfix][Rust Frontend][Renderer] Align DeepSeek tool-call arguments with deepseek-recipe (#56260)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Codex <noreply@openai.com> Signed-off-by: JinYan Su <751080330@qq.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

JinYan Su committed Sep 11, 2026 at 18:19 UTC 79c137c6ada6bf9384e14feb260e10d91bf58c0b
2 files changed +57 -12
rust/src/chat/src/renderer/deepseek.rs
+11 -12
@@ -11,7 +11,7 @@ use std::collections::HashMap;
11 use std::fmt::Write as _;
12
13 use serde::Serialize;
14 -use serde_json::Value;
14 +use serde_json::{Map, Value};
15 use serde_json_fmt::JsonFormat;
16
17 use llm_multimodal::DEEPSEEK_V41_IMAGE_PLACEHOLDER;
@@ -685,19 +685,18 @@ fn encode_arguments_to_dsml(
685 dialect: DsDialect,
686 ) -> Result<()> {
687 let parameter_tag = dialect.parameter_tag();
688 - let arguments: Value = serde_json::from_str(&tool_call.arguments).map_err(|error| {
689 - Error::ChatTemplate(format!(
690 - "assistant tool call has invalid JSON arguments for DeepSeek: {error}"
691 - ))
692 - })?;
693 - let Some(arguments) = arguments.as_object() else {
694 - return Err(Error::ChatTemplate(
695 - "assistant tool call arguments for DeepSeek must be a JSON object".to_string(),
696 - ));
697 - };
688 + // Match deepseek-recipe's render_tool_arguments for both V4 and V4.1:
689 + // https://github.com/deepseek-ai/deepseek-recipe/blob/8cadfede7063c896b944e7bae05daa3549ae97ea/deepseek-recipe-encoding/src/v4/mod.rs#L48-L58
690 + let arguments = serde_json::from_str::<Map<String, Value>>(&tool_call.arguments)
691 + .unwrap_or_else(|_| {
692 + Map::from_iter([(
693 + "arguments".to_owned(),
694 + Value::String(tool_call.arguments.clone()),
695 + )])
696 + });
697
698 let mut wrote_parameter = false;
700 - for (key, value) in arguments {
699 + for (key, value) in &arguments {
700 if wrote_parameter {
701 out.push('\n');
702 }
rust/src/chat/src/renderer/deepseek_v4/tests.rs
+46
@@ -9,6 +9,7 @@ use serde_json::Value;
9 use super::DeepSeekV4ChatRenderer;
10 use crate::ChatRenderer;
11 use crate::event::{AssistantContentBlock, AssistantToolCall};
12 +use crate::renderer::deepseek_v41::DeepSeekV41ChatRenderer;
13 use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
14 use crate::request::{ChatMessage, ChatRequest, ChatTool, GenerationPromptMode, ReasoningEffort};
15
@@ -399,6 +400,51 @@ fn reasoning_effort_template_kwarg_is_ignored() {
400 assert!(rendered.ends_with("<|Assistant|><think>"));
401 }
402
403 +#[test]
404 +fn tool_call_arguments_preserve_non_objects_like_recipe() {
405 + let renderers: [(&dyn ChatRenderer, &str); 2] = [
406 + (&DeepSeekV4ChatRenderer::new(), "parameter"),
407 + (&DeepSeekV41ChatRenderer::new(), " parameter"),
408 + ];
409 + for arguments in [
410 + "not json",
411 + "[1, 2]",
412 + "null",
413 + "42",
414 + "true",
415 + "",
416 + r#""text""#,
417 + r#""{\"query\":\"double\"}""#,
418 + ] {
419 + let request = ChatRequest {
420 + messages: vec![
421 + ChatMessage::assistant_blocks(vec![AssistantContentBlock::ToolCall(
422 + AssistantToolCall {
423 + id: "call".to_string(),
424 + name: "search".to_string(),
425 + arguments: arguments.to_string(),
426 + },
427 + )]),
428 + ChatMessage::tool_response("result", "call"),
429 + ],
430 + ..ChatRequest::for_test()
431 + };
432 +
433 + for (renderer, tag) in renderers {
434 + let prompt = renderer.render(&request).unwrap().prompt.into_text().unwrap();
435 + let parameters: Vec<_> =
436 + prompt.lines().filter(|line| line.contains("parameter name=")).collect();
437 + assert_eq!(
438 + parameters,
439 + [format!(
440 + "<|DSML|{tag} name=\"arguments\" string=\"true\">{arguments}</|DSML|{tag}>"
441 + )],
442 + "arguments: {arguments:?}, tag: {tag:?}",
443 + );
444 + }
445 + }
446 +}
447 +
448 #[test]
449 fn tool_results_are_sorted_by_previous_assistant_tool_call_order() {
450 let mut request = ChatRequest {