Skip to main content

dynamo_renderer/deepseek/
common.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared DeepSeek native prompt-formatting helpers.
5
6use anyhow::{Context, Result};
7use serde_json::Value as JsonValue;
8
9/// Special tokens for DeepSeek prompt formatting.
10pub mod tokens {
11    pub const BOS: &str = "<|begin▁of▁sentence|>";
12    pub const EOS: &str = "<|end▁of▁sentence|>";
13    pub const THINKING_START: &str = "<think>";
14    pub const THINKING_END: &str = "</think>";
15    pub const DSML_TOKEN: &str = "|DSML|";
16    pub const USER_START: &str = "<|User|>";
17    pub const ASSISTANT_START: &str = "<|Assistant|>";
18    pub const LATEST_REMINDER: &str = "<|latest_reminder|>";
19
20    // Quick-instruction task tokens
21    pub const TASK_ACTION: &str = "<|action|>";
22    pub const TASK_QUERY: &str = "<|query|>";
23    pub const TASK_AUTHORITY: &str = "<|authority|>";
24    pub const TASK_DOMAIN: &str = "<|domain|>";
25    pub const TASK_TITLE: &str = "<|title|>";
26    pub const TASK_READ_URL: &str = "<|read_url|>";
27}
28
29pub(crate) const TOOL_CALLS_BLOCK_NAME: &str = "tool_calls";
30
31pub(crate) const RESPONSE_FORMAT_TEMPLATE: &str =
32    "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}";
33
34pub(crate) const TOOLS_TEMPLATE: &str = r#"## Tools
35
36You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following:
37
38<{dsml_token}tool_calls>
39<{dsml_token}invoke name="$TOOL_NAME">
40<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
41...
42</{dsml_token}invoke>
43<{dsml_token}invoke name="$TOOL_NAME2">
44...
45</{dsml_token}invoke>
46</{dsml_token}tool_calls>
47
48String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
49
50If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
51
52Otherwise, output directly after {thinking_end_token} with tool calls or final response.
53
54### Available Tool Schemas
55
56{tool_schemas}
57
58You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
59"#;
60
61/// System message template for tools.
62pub(crate) const TOOLS_SYSTEM_TEMPLATE: &str = r#"## Tools
63
64You have access to a set of tools you can use to answer the user's question.
65You can invoke functions by writing a "<{dsml_token}function_calls>" block like the following as part of your reply to the user:
66<{dsml_token}function_calls>
67<{dsml_token}invoke name="$FUNCTION_NAME">
68<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
69...
70</{dsml_token}invoke>
71<{dsml_token}invoke name="$FUNCTION_NAME2">
72...
73</{dsml_token}invoke>
74</{dsml_token}function_calls>
75
76String and scalar parameters should be specified as is without any escaping or quotes, while lists and objects should use JSON format. The "string" attribute should be set to "true" for string type parameters and "false" for other types (numbers, booleans, arrays, objects).
77
78If the thinking_mode is enabled, then after function results you should strongly consider outputting a thinking block. Here is an example:
79
80<{dsml_token}function_calls>
81...
82</{dsml_token}function_calls>
83
84<function_results>
85...
86</function_results>
87
88{thinking_start_token}...thinking about results{thinking_end_token}
89
90Here are the functions available in JSONSchema format:
91<functions>
92{tool_schemas}
93</functions>
94"#;
95
96pub(crate) const REASONING_EFFORT_HIGH: &str = "Reasoning Effort: Absolute maximum with no shortcuts permitted.\nYou MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\nExplicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n";
97
98pub(crate) const REASONING_EFFORT_MAX: &str = "Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\nYou MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\nDo not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n";
99
100/// Thinking mode for the model.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum ThinkingMode {
103    Chat,
104    Thinking,
105}
106
107impl ThinkingMode {
108    pub fn as_str(&self) -> &'static str {
109        match self {
110            ThinkingMode::Chat => "chat",
111            ThinkingMode::Thinking => "thinking",
112        }
113    }
114}
115
116/// Reasoning effort level. `None` conveyed as `Option<ReasoningEffort>`.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum ReasoningEffort {
119    Max,
120    High,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub(crate) enum NormalizeNonText {
125    SerializeJson,
126    LeaveUntouched,
127}
128
129// Serialize a JSON value to match Python's `json.dumps(ensure_ascii=False)` spacing.
130// Python's default separators are `(', ', ': ')`; we use a custom `Formatter`
131// so escape sequences inside strings can't confuse state tracking.
132pub(crate) fn to_json(value: &JsonValue) -> String {
133    use serde::Serialize;
134    use serde_json::ser::Formatter;
135    use std::io;
136
137    struct PythonFormatter;
138
139    impl Formatter for PythonFormatter {
140        fn begin_array_value<W: ?Sized + io::Write>(
141            &mut self,
142            writer: &mut W,
143            first: bool,
144        ) -> io::Result<()> {
145            if first {
146                Ok(())
147            } else {
148                writer.write_all(b", ")
149            }
150        }
151
152        fn begin_object_key<W: ?Sized + io::Write>(
153            &mut self,
154            writer: &mut W,
155            first: bool,
156        ) -> io::Result<()> {
157            if first {
158                Ok(())
159            } else {
160                writer.write_all(b", ")
161            }
162        }
163
164        fn begin_object_value<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
165            writer.write_all(b": ")
166        }
167
168        fn write_f64<W: ?Sized + io::Write>(
169            &mut self,
170            writer: &mut W,
171            value: f64,
172        ) -> io::Result<()> {
173            writer.write_all(python_float_repr(value).as_bytes())
174        }
175    }
176
177    // Serializing a JsonValue into Vec<u8> is infallible; the output is always UTF-8.
178    let mut buf = Vec::with_capacity(64);
179    let mut ser = serde_json::Serializer::with_formatter(&mut buf, PythonFormatter);
180    value
181        .serialize(&mut ser)
182        .expect("JsonValue serialization to Vec<u8> is infallible");
183    String::from_utf8(buf).expect("serde_json output is always valid UTF-8")
184}
185
186pub(crate) fn render_tools(template: &str, tools: &[JsonValue]) -> String {
187    let tools_json: Vec<String> = tools
188        .iter()
189        .filter_map(|tool| tool.get("function"))
190        .map(to_json)
191        .collect();
192
193    // Always do the tool_schemas last because they are user controlled.
194    // See test_render_tools_preserves_placeholder_text_inside_tool_schema.
195    template
196        .replace("{dsml_token}", tokens::DSML_TOKEN)
197        .replace("{thinking_start_token}", tokens::THINKING_START)
198        .replace("{thinking_end_token}", tokens::THINKING_END)
199        .replace("{tool_schemas}", &tools_json.join("\n"))
200}
201
202pub(crate) fn find_last_user_index(messages: &[JsonValue]) -> Option<usize> {
203    messages
204        .iter()
205        .enumerate()
206        .rev()
207        .find(|(_, msg)| {
208            msg.get("role")
209                .and_then(|r| r.as_str())
210                .map(|r| r == "user" || r == "developer")
211                .unwrap_or(false)
212        })
213        .map(|(idx, _)| idx)
214}
215
216pub(crate) fn extract_visible_text(content: &JsonValue) -> String {
217    match content {
218        JsonValue::String(text) => text.clone(),
219        JsonValue::Array(items) => items
220            .iter()
221            .filter_map(|item| {
222                if let Some(text) = item.as_str() {
223                    return Some(text.to_string());
224                }
225                let item_type = item.get("type").and_then(|v| v.as_str());
226                if item_type == Some("text") {
227                    return item
228                        .get("text")
229                        .and_then(|v| v.as_str())
230                        .map(|text| text.to_string());
231                }
232                tracing::warn!(
233                    chunk_type = item_type.unwrap_or("unknown"),
234                    "DeepSeek formatter dropped non-text content chunk while normalizing message content",
235                );
236                None
237            })
238            .collect::<String>(),
239        _ => to_json(content),
240    }
241}
242
243pub(crate) fn normalize_message_contents(messages: &mut [JsonValue], non_text: NormalizeNonText) {
244    for msg in messages {
245        let Some(content) = msg.get("content") else {
246            continue;
247        };
248        // Text content is already normalized. Keep its allocation in place.
249        if content.is_string() {
250            continue;
251        }
252        if !content.is_array() && non_text == NormalizeNonText::LeaveUntouched {
253            continue;
254        }
255        let normalized = extract_visible_text(content);
256        if let Some(obj) = msg.as_object_mut() {
257            obj.insert("content".to_string(), JsonValue::String(normalized));
258        }
259    }
260}
261
262pub(crate) fn encode_arguments_to_dsml(tool_call: &JsonValue) -> Result<String> {
263    let arguments_str = tool_call
264        .get("arguments")
265        .and_then(|a| a.as_str())
266        .context("Missing or invalid 'arguments' field")?;
267
268    // Python falls back to `{"arguments": raw_string}` on parse failure.
269    let arguments: JsonValue = match serde_json::from_str(arguments_str) {
270        Ok(v) => v,
271        Err(_) => serde_json::json!({ "arguments": arguments_str }),
272    };
273
274    let arguments_obj = arguments
275        .as_object()
276        .context("Arguments must be a JSON object")?;
277
278    let mut params = Vec::new();
279    for (key, value) in arguments_obj {
280        let value_str = if let Some(vs) = value.as_str() {
281            vs.to_string()
282        } else {
283            to_json(value)
284        };
285        params.push(format!(
286            "<{}parameter name=\"{}\" string=\"{}\">{}</{}parameter>",
287            tokens::DSML_TOKEN,
288            key,
289            if value.is_string() { "true" } else { "false" },
290            value_str,
291            tokens::DSML_TOKEN
292        ));
293    }
294
295    Ok(params.join("\n"))
296}
297
298pub(crate) fn task_token(task: &str) -> Option<&'static str> {
299    match task {
300        "action" => Some(tokens::TASK_ACTION),
301        "query" => Some(tokens::TASK_QUERY),
302        "authority" => Some(tokens::TASK_AUTHORITY),
303        "domain" => Some(tokens::TASK_DOMAIN),
304        "title" => Some(tokens::TASK_TITLE),
305        "read_url" => Some(tokens::TASK_READ_URL),
306        _ => None,
307    }
308}
309
310const USER_FIELDS_TO_PRESERVE: [&str; 3] = ["task", "wo_eos", "mask"];
311
312fn preserve_user_fields(target: &mut JsonValue, source: &JsonValue) {
313    if let Some(obj) = target.as_object_mut() {
314        for key in USER_FIELDS_TO_PRESERVE {
315            if let Some(v) = source.get(key) {
316                obj.insert(key.to_string(), v.clone());
317            }
318        }
319    }
320}
321
322// Merge `tool` role messages into preceding user `content_blocks` and collapse
323// consecutive user turns with Python-compatible rendering. The internal user
324// representation keeps text only in content_blocks, without Python's duplicate
325// content field.
326pub(crate) fn merge_tool_messages(messages: Vec<JsonValue>) -> Vec<JsonValue> {
327    let mut merged: Vec<JsonValue> = Vec::with_capacity(messages.len());
328
329    for mut msg in messages {
330        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
331
332        if role == "tool" {
333            let obj = msg
334                .as_object_mut()
335                .expect("a message with a role is an object");
336            let tool_block = JsonValue::Object(serde_json::Map::from_iter([
337                ("type".into(), JsonValue::String("tool_result".into())),
338                (
339                    "tool_use_id".into(),
340                    obj.remove("tool_call_id")
341                        .unwrap_or_else(|| JsonValue::String(String::new())),
342                ),
343                (
344                    "content".into(),
345                    obj.remove("content")
346                        .unwrap_or_else(|| JsonValue::String(String::new())),
347                ),
348            ]));
349
350            let can_merge = merged
351                .last()
352                .map(|m| {
353                    m.get("role").and_then(|r| r.as_str()) == Some("user")
354                        && m.get("content_blocks").is_some()
355                })
356                .unwrap_or(false);
357
358            if can_merge {
359                let last = merged.last_mut().unwrap();
360                if let Some(blocks) = last
361                    .as_object_mut()
362                    .and_then(|o| o.get_mut("content_blocks"))
363                    .and_then(|v| v.as_array_mut())
364                {
365                    blocks.push(tool_block);
366                }
367            } else {
368                merged.push(JsonValue::Object(serde_json::Map::from_iter([
369                    ("role".into(), JsonValue::String("user".into())),
370                    ("content_blocks".into(), JsonValue::Array(vec![tool_block])),
371                ])));
372            }
373        } else if role == "user" {
374            let text = msg
375                .as_object_mut()
376                .expect("a message with a role is an object")
377                .remove("content")
378                .filter(JsonValue::is_string)
379                .unwrap_or_else(|| JsonValue::String(String::new()));
380            let text_block = JsonValue::Object(serde_json::Map::from_iter([
381                ("type".into(), JsonValue::String("text".into())),
382                ("text".into(), text),
383            ]));
384
385            let can_merge = merged
386                .last()
387                .map(|m| {
388                    m.get("role").and_then(|r| r.as_str()) == Some("user")
389                        && m.get("content_blocks").is_some()
390                        && m.get("task").map(|v| v.is_null()).unwrap_or(true)
391                })
392                .unwrap_or(false);
393
394            if can_merge {
395                // The reference appends only the text; the merged user's own fields are dropped.
396                if let Some(blocks) = merged
397                    .last_mut()
398                    .and_then(|last| last.get_mut("content_blocks"))
399                    .and_then(|v| v.as_array_mut())
400                {
401                    blocks.push(text_block);
402                }
403            } else {
404                // Rendering reads content_blocks; retaining content would copy
405                // the entire user message for an unused second representation.
406                let mut new_msg = JsonValue::Object(serde_json::Map::from_iter([
407                    ("role".into(), JsonValue::String("user".into())),
408                    ("content_blocks".into(), JsonValue::Array(vec![text_block])),
409                ]));
410                preserve_user_fields(&mut new_msg, &msg);
411                merged.push(new_msg);
412            }
413        } else {
414            merged.push(msg);
415        }
416    }
417
418    merged
419}
420
421// Sort `tool_result` blocks within user messages by the `tool_calls[].id` order
422// of the preceding assistant message.
423pub(crate) fn sort_tool_results_by_call_order(mut messages: Vec<JsonValue>) -> Vec<JsonValue> {
424    use std::collections::HashMap;
425    let mut last_order: HashMap<String, usize> = HashMap::new();
426
427    for msg in &mut messages {
428        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
429        if role == "assistant" {
430            // Like the reference's `if msg.get("tool_calls")`, an empty list keeps the order.
431            if let Some(tcs) = msg
432                .get("tool_calls")
433                .and_then(|t| t.as_array())
434                .filter(|t| !t.is_empty())
435            {
436                last_order.clear();
437                for (idx, tc) in tcs.iter().enumerate() {
438                    let id = tc
439                        .get("id")
440                        .and_then(|v| v.as_str())
441                        .or_else(|| {
442                            tc.get("function")
443                                .and_then(|f| f.get("id"))
444                                .and_then(|v| v.as_str())
445                        })
446                        .unwrap_or("");
447                    if !id.is_empty() {
448                        last_order.insert(id.to_string(), idx);
449                    }
450                }
451            }
452        } else if role == "user" && !last_order.is_empty() {
453            let Some(blocks) = msg
454                .as_object_mut()
455                .and_then(|o| o.get_mut("content_blocks"))
456                .and_then(|v| v.as_array_mut())
457            else {
458                continue;
459            };
460
461            // Collect tool_result blocks with their positions.
462            let tool_positions: Vec<usize> = blocks
463                .iter()
464                .enumerate()
465                .filter(|(_, b)| b.get("type").and_then(|v| v.as_str()) == Some("tool_result"))
466                .map(|(i, _)| i)
467                .collect();
468
469            if tool_positions.len() > 1 {
470                let start = *tool_positions
471                    .first()
472                    .expect("tool_positions has length > 1");
473                let end = *tool_positions
474                    .last()
475                    .expect("tool_positions has length > 1");
476                let is_contiguous = end - start + 1 == tool_positions.len();
477
478                if is_contiguous {
479                    // Fast path: sort the contiguous slice in place
480                    blocks[start..=end].sort_by_key(|b| {
481                        let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
482                        *last_order.get(id).unwrap_or(&0)
483                    });
484                } else {
485                    // Fallback: extract, sort, and replace for non-contiguous blocks
486                    let mut tool_blocks: Vec<JsonValue> = tool_positions
487                        .iter()
488                        .map(|&i| std::mem::take(&mut blocks[i]))
489                        .collect();
490
491                    tool_blocks.sort_by_key(|b| {
492                        let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
493                        *last_order.get(id).unwrap_or(&0)
494                    });
495
496                    for (sorted_idx, &pos) in tool_positions.iter().enumerate() {
497                        blocks[pos] = std::mem::take(&mut tool_blocks[sorted_idx]);
498                    }
499                }
500            }
501        }
502    }
503
504    messages
505}
506
507// Drop reasoning and non-essential messages before the last user message.
508pub(crate) fn drop_thinking_messages(messages: Vec<JsonValue>) -> Vec<JsonValue> {
509    let last_user_idx = find_last_user_index(&messages);
510    let mut out = Vec::with_capacity(messages.len());
511    const KEEP: &[&str] = &[
512        "user",
513        "system",
514        "tool",
515        "latest_reminder",
516        "direct_search_results",
517    ];
518
519    for (idx, mut msg) in messages.into_iter().enumerate() {
520        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
521        if KEEP.contains(&role) || last_user_idx.is_none_or(|u| idx >= u) {
522            out.push(msg);
523        } else if role == "assistant" {
524            if let Some(obj) = msg.as_object_mut() {
525                obj.remove("reasoning_content");
526            }
527            out.push(msg);
528        }
529        // developer and other roles before last_user_idx are dropped.
530    }
531    out
532}
533
534pub(crate) fn resolve_thinking_mode(
535    args: Option<&std::collections::HashMap<String, serde_json::Value>>,
536    default_mode: ThinkingMode,
537) -> ThinkingMode {
538    if let Some(enabled) = crate::thinking_bool_from_args(args) {
539        return if enabled {
540            ThinkingMode::Thinking
541        } else {
542            ThinkingMode::Chat
543        };
544    }
545    if let Some(args) = args
546        && let Some(mode) = args.get("thinking_mode").and_then(|v| v.as_str())
547    {
548        match mode {
549            "chat" => return ThinkingMode::Chat,
550            "thinking" => return ThinkingMode::Thinking,
551            _ => {}
552        }
553    }
554    default_mode
555}
556
557pub(crate) fn inject_tools_and_response_format(
558    messages_array: &mut Vec<JsonValue>,
559    req: &dyn crate::OAIChatLikeRequest,
560) -> Result<()> {
561    let tools_json = req
562        .tools()
563        .map(|t| serde_json::to_value(&t))
564        .transpose()
565        .context("Failed to convert tools to JSON")?;
566
567    // OpenAI semantics for `tool_choice: "none"`: the model must not call
568    // tools. Strip the tool definitions from the prompt so the model never
569    // sees the DSML tool instructions and cannot emit raw tool markup.
570    // response_format is kept intact. Mirrors the jinja path's
571    // exclude_tools_when_tool_choice_none handling (template/oai.rs).
572    let tools_json = match req.tool_choice() {
573        Some(ref tc) if tc.as_str() == Some("none") => None,
574        _ => tools_json,
575    };
576
577    let response_format_json = req
578        .response_format()
579        .map(|rf| serde_json::to_value(&rf))
580        .transpose()
581        .context("Failed to convert response_format to JSON")?;
582
583    if tools_json.is_some() || response_format_json.is_some() {
584        let system_idx = messages_array
585            .iter()
586            .position(|msg| msg.get("role").and_then(|r| r.as_str()) == Some("system"));
587
588        if let Some(idx) = system_idx {
589            if let Some(msg) = messages_array.get_mut(idx)
590                && let Some(obj) = msg.as_object_mut()
591            {
592                if let Some(tools) = tools_json {
593                    obj.insert("tools".to_string(), tools);
594                }
595                if let Some(rf) = response_format_json {
596                    obj.insert("response_format".to_string(), rf);
597                }
598            }
599        } else {
600            let mut system_msg = serde_json::json!({
601                "role": "system",
602                "content": ""
603            });
604            if let Some(obj) = system_msg.as_object_mut() {
605                if let Some(tools) = tools_json {
606                    obj.insert("tools".to_string(), tools);
607                }
608                if let Some(rf) = response_format_json {
609                    obj.insert("response_format".to_string(), rf);
610                }
611            }
612            messages_array.insert(0, system_msg);
613        }
614    }
615    Ok(())
616}
617
618/// Python's `repr(float)`, which `json.dumps` uses: the shortest round-trip digits,
619/// fixed notation for exponents in [-4, 16) (always with a fraction, `100.0`),
620/// otherwise scientific with a signed, two-digit exponent (`1e-06`, `1e+16`).
621fn python_float_repr(value: f64) -> String {
622    // serde_json's ryu picks the same shortest digits as Python, halfway ties
623    // included (`{:e}` breaks some differently); only the notation is respelled.
624    let shortest = serde_json::Number::from_f64(value)
625        .expect("JSON floats are finite")
626        .to_string();
627    let (sign, unsigned) = match shortest.strip_prefix('-') {
628        Some(unsigned) => ("-", unsigned),
629        None => ("", shortest.as_str()),
630    };
631    let (mantissa, exponent) = match unsigned.split_once('e') {
632        Some((mantissa, exponent)) => (mantissa, exponent.parse().expect("ryu exponent")),
633        None => (unsigned, 0),
634    };
635    let (integer, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
636    let all_digits = format!("{integer}{fraction}");
637    let significant = all_digits.trim_start_matches('0');
638    let digits = significant.trim_end_matches('0');
639    if digits.is_empty() {
640        return format!("{sign}0.0");
641    }
642    let leading_zeros = (all_digits.len() - significant.len()) as i32;
643    let exponent: i32 = exponent + integer.len() as i32 - 1 - leading_zeros;
644    if (-4..16).contains(&exponent) {
645        let point = exponent + 1;
646        let fixed = if point <= 0 {
647            format!("0.{}{digits}", "0".repeat(point.unsigned_abs() as usize))
648        } else if point as usize >= digits.len() {
649            format!("{digits}{}.0", "0".repeat(point as usize - digits.len()))
650        } else {
651            format!(
652                "{}.{}",
653                &digits[..point as usize],
654                &digits[point as usize..]
655            )
656        };
657        format!("{sign}{fixed}")
658    } else {
659        let (head, tail) = digits.split_at(1);
660        let mantissa = if tail.is_empty() {
661            head.to_string()
662        } else {
663            format!("{head}.{tail}")
664        };
665        let exponent_sign = if exponent < 0 { '-' } else { '+' };
666        format!(
667            "{sign}{mantissa}e{exponent_sign}{:02}",
668            exponent.unsigned_abs()
669        )
670    }
671}
672
673#[cfg(test)]
674mod tests {
675
676    #[test]
677    fn floats_match_python_json_dumps() {
678        for (value, python) in [
679            (0.000001, "1e-06"),
680            (0.0001, "0.0001"),
681            (0.00001234, "1.234e-05"),
682            (1e16, "1e+16"),
683            (1e15, "1000000000000000.0"),
684            (1.5e-7, "1.5e-07"),
685            (2.5, "2.5"),
686            (100.0, "100.0"),
687            (0.1, "0.1"),
688            (-0.0, "-0.0"),
689            (0.0, "0.0"),
690            (-123.456, "-123.456"),
691            (1.7976931348623157e308, "1.7976931348623157e+308"),
692            (5e-324, "5e-324"),
693            // Exactly halfway between two shortest candidates.
694            (1e15 + 0.25, "1000000000000000.2"),
695            (1e14 + 0.125, "100000000000000.12"),
696        ] {
697            assert_eq!(python_float_repr(value), python, "{value:?}");
698        }
699        let value = serde_json::json!({"a": 0.000001, "b": [1e16, 3]});
700        assert_eq!(to_json(&value), r#"{"a": 1e-06, "b": [1e+16, 3]}"#);
701    }
702
703    use super::*;
704    use serde_json::json;
705
706    #[test]
707    fn merged_users_drop_their_metadata_like_the_reference() {
708        // Expected output comes from the official DeepSeek-V4-Flash encoding_dsv4.py:
709        // a user merged into a group drops its fields; a group's task stops merging.
710        let merged = merge_tool_messages(vec![
711            json!({"role": "tool", "tool_call_id": "c1", "content": "result"}),
712            json!({"role": "user", "content": "one", "wo_eos": true, "mask": [1, 0]}),
713            json!({"role": "user", "content": "two", "task": "action"}),
714            json!({"role": "assistant", "content": "a"}),
715            json!({"role": "user", "content": "three", "task": "action"}),
716            json!({"role": "user", "content": "four", "wo_eos": true}),
717        ]);
718        assert_eq!(
719            merged,
720            [
721                json!({"role": "user", "content_blocks": [
722                    {"type": "tool_result", "tool_use_id": "c1", "content": "result"},
723                    {"type": "text", "text": "one"},
724                    {"type": "text", "text": "two"}
725                ]}),
726                json!({"role": "assistant", "content": "a"}),
727                json!({"role": "user", "content_blocks": [{"type": "text", "text": "three"}], "task": "action"}),
728                json!({"role": "user", "content_blocks": [{"type": "text", "text": "four"}], "wo_eos": true}),
729            ]
730        );
731    }
732
733    #[test]
734    fn test_extract_visible_text_from_content_array() {
735        let content = json!([
736            {"type": "text", "text": "who "},
737            {"type": "text", "text": "are "},
738            {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
739            {"type": "text", "text": "you?"}
740        ]);
741        assert_eq!(extract_visible_text(&content), "who are you?");
742    }
743
744    #[test]
745    fn test_render_tools_preserves_placeholder_text_inside_tool_schema() {
746        let tools = json!([{
747            "type": "function",
748            "function": {
749                "name": "placeholder_tool",
750                "description": "literal {dsml_token} {thinking_start_token} {thinking_end_token}",
751                "parameters": {"type": "object", "properties": {}}
752            }
753        }]);
754        let rendered = render_tools(
755            "static {dsml_token} {thinking_start_token} {thinking_end_token}\n{tool_schemas}",
756            tools.as_array().unwrap(),
757        );
758
759        assert!(rendered.starts_with(&format!(
760            "static {} {} {}\n",
761            tokens::DSML_TOKEN,
762            tokens::THINKING_START,
763            tokens::THINKING_END
764        )));
765        assert!(
766            rendered.contains("literal {dsml_token} {thinking_start_token} {thinking_end_token}")
767        );
768    }
769}