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
69 && !tools
70 .as_ref()
71 .is_some_and(|tools| contains_tool(tools, named_tool))
72 {
73 return Err(PromptRenderError::invalid_request(format!(
74 "tool named {named_tool:?} in tool_choice is not present in tools"
75 ))
76 .into());
77 }
78 if self.exclude_tools_when_tool_choice_none && tool_choice_kind == Some("none") {
79 tools = None;
80 }
81 let tools = tools.map(deep_sort);
82
83 let args = req.chat_template_args();
84 let thinking = named_tool.is_none() && thinking_bool_from_args(args).unwrap_or(true);
88 let thinking_effort = resolve_thinking_effort(args);
89 if thinking && !VALID_THINKING_EFFORTS.contains(&thinking_effort.as_str()) {
90 return Err(PromptRenderError::invalid_request(format!(
91 "Unsupported Kimi K3 thinking_effort={thinking_effort:?}; supported values are low, high, and max"
92 ))
93 .into());
94 }
95
96 let response_format = req.response_format().map(json_value).transpose()?;
97 build_chat_segments(
98 &messages,
99 tools.as_ref(),
100 tool_choice_kind,
101 named_tool,
102 response_format.as_ref(),
103 req.should_add_generation_prompt(),
104 thinking,
105 thinking_effort.as_str(),
106 )
107 }
108}
109
110impl OAIPromptFormatter for KimiK3Formatter {
111 fn supports_add_generation_prompt(&self) -> bool {
112 true
113 }
114
115 fn render(&self, req: &dyn OAIChatLikeRequest) -> Result<String> {
116 Ok(RenderedPrompt::segmented(self.build_segments(req)?).into_text())
117 }
118
119 fn render_prompt(&self, req: &dyn OAIChatLikeRequest) -> Result<RenderedPrompt> {
120 Ok(RenderedPrompt::segmented(self.build_segments(req)?))
121 }
122}
123
124fn json_value(value: minijinja::value::Value) -> Result<Value> {
125 serde_json::to_value(&value).context("Failed to convert template value to JSON")
126}
127
128fn resolve_tool_choice(tool_choice: Option<&Value>) -> Result<(Option<&str>, Option<&str>)> {
129 match tool_choice {
130 Some(Value::String(kind)) => Ok((Some(kind.as_str()), None)),
131 Some(Value::Object(choice)) => {
132 if choice.get("type").and_then(Value::as_str) != Some("function") {
133 return Err(PromptRenderError::invalid_request(
134 "Kimi K3 named tool_choice must have type=\"function\"",
135 )
136 .into());
137 }
138 let name = choice
142 .get("function")
143 .and_then(Value::as_object)
144 .and_then(|function| function.get("name"))
145 .or_else(|| choice.get("name"))
146 .and_then(Value::as_str)
147 .filter(|name| !name.is_empty())
148 .ok_or_else(|| {
149 PromptRenderError::invalid_request(
150 "Kimi K3 named tool_choice requires a non-empty function name",
151 )
152 })?;
153 Ok((Some("specified"), Some(name)))
154 }
155 Some(Value::Null) | None => Ok((None, None)),
156 Some(other) => Err(anyhow::anyhow!(
157 "Unsupported Kimi K3 tool_choice value: {other}"
158 )),
159 }
160}
161
162fn contains_tool(tools: &Value, name: &str) -> bool {
163 tools.as_array().is_some_and(|tools| {
164 tools.iter().any(|tool| {
165 tool.get("function")
166 .and_then(Value::as_object)
167 .and_then(|function| function.get("name"))
168 .or_else(|| tool.get("name"))
169 .and_then(Value::as_str)
170 == Some(name)
171 })
172 })
173}
174
175fn resolve_thinking_effort(args: Option<&HashMap<String, Value>>) -> String {
176 args.and_then(|args| {
177 args.get("thinking_effort")
178 .or_else(|| args.get("reasoning_effort"))
179 .and_then(Value::as_str)
180 })
181 .unwrap_or("max")
182 .to_string()
183}
184
185fn push_segment(segments: &mut Vec<RenderedSegment>, text: impl Into<String>, allow_special: bool) {
186 let text = text.into();
187 if !text.is_empty() {
188 segments.push(RenderedSegment {
189 text,
190 allow_special,
191 });
192 }
193}
194
195fn control(segments: &mut Vec<RenderedSegment>, text: impl Into<String>) {
196 push_segment(segments, text, true);
197}
198
199fn text(segments: &mut Vec<RenderedSegment>, text: impl Into<String>) {
200 push_segment(segments, text, false);
201}
202
203fn escape_attr_value(value: impl std::fmt::Display) -> String {
204 value
205 .to_string()
206 .replace('&', "&")
207 .replace('"', """)
208}
209
210fn open_tag(
211 segments: &mut Vec<RenderedSegment>,
212 tag: &str,
213 attrs: impl IntoIterator<Item = (String, String)>,
214) {
215 control(segments, OPEN_TOKEN);
216 text(segments, tag);
217 for (key, value) in attrs {
218 text(segments, format!(" {key}"));
219 text(segments, "=\"");
220 text(segments, escape_attr_value(value));
221 text(segments, "\"");
222 }
223 control(segments, SEP_TOKEN);
224}
225
226fn close_tag(segments: &mut Vec<RenderedSegment>, tag: &str) {
227 control(segments, CLOSE_TOKEN);
228 text(segments, tag);
229 control(segments, SEP_TOKEN);
230}
231
232fn end_of_msg(segments: &mut Vec<RenderedSegment>) {
233 control(segments, END_OF_MSG_TOKEN);
234}
235
236fn internal_system_message(segments: &mut Vec<RenderedSegment>, message_type: &str, body: &str) {
237 open_tag(
238 segments,
239 "message",
240 [
241 ("role".to_string(), "system".to_string()),
242 ("type".to_string(), message_type.to_string()),
243 ],
244 );
245 text(segments, body.trim());
246 close_tag(segments, "message");
247 end_of_msg(segments);
248}
249
250fn deep_sort(value: Value) -> Value {
251 match value {
252 Value::Object(map) => {
253 let mut entries: Vec<_> = map.into_iter().collect();
254 entries.sort_by(|(left, _), (right, _)| left.cmp(right));
255 Value::Object(
256 entries
257 .into_iter()
258 .map(|(key, value)| (key, deep_sort(value)))
259 .collect(),
260 )
261 }
262 Value::Array(items) => Value::Array(items.into_iter().map(deep_sort).collect()),
263 other => other,
264 }
265}
266
267fn compact_json(value: &Value) -> Result<String> {
268 serde_json::to_string(value).context("Failed to serialize K3 JSON")
269}
270
271fn response_schema(response_format: &Value) -> Option<Value> {
272 let json_schema = response_format.get("json_schema")?;
273 if let Some(schema) = json_schema.get("schema") {
274 return Some(schema.clone());
275 }
276 if let Some(schema) = json_schema.get("json_schema") {
277 return Some(schema.clone());
278 }
279 Some(json_schema.clone())
280}
281
282fn value_as_body_text(value: &Value) -> Result<String> {
283 match value {
284 Value::String(value) => Ok(value.clone()),
285 Value::Array(values) if values.iter().all(Value::is_string) => Ok(values
286 .iter()
287 .filter_map(Value::as_str)
288 .filter(|value| !value.is_empty())
289 .collect::<Vec<_>>()
290 .join("\n")),
291 other => compact_json(other),
292 }
293}
294
295fn render_content_segments(
296 segments: &mut Vec<RenderedSegment>,
297 content: Option<&Value>,
298) -> Result<()> {
299 let Some(content) = content else {
300 return Ok(());
301 };
302 match content {
303 Value::Null => {}
304 Value::String(value) => text(segments, value),
305 Value::Array(parts) => {
306 for part in parts {
307 match part.get("type").and_then(Value::as_str) {
308 Some("image" | "image_url") => control(segments, MEDIA_PAD),
309 _ => {
310 if let Some(part_text) = part.get("text") {
311 text(segments, value_as_body_text(part_text)?);
312 }
313 }
314 }
315 }
316 }
317 other => text(segments, value_as_body_text(other)?),
318 }
319 Ok(())
320}
321
322fn render_role_message(
323 segments: &mut Vec<RenderedSegment>,
324 message: &Value,
325 role: &str,
326) -> Result<()> {
327 let mut attrs = vec![("role".to_string(), role.to_string())];
328 if let Some(name) = message
329 .get("name")
330 .and_then(Value::as_str)
331 .filter(|name| !name.is_empty())
332 {
333 attrs.push(("name".to_string(), name.to_string()));
334 }
335 open_tag(segments, "message", attrs);
336 render_content_segments(segments, message.get("content"))?;
337 close_tag(segments, "message");
338 end_of_msg(segments);
339 Ok(())
340}
341
342fn render_tool_declare(
343 segments: &mut Vec<RenderedSegment>,
344 tools: &Value,
345 dynamic: bool,
346) -> Result<()> {
347 let tools = compact_json(tools)?;
348 let body = if dynamic {
349 format!(
350 "## New Tools Available\n\
351 The system dynamically extends the toolset via lazy-loading.\n\
352 You have access to all existing and extended tools.\n\
353 Here are the specs for the extended tools.\n\n\
354 ```json\n{tools}\n```"
355 )
356 } else {
357 format!(
358 "# Tools\n\
359 Here are the available tools, described in JSONSchema.\n\n\
360 ```json\n{tools}\n```"
361 )
362 };
363 open_tag(
364 segments,
365 "message",
366 [
367 ("role".to_string(), "system".to_string()),
368 ("type".to_string(), "tool-declare".to_string()),
369 ],
370 );
371 text(segments, body);
372 close_tag(segments, "message");
373 end_of_msg(segments);
374 Ok(())
375}
376
377fn xtml_type(value: &Value) -> &'static str {
378 match value {
379 Value::Bool(_) => "boolean",
380 Value::Null => "null",
381 Value::Number(_) => "number",
382 Value::String(_) => "string",
383 Value::Object(_) => "object",
384 Value::Array(_) => "array",
385 }
386}
387
388fn xtml_value(value: &Value) -> Result<String> {
389 match value {
390 Value::String(value) => Ok(value.clone()),
391 other => python_default_json(other),
395 }
396}
397
398fn python_default_json(value: &Value) -> Result<String> {
399 let compact = compact_json(value)?;
400 let mut output = String::with_capacity(compact.len());
401 let mut in_string = false;
402 let mut escaped = false;
403 for ch in compact.chars() {
404 output.push(ch);
405 if in_string {
406 if escaped {
407 escaped = false;
408 } else if ch == '\\' {
409 escaped = true;
410 } else if ch == '"' {
411 in_string = false;
412 }
413 } else if ch == '"' {
414 in_string = true;
415 } else if matches!(ch, ',' | ':') {
416 output.push(' ');
417 }
418 }
419 Ok(output)
420}
421
422enum NormalizedArguments {
423 Object(Map<String, Value>),
424 JsonBlock(String),
425}
426
427fn normalize_arguments(arguments: Option<&Value>) -> Result<NormalizedArguments> {
428 let Some(arguments) = arguments else {
429 return Ok(NormalizedArguments::Object(Map::new()));
430 };
431 match arguments {
432 Value::Null => Ok(NormalizedArguments::Object(Map::new())),
433 Value::Object(arguments) => Ok(NormalizedArguments::Object(arguments.clone())),
434 Value::String(arguments) if arguments.trim().is_empty() => {
435 Ok(NormalizedArguments::Object(Map::new()))
436 }
437 Value::String(arguments) => match serde_json::from_str::<Value>(arguments) {
438 Ok(Value::Object(arguments)) => Ok(NormalizedArguments::Object(arguments)),
439 Ok(_) => bail!("Kimi K3 tool call arguments must be a JSON object"),
440 Err(_) => Ok(NormalizedArguments::JsonBlock(arguments.clone())),
441 },
442 _ => bail!("Kimi K3 tool call arguments must be an object or JSON object string"),
443 }
444}
445
446fn render_assistant_segments(
447 segments: &mut Vec<RenderedSegment>,
448 message: &Value,
449 thinking: bool,
450) -> Result<()> {
451 let reasoning = message
454 .get("reasoning_content")
455 .filter(|value| match value {
456 Value::Null => false,
457 Value::Bool(value) => *value,
458 Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
459 Value::String(value) => !value.is_empty(),
460 Value::Array(value) => !value.is_empty(),
461 Value::Object(value) => !value.is_empty(),
462 })
463 .or_else(|| message.get("reasoning"))
464 .map(value_as_body_text)
465 .transpose()?;
466
467 if thinking {
472 open_tag(segments, "think", []);
473 if let Some(reasoning) = reasoning.filter(|reasoning| !reasoning.trim().is_empty()) {
474 text(segments, reasoning);
475 }
476 close_tag(segments, "think");
477 }
478
479 open_tag(segments, "response", []);
480 render_content_segments(segments, message.get("content"))?;
481 close_tag(segments, "response");
482
483 let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) else {
484 return Ok(());
485 };
486 if tool_calls.is_empty() {
487 return Ok(());
488 }
489
490 open_tag(segments, "tools", []);
491 for (position, tool_call) in tool_calls.iter().enumerate() {
492 let function = tool_call.get("function").unwrap_or(tool_call);
493 let name = function
494 .get("name")
495 .and_then(Value::as_str)
496 .context("Kimi K3 tool call is missing function.name")?;
497 open_tag(
498 segments,
499 "call",
500 [
501 ("tool".to_string(), name.to_string()),
502 ("index".to_string(), (position + 1).to_string()),
503 ],
504 );
505
506 match normalize_arguments(function.get("arguments"))? {
507 NormalizedArguments::JsonBlock(raw) => {
508 open_tag(
509 segments,
510 "json",
511 [("type".to_string(), "object".to_string())],
512 );
513 text(segments, raw);
514 close_tag(segments, "json");
515 }
516 NormalizedArguments::Object(arguments) => {
517 for (key, value) in arguments {
518 open_tag(
519 segments,
520 "argument",
521 [
522 ("key".to_string(), key),
523 ("type".to_string(), xtml_type(&value).to_string()),
524 ],
525 );
526 text(segments, xtml_value(&value)?);
527 close_tag(segments, "argument");
528 }
529 }
530 }
531 close_tag(segments, "call");
532 }
533 close_tag(segments, "tools");
534 Ok(())
535}
536
537fn tool_call_index(tool_calls: Option<&Value>) -> HashMap<String, (usize, Option<String>)> {
538 let mut index = HashMap::new();
539 let Some(tool_calls) = tool_calls.and_then(Value::as_array) else {
540 return index;
541 };
542 for (position, tool_call) in tool_calls.iter().enumerate() {
543 let Some(id) = tool_call.get("id").and_then(Value::as_str) else {
544 continue;
545 };
546 let function = tool_call.get("function").unwrap_or(tool_call);
547 let name = function
548 .get("name")
549 .and_then(Value::as_str)
550 .map(str::to_string);
551 index.entry(id.to_string()).or_insert((position + 1, name));
552 }
553 index
554}
555
556fn normalize_tool_result_messages(messages: &[Value]) -> Result<Vec<Value>> {
557 let mut output = Vec::with_capacity(messages.len());
558 let mut current_index = HashMap::new();
559 let mut position = 0;
560
561 while position < messages.len() {
562 let message = &messages[position];
563 let role = message.get("role").and_then(Value::as_str);
564 if role == Some("assistant") {
565 current_index = tool_call_index(message.get("tool_calls"));
566 output.push(message.clone());
567 position += 1;
568 continue;
569 }
570 if role != Some("tool") {
571 output.push(message.clone());
572 position += 1;
573 continue;
574 }
575
576 let mut run: Vec<(Option<usize>, usize, Value, Option<String>)> = Vec::new();
577 let mut unresolved = false;
578 let mut offset = 0;
579 while position < messages.len()
580 && messages[position].get("role").and_then(Value::as_str) == Some("tool")
581 {
582 let tool_message = &messages[position];
583 let call_id = tool_message
584 .get("tool_call_id")
585 .or_else(|| tool_message.get("id"))
586 .and_then(Value::as_str);
587 let matched = call_id.and_then(|id| current_index.get(id));
588 if let Some((tool_position, name)) = matched {
589 run.push((
590 Some(*tool_position),
591 offset,
592 tool_message.clone(),
593 name.clone(),
594 ));
595 } else {
596 unresolved = true;
597 run.push((None, offset, tool_message.clone(), None));
598 }
599 offset += 1;
600 position += 1;
601 }
602
603 if unresolved {
604 output.extend(run.into_iter().map(|(_, _, message, _)| message));
605 continue;
606 }
607 run.sort_by_key(|(tool_position, offset, _, _)| (*tool_position, *offset));
608 for (_, _, mut message, name) in run {
609 if let (Some(name), Some(message)) = (name, message.as_object_mut()) {
610 message.insert("tool".to_string(), Value::String(name.clone()));
611 if message.contains_key("name") {
612 message.insert("name".to_string(), Value::String(name));
613 }
614 }
615 output.push(message);
616 }
617 }
618 Ok(output)
619}
620
621#[allow(clippy::too_many_arguments)]
622fn build_chat_segments(
623 messages: &[Value],
624 tools: Option<&Value>,
625 tool_choice: Option<&str>,
626 named_tool: Option<&str>,
627 response_format: Option<&Value>,
628 add_generation_prompt: bool,
629 thinking: bool,
630 thinking_effort: &str,
631) -> Result<Vec<RenderedSegment>> {
632 let mut segments = Vec::new();
633 let mut previous_tool_calls: Option<&Value> = None;
634 let mut tool_index = 0usize;
635
636 if let Some(tools) = tools.filter(|tools| !tools.as_array().is_some_and(Vec::is_empty)) {
637 render_tool_declare(&mut segments, tools, false)?;
638 }
639
640 if thinking {
641 internal_system_message(
642 &mut segments,
643 "thinking-effort",
644 &format!(
645 "`thinking_effort` guides on how much to think in your thinking channel \
646 (not including the response channel), supported values include `low`, \
647 `medium`, `high`, and `max`.\nNow the system is invoked with \
648 `thinking_effort={thinking_effort}`."
649 ),
650 );
651 }
652
653 for message in messages {
654 let role = message.get("role").and_then(Value::as_str).ok_or_else(|| {
655 PromptRenderError::invalid_request("Kimi K3 messages must contain a string role")
656 })?;
657 match role {
658 "system" | "developer"
659 if message.get("tools").is_some_and(|tools| {
660 !tools.is_null() && !tools.as_array().is_some_and(Vec::is_empty)
661 }) =>
662 {
663 let dynamic_tools = deep_sort(message["tools"].clone());
664 render_tool_declare(&mut segments, &dynamic_tools, true)?;
665 if role == "developer"
666 && message
667 .get("content")
668 .is_some_and(|content| !content.is_null())
669 {
670 render_role_message(&mut segments, message, "system")?;
671 }
672 }
673 "user" | "system" | "developer" => {
674 let rendered_role = if role == "developer" { "system" } else { role };
675 render_role_message(&mut segments, message, rendered_role)?;
676 }
677 "assistant" => {
678 previous_tool_calls = message.get("tool_calls");
679 tool_index = 0;
680 let mut attrs = vec![("role".to_string(), "assistant".to_string())];
681 if let Some(name) = message
682 .get("name")
683 .and_then(Value::as_str)
684 .filter(|name| !name.is_empty())
685 {
686 attrs.push(("name".to_string(), name.to_string()));
687 }
688 open_tag(&mut segments, "message", attrs);
689 render_assistant_segments(&mut segments, message, thinking)?;
690 close_tag(&mut segments, "message");
691 end_of_msg(&mut segments);
692 }
693 "tool" => {
694 tool_index += 1;
695 let fallback_name = previous_tool_calls
696 .and_then(Value::as_array)
697 .and_then(|calls| calls.get(tool_index - 1))
698 .map(|call| call.get("function").unwrap_or(call))
699 .and_then(|function| function.get("name"))
700 .and_then(Value::as_str);
701 let tool_name = message
702 .get("tool")
703 .or_else(|| message.get("name"))
704 .and_then(Value::as_str)
705 .or(fallback_name)
706 .context(
707 "Kimi K3 tool messages need a tool/name or a preceding assistant tool call",
708 )?;
709 open_tag(
710 &mut segments,
711 "message",
712 [
713 ("role".to_string(), "tool".to_string()),
714 ("tool".to_string(), tool_name.to_string()),
715 ("index".to_string(), tool_index.to_string()),
716 ],
717 );
718 render_content_segments(&mut segments, message.get("content"))?;
719 close_tag(&mut segments, "message");
720 end_of_msg(&mut segments);
721 }
722 unsupported => {
723 return Err(PromptRenderError::invalid_request(format!(
724 "Kimi K3 does not support message role {unsupported:?}"
725 ))
726 .into());
727 }
728 }
729 }
730
731 match tool_choice {
732 Some("required") => internal_system_message(
733 &mut segments,
734 "tool-choice",
735 "The system is invoked with `tool_choice=required`.\n\
736 You MUST call tools in the next message.",
737 ),
738 Some("none") => internal_system_message(
739 &mut segments,
740 "tool-choice",
741 "The system is invoked with `tool_choice=none`.\n\
742 You MUST NOT call any tools in the next message.",
743 ),
744 Some("specified") => internal_system_message(
745 &mut segments,
746 "tool-choice",
747 &format!(
748 "The system is invoked with `tool_choice=specified`.\n\
749 You MUST call the tool `{}` in the next message.",
750 named_tool.expect("specified tool_choice has a function name")
751 ),
752 ),
753 _ => {}
754 }
755
756 if let Some(response_format) = response_format {
757 match response_format.get("type").and_then(Value::as_str) {
758 Some("json_object") => internal_system_message(
759 &mut segments,
760 "response-format",
761 "The system is invoked with `response_format=json_object`.\n\
762 Your response must be raw JSON data without markdown code blocks \
763 (```json) or any additional formatting.",
764 ),
765 Some("json_schema") => {
766 let schema = response_schema(response_format)
767 .map(deep_sort)
768 .unwrap_or(Value::Null);
769 internal_system_message(
770 &mut segments,
771 "response-format",
772 &format!(
773 "The system is invoked with `response_format=json_schema`.\n\
774 Your response must be raw JSON data without markdown code blocks \
775 (```json) or any additional formatting.\n\
776 The JSON data must match the following schema:\n\
777 ```json\n{}\n```",
778 compact_json(&schema)?
779 ),
780 );
781 }
782 _ => {}
783 }
784 }
785
786 if add_generation_prompt {
787 open_tag(
788 &mut segments,
789 "message",
790 [("role".to_string(), "assistant".to_string())],
791 );
792 open_tag(
793 &mut segments,
794 if thinking { "think" } else { "response" },
795 [],
796 );
797 }
798
799 Ok(segments)
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805 use minijinja::value::Value as MiniValue;
806 use serde_json::json;
807
808 struct Request {
809 messages: Value,
810 tools: Option<Value>,
811 tool_choice: Option<Value>,
812 response_format: Option<Value>,
813 args: HashMap<String, Value>,
814 add_generation_prompt: bool,
815 }
816
817 impl Request {
818 fn new(messages: Value) -> Self {
819 Self {
820 messages,
821 tools: None,
822 tool_choice: None,
823 response_format: None,
824 args: HashMap::new(),
825 add_generation_prompt: true,
826 }
827 }
828 }
829
830 impl OAIChatLikeRequest for Request {
831 fn model(&self) -> String {
832 "kimi-k3".to_string()
833 }
834
835 fn messages(&self) -> MiniValue {
836 MiniValue::from_serialize(&self.messages)
837 }
838
839 fn tools(&self) -> Option<MiniValue> {
840 self.tools.as_ref().map(MiniValue::from_serialize)
841 }
842
843 fn tool_choice(&self) -> Option<MiniValue> {
844 self.tool_choice.as_ref().map(MiniValue::from_serialize)
845 }
846
847 fn response_format(&self) -> Option<MiniValue> {
848 self.response_format.as_ref().map(MiniValue::from_serialize)
849 }
850
851 fn should_add_generation_prompt(&self) -> bool {
852 self.add_generation_prompt
853 }
854
855 fn chat_template_args(&self) -> Option<&HashMap<String, Value>> {
856 Some(&self.args)
857 }
858 }
859
860 fn fmt() -> KimiK3Formatter {
862 KimiK3Formatter::new(true)
863 }
864
865 fn image_request() -> Request {
867 let mut request = Request::new(json!([{
868 "role": "user",
869 "content": [{"type": "image_url", "image_url": {"url": "http://example.com/a.png"}}]
870 }]));
871 request
872 .args
873 .insert("thinking".to_string(), Value::Bool(false));
874 request
875 }
876
877 fn image_segments(formatter: &KimiK3Formatter, request: &Request) -> Vec<RenderedSegment> {
878 formatter
879 .render_prompt(request)
880 .unwrap()
881 .segments()
882 .expect("K3 always renders segmented prompts")
883 .to_vec()
884 }
885
886 #[test]
887 fn renders_one_media_pad_per_image() {
888 let segments = image_segments(&fmt(), &image_request());
889
890 let matches: Vec<_> = segments
891 .iter()
892 .filter(|segment| segment.text == MEDIA_PAD)
893 .collect();
894 assert_eq!(matches.len(), 1, "exactly one pad per image");
895 assert!(matches[0].allow_special);
898 assert!(
901 !segments
902 .iter()
903 .any(|segment| segment.text.contains("kimi_image_placeholder")),
904 );
905 }
906
907 #[test]
908 fn image_token_cardinality_is_one_per_image() {
909 let mut request = Request::new(json!([{
910 "role": "user",
911 "content": [
912 {"type": "image_url", "image_url": {"url": "http://example.com/a.png"}},
913 {"type": "text", "text": "and"},
914 {"type": "image_url", "image_url": {"url": "http://example.com/b.png"}},
915 {"type": "text", "text": "compare them"}
916 ]
917 }]));
918 request
919 .args
920 .insert("thinking".to_string(), Value::Bool(false));
921
922 let segments = image_segments(&fmt(), &request);
923
924 assert_eq!(
925 segments
926 .iter()
927 .filter(|segment| segment.text == MEDIA_PAD)
928 .count(),
929 2
930 );
931 for body in ["and", "compare them"] {
933 assert!(
934 segments
935 .iter()
936 .any(|segment| segment.text == body && !segment.allow_special)
937 );
938 }
939 }
940
941 #[test]
942 fn user_text_spelling_the_pad_stays_ordinary() {
943 let body = "please describe <|media_pad|>";
944 let mut request = Request::new(json!([{"role": "user", "content": body}]));
945 request
946 .args
947 .insert("thinking".to_string(), Value::Bool(false));
948
949 let segments = image_segments(&fmt(), &request);
950
951 assert!(
952 segments
953 .iter()
954 .any(|segment| segment.text == body && !segment.allow_special),
955 "user content must never be promoted into prompt structure"
956 );
957 }
958
959 #[test]
960 fn renders_off_mode_like_model_encoding() {
961 let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
962 request
963 .args
964 .insert("thinking".to_string(), Value::Bool(false));
965 let rendered = fmt().render(&request).unwrap();
966 assert_eq!(
967 rendered,
968 concat!(
969 "<|open|>message role=\"user\"<|sep|>Hello",
970 "<|close|>message<|sep|><|end_of_msg|>",
971 "<|open|>message role=\"assistant\"<|sep|>",
972 "<|open|>response<|sep|>"
973 )
974 );
975 }
976
977 #[test]
978 fn renders_developer_messages_as_system() {
979 let mut request = Request::new(json!([
980 {"role": "developer", "content": "Follow this policy", "name": "policy"},
981 {"role": "user", "content": "Hello"}
982 ]));
983 request
984 .args
985 .insert("thinking".to_string(), Value::Bool(false));
986
987 let rendered = fmt().render(&request).unwrap();
988
989 assert!(
990 rendered.contains(
991 "<|open|>message role=\"system\" name=\"policy\"<|sep|>Follow this policy"
992 )
993 );
994 assert!(!rendered.contains("role=\"developer\""));
995 assert!(
996 rendered.find("Follow this policy").unwrap() < rendered.find("Hello").unwrap(),
997 "developer instructions must retain their position"
998 );
999 }
1000
1001 #[test]
1002 fn renders_developer_tools_and_content() {
1003 let mut request = Request::new(json!([
1004 {
1005 "role": "developer",
1006 "content": "Use the newly available tool",
1007 "tools": [{
1008 "type": "function",
1009 "function": {"name": "lookup", "parameters": {"type": "object"}}
1010 }]
1011 },
1012 {"role": "user", "content": "Look this up"}
1013 ]));
1014 request
1015 .args
1016 .insert("thinking".to_string(), Value::Bool(false));
1017
1018 let rendered = fmt().render(&request).unwrap();
1019
1020 assert!(rendered.contains("## New Tools Available"));
1021 assert!(
1022 rendered.contains("<|open|>message role=\"system\"<|sep|>Use the newly available tool")
1023 );
1024 }
1025
1026 #[test]
1027 fn rejects_unsupported_message_roles() {
1028 for role in ["function", "unknown"] {
1029 let request = Request::new(json!([{"role": role, "content": "ignored before"}]));
1030
1031 let error = fmt().render(&request).unwrap_err();
1032
1033 assert!(matches!(
1034 error.downcast_ref::<PromptRenderError>(),
1035 Some(PromptRenderError::InvalidRequest(message))
1036 if message == &format!("Kimi K3 does not support message role {role:?}")
1037 ));
1038 }
1039 }
1040
1041 #[test]
1042 fn rejects_messages_without_a_string_role() {
1043 for messages in [json!([{"content": "missing"}]), json!([{"role": 7}])] {
1044 let request = Request::new(messages);
1045
1046 let error = fmt().render(&request).unwrap_err();
1047
1048 assert!(matches!(
1049 error.downcast_ref::<PromptRenderError>(),
1050 Some(PromptRenderError::InvalidRequest(message))
1051 if message == "Kimi K3 messages must contain a string role"
1052 ));
1053 }
1054 }
1055
1056 #[test]
1057 fn rejects_unsupported_thinking_effort_as_invalid_request() {
1058 let mut request = Request::new(json!([{"role": "user", "content": "Hello"}]));
1059 request.args.insert(
1060 "thinking_effort".to_string(),
1061 Value::String("medium".to_string()),
1062 );
1063
1064 let error = fmt().render(&request).unwrap_err();
1065 assert!(matches!(
1066 error.downcast_ref::<PromptRenderError>(),
1067 Some(PromptRenderError::InvalidRequest(message))
1068 if message.contains("thinking_effort=\"medium\"")
1069 ));
1070 }
1071
1072 #[test]
1073 fn named_tool_choice_forces_tool_and_disables_thinking() {
1074 let mut request = Request::new(json!([
1075 {"role": "user", "content": "What did you do before?"},
1076 {
1077 "role": "assistant",
1078 "reasoning_content": "historical hidden reasoning",
1079 "content": "I answered the earlier question."
1080 },
1081 {"role": "user", "content": "Calculate"}
1082 ]));
1083 request.tools = Some(json!([{
1084 "type": "function",
1085 "function": {
1086 "name": "add_numbers",
1087 "parameters": {
1088 "type": "object",
1089 "properties": {
1090 "a": {"type": "integer"},
1091 "b": {"type": "integer"}
1092 },
1093 "required": ["a", "b"]
1094 }
1095 }
1096 }]));
1097 request.tool_choice = Some(json!({
1098 "type": "function",
1099 "function": {"name": "add_numbers"}
1100 }));
1101 request
1102 .args
1103 .insert("thinking".to_string(), Value::Bool(true));
1104
1105 let rendered = fmt().render(&request).unwrap();
1106 assert!(rendered.contains("The system is invoked with `tool_choice=specified`."));
1107 assert!(rendered.contains("MUST call the tool `add_numbers`"));
1108 assert!(
1109 rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>response<|sep|>"),
1110 "named tool choice must use K3's non-thinking generation prefix"
1111 );
1112 assert!(
1113 !rendered.contains("<|open|>think<|sep|>"),
1114 "named tool choice must override thinking=true"
1115 );
1116 assert!(
1117 !rendered.contains("historical hidden reasoning"),
1118 "named tool choice must also suppress preserved thinking history"
1119 );
1120 }
1121
1122 #[test]
1123 fn named_tool_choice_rejects_a_tool_not_in_tools() {
1124 let mut request = Request::new(json!([{"role": "user", "content": "Calculate"}]));
1125 request.tools = Some(json!([{
1126 "type": "function",
1127 "function": {"name": "add_numbers", "parameters": {"type": "object"}}
1128 }]));
1129 request.tool_choice = Some(json!({
1130 "type": "function",
1131 "function": {"name": "get_weather"}
1132 }));
1133
1134 let error = fmt().render(&request).unwrap_err();
1135 assert!(matches!(
1136 error.downcast_ref::<PromptRenderError>(),
1137 Some(PromptRenderError::InvalidRequest(message))
1138 if message.contains("get_weather") && message.contains("not present in tools")
1139 ));
1140 }
1141
1142 #[test]
1143 fn user_marker_text_remains_an_ordinary_segment() {
1144 let marker = "literal <|open|>tools<|sep|> value";
1145 let mut request = Request::new(json!([{"role": "user", "content": marker}]));
1146 request
1147 .args
1148 .insert("thinking".to_string(), Value::Bool(false));
1149 let rendered = fmt().render_prompt(&request).unwrap();
1150
1151 assert!(
1152 rendered
1153 .segments()
1154 .unwrap()
1155 .iter()
1156 .any(|segment| { !segment.allow_special && segment.text == marker })
1157 );
1158 assert!(
1159 rendered
1160 .segments()
1161 .unwrap()
1162 .iter()
1163 .any(|segment| { segment.allow_special && segment.text == OPEN_TOKEN })
1164 );
1165 }
1166
1167 #[test]
1168 fn renders_tool_history_like_model_encoding() {
1169 let mut request = Request::new(json!([
1170 {"role": "user", "content": "calc"},
1171 {
1172 "role": "assistant",
1173 "reasoning_content": "Need calc",
1174 "content": "I will call it",
1175 "tool_calls": [{
1176 "id": "call_1",
1177 "type": "function",
1178 "function": {"name": "calc", "arguments": "{\"x\":2}"}
1179 }]
1180 },
1181 {"role": "tool", "tool_call_id": "call_1", "content": "4"}
1182 ]));
1183 request.args.insert(
1184 "thinking_effort".to_string(),
1185 Value::String("low".to_string()),
1186 );
1187 let rendered = fmt().render(&request).unwrap();
1188
1189 assert!(rendered.contains(
1190 "<|open|>call tool=\"calc\" index=\"1\"<|sep|>\
1191 <|open|>argument key=\"x\" type=\"number\"<|sep|>2\
1192 <|close|>argument<|sep|><|close|>call<|sep|>"
1193 ));
1194 assert!(
1195 rendered.contains("<|open|>message role=\"tool\" tool=\"calc\" index=\"1\"<|sep|>4")
1196 );
1197 assert!(
1198 rendered.ends_with("<|open|>message role=\"assistant\"<|sep|><|open|>think<|sep|>")
1199 );
1200 }
1201
1202 #[test]
1203 fn thinking_history_renders_an_empty_think_channel() {
1204 let request = Request::new(json!([
1205 {"role": "user", "content": "question"},
1206 {"role": "assistant", "content": "answer"},
1207 {"role": "user", "content": "follow-up"}
1208 ]));
1209
1210 let rendered = fmt().render(&request).unwrap();
1211
1212 assert!(rendered.contains(concat!(
1213 "<|open|>message role=\"assistant\"<|sep|>",
1214 "<|open|>think<|sep|><|close|>think<|sep|>",
1215 "<|open|>response<|sep|>answer<|close|>response<|sep|>"
1216 )));
1217 }
1218
1219 #[test]
1220 fn non_thinking_history_omits_preserved_reasoning() {
1221 let mut request = Request::new(json!([
1222 {"role": "user", "content": "question"},
1223 {
1224 "role": "assistant",
1225 "reasoning_content": "hidden reasoning",
1226 "content": "answer"
1227 },
1228 {"role": "user", "content": "follow-up"}
1229 ]));
1230 request
1231 .args
1232 .insert("thinking".to_string(), Value::Bool(false));
1233
1234 let rendered = fmt().render(&request).unwrap();
1235
1236 assert!(!rendered.contains("hidden reasoning"));
1237 assert!(!rendered.contains("<|open|>think<|sep|>"));
1238 assert!(rendered.contains(concat!(
1239 "<|open|>message role=\"assistant\"<|sep|>",
1240 "<|open|>response<|sep|>answer<|close|>response<|sep|>"
1241 )));
1242 }
1243
1244 #[test]
1245 fn tools_are_deep_sorted_before_declaration() {
1246 let mut request = Request::new(json!([{"role": "user", "content": "Weather?"}]));
1247 request
1248 .args
1249 .insert("thinking".to_string(), Value::Bool(false));
1250 request.tools = Some(json!([{
1251 "type": "function",
1252 "function": {
1253 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
1254 "name": "weather",
1255 "description": "Get weather"
1256 }
1257 }]));
1258 let rendered = fmt().render(&request).unwrap();
1259 assert!(rendered.contains(concat!(
1260 "[{\"function\":{\"description\":\"Get weather\",",
1261 "\"name\":\"weather\",\"parameters\":{\"properties\":",
1262 "{\"city\":{\"type\":\"string\"}},\"type\":\"object\"}},",
1263 "\"type\":\"function\"}]"
1264 )));
1265 }
1266
1267 #[test]
1268 fn assistant_history_matches_python_json_spacing_and_reasoning_fallback() {
1269 let request = Request::new(json!([{
1270 "role": "assistant",
1271 "reasoning_content": "",
1272 "reasoning": "fallback",
1273 "content": null,
1274 "tool_calls": [{
1275 "type": "function",
1276 "function": {
1277 "name": "run",
1278 "arguments": {
1279 "opts": {"a": 1, "b": [true, false]}
1280 }
1281 }
1282 }]
1283 }]));
1284 let rendered = fmt().render(&request).unwrap();
1285
1286 assert!(rendered.contains("<|open|>think<|sep|>fallback<|close|>think<|sep|>"));
1287 assert!(rendered.contains(concat!(
1288 "<|open|>argument key=\"opts\" type=\"object\"<|sep|>",
1289 "{\"a\": 1, \"b\": [true, false]}",
1290 "<|close|>argument<|sep|>"
1291 )));
1292 }
1293}