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