1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use super::model_allowlist::{KIMI_DEFAULT_MODEL, assert_allowed_model, is_k3, resolve_model};
5use crate::anthropic::schema::MessagesRequest;
6use crate::providers::translate_shared::{
7 ContentBlock, flatten_system_text, image_block_to_url, image_source_to_url, normalize_content,
8 parallel_tool_calls, read_effort,
9};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct KimiChatRequest {
17 pub model: String,
18 pub messages: Vec<KimiMessage>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub tools: Option<Vec<KimiTool>>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub tool_choice: Option<KimiToolChoice>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub parallel_tool_calls: Option<bool>,
25 pub stream: bool,
26 pub stream_options: KimiStreamOptions,
27 pub max_tokens: u32,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub reasoning_effort: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub thinking: Option<KimiThinking>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub prompt_cache_key: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct KimiStreamOptions {
38 pub include_usage: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct KimiThinking {
43 #[serde(rename = "type")]
44 pub kind: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(untagged)]
49pub enum KimiToolChoice {
50 Auto,
51 None,
52 Required,
53 Function {
54 #[serde(rename = "type")]
55 kind: String,
56 function: KimiToolChoiceFunction,
57 },
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct KimiToolChoiceFunction {
62 pub name: String,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(untagged)]
67pub enum KimiMessage {
68 System {
69 role: String,
70 content: String,
71 },
72 User {
73 role: String,
74 content: serde_json::Value,
75 },
76 Assistant {
77 role: String,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 content: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 reasoning_content: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 tool_calls: Option<Vec<KimiAssistantToolCall>>,
84 },
85 Tool {
86 role: String,
87 tool_call_id: String,
88 content: serde_json::Value,
89 },
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct KimiAssistantToolCall {
94 pub id: String,
95 #[serde(rename = "type")]
96 pub kind: String,
97 pub function: KimiToolCallFunction,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct KimiToolCallFunction {
102 pub name: String,
103 pub arguments: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct KimiTool {
108 #[serde(rename = "type")]
109 pub kind: String,
110 pub function: KimiToolFunction,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct KimiToolFunction {
115 pub name: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub description: Option<String>,
118 pub parameters: serde_json::Value,
119}
120
121pub struct TranslateOptions {
122 pub session_id: Option<String>,
123}
124
125const DEFAULT_MAX_TOKENS: u32 = 32000;
126
127pub fn translate_request(
132 req: &MessagesRequest,
133 opts: TranslateOptions,
134) -> Result<KimiChatRequest, anyhow::Error> {
135 let model = req.model.as_deref().unwrap_or(KIMI_DEFAULT_MODEL);
136 let resolved = resolve_model(model);
137 assert_allowed_model(&resolved).map_err(|e| anyhow::anyhow!("{e}"))?;
138
139 let k3 = is_k3(&resolved);
140 let messages = build_messages(req, &resolved)?;
141 let tools = read_tools(req)?;
142 let tool_choice = read_tool_choice(req)?;
143
144 let mut out = KimiChatRequest {
145 model: resolved,
146 messages,
147 stream: true,
148 stream_options: KimiStreamOptions {
149 include_usage: true,
150 },
151 max_tokens: clamp_max_tokens(req.max_tokens, k3),
152 reasoning_effort: Some(map_reasoning_effort(read_effort(req)?, k3)),
153 thinking: Some(KimiThinking {
154 kind: "enabled".to_string(),
155 }),
156 tools: if tools.is_empty() { None } else { Some(tools) },
157 tool_choice,
158 parallel_tool_calls: parallel_tool_calls(req),
159 prompt_cache_key: opts.session_id,
160 };
161
162 if matches!(out.tool_choice, Some(KimiToolChoice::Auto)) {
164 out.tool_choice = None;
165 }
166
167 Ok(out)
168}
169
170fn clamp_max_tokens(requested: Option<u32>, k3: bool) -> u32 {
171 if k3 {
172 requested.filter(|v| *v > 0).unwrap_or(1_048_576)
176 } else {
177 match requested {
178 Some(v) if v > 0 => v.min(DEFAULT_MAX_TOKENS),
179 _ => DEFAULT_MAX_TOKENS,
180 }
181 }
182}
183
184fn map_reasoning_effort(effort: Option<&str>, k3: bool) -> String {
185 if k3 {
186 match effort {
188 Some("max") => "max".to_string(),
189 Some("xhigh" | "high") => "high".to_string(),
190 Some("low") => "low".to_string(),
191 _ => "high".to_string(),
192 }
193 } else {
194 match effort {
195 Some("max" | "xhigh") => "high".to_string(),
196 Some(v) => v.to_string(),
197 None => "medium".to_string(),
198 }
199 }
200}
201
202fn map_tool_choice(choice: &serde_json::Map<String, Value>) -> KimiToolChoice {
207 match choice.get("type").and_then(|v| v.as_str()) {
208 Some("auto") => KimiToolChoice::Auto,
209 Some("none") => KimiToolChoice::None,
210 Some("any") => KimiToolChoice::Required,
211 Some("tool") => {
212 if let Some(name) = choice.get("name").and_then(|v| v.as_str()) {
213 KimiToolChoice::Function {
214 kind: "function".to_string(),
215 function: KimiToolChoiceFunction {
216 name: name.to_string(),
217 },
218 }
219 } else {
220 KimiToolChoice::Required
221 }
222 }
223 _ => KimiToolChoice::Auto,
224 }
225}
226
227fn read_tool_choice(req: &MessagesRequest) -> Result<Option<KimiToolChoice>, anyhow::Error> {
228 match req.extra.get("tool_choice") {
229 Some(Value::Object(choice)) => Ok(Some(map_tool_choice(choice))),
230 Some(Value::String(s)) => Ok(Some(match s.as_str() {
231 "auto" => KimiToolChoice::Auto,
232 "none" => KimiToolChoice::None,
233 "any" | "required" => KimiToolChoice::Required,
234 _ => KimiToolChoice::Auto,
235 })),
236 _ => Ok(None),
237 }
238}
239
240fn read_tools(req: &MessagesRequest) -> Result<Vec<KimiTool>, anyhow::Error> {
241 let Some(tools) = req.extra.get("tools") else {
242 return Ok(Vec::new());
243 };
244 let tools_arr = match tools {
245 Value::Array(a) => a,
246 _ => return Ok(Vec::new()),
247 };
248 let mut out = Vec::new();
249 for tool in tools_arr {
250 let name = tool
251 .get("name")
252 .and_then(|v| v.as_str())
253 .unwrap_or("")
254 .to_string();
255 let description = tool
256 .get("description")
257 .and_then(|v| v.as_str())
258 .map(|s| s.to_string());
259 let parameters = tool
260 .get("input_schema")
261 .cloned()
262 .unwrap_or(serde_json::json!({}));
263 out.push(KimiTool {
264 kind: "function".to_string(),
265 function: KimiToolFunction {
266 name,
267 description,
268 parameters,
269 },
270 });
271 }
272 Ok(out)
273}
274
275fn build_messages(req: &MessagesRequest, model: &str) -> Result<Vec<KimiMessage>, anyhow::Error> {
280 let mut out: Vec<KimiMessage> = Vec::new();
281 let k3_mode = is_k3(model);
282
283 let mut k3_system_parts: Vec<String> = Vec::new();
286
287 if let Some(system) = flatten_system_text(req.extra.get("system")) {
289 if k3_mode {
290 k3_system_parts.push(system);
291 } else {
292 out.push(KimiMessage::System {
293 role: "system".to_string(),
294 content: system,
295 });
296 }
297 }
298
299 for msg in &req.messages {
301 let blocks = normalize_content(&msg.content, serde_json::json!({}));
302 match msg.role.as_str() {
303 "user" => {
304 if k3_mode && !k3_system_parts.is_empty() {
305 let system_prefix = k3_system_parts.join("\n\n");
306 k3_system_parts.clear();
307
308 for block in &blocks {
310 if let ContentBlock::ToolResult {
311 tool_use_id,
312 content,
313 is_error,
314 } = block
315 {
316 out.push(KimiMessage::Tool {
317 role: "tool".to_string(),
318 tool_call_id: tool_use_id.clone(),
319 content: tool_result_content(content, *is_error),
320 });
321 }
322 }
323
324 let has_text = blocks
326 .iter()
327 .any(|b| matches!(b, ContentBlock::Text { .. }));
328 let has_images = blocks
329 .iter()
330 .any(|b| matches!(b, ContentBlock::Image { .. }));
331
332 if has_images {
333 let mut parts: Vec<KimiUserContentPart> = Vec::new();
335 parts.push(KimiUserContentPart::Text {
336 text: system_prefix,
337 });
338 for block in &blocks {
339 match block {
340 ContentBlock::Text { text } if !text.is_empty() => {
341 parts.push(KimiUserContentPart::Text { text: text.clone() });
342 }
343 ContentBlock::Image { source } => {
344 parts.push(KimiUserContentPart::ImageUrl {
345 image_url: KimiImageUrl {
346 url: image_source_to_url(source),
347 },
348 });
349 }
350 _ => {}
351 }
352 }
353 out.push(KimiMessage::User {
354 role: "user".to_string(),
355 content: serde_json::to_value(parts).unwrap_or_default(),
356 });
357 } else if has_text {
358 let user_text: String = blocks
359 .iter()
360 .filter_map(|b| match b {
361 ContentBlock::Text { text } => Some(text.as_str()),
362 _ => None,
363 })
364 .collect::<Vec<_>>()
365 .join("");
366 let merged = format!("{system_prefix}\n\n{user_text}");
367 out.push(KimiMessage::User {
368 role: "user".to_string(),
369 content: serde_json::Value::String(merged),
370 });
371 } else {
372 out.push(KimiMessage::User {
374 role: "user".to_string(),
375 content: serde_json::Value::String(system_prefix),
376 });
377 }
378 } else {
379 push_user_messages(&mut out, &blocks);
380 }
381 }
382 "assistant" => push_assistant_message(&mut out, &blocks),
383 "system" | "developer" => {
384 let text = blocks
385 .iter()
386 .filter_map(|b| match b {
387 ContentBlock::Text { text } => Some(text.as_str()),
388 _ => None,
389 })
390 .collect::<Vec<_>>()
391 .join("\n");
392 if !text.is_empty() {
393 if k3_mode {
394 k3_system_parts.push(text);
395 } else {
396 out.push(KimiMessage::System {
397 role: "system".to_string(),
398 content: text,
399 });
400 }
401 }
402 }
403 other => {
404 anyhow::bail!("unexpected message role: {other}");
405 }
406 }
407 }
408
409 if k3_mode && !k3_system_parts.is_empty() {
411 let text = k3_system_parts.join("\n\n");
412 out.push(KimiMessage::User {
413 role: "user".to_string(),
414 content: serde_json::Value::String(text),
415 });
416 }
417
418 Ok(out)
419}
420
421fn push_user_messages(out: &mut Vec<KimiMessage>, blocks: &[ContentBlock]) {
422 let mut buffer: Vec<KimiUserContentPart> = Vec::new();
423
424 let flush_buffer = |out: &mut Vec<KimiMessage>, buffer: &mut Vec<KimiUserContentPart>| {
425 if buffer.is_empty() {
426 return;
427 }
428 let all_text = buffer
429 .iter()
430 .all(|p| matches!(p, KimiUserContentPart::Text { .. }));
431 if all_text {
432 let joined: String = buffer
433 .iter()
434 .map(|p| match p {
435 KimiUserContentPart::Text { text } => text.as_str(),
436 _ => "",
437 })
438 .collect();
439 out.push(KimiMessage::User {
440 role: "user".to_string(),
441 content: Value::String(joined),
442 });
443 } else {
444 let parts: Vec<KimiUserContentPart> = std::mem::take(buffer);
445 out.push(KimiMessage::User {
446 role: "user".to_string(),
447 content: serde_json::to_value(parts).unwrap_or_default(),
448 });
449 return;
450 }
451 buffer.clear();
452 };
453
454 for block in blocks {
455 match block {
456 ContentBlock::Text { text } => {
457 buffer.push(KimiUserContentPart::Text { text: text.clone() });
458 }
459 ContentBlock::Image { source } => {
460 buffer.push(KimiUserContentPart::ImageUrl {
461 image_url: KimiImageUrl {
462 url: image_source_to_url(source),
463 },
464 });
465 }
466 ContentBlock::ToolResult {
467 tool_use_id,
468 content,
469 is_error,
470 } => {
471 let mut temp = Vec::new();
473 std::mem::swap(&mut buffer, &mut temp);
474 flush_buffer(out, &mut temp);
475
476 out.push(KimiMessage::Tool {
477 role: "tool".to_string(),
478 tool_call_id: tool_use_id.clone(),
479 content: tool_result_content(content, *is_error),
480 });
481 }
482 _ => {}
483 }
484 }
485
486 flush_buffer(out, &mut buffer);
488}
489
490#[derive(Debug, Clone, Serialize)]
491#[serde(tag = "type", rename_all = "snake_case")]
492enum KimiUserContentPart {
493 Text { text: String },
494 ImageUrl { image_url: KimiImageUrl },
495}
496
497#[derive(Debug, Clone, Serialize)]
498struct KimiImageUrl {
499 url: String,
500}
501
502fn tool_result_content(content: &Value, is_error: Option<bool>) -> Value {
503 let prefix = if is_error.unwrap_or(false) {
504 "[tool execution error]\n"
505 } else {
506 ""
507 };
508
509 match content {
510 Value::String(s) => Value::String(format!("{prefix}{s}")),
511 Value::Array(arr) => {
512 let mut parts: Vec<KimiToolResultPart> = Vec::new();
513 if !prefix.is_empty() {
514 parts.push(KimiToolResultPart::Text {
515 text: prefix.to_string(),
516 });
517 }
518 for b in arr {
519 match b.get("type").and_then(|v| v.as_str()) {
520 Some("text") => {
521 let text = b.get("text").and_then(|v| v.as_str()).unwrap_or("");
522 parts.push(KimiToolResultPart::Text {
523 text: text.to_string(),
524 });
525 }
526 Some("image") => {
527 let url = image_block_to_url(b);
528 parts.push(KimiToolResultPart::ImageUrl {
529 image_url: KimiImageUrl { url },
530 });
531 }
532 Some(other) => {
533 parts.push(KimiToolResultPart::Text {
534 text: format!("[unsupported content block omitted: {other}]"),
535 });
536 }
537 None => {}
538 }
539 }
540
541 if parts.len() == 1
543 && let KimiToolResultPart::Text { text } = &parts[0]
544 {
545 return Value::String(text.clone());
546 }
547
548 serde_json::to_value(parts).unwrap_or(Value::String(prefix.to_string()))
549 }
550 _ => Value::String(prefix.to_string()),
551 }
552}
553
554#[derive(Debug, Clone, Serialize)]
555#[serde(tag = "type", rename_all = "snake_case")]
556enum KimiToolResultPart {
557 Text { text: String },
558 ImageUrl { image_url: KimiImageUrl },
559}
560
561fn push_assistant_message(out: &mut Vec<KimiMessage>, blocks: &[ContentBlock]) {
562 let mut text_parts: Vec<String> = Vec::new();
563 let mut thinking_parts: Vec<String> = Vec::new();
564 let mut tool_calls: Vec<KimiAssistantToolCall> = Vec::new();
565
566 for block in blocks {
567 match block {
568 ContentBlock::Text { text } => {
569 if !text.is_empty() {
570 text_parts.push(text.clone());
571 }
572 }
573 ContentBlock::Thinking { thinking, .. } => {
574 if !thinking.is_empty() {
575 thinking_parts.push(thinking.clone());
576 }
577 }
578 ContentBlock::ToolUse { id, name, input } => {
579 let args = serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string());
580 tool_calls.push(KimiAssistantToolCall {
581 id: id.clone(),
582 kind: "function".to_string(),
583 function: KimiToolCallFunction {
584 name: name.clone(),
585 arguments: args,
586 },
587 });
588 }
589 _ => {}
591 }
592 }
593
594 if text_parts.is_empty() && tool_calls.is_empty() && thinking_parts.is_empty() {
595 return;
596 }
597
598 let content = if text_parts.is_empty() {
599 Some(String::new())
600 } else {
601 Some(text_parts.join(""))
602 };
603
604 let reasoning_content = if thinking_parts.is_empty() {
605 None
606 } else {
607 Some(thinking_parts.join("\n\n"))
608 };
609
610 let tool_calls_val = if tool_calls.is_empty() {
611 None
612 } else {
613 Some(tool_calls)
614 };
615
616 out.push(KimiMessage::Assistant {
617 role: "assistant".to_string(),
618 content,
619 reasoning_content,
620 tool_calls: tool_calls_val,
621 });
622}
623
624#[cfg(test)]
629mod tests {
630 use super::*;
631 use serde_json::json;
632
633 #[test]
634 fn translate_text_request_defaults_like_reference() {
635 let req: MessagesRequest = serde_json::from_value(json!({
636 "model": "haiku",
637 "max_tokens": 10,
638 "system": "sys",
639 "messages": [{"role": "user", "content": "hello"}],
640 "tools": [{"name":"search","description":"Search","input_schema":{"type":"object"}}],
641 "tool_choice": {"type":"tool", "name":"search"},
642 "output_config": {"effort":"max"}
643 }))
644 .unwrap();
645 let translated = translate_request(
646 &req,
647 TranslateOptions {
648 session_id: Some("sid".into()),
649 },
650 )
651 .unwrap();
652 assert_eq!(translated.model, "kimi-for-coding");
653 assert_eq!(translated.reasoning_effort.as_deref(), Some("high"));
654 assert_eq!(translated.prompt_cache_key.as_deref(), Some("sid"));
655 assert_eq!(translated.max_tokens, 10);
656 }
657
658 #[test]
659 fn translate_tool_result_with_unsupported_blocks() {
660 let req: MessagesRequest = serde_json::from_value(json!({
661 "model": "kimi-k2",
662 "messages": [{
663 "role": "user",
664 "content": [{
665 "type": "tool_result",
666 "tool_use_id": "toolu_1",
667 "content": [
668 {"type": "text", "text": "visible output"},
669 {"type": "thinking", "thinking": "hidden thought"}
670 ]
671 }]
672 }]
673 }))
674 .unwrap();
675 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
676 assert_eq!(translated.messages.len(), 1);
678 match &translated.messages[0] {
679 KimiMessage::Tool {
680 role,
681 tool_call_id,
682 content,
683 } => {
684 assert_eq!(role, "tool");
685 assert_eq!(tool_call_id, "toolu_1");
686 let parts: Vec<&Value> = match content {
688 Value::Array(a) => a.iter().collect(),
689 _ => panic!("expected array content"),
690 };
691 assert_eq!(parts.len(), 2);
692 assert_eq!(
693 parts[0].get("text").and_then(|v| v.as_str()),
694 Some("visible output")
695 );
696 assert_eq!(
697 parts[1].get("text").and_then(|v| v.as_str()),
698 Some("[unsupported content block omitted: thinking]")
699 );
700 }
701 _ => panic!("expected Tool message"),
702 }
703 }
704
705 #[test]
706 fn translate_tool_result_with_image() {
707 let req: MessagesRequest = serde_json::from_value(json!({
708 "model": "kimi-k2",
709 "messages": [{
710 "role": "user",
711 "content": [{
712 "type": "tool_result",
713 "tool_use_id": "toolu_1",
714 "content": [
715 {"type": "text", "text": "caption"},
716 {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc"}}
717 ]
718 }]
719 }]
720 }))
721 .unwrap();
722 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
723 assert_eq!(translated.messages.len(), 1);
724 match &translated.messages[0] {
725 KimiMessage::Tool {
726 role,
727 tool_call_id,
728 content,
729 } => {
730 assert_eq!(role, "tool");
731 assert_eq!(tool_call_id, "toolu_1");
732 let parts: Vec<&Value> = match content {
733 Value::Array(a) => a.iter().collect(),
734 _ => panic!("expected array"),
735 };
736 assert_eq!(parts.len(), 2);
737 assert_eq!(
738 parts[1]
739 .get("image_url")
740 .and_then(|u| u.get("url"))
741 .and_then(|v| v.as_str()),
742 Some("data:image/png;base64,abc")
743 );
744 }
745 _ => panic!("expected Tool message"),
746 }
747 }
748
749 #[test]
750 fn translate_assistant_with_thinking_tool_use_and_text() {
751 let req: MessagesRequest = serde_json::from_value(json!({
752 "model": "kimi-for-coding",
753 "messages": [{
754 "role": "assistant",
755 "content": [
756 {"type": "thinking", "thinking": "let me think..."},
757 {"type": "text", "text": "here's the answer"},
758 {"type": "tool_use", "id": "tu_1", "name": "search", "input": {"q": "rust"}}
759 ]
760 }]
761 }))
762 .unwrap();
763 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
764 assert_eq!(translated.messages.len(), 1);
765 match &translated.messages[0] {
766 KimiMessage::Assistant {
767 role,
768 content,
769 reasoning_content,
770 tool_calls,
771 } => {
772 assert_eq!(role, "assistant");
773 assert_eq!(content.as_deref(), Some("here's the answer"));
774 assert_eq!(reasoning_content.as_deref(), Some("let me think..."));
775 assert!(tool_calls.is_some());
776 let tcs = tool_calls.as_ref().unwrap();
777 assert_eq!(tcs.len(), 1);
778 assert_eq!(tcs[0].function.name, "search");
779 }
780 _ => panic!("expected Assistant message"),
781 }
782 }
783
784 #[test]
785 fn translate_empty_assistant_content_emits_empty_string() {
786 let req: MessagesRequest = serde_json::from_value(json!({
788 "model": "kimi-for-coding",
789 "messages": [{
790 "role": "assistant",
791 "content": [
792 {"type": "thinking", "thinking": "thinking..."}
793 ]
794 }]
795 }))
796 .unwrap();
797 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
798 assert_eq!(translated.messages.len(), 1);
799 match &translated.messages[0] {
800 KimiMessage::Assistant {
801 content,
802 reasoning_content,
803 ..
804 } => {
805 assert_eq!(content.as_deref(), Some(""));
806 assert_eq!(reasoning_content.as_deref(), Some("thinking..."));
807 }
808 _ => panic!("expected Assistant message"),
809 }
810 }
811
812 #[test]
813 fn translate_user_text_and_image_collapse_correctly() {
814 let req: MessagesRequest = serde_json::from_value(json!({
815 "model": "kimi-for-coding",
816 "messages": [{
817 "role": "user",
818 "content": [
819 {"type": "text", "text": "describe this"},
820 {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "xyz"}}
821 ]
822 }]
823 }))
824 .unwrap();
825 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
826 assert_eq!(translated.messages.len(), 1);
827 match &translated.messages[0] {
828 KimiMessage::User { role, content } => {
829 assert_eq!(role, "user");
830 assert!(content.is_array());
832 let parts = content.as_array().unwrap();
833 assert_eq!(parts.len(), 2);
834 assert_eq!(
835 parts[0].get("text").and_then(|v| v.as_str()),
836 Some("describe this")
837 );
838 assert!(parts[1].get("image_url").is_some());
839 }
840 _ => panic!("expected User message"),
841 }
842 }
843
844 #[test]
845 fn user_content_parts_carry_a_type_discriminant() {
846 let req: MessagesRequest = serde_json::from_value(json!({
847 "model": "kimi-for-coding",
848 "messages": [{
849 "role": "user",
850 "content": [
851 {"type": "text", "text": "describe this"},
852 {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "xyz"}}
853 ]
854 }]
855 }))
856 .unwrap();
857 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
858 match &translated.messages[0] {
859 KimiMessage::User { content, .. } => {
860 let parts = content.as_array().unwrap();
861 assert_eq!(parts[0].get("type").and_then(|v| v.as_str()), Some("text"));
862 assert_eq!(
863 parts[1].get("type").and_then(|v| v.as_str()),
864 Some("image_url")
865 );
866 }
867 _ => panic!("expected User message"),
868 }
869 }
870
871 #[test]
872 fn tool_result_parts_carry_a_type_discriminant() {
873 let req: MessagesRequest = serde_json::from_value(json!({
874 "model": "kimi-for-coding",
875 "messages": [{
876 "role": "user",
877 "content": [{
878 "type": "tool_result",
879 "tool_use_id": "toolu_1",
880 "content": [
881 {"type": "text", "text": "caption"},
882 {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc"}}
883 ]
884 }]
885 }]
886 }))
887 .unwrap();
888 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
889 match &translated.messages[0] {
890 KimiMessage::Tool { content, .. } => {
891 let parts = content.as_array().unwrap();
892 assert_eq!(parts[0].get("type").and_then(|v| v.as_str()), Some("text"));
893 assert_eq!(
894 parts[1].get("type").and_then(|v| v.as_str()),
895 Some("image_url")
896 );
897 }
898 _ => panic!("expected Tool message"),
899 }
900 }
901
902 #[test]
903 fn translate_text_only_user_collapses_to_string() {
904 let req: MessagesRequest = serde_json::from_value(json!({
905 "model": "kimi-for-coding",
906 "messages": [{
907 "role": "user",
908 "content": "hello"
909 }]
910 }))
911 .unwrap();
912 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
913 match &translated.messages[0] {
914 KimiMessage::User { role, content } => {
915 assert_eq!(role, "user");
916 assert_eq!(content.as_str(), Some("hello"));
917 }
918 _ => panic!("expected User message"),
919 }
920 }
921
922 #[test]
923 fn max_tokens_defaults_to_32000() {
924 let req: MessagesRequest = serde_json::from_value(json!({
925 "model": "kimi-for-coding",
926 "messages": [{"role": "user", "content": "hi"}]
927 }))
928 .unwrap();
929 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
930 assert_eq!(translated.max_tokens, 32000);
931 }
932
933 #[test]
934 fn max_tokens_clamps_at_32000() {
935 let req: MessagesRequest = serde_json::from_value(json!({
936 "model": "kimi-for-coding",
937 "max_tokens": 99999,
938 "messages": [{"role": "user", "content": "hi"}]
939 }))
940 .unwrap();
941 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
942 assert_eq!(translated.max_tokens, 32000);
943 }
944
945 #[test]
946 fn invalid_effort_rejected() {
947 let req: Result<MessagesRequest, _> = serde_json::from_value(json!({
948 "model": "kimi-for-coding",
949 "messages": [{"role": "user", "content": "hi"}],
950 "output_config": {"effort": "extreme"}
951 }));
952 let req = req.unwrap();
954 let result = translate_request(&req, TranslateOptions { session_id: None });
955 assert!(result.is_err());
956 }
957
958 #[test]
959 fn effort_xhigh_maps_to_high() {
960 let req: MessagesRequest = serde_json::from_value(json!({
961 "model": "kimi-for-coding",
962 "messages": [{"role": "user", "content": "hi"}],
963 "output_config": {"effort": "xhigh"}
964 }))
965 .unwrap();
966 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
967 assert_eq!(translated.reasoning_effort.as_deref(), Some("high"));
968 }
969
970 #[test]
971 fn auto_tool_choice_is_collapsed() {
972 let req: MessagesRequest = serde_json::from_value(json!({
973 "model": "kimi-for-coding",
974 "messages": [{"role": "user", "content": "hi"}],
975 "tool_choice": {"type": "auto"}
976 }))
977 .unwrap();
978 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
979 assert!(translated.tool_choice.is_none());
980 }
981
982 #[test]
983 fn any_tool_choice_becomes_required() {
984 let req: MessagesRequest = serde_json::from_value(json!({
985 "model": "kimi-for-coding",
986 "messages": [{"role": "user", "content": "hi"}],
987 "tools": [{"name":"search","input_schema":{"type":"object"}}],
988 "tool_choice": {"type": "any"}
989 }))
990 .unwrap();
991 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
992 assert!(matches!(
993 translated.tool_choice,
994 Some(KimiToolChoice::Required)
995 ));
996 }
997
998 #[test]
999 fn system_text_excludes_billing_headers() {
1000 let req: MessagesRequest = serde_json::from_value(json!({
1001 "model": "kimi-for-coding",
1002 "system": [
1003 {"type": "text", "text": "You are a helpful assistant."},
1004 {"type": "text", "text": "x-anthropic-billing-header: secret"}
1005 ],
1006 "messages": [{"role": "user", "content": "hi"}]
1007 }))
1008 .unwrap();
1009 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
1010 let system_msg = translated
1011 .messages
1012 .iter()
1013 .find(|m| matches!(m, KimiMessage::System { .. }));
1014 assert!(system_msg.is_some());
1015 if let Some(KimiMessage::System { content, .. }) = system_msg {
1016 assert!(!content.contains("x-anthropic-billing-header"));
1017 assert!(content.contains("helpful"));
1018 }
1019 }
1020
1021 #[test]
1022 fn k3_system_merged_into_user() {
1023 let req: MessagesRequest = serde_json::from_value(json!({
1024 "model": "kimi-k3",
1025 "messages": [{"role": "user", "content": "hello"}],
1026 "system": "Be helpful."
1027 }))
1028 .unwrap();
1029 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
1030 assert_eq!(translated.model, "k3");
1031 assert!(
1032 !translated
1033 .messages
1034 .iter()
1035 .any(|m| matches!(m, KimiMessage::System { .. }))
1036 );
1037 match &translated.messages[0] {
1038 KimiMessage::User { content, .. } => {
1039 let text = content.as_str().unwrap();
1040 assert!(text.contains("Be helpful."));
1041 assert!(text.contains("hello"));
1042 }
1043 _ => panic!("expected User message"),
1044 }
1045 }
1046
1047 #[test]
1048 fn k3_system_with_images_preserves_image_blocks() {
1049 let req: MessagesRequest = serde_json::from_value(json!({
1050 "model": "kimi-k3",
1051 "system": "Describe images.",
1052 "messages": [{"role": "user", "content": [
1053 {"type": "text", "text": "what is this?"},
1054 {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc"}}
1055 ]}]
1056 }))
1057 .unwrap();
1058 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
1059 match &translated.messages[0] {
1060 KimiMessage::User { content, .. } => {
1061 let parts = content
1062 .as_array()
1063 .expect("expected array for mixed content");
1064 assert!(parts.len() >= 3);
1065 assert!(
1066 parts.iter().any(|p| p.get("image_url").is_some()),
1067 "image block missing"
1068 );
1069 let texts: Vec<&str> = parts
1070 .iter()
1071 .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
1072 .collect();
1073 assert!(
1074 texts.iter().any(|t| t.contains("Describe images.")),
1075 "system text missing"
1076 );
1077 assert!(
1078 texts.iter().any(|t| t.contains("what is this?")),
1079 "user text missing"
1080 );
1081 }
1082 _ => panic!("expected User message"),
1083 }
1084 }
1085
1086 #[test]
1087 fn k3_system_not_lost_on_tool_result_only_turn() {
1088 let req: MessagesRequest = serde_json::from_value(json!({
1089 "model": "kimi-k3",
1090 "messages": [
1091 {"role": "user", "content": "do something"},
1092 {"role": "assistant", "content": [
1093 {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"cmd": "ls"}}
1094 ]},
1095 {"role": "system", "content": [{"type": "text", "text": "reminder text"}]},
1096 {"role": "user", "content": [
1097 {"type": "tool_result", "tool_use_id": "t1", "content": "output"}
1098 ]}
1099 ]
1100 }))
1101 .unwrap();
1102 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
1103 let user_msgs: Vec<&KimiMessage> = translated
1104 .messages
1105 .iter()
1106 .filter(|m| matches!(m, KimiMessage::User { .. }))
1107 .collect();
1108 let all_text: String = user_msgs
1109 .iter()
1110 .map(|m| match m {
1111 KimiMessage::User { content, .. } => content.as_str().unwrap_or("").to_string(),
1112 _ => String::new(),
1113 })
1114 .collect();
1115 assert!(
1116 all_text.contains("reminder text"),
1117 "system reminder lost on tool-result-only turn"
1118 );
1119 assert!(
1120 translated
1121 .messages
1122 .iter()
1123 .any(|m| matches!(m, KimiMessage::Tool { .. })),
1124 "tool result missing"
1125 );
1126 }
1127
1128 #[test]
1129 fn k3_effort_defaults_to_high() {
1130 let req: MessagesRequest = serde_json::from_value(json!({
1131 "model": "kimi-k3",
1132 "messages": [{"role": "user", "content": "hi"}]
1133 }))
1134 .unwrap();
1135 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
1136 assert_eq!(translated.reasoning_effort.as_deref(), Some("high"));
1137 }
1138
1139 #[test]
1140 fn k3_effort_max_preserved() {
1141 let req: MessagesRequest = serde_json::from_value(json!({
1142 "model": "kimi-k3",
1143 "messages": [{"role": "user", "content": "hi"}],
1144 "output_config": {"effort": "max"}
1145 }))
1146 .unwrap();
1147 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
1148 assert_eq!(translated.reasoning_effort.as_deref(), Some("max"));
1149 }
1150
1151 #[test]
1152 fn k3_max_tokens_passes_through() {
1153 let req: MessagesRequest = serde_json::from_value(json!({
1154 "model": "kimi-k3",
1155 "max_tokens": 500000,
1156 "messages": [{"role": "user", "content": "hi"}]
1157 }))
1158 .unwrap();
1159 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
1160 assert_eq!(translated.max_tokens, 500000);
1161 }
1162
1163 #[test]
1164 fn k3_max_tokens_defaults_to_context_window() {
1165 let req: MessagesRequest = serde_json::from_value(json!({
1166 "model": "kimi-k3",
1167 "messages": [{"role": "user", "content": "hi"}]
1168 }))
1169 .unwrap();
1170 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
1171 assert_eq!(translated.max_tokens, 1_048_576);
1172 }
1173}