1use 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}