Skip to main content

cortiq_engine/
dsv41_encoding.rs

1//! DeepSeek-V4.1 prompt encoding and completion parsing.
2//!
3//! The V4.1 checkpoint intentionally ships without a Jinja chat template.
4//! This module is a Rust port of the official `encoding/encoding.py` at the
5//! pinned model revision.  It keeps the protocol in one place for the CLI,
6//! OpenAI server, and any embedding application: spaced DSML tags, numeric
7//! reasoning effort, mid conversation system turns, tool result merging, and
8//! image ordering all follow the reference implementation.
9
10use serde::Serialize;
11use serde_json::{Map, Value, json};
12use std::collections::HashMap;
13use std::io;
14use thiserror::Error;
15
16pub const BOS_TOKEN: &str = "<|begin▁of▁sentence|>";
17pub const EOS_TOKEN: &str = "<|end▁of▁sentence|>";
18pub const THINKING_START_TOKEN: &str = "<think>";
19pub const THINKING_END_TOKEN: &str = "</think>";
20pub const DSML_TOKEN: &str = "|DSML|";
21pub const USER_SP_TOKEN: &str = "<|User|>";
22pub const ASSISTANT_SP_TOKEN: &str = "<|Assistant|>";
23pub const SYSTEM_SP_TOKEN: &str = "<|System|>";
24pub const LATEST_REMINDER_SP_TOKEN: &str = "<|latest_reminder|>";
25pub const IMAGE_PLACEHOLDER: &str = "<|deepseek_image|>";
26
27const TOOL_CALLS_BLOCK_NAME: &str = " calls";
28const TOOL_CALL_TAG_NAME: &str = " invoke";
29const TOOL_PARAMETER_TAG_NAME: &str = " parameter";
30
31/// Internal quick instruction tasks supported by the official formatter.
32pub const TASK_TOKENS: &[(&str, &str)] = &[
33    ("action", "<|action|>"),
34    ("query", "<|query|>"),
35    ("authority", "<|authority|>"),
36    ("domain", "<|domain|>"),
37    ("title", "<|title|>"),
38    ("read_url", "<|read_url|>"),
39];
40
41#[derive(Debug, Error)]
42pub enum EncodingError {
43    #[error("invalid thinking mode '{0}', expected 'chat' or 'thinking'")]
44    InvalidThinkingMode(String),
45    #[error("invalid reasoning effort '{0}', expected an integer in 1..=100 or low/high/max")]
46    InvalidReasoningEffort(String),
47    #[error("message {index} has unsupported role '{role}'")]
48    UnsupportedRole { index: usize, role: String },
49    #[error("invalid message: {0}")]
50    InvalidMessage(String),
51    #[error("invalid image block: {0}")]
52    InvalidImage(String),
53    #[error("invalid DSML tool call: {0}")]
54    InvalidToolCall(String),
55    #[error("invalid completion: {0}")]
56    InvalidCompletion(String),
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum ThinkingMode {
61    Chat,
62    Thinking,
63}
64
65impl ThinkingMode {
66    pub fn parse(value: &str) -> Result<Self, EncodingError> {
67        match value {
68            "chat" => Ok(Self::Chat),
69            "thinking" => Ok(Self::Thinking),
70            other => Err(EncodingError::InvalidThinkingMode(other.to_string())),
71        }
72    }
73
74    pub fn as_str(self) -> &'static str {
75        match self {
76            Self::Chat => "chat",
77            Self::Thinking => "thinking",
78        }
79    }
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum ReasoningEffort {
84    Budget(u8),
85    Low,
86    High,
87    Max,
88}
89
90impl Default for ReasoningEffort {
91    fn default() -> Self {
92        Self::High
93    }
94}
95
96impl ReasoningEffort {
97    pub fn budget(self) -> u8 {
98        match self {
99            Self::Budget(v) => v,
100            Self::Low => 50,
101            Self::High => 75,
102            Self::Max => 100,
103        }
104    }
105
106    pub fn parse(value: &str) -> Result<Self, EncodingError> {
107        match value {
108            "low" => Ok(Self::Low),
109            "high" => Ok(Self::High),
110            "max" => Ok(Self::Max),
111            other => other
112                .parse::<u8>()
113                .ok()
114                .filter(|v| (1..=100).contains(v))
115                .map(Self::Budget)
116                .ok_or_else(|| EncodingError::InvalidReasoningEffort(other.to_string())),
117        }
118    }
119
120    pub fn from_json(value: &Value) -> Result<Self, EncodingError> {
121        if let Some(s) = value.as_str() {
122            return match s {
123                "low" => Ok(Self::Low),
124                "high" => Ok(Self::High),
125                "max" => Ok(Self::Max),
126                _ => Err(EncodingError::InvalidReasoningEffort(value.to_string())),
127            };
128        }
129        // bool is deliberately rejected even though serde_json represents it
130        // as neither an integer nor a float.
131        if let Some(v) = value.as_i64() {
132            if (1..=100).contains(&v) {
133                return Ok(Self::Budget(v as u8));
134            }
135        }
136        Err(EncodingError::InvalidReasoningEffort(value.to_string()))
137    }
138}
139
140#[derive(Clone, Debug)]
141pub struct EncodeOptions {
142    pub thinking_mode: ThinkingMode,
143    pub context: Vec<Value>,
144    pub drop_thinking: bool,
145    pub add_default_bos_token: bool,
146    pub reasoning_effort: Option<ReasoningEffort>,
147}
148
149impl Default for EncodeOptions {
150    fn default() -> Self {
151        Self {
152            thinking_mode: ThinkingMode::Chat,
153            context: Vec::new(),
154            drop_thinking: true,
155            add_default_bos_token: true,
156            reasoning_effort: None,
157        }
158    }
159}
160
161#[derive(Clone, Debug, PartialEq)]
162pub struct EncodedPrompt {
163    pub prompt: String,
164    /// Image records in the order their placeholders occur in the current
165    /// messages. Records are intentionally JSON shaped so OpenAI, Anthropic,
166    /// local paths, and data URLs survive the protocol boundary unchanged.
167    pub images: Vec<Value>,
168}
169
170#[derive(Clone, Copy, Debug, Default)]
171struct PythonJsonFormatter;
172
173impl serde_json::ser::Formatter for PythonJsonFormatter {
174    fn begin_array_value<W>(&mut self, writer: &mut W, first: bool) -> io::Result<()>
175    where
176        W: ?Sized + io::Write,
177    {
178        if first {
179            Ok(())
180        } else {
181            writer.write_all(b", ")
182        }
183    }
184
185    fn begin_object_key<W>(&mut self, writer: &mut W, first: bool) -> io::Result<()>
186    where
187        W: ?Sized + io::Write,
188    {
189        if first {
190            Ok(())
191        } else {
192            writer.write_all(b", ")
193        }
194    }
195
196    fn begin_object_value<W>(&mut self, writer: &mut W) -> io::Result<()>
197    where
198        W: ?Sized + io::Write,
199    {
200        writer.write_all(b": ")
201    }
202}
203
204pub fn to_json(value: &Value) -> String {
205    let mut bytes = Vec::new();
206    let mut serializer = serde_json::Serializer::with_formatter(&mut bytes, PythonJsonFormatter);
207    if value.serialize(&mut serializer).is_err() {
208        return "null".to_string();
209    }
210    String::from_utf8(bytes).unwrap_or_else(|_| "null".to_string())
211}
212
213fn object(value: &Value) -> Result<&Map<String, Value>, EncodingError> {
214    value
215        .as_object()
216        .ok_or_else(|| EncodingError::InvalidMessage("expected a JSON object".to_string()))
217}
218
219fn role(value: &Value, index: usize) -> Result<&str, EncodingError> {
220    object(value)?
221        .get("role")
222        .and_then(Value::as_str)
223        .ok_or_else(|| EncodingError::InvalidMessage(format!("message {index} has no string role")))
224}
225
226fn text(value: Option<&Value>) -> String {
227    value.and_then(Value::as_str).unwrap_or("").to_string()
228}
229
230/// Split a qualified tool name and validate explicit namespace metadata.
231fn split_tool_name(
232    name: &str,
233    namespace: Option<&str>,
234) -> Result<(Option<String>, String), EncodingError> {
235    let (mut ns, bare) = if let Some((prefix, suffix)) = name.split_once("::") {
236        if suffix.contains("::") {
237            return Err(EncodingError::InvalidToolCall(format!(
238                "tool name contains multiple '::': {name}"
239            )));
240        }
241        if let Some(given) = namespace {
242            if given != prefix {
243                return Err(EncodingError::InvalidToolCall(format!(
244                    "conflicting tool namespaces: {given} != {prefix}"
245                )));
246            }
247        }
248        (Some(prefix.to_string()), suffix.to_string())
249    } else {
250        (namespace.map(str::to_string), name.to_string())
251    };
252    if bare.contains("::") || ns.as_deref().is_some_and(|v| v.contains("::")) {
253        return Err(EncodingError::InvalidToolCall(format!(
254            "invalid qualified tool name: {name}"
255        )));
256    }
257    if bare.is_empty() {
258        return Err(EncodingError::InvalidToolCall(
259            "tool name is empty".to_string(),
260        ));
261    }
262    Ok((ns.take(), bare))
263}
264
265fn tool_name_for_encoding(tool: &Value) -> Result<String, EncodingError> {
266    let map = object(tool)?;
267    let name = map
268        .get("name")
269        .and_then(Value::as_str)
270        .ok_or_else(|| EncodingError::InvalidToolCall("tool definition has no name".to_string()))?;
271    let namespace = match map.get("namespace") {
272        Some(Value::String(v)) => Some(v.as_str()),
273        Some(Value::Object(v)) => v.get("name").and_then(Value::as_str),
274        _ => None,
275    };
276    let (ns, bare) = split_tool_name(name, namespace)?;
277    Ok(ns.map(|v| format!("{v}::{bare}")).unwrap_or(bare))
278}
279
280fn tools_from_openai_format(tools: &Value) -> Result<Vec<Value>, EncodingError> {
281    let list = tools
282        .as_array()
283        .ok_or_else(|| EncodingError::InvalidToolCall("tools must be an array".to_string()))?;
284    let mut out = Vec::with_capacity(list.len());
285    for tool in list {
286        let map = object(tool)?;
287        let function = map
288            .get("function")
289            .and_then(Value::as_object)
290            .ok_or_else(|| {
291                EncodingError::InvalidToolCall("OpenAI tool has no function object".to_string())
292            })?;
293        let mut f = function.clone();
294        if let Some(ns) = map.get("namespace") {
295            f.insert("namespace".to_string(), ns.clone());
296        }
297        let encoded = tool_name_for_encoding(&Value::Object(f.clone()))?;
298        f.insert("name".to_string(), Value::String(encoded));
299        if let Some(Value::Object(ns)) = f.get("namespace") {
300            if let Some(description) = ns.get("description").and_then(Value::as_str) {
301                let old = f.get("description").and_then(Value::as_str).unwrap_or("");
302                f.insert(
303                    "description".to_string(),
304                    Value::String(format!("{description}\n{old}")),
305                );
306            }
307        }
308        // The internal schema carries namespace only while constructing the
309        // qualified name.  The official formatter removes it from the
310        // emitted function object regardless of whether the value was a
311        // string or an object.
312        f.remove("namespace");
313        out.push(Value::Object(f));
314    }
315    Ok(out)
316}
317
318fn tool_calls_from_openai_format(value: &Value) -> Result<Vec<Value>, EncodingError> {
319    let calls = value
320        .as_array()
321        .ok_or_else(|| EncodingError::InvalidToolCall("tool_calls must be an array".to_string()))?;
322    let mut out = Vec::with_capacity(calls.len());
323    for call in calls {
324        let map = object(call)?;
325        let function = map
326            .get("function")
327            .and_then(Value::as_object)
328            .ok_or_else(|| {
329                EncodingError::InvalidToolCall("tool call has no function object".to_string())
330            })?;
331        let name = function
332            .get("name")
333            .and_then(Value::as_str)
334            .ok_or_else(|| {
335                EncodingError::InvalidToolCall("tool call has no function name".to_string())
336            })?;
337        let namespace = map
338            .get("namespace")
339            .and_then(Value::as_str)
340            .or_else(|| function.get("namespace").and_then(Value::as_str));
341        let (ns, bare) = split_tool_name(name, namespace)?;
342        let mut result = Map::new();
343        result.insert("name".to_string(), Value::String(bare));
344        result.insert(
345            "arguments".to_string(),
346            function
347                .get("arguments")
348                .cloned()
349                .unwrap_or(Value::String(String::new())),
350        );
351        if let Some(ns) = ns {
352            result.insert("namespace".to_string(), Value::String(ns));
353        }
354        out.push(Value::Object(result));
355    }
356    Ok(out)
357}
358
359fn encode_arguments_to_dsml(tool_call: &Value) -> Result<String, EncodingError> {
360    let map = object(tool_call)?;
361    let mut arguments = map.get("arguments").cloned().unwrap_or(Value::Null);
362    if !arguments.is_object() {
363        for _ in 0..2 {
364            if let Some(s) = arguments.as_str() {
365                if let Ok(v) = serde_json::from_str::<Value>(s) {
366                    arguments = v;
367                } else {
368                    break;
369                }
370            } else {
371                break;
372            }
373        }
374    }
375    let mut fields = Vec::new();
376    if let Some(args) = arguments.as_object() {
377        for (key, value) in args {
378            let is_string = value.is_string();
379            let encoded = value
380                .as_str()
381                .map(str::to_string)
382                .unwrap_or_else(|| to_json(value));
383            fields.push(format!(
384                "<{DSML_TOKEN}{TOOL_PARAMETER_TAG_NAME} name=\"{key}\" string=\"{}\">{encoded}</{DSML_TOKEN}{TOOL_PARAMETER_TAG_NAME}>",
385                if is_string { "true" } else { "false" }
386            ));
387        }
388    } else {
389        fields.push(format!(
390            "<{DSML_TOKEN}{TOOL_PARAMETER_TAG_NAME} name=\"arguments\" string=\"false\">{}</{DSML_TOKEN}{TOOL_PARAMETER_TAG_NAME}>",
391            to_json(&arguments)
392        ));
393    }
394    Ok(fields.join("\n"))
395}
396
397fn render_tools(tools: &Value) -> Result<String, EncodingError> {
398    let functions = tools_from_openai_format(tools)?;
399    let schemas = functions.iter().map(to_json).collect::<Vec<_>>().join("\n");
400    Ok(format!(
401        "## Tools\n\nYou 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_NAME}>\" block like the following:\n\n<{DSML_TOKEN}{TOOL_CALLS_BLOCK_NAME}>\n<{DSML_TOKEN}{TOOL_CALL_TAG_NAME} name=\"$TOOL_NAME\">\n<{DSML_TOKEN}{TOOL_PARAMETER_TAG_NAME} name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</{DSML_TOKEN}{TOOL_PARAMETER_TAG_NAME}>\n...\n</{DSML_TOKEN}{TOOL_CALL_TAG_NAME}>\n<{DSML_TOKEN}{TOOL_CALL_TAG_NAME} name=\"$TOOL_NAME2\">\n...\n</{DSML_TOKEN}{TOOL_CALL_TAG_NAME}>\n</{DSML_TOKEN}{TOOL_CALLS_BLOCK_NAME}>\n\nString 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\"`.\n\nIf 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.\n\nOtherwise, output directly after {THINKING_END_TOKEN} with tool calls or final response.\n\n### Available Tool Schemas\n\n{schemas}\n\nYou MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.\n"
402    ))
403}
404
405fn decode_dsml_to_arguments(
406    tool_name: &str,
407    args: &[(String, String, String)],
408) -> Result<Value, EncodingError> {
409    let mut fields = Vec::with_capacity(args.len());
410    for (key, value, string) in args {
411        let encoded = if string == "true" {
412            to_json(&Value::String(value.clone()))
413        } else {
414            value.clone()
415        };
416        fields.push(format!(
417            "{}: {}",
418            to_json(&Value::String(key.clone())),
419            encoded
420        ));
421    }
422    let arguments = format!("{{{}}}", fields.join(", "));
423    let (namespace, name) = split_tool_name(tool_name, None)?;
424    let mut out = Map::new();
425    out.insert("name".to_string(), Value::String(name));
426    out.insert("arguments".to_string(), Value::String(arguments));
427    if let Some(namespace) = namespace {
428        out.insert("namespace".to_string(), Value::String(namespace));
429    }
430    Ok(Value::Object(out))
431}
432
433/// Convert compact `<image>path</image>` input to OpenAI style content blocks.
434pub fn parse_tagged_text(input: &str) -> Result<Value, EncodingError> {
435    let mut blocks = Vec::new();
436    let mut cursor = 0usize;
437    let mut found = false;
438    while let Some(rel) = input[cursor..].find("<image>") {
439        let start = cursor + rel;
440        if input[cursor..start].contains("</image>") {
441            return Err(EncodingError::InvalidImage(
442                "malformed <image>path</image> tag".to_string(),
443            ));
444        }
445        let end_rel = input[start + 7..]
446            .find("</image>")
447            .ok_or_else(|| EncodingError::InvalidImage("malformed <image> tag".to_string()))?;
448        let end = start + 7 + end_rel;
449        if start > cursor {
450            blocks.push(json!({"type":"text", "text": &input[cursor..start]}));
451        }
452        let path = &input[start + 7..end];
453        if path.is_empty() {
454            return Err(EncodingError::InvalidImage(
455                "image path must not be empty".to_string(),
456            ));
457        }
458        blocks.push(json!({"type":"image_url", "image_url":{"url":path}}));
459        cursor = end + 8;
460        found = true;
461    }
462    if input[cursor..].contains("<image>") || input[cursor..].contains("</image>") {
463        return Err(EncodingError::InvalidImage(
464            "malformed <image>path</image> tag".to_string(),
465        ));
466    }
467    if !found {
468        return Ok(Value::String(input.to_string()));
469    }
470    if cursor < input.len() {
471        blocks.push(json!({"type":"text", "text": &input[cursor..]}));
472    }
473    Ok(Value::Array(blocks))
474}
475
476fn is_image_block(block: &Value) -> bool {
477    matches!(
478        block.get("type").and_then(Value::as_str),
479        Some("image" | "image_url")
480    )
481}
482
483fn extract_image(block: &Value) -> Result<Value, EncodingError> {
484    let map = object(block)?;
485    let mut record = Map::new();
486    record.insert("type".to_string(), Value::String("image".to_string()));
487    if map.get("type").and_then(Value::as_str) == Some("image_url") {
488        match map.get("image_url") {
489            Some(Value::String(url)) => {
490                record.insert("url".to_string(), Value::String(url.clone()));
491            }
492            Some(Value::Object(image_url)) => {
493                record.insert(
494                    "url".to_string(),
495                    image_url
496                        .get("url")
497                        .cloned()
498                        .unwrap_or(Value::String(String::new())),
499                );
500            }
501            _ => {}
502        }
503    } else {
504        for key in ["source", "url", "data"] {
505            if let Some(value) = map.get(key) {
506                record.insert(key.to_string(), value.clone());
507            }
508        }
509    }
510    if !["source", "url", "data"]
511        .iter()
512        .any(|key| record.get(*key).is_some_and(json_truthy))
513    {
514        return Err(EncodingError::InvalidImage(
515            "image block has no source".to_string(),
516        ));
517    }
518    Ok(Value::Object(record))
519}
520
521fn json_truthy(value: &Value) -> bool {
522    match value {
523        Value::Null => false,
524        Value::Bool(value) => *value,
525        Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
526        Value::String(value) => !value.is_empty(),
527        Value::Array(value) => !value.is_empty(),
528        Value::Object(value) => !value.is_empty(),
529    }
530}
531
532fn process_image_blocks(blocks: &[Value]) -> Result<(Vec<Value>, Vec<Value>), EncodingError> {
533    let mut out = Vec::with_capacity(blocks.len());
534    let mut images = Vec::new();
535    for block in blocks {
536        if !block.is_object() {
537            out.push(block.clone());
538            continue;
539        }
540        if is_image_block(block) {
541            out.push(json!({"type":"text", "text":IMAGE_PLACEHOLDER}));
542            images.push(extract_image(block)?);
543            continue;
544        }
545        if block.get("type").and_then(Value::as_str) == Some("tool_result")
546            && block.get("content").and_then(Value::as_array).is_some()
547        {
548            let (nested, nested_images) =
549                process_image_blocks(block.get("content").and_then(Value::as_array).unwrap())?;
550            let mut copy = object(block)?.clone();
551            copy.insert("content".to_string(), Value::Array(nested));
552            out.push(Value::Object(copy));
553            images.extend(nested_images);
554            continue;
555        }
556        if block.get("type").and_then(Value::as_str) == Some("text") {
557            let value = text(block.get("text"));
558            if value.contains(IMAGE_PLACEHOLDER) {
559                return Err(EncodingError::InvalidImage(
560                    "text blocks must use image content blocks for the image placeholder"
561                        .to_string(),
562                ));
563            }
564        }
565        out.push(block.clone());
566    }
567    Ok((out, images))
568}
569
570fn validate_no_image_tokens(message: &Value) -> Result<(), EncodingError> {
571    let map = object(message)?;
572    for key in ["content", "reasoning_content"] {
573        if map
574            .get(key)
575            .and_then(Value::as_str)
576            .is_some_and(|s| s.contains(IMAGE_PLACEHOLDER))
577        {
578            return Err(EncodingError::InvalidImage(format!(
579                "message {key} contains {IMAGE_PLACEHOLDER}; use an image content block"
580            )));
581        }
582    }
583    Ok(())
584}
585
586/// Normalize image blocks and return image records in prompt order.
587pub fn process_image_messages(
588    messages: &[Value],
589) -> Result<(Vec<Value>, Vec<Value>), EncodingError> {
590    let mut processed = Vec::with_capacity(messages.len());
591    let mut images = Vec::new();
592    for original in messages {
593        validate_no_image_tokens(original)?;
594        let mut message = object(original)?.clone();
595        if message.get("content_blocks").is_none() {
596            if let Some(Value::Array(blocks)) = message.get("content") {
597                message.insert("content_blocks".to_string(), Value::Array(blocks.clone()));
598                message.remove("content");
599            }
600        }
601        if let Some(Value::Array(blocks)) = message
602            .get("content_blocks")
603            .filter(|blocks| !blocks.as_array().map_or(true, |items| items.is_empty()))
604        {
605            let (new_blocks, new_images) = process_image_blocks(blocks)?;
606            let strings = new_blocks
607                .iter()
608                .filter_map(|block| {
609                    (block.get("type").and_then(Value::as_str) == Some("text"))
610                        .then(|| text(block.get("text")))
611                })
612                .collect::<Vec<_>>();
613            message.insert("content_blocks".to_string(), Value::Array(new_blocks));
614            if !message.get("content").is_some_and(Value::is_string) {
615                message.insert("content".to_string(), Value::String(strings.join("\n\n")));
616            }
617            images.extend(new_images);
618        }
619        processed.push(Value::Object(message));
620    }
621    Ok((processed, images))
622}
623
624/// Merge standalone OpenAI `tool` messages into a preceding user content list.
625pub fn merge_tool_messages(messages: &[Value]) -> Result<Vec<Value>, EncodingError> {
626    let mut merged = Vec::new();
627    for original in messages {
628        let message = object(original)?.clone();
629        let message_role = message.get("role").and_then(Value::as_str).unwrap_or("");
630        if message_role == "tool" {
631            let block = json!({
632                "type":"tool_result",
633                "tool_use_id": message.get("tool_call_id").cloned().unwrap_or(Value::String(String::new())),
634                "content": message.get("content").cloned().unwrap_or(Value::String(String::new())),
635            });
636            let can_append = merged.last().is_some_and(|m: &Value| {
637                m.get("role").and_then(Value::as_str) == Some("user")
638                    && m.get("content_blocks").is_some()
639            });
640            if can_append {
641                merged.last_mut().unwrap()["content_blocks"]
642                    .as_array_mut()
643                    .unwrap()
644                    .push(block);
645            } else {
646                merged.push(json!({"role":"user", "content_blocks":[block]}));
647            }
648        } else if message_role == "user" {
649            let blocks = message
650                .get("content_blocks")
651                .filter(|value| !value.is_null())
652                .cloned()
653                .unwrap_or_else(|| {
654                    json!([{"type":"text", "text": message.get("content").cloned().unwrap_or(Value::String(String::new()))}])
655                });
656            let can_append = merged.last().is_some_and(|m: &Value| {
657                m.get("role").and_then(Value::as_str) == Some("user")
658                    && m.get("content_blocks").is_some()
659                    && m.get("task").is_none()
660            });
661            if can_append {
662                let dst = merged.last_mut().unwrap()["content_blocks"]
663                    .as_array_mut()
664                    .unwrap();
665                if let Some(src) = blocks.as_array() {
666                    dst.extend(src.iter().cloned());
667                }
668            } else {
669                let mut copy = message;
670                copy.insert("content_blocks".to_string(), blocks);
671                merged.push(Value::Object(copy));
672            }
673        } else {
674            merged.push(Value::Object(message));
675        }
676    }
677    Ok(merged)
678}
679
680/// Stable tool result order follows the preceding assistant's tool call order.
681pub fn sort_tool_results_by_call_order(messages: &mut [Value]) {
682    let mut order: HashMap<String, usize> = HashMap::new();
683    for message in messages {
684        match message.get("role").and_then(Value::as_str) {
685            Some("assistant") => {
686                if let Some(calls) = message
687                    .get("tool_calls")
688                    .filter(|value| json_truthy(value))
689                    .and_then(Value::as_array)
690                {
691                    order.clear();
692                    for (index, call) in calls.iter().enumerate() {
693                        let id = call.get("id").and_then(Value::as_str).or_else(|| {
694                            call.get("function")
695                                .and_then(|f| f.get("id"))
696                                .and_then(Value::as_str)
697                        });
698                        if let Some(id) = id {
699                            order.insert(id.to_string(), index);
700                        }
701                    }
702                }
703            }
704            Some("user") => {
705                if let Some(blocks) = message
706                    .get_mut("content_blocks")
707                    .and_then(Value::as_array_mut)
708                {
709                    let mut tools = blocks
710                        .iter()
711                        .filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
712                        .cloned()
713                        .collect::<Vec<_>>();
714                    if tools.len() > 1 && !order.is_empty() {
715                        tools.sort_by_key(|b| {
716                            order
717                                .get(b.get("tool_use_id").and_then(Value::as_str).unwrap_or(""))
718                                .copied()
719                                .unwrap_or(0)
720                        });
721                        let mut next = 0;
722                        for block in blocks.iter_mut() {
723                            if block.get("type").and_then(Value::as_str) == Some("tool_result") {
724                                *block = tools[next].clone();
725                                next += 1;
726                            }
727                        }
728                    }
729                }
730            }
731            _ => {}
732        }
733    }
734}
735
736fn last_user_index(messages: &[Value]) -> isize {
737    messages
738        .iter()
739        .enumerate()
740        .rev()
741        .find(|(idx, m)| {
742            let role = m.get("role").and_then(Value::as_str);
743            role == Some("user") || (role == Some("system") && *idx > 0)
744        })
745        .map(|(i, _)| i as isize)
746        .unwrap_or(-1)
747}
748
749fn render_reasoning_effort(
750    index: usize,
751    mode: ThinkingMode,
752    effort: Option<ReasoningEffort>,
753) -> String {
754    if index == 0 && mode == ThinkingMode::Thinking {
755        let budget = effort.unwrap_or_default().budget();
756        format!(
757            "Reasoning Effort: {budget} (range 1-100, the higher the value, the more thorough the reasoning)\n\n"
758        )
759    } else {
760        String::new()
761    }
762}
763
764fn render_content_blocks(blocks: &[Value]) -> String {
765    blocks
766        .iter()
767        .map(|block| match block.get("type").and_then(Value::as_str) {
768            Some("text") => text(block.get("text")),
769            Some("tool_result") => {
770                let content = block.get("content");
771                let rendered = if let Some(parts) = content.and_then(Value::as_array) {
772                    parts
773                        .iter()
774                        .map(|part| {
775                            if part.get("type").and_then(Value::as_str) == Some("text") {
776                                text(part.get("text"))
777                            } else {
778                                format!(
779                                    "[Unsupported {}]",
780                                    part.get("type").and_then(Value::as_str).unwrap_or("block")
781                                )
782                            }
783                        })
784                        .collect::<Vec<_>>()
785                        .join("\n\n")
786                } else {
787                    // Python's formatter inserts a string tool result as-is;
788                    // JSON quoting here would change the prompt protocol.
789                    content
790                        .map(|value| {
791                            value
792                                .as_str()
793                                .map(str::to_string)
794                                .unwrap_or_else(|| to_json(value))
795                        })
796                        .unwrap_or_default()
797                };
798                format!("<tool_result>{rendered}</tool_result>")
799            }
800            Some(kind) => format!("[Unsupported {kind}]"),
801            None => String::new(),
802        })
803        .collect::<Vec<_>>()
804        .join("\n\n")
805}
806
807fn task_token(task: &str) -> Option<&'static str> {
808    TASK_TOKENS
809        .iter()
810        .find(|(name, _)| *name == task)
811        .map(|(_, token)| *token)
812}
813
814/// Render one message exactly as the official Python implementation does.
815pub fn render_message(
816    index: usize,
817    messages: &[Value],
818    options: &EncodeOptions,
819) -> Result<String, EncodingError> {
820    let message = object(messages.get(index).ok_or_else(|| {
821        EncodingError::InvalidMessage(format!("message index {index} out of range"))
822    })?)?;
823    let message_value = Value::Object(message.clone());
824    let message_role = role(&message_value, index)?;
825    let last_user = last_user_index(messages);
826    let effort = render_reasoning_effort(index, options.thinking_mode, options.reasoning_effort);
827    let mut prompt = if index == 0 && (!effort.is_empty() || message_role == "system") {
828        SYSTEM_SP_TOKEN.to_string()
829    } else {
830        String::new()
831    };
832    prompt.push_str(&effort);
833
834    match message_role {
835        "system" => {
836            if index > 0 {
837                prompt.push_str(SYSTEM_SP_TOKEN);
838            }
839            prompt.push_str(&text(message.get("content")));
840            if let Some(tools) = message.get("tools").filter(|value| json_truthy(value)) {
841                prompt.push_str("\n\n");
842                prompt.push_str(&render_tools(tools)?);
843            }
844            if let Some(schema) = message
845                .get("response_format")
846                .filter(|value| json_truthy(value))
847            {
848                prompt.push_str("\n\n## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n");
849                prompt.push_str(&to_json(schema));
850            }
851        }
852        "user" => {
853            prompt.push_str(USER_SP_TOKEN);
854            if let Some(blocks) = message
855                .get("content_blocks")
856                .and_then(Value::as_array)
857                .filter(|blocks| !blocks.is_empty())
858            {
859                prompt.push_str(&render_content_blocks(blocks));
860            } else {
861                prompt.push_str(&text(message.get("content")));
862            }
863        }
864        "latest_reminder" => {
865            prompt.push_str(LATEST_REMINDER_SP_TOKEN);
866            prompt.push_str(&text(message.get("content")));
867        }
868        "tool" => {
869            return Err(EncodingError::UnsupportedRole {
870                index,
871                role: "tool".to_string(),
872            });
873        }
874        "assistant" => {
875            let mut reasoning = String::new();
876            let mut tool_calls = String::new();
877            if let Some(calls) = message.get("tool_calls") {
878                let calls = tool_calls_from_openai_format(calls)?;
879                if !calls.is_empty() {
880                    let mut encoded = Vec::new();
881                    for call in calls {
882                        let cm = object(&call)?;
883                        let name = tool_name_for_encoding(&call)?;
884                        encoded.push(format!(
885                            "<{DSML_TOKEN}{TOOL_CALL_TAG_NAME} name=\"{name}\">\n{}\n</{DSML_TOKEN}{TOOL_CALL_TAG_NAME}>",
886                            encode_arguments_to_dsml(&Value::Object(cm.clone()))?
887                        ));
888                    }
889                    tool_calls = format!(
890                        "\n\n<{DSML_TOKEN}{TOOL_CALLS_BLOCK_NAME}>\n{}\n</{DSML_TOKEN}{TOOL_CALLS_BLOCK_NAME}>",
891                        encoded.join("\n")
892                    );
893                }
894            }
895            let previous_has_task = index > 0
896                && messages[index - 1]
897                    .get("task")
898                    .is_some_and(|v| !v.is_null());
899            if options.thinking_mode == ThinkingMode::Thinking && !previous_has_task {
900                let keep = !options.drop_thinking || index as isize > last_user;
901                if keep {
902                    reasoning.push_str(&text(message.get("reasoning_content")));
903                    reasoning.push_str(THINKING_END_TOKEN);
904                }
905            }
906            prompt.push_str(&reasoning);
907            prompt.push_str(&text(message.get("content")));
908            prompt.push_str(&tool_calls);
909            if !message
910                .get("wo_eos")
911                .and_then(Value::as_bool)
912                .unwrap_or(false)
913            {
914                prompt.push_str(EOS_TOKEN);
915            }
916        }
917        other => {
918            return Err(EncodingError::UnsupportedRole {
919                index,
920                role: other.to_string(),
921            });
922        }
923    }
924
925    // The assistant generation header belongs after a user/mid-system turn
926    // when the following message is an assistant (or when this is the final
927    // message).  A non-assistant next turn is a completed transition and
928    // leaves the header to that later turn, matching the Python formatter.
929    let next_is_non_assistant = index + 1 < messages.len()
930        && !matches!(
931            messages[index + 1].get("role").and_then(Value::as_str),
932            Some("assistant" | "latest_reminder")
933        );
934    if next_is_non_assistant {
935        return Ok(prompt);
936    }
937    if let Some(task) = message.get("task").and_then(Value::as_str) {
938        let token = task_token(task)
939            .ok_or_else(|| EncodingError::InvalidMessage(format!("invalid task '{task}'")))?;
940        if task != "action" {
941            prompt.push_str(token);
942        } else {
943            prompt.push_str(ASSISTANT_SP_TOKEN);
944            prompt.push_str(if options.thinking_mode == ThinkingMode::Thinking {
945                THINKING_START_TOKEN
946            } else {
947                THINKING_END_TOKEN
948            });
949            prompt.push_str(token);
950        }
951    } else if message_role == "user" || (message_role == "system" && index > 0) {
952        prompt.push_str(ASSISTANT_SP_TOKEN);
953        if options.thinking_mode == ThinkingMode::Thinking
954            && (!options.drop_thinking || index as isize >= last_user)
955        {
956            prompt.push_str(THINKING_START_TOKEN);
957        } else {
958            prompt.push_str(THINKING_END_TOKEN);
959        }
960    }
961    Ok(prompt)
962}
963
964fn drop_thinking_messages(messages: &[Value]) -> Vec<Value> {
965    let last = last_user_index(messages);
966    messages
967        .iter()
968        .enumerate()
969        .filter_map(|(index, message)| {
970            let role = message.get("role").and_then(Value::as_str).unwrap_or("");
971            let keep_role = matches!(
972                role,
973                "user" | "system" | "tool" | "latest_reminder" | "direct_search_results"
974            );
975            if keep_role || index as isize >= last {
976                return Some(message.clone());
977            }
978            if role == "assistant" {
979                let mut copy = object(message).ok()?.clone();
980                copy.remove("reasoning_content");
981                Some(Value::Object(copy))
982            } else {
983                None
984            }
985        })
986        .collect()
987}
988
989fn encode_messages_text(
990    messages: &[Value],
991    options: &EncodeOptions,
992    context: &[Value],
993) -> Result<String, EncodingError> {
994    let mut current = merge_tool_messages(messages)?;
995    // The reference sorts the original context plus the merged current turn,
996    // then slices back to the current portion using the original context
997    // length.  It subsequently merges/sorts context independently.  Keep
998    // that ordering: tool results belonging to a prior assistant call must
999    // never move across the context/current boundary.
1000    let original_context_len = context.len();
1001    let mut sorted_current = context.to_vec();
1002    sorted_current.append(&mut current);
1003    sort_tool_results_by_call_order(&mut sorted_current);
1004    let current = sorted_current
1005        .into_iter()
1006        .skip(original_context_len)
1007        .collect::<Vec<_>>();
1008    let mut ctx = merge_tool_messages(context)?;
1009    sort_tool_results_by_call_order(&mut ctx);
1010    let mut full = ctx.clone();
1011    full.extend(current.iter().cloned());
1012    let mut prompt = if options.add_default_bos_token && context.is_empty() {
1013        BOS_TOKEN.to_string()
1014    } else {
1015        String::new()
1016    };
1017    let effective_drop = options.drop_thinking
1018        && !full.iter().any(|m| {
1019            m.get("tools")
1020                .and_then(Value::as_array)
1021                .is_some_and(|tools| !tools.is_empty())
1022        });
1023    let (render_messages, render_count, context_len) =
1024        if options.thinking_mode == ThinkingMode::Thinking && effective_drop {
1025            let reduced_full = drop_thinking_messages(&full);
1026            let reduced_context = drop_thinking_messages(&ctx);
1027            let count = reduced_full.len().saturating_sub(reduced_context.len());
1028            (reduced_full, count, reduced_context.len())
1029        } else {
1030            // `current` may gain/lose entries while tool messages are merged; the
1031            // official implementation renders the merged current list.
1032            (full, current.len(), ctx.len())
1033        };
1034    let render_options = EncodeOptions {
1035        drop_thinking: effective_drop,
1036        ..options.clone()
1037    };
1038    for index in 0..render_count {
1039        prompt.push_str(&render_message(
1040            index + context_len,
1041            &render_messages,
1042            &render_options,
1043        )?);
1044    }
1045    Ok(prompt)
1046}
1047
1048/// Encode current messages. `images` contains only records belonging to the
1049/// current turn, matching the official helper's context-cache contract.
1050pub fn encode_messages(
1051    messages: &[Value],
1052    options: &EncodeOptions,
1053) -> Result<EncodedPrompt, EncodingError> {
1054    let (processed_context, _) = if options.context.is_empty() {
1055        (Vec::new(), Vec::new())
1056    } else {
1057        process_image_messages(&options.context)?
1058    };
1059    let (processed, images) = process_image_messages(messages)?;
1060    let mut render_options = options.clone();
1061    render_options.context = processed_context;
1062    let prompt = encode_messages_text(&processed, &render_options, &render_options.context)?;
1063    Ok(EncodedPrompt { prompt, images })
1064}
1065
1066pub fn encode_messages_simple(
1067    messages: &[Value],
1068    thinking_mode: ThinkingMode,
1069) -> Result<EncodedPrompt, EncodingError> {
1070    encode_messages(
1071        messages,
1072        &EncodeOptions {
1073            thinking_mode,
1074            ..EncodeOptions::default()
1075        },
1076    )
1077}
1078
1079pub fn encode_case(
1080    case: &Value,
1081    default_mode: ThinkingMode,
1082) -> Result<EncodedPrompt, EncodingError> {
1083    let map = object(case)?;
1084    let messages = map
1085        .get("messages")
1086        .and_then(Value::as_array)
1087        .ok_or_else(|| EncodingError::InvalidMessage("case has no messages array".to_string()))?;
1088    let mode = map
1089        .get("thinking_mode")
1090        .and_then(Value::as_str)
1091        .map(ThinkingMode::parse)
1092        .transpose()?
1093        .unwrap_or(default_mode);
1094    let effort = map
1095        .get("reasoning_effort")
1096        .filter(|value| !value.is_null())
1097        .map(ReasoningEffort::from_json)
1098        .transpose()?;
1099    let context = map
1100        .get("context")
1101        .and_then(Value::as_array)
1102        .cloned()
1103        .unwrap_or_default();
1104    encode_messages(
1105        messages,
1106        &EncodeOptions {
1107            thinking_mode: mode,
1108            reasoning_effort: effort,
1109            context,
1110            ..EncodeOptions::default()
1111        },
1112    )
1113}
1114
1115fn read_until_stop(index: usize, input: &str, stops: &[&str]) -> (usize, String, Option<String>) {
1116    let mut position = input.len();
1117    let mut matched = None;
1118    for stop in stops {
1119        if let Some(relative) = input[index..].find(stop) {
1120            let candidate = index + relative;
1121            if candidate < position {
1122                position = candidate;
1123                matched = Some((*stop).to_string());
1124            }
1125        }
1126    }
1127    match matched {
1128        Some(stop) => (
1129            position + stop.len(),
1130            input[index..position].to_string(),
1131            Some(stop),
1132        ),
1133        None => (input.len(), input[index..].to_string(), None),
1134    }
1135}
1136
1137pub fn parse_tool_calls(
1138    mut index: usize,
1139    input: &str,
1140) -> Result<(usize, Option<String>, Vec<Value>), EncodingError> {
1141    let calls_end = format!("</{DSML_TOKEN}{TOOL_CALLS_BLOCK_NAME}>");
1142    let call_start = format!("<{DSML_TOKEN}{TOOL_CALL_TAG_NAME}");
1143    let call_end = format!("</{DSML_TOKEN}{TOOL_CALL_TAG_NAME}");
1144    let parameter_start = format!("<{DSML_TOKEN}{TOOL_PARAMETER_TAG_NAME}");
1145    let parameter_end = format!("/{DSML_TOKEN}{TOOL_PARAMETER_TAG_NAME}");
1146    let mut calls = Vec::new();
1147    let mut last_stop = None;
1148    while index < input.len() {
1149        let (next, content, stop) = read_until_stop(index, input, &[&call_start, &calls_end]);
1150        index = next;
1151        last_stop = stop.clone();
1152        if content != ">\n" {
1153            return Err(EncodingError::InvalidCompletion(format!(
1154                "tool call header expected '>\\n', got {content:?}"
1155            )));
1156        }
1157        if stop.as_deref() == Some(calls_end.as_str()) {
1158            break;
1159        }
1160        if stop.is_none() {
1161            return Err(EncodingError::InvalidCompletion(
1162                "missing DSML tool-call tag".to_string(),
1163            ));
1164        }
1165        let (next, name_content, mut stop) =
1166            read_until_stop(index, input, &[&parameter_start, &call_end]);
1167        index = next;
1168        let name = name_content
1169            .strip_prefix(" name=\"")
1170            .and_then(|s| s.strip_suffix("\">\n"))
1171            .ok_or_else(|| {
1172                EncodingError::InvalidCompletion(format!(
1173                    "invalid tool name header {name_content:?}"
1174                ))
1175            })?;
1176        let mut args = Vec::new();
1177        while stop.as_deref() == Some(parameter_start.as_str()) {
1178            let (next, parameter, matched) = read_until_stop(index, input, &[&parameter_end]);
1179            index = next;
1180            let body = parameter.strip_prefix(" name=\"").ok_or_else(|| {
1181                EncodingError::InvalidCompletion("invalid parameter header".to_string())
1182            })?;
1183            let (name_end, rest) = body.split_once("\" string=\"").ok_or_else(|| {
1184                EncodingError::InvalidCompletion(format!("invalid parameter {parameter:?}"))
1185            })?;
1186            let (string, value) = rest.split_once("\">").ok_or_else(|| {
1187                EncodingError::InvalidCompletion(format!("invalid parameter {parameter:?}"))
1188            })?;
1189            let (value, close) = value.rsplit_once("<").ok_or_else(|| {
1190                EncodingError::InvalidCompletion(format!("invalid parameter {parameter:?}"))
1191            })?;
1192            if close != "" || !matches!(string, "true" | "false") {
1193                return Err(EncodingError::InvalidCompletion(format!(
1194                    "invalid parameter {parameter:?}"
1195                )));
1196            }
1197            if args
1198                .iter()
1199                .any(|(key, _, _): &(String, String, String)| key == name_end)
1200            {
1201                return Err(EncodingError::InvalidCompletion(format!(
1202                    "duplicate parameter {name_end}"
1203                )));
1204            }
1205            let (next, content, next_stop) =
1206                read_until_stop(index, input, &[&parameter_start, &call_end]);
1207            index = next;
1208            if content != ">\n" {
1209                return Err(EncodingError::InvalidCompletion(
1210                    "parameter separator expected '>\\n'".to_string(),
1211                ));
1212            }
1213            args.push((name_end.to_string(), value.to_string(), string.to_string()));
1214            stop = next_stop;
1215            if matched.is_none() {
1216                return Err(EncodingError::InvalidCompletion(
1217                    "unterminated parameter".to_string(),
1218                ));
1219            }
1220        }
1221        calls.push(decode_dsml_to_arguments(name, &args)?);
1222    }
1223    Ok((index, last_stop, calls))
1224}
1225
1226fn tool_calls_to_openai(calls: &[Value]) -> Value {
1227    Value::Array(
1228        calls
1229            .iter()
1230            .map(|call| {
1231                let name = call
1232                    .get("name")
1233                    .cloned()
1234                    .unwrap_or(Value::String(String::new()));
1235                let arguments = call
1236                    .get("arguments")
1237                    .cloned()
1238                    .unwrap_or(Value::String(String::new()));
1239                let mut function = Map::new();
1240                function.insert("name".to_string(), name);
1241                function.insert("arguments".to_string(), arguments);
1242                let mut result = Map::new();
1243                result.insert("type".to_string(), Value::String("function".to_string()));
1244                result.insert("function".to_string(), Value::Object(function));
1245                if let Some(namespace) = call.get("namespace") {
1246                    result.insert("namespace".to_string(), namespace.clone());
1247                }
1248                Value::Object(result)
1249            })
1250            .collect(),
1251    )
1252}
1253
1254/// Parse a generated V4.1 completion into an OpenAI style assistant object.
1255pub fn parse_message_from_completion_text(
1256    input: &str,
1257    mode: ThinkingMode,
1258) -> Result<Value, EncodingError> {
1259    let tool_start = format!("\n\n<{DSML_TOKEN}{TOOL_CALLS_BLOCK_NAME}");
1260    let (mut index, reasoning, mut stop) = if mode == ThinkingMode::Thinking {
1261        let (next, content, matched) =
1262            read_until_stop(0, input, &[THINKING_END_TOKEN, &tool_start]);
1263        if matched.as_deref() != Some(THINKING_END_TOKEN) {
1264            return Err(EncodingError::InvalidCompletion(
1265                "thinking completion is missing </think>".to_string(),
1266            ));
1267        }
1268        (next, content, matched)
1269    } else {
1270        (0, String::new(), None)
1271    };
1272    let (next, summary, matched) = read_until_stop(index, input, &[EOS_TOKEN, &tool_start]);
1273    index = next;
1274    stop = matched;
1275    let mut calls = Vec::new();
1276    if stop.as_deref() == Some(tool_start.as_str()) {
1277        let (next, _, parsed_calls) = parse_tool_calls(index, input)?;
1278        index = next;
1279        calls = parsed_calls;
1280        let (next, tail, matched) = read_until_stop(index, input, &[EOS_TOKEN]);
1281        if !tail.is_empty() {
1282            return Err(EncodingError::InvalidCompletion(
1283                "content follows DSML calls".to_string(),
1284            ));
1285        }
1286        index = next;
1287        stop = matched;
1288    } else if stop.as_deref() != Some(EOS_TOKEN) {
1289        return Err(EncodingError::InvalidCompletion(
1290            "completion is missing EOS token".to_string(),
1291        ));
1292    }
1293    if index != input.len() {
1294        return Err(EncodingError::InvalidCompletion(
1295            "unexpected bytes after completion".to_string(),
1296        ));
1297    }
1298    for special in [
1299        BOS_TOKEN,
1300        EOS_TOKEN,
1301        THINKING_START_TOKEN,
1302        THINKING_END_TOKEN,
1303        DSML_TOKEN,
1304    ] {
1305        if summary.contains(special) || reasoning.contains(special) {
1306            return Err(EncodingError::InvalidCompletion(format!(
1307                "special token {special} leaked into content"
1308            )));
1309        }
1310    }
1311    Ok(json!({
1312        "role":"assistant",
1313        "content":summary,
1314        "reasoning_content":reasoning,
1315        "tool_calls":tool_calls_to_openai(&calls),
1316    }))
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321    use super::*;
1322
1323    fn user(content: Value) -> Value {
1324        json!({"role":"user", "content":content})
1325    }
1326
1327    #[test]
1328    fn text_chat_and_numeric_reasoning_match_reference() {
1329        let chat = encode_messages(
1330            &[user(Value::String("hello".into()))],
1331            &EncodeOptions::default(),
1332        )
1333        .unwrap();
1334        assert_eq!(
1335            chat.prompt,
1336            "<|begin▁of▁sentence|><|User|>hello<|Assistant|></think>"
1337        );
1338        let thinking = encode_messages(
1339            &[user(Value::String("question".into()))],
1340            &EncodeOptions {
1341                thinking_mode: ThinkingMode::Thinking,
1342                reasoning_effort: Some(ReasoningEffort::Budget(42)),
1343                ..EncodeOptions::default()
1344            },
1345        )
1346        .unwrap();
1347        assert_eq!(
1348            thinking.prompt,
1349            "<|begin▁of▁sentence|><|System|>Reasoning Effort: 42 (range 1-100, the higher the value, the more thorough the reasoning)\n\n<|User|>question<|Assistant|><think>"
1350        );
1351    }
1352
1353    #[test]
1354    fn images_and_tool_tags_are_ordered_and_spaced() {
1355        let prompt = encode_messages(
1356            &[user(json!([
1357                {"type":"text","text":"inspect"},
1358                {"type":"image_url","image_url":{"url":"/tmp/a.png"}},
1359            ]))],
1360            &EncodeOptions::default(),
1361        )
1362        .unwrap();
1363        assert_eq!(
1364            prompt.images,
1365            vec![json!({"type":"image","url":"/tmp/a.png"})]
1366        );
1367        assert_eq!(
1368            prompt.prompt,
1369            "<|begin▁of▁sentence|><|User|>inspect\n\n<|deepseek_image|><|Assistant|></think>"
1370        );
1371        assert!(!prompt.prompt.contains("<|DSML|tool_calls>"));
1372    }
1373
1374    #[test]
1375    fn mid_system_fixture_keeps_prior_reasoning_and_new_header() {
1376        let encoded = encode_messages(
1377            &[
1378                user(Value::String("old".into())),
1379                json!({
1380                    "role":"assistant",
1381                    "content":"old answer",
1382                    "reasoning_content":"private",
1383                    "wo_eos":true
1384                }),
1385                json!({"role":"system", "content":"new policy"}),
1386                user(Value::String("now".into())),
1387            ],
1388            &EncodeOptions {
1389                thinking_mode: ThinkingMode::Thinking,
1390                reasoning_effort: Some(ReasoningEffort::Budget(75)),
1391                ..EncodeOptions::default()
1392            },
1393        )
1394        .unwrap();
1395        assert_eq!(
1396            encoded.prompt,
1397            "<|begin▁of▁sentence|><|System|>Reasoning Effort: 75 (range 1-100, the higher the value, the more thorough the reasoning)\n\n<|User|>old<|Assistant|></think>old answer<|System|>new policy<|User|>now<|Assistant|><think>"
1398        );
1399    }
1400
1401    #[test]
1402    fn spaced_dsml_round_trip() {
1403        let completion = "reason </think>answer\n\n<|DSML| calls>\n<|DSML| invoke name=\"lookup\">\n<|DSML| parameter name=\"q\" string=\"true\">Paris</|DSML| parameter>\n</|DSML| invoke>\n</|DSML| calls><|end▁of▁sentence|>";
1404        let parsed =
1405            parse_message_from_completion_text(completion, ThinkingMode::Thinking).unwrap();
1406        assert_eq!(parsed["tool_calls"][0]["function"]["name"], "lookup");
1407        assert_eq!(
1408            parsed["tool_calls"][0]["function"]["arguments"],
1409            "{\"q\": \"Paris\"}"
1410        );
1411    }
1412
1413    #[test]
1414    fn effort_rejects_float_and_bool_json() {
1415        assert!(ReasoningEffort::from_json(&json!(1.5)).is_err());
1416        assert!(ReasoningEffort::from_json(&json!(true)).is_err());
1417        assert!(ReasoningEffort::from_json(&json!(0)).is_err());
1418        assert!(ReasoningEffort::from_json(&json!("42")).is_err());
1419    }
1420}