Skip to main content

dynamo_renderer/
inkling.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Native Inkling chat renderer.
5//!
6//! Inkling does not publish a Hugging Face `chat_template`. Its wire format is
7//! a sequence of role and content-kind special tokens, so rendering it through
8//! Jinja is both unnecessary and lossy. This formatter emits those special
9//! token spellings as text; the consumer's tokenizer turns them into the exact
10//! token IDs before dispatching the request to the backend.
11
12use std::collections::HashMap;
13
14use anyhow::{Context, Result, bail};
15use minijinja::Value;
16use serde_json::{Map, Value as JsonValue, json};
17
18use crate::{OAIChatLikeRequest, OAIPromptFormatter};
19
20const MESSAGE_USER: &str = "<|message_user|>";
21const MESSAGE_MODEL: &str = "<|message_model|>";
22const MESSAGE_SYSTEM: &str = "<|message_system|>";
23const MESSAGE_TOOL: &str = "<|message_tool|>";
24const CONTENT_TEXT: &str = "<|content_text|>";
25const CONTENT_IMAGE: &str = "<|content_image|>";
26const CONTENT_MODEL_END_SAMPLING: &str = "<|content_model_end_sampling|>";
27const CONTENT_AUDIO_INPUT: &str = "<|content_audio_input|>";
28const CONTENT_THINKING: &str = "<|content_thinking|>";
29const CONTENT_XML: &str = "<|content_xml|>";
30const CONTENT_INVOKE_TOOL_JSON: &str = "<|content_invoke_tool_json|>";
31const END_MESSAGE: &str = "<|end_message|>";
32const AUDIO_END: &str = "<|audio_end|>";
33const MAX_REASONING_EFFORT: f64 = 0.99;
34
35#[derive(Debug, Clone, Copy, Default)]
36pub struct InklingFormatter;
37
38impl OAIPromptFormatter for InklingFormatter {
39    fn supports_add_generation_prompt(&self) -> bool {
40        true
41    }
42
43    fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
44        let mut messages = json_value(req.messages()).context("serialize Inkling messages")?;
45        crate::reject_unsupported_partial_assistant(&messages)?;
46        crate::reject_unsupported_message_tools(&messages, &["developer"])?;
47        let messages = messages
48            .as_array_mut()
49            .context("Inkling messages must be an array")?;
50
51        let args = req.chat_template_args();
52        if args
53            .and_then(|args| args.get("continue_final_message"))
54            .and_then(JsonValue::as_bool)
55            == Some(true)
56        {
57            bail!("Inkling renderer does not support continue_final_message");
58        }
59
60        let tools_enabled = req.tool_choice().as_ref().and_then(Value::as_str) != Some("none");
61        let request_tools = if tools_enabled {
62            req.tools()
63                .map(json_value)
64                .transpose()
65                .context("serialize Inkling tools")?
66                .unwrap_or_else(|| JsonValue::Array(Vec::new()))
67        } else {
68            JsonValue::Array(Vec::new())
69        };
70
71        let mut output = String::new();
72        let all_tools = if tools_enabled {
73            collect_tools(messages, &request_tools)?
74        } else {
75            Vec::new()
76        };
77        write_tool_declarations(&mut output, &all_tools)?;
78
79        let effort_value = req
80            .reasoning_effort()
81            .map(json_value)
82            .transpose()
83            .context("serialize Inkling reasoning_effort")?
84            .or_else(|| args.and_then(|args| args.get("reasoning_effort").cloned()));
85        let mut reasoning_effort = resolve_reasoning_effort(effort_value.as_ref());
86        let mut tool_call_id_to_name = HashMap::new();
87
88        for message in messages {
89            let role = message
90                .get("role")
91                .and_then(JsonValue::as_str)
92                .context("Inkling message is missing a string role")?;
93
94            if !matches!(role, "system" | "developer")
95                && let Some(effort) = reasoning_effort.take()
96            {
97                write_reasoning_effort(&mut output, effort)?;
98            }
99
100            match role {
101                "system" | "developer" => write_content(
102                    &mut output,
103                    MESSAGE_SYSTEM,
104                    message.get("content").unwrap_or(&JsonValue::Null),
105                )?,
106                "user" => write_content(
107                    &mut output,
108                    MESSAGE_USER,
109                    message.get("content").unwrap_or(&JsonValue::Null),
110                )?,
111                "assistant" => write_assistant(&mut output, message, &mut tool_call_id_to_name)?,
112                "tool" => write_tool_response(&mut output, message, &tool_call_id_to_name)?,
113                other => bail!(
114                    "unsupported Inkling message role {other:?}; expected system, developer, user, assistant, or tool"
115                ),
116            }
117        }
118
119        if let Some(effort) = reasoning_effort {
120            write_reasoning_effort(&mut output, effort)?;
121        }
122        if req.should_add_generation_prompt() {
123            output.push_str(MESSAGE_MODEL);
124        }
125        Ok(output)
126    }
127}
128
129fn json_value(value: Value) -> Result<JsonValue> {
130    serde_json::to_value(value).context("convert minijinja value to JSON")
131}
132
133fn collect_tools(messages: &[JsonValue], request_tools: &JsonValue) -> Result<Vec<JsonValue>> {
134    let mut tools = request_tools
135        .as_array()
136        .context("Inkling tools must be an array")?
137        .clone();
138    for message in messages {
139        if message.get("role").and_then(JsonValue::as_str) == Some("developer")
140            && let Some(local_tools) = message.get("tools")
141        {
142            tools.extend(
143                local_tools
144                    .as_array()
145                    .context("developer message tools must be an array")?
146                    .iter()
147                    .cloned(),
148            );
149        }
150    }
151    Ok(tools)
152}
153
154fn write_tool_declarations(output: &mut String, tools: &[JsonValue]) -> Result<()> {
155    if tools.is_empty() {
156        return Ok(());
157    }
158
159    let mut specs = Vec::with_capacity(tools.len());
160    for tool in tools {
161        let tool = tool
162            .as_object()
163            .context("Inkling tool declaration must be an object")?;
164        let function = tool
165            .get("function")
166            .and_then(JsonValue::as_object)
167            .context("Inkling tool declaration is missing function")?;
168        let name = function
169            .get("name")
170            .and_then(JsonValue::as_str)
171            .context("Inkling tool function is missing name")?;
172        specs.push(json!({
173            "description": function
174                .get("description")
175                .and_then(JsonValue::as_str)
176                .unwrap_or(""),
177            "name": name,
178            "parameters": function
179                .get("parameters")
180                .cloned()
181                .unwrap_or_else(|| JsonValue::Object(Map::new())),
182            "type": tool
183                .get("type")
184                .and_then(JsonValue::as_str)
185                .unwrap_or("function"),
186        }));
187    }
188    let payload = canonical_json(&JsonValue::Array(specs))?;
189    write_block(
190        output,
191        MESSAGE_SYSTEM,
192        Some("tool_declare"),
193        CONTENT_XML,
194        &payload,
195    );
196    Ok(())
197}
198
199fn write_content(output: &mut String, role_token: &str, content: &JsonValue) -> Result<()> {
200    match content {
201        JsonValue::Null => {}
202        JsonValue::String(text) => {
203            if !text.is_empty() {
204                write_block(output, role_token, None, CONTENT_TEXT, text);
205            }
206        }
207        JsonValue::Array(parts) => {
208            for part in parts {
209                if let Some(text) = part.as_str() {
210                    write_block(output, role_token, None, CONTENT_TEXT, text);
211                    continue;
212                }
213                let part = part
214                    .as_object()
215                    .context("Inkling content part must be an object")?;
216                let part_type = part
217                    .get("type")
218                    .and_then(JsonValue::as_str)
219                    .unwrap_or("text");
220                match part_type {
221                    "text" | "input_text" => write_block(
222                        output,
223                        role_token,
224                        None,
225                        CONTENT_TEXT,
226                        part.get("text").and_then(JsonValue::as_str).unwrap_or(""),
227                    ),
228                    "image" | "input_image" | "image_url" => {
229                        write_block(output, role_token, None, CONTENT_IMAGE, "")
230                    }
231                    "audio" | "input_audio" | "audio_url" => {
232                        output.push_str(role_token);
233                        output.push_str(CONTENT_AUDIO_INPUT);
234                        output.push_str(AUDIO_END);
235                        output.push_str(END_MESSAGE);
236                    }
237                    "video" | "input_video" | "video_url" => {
238                        bail!("Inkling does not support video content")
239                    }
240                    other => bail!("unsupported Inkling content part type {other:?}"),
241                }
242            }
243        }
244        _ => bail!("Inkling message content must be a string or array"),
245    }
246    Ok(())
247}
248
249fn write_assistant(
250    output: &mut String,
251    message: &JsonValue,
252    tool_call_id_to_name: &mut HashMap<String, String>,
253) -> Result<()> {
254    let tool_calls = message
255        .get("tool_calls")
256        .and_then(JsonValue::as_array)
257        .map(Vec::as_slice)
258        .unwrap_or(&[]);
259    let reasoning = message
260        .get("reasoning_content")
261        .or_else(|| message.get("reasoning"));
262
263    match reasoning {
264        Some(JsonValue::Array(segments)) => {
265            for (index, tool_call) in tool_calls.iter().enumerate() {
266                if let Some(text) = segments.get(index).and_then(JsonValue::as_str) {
267                    write_reasoning_block(output, text);
268                }
269                write_tool_call(output, tool_call, tool_call_id_to_name)?;
270            }
271            for segment in segments.iter().skip(tool_calls.len()) {
272                let text = segment
273                    .as_str()
274                    .context("Inkling reasoning_content segments must be strings")?;
275                write_reasoning_block(output, text);
276            }
277            write_content(
278                output,
279                MESSAGE_MODEL,
280                message.get("content").unwrap_or(&JsonValue::Null),
281            )?;
282        }
283        Some(JsonValue::String(text)) => {
284            write_reasoning_block(output, text);
285            write_content(
286                output,
287                MESSAGE_MODEL,
288                message.get("content").unwrap_or(&JsonValue::Null),
289            )?;
290            for tool_call in tool_calls {
291                write_tool_call(output, tool_call, tool_call_id_to_name)?;
292            }
293        }
294        None | Some(JsonValue::Null) => {
295            write_content(
296                output,
297                MESSAGE_MODEL,
298                message.get("content").unwrap_or(&JsonValue::Null),
299            )?;
300            for tool_call in tool_calls {
301                write_tool_call(output, tool_call, tool_call_id_to_name)?;
302            }
303        }
304        Some(_) => bail!("Inkling reasoning_content must be a string or array of strings"),
305    }
306
307    output.push_str(CONTENT_MODEL_END_SAMPLING);
308    Ok(())
309}
310
311fn write_reasoning_block(output: &mut String, text: &str) {
312    if !text.is_empty() {
313        write_block(output, MESSAGE_MODEL, None, CONTENT_THINKING, text);
314    }
315}
316
317fn write_tool_call(
318    output: &mut String,
319    tool_call: &JsonValue,
320    tool_call_id_to_name: &mut HashMap<String, String>,
321) -> Result<()> {
322    let tool_call = tool_call
323        .as_object()
324        .context("Inkling tool call must be an object")?;
325    let function = tool_call
326        .get("function")
327        .and_then(JsonValue::as_object)
328        .context("Inkling tool call is missing function")?;
329    let name = function
330        .get("name")
331        .and_then(JsonValue::as_str)
332        .context("Inkling tool call function is missing name")?;
333    if let Some(id) = tool_call.get("id").and_then(JsonValue::as_str)
334        && !id.is_empty()
335    {
336        tool_call_id_to_name.insert(id.to_string(), name.to_string());
337    }
338
339    let arguments = match function.get("arguments") {
340        None | Some(JsonValue::Null) => JsonValue::Object(Map::new()),
341        Some(JsonValue::String(arguments)) if arguments.trim().is_empty() => {
342            JsonValue::Object(Map::new())
343        }
344        Some(JsonValue::String(arguments)) => serde_json::from_str(arguments)
345            .context("Inkling tool call arguments must be valid JSON")?,
346        Some(arguments) => arguments.clone(),
347    };
348    if !arguments.is_object() {
349        bail!("Inkling tool call arguments must decode to a JSON object");
350    }
351
352    let name_json = serde_json::to_string(name)?;
353    let args_json = canonical_json(&arguments)?;
354    let payload = format!("{{\"name\":{name_json},\"args\":{args_json}}}");
355    write_block(
356        output,
357        MESSAGE_MODEL,
358        Some(name),
359        CONTENT_INVOKE_TOOL_JSON,
360        &payload,
361    );
362    Ok(())
363}
364
365fn write_tool_response(
366    output: &mut String,
367    message: &JsonValue,
368    tool_call_id_to_name: &HashMap<String, String>,
369) -> Result<()> {
370    let tool_call_id = message
371        .get("tool_call_id")
372        .and_then(JsonValue::as_str)
373        .unwrap_or("");
374    let name = message
375        .get("name")
376        .and_then(JsonValue::as_str)
377        .or_else(|| tool_call_id_to_name.get(tool_call_id).map(String::as_str))
378        .unwrap_or("");
379    let text = flatten_text_content(message.get("content").unwrap_or(&JsonValue::Null))?;
380    write_block(output, MESSAGE_TOOL, Some(name), CONTENT_TEXT, &text);
381    Ok(())
382}
383
384fn flatten_text_content(content: &JsonValue) -> Result<String> {
385    match content {
386        JsonValue::Null => Ok(String::new()),
387        JsonValue::String(text) => Ok(text.clone()),
388        JsonValue::Array(parts) => {
389            let mut output = String::new();
390            for part in parts {
391                if let Some(text) = part.as_str() {
392                    output.push_str(text);
393                    continue;
394                }
395                let part = part
396                    .as_object()
397                    .context("Inkling tool response part must be an object")?;
398                let kind = part
399                    .get("type")
400                    .and_then(JsonValue::as_str)
401                    .unwrap_or("text");
402                if !matches!(kind, "text" | "input_text") {
403                    bail!("Inkling tool response content must be text, got {kind:?}");
404                }
405                output.push_str(part.get("text").and_then(JsonValue::as_str).unwrap_or(""));
406            }
407            Ok(output)
408        }
409        _ => bail!("Inkling tool response content must be text"),
410    }
411}
412
413fn write_reasoning_effort(output: &mut String, effort: f64) -> Result<()> {
414    if !(0.0..=MAX_REASONING_EFFORT).contains(&effort) {
415        bail!("Inkling reasoning_effort must be in [0.0, 0.99], got {effort}");
416    }
417    let formatted = format!("{effort:.2}");
418    let effort = formatted.trim_end_matches('0').trim_end_matches('.');
419    let effort = if matches!(effort, "0" | "-0") {
420        "0.0"
421    } else {
422        effort
423    };
424    write_block(
425        output,
426        MESSAGE_SYSTEM,
427        None,
428        CONTENT_TEXT,
429        &format!("Thinking effort level: {effort}"),
430    );
431    Ok(())
432}
433
434fn resolve_reasoning_effort(value: Option<&JsonValue>) -> Option<f64> {
435    let Some(value) = value else {
436        return Some(0.9);
437    };
438    match value {
439        JsonValue::String(name) => match name.as_str() {
440            "none" => Some(0.0),
441            "minimal" => Some(0.1),
442            "low" => Some(0.2),
443            "medium" => Some(0.7),
444            "high" => Some(0.9),
445            "xhigh" | "max" => Some(0.99),
446            _ => None,
447        },
448        JsonValue::Number(number) => number.as_f64(),
449        _ => None,
450    }
451}
452
453fn write_block(
454    output: &mut String,
455    role_token: &str,
456    author_name: Option<&str>,
457    content_token: &str,
458    text: &str,
459) {
460    output.push_str(role_token);
461    if let Some(author_name) = author_name
462        && !author_name.is_empty()
463    {
464        output.push_str(author_name);
465    }
466    output.push_str(content_token);
467    output.push_str(text);
468    output.push_str(END_MESSAGE);
469}
470
471fn canonical_json(value: &JsonValue) -> Result<String> {
472    serde_json::to_string(&sort_json(value)).context("serialize Inkling JSON payload")
473}
474
475fn sort_json(value: &JsonValue) -> JsonValue {
476    match value {
477        JsonValue::Array(items) => JsonValue::Array(items.iter().map(sort_json).collect()),
478        JsonValue::Object(map) => {
479            let mut sorted = Map::new();
480            let mut keys = map.keys().collect::<Vec<_>>();
481            keys.sort();
482            for key in keys {
483                sorted.insert(key.clone(), sort_json(&map[key]));
484            }
485            JsonValue::Object(sorted)
486        }
487        _ => value.clone(),
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::{OAIChatLikeRequest, PromptFormatter};
495    use std::collections::HashMap;
496
497    #[derive(Default)]
498    struct Request {
499        messages: JsonValue,
500        tools: Option<JsonValue>,
501        tool_choice: Option<JsonValue>,
502        args: Option<HashMap<String, JsonValue>>,
503        reasoning_effort: Option<JsonValue>,
504        add_generation_prompt: bool,
505    }
506
507    impl Request {
508        fn new(messages: JsonValue) -> Self {
509            Self {
510                messages,
511                add_generation_prompt: true,
512                ..Default::default()
513            }
514        }
515    }
516
517    impl OAIChatLikeRequest for Request {
518        fn model(&self) -> String {
519            "thinkingmachines/Inkling-NVFP4".to_string()
520        }
521
522        fn messages(&self) -> Value {
523            Value::from_serialize(&self.messages)
524        }
525
526        fn tools(&self) -> Option<Value> {
527            self.tools.as_ref().map(Value::from_serialize)
528        }
529
530        fn tool_choice(&self) -> Option<Value> {
531            self.tool_choice.as_ref().map(Value::from_serialize)
532        }
533
534        fn response_format(&self) -> Option<Value> {
535            None
536        }
537
538        fn reasoning_effort(&self) -> Option<Value> {
539            self.reasoning_effort.as_ref().map(Value::from_serialize)
540        }
541
542        fn should_add_generation_prompt(&self) -> bool {
543            self.add_generation_prompt
544        }
545
546        fn chat_template_args(&self) -> Option<&HashMap<String, JsonValue>> {
547            self.args.as_ref()
548        }
549    }
550
551    #[test]
552    fn rejects_unsupported_partial_assistant() {
553        let request = Request::new(json!([
554            {"role": "user", "content": "Continue"},
555            {"role": "assistant", "content": "prefix", "partial": true}
556        ]));
557        let error = InklingFormatter.render(&request).unwrap_err();
558
559        assert!(matches!(
560            error.downcast_ref::<crate::PromptRenderError>(),
561            Some(crate::PromptRenderError::InvalidRequest(message))
562                if message.contains("`partial: true` is not supported")
563        ));
564    }
565
566    #[test]
567    fn rejects_message_level_system_tools() {
568        let request = Request::new(json!([
569            {"role": "system", "tools": [{"name": "lookup"}]},
570            {"role": "user", "content": "Continue"}
571        ]));
572        let error = InklingFormatter.render(&request).unwrap_err();
573
574        assert!(matches!(
575            error.downcast_ref::<crate::PromptRenderError>(),
576            Some(crate::PromptRenderError::InvalidRequest(message))
577                if message.contains("message-level `tools`")
578        ));
579    }
580
581    #[test]
582    fn renders_text_and_image_like_vllm_fixture() {
583        let request = Request::new(json!([{
584            "role": "user",
585            "content": [
586                {"type": "text", "text": "look"},
587                {"type": "image_url", "image_url": {"url": "data:image/png;base64,"}}
588            ]
589        }]));
590        assert_eq!(
591            InklingFormatter.render(&request).unwrap(),
592            "<|message_system|><|content_text|>Thinking effort level: 0.9<|end_message|><|message_user|><|content_text|>look<|end_message|><|message_user|><|content_image|><|end_message|><|message_model|>"
593        );
594    }
595
596    #[test]
597    fn renders_audio_markers_like_vllm_fixture() {
598        let request = Request::new(json!([{
599            "role": "user",
600            "content": [
601                {"type": "text", "text": "transcribe"},
602                {"type": "input_audio", "input_audio": {"data": "", "format": "wav"}},
603                {"type": "audio_url", "audio_url": {"url": "data:audio/wav;base64,"}}
604            ]
605        }]));
606        assert_eq!(
607            InklingFormatter.render(&request).unwrap(),
608            "<|message_system|><|content_text|>Thinking effort level: 0.9<|end_message|><|message_user|><|content_text|>transcribe<|end_message|><|message_user|><|content_audio_input|><|audio_end|><|end_message|><|message_user|><|content_audio_input|><|audio_end|><|end_message|><|message_model|>"
609        );
610    }
611
612    #[test]
613    fn renders_tool_declaration_and_round_trip_like_vllm_fixtures() {
614        let mut request = Request::new(json!([
615            {
616                "role": "developer",
617                "content": "rules",
618                "tools": [{
619                    "type": "function",
620                    "function": {
621                        "name": "local_tool",
622                        "parameters": {"z": 1, "a": {"b": 2}}
623                    }
624                }]
625            },
626            {"role": "user", "content": "hi"}
627        ]));
628        request.tools = Some(json!([{
629            "type": "function",
630            "function": {
631                "name": "get_weather",
632                "description": "Get weather information",
633                "parameters": {
634                    "type": "object",
635                    "required": ["city"],
636                    "properties": {"city": {"type": "string"}}
637                }
638            }
639        }]));
640        assert_eq!(
641            InklingFormatter.render(&request).unwrap(),
642            "<|message_system|>tool_declare<|content_xml|>[{\"description\":\"Get weather information\",\"name\":\"get_weather\",\"parameters\":{\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"type\":\"object\"},\"type\":\"function\"},{\"description\":\"\",\"name\":\"local_tool\",\"parameters\":{\"a\":{\"b\":2},\"z\":1},\"type\":\"function\"}]<|end_message|><|message_system|><|content_text|>rules<|end_message|><|message_system|><|content_text|>Thinking effort level: 0.9<|end_message|><|message_user|><|content_text|>hi<|end_message|><|message_model|>"
643        );
644
645        let mut round_trip = Request::new(json!([
646            {
647                "role": "assistant",
648                "reasoning_content": "think",
649                "content": "answer",
650                "tool_calls": [{
651                    "id": "call_1",
652                    "type": "function",
653                    "function": {"name": "get_weather", "arguments": "{\"city\":\"SF\"}"}
654                }]
655            },
656            {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
657        ]));
658        round_trip.add_generation_prompt = false;
659        assert_eq!(
660            InklingFormatter.render(&round_trip).unwrap(),
661            "<|message_system|><|content_text|>Thinking effort level: 0.9<|end_message|><|message_model|><|content_thinking|>think<|end_message|><|message_model|><|content_text|>answer<|end_message|><|message_model|>get_weather<|content_invoke_tool_json|>{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}<|end_message|><|content_model_end_sampling|><|message_tool|>get_weather<|content_text|>sunny<|end_message|>"
662        );
663    }
664
665    #[test]
666    fn interleaves_segmented_reasoning_with_tool_calls() {
667        let mut request = Request::new(json!([{
668            "role": "assistant",
669            "reasoning_content": ["first", "second", "after"],
670            "content": "done",
671            "tool_calls": [
672                {"id": "a", "function": {"name": "one", "arguments": "{\"b\":2,\"a\":1}"}},
673                {"id": "b", "function": {"name": "two", "arguments": "{}"}}
674            ]
675        }]));
676        request.reasoning_effort = Some(json!("none"));
677        request.add_generation_prompt = false;
678        assert_eq!(
679            InklingFormatter.render(&request).unwrap(),
680            "<|message_system|><|content_text|>Thinking effort level: 0.0<|end_message|><|message_model|><|content_thinking|>first<|end_message|><|message_model|>one<|content_invoke_tool_json|>{\"name\":\"one\",\"args\":{\"a\":1,\"b\":2}}<|end_message|><|message_model|><|content_thinking|>second<|end_message|><|message_model|>two<|content_invoke_tool_json|>{\"name\":\"two\",\"args\":{}}<|end_message|><|message_model|><|content_thinking|>after<|end_message|><|message_model|><|content_text|>done<|end_message|><|content_model_end_sampling|>"
681        );
682    }
683
684    #[test]
685    fn ignores_unsupported_reasoning_effort_values() {
686        for value in [json!(true), json!("invalid"), json!(null)] {
687            let mut request = Request::new(json!([{
688                "role": "user",
689                "content": "test"
690            }]));
691            request.reasoning_effort = Some(value);
692
693            assert_eq!(
694                InklingFormatter.render(&request).unwrap(),
695                "<|message_user|><|content_text|>test<|end_message|><|message_model|>"
696            );
697        }
698    }
699
700    #[test]
701    fn tool_choice_none_suppresses_declarations() {
702        let mut request = Request::new(json!([
703            {
704                "role": "developer",
705                "content": "rules",
706                "tools": [{
707                    "type": "function",
708                    "function": {"name": "also_hidden", "parameters": {}}
709                }]
710            },
711            {"role": "user", "content": "hi"}
712        ]));
713        request.tools = Some(json!([{
714            "type": "function",
715            "function": {"name": "hidden", "parameters": {}}
716        }]));
717        request.tool_choice = Some(json!("none"));
718        let rendered = InklingFormatter.render(&request).unwrap();
719        assert!(!rendered.contains("tool_declare"));
720        assert!(!rendered.contains("hidden"));
721        assert!(!rendered.contains("also_hidden"));
722    }
723
724    #[test]
725    fn native_selection_uses_exact_model_type_not_display_name() {
726        assert!(matches!(
727            crate::native_formatter_for(&Some("inkling_mm_model".to_string()), "renamed"),
728            Some(PromptFormatter::OAI(_))
729        ));
730        assert!(crate::native_formatter_for(&None, "inkling-nvfp4").is_none());
731    }
732}