1use std::collections::HashMap;
13
14use anyhow::{Context, Result, bail};
15use serde_json::{Map, Value};
16
17use crate::{
18 OAIChatLikeRequest, OAIPromptFormatter, PromptRenderError, RenderedPrompt, RenderedSegment,
19 thinking_bool_from_args,
20};
21
22const OPEN_TOKEN: &str = "<|open|>";
23const CLOSE_TOKEN: &str = "<|close|>";
24const SEP_TOKEN: &str = "<|sep|>";
25const END_OF_MSG_TOKEN: &str = "<|end_of_msg|>";
26const MEDIA_PAD: &str = "<|media_pad|>";
44const VALID_THINKING_EFFORTS: &[&str] = &["low", "high", "max"];
45
46#[derive(Debug, Clone)]
47pub struct KimiK3Formatter {
48 exclude_tools_when_tool_choice_none: bool,
49}
50
51impl KimiK3Formatter {
52 pub fn new(exclude_tools_when_tool_choice_none: bool) -> Self {
53 Self {
54 exclude_tools_when_tool_choice_none,
55 }
56 }
57
58 fn build_segments(&self, req: &dyn OAIChatLikeRequest) -> Result<Vec<RenderedSegment>> {
59 let messages = json_value(req.messages()).context("Failed to convert K3 messages")?;
60 let messages = messages
61 .as_array()
62 .context("Kimi K3 messages must be an array")?;
63 let messages = normalize_tool_result_messages(messages)?;
64
65 let tool_choice = req.tool_choice().map(json_value).transpose()?;
66 let (tool_choice_kind, named_tool) = resolve_tool_choice(tool_choice.as_ref())?;
67 let mut tools = req.tools().map(json_value).transpose()?;
68 if let Some(named_tool) = named_tool
71 && !tools
72 .as_ref()
73 .is_some_and(|tools| contains_tool(tools, named_tool))
74 && !messages
75 .iter()
76 .any(|message| message_declares_tool(message, named_tool))
77 {
78 return Err(PromptRenderError::invalid_request(format!(
79 "tool named {named_tool:?} in tool_choice is not present in tools"
80 ))
81 .into());
82 }
83 if self.exclude_tools_when_tool_choice_none && tool_choice_kind == Some("none") {
84 tools = None;
85 }
86 let tools = tools.map(deep_sort);
87
88 let args = req.chat_template_args();
89 let thinking = named_tool.is_none() && thinking_bool_from_args(args).unwrap_or(true);
93 let thinking_effort = resolve_thinking_effort(args);
94 if thinking && !VALID_THINKING_EFFORTS.contains(&thinking_effort.as_str()) {
95 return Err(PromptRenderError::invalid_request(format!(
96 "Unsupported Kimi K3 thinking_effort={thinking_effort:?}; supported values are low, high, and max"
97 ))
98 .into());
99 }
100
101 let response_format = req.response_format().map(json_value).transpose()?;
102 build_chat_segments(
103 &messages,
104 tools.as_ref(),
105 tool_choice_kind,
106 named_tool,
107 response_format.as_ref(),
108 req.should_add_generation_prompt(),
109 thinking,
110 thinking_effort.as_str(),
111 )
112 }
113}
114
115impl OAIPromptFormatter for KimiK3Formatter {
116 fn supports_add_generation_prompt(&self) -> bool {
117 true
118 }
119
120 fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
121 Ok(RenderedPrompt::segmented(self.build_segments(req)?).into_text())
122 }
123
124 fn render_prompt(&self, req: &dyn OAIChatLikeRequest) -> Result<RenderedPrompt> {
125 Ok(RenderedPrompt::segmented(self.build_segments(req)?))
126 }
127}
128
129fn json_value(value: minijinja::value::Value) -> Result<Value> {
130 serde_json::to_value(&value).context("Failed to convert template value to JSON")
131}
132
133fn resolve_tool_choice(tool_choice: Option<&Value>) -> Result<(Option<&str>, Option<&str>)> {
134 match tool_choice {
135 Some(Value::String(kind)) => Ok((Some(kind.as_str()), None)),
136 Some(Value::Object(choice)) => {
137 if choice.get("type").and_then(Value::as_str) != Some("function") {
138 return Err(PromptRenderError::invalid_request(
139 "Kimi K3 named tool_choice must have type=\"function\"",
140 )
141 .into());
142 }
143 let name = choice
147 .get("function")
148 .and_then(Value::as_object)
149 .and_then(|function| function.get("name"))
150 .or_else(|| choice.get("name"))
151 .and_then(Value::as_str)
152 .filter(|name| !name.is_empty())
153 .ok_or_else(|| {
154 PromptRenderError::invalid_request(
155 "Kimi K3 named tool_choice requires a non-empty function name",
156 )
157 })?;
158 Ok((Some("specified"), Some(name)))
159 }
160 Some(Value::Null) | None => Ok((None, None)),
161 Some(other) => Err(anyhow::anyhow!(
162 "Unsupported Kimi K3 tool_choice value: {other}"
163 )),
164 }
165}
166
167fn contains_tool(tools: &Value, name: &str) -> bool {
168 tools.as_array().is_some_and(|tools| {
169 tools.iter().any(|tool| {
170 tool.get("function")
171 .and_then(Value::as_object)
172 .and_then(|function| function.get("name"))
173 .or_else(|| tool.get("name"))
174 .and_then(Value::as_str)
175 == Some(name)
176 })
177 })
178}
179
180const MAX_TOOL_NAME_LEN: usize = 256;
182
183fn dynamic_tools_of(message: &Value) -> Result<Option<&Vec<Value>>> {
189 match message.get("tools") {
190 None | Some(Value::Null) => Ok(None),
191 Some(Value::Array(tools)) if tools.is_empty() => Ok(None),
192 Some(Value::Array(tools)) => Ok(Some(tools)),
193 Some(_) => Err(PromptRenderError::invalid_request(
194 "Kimi K3 dynamic tool messages need `tools` to be an array",
195 )
196 .into()),
197 }
198}
199
200fn dynamic_tool_entry_name(tool: &Value) -> Result<&str> {
203 let object = tool.as_object().ok_or_else(|| {
204 PromptRenderError::invalid_request("Kimi K3 dynamic tool entries must be JSON objects")
205 })?;
206 let name = match (object.get("type"), object.get("function")) {
207 (Some(kind), function) => {
208 if kind.as_str() != Some("function") {
209 return Err(PromptRenderError::invalid_request(format!(
210 "Kimi K3 dynamic tool entries must have type=\"function\", got {kind}"
211 ))
212 .into());
213 }
214 let function = function.and_then(Value::as_object).ok_or_else(|| {
215 PromptRenderError::invalid_request(
216 "Kimi K3 dynamic tool entries with type=\"function\" need a `function` object",
217 )
218 })?;
219 function.get("name")
220 }
221 (None, Some(_)) => {
222 return Err(PromptRenderError::invalid_request(
223 "Kimi K3 dynamic tool entries with a `function` object need type=\"function\"",
224 )
225 .into());
226 }
227 (None, None) => object.get("name"),
228 };
229 let name = name.and_then(Value::as_str).ok_or_else(|| {
230 PromptRenderError::invalid_request("Kimi K3 dynamic tool entries need a string `name`")
231 })?;
232 validate_tool_name(name)?;
233 Ok(name)
234}
235
236fn validate_tool_name(name: &str) -> Result<()> {
239 let mut chars = name.chars();
240 let valid_start = chars
241 .next()
242 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
243 let valid_rest = chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
244 if !valid_start || !valid_rest {
245 return Err(PromptRenderError::invalid_request(format!(
246 "Kimi K3 tool name {name:?} must match [A-Za-z_][A-Za-z0-9_-]*"
247 ))
248 .into());
249 }
250 if name.len() > MAX_TOOL_NAME_LEN {
251 return Err(PromptRenderError::invalid_request(format!(
252 "Kimi K3 tool name is {} characters; the maximum is {MAX_TOOL_NAME_LEN}",
253 name.len()
254 ))
255 .into());
256 }
257 Ok(())
258}
259
260fn validate_tool_declarations(top_level: Option<&Value>, messages: &[Value]) -> Result<()> {
264 let mut seen = std::collections::HashSet::new();
265 for tool in top_level.and_then(Value::as_array).into_iter().flatten() {
267 let name = dynamic_tool_entry_name(tool)?;
268 if !seen.insert(name) {
269 return Err(PromptRenderError::invalid_request(format!(
270 "tool {name:?} is declared more than once in `tools`"
271 ))
272 .into());
273 }
274 }
275 for message in messages {
276 let role = message.get("role").and_then(Value::as_str);
277 if !matches!(role, Some("system" | "developer")) {
278 if message.get("tools").is_some_and(|tools| !tools.is_null()) {
279 return Err(PromptRenderError::invalid_request(format!(
280 "`tools` is only accepted on system or developer messages, not on role {}",
281 role.unwrap_or("<missing>")
282 ))
283 .into());
284 }
285 continue;
286 }
287 for tool in dynamic_tools_of(message)?.into_iter().flatten() {
288 let name = dynamic_tool_entry_name(tool)?;
289 if !seen.insert(name) {
290 return Err(PromptRenderError::invalid_request(format!(
291 "tool {name:?} is declared more than once across `tools` and dynamic message tools"
292 ))
293 .into());
294 }
295 }
296 }
297 Ok(())
298}
299
300fn content_is_non_empty(content: Option<&Value>) -> bool {
303 match content {
304 None | Some(Value::Null) => false,
305 Some(Value::String(text)) => !text.is_empty(),
306 Some(Value::Array(parts)) => !parts.is_empty(),
307 Some(_) => true,
308 }
309}
310
311fn message_declares_tool(message: &Value, name: &str) -> bool {
312 matches!(
313 message.get("role").and_then(Value::as_str),
314 Some("system" | "developer")
315 ) && message
316 .get("tools")
317 .is_some_and(|tools| contains_tool(tools, name))
318}
319
320fn resolve_thinking_effort(args: Option<&HashMap<String, Value>>) -> String {
321 args.and_then(|args| {
322 args.get("thinking_effort")
323 .or_else(|| args.get("reasoning_effort"))
324 .and_then(Value::as_str)
325 })
326 .unwrap_or("max")
327 .to_string()
328}
329
330fn push_segment(segments: &mut Vec<RenderedSegment>, text: impl Into<String>, allow_special: bool) {
331 let text = text.into();
332 if !text.is_empty() {
333 segments.push(RenderedSegment {
334 text,
335 allow_special,
336 });
337 }
338}
339
340fn control(segments: &mut Vec<RenderedSegment>, text: impl Into<String>) {
341 push_segment(segments, text, true);
342}
343
344fn text(segments: &mut Vec<RenderedSegment>, text: impl Into<String>) {
345 push_segment(segments, text, false);
346}
347
348fn escape_attr_value(value: impl std::fmt::Display) -> String {
349 value
350 .to_string()
351 .replace('&', "&")
352 .replace('"', """)
353}
354
355fn open_tag(
356 segments: &mut Vec<RenderedSegment>,
357 tag: &str,
358 attrs: impl IntoIterator<Item = (String, String)>,
359) {
360 control(segments, OPEN_TOKEN);
361 text(segments, tag);
362 for (key, value) in attrs {
363 text(segments, format!(" {key}"));
364 text(segments, "=\"");
365 text(segments, escape_attr_value(value));
366 text(segments, "\"");
367 }
368 control(segments, SEP_TOKEN);
369}
370
371fn close_tag(segments: &mut Vec<RenderedSegment>, tag: &str) {
372 control(segments, CLOSE_TOKEN);
373 text(segments, tag);
374 control(segments, SEP_TOKEN);
375}
376
377fn end_of_msg(segments: &mut Vec<RenderedSegment>) {
378 control(segments, END_OF_MSG_TOKEN);
379}
380
381fn internal_system_message(segments: &mut Vec<RenderedSegment>, message_type: &str, body: &str) {
382 open_tag(
383 segments,
384 "message",
385 [
386 ("role".to_string(), "system".to_string()),
387 ("type".to_string(), message_type.to_string()),
388 ],
389 );
390 text(segments, body.trim());
391 close_tag(segments, "message");
392 end_of_msg(segments);
393}
394
395fn deep_sort(value: Value) -> Value {
396 match value {
397 Value::Object(map) => {
398 let mut entries: Vec<_> = map.into_iter().collect();
399 entries.sort_by(|(left, _), (right, _)| left.cmp(right));
400 Value::Object(
401 entries
402 .into_iter()
403 .map(|(key, value)| (key, deep_sort(value)))
404 .collect(),
405 )
406 }
407 Value::Array(items) => Value::Array(items.into_iter().map(deep_sort).collect()),
408 other => other,
409 }
410}
411
412fn compact_json(value: &Value) -> Result<String> {
413 serde_json::to_string(value).context("Failed to serialize K3 JSON")
414}
415
416fn response_schema(response_format: &Value) -> Option<Value> {
417 let json_schema = response_format.get("json_schema")?;
418 if let Some(schema) = json_schema.get("schema") {
419 return Some(schema.clone());
420 }
421 if let Some(schema) = json_schema.get("json_schema") {
422 return Some(schema.clone());
423 }
424 Some(json_schema.clone())
425}
426
427fn value_as_body_text(value: &Value) -> Result<String> {
428 match value {
429 Value::String(value) => Ok(value.clone()),
430 Value::Array(values) if values.iter().all(Value::is_string) => Ok(values
431 .iter()
432 .filter_map(Value::as_str)
433 .filter(|value| !value.is_empty())
434 .collect::<Vec<_>>()
435 .join("\n")),
436 other => compact_json(other),
437 }
438}
439
440fn render_content_segments(
441 segments: &mut Vec<RenderedSegment>,
442 content: Option<&Value>,
443) -> Result<()> {
444 let Some(content) = content else {
445 return Ok(());
446 };
447 match content {
448 Value::Null => {}
449 Value::String(value) => text(segments, value),
450 Value::Array(parts) => {
451 for part in parts {
452 match part.get("type").and_then(Value::as_str) {
453 Some("image" | "image_url") => control(segments, MEDIA_PAD),
454 _ => {
455 if let Some(part_text) = part.get("text") {
456 text(segments, value_as_body_text(part_text)?);
457 }
458 }
459 }
460 }
461 }
462 other => text(segments, value_as_body_text(other)?),
463 }
464 Ok(())
465}
466
467fn render_role_message(
468 segments: &mut Vec<RenderedSegment>,
469 message: &Value,
470 role: &str,
471) -> Result<()> {
472 let mut attrs = vec![("role".to_string(), role.to_string())];
473 if let Some(name) = message
474 .get("name")
475 .and_then(Value::as_str)
476 .filter(|name| !name.is_empty())
477 {
478 attrs.push(("name".to_string(), name.to_string()));
479 }
480 open_tag(segments, "message", attrs);
481 render_content_segments(segments, message.get("content"))?;
482 close_tag(segments, "message");
483 end_of_msg(segments);
484 Ok(())
485}
486
487fn render_tool_declare(
488 segments: &mut Vec<RenderedSegment>,
489 tools: &Value,
490 dynamic: bool,
491) -> Result<()> {
492 let tools = compact_json(tools)?;
493 let body = if dynamic {
494 format!(
495 "## New Tools Available\n\
496 The system dynamically extends the toolset via lazy-loading.\n\
497 You have access to all existing and extended tools.\n\
498 Here are the specs for the extended tools.\n\n\
499 ```json\n{tools}\n```"
500 )
501 } else {
502 format!(
503 "# Tools\n\
504 Here are the available tools, described in JSONSchema.\n\n\
505 ```json\n{tools}\n```"
506 )
507 };
508 open_tag(
509 segments,
510 "message",
511 [
512 ("role".to_string(), "system".to_string()),
513 ("type".to_string(), "tool-declare".to_string()),
514 ],
515 );
516 text(segments, body);
517 close_tag(segments, "message");
518 end_of_msg(segments);
519 Ok(())
520}
521
522fn xtml_type(value: &Value) -> &'static str {
523 match value {
524 Value::Bool(_) => "boolean",
525 Value::Null => "null",
526 Value::Number(_) => "number",
527 Value::String(_) => "string",
528 Value::Object(_) => "object",
529 Value::Array(_) => "array",
530 }
531}
532
533fn xtml_value(value: &Value) -> Result<String> {
534 match value {
535 Value::String(value) => Ok(value.clone()),
536 other => python_default_json(other),
540 }
541}
542
543fn python_default_json(value: &Value) -> Result<String> {
544 let compact = compact_json(value)?;
545 let mut output = String::with_capacity(compact.len());
546 let mut in_string = false;
547 let mut escaped = false;
548 for ch in compact.chars() {
549 output.push(ch);
550 if in_string {
551 if escaped {
552 escaped = false;
553 } else if ch == '\\' {
554 escaped = true;
555 } else if ch == '"' {
556 in_string = false;
557 }
558 } else if ch == '"' {
559 in_string = true;
560 } else if matches!(ch, ',' | ':') {
561 output.push(' ');
562 }
563 }
564 Ok(output)
565}
566
567enum NormalizedArguments {
568 Object(Map<String, Value>),
569 JsonBlock(String),
570}
571
572fn normalize_arguments(arguments: Option<&Value>) -> Result<NormalizedArguments> {
573 let Some(arguments) = arguments else {
574 return Ok(NormalizedArguments::Object(Map::new()));
575 };
576 match arguments {
577 Value::Null => Ok(NormalizedArguments::Object(Map::new())),
578 Value::Object(arguments) => Ok(NormalizedArguments::Object(arguments.clone())),
579 Value::String(arguments) if arguments.trim().is_empty() => {
580 Ok(NormalizedArguments::Object(Map::new()))
581 }
582 Value::String(arguments) => match serde_json::from_str::<Value>(arguments) {
583 Ok(Value::Object(arguments)) => Ok(NormalizedArguments::Object(arguments)),
584 Ok(_) => bail!("Kimi K3 tool call arguments must be a JSON object"),
585 Err(_) => Ok(NormalizedArguments::JsonBlock(arguments.clone())),
586 },
587 _ => bail!("Kimi K3 tool call arguments must be an object or JSON object string"),
588 }
589}
590
591fn render_think_channel(
598 segments: &mut Vec<RenderedSegment>,
599 message: &Value,
600 thinking: bool,
601) -> Result<()> {
602 if !thinking {
603 return Ok(());
604 }
605 let reasoning = message
608 .get("reasoning_content")
609 .filter(|value| match value {
610 Value::Null => false,
611 Value::Bool(value) => *value,
612 Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
613 Value::String(value) => !value.is_empty(),
614 Value::Array(value) => !value.is_empty(),
615 Value::Object(value) => !value.is_empty(),
616 })
617 .or_else(|| message.get("reasoning"))
618 .map(value_as_body_text)
619 .transpose()?;
620
621 open_tag(segments, "think", []);
622 if let Some(reasoning) = reasoning.filter(|reasoning| !reasoning.trim().is_empty()) {
623 text(segments, reasoning);
624 }
625 close_tag(segments, "think");
626 Ok(())
627}
628
629fn assistant_message_attrs(message: &Value) -> Vec<(String, String)> {
630 let mut attrs = vec![("role".to_string(), "assistant".to_string())];
631 if let Some(name) = message
632 .get("name")
633 .and_then(Value::as_str)
634 .filter(|name| !name.is_empty())
635 {
636 attrs.push(("name".to_string(), name.to_string()));
637 }
638 attrs
639}
640
641fn is_partial(message: &Value) -> bool {
642 message.get("partial").and_then(Value::as_bool) == Some(true)
643}
644
645fn render_partial_assistant_segments(
649 segments: &mut Vec<RenderedSegment>,
650 message: &Value,
651 thinking: bool,
652) -> Result<()> {
653 if message
654 .get("tool_calls")
655 .is_some_and(|calls| !calls.is_null() && !calls.as_array().is_some_and(Vec::is_empty))
656 {
657 return Err(PromptRenderError::invalid_request(
658 "Kimi K3 partial assistant messages cannot carry tool_calls",
659 )
660 .into());
661 }
662 open_tag(segments, "message", assistant_message_attrs(message));
663 render_think_channel(segments, message, thinking)?;
664 open_tag(segments, "response", []);
665 render_content_segments(segments, message.get("content"))?;
666 Ok(())
667}
668
669fn render_assistant_segments(
670 segments: &mut Vec<RenderedSegment>,
671 message: &Value,
672 thinking: bool,
673) -> Result<()> {
674 render_think_channel(segments, message, thinking)?;
675
676 open_tag(segments, "response", []);
677 render_content_segments(segments, message.get("content"))?;
678 close_tag(segments, "response");
679
680 let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) else {
681 return Ok(());
682 };
683 if tool_calls.is_empty() {
684 return Ok(());
685 }
686
687 open_tag(segments, "tools", []);
688 for (position, tool_call) in tool_calls.iter().enumerate() {
689 let function = tool_call.get("function").unwrap_or(tool_call);
690 let name = function
691 .get("name")
692 .and_then(Value::as_str)
693 .context("Kimi K3 tool call is missing function.name")?;
694 open_tag(
695 segments,
696 "call",
697 [
698 ("tool".to_string(), name.to_string()),
699 ("index".to_string(), (position + 1).to_string()),
700 ],
701 );
702
703 match normalize_arguments(function.get("arguments"))? {
704 NormalizedArguments::JsonBlock(raw) => {
705 open_tag(
706 segments,
707 "json",
708 [("type".to_string(), "object".to_string())],
709 );
710 text(segments, raw);
711 close_tag(segments, "json");
712 }
713 NormalizedArguments::Object(arguments) => {
714 for (key, value) in arguments {
715 open_tag(
716 segments,
717 "argument",
718 [
719 ("key".to_string(), key),
720 ("type".to_string(), xtml_type(&value).to_string()),
721 ],
722 );
723 text(segments, xtml_value(&value)?);
724 close_tag(segments, "argument");
725 }
726 }
727 }
728 close_tag(segments, "call");
729 }
730 close_tag(segments, "tools");
731 Ok(())
732}
733
734fn tool_call_index(tool_calls: Option<&Value>) -> HashMap<String, (usize, Option<String>)> {
735 let mut index = HashMap::new();
736 let Some(tool_calls) = tool_calls.and_then(Value::as_array) else {
737 return index;
738 };
739 for (position, tool_call) in tool_calls.iter().enumerate() {
740 let Some(id) = tool_call.get("id").and_then(Value::as_str) else {
741 continue;
742 };
743 let function = tool_call.get("function").unwrap_or(tool_call);
744 let name = function
745 .get("name")
746 .and_then(Value::as_str)
747 .map(str::to_string);
748 index.entry(id.to_string()).or_insert((position + 1, name));
749 }
750 index
751}
752
753fn normalize_tool_result_messages(messages: &[Value]) -> Result<Vec<Value>> {
754 let mut output = Vec::with_capacity(messages.len());
755 let mut current_index = HashMap::new();
756 let mut position = 0;
757
758 while position < messages.len() {
759 let message = &messages[position];
760 let role = message.get("role").and_then(Value::as_str);
761 if role == Some("assistant") {
762 current_index = tool_call_index(message.get("tool_calls"));
763 output.push(message.clone());
764 position += 1;
765 continue;
766 }
767 if role != Some("tool") {
768 output.push(message.clone());
769 position += 1;
770 continue;
771 }
772
773 let mut run: Vec<(Option<usize>, usize, Value, Option<String>)> = Vec::new();
774 let mut unresolved = false;
775 let mut offset = 0;
776 while position < messages.len()
777 && messages[position].get("role").and_then(Value::as_str) == Some("tool")
778 {
779 let tool_message = &messages[position];
780 let call_id = tool_message
781 .get("tool_call_id")
782 .or_else(|| tool_message.get("id"))
783 .and_then(Value::as_str);
784 let matched = call_id.and_then(|id| current_index.get(id));
785 if let Some((tool_position, name)) = matched {
786 run.push((
787 Some(*tool_position),
788 offset,
789 tool_message.clone(),
790 name.clone(),
791 ));
792 } else {
793 unresolved = true;
794 run.push((None, offset, tool_message.clone(), None));
795 }
796 offset += 1;
797 position += 1;
798 }
799
800 if unresolved {
801 output.extend(run.into_iter().map(|(_, _, message, _)| message));
802 continue;
803 }
804 run.sort_by_key(|(tool_position, offset, _, _)| (*tool_position, *offset));
805 for (_, _, mut message, name) in run {
806 if let (Some(name), Some(message)) = (name, message.as_object_mut()) {
807 message.insert("tool".to_string(), Value::String(name.clone()));
808 if message.contains_key("name") {
809 message.insert("name".to_string(), Value::String(name));
810 }
811 }
812 output.push(message);
813 }
814 }
815 Ok(output)
816}
817
818#[allow(clippy::too_many_arguments)]
819fn build_chat_segments(
820 messages: &[Value],
821 tools: Option<&Value>,
822 tool_choice: Option<&str>,
823 named_tool: Option<&str>,
824 response_format: Option<&Value>,
825 add_generation_prompt: bool,
826 thinking: bool,
827 thinking_effort: &str,
828) -> Result<Vec<RenderedSegment>> {
829 let mut segments = Vec::new();
830 let mut previous_tool_calls: Option<&Value> = None;
831 let mut tool_index = 0usize;
832
833 for message in messages {
834 let Some(partial) = message.get("partial").filter(|value| !value.is_null()) else {
835 continue;
836 };
837 if message.get("role").and_then(Value::as_str) != Some("assistant") {
838 return Err(PromptRenderError::invalid_request(
839 "Kimi K3 `partial` is only supported on an assistant message",
840 )
841 .into());
842 }
843 if !partial.is_boolean() {
844 return Err(
845 PromptRenderError::invalid_request("Kimi K3 `partial` must be a boolean").into(),
846 );
847 }
848 }
849
850 let (history, partial_tail) = match messages.split_last() {
855 Some((last, history)) if is_partial(last) => (history, Some(last)),
856 _ => (messages, None),
857 };
858
859 validate_tool_declarations(tools, messages)?;
863 if history.iter().any(is_partial) {
864 return Err(PromptRenderError::invalid_request(
865 "Kimi K3 `partial` is only supported on the final message",
866 )
867 .into());
868 }
869
870 if let Some(tools) = tools.filter(|tools| !tools.as_array().is_some_and(Vec::is_empty)) {
871 render_tool_declare(&mut segments, tools, false)?;
872 }
873
874 if thinking {
875 internal_system_message(
876 &mut segments,
877 "thinking-effort",
878 &format!(
879 "`thinking_effort` guides on how much to think in your thinking channel \
880 (not including the response channel), supported values include `low`, \
881 `medium`, `high`, and `max`.\nNow the system is invoked with \
882 `thinking_effort={thinking_effort}`."
883 ),
884 );
885 }
886
887 for message in history {
888 let role = message.get("role").and_then(Value::as_str).ok_or_else(|| {
889 PromptRenderError::invalid_request("Kimi K3 messages must contain a string role")
890 })?;
891 let dynamic_tools = dynamic_tools_of(message)?;
893 match role {
894 "system" | "developer" if dynamic_tools.is_some() => {
895 let dynamic_tools = dynamic_tools.expect("guarded by the match arm");
896 if role == "system" && content_is_non_empty(message.get("content")) {
901 return Err(PromptRenderError::invalid_request(
902 "Kimi K3 system messages carry either `content` or `tools`, not both",
903 )
904 .into());
905 }
906 let dynamic_tools = deep_sort(Value::Array(dynamic_tools.clone()));
907 render_tool_declare(&mut segments, &dynamic_tools, true)?;
908 if role == "developer"
909 && message
910 .get("content")
911 .is_some_and(|content| !content.is_null())
912 {
913 render_role_message(&mut segments, message, "system")?;
914 }
915 }
916 "system" | "developer"
917 if message
918 .get("content")
919 .is_none_or(|content| content.is_null()) =>
920 {
921 return Err(PromptRenderError::invalid_request(format!(
922 "Kimi K3 {role} messages need `content` or `tools`"
923 ))
924 .into());
925 }
926 "user" | "system" | "developer" => {
927 let rendered_role = if role == "developer" { "system" } else { role };
928 render_role_message(&mut segments, message, rendered_role)?;
929 }
930 "assistant" => {
931 previous_tool_calls = message.get("tool_calls");
932 tool_index = 0;
933 open_tag(&mut segments, "message", assistant_message_attrs(message));
934 render_assistant_segments(&mut segments, message, thinking)?;
935 close_tag(&mut segments, "message");
936 end_of_msg(&mut segments);
937 }
938 "tool" => {
939 tool_index += 1;
940 let fallback_name = previous_tool_calls
941 .and_then(Value::as_array)
942 .and_then(|calls| calls.get(tool_index - 1))
943 .map(|call| call.get("function").unwrap_or(call))
944 .and_then(|function| function.get("name"))
945 .and_then(Value::as_str);
946 let tool_name = message
947 .get("tool")
948 .or_else(|| message.get("name"))
949 .and_then(Value::as_str)
950 .or(fallback_name)
951 .context(
952 "Kimi K3 tool messages need a tool/name or a preceding assistant tool call",
953 )?;
954 open_tag(
955 &mut segments,
956 "message",
957 [
958 ("role".to_string(), "tool".to_string()),
959 ("tool".to_string(), tool_name.to_string()),
960 ("index".to_string(), tool_index.to_string()),
961 ],
962 );
963 render_content_segments(&mut segments, message.get("content"))?;
964 close_tag(&mut segments, "message");
965 end_of_msg(&mut segments);
966 }
967 unsupported => {
968 return Err(PromptRenderError::invalid_request(format!(
969 "Kimi K3 does not support message role {unsupported:?}"
970 ))
971 .into());
972 }
973 }
974 }
975
976 match tool_choice {
977 Some("required") => internal_system_message(
978 &mut segments,
979 "tool-choice",
980 "The system is invoked with `tool_choice=required`.\n\
981 You MUST call tools in the next message.",
982 ),
983 Some("none") => internal_system_message(
984 &mut segments,
985 "tool-choice",
986 "The system is invoked with `tool_choice=none`.\n\
987 You MUST NOT call any tools in the next message.",
988 ),
989 Some("specified") => internal_system_message(
990 &mut segments,
991 "tool-choice",
992 &format!(
993 "The system is invoked with `tool_choice=specified`.\n\
994 You MUST call the tool `{}` in the next message.",
995 named_tool.expect("specified tool_choice has a function name")
996 ),
997 ),
998 _ => {}
999 }
1000
1001 if let Some(response_format) = response_format {
1002 match response_format.get("type").and_then(Value::as_str) {
1003 Some("json_object") => internal_system_message(
1004 &mut segments,
1005 "response-format",
1006 "The system is invoked with `response_format=json_object`.\n\
1007 Your response must be raw JSON data without markdown code blocks \
1008 (```json) or any additional formatting.",
1009 ),
1010 Some("json_schema") => {
1011 let schema = response_schema(response_format)
1012 .map(deep_sort)
1013 .unwrap_or(Value::Null);
1014 internal_system_message(
1015 &mut segments,
1016 "response-format",
1017 &format!(
1018 "The system is invoked with `response_format=json_schema`.\n\
1019 Your response must be raw JSON data without markdown code blocks \
1020 (```json) or any additional formatting.\n\
1021 The JSON data must match the following schema:\n\
1022 ```json\n{}\n```",
1023 compact_json(&schema)?
1024 ),
1025 );
1026 }
1027 _ => {}
1028 }
1029 }
1030
1031 if let Some(partial) = partial_tail {
1035 render_partial_assistant_segments(&mut segments, partial, thinking)?;
1036 } else if add_generation_prompt {
1037 open_tag(
1038 &mut segments,
1039 "message",
1040 [("role".to_string(), "assistant".to_string())],
1041 );
1042 open_tag(
1043 &mut segments,
1044 if thinking { "think" } else { "response" },
1045 [],
1046 );
1047 }
1048
1049 Ok(segments)
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055 use minijinja::value::Value as MiniValue;
1056 use serde_json::json;
1057
1058 struct Request {
1059 messages: Value,
1060 tools: Option<Value>,
1061 tool_choice: Option<Value>,
1062 response_format: Option<Value>,
1063 args: HashMap<String, Value>,
1064 add_generation_prompt: bool,
1065 }
1066
1067 impl Request {
1068 fn new(messages: Value) -> Self {
1069 Self {
1070 messages,
1071 tools: None,
1072 tool_choice: None,
1073 response_format: None,
1074 args: HashMap::new(),
1075 add_generation_prompt: true,
1076 }
1077 }
1078 }
1079
1080 impl OAIChatLikeRequest for Request {
1081 fn model(&self) -> String {
1082 "kimi-k3".to_string()
1083 }
1084
1085 fn messages(&self) -> MiniValue {
1086 MiniValue::from_serialize(&self.messages)
1087 }
1088
1089 fn tools(&self) -> Option<MiniValue> {
1090 self.tools.as_ref().map(MiniValue::from_serialize)
1091 }
1092
1093 fn tool_choice(&self) -> Option<MiniValue> {
1094 self.tool_choice.as_ref().map(MiniValue::from_serialize)
1095 }
1096
1097 fn response_format(&self) -> Option<MiniValue> {
1098 self.response_format.as_ref().map(MiniValue::from_serialize)
1099 }
1100
1101 fn should_add_generation_prompt(&self) -> bool {
1102 self.add_generation_prompt
1103 }
1104
1105 fn chat_template_args(&self) -> Option<&HashMap<String, Value>> {
1106 Some(&self.args)
1107 }
1108 }
1109
1110 fn fmt() -> KimiK3Formatter {
1112 KimiK3Formatter::new(true)
1113 }
1114
1115 fn image_request() -> Request {
1117 let mut request = Request::new(json!([{
1118 "role": "user",
1119 "content": [{"type": "image_url", "image_url": {"url": "http://example.com/a.png"}}]
1120 }]));
1121 request
1122 .args
1123 .insert("thinking".to_string(), Value::Bool(false));
1124 request
1125 }
1126
1127 fn image_segments(formatter: &KimiK3Formatter, request: &Request) -> Vec<RenderedSegment> {
1128 formatter
1129 .render_prompt(request)
1130 .unwrap()
1131 .segments()
1132 .expect("K3 always renders segmented prompts")
1133 .to_vec()
1134 }
1135
1136 #[test]
1137 fn renders_one_media_pad_per_image() {
1138 let segments = image_segments(&fmt(), &image_request());
1139
1140 let matches: Vec<_> = segments
1141 .iter()
1142 .filter(|segment| segment.text == MEDIA_PAD)
1143 .collect();
1144 assert_eq!(matches.len(), 1, "exactly one pad per image");
1145 assert!(matches[0].allow_special);
1148 assert!(
1151 !segments
1152 .iter()
1153 .any(|segment| segment.text.contains("kimi_image_placeholder")),
1154 );
1155 }
1156
1157 #[test]
1158 fn image_token_cardinality_is_one_per_image() {
1159 let mut request = Request::new(json!([{
1160 "role": "user",
1161 "content": [
1162 {"type": "image_url", "image_url": {"url": "http://example.com/a.png"}},
1163 {"type": "text", "text": "and"},
1164 {"type": "image_url", "image_url": {"url": "http://example.com/b.png"}},
1165 {"type": "text", "text": "compare them"}
1166 ]
1167 }]));
1168 request
1169 .args
1170 .insert("thinking".to_string(), Value::Bool(false));
1171
1172 let segments = image_segments(&fmt(), &request);
1173
1174 assert_eq!(
1175 segments
1176 .iter()
1177 .filter(|segment| segment.text == MEDIA_PAD)
1178 .count(),
1179 2
1180 );
1181 for body in ["and", "compare them"] {
1183 assert!(
1184 segments
1185 .iter()
1186 .any(|segment| segment.text == body && !segment.allow_special)
1187 );
1188 }
1189 }
1190
1191 #[test]
1192 fn user_text_spelling_the_pad_stays_ordinary() {
1193 let body = "please describe <|media_pad|>";
1194 let mut request = Request::new(json!([{"role": "user", "content": body}]));
1195 request
1196 .args
1197 .insert("thinking".to_string(), Value::Bool(false));
1198
1199 let segments = image_segments(&fmt(), &request);
1200
1201 assert!(
1202 segments
1203 .iter()
1204 .any(|segment| segment.text == body && !segment.allow_special),
1205 "user content must never be promoted into prompt structure"
1206 );
1207 }
1208
1209 #[test]
1210 fn renders_off_mode_like_model_encoding() {
1211 let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
1212 request
1213 .args
1214 .insert("thinking".to_string(), Value::Bool(false));
1215 let rendered = fmt().render(&request).unwrap();
1216 assert_eq!(
1217 rendered,
1218 concat!(
1219 "<|open|>message role=\"user\"<|sep|>Hello",
1220 "<|close|>message<|sep|><|end_of_msg|>",
1221 "<|open|>message role=\"assistant\"<|sep|>",
1222 "<|open|>response<|sep|>"
1223 )
1224 );
1225 }
1226
1227 #[test]
1228 fn renders_developer_messages_as_system() {
1229 let mut request = Request::new(json!([
1230 {"role": "developer", "content": "Follow this policy", "name": "policy"},
1231 {"role": "user", "content": "Hello"}
1232 ]));
1233 request
1234 .args
1235 .insert("thinking".to_string(), Value::Bool(false));
1236
1237 let rendered = fmt().render(&request).unwrap();
1238
1239 assert!(
1240 rendered.contains(
1241 "<|open|>message role=\"system\" name=\"policy\"<|sep|>Follow this policy"
1242 )
1243 );
1244 assert!(!rendered.contains("role=\"developer\""));
1245 assert!(
1246 rendered.find("Follow this policy").unwrap() < rendered.find("Hello").unwrap(),
1247 "developer instructions must retain their position"
1248 );
1249 }
1250
1251 #[test]
1252 fn renders_developer_tools_and_content_in_place_with_named_tool_choice() {
1253 let mut request = Request::new(json!([
1254 {"role": "user", "content": "Start"},
1255 {
1256 "role": "developer",
1257 "name": "policy",
1258 "content": "Use the lookup tool",
1259 "tools": [{"type": "function", "function": {"name": "lookup"}}]
1260 },
1261 {"role": "user", "content": "Look this up"}
1262 ]));
1263 request.tool_choice = Some(json!({
1264 "type": "function",
1265 "function": {"name": "lookup"}
1266 }));
1267 let rendered = fmt().render(&request).unwrap();
1268 let developer_turn = concat!(
1269 "<|open|>message role=\"system\" name=\"policy\"<|sep|>Use the lookup tool",
1270 "<|close|>message<|sep|><|end_of_msg|>"
1271 );
1272 let declaration = rendered.find("## New Tools Available").unwrap();
1273 let content = rendered.find(developer_turn).unwrap();
1274 assert!(rendered.find("Start").unwrap() < declaration);
1275 assert!(declaration < content);
1276 assert!(content < rendered.find("Look this up").unwrap());
1277 assert!(rendered.contains("\"name\":\"lookup\""));
1278 assert!(rendered.contains("MUST call the tool `lookup`"));
1279
1280 request.messages[1]
1281 .as_object_mut()
1282 .unwrap()
1283 .remove("content");
1284 assert_eq!(
1285 fmt().render(&request).unwrap(),
1286 rendered.replace(developer_turn, "")
1287 );
1288 }
1289
1290 #[test]
1291 fn rejects_tools_on_unsupported_message_roles() {
1292 let tools = json!([{"type": "function", "function": {"name": "lookup"}}]);
1293 for (role, extra) in [
1294 ("user", json!({"content": "Look this up"})),
1295 ("assistant", json!({"content": "ok"})),
1296 ] {
1297 let mut message = extra;
1298 message["role"] = json!(role);
1299 message["tools"] = tools.clone();
1300 let request = Request::new(json!([message, {"role": "user", "content": "Go"}]));
1301
1302 let error = fmt().render(&request).unwrap_err();
1303 assert_eq!(
1304 invalid_request_message(&error),
1305 format!(
1306 "`tools` is only accepted on system or developer messages, not on role {role}"
1307 ),
1308 "role={role}"
1309 );
1310 }
1311 }
1312
1313 #[test]
1314 fn rejects_unsupported_message_roles() {
1315 for role in ["function", "unknown"] {
1316 let request = Request::new(json!([{"role": role, "content": "ignored before"}]));
1317
1318 let error = fmt().render(&request).unwrap_err();
1319
1320 assert!(matches!(
1321 error.downcast_ref::<PromptRenderError>(),
1322 Some(PromptRenderError::InvalidRequest(message))
1323 if message == &format!("Kimi K3 does not support message role {role:?}")
1324 ));
1325 }
1326 }
1327
1328 #[test]
1329 fn rejects_messages_without_a_string_role() {
1330 for messages in [json!([{"content": "missing"}]), json!([{"role": 7}])] {
1331 let request = Request::new(messages);
1332
1333 let error = fmt().render(&request).unwrap_err();
1334
1335 assert!(matches!(
1336 error.downcast_ref::<PromptRenderError>(),
1337 Some(PromptRenderError::InvalidRequest(message))
1338 if message == "Kimi K3 messages must contain a string role"
1339 ));
1340 }
1341 }
1342
1343 #[test]
1344 fn rejects_unsupported_thinking_effort_as_invalid_request() {
1345 let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
1346 request.args.insert(
1347 "thinking_effort".to_string(),
1348 Value::String("medium".to_string()),
1349 );
1350
1351 let error = fmt().render(&request).unwrap_err();
1352 assert!(matches!(
1353 error.downcast_ref::<PromptRenderError>(),
1354 Some(PromptRenderError::InvalidRequest(message))
1355 if message.contains("thinking_effort=\"medium\"")
1356 ));
1357 }
1358
1359 #[test]
1362 fn partial_assistant_renders_open_turn_in_place_of_generation_prompt() {
1363 let mut request = Request::new(json!([
1364 {"role": "user", "content": "Greet the customer"},
1365 {"role": "assistant", "content": "Dear customer, hello", "partial": true}
1366 ]));
1367 request
1368 .args
1369 .insert("thinking".to_string(), Value::Bool(false));
1370
1371 let rendered = fmt().render(&request).unwrap();
1372
1373 assert_eq!(
1374 rendered,
1375 concat!(
1376 "<|open|>message role=\"user\"<|sep|>Greet the customer",
1377 "<|close|>message<|sep|><|end_of_msg|>",
1378 "<|open|>message role=\"assistant\"<|sep|>",
1379 "<|open|>response<|sep|>Dear customer, hello"
1380 ),
1381 "the partial turn must stay open: no <|close|>response / <|close|>message / <|end_of_msg|>, \
1382 and no extra generation prompt after it"
1383 );
1384 for tool_calls in [Value::Null, json!([])] {
1385 request.messages[1]["tool_calls"] = tool_calls;
1386 assert_eq!(fmt().render(&request).unwrap(), rendered);
1387 }
1388 }
1389
1390 #[test]
1391 fn partial_assistant_ignores_add_generation_prompt_flag() {
1392 let mut request = Request::new(json!([
1393 {"role": "user", "content": "Go"},
1394 {"role": "assistant", "content": "prefix", "partial": true}
1395 ]));
1396 request
1397 .args
1398 .insert("thinking".to_string(), Value::Bool(false));
1399 request.add_generation_prompt = false;
1400
1401 let rendered = fmt().render(&request).unwrap();
1402
1403 assert!(rendered.ends_with("<|open|>response<|sep|>prefix"));
1404 assert_eq!(rendered.matches("role=\"assistant\"").count(), 1);
1405 }
1406
1407 #[test]
1408 fn partial_assistant_in_thinking_mode_closes_think_then_opens_response() {
1409 let mut request = Request::new(json!([
1410 {"role": "user", "content": "Go"},
1411 {
1412 "role": "assistant",
1413 "reasoning_content": "carried over reasoning",
1414 "content": "prefix",
1415 "partial": true
1416 }
1417 ]));
1418 request
1419 .args
1420 .insert("thinking".to_string(), Value::Bool(true));
1421
1422 let rendered = fmt().render(&request).unwrap();
1423
1424 assert!(rendered.ends_with(concat!(
1425 "<|open|>message role=\"assistant\"<|sep|>",
1426 "<|open|>think<|sep|>carried over reasoning<|close|>think<|sep|>",
1427 "<|open|>response<|sep|>prefix"
1428 )));
1429 }
1430
1431 #[test]
1432 fn partial_assistant_keeps_name_as_part_of_the_prefix() {
1433 let mut request = Request::new(json!([
1434 {"role": "user", "content": "Who are you?"},
1435 {"role": "assistant", "name": "Sherlock", "content": "Elementary", "partial": true}
1436 ]));
1437 request
1438 .args
1439 .insert("thinking".to_string(), Value::Bool(false));
1440
1441 let rendered = fmt().render(&request).unwrap();
1442
1443 assert!(rendered.ends_with(concat!(
1444 "<|open|>message role=\"assistant\" name=\"Sherlock\"<|sep|>",
1445 "<|open|>response<|sep|>Elementary"
1446 )));
1447 }
1448
1449 #[test]
1450 fn partial_assistant_follows_internal_system_messages() {
1451 let mut request = Request::new(json!([
1454 {"role": "user", "content": "Go"},
1455 {"role": "assistant", "content": "prefix", "partial": true}
1456 ]));
1457 request.tools = Some(json!([{
1458 "type": "function",
1459 "function": {"name": "lookup", "parameters": {"type": "object"}}
1460 }]));
1461 request.tool_choice = Some(json!("none"));
1462 request
1463 .args
1464 .insert("thinking".to_string(), Value::Bool(false));
1465
1466 let rendered = fmt().render(&request).unwrap();
1467
1468 let hint = rendered
1469 .find("tool_choice=none")
1470 .expect("tool-choice hint rendered");
1471 let turn = rendered
1472 .rfind("<|open|>message role=\"assistant\"<|sep|>")
1473 .expect("partial turn rendered");
1474 assert!(
1475 hint < turn,
1476 "internal system messages must precede the open partial turn"
1477 );
1478 assert!(rendered.ends_with("<|open|>response<|sep|>prefix"));
1479 }
1480
1481 #[test]
1482 fn partial_false_is_an_ordinary_assistant_turn() {
1483 let mut request = Request::new(json!([
1484 {"role": "user", "content": "Go"},
1485 {"role": "assistant", "content": "done", "partial": false}
1486 ]));
1487 request
1488 .args
1489 .insert("thinking".to_string(), Value::Bool(false));
1490
1491 let rendered = fmt().render(&request).unwrap();
1492
1493 assert!(rendered.contains(
1494 "<|open|>response<|sep|>done<|close|>response<|sep|><|close|>message<|sep|><|end_of_msg|>"
1495 ));
1496 assert!(
1497 rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>response<|sep|>")
1498 );
1499 }
1500
1501 #[test]
1502 fn rejects_partial_on_a_non_final_message() {
1503 let request = Request::new(json!([
1504 {"role": "assistant", "content": "early", "partial": true},
1505 {"role": "user", "content": "Go"}
1506 ]));
1507
1508 let error = fmt().render(&request).unwrap_err();
1509
1510 assert!(matches!(
1511 error.downcast_ref::<PromptRenderError>(),
1512 Some(PromptRenderError::InvalidRequest(message))
1513 if message == "Kimi K3 `partial` is only supported on the final message"
1514 ));
1515 }
1516
1517 #[test]
1518 fn rejects_partial_on_a_non_assistant_message() {
1519 let request = Request::new(json!([
1520 {"role": "user", "content": "Go", "partial": false}
1521 ]));
1522 let error = fmt().render(&request).unwrap_err();
1523 assert_eq!(
1524 invalid_request_message(&error),
1525 "Kimi K3 `partial` is only supported on an assistant message"
1526 );
1527 }
1528
1529 #[test]
1530 fn rejects_non_boolean_partial() {
1531 let request = Request::new(json!([
1532 {"role": "assistant", "content": "done", "partial": "true"}
1533 ]));
1534 let error = fmt().render(&request).unwrap_err();
1535 assert_eq!(
1536 invalid_request_message(&error),
1537 "Kimi K3 `partial` must be a boolean"
1538 );
1539 }
1540
1541 #[test]
1542 fn null_partial_is_equivalent_to_absent() {
1543 let mut request = Request::new(json!([
1544 {"role": "user", "content": "Go"}
1545 ]));
1546 let expected = fmt().render(&request).unwrap();
1547 request.messages[0]["partial"] = Value::Null;
1548 assert_eq!(fmt().render(&request).unwrap(), expected);
1549 }
1550
1551 fn invalid_request_message(error: &anyhow::Error) -> &str {
1554 match error.downcast_ref::<PromptRenderError>() {
1555 Some(PromptRenderError::InvalidRequest(message)) => message,
1556 other => panic!("expected InvalidRequest, got {other:?}"),
1557 }
1558 }
1559
1560 #[test]
1561 fn rejects_system_message_with_both_content_and_tools() {
1562 let request = Request::new(json!([
1563 {
1564 "role": "system",
1565 "content": "You are helpful",
1566 "tools": [{"type": "function", "function": {"name": "lookup"}}]
1567 },
1568 {"role": "user", "content": "Go"}
1569 ]));
1570
1571 let error = fmt().render(&request).unwrap_err();
1572 assert_eq!(
1573 invalid_request_message(&error),
1574 "Kimi K3 system messages carry either `content` or `tools`, not both"
1575 );
1576 }
1577
1578 #[test]
1579 fn rejects_system_message_tools_that_are_not_an_array() {
1580 let request = Request::new(json!([
1581 {"role": "system", "tools": {"name": "lookup"}},
1582 {"role": "user", "content": "Go"}
1583 ]));
1584
1585 let error = fmt().render(&request).unwrap_err();
1586 assert_eq!(
1587 invalid_request_message(&error),
1588 "Kimi K3 dynamic tool messages need `tools` to be an array"
1589 );
1590 }
1591
1592 #[test]
1595 fn accepts_dynamic_tools_with_empty_string_content() {
1596 for empty in [json!(""), json!([]), Value::Null] {
1597 let mut request = Request::new(json!([
1598 {"role": "user", "content": "Start"},
1599 {
1600 "role": "system",
1601 "content": empty,
1602 "tools": [{"type": "function", "function": {"name": "lookup"}}]
1603 },
1604 {"role": "user", "content": "Go"}
1605 ]));
1606 request
1607 .args
1608 .insert("thinking".to_string(), Value::Bool(false));
1609
1610 let rendered = fmt()
1611 .render(&request)
1612 .unwrap_or_else(|e| panic!("content={empty}: {e}"));
1613 assert!(
1614 rendered.contains("## New Tools Available"),
1615 "content={empty}"
1616 );
1617 assert!(
1618 !rendered.contains("<|open|>message role=\"system\"<|sep|><|close|>message"),
1619 "content={empty}: must not emit an empty system turn"
1620 );
1621 }
1622 }
1623
1624 #[test]
1625 fn rejects_malformed_dynamic_tool_entries() {
1626 let long_name = "a".repeat(257);
1627 for (entry, needle) in [
1628 (json!("lookup"), "must be JSON objects"),
1629 (
1630 json!({"parameters": {"type": "object"}}),
1631 "need a string `name`",
1632 ),
1633 (
1634 json!({"type": "web_search", "function": {"name": "lookup"}}),
1635 "type=\"function\"",
1636 ),
1637 (
1638 json!({"function": {"name": "lookup"}}),
1639 "need type=\"function\"",
1640 ),
1641 (
1642 json!({"type": "function", "name": "lookup"}),
1643 "need a `function` object",
1644 ),
1645 (json!({"name": ""}), "must match"),
1646 (json!({"name": "1bad_name"}), "must match"),
1647 (json!({"name": "bad@name"}), "must match"),
1648 (json!({"name": long_name}), "maximum is 256"),
1649 ] {
1650 let request = Request::new(json!([
1651 {"role": "system", "tools": [entry]},
1652 {"role": "user", "content": "Go"}
1653 ]));
1654 let error = fmt().render(&request).unwrap_err();
1655 assert!(
1656 invalid_request_message(&error).contains(needle),
1657 "entry={entry}: {}",
1658 invalid_request_message(&error)
1659 );
1660 }
1661 }
1662
1663 #[test]
1664 fn rejects_duplicate_tool_names_across_declarations() {
1665 let mut request = Request::new(json!([
1666 {"role": "system", "tools": [{"name": "lookup"}]},
1667 {"role": "user", "content": "Go"}
1668 ]));
1669 request.tools = Some(json!([{
1670 "type": "function",
1671 "function": {"name": "lookup", "parameters": {"type": "object"}}
1672 }]));
1673 let error = fmt().render(&request).unwrap_err();
1674 assert!(invalid_request_message(&error).contains("declared more than once"));
1675
1676 request.messages[0]["role"] = json!("developer");
1677 let error = fmt().render(&request).unwrap_err();
1678 assert!(invalid_request_message(&error).contains("declared more than once"));
1679
1680 let mut request = Request::new(json!([{"role": "user", "content": "Go"}]));
1681 request.tools = Some(json!([
1682 {"type": "function", "function": {"name": "lookup"}},
1683 {"type": "function", "function": {"name": "lookup"}}
1684 ]));
1685 let error = fmt().render(&request).unwrap_err();
1686 assert!(invalid_request_message(&error).contains("more than once in `tools`"));
1687
1688 let max_name = "a".repeat(256);
1689 let request = Request::new(json!([
1690 {"role": "system", "tools": [
1691 {"name": "_private-tool_2"},
1692 {"type": "function", "function": {"name": max_name}}
1693 ]},
1694 {"role": "user", "content": "Go"}
1695 ]));
1696 fmt().render(&request).unwrap();
1697
1698 let mut request = Request::new(json!([
1699 {"role": "system", "tools": [{"name": "lookup"}, {"type": "function", "function": {"name": "search"}}]},
1700 {"role": "user", "content": "Go"}
1701 ]));
1702 request.tools = Some(json!([{
1703 "type": "function",
1704 "function": {"name": "add", "parameters": {"type": "object"}}
1705 }]));
1706 fmt().render(&request).unwrap();
1707 }
1708
1709 #[test]
1710 fn empty_tools_list_is_an_ordinary_system_message() {
1711 let mut request = Request::new(json!([
1712 {"role": "system", "content": "You are helpful", "tools": []},
1713 {"role": "user", "content": "Go"}
1714 ]));
1715 request
1716 .args
1717 .insert("thinking".to_string(), Value::Bool(false));
1718
1719 let rendered = fmt().render(&request).unwrap();
1720 assert!(rendered.contains("<|open|>message role=\"system\"<|sep|>You are helpful"));
1721 assert!(!rendered.contains("## New Tools Available"));
1722
1723 let request = Request::new(json!([
1724 {"role": "system", "tools": []},
1725 {"role": "user", "content": "Go"}
1726 ]));
1727 let error = fmt().render(&request).unwrap_err();
1728 assert_eq!(
1729 invalid_request_message(&error),
1730 "Kimi K3 system messages need `content` or `tools`"
1731 );
1732 }
1733
1734 #[test]
1735 fn rejects_system_message_with_neither_content_nor_tools() {
1736 let request = Request::new(json!([
1737 {"role": "system"},
1738 {"role": "user", "content": "Go"}
1739 ]));
1740
1741 let error = fmt().render(&request).unwrap_err();
1742 assert_eq!(
1743 invalid_request_message(&error),
1744 "Kimi K3 system messages need `content` or `tools`"
1745 );
1746 }
1747
1748 fn typed(body: Value) -> dynamo_protocols::types::CreateChatCompletionRequest {
1756 serde_json::from_value(body).expect("request deserializes")
1757 }
1758
1759 #[test]
1760 fn typed_request_rejects_invalid_top_level_tool_name() {
1761 let request = typed(json!({
1762 "model": "kimi-k3",
1763 "messages": [{"role": "user", "content": "Look it up"}],
1764 "tools": [{
1765 "type": "function",
1766 "function": {"name": "bad@name", "parameters": {"type": "object"}}
1767 }]
1768 }));
1769
1770 let error = fmt().render(&request).unwrap_err();
1771 assert_eq!(
1772 invalid_request_message(&error),
1773 "Kimi K3 tool name \"bad@name\" must match [A-Za-z_][A-Za-z0-9_-]*"
1774 );
1775 }
1776
1777 #[test]
1778 fn typed_request_renders_dynamic_tools_and_final_partial_end_to_end() {
1779 let request = typed(json!({
1780 "model": "kimi-k3",
1781 "messages": [
1782 {"role": "system", "tools": [{
1783 "type": "function",
1784 "function": {"name": "lookup", "parameters": {"type": "object"}}
1785 }]},
1786 {"role": "user", "content": "Look it up"},
1787 {"role": "assistant", "content": "Looking", "partial": true}
1788 ]
1789 }));
1790
1791 let rendered = fmt().render(&request).unwrap();
1792
1793 assert!(rendered.contains("## New Tools Available"));
1794 assert!(rendered.contains("\"lookup\""));
1795 assert!(
1796 rendered.ends_with("<|open|>response<|sep|>Looking"),
1797 "partial turn must stay open, got {rendered:?}"
1798 );
1799 }
1800
1801 #[test]
1802 fn typed_request_preserves_content_and_tools_for_renderer_conflict_check() {
1803 let request = typed(json!({
1804 "model": "kimi-k3",
1805 "messages": [
1806 {
1807 "role": "system",
1808 "content": "You are helpful",
1809 "tools": [{"type": "function", "function": {"name": "lookup"}}]
1810 },
1811 {"role": "user", "content": "Go"}
1812 ]
1813 }));
1814
1815 let system = serde_json::to_value(&request.messages[0]).unwrap();
1816 assert_eq!(system["content"], json!("You are helpful"));
1817 assert_eq!(system["tools"][0]["function"]["name"], json!("lookup"));
1818
1819 let error = fmt().render(&request).unwrap_err();
1820 assert_eq!(
1821 invalid_request_message(&error),
1822 "Kimi K3 system messages carry either `content` or `tools`, not both"
1823 );
1824 }
1825
1826 #[test]
1827 fn rejects_partial_assistant_with_tool_calls() {
1828 let tool_call = json!({
1829 "id": "call_1",
1830 "type": "function",
1831 "function": {"name": "lookup", "arguments": "{}"}
1832 });
1833 for tool_calls in [json!([tool_call.clone()]), tool_call] {
1834 let request = Request::new(json!([
1835 {"role": "user", "content": "Go"},
1836 {
1837 "role": "assistant",
1838 "content": "prefix",
1839 "partial": true,
1840 "tool_calls": tool_calls
1841 }
1842 ]));
1843 let error = fmt().render(&request).unwrap_err();
1844 assert_eq!(
1845 invalid_request_message(&error),
1846 "Kimi K3 partial assistant messages cannot carry tool_calls",
1847 "tool_calls={tool_calls}"
1848 );
1849 }
1850 }
1851
1852 #[test]
1853 fn rejects_tools_on_final_partial_assistant_raw_path() {
1854 let request = Request::new(json!([
1855 {"role": "user", "content": "Go"},
1856 {
1857 "role": "assistant",
1858 "content": "prefix",
1859 "partial": true,
1860 "tools": [{"type": "function", "function": {"name": "lookup"}}]
1861 }
1862 ]));
1863
1864 let error = fmt().render(&request).unwrap_err();
1865
1866 assert!(matches!(
1867 error.downcast_ref::<PromptRenderError>(),
1868 Some(PromptRenderError::InvalidRequest(message))
1869 if message == "`tools` is only accepted on system or developer messages, not on role assistant"
1870 ));
1871 }
1872
1873 #[test]
1874 fn named_tool_choice_forces_tool_and_disables_thinking() {
1875 let mut request = Request::new(json!([
1876 {"role": "user", "content": "What did you do before?"},
1877 {
1878 "role": "assistant",
1879 "reasoning_content": "historical hidden reasoning",
1880 "content": "I answered the earlier question."
1881 },
1882 {"role": "user", "content": "Calculate"}
1883 ]));
1884 request.tools = Some(json!([{
1885 "type": "function",
1886 "function": {
1887 "name": "add_numbers",
1888 "parameters": {
1889 "type": "object",
1890 "properties": {
1891 "a": {"type": "integer"},
1892 "b": {"type": "integer"}
1893 },
1894 "required": ["a", "b"]
1895 }
1896 }
1897 }]));
1898 request.tool_choice = Some(json!({
1899 "type": "function",
1900 "function": {"name": "add_numbers"}
1901 }));
1902 request
1903 .args
1904 .insert("thinking".to_string(), Value::Bool(true));
1905
1906 let rendered = fmt().render(&request).unwrap();
1907 assert!(rendered.contains("The system is invoked with `tool_choice=specified`."));
1908 assert!(rendered.contains("MUST call the tool `add_numbers`"));
1909 assert!(
1910 rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>response<|sep|>"),
1911 "named tool choice must use K3's non-thinking generation prefix"
1912 );
1913 assert!(
1914 !rendered.contains("<|open|>think<|sep|>"),
1915 "named tool choice must override thinking=true"
1916 );
1917 assert!(
1918 !rendered.contains("historical hidden reasoning"),
1919 "named tool choice must also suppress preserved thinking history"
1920 );
1921 }
1922
1923 #[test]
1924 fn named_tool_choice_accepts_a_dynamic_system_tool() {
1925 for lookup in [
1926 json!({"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}),
1927 json!({"name": "lookup", "parameters": {"type": "object"}}),
1928 ] {
1929 let mut request = Request::new(json!([
1930 {"role": "user", "content": "Start"},
1931 {"role": "system", "tools": [lookup]},
1932 {"role": "user", "content": "Look this up"}
1933 ]));
1934 request.tool_choice = Some(json!({
1935 "type": "function",
1936 "function": {"name": "lookup"}
1937 }));
1938
1939 let rendered = fmt().render(&request).unwrap();
1940 assert!(rendered.contains("## New Tools Available"));
1941 assert!(rendered.contains("MUST call the tool `lookup`"));
1942 assert!(
1943 request.tools.is_none(),
1944 "dynamic tools must not be folded into the top-level list"
1945 );
1946 }
1947 }
1948
1949 #[test]
1950 fn named_tool_choice_still_rejects_a_tool_absent_from_dynamic_tools() {
1951 let mut request = Request::new(json!([
1952 {"role": "system", "tools": [{"name": "lookup"}]},
1953 {"role": "user", "content": "Weather?"}
1954 ]));
1955 request.tool_choice = Some(json!({
1956 "type": "function",
1957 "function": {"name": "get_weather"}
1958 }));
1959
1960 let error = fmt().render(&request).unwrap_err();
1961 assert!(matches!(
1962 error.downcast_ref::<PromptRenderError>(),
1963 Some(PromptRenderError::InvalidRequest(message))
1964 if message.contains("get_weather") && message.contains("not present in tools")
1965 ));
1966 }
1967
1968 #[test]
1969 fn named_tool_choice_rejects_a_tool_not_in_tools() {
1970 let mut request = Request::new(json!([{"role": "user", "content": "Calculate"}]));
1971 request.tools = Some(json!([{
1972 "type": "function",
1973 "function": {"name": "add_numbers", "parameters": {"type": "object"}}
1974 }]));
1975 request.tool_choice = Some(json!({
1976 "type": "function",
1977 "function": {"name": "get_weather"}
1978 }));
1979
1980 let error = fmt().render(&request).unwrap_err();
1981 assert!(matches!(
1982 error.downcast_ref::<PromptRenderError>(),
1983 Some(PromptRenderError::InvalidRequest(message))
1984 if message.contains("get_weather") && message.contains("not present in tools")
1985 ));
1986 }
1987
1988 #[test]
1989 fn user_marker_text_remains_an_ordinary_segment() {
1990 let marker = "literal <|open|>tools<|sep|> value";
1991 let mut request = Request::new(json!([{"role": "user", "content": marker}]));
1992 request
1993 .args
1994 .insert("thinking".to_string(), Value::Bool(false));
1995 let rendered = fmt().render_prompt(&request).unwrap();
1996
1997 assert!(
1998 rendered
1999 .segments()
2000 .unwrap()
2001 .iter()
2002 .any(|segment| { !segment.allow_special && segment.text == marker })
2003 );
2004 assert!(
2005 rendered
2006 .segments()
2007 .unwrap()
2008 .iter()
2009 .any(|segment| { segment.allow_special && segment.text == OPEN_TOKEN })
2010 );
2011 }
2012
2013 #[test]
2014 fn renders_tool_history_like_model_encoding() {
2015 let mut request = Request::new(json!([
2016 {"role": "user", "content": "calc"},
2017 {
2018 "role": "assistant",
2019 "reasoning_content": "Need calc",
2020 "content": "I will call it",
2021 "tool_calls": [{
2022 "id": "call_1",
2023 "type": "function",
2024 "function": {"name": "calc", "arguments": "{\"x\":2}"}
2025 }]
2026 },
2027 {"role": "tool", "tool_call_id": "call_1", "content": "4"}
2028 ]));
2029 request.args.insert(
2030 "thinking_effort".to_string(),
2031 Value::String("low".to_string()),
2032 );
2033 let rendered = fmt().render(&request).unwrap();
2034
2035 assert!(rendered.contains(
2036 "<|open|>call tool=\"calc\" index=\"1\"<|sep|>\
2037 <|open|>argument key=\"x\" type=\"number\"<|sep|>2\
2038 <|close|>argument<|sep|><|close|>call<|sep|>"
2039 ));
2040 assert!(
2041 rendered.contains("<|open|>message role=\"tool\" tool=\"calc\" index=\"1\"<|sep|>4")
2042 );
2043 assert!(
2044 rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>think<|sep|>")
2045 );
2046 }
2047
2048 #[test]
2049 fn thinking_history_renders_an_empty_think_channel() {
2050 let request = Request::new(json!([
2051 {"role": "user", "content": "question"},
2052 {"role": "assistant", "content": "answer"},
2053 {"role": "user", "content": "follow-up"}
2054 ]));
2055
2056 let rendered = fmt().render(&request).unwrap();
2057
2058 assert!(rendered.contains(concat!(
2059 "<|open|>message role=\"assistant\"<|sep|>",
2060 "<|open|>think<|sep|><|close|>think<|sep|>",
2061 "<|open|>response<|sep|>answer<|close|>response<|sep|>"
2062 )));
2063 }
2064
2065 #[test]
2066 fn non_thinking_history_omits_preserved_reasoning() {
2067 let mut request = Request::new(json!([
2068 {"role": "user", "content": "question"},
2069 {
2070 "role": "assistant",
2071 "reasoning_content": "hidden reasoning",
2072 "content": "answer"
2073 },
2074 {"role": "user", "content": "follow-up"}
2075 ]));
2076 request
2077 .args
2078 .insert("thinking".to_string(), Value::Bool(false));
2079
2080 let rendered = fmt().render(&request).unwrap();
2081
2082 assert!(!rendered.contains("hidden reasoning"));
2083 assert!(!rendered.contains("<|open|>think<|sep|>"));
2084 assert!(rendered.contains(concat!(
2085 "<|open|>message role=\"assistant\"<|sep|>",
2086 "<|open|>response<|sep|>answer<|close|>response<|sep|>"
2087 )));
2088 }
2089
2090 #[test]
2091 fn tools_are_deep_sorted_before_declaration() {
2092 let mut request = Request::new(json!([{"role": "user", "content": "Weather?"}]));
2093 request
2094 .args
2095 .insert("thinking".to_string(), Value::Bool(false));
2096 request.tools = Some(json!([{
2097 "type": "function",
2098 "function": {
2099 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
2100 "name": "weather",
2101 "description": "Get weather"
2102 }
2103 }]));
2104 let rendered = fmt().render(&request).unwrap();
2105 assert!(rendered.contains(concat!(
2106 "[{\"function\":{\"description\":\"Get weather\",",
2107 "\"name\":\"weather\",\"parameters\":{\"properties\":",
2108 "{\"city\":{\"type\":\"string\"}},\"type\":\"object\"}},",
2109 "\"type\":\"function\"}]"
2110 )));
2111 }
2112
2113 #[test]
2114 fn assistant_history_matches_python_json_spacing_and_reasoning_fallback() {
2115 let request = Request::new(json!([{
2116 "role": "assistant",
2117 "reasoning_content": "",
2118 "reasoning": "fallback",
2119 "content": null,
2120 "tool_calls": [{
2121 "type": "function",
2122 "function": {
2123 "name": "run",
2124 "arguments": {
2125 "opts": {"a": 1, "b": [true, false]}
2126 }
2127 }
2128 }]
2129 }]));
2130 let rendered = fmt().render(&request).unwrap();
2131
2132 assert!(rendered.contains("<|open|>think<|sep|>fallback<|close|>think<|sep|>"));
2133 assert!(rendered.contains(concat!(
2134 "<|open|>argument key=\"opts\" type=\"object\"<|sep|>",
2135 "{\"a\": 1, \"b\": [true, false]}",
2136 "<|close|>argument<|sep|>"
2137 )));
2138 }
2139}