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