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 = crate::messages_to_json(req).context("Failed to convert K3 messages")?;
60 let Value::Array(messages) = messages else {
61 anyhow::bail!("Kimi K3 messages must be an array");
62 };
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: Vec<Value>) -> Result<Vec<Value>> {
754 let mut output = Vec::with_capacity(messages.len());
755 let mut current_index = HashMap::new();
756 let mut messages = messages.into_iter().peekable();
757
758 while let Some(message) = messages.peek() {
759 let role = message.get("role").and_then(Value::as_str);
760 if role == Some("assistant") {
761 current_index = tool_call_index(message.get("tool_calls"));
762 output.push(messages.next().expect("peeked message"));
763 continue;
764 }
765 if role != Some("tool") {
766 output.push(messages.next().expect("peeked message"));
767 continue;
768 }
769
770 let mut run: Vec<(Option<usize>, usize, Value, Option<String>)> = Vec::new();
771 let mut unresolved = false;
772 let mut offset = 0;
773 while messages
774 .peek()
775 .is_some_and(|message| message.get("role").and_then(Value::as_str) == Some("tool"))
776 {
777 let tool_message = messages.next().expect("peeked tool message");
778 let call_id = tool_message
779 .get("tool_call_id")
780 .or_else(|| tool_message.get("id"))
781 .and_then(Value::as_str);
782 let matched = call_id.and_then(|id| current_index.get(id));
783 if let Some((tool_position, name)) = matched {
784 run.push((Some(*tool_position), offset, tool_message, name.clone()));
785 } else {
786 unresolved = true;
787 run.push((None, offset, tool_message, None));
788 }
789 offset += 1;
790 }
791
792 if unresolved {
793 output.extend(run.into_iter().map(|(_, _, message, _)| message));
794 continue;
795 }
796 run.sort_by_key(|(tool_position, offset, _, _)| (*tool_position, *offset));
797 for (_, _, mut message, name) in run {
798 if let (Some(name), Some(message)) = (name, message.as_object_mut()) {
799 message.insert("tool".to_string(), Value::String(name.clone()));
800 if message.contains_key("name") {
801 message.insert("name".to_string(), Value::String(name));
802 }
803 }
804 output.push(message);
805 }
806 }
807 Ok(output)
808}
809
810#[allow(clippy::too_many_arguments)]
811fn build_chat_segments(
812 messages: &[Value],
813 tools: Option<&Value>,
814 tool_choice: Option<&str>,
815 named_tool: Option<&str>,
816 response_format: Option<&Value>,
817 add_generation_prompt: bool,
818 thinking: bool,
819 thinking_effort: &str,
820) -> Result<Vec<RenderedSegment>> {
821 let mut segments = Vec::new();
822 let mut previous_tool_calls: Option<&Value> = None;
823 let mut tool_index = 0usize;
824
825 for message in messages {
826 let Some(partial) = message.get("partial").filter(|value| !value.is_null()) else {
827 continue;
828 };
829 if message.get("role").and_then(Value::as_str) != Some("assistant") {
830 return Err(PromptRenderError::invalid_request(
831 "Kimi K3 `partial` is only supported on an assistant message",
832 )
833 .into());
834 }
835 if !partial.is_boolean() {
836 return Err(
837 PromptRenderError::invalid_request("Kimi K3 `partial` must be a boolean").into(),
838 );
839 }
840 }
841
842 let (history, partial_tail) = match messages.split_last() {
847 Some((last, history)) if is_partial(last) => (history, Some(last)),
848 _ => (messages, None),
849 };
850
851 validate_tool_declarations(tools, messages)?;
855 if history.iter().any(is_partial) {
856 return Err(PromptRenderError::invalid_request(
857 "Kimi K3 `partial` is only supported on the final message",
858 )
859 .into());
860 }
861
862 if let Some(tools) = tools.filter(|tools| !tools.as_array().is_some_and(Vec::is_empty)) {
863 render_tool_declare(&mut segments, tools, false)?;
864 }
865
866 if thinking {
867 internal_system_message(
868 &mut segments,
869 "thinking-effort",
870 &format!(
871 "`thinking_effort` guides on how much to think in your thinking channel \
872 (not including the response channel), supported values include `low`, \
873 `medium`, `high`, and `max`.\nNow the system is invoked with \
874 `thinking_effort={thinking_effort}`."
875 ),
876 );
877 }
878
879 for message in history {
880 let role = message.get("role").and_then(Value::as_str).ok_or_else(|| {
881 PromptRenderError::invalid_request("Kimi K3 messages must contain a string role")
882 })?;
883 let dynamic_tools = dynamic_tools_of(message)?;
885 match role {
886 "system" | "developer" if dynamic_tools.is_some() => {
887 let dynamic_tools = dynamic_tools.expect("guarded by the match arm");
888 if role == "system" && content_is_non_empty(message.get("content")) {
893 return Err(PromptRenderError::invalid_request(
894 "Kimi K3 system messages carry either `content` or `tools`, not both",
895 )
896 .into());
897 }
898 let dynamic_tools = deep_sort(Value::Array(dynamic_tools.clone()));
899 render_tool_declare(&mut segments, &dynamic_tools, true)?;
900 if role == "developer"
901 && message
902 .get("content")
903 .is_some_and(|content| !content.is_null())
904 {
905 render_role_message(&mut segments, message, "system")?;
906 }
907 }
908 "system" | "developer"
909 if message
910 .get("content")
911 .is_none_or(|content| content.is_null()) =>
912 {
913 return Err(PromptRenderError::invalid_request(format!(
914 "Kimi K3 {role} messages need `content` or `tools`"
915 ))
916 .into());
917 }
918 "user" | "system" | "developer" => {
919 let rendered_role = if role == "developer" { "system" } else { role };
920 render_role_message(&mut segments, message, rendered_role)?;
921 }
922 "assistant" => {
923 previous_tool_calls = message.get("tool_calls");
924 tool_index = 0;
925 open_tag(&mut segments, "message", assistant_message_attrs(message));
926 render_assistant_segments(&mut segments, message, thinking)?;
927 close_tag(&mut segments, "message");
928 end_of_msg(&mut segments);
929 }
930 "tool" => {
931 tool_index += 1;
932 let fallback_name = previous_tool_calls
933 .and_then(Value::as_array)
934 .and_then(|calls| calls.get(tool_index - 1))
935 .map(|call| call.get("function").unwrap_or(call))
936 .and_then(|function| function.get("name"))
937 .and_then(Value::as_str);
938 let tool_name = message
939 .get("tool")
940 .or_else(|| message.get("name"))
941 .and_then(Value::as_str)
942 .or(fallback_name)
943 .context(
944 "Kimi K3 tool messages need a tool/name or a preceding assistant tool call",
945 )?;
946 open_tag(
947 &mut segments,
948 "message",
949 [
950 ("role".to_string(), "tool".to_string()),
951 ("tool".to_string(), tool_name.to_string()),
952 ("index".to_string(), tool_index.to_string()),
953 ],
954 );
955 render_content_segments(&mut segments, message.get("content"))?;
956 close_tag(&mut segments, "message");
957 end_of_msg(&mut segments);
958 }
959 unsupported => {
960 return Err(PromptRenderError::invalid_request(format!(
961 "Kimi K3 does not support message role {unsupported:?}"
962 ))
963 .into());
964 }
965 }
966 }
967
968 match tool_choice {
969 Some("required") => internal_system_message(
970 &mut segments,
971 "tool-choice",
972 "The system is invoked with `tool_choice=required`.\n\
973 You MUST call tools in the next message.",
974 ),
975 Some("none") => internal_system_message(
976 &mut segments,
977 "tool-choice",
978 "The system is invoked with `tool_choice=none`.\n\
979 You MUST NOT call any tools in the next message.",
980 ),
981 Some("specified") => internal_system_message(
982 &mut segments,
983 "tool-choice",
984 &format!(
985 "The system is invoked with `tool_choice=specified`.\n\
986 You MUST call the tool `{}` in the next message.",
987 named_tool.expect("specified tool_choice has a function name")
988 ),
989 ),
990 _ => {}
991 }
992
993 if let Some(response_format) = response_format {
994 match response_format.get("type").and_then(Value::as_str) {
995 Some("json_object") => internal_system_message(
996 &mut segments,
997 "response-format",
998 "The system is invoked with `response_format=json_object`.\n\
999 Your response must be raw JSON data without markdown code blocks \
1000 (```json) or any additional formatting.",
1001 ),
1002 Some("json_schema") => {
1003 let schema = response_schema(response_format)
1004 .map(deep_sort)
1005 .unwrap_or(Value::Null);
1006 internal_system_message(
1007 &mut segments,
1008 "response-format",
1009 &format!(
1010 "The system is invoked with `response_format=json_schema`.\n\
1011 Your response must be raw JSON data without markdown code blocks \
1012 (```json) or any additional formatting.\n\
1013 The JSON data must match the following schema:\n\
1014 ```json\n{}\n```",
1015 compact_json(&schema)?
1016 ),
1017 );
1018 }
1019 _ => {}
1020 }
1021 }
1022
1023 if let Some(partial) = partial_tail {
1027 render_partial_assistant_segments(&mut segments, partial, thinking)?;
1028 } else if add_generation_prompt {
1029 open_tag(
1030 &mut segments,
1031 "message",
1032 [("role".to_string(), "assistant".to_string())],
1033 );
1034 open_tag(
1035 &mut segments,
1036 if thinking { "think" } else { "response" },
1037 [],
1038 );
1039 }
1040
1041 Ok(segments)
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047 use minijinja::value::Value as MiniValue;
1048 use serde_json::json;
1049
1050 struct Request {
1051 messages: Value,
1052 tools: Option<Value>,
1053 tool_choice: Option<Value>,
1054 response_format: Option<Value>,
1055 args: HashMap<String, Value>,
1056 add_generation_prompt: bool,
1057 }
1058
1059 impl Request {
1060 fn new(messages: Value) -> Self {
1061 Self {
1062 messages,
1063 tools: None,
1064 tool_choice: None,
1065 response_format: None,
1066 args: HashMap::new(),
1067 add_generation_prompt: true,
1068 }
1069 }
1070 }
1071
1072 impl OAIChatLikeRequest for Request {
1073 fn model(&self) -> String {
1074 "kimi-k3".to_string()
1075 }
1076
1077 fn messages(&self) -> MiniValue {
1078 MiniValue::from_serialize(&self.messages)
1079 }
1080
1081 fn tools(&self) -> Option<MiniValue> {
1082 self.tools.as_ref().map(MiniValue::from_serialize)
1083 }
1084
1085 fn tool_choice(&self) -> Option<MiniValue> {
1086 self.tool_choice.as_ref().map(MiniValue::from_serialize)
1087 }
1088
1089 fn response_format(&self) -> Option<MiniValue> {
1090 self.response_format.as_ref().map(MiniValue::from_serialize)
1091 }
1092
1093 fn should_add_generation_prompt(&self) -> bool {
1094 self.add_generation_prompt
1095 }
1096
1097 fn chat_template_args(&self) -> Option<&HashMap<String, Value>> {
1098 Some(&self.args)
1099 }
1100 }
1101
1102 fn fmt() -> KimiK3Formatter {
1104 KimiK3Formatter::new(true)
1105 }
1106
1107 fn image_request() -> Request {
1109 let mut request = Request::new(json!([{
1110 "role": "user",
1111 "content": [{"type": "image_url", "image_url": {"url": "http://example.com/a.png"}}]
1112 }]));
1113 request
1114 .args
1115 .insert("thinking".to_string(), Value::Bool(false));
1116 request
1117 }
1118
1119 fn image_segments(formatter: &KimiK3Formatter, request: &Request) -> Vec<RenderedSegment> {
1120 formatter
1121 .render_prompt(request)
1122 .unwrap()
1123 .segments()
1124 .expect("K3 always renders segmented prompts")
1125 .to_vec()
1126 }
1127
1128 #[test]
1129 fn renders_one_media_pad_per_image() {
1130 let segments = image_segments(&fmt(), &image_request());
1131
1132 let matches: Vec<_> = segments
1133 .iter()
1134 .filter(|segment| segment.text == MEDIA_PAD)
1135 .collect();
1136 assert_eq!(matches.len(), 1, "exactly one pad per image");
1137 assert!(matches[0].allow_special);
1140 assert!(
1143 !segments
1144 .iter()
1145 .any(|segment| segment.text.contains("kimi_image_placeholder")),
1146 );
1147 }
1148
1149 #[test]
1150 fn image_token_cardinality_is_one_per_image() {
1151 let mut request = Request::new(json!([{
1152 "role": "user",
1153 "content": [
1154 {"type": "image_url", "image_url": {"url": "http://example.com/a.png"}},
1155 {"type": "text", "text": "and"},
1156 {"type": "image_url", "image_url": {"url": "http://example.com/b.png"}},
1157 {"type": "text", "text": "compare them"}
1158 ]
1159 }]));
1160 request
1161 .args
1162 .insert("thinking".to_string(), Value::Bool(false));
1163
1164 let segments = image_segments(&fmt(), &request);
1165
1166 assert_eq!(
1167 segments
1168 .iter()
1169 .filter(|segment| segment.text == MEDIA_PAD)
1170 .count(),
1171 2
1172 );
1173 for body in ["and", "compare them"] {
1175 assert!(
1176 segments
1177 .iter()
1178 .any(|segment| segment.text == body && !segment.allow_special)
1179 );
1180 }
1181 }
1182
1183 #[test]
1184 fn user_text_spelling_the_pad_stays_ordinary() {
1185 let body = "please describe <|media_pad|>";
1186 let mut request = Request::new(json!([{"role": "user", "content": body}]));
1187 request
1188 .args
1189 .insert("thinking".to_string(), Value::Bool(false));
1190
1191 let segments = image_segments(&fmt(), &request);
1192
1193 assert!(
1194 segments
1195 .iter()
1196 .any(|segment| segment.text == body && !segment.allow_special),
1197 "user content must never be promoted into prompt structure"
1198 );
1199 }
1200
1201 #[test]
1202 fn renders_off_mode_like_model_encoding() {
1203 let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
1204 request
1205 .args
1206 .insert("thinking".to_string(), Value::Bool(false));
1207 let rendered = fmt().render(&request).unwrap();
1208 assert_eq!(
1209 rendered,
1210 concat!(
1211 "<|open|>message role=\"user\"<|sep|>Hello",
1212 "<|close|>message<|sep|><|end_of_msg|>",
1213 "<|open|>message role=\"assistant\"<|sep|>",
1214 "<|open|>response<|sep|>"
1215 )
1216 );
1217 }
1218
1219 #[test]
1220 fn renders_developer_messages_as_system() {
1221 let mut request = Request::new(json!([
1222 {"role": "developer", "content": "Follow this policy", "name": "policy"},
1223 {"role": "user", "content": "Hello"}
1224 ]));
1225 request
1226 .args
1227 .insert("thinking".to_string(), Value::Bool(false));
1228
1229 let rendered = fmt().render(&request).unwrap();
1230
1231 assert!(
1232 rendered.contains(
1233 "<|open|>message role=\"system\" name=\"policy\"<|sep|>Follow this policy"
1234 )
1235 );
1236 assert!(!rendered.contains("role=\"developer\""));
1237 assert!(
1238 rendered.find("Follow this policy").unwrap() < rendered.find("Hello").unwrap(),
1239 "developer instructions must retain their position"
1240 );
1241 }
1242
1243 #[test]
1244 fn renders_developer_tools_and_content_in_place_with_named_tool_choice() {
1245 let mut request = Request::new(json!([
1246 {"role": "user", "content": "Start"},
1247 {
1248 "role": "developer",
1249 "name": "policy",
1250 "content": "Use the lookup tool",
1251 "tools": [{"type": "function", "function": {"name": "lookup"}}]
1252 },
1253 {"role": "user", "content": "Look this up"}
1254 ]));
1255 request.tool_choice = Some(json!({
1256 "type": "function",
1257 "function": {"name": "lookup"}
1258 }));
1259 let rendered = fmt().render(&request).unwrap();
1260 let developer_turn = concat!(
1261 "<|open|>message role=\"system\" name=\"policy\"<|sep|>Use the lookup tool",
1262 "<|close|>message<|sep|><|end_of_msg|>"
1263 );
1264 let declaration = rendered.find("## New Tools Available").unwrap();
1265 let content = rendered.find(developer_turn).unwrap();
1266 assert!(rendered.find("Start").unwrap() < declaration);
1267 assert!(declaration < content);
1268 assert!(content < rendered.find("Look this up").unwrap());
1269 assert!(rendered.contains("\"name\":\"lookup\""));
1270 assert!(rendered.contains("MUST call the tool `lookup`"));
1271
1272 request.messages[1]
1273 .as_object_mut()
1274 .unwrap()
1275 .remove("content");
1276 assert_eq!(
1277 fmt().render(&request).unwrap(),
1278 rendered.replace(developer_turn, "")
1279 );
1280 }
1281
1282 #[test]
1283 fn rejects_tools_on_unsupported_message_roles() {
1284 let tools = json!([{"type": "function", "function": {"name": "lookup"}}]);
1285 for (role, extra) in [
1286 ("user", json!({"content": "Look this up"})),
1287 ("assistant", json!({"content": "ok"})),
1288 ] {
1289 let mut message = extra;
1290 message["role"] = json!(role);
1291 message["tools"] = tools.clone();
1292 let request = Request::new(json!([message, {"role": "user", "content": "Go"}]));
1293
1294 let error = fmt().render(&request).unwrap_err();
1295 assert_eq!(
1296 invalid_request_message(&error),
1297 format!(
1298 "`tools` is only accepted on system or developer messages, not on role {role}"
1299 ),
1300 "role={role}"
1301 );
1302 }
1303 }
1304
1305 #[test]
1306 fn rejects_unsupported_message_roles() {
1307 for role in ["function", "unknown"] {
1308 let request = Request::new(json!([{"role": role, "content": "ignored before"}]));
1309
1310 let error = fmt().render(&request).unwrap_err();
1311
1312 assert!(matches!(
1313 error.downcast_ref::<PromptRenderError>(),
1314 Some(PromptRenderError::InvalidRequest(message))
1315 if message == &format!("Kimi K3 does not support message role {role:?}")
1316 ));
1317 }
1318 }
1319
1320 #[test]
1321 fn rejects_messages_without_a_string_role() {
1322 for messages in [json!([{"content": "missing"}]), json!([{"role": 7}])] {
1323 let request = Request::new(messages);
1324
1325 let error = fmt().render(&request).unwrap_err();
1326
1327 assert!(matches!(
1328 error.downcast_ref::<PromptRenderError>(),
1329 Some(PromptRenderError::InvalidRequest(message))
1330 if message == "Kimi K3 messages must contain a string role"
1331 ));
1332 }
1333 }
1334
1335 #[test]
1336 fn rejects_unsupported_thinking_effort_as_invalid_request() {
1337 let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
1338 request.args.insert(
1339 "thinking_effort".to_string(),
1340 Value::String("medium".to_string()),
1341 );
1342
1343 let error = fmt().render(&request).unwrap_err();
1344 assert!(matches!(
1345 error.downcast_ref::<PromptRenderError>(),
1346 Some(PromptRenderError::InvalidRequest(message))
1347 if message.contains("thinking_effort=\"medium\"")
1348 ));
1349 }
1350
1351 #[test]
1354 fn partial_assistant_renders_open_turn_in_place_of_generation_prompt() {
1355 let mut request = Request::new(json!([
1356 {"role": "user", "content": "Greet the customer"},
1357 {"role": "assistant", "content": "Dear customer, hello", "partial": true}
1358 ]));
1359 request
1360 .args
1361 .insert("thinking".to_string(), Value::Bool(false));
1362
1363 let rendered = fmt().render(&request).unwrap();
1364
1365 assert_eq!(
1366 rendered,
1367 concat!(
1368 "<|open|>message role=\"user\"<|sep|>Greet the customer",
1369 "<|close|>message<|sep|><|end_of_msg|>",
1370 "<|open|>message role=\"assistant\"<|sep|>",
1371 "<|open|>response<|sep|>Dear customer, hello"
1372 ),
1373 "the partial turn must stay open: no <|close|>response / <|close|>message / <|end_of_msg|>, \
1374 and no extra generation prompt after it"
1375 );
1376 for tool_calls in [Value::Null, json!([])] {
1377 request.messages[1]["tool_calls"] = tool_calls;
1378 assert_eq!(fmt().render(&request).unwrap(), rendered);
1379 }
1380 }
1381
1382 #[test]
1383 fn partial_assistant_ignores_add_generation_prompt_flag() {
1384 let mut request = Request::new(json!([
1385 {"role": "user", "content": "Go"},
1386 {"role": "assistant", "content": "prefix", "partial": true}
1387 ]));
1388 request
1389 .args
1390 .insert("thinking".to_string(), Value::Bool(false));
1391 request.add_generation_prompt = false;
1392
1393 let rendered = fmt().render(&request).unwrap();
1394
1395 assert!(rendered.ends_with("<|open|>response<|sep|>prefix"));
1396 assert_eq!(rendered.matches("role=\"assistant\"").count(), 1);
1397 }
1398
1399 #[test]
1400 fn partial_assistant_in_thinking_mode_closes_think_then_opens_response() {
1401 let mut request = Request::new(json!([
1402 {"role": "user", "content": "Go"},
1403 {
1404 "role": "assistant",
1405 "reasoning_content": "carried over reasoning",
1406 "content": "prefix",
1407 "partial": true
1408 }
1409 ]));
1410 request
1411 .args
1412 .insert("thinking".to_string(), Value::Bool(true));
1413
1414 let rendered = fmt().render(&request).unwrap();
1415
1416 assert!(rendered.ends_with(concat!(
1417 "<|open|>message role=\"assistant\"<|sep|>",
1418 "<|open|>think<|sep|>carried over reasoning<|close|>think<|sep|>",
1419 "<|open|>response<|sep|>prefix"
1420 )));
1421 }
1422
1423 #[test]
1424 fn partial_assistant_keeps_name_as_part_of_the_prefix() {
1425 let mut request = Request::new(json!([
1426 {"role": "user", "content": "Who are you?"},
1427 {"role": "assistant", "name": "Sherlock", "content": "Elementary", "partial": true}
1428 ]));
1429 request
1430 .args
1431 .insert("thinking".to_string(), Value::Bool(false));
1432
1433 let rendered = fmt().render(&request).unwrap();
1434
1435 assert!(rendered.ends_with(concat!(
1436 "<|open|>message role=\"assistant\" name=\"Sherlock\"<|sep|>",
1437 "<|open|>response<|sep|>Elementary"
1438 )));
1439 }
1440
1441 #[test]
1442 fn partial_assistant_follows_internal_system_messages() {
1443 let mut request = Request::new(json!([
1446 {"role": "user", "content": "Go"},
1447 {"role": "assistant", "content": "prefix", "partial": true}
1448 ]));
1449 request.tools = Some(json!([{
1450 "type": "function",
1451 "function": {"name": "lookup", "parameters": {"type": "object"}}
1452 }]));
1453 request.tool_choice = Some(json!("none"));
1454 request
1455 .args
1456 .insert("thinking".to_string(), Value::Bool(false));
1457
1458 let rendered = fmt().render(&request).unwrap();
1459
1460 let hint = rendered
1461 .find("tool_choice=none")
1462 .expect("tool-choice hint rendered");
1463 let turn = rendered
1464 .rfind("<|open|>message role=\"assistant\"<|sep|>")
1465 .expect("partial turn rendered");
1466 assert!(
1467 hint < turn,
1468 "internal system messages must precede the open partial turn"
1469 );
1470 assert!(rendered.ends_with("<|open|>response<|sep|>prefix"));
1471 }
1472
1473 #[test]
1474 fn partial_false_is_an_ordinary_assistant_turn() {
1475 let mut request = Request::new(json!([
1476 {"role": "user", "content": "Go"},
1477 {"role": "assistant", "content": "done", "partial": false}
1478 ]));
1479 request
1480 .args
1481 .insert("thinking".to_string(), Value::Bool(false));
1482
1483 let rendered = fmt().render(&request).unwrap();
1484
1485 assert!(rendered.contains(
1486 "<|open|>response<|sep|>done<|close|>response<|sep|><|close|>message<|sep|><|end_of_msg|>"
1487 ));
1488 assert!(
1489 rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>response<|sep|>")
1490 );
1491 }
1492
1493 #[test]
1494 fn rejects_partial_on_a_non_final_message() {
1495 let request = Request::new(json!([
1496 {"role": "assistant", "content": "early", "partial": true},
1497 {"role": "user", "content": "Go"}
1498 ]));
1499
1500 let error = fmt().render(&request).unwrap_err();
1501
1502 assert!(matches!(
1503 error.downcast_ref::<PromptRenderError>(),
1504 Some(PromptRenderError::InvalidRequest(message))
1505 if message == "Kimi K3 `partial` is only supported on the final message"
1506 ));
1507 }
1508
1509 #[test]
1510 fn rejects_partial_on_a_non_assistant_message() {
1511 let request = Request::new(json!([
1512 {"role": "user", "content": "Go", "partial": false}
1513 ]));
1514 let error = fmt().render(&request).unwrap_err();
1515 assert_eq!(
1516 invalid_request_message(&error),
1517 "Kimi K3 `partial` is only supported on an assistant message"
1518 );
1519 }
1520
1521 #[test]
1522 fn rejects_non_boolean_partial() {
1523 let request = Request::new(json!([
1524 {"role": "assistant", "content": "done", "partial": "true"}
1525 ]));
1526 let error = fmt().render(&request).unwrap_err();
1527 assert_eq!(
1528 invalid_request_message(&error),
1529 "Kimi K3 `partial` must be a boolean"
1530 );
1531 }
1532
1533 #[test]
1534 fn null_partial_is_equivalent_to_absent() {
1535 let mut request = Request::new(json!([
1536 {"role": "user", "content": "Go"}
1537 ]));
1538 let expected = fmt().render(&request).unwrap();
1539 request.messages[0]["partial"] = Value::Null;
1540 assert_eq!(fmt().render(&request).unwrap(), expected);
1541 }
1542
1543 fn invalid_request_message(error: &anyhow::Error) -> &str {
1546 match error.downcast_ref::<PromptRenderError>() {
1547 Some(PromptRenderError::InvalidRequest(message)) => message,
1548 other => panic!("expected InvalidRequest, got {other:?}"),
1549 }
1550 }
1551
1552 #[test]
1553 fn rejects_system_message_with_both_content_and_tools() {
1554 let request = Request::new(json!([
1555 {
1556 "role": "system",
1557 "content": "You are helpful",
1558 "tools": [{"type": "function", "function": {"name": "lookup"}}]
1559 },
1560 {"role": "user", "content": "Go"}
1561 ]));
1562
1563 let error = fmt().render(&request).unwrap_err();
1564 assert_eq!(
1565 invalid_request_message(&error),
1566 "Kimi K3 system messages carry either `content` or `tools`, not both"
1567 );
1568 }
1569
1570 #[test]
1571 fn rejects_system_message_tools_that_are_not_an_array() {
1572 let request = Request::new(json!([
1573 {"role": "system", "tools": {"name": "lookup"}},
1574 {"role": "user", "content": "Go"}
1575 ]));
1576
1577 let error = fmt().render(&request).unwrap_err();
1578 assert_eq!(
1579 invalid_request_message(&error),
1580 "Kimi K3 dynamic tool messages need `tools` to be an array"
1581 );
1582 }
1583
1584 #[test]
1587 fn accepts_dynamic_tools_with_empty_string_content() {
1588 for empty in [json!(""), json!([]), Value::Null] {
1589 let mut request = Request::new(json!([
1590 {"role": "user", "content": "Start"},
1591 {
1592 "role": "system",
1593 "content": empty,
1594 "tools": [{"type": "function", "function": {"name": "lookup"}}]
1595 },
1596 {"role": "user", "content": "Go"}
1597 ]));
1598 request
1599 .args
1600 .insert("thinking".to_string(), Value::Bool(false));
1601
1602 let rendered = fmt()
1603 .render(&request)
1604 .unwrap_or_else(|e| panic!("content={empty}: {e}"));
1605 assert!(
1606 rendered.contains("## New Tools Available"),
1607 "content={empty}"
1608 );
1609 assert!(
1610 !rendered.contains("<|open|>message role=\"system\"<|sep|><|close|>message"),
1611 "content={empty}: must not emit an empty system turn"
1612 );
1613 }
1614 }
1615
1616 #[test]
1617 fn rejects_malformed_dynamic_tool_entries() {
1618 let long_name = "a".repeat(257);
1619 for (entry, needle) in [
1620 (json!("lookup"), "must be JSON objects"),
1621 (
1622 json!({"parameters": {"type": "object"}}),
1623 "need a string `name`",
1624 ),
1625 (
1626 json!({"type": "web_search", "function": {"name": "lookup"}}),
1627 "type=\"function\"",
1628 ),
1629 (
1630 json!({"function": {"name": "lookup"}}),
1631 "need type=\"function\"",
1632 ),
1633 (
1634 json!({"type": "function", "name": "lookup"}),
1635 "need a `function` object",
1636 ),
1637 (json!({"name": ""}), "must match"),
1638 (json!({"name": "1bad_name"}), "must match"),
1639 (json!({"name": "bad@name"}), "must match"),
1640 (json!({"name": long_name}), "maximum is 256"),
1641 ] {
1642 let request = Request::new(json!([
1643 {"role": "system", "tools": [entry]},
1644 {"role": "user", "content": "Go"}
1645 ]));
1646 let error = fmt().render(&request).unwrap_err();
1647 assert!(
1648 invalid_request_message(&error).contains(needle),
1649 "entry={entry}: {}",
1650 invalid_request_message(&error)
1651 );
1652 }
1653 }
1654
1655 #[test]
1656 fn rejects_duplicate_tool_names_across_declarations() {
1657 let mut request = Request::new(json!([
1658 {"role": "system", "tools": [{"name": "lookup"}]},
1659 {"role": "user", "content": "Go"}
1660 ]));
1661 request.tools = Some(json!([{
1662 "type": "function",
1663 "function": {"name": "lookup", "parameters": {"type": "object"}}
1664 }]));
1665 let error = fmt().render(&request).unwrap_err();
1666 assert!(invalid_request_message(&error).contains("declared more than once"));
1667
1668 request.messages[0]["role"] = json!("developer");
1669 let error = fmt().render(&request).unwrap_err();
1670 assert!(invalid_request_message(&error).contains("declared more than once"));
1671
1672 let mut request = Request::new(json!([{"role": "user", "content": "Go"}]));
1673 request.tools = Some(json!([
1674 {"type": "function", "function": {"name": "lookup"}},
1675 {"type": "function", "function": {"name": "lookup"}}
1676 ]));
1677 let error = fmt().render(&request).unwrap_err();
1678 assert!(invalid_request_message(&error).contains("more than once in `tools`"));
1679
1680 let max_name = "a".repeat(256);
1681 let request = Request::new(json!([
1682 {"role": "system", "tools": [
1683 {"name": "_private-tool_2"},
1684 {"type": "function", "function": {"name": max_name}}
1685 ]},
1686 {"role": "user", "content": "Go"}
1687 ]));
1688 fmt().render(&request).unwrap();
1689
1690 let mut request = Request::new(json!([
1691 {"role": "system", "tools": [{"name": "lookup"}, {"type": "function", "function": {"name": "search"}}]},
1692 {"role": "user", "content": "Go"}
1693 ]));
1694 request.tools = Some(json!([{
1695 "type": "function",
1696 "function": {"name": "add", "parameters": {"type": "object"}}
1697 }]));
1698 fmt().render(&request).unwrap();
1699 }
1700
1701 #[test]
1702 fn empty_tools_list_is_an_ordinary_system_message() {
1703 let mut request = Request::new(json!([
1704 {"role": "system", "content": "You are helpful", "tools": []},
1705 {"role": "user", "content": "Go"}
1706 ]));
1707 request
1708 .args
1709 .insert("thinking".to_string(), Value::Bool(false));
1710
1711 let rendered = fmt().render(&request).unwrap();
1712 assert!(rendered.contains("<|open|>message role=\"system\"<|sep|>You are helpful"));
1713 assert!(!rendered.contains("## New Tools Available"));
1714
1715 let request = Request::new(json!([
1716 {"role": "system", "tools": []},
1717 {"role": "user", "content": "Go"}
1718 ]));
1719 let error = fmt().render(&request).unwrap_err();
1720 assert_eq!(
1721 invalid_request_message(&error),
1722 "Kimi K3 system messages need `content` or `tools`"
1723 );
1724 }
1725
1726 #[test]
1727 fn rejects_system_message_with_neither_content_nor_tools() {
1728 let request = Request::new(json!([
1729 {"role": "system"},
1730 {"role": "user", "content": "Go"}
1731 ]));
1732
1733 let error = fmt().render(&request).unwrap_err();
1734 assert_eq!(
1735 invalid_request_message(&error),
1736 "Kimi K3 system messages need `content` or `tools`"
1737 );
1738 }
1739
1740 fn typed(body: Value) -> dynamo_protocols::types::CreateChatCompletionRequest {
1748 serde_json::from_value(body).expect("request deserializes")
1749 }
1750
1751 #[test]
1752 fn typed_request_rejects_invalid_top_level_tool_name() {
1753 let request = typed(json!({
1754 "model": "kimi-k3",
1755 "messages": [{"role": "user", "content": "Look it up"}],
1756 "tools": [{
1757 "type": "function",
1758 "function": {"name": "bad@name", "parameters": {"type": "object"}}
1759 }]
1760 }));
1761
1762 let error = fmt().render(&request).unwrap_err();
1763 assert_eq!(
1764 invalid_request_message(&error),
1765 "Kimi K3 tool name \"bad@name\" must match [A-Za-z_][A-Za-z0-9_-]*"
1766 );
1767 }
1768
1769 #[test]
1770 fn typed_request_renders_dynamic_tools_and_final_partial_end_to_end() {
1771 let request = typed(json!({
1772 "model": "kimi-k3",
1773 "messages": [
1774 {"role": "system", "tools": [{
1775 "type": "function",
1776 "function": {"name": "lookup", "parameters": {"type": "object"}}
1777 }]},
1778 {"role": "user", "content": "Look it up"},
1779 {"role": "assistant", "content": "Looking", "partial": true}
1780 ]
1781 }));
1782
1783 let rendered = fmt().render(&request).unwrap();
1784
1785 assert!(rendered.contains("## New Tools Available"));
1786 assert!(rendered.contains("\"lookup\""));
1787 assert!(
1788 rendered.ends_with("<|open|>response<|sep|>Looking"),
1789 "partial turn must stay open, got {rendered:?}"
1790 );
1791 }
1792
1793 #[test]
1794 fn typed_request_preserves_content_and_tools_for_renderer_conflict_check() {
1795 let request = typed(json!({
1796 "model": "kimi-k3",
1797 "messages": [
1798 {
1799 "role": "system",
1800 "content": "You are helpful",
1801 "tools": [{"type": "function", "function": {"name": "lookup"}}]
1802 },
1803 {"role": "user", "content": "Go"}
1804 ]
1805 }));
1806
1807 let system = serde_json::to_value(&request.messages[0]).unwrap();
1808 assert_eq!(system["content"], json!("You are helpful"));
1809 assert_eq!(system["tools"][0]["function"]["name"], json!("lookup"));
1810
1811 let error = fmt().render(&request).unwrap_err();
1812 assert_eq!(
1813 invalid_request_message(&error),
1814 "Kimi K3 system messages carry either `content` or `tools`, not both"
1815 );
1816 }
1817
1818 #[test]
1819 fn rejects_partial_assistant_with_tool_calls() {
1820 let tool_call = json!({
1821 "id": "call_1",
1822 "type": "function",
1823 "function": {"name": "lookup", "arguments": "{}"}
1824 });
1825 for tool_calls in [json!([tool_call.clone()]), tool_call] {
1826 let request = Request::new(json!([
1827 {"role": "user", "content": "Go"},
1828 {
1829 "role": "assistant",
1830 "content": "prefix",
1831 "partial": true,
1832 "tool_calls": tool_calls
1833 }
1834 ]));
1835 let error = fmt().render(&request).unwrap_err();
1836 assert_eq!(
1837 invalid_request_message(&error),
1838 "Kimi K3 partial assistant messages cannot carry tool_calls",
1839 "tool_calls={tool_calls}"
1840 );
1841 }
1842 }
1843
1844 #[test]
1845 fn rejects_tools_on_final_partial_assistant_raw_path() {
1846 let request = Request::new(json!([
1847 {"role": "user", "content": "Go"},
1848 {
1849 "role": "assistant",
1850 "content": "prefix",
1851 "partial": true,
1852 "tools": [{"type": "function", "function": {"name": "lookup"}}]
1853 }
1854 ]));
1855
1856 let error = fmt().render(&request).unwrap_err();
1857
1858 assert!(matches!(
1859 error.downcast_ref::<PromptRenderError>(),
1860 Some(PromptRenderError::InvalidRequest(message))
1861 if message == "`tools` is only accepted on system or developer messages, not on role assistant"
1862 ));
1863 }
1864
1865 #[test]
1866 fn named_tool_choice_forces_tool_and_disables_thinking() {
1867 let mut request = Request::new(json!([
1868 {"role": "user", "content": "What did you do before?"},
1869 {
1870 "role": "assistant",
1871 "reasoning_content": "historical hidden reasoning",
1872 "content": "I answered the earlier question."
1873 },
1874 {"role": "user", "content": "Calculate"}
1875 ]));
1876 request.tools = Some(json!([{
1877 "type": "function",
1878 "function": {
1879 "name": "add_numbers",
1880 "parameters": {
1881 "type": "object",
1882 "properties": {
1883 "a": {"type": "integer"},
1884 "b": {"type": "integer"}
1885 },
1886 "required": ["a", "b"]
1887 }
1888 }
1889 }]));
1890 request.tool_choice = Some(json!({
1891 "type": "function",
1892 "function": {"name": "add_numbers"}
1893 }));
1894 request
1895 .args
1896 .insert("thinking".to_string(), Value::Bool(true));
1897
1898 let rendered = fmt().render(&request).unwrap();
1899 assert!(rendered.contains("The system is invoked with `tool_choice=specified`."));
1900 assert!(rendered.contains("MUST call the tool `add_numbers`"));
1901 assert!(
1902 rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>response<|sep|>"),
1903 "named tool choice must use K3's non-thinking generation prefix"
1904 );
1905 assert!(
1906 !rendered.contains("<|open|>think<|sep|>"),
1907 "named tool choice must override thinking=true"
1908 );
1909 assert!(
1910 !rendered.contains("historical hidden reasoning"),
1911 "named tool choice must also suppress preserved thinking history"
1912 );
1913 }
1914
1915 #[test]
1916 fn named_tool_choice_accepts_a_dynamic_system_tool() {
1917 for lookup in [
1918 json!({"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}),
1919 json!({"name": "lookup", "parameters": {"type": "object"}}),
1920 ] {
1921 let mut request = Request::new(json!([
1922 {"role": "user", "content": "Start"},
1923 {"role": "system", "tools": [lookup]},
1924 {"role": "user", "content": "Look this up"}
1925 ]));
1926 request.tool_choice = Some(json!({
1927 "type": "function",
1928 "function": {"name": "lookup"}
1929 }));
1930
1931 let rendered = fmt().render(&request).unwrap();
1932 assert!(rendered.contains("## New Tools Available"));
1933 assert!(rendered.contains("MUST call the tool `lookup`"));
1934 assert!(
1935 request.tools.is_none(),
1936 "dynamic tools must not be folded into the top-level list"
1937 );
1938 }
1939 }
1940
1941 #[test]
1942 fn named_tool_choice_still_rejects_a_tool_absent_from_dynamic_tools() {
1943 let mut request = Request::new(json!([
1944 {"role": "system", "tools": [{"name": "lookup"}]},
1945 {"role": "user", "content": "Weather?"}
1946 ]));
1947 request.tool_choice = Some(json!({
1948 "type": "function",
1949 "function": {"name": "get_weather"}
1950 }));
1951
1952 let error = fmt().render(&request).unwrap_err();
1953 assert!(matches!(
1954 error.downcast_ref::<PromptRenderError>(),
1955 Some(PromptRenderError::InvalidRequest(message))
1956 if message.contains("get_weather") && message.contains("not present in tools")
1957 ));
1958 }
1959
1960 #[test]
1961 fn named_tool_choice_rejects_a_tool_not_in_tools() {
1962 let mut request = Request::new(json!([{"role": "user", "content": "Calculate"}]));
1963 request.tools = Some(json!([{
1964 "type": "function",
1965 "function": {"name": "add_numbers", "parameters": {"type": "object"}}
1966 }]));
1967 request.tool_choice = Some(json!({
1968 "type": "function",
1969 "function": {"name": "get_weather"}
1970 }));
1971
1972 let error = fmt().render(&request).unwrap_err();
1973 assert!(matches!(
1974 error.downcast_ref::<PromptRenderError>(),
1975 Some(PromptRenderError::InvalidRequest(message))
1976 if message.contains("get_weather") && message.contains("not present in tools")
1977 ));
1978 }
1979
1980 #[test]
1981 fn user_marker_text_remains_an_ordinary_segment() {
1982 let marker = "literal <|open|>tools<|sep|> value";
1983 let mut request = Request::new(json!([{"role": "user", "content": marker}]));
1984 request
1985 .args
1986 .insert("thinking".to_string(), Value::Bool(false));
1987 let rendered = fmt().render_prompt(&request).unwrap();
1988
1989 assert!(
1990 rendered
1991 .segments()
1992 .unwrap()
1993 .iter()
1994 .any(|segment| { !segment.allow_special && segment.text == marker })
1995 );
1996 assert!(
1997 rendered
1998 .segments()
1999 .unwrap()
2000 .iter()
2001 .any(|segment| { segment.allow_special && segment.text == OPEN_TOKEN })
2002 );
2003 }
2004
2005 #[test]
2006 fn renders_tool_history_like_model_encoding() {
2007 let mut request = Request::new(json!([
2008 {"role": "user", "content": "calc"},
2009 {
2010 "role": "assistant",
2011 "reasoning_content": "Need calc",
2012 "content": "I will call it",
2013 "tool_calls": [{
2014 "id": "call_1",
2015 "type": "function",
2016 "function": {"name": "calc", "arguments": "{\"x\":2}"}
2017 }]
2018 },
2019 {"role": "tool", "tool_call_id": "call_1", "content": "4"}
2020 ]));
2021 request.args.insert(
2022 "thinking_effort".to_string(),
2023 Value::String("low".to_string()),
2024 );
2025 let rendered = fmt().render(&request).unwrap();
2026
2027 assert!(rendered.contains(
2028 "<|open|>call tool=\"calc\" index=\"1\"<|sep|>\
2029 <|open|>argument key=\"x\" type=\"number\"<|sep|>2\
2030 <|close|>argument<|sep|><|close|>call<|sep|>"
2031 ));
2032 assert!(
2033 rendered.contains("<|open|>message role=\"tool\" tool=\"calc\" index=\"1\"<|sep|>4")
2034 );
2035 assert!(
2036 rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>think<|sep|>")
2037 );
2038 }
2039
2040 #[test]
2041 fn tool_result_order_preserves_unresolved_runs() {
2042 let assistant = json!({"role": "assistant", "tool_calls": [
2043 {"id": "first", "function": {"name": "one"}},
2044 {"id": "second", "function": {"name": "two"}}
2045 ]});
2046 let second =
2047 json!({"role": "tool", "tool_call_id": "second", "content": "第二", "name": "old"});
2048 let first = json!({"role": "tool", "tool_call_id": "first", "content": "第一"});
2049 let sorted =
2050 normalize_tool_result_messages(vec![assistant.clone(), second.clone(), first]).unwrap();
2051 assert_eq!(sorted[1]["content"], "第一");
2052 assert_eq!(sorted[1]["tool"], "one");
2053 assert_eq!(sorted[2]["content"], "第二");
2054 assert_eq!(sorted[2]["tool"], "two");
2055 assert_eq!(sorted[2]["name"], "two");
2056
2057 let unresolved = vec![
2058 assistant,
2059 second,
2060 json!({"role": "tool", "tool_call_id": "unknown", "content": "keep order"}),
2061 ];
2062 assert_eq!(
2063 normalize_tool_result_messages(unresolved.clone()).unwrap(),
2064 unresolved
2065 );
2066 }
2067
2068 #[test]
2069 fn thinking_history_renders_an_empty_think_channel() {
2070 let request = Request::new(json!([
2071 {"role": "user", "content": "question"},
2072 {"role": "assistant", "content": "answer"},
2073 {"role": "user", "content": "follow-up"}
2074 ]));
2075
2076 let rendered = fmt().render(&request).unwrap();
2077
2078 assert!(rendered.contains(concat!(
2079 "<|open|>message role=\"assistant\"<|sep|>",
2080 "<|open|>think<|sep|><|close|>think<|sep|>",
2081 "<|open|>response<|sep|>answer<|close|>response<|sep|>"
2082 )));
2083 }
2084
2085 #[test]
2086 fn non_thinking_history_omits_preserved_reasoning() {
2087 let mut request = Request::new(json!([
2088 {"role": "user", "content": "question"},
2089 {
2090 "role": "assistant",
2091 "reasoning_content": "hidden reasoning",
2092 "content": "answer"
2093 },
2094 {"role": "user", "content": "follow-up"}
2095 ]));
2096 request
2097 .args
2098 .insert("thinking".to_string(), Value::Bool(false));
2099
2100 let rendered = fmt().render(&request).unwrap();
2101
2102 assert!(!rendered.contains("hidden reasoning"));
2103 assert!(!rendered.contains("<|open|>think<|sep|>"));
2104 assert!(rendered.contains(concat!(
2105 "<|open|>message role=\"assistant\"<|sep|>",
2106 "<|open|>response<|sep|>answer<|close|>response<|sep|>"
2107 )));
2108 }
2109
2110 #[test]
2111 fn tools_are_deep_sorted_before_declaration() {
2112 let mut request = Request::new(json!([{"role": "user", "content": "Weather?"}]));
2113 request
2114 .args
2115 .insert("thinking".to_string(), Value::Bool(false));
2116 request.tools = Some(json!([{
2117 "type": "function",
2118 "function": {
2119 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
2120 "name": "weather",
2121 "description": "Get weather"
2122 }
2123 }]));
2124 let rendered = fmt().render(&request).unwrap();
2125 assert!(rendered.contains(concat!(
2126 "[{\"function\":{\"description\":\"Get weather\",",
2127 "\"name\":\"weather\",\"parameters\":{\"properties\":",
2128 "{\"city\":{\"type\":\"string\"}},\"type\":\"object\"}},",
2129 "\"type\":\"function\"}]"
2130 )));
2131 }
2132
2133 #[test]
2134 fn assistant_history_matches_python_json_spacing_and_reasoning_fallback() {
2135 let request = Request::new(json!([{
2136 "role": "assistant",
2137 "reasoning_content": "",
2138 "reasoning": "fallback",
2139 "content": null,
2140 "tool_calls": [{
2141 "type": "function",
2142 "function": {
2143 "name": "run",
2144 "arguments": {
2145 "opts": {"a": 1, "b": [true, false]}
2146 }
2147 }
2148 }]
2149 }]));
2150 let rendered = fmt().render(&request).unwrap();
2151
2152 assert!(rendered.contains("<|open|>think<|sep|>fallback<|close|>think<|sep|>"));
2153 assert!(rendered.contains(concat!(
2154 "<|open|>argument key=\"opts\" type=\"object\"<|sep|>",
2155 "{\"a\": 1, \"b\": [true, false]}",
2156 "<|close|>argument<|sep|>"
2157 )));
2158 }
2159}