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                let last = merged.last_mut().unwrap();
396                let appended = last
397                    .as_object_mut()
398                    .and_then(|o| o.get_mut("content_blocks"))
399                    .and_then(|v| v.as_array_mut())
400                    .map(|blocks| {
401                        blocks.push(text_block);
402                    })
403                    .is_some();
404                if appended {
405                    preserve_user_fields(last, &msg);
406                }
407            } else {
408                // Rendering reads content_blocks; retaining content would copy
409                // the entire user message for an unused second representation.
410                let mut new_msg = JsonValue::Object(serde_json::Map::from_iter([
411                    ("role".into(), JsonValue::String("user".into())),
412                    ("content_blocks".into(), JsonValue::Array(vec![text_block])),
413                ]));
414                preserve_user_fields(&mut new_msg, &msg);
415                merged.push(new_msg);
416            }
417        } else {
418            merged.push(msg);
419        }
420    }
421
422    merged
423}
424
425// Sort `tool_result` blocks within user messages by the `tool_calls[].id` order
426// of the preceding assistant message.
427pub(crate) fn sort_tool_results_by_call_order(mut messages: Vec<JsonValue>) -> Vec<JsonValue> {
428    use std::collections::HashMap;
429    let mut last_order: HashMap<String, usize> = HashMap::new();
430
431    for msg in &mut messages {
432        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
433        if role == "assistant" {
434            if let Some(tcs) = msg.get("tool_calls").and_then(|t| t.as_array()) {
435                last_order.clear();
436                for (idx, tc) in tcs.iter().enumerate() {
437                    let id = tc
438                        .get("id")
439                        .and_then(|v| v.as_str())
440                        .or_else(|| {
441                            tc.get("function")
442                                .and_then(|f| f.get("id"))
443                                .and_then(|v| v.as_str())
444                        })
445                        .unwrap_or("");
446                    if !id.is_empty() {
447                        last_order.insert(id.to_string(), idx);
448                    }
449                }
450            }
451        } else if role == "user" && !last_order.is_empty() {
452            let Some(blocks) = msg
453                .as_object_mut()
454                .and_then(|o| o.get_mut("content_blocks"))
455                .and_then(|v| v.as_array_mut())
456            else {
457                continue;
458            };
459
460            // Collect tool_result blocks with their positions.
461            let tool_positions: Vec<usize> = blocks
462                .iter()
463                .enumerate()
464                .filter(|(_, b)| b.get("type").and_then(|v| v.as_str()) == Some("tool_result"))
465                .map(|(i, _)| i)
466                .collect();
467
468            if tool_positions.len() > 1 {
469                let start = *tool_positions
470                    .first()
471                    .expect("tool_positions has length > 1");
472                let end = *tool_positions
473                    .last()
474                    .expect("tool_positions has length > 1");
475                let is_contiguous = end - start + 1 == tool_positions.len();
476
477                if is_contiguous {
478                    // Fast path: sort the contiguous slice in place
479                    blocks[start..=end].sort_by_key(|b| {
480                        let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
481                        *last_order.get(id).unwrap_or(&0)
482                    });
483                } else {
484                    // Fallback: extract, sort, and replace for non-contiguous blocks
485                    let mut tool_blocks: Vec<JsonValue> = tool_positions
486                        .iter()
487                        .map(|&i| std::mem::take(&mut blocks[i]))
488                        .collect();
489
490                    tool_blocks.sort_by_key(|b| {
491                        let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
492                        *last_order.get(id).unwrap_or(&0)
493                    });
494
495                    for (sorted_idx, &pos) in tool_positions.iter().enumerate() {
496                        blocks[pos] = std::mem::take(&mut tool_blocks[sorted_idx]);
497                    }
498                }
499            }
500        }
501    }
502
503    messages
504}
505
506// Drop reasoning and non-essential messages before the last user message.
507pub(crate) fn drop_thinking_messages(messages: Vec<JsonValue>) -> Vec<JsonValue> {
508    let last_user_idx = find_last_user_index(&messages);
509    let mut out = Vec::with_capacity(messages.len());
510    const KEEP: &[&str] = &[
511        "user",
512        "system",
513        "tool",
514        "latest_reminder",
515        "direct_search_results",
516    ];
517
518    for (idx, mut msg) in messages.into_iter().enumerate() {
519        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
520        if KEEP.contains(&role) || last_user_idx.is_none_or(|u| idx >= u) {
521            out.push(msg);
522        } else if role == "assistant" {
523            if let Some(obj) = msg.as_object_mut() {
524                obj.remove("reasoning_content");
525            }
526            out.push(msg);
527        }
528        // developer and other roles before last_user_idx are dropped.
529    }
530    out
531}
532
533pub(crate) fn resolve_thinking_mode(
534    args: Option<&std::collections::HashMap<String, serde_json::Value>>,
535    default_mode: ThinkingMode,
536) -> ThinkingMode {
537    if let Some(enabled) = crate::thinking_bool_from_args(args) {
538        return if enabled {
539            ThinkingMode::Thinking
540        } else {
541            ThinkingMode::Chat
542        };
543    }
544    if let Some(args) = args
545        && let Some(mode) = args.get("thinking_mode").and_then(|v| v.as_str())
546    {
547        match mode {
548            "chat" => return ThinkingMode::Chat,
549            "thinking" => return ThinkingMode::Thinking,
550            _ => {}
551        }
552    }
553    default_mode
554}
555
556pub(crate) fn inject_tools_and_response_format(
557    messages_array: &mut Vec<JsonValue>,
558    req: &dyn crate::OAIChatLikeRequest,
559) -> Result<()> {
560    let tools_json = req
561        .tools()
562        .map(|t| serde_json::to_value(&t))
563        .transpose()
564        .context("Failed to convert tools to JSON")?;
565
566    // OpenAI semantics for `tool_choice: "none"`: the model must not call
567    // tools. Strip the tool definitions from the prompt so the model never
568    // sees the DSML tool instructions and cannot emit raw tool markup.
569    // response_format is kept intact. Mirrors the jinja path's
570    // exclude_tools_when_tool_choice_none handling (template/oai.rs).
571    let tools_json = match req.tool_choice() {
572        Some(ref tc) if tc.as_str() == Some("none") => None,
573        _ => tools_json,
574    };
575
576    let response_format_json = req
577        .response_format()
578        .map(|rf| serde_json::to_value(&rf))
579        .transpose()
580        .context("Failed to convert response_format to JSON")?;
581
582    if tools_json.is_some() || response_format_json.is_some() {
583        let system_idx = messages_array
584            .iter()
585            .position(|msg| msg.get("role").and_then(|r| r.as_str()) == Some("system"));
586
587        if let Some(idx) = system_idx {
588            if let Some(msg) = messages_array.get_mut(idx)
589                && let Some(obj) = msg.as_object_mut()
590            {
591                if let Some(tools) = tools_json {
592                    obj.insert("tools".to_string(), tools);
593                }
594                if let Some(rf) = response_format_json {
595                    obj.insert("response_format".to_string(), rf);
596                }
597            }
598        } else {
599            let mut system_msg = serde_json::json!({
600                "role": "system",
601                "content": ""
602            });
603            if let Some(obj) = system_msg.as_object_mut() {
604                if let Some(tools) = tools_json {
605                    obj.insert("tools".to_string(), tools);
606                }
607                if let Some(rf) = response_format_json {
608                    obj.insert("response_format".to_string(), rf);
609                }
610            }
611            messages_array.insert(0, system_msg);
612        }
613    }
614    Ok(())
615}
616
617/// Python's `repr(float)`, which `json.dumps` uses: the shortest round-trip digits,
618/// fixed notation for exponents in [-4, 16) (always with a fraction, `100.0`),
619/// otherwise scientific with a signed, two-digit exponent (`1e-06`, `1e+16`).
620fn python_float_repr(value: f64) -> String {
621    // serde_json's ryu picks the same shortest digits as Python, halfway ties
622    // included (`{:e}` breaks some differently); only the notation is respelled.
623    let shortest = serde_json::Number::from_f64(value)
624        .expect("JSON floats are finite")
625        .to_string();
626    let (sign, unsigned) = match shortest.strip_prefix('-') {
627        Some(unsigned) => ("-", unsigned),
628        None => ("", shortest.as_str()),
629    };
630    let (mantissa, exponent) = match unsigned.split_once('e') {
631        Some((mantissa, exponent)) => (mantissa, exponent.parse().expect("ryu exponent")),
632        None => (unsigned, 0),
633    };
634    let (integer, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
635    let all_digits = format!("{integer}{fraction}");
636    let significant = all_digits.trim_start_matches('0');
637    let digits = significant.trim_end_matches('0');
638    if digits.is_empty() {
639        return format!("{sign}0.0");
640    }
641    let leading_zeros = (all_digits.len() - significant.len()) as i32;
642    let exponent: i32 = exponent + integer.len() as i32 - 1 - leading_zeros;
643    if (-4..16).contains(&exponent) {
644        let point = exponent + 1;
645        let fixed = if point <= 0 {
646            format!("0.{}{digits}", "0".repeat(point.unsigned_abs() as usize))
647        } else if point as usize >= digits.len() {
648            format!("{digits}{}.0", "0".repeat(point as usize - digits.len()))
649        } else {
650            format!(
651                "{}.{}",
652                &digits[..point as usize],
653                &digits[point as usize..]
654            )
655        };
656        format!("{sign}{fixed}")
657    } else {
658        let (head, tail) = digits.split_at(1);
659        let mantissa = if tail.is_empty() {
660            head.to_string()
661        } else {
662            format!("{head}.{tail}")
663        };
664        let exponent_sign = if exponent < 0 { '-' } else { '+' };
665        format!(
666            "{sign}{mantissa}e{exponent_sign}{:02}",
667            exponent.unsigned_abs()
668        )
669    }
670}
671
672#[cfg(test)]
673mod tests {
674
675    #[test]
676    fn floats_match_python_json_dumps() {
677        for (value, python) in [
678            (0.000001, "1e-06"),
679            (0.0001, "0.0001"),
680            (0.00001234, "1.234e-05"),
681            (1e16, "1e+16"),
682            (1e15, "1000000000000000.0"),
683            (1.5e-7, "1.5e-07"),
684            (2.5, "2.5"),
685            (100.0, "100.0"),
686            (0.1, "0.1"),
687            (-0.0, "-0.0"),
688            (0.0, "0.0"),
689            (-123.456, "-123.456"),
690            (1.7976931348623157e308, "1.7976931348623157e+308"),
691            (5e-324, "5e-324"),
692            // Exactly halfway between two shortest candidates.
693            (1e15 + 0.25, "1000000000000000.2"),
694            (1e14 + 0.125, "100000000000000.12"),
695        ] {
696            assert_eq!(python_float_repr(value), python, "{value:?}");
697        }
698        let value = serde_json::json!({"a": 0.000001, "b": [1e16, 3]});
699        assert_eq!(to_json(&value), r#"{"a": 1e-06, "b": [1e+16, 3]}"#);
700    }
701
702    use super::*;
703    use serde_json::json;
704
705    #[test]
706    fn merged_user_blocks_preserve_metadata_and_order() {
707        let merged = merge_tool_messages(vec![
708            serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "result"}),
709            serde_json::json!({"role": "user", "content": "one", "wo_eos": true, "mask": [1, 0]}),
710            serde_json::json!({"role": "user", "content": "two", "task": "action"}),
711            serde_json::json!({"role": "user", "content": "separate"}),
712        ]);
713        assert_eq!(merged.len(), 2);
714        assert!(
715            merged
716                .iter()
717                .all(|message| message.get("content").is_none())
718        );
719        assert_eq!(
720            merged[0]["content_blocks"],
721            serde_json::json!([
722                {"type": "tool_result", "tool_use_id": "c1", "content": "result"},
723                {"type": "text", "text": "one"},
724                {"type": "text", "text": "two"}
725            ])
726        );
727        assert_eq!(merged[0]["task"], "action");
728        assert_eq!(merged[0]["wo_eos"], true);
729        assert_eq!(merged[0]["mask"], serde_json::json!([1, 0]));
730        assert_eq!(merged[1]["content_blocks"][0]["text"], "separate");
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}