1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use super::model_allowlist::{KIMI_DEFAULT_MODEL, assert_allowed_model, 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 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 pub stream: bool,
24 pub stream_options: KimiStreamOptions,
25 pub max_tokens: u32,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub reasoning_effort: Option<String>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub thinking: Option<KimiThinking>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub prompt_cache_key: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct KimiStreamOptions {
36 pub include_usage: bool,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct KimiThinking {
41 #[serde(rename = "type")]
42 pub kind: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(untagged)]
47pub enum KimiToolChoice {
48 Auto,
49 None,
50 Required,
51 Function {
52 #[serde(rename = "type")]
53 kind: String,
54 function: KimiToolChoiceFunction,
55 },
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct KimiToolChoiceFunction {
60 pub name: String,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(untagged)]
65pub enum KimiMessage {
66 System {
67 role: String,
68 content: String,
69 },
70 User {
71 role: String,
72 content: serde_json::Value,
73 },
74 Assistant {
75 role: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 content: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 reasoning_content: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 tool_calls: Option<Vec<KimiAssistantToolCall>>,
82 },
83 Tool {
84 role: String,
85 tool_call_id: String,
86 content: serde_json::Value,
87 },
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct KimiAssistantToolCall {
92 pub id: String,
93 #[serde(rename = "type")]
94 pub kind: String,
95 pub function: KimiToolCallFunction,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct KimiToolCallFunction {
100 pub name: String,
101 pub arguments: String,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct KimiTool {
106 #[serde(rename = "type")]
107 pub kind: String,
108 pub function: KimiToolFunction,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct KimiToolFunction {
113 pub name: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub description: Option<String>,
116 pub parameters: serde_json::Value,
117}
118
119pub struct TranslateOptions {
120 pub session_id: Option<String>,
121}
122
123const DEFAULT_MAX_TOKENS: u32 = 32000;
124
125pub fn translate_request(
130 req: &MessagesRequest,
131 opts: TranslateOptions,
132) -> Result<KimiChatRequest, anyhow::Error> {
133 let model = req.model.as_deref().unwrap_or(KIMI_DEFAULT_MODEL);
134 let resolved = resolve_model(model);
135 assert_allowed_model(&resolved).map_err(|e| anyhow::anyhow!("{e}"))?;
136
137 let messages = build_messages(req)?;
138 let tools = read_tools(req)?;
139 let tool_choice = read_tool_choice(req)?;
140
141 let mut out = KimiChatRequest {
142 model: resolved,
143 messages,
144 stream: true,
145 stream_options: KimiStreamOptions {
146 include_usage: true,
147 },
148 max_tokens: clamp_max_tokens(req.max_tokens),
149 reasoning_effort: Some(map_reasoning_effort(read_effort(req)?)),
150 thinking: Some(KimiThinking {
151 kind: "enabled".to_string(),
152 }),
153 tools: if tools.is_empty() { None } else { Some(tools) },
154 tool_choice,
155 prompt_cache_key: opts.session_id,
156 };
157
158 if matches!(out.tool_choice, Some(KimiToolChoice::Auto)) {
160 out.tool_choice = None;
161 }
162
163 Ok(out)
164}
165
166fn clamp_max_tokens(requested: Option<u32>) -> u32 {
167 match requested {
168 Some(v) if v > 0 => v.min(DEFAULT_MAX_TOKENS),
169 _ => DEFAULT_MAX_TOKENS,
170 }
171}
172
173fn map_reasoning_effort(effort: Option<&str>) -> String {
174 match effort {
175 Some("max" | "xhigh") => "high".to_string(),
176 Some(v) => v.to_string(),
177 None => "medium".to_string(),
178 }
179}
180
181fn map_tool_choice(choice: &serde_json::Map<String, Value>) -> KimiToolChoice {
186 match choice.get("type").and_then(|v| v.as_str()) {
187 Some("auto") => KimiToolChoice::Auto,
188 Some("none") => KimiToolChoice::None,
189 Some("any") => KimiToolChoice::Required,
190 Some("tool") => {
191 if let Some(name) = choice.get("name").and_then(|v| v.as_str()) {
192 KimiToolChoice::Function {
193 kind: "function".to_string(),
194 function: KimiToolChoiceFunction {
195 name: name.to_string(),
196 },
197 }
198 } else {
199 KimiToolChoice::Required
200 }
201 }
202 _ => KimiToolChoice::Auto,
203 }
204}
205
206fn read_tool_choice(req: &MessagesRequest) -> Result<Option<KimiToolChoice>, anyhow::Error> {
207 match req.extra.get("tool_choice") {
208 Some(Value::Object(choice)) => Ok(Some(map_tool_choice(choice))),
209 Some(Value::String(s)) => Ok(Some(match s.as_str() {
210 "auto" => KimiToolChoice::Auto,
211 "none" => KimiToolChoice::None,
212 "any" | "required" => KimiToolChoice::Required,
213 _ => KimiToolChoice::Auto,
214 })),
215 _ => Ok(None),
216 }
217}
218
219fn read_tools(req: &MessagesRequest) -> Result<Vec<KimiTool>, anyhow::Error> {
220 let Some(tools) = req.extra.get("tools") else {
221 return Ok(Vec::new());
222 };
223 let tools_arr = match tools {
224 Value::Array(a) => a,
225 _ => return Ok(Vec::new()),
226 };
227 let mut out = Vec::new();
228 for tool in tools_arr {
229 let name = tool
230 .get("name")
231 .and_then(|v| v.as_str())
232 .unwrap_or("")
233 .to_string();
234 let description = tool
235 .get("description")
236 .and_then(|v| v.as_str())
237 .map(|s| s.to_string());
238 let parameters = tool
239 .get("input_schema")
240 .cloned()
241 .unwrap_or(serde_json::json!({}));
242 out.push(KimiTool {
243 kind: "function".to_string(),
244 function: KimiToolFunction {
245 name,
246 description,
247 parameters,
248 },
249 });
250 }
251 Ok(out)
252}
253
254fn build_messages(req: &MessagesRequest) -> Result<Vec<KimiMessage>, anyhow::Error> {
259 let mut out: Vec<KimiMessage> = Vec::new();
260
261 if let Some(system) = flatten_system_text(req.extra.get("system")) {
263 out.push(KimiMessage::System {
264 role: "system".to_string(),
265 content: system,
266 });
267 }
268
269 for msg in &req.messages {
271 let blocks = normalize_content(&msg.content, serde_json::json!({}));
272 match msg.role.as_str() {
273 "user" => push_user_messages(&mut out, &blocks),
274 "assistant" => push_assistant_message(&mut out, &blocks),
275 other => {
276 anyhow::bail!("unexpected message role: {other}");
277 }
278 }
279 }
280
281 Ok(out)
282}
283
284fn push_user_messages(out: &mut Vec<KimiMessage>, blocks: &[ContentBlock]) {
285 let mut buffer: Vec<KimiUserContentPart> = Vec::new();
286
287 let flush_buffer = |out: &mut Vec<KimiMessage>, buffer: &mut Vec<KimiUserContentPart>| {
288 if buffer.is_empty() {
289 return;
290 }
291 let all_text = buffer
292 .iter()
293 .all(|p| matches!(p, KimiUserContentPart::Text { .. }));
294 if all_text {
295 let joined: String = buffer
296 .iter()
297 .map(|p| match p {
298 KimiUserContentPart::Text { text } => text.as_str(),
299 _ => "",
300 })
301 .collect();
302 out.push(KimiMessage::User {
303 role: "user".to_string(),
304 content: Value::String(joined),
305 });
306 } else {
307 let parts: Vec<KimiUserContentPart> = std::mem::take(buffer);
308 out.push(KimiMessage::User {
309 role: "user".to_string(),
310 content: serde_json::to_value(parts).unwrap_or_default(),
311 });
312 return;
313 }
314 buffer.clear();
315 };
316
317 for block in blocks {
318 match block {
319 ContentBlock::Text { text } => {
320 buffer.push(KimiUserContentPart::Text { text: text.clone() });
321 }
322 ContentBlock::Image { source } => {
323 buffer.push(KimiUserContentPart::ImageUrl {
324 image_url: KimiImageUrl {
325 url: image_source_to_url(source),
326 },
327 });
328 }
329 ContentBlock::ToolResult {
330 tool_use_id,
331 content,
332 is_error,
333 } => {
334 let mut temp = Vec::new();
336 std::mem::swap(&mut buffer, &mut temp);
337 flush_buffer(out, &mut temp);
338
339 out.push(KimiMessage::Tool {
340 role: "tool".to_string(),
341 tool_call_id: tool_use_id.clone(),
342 content: tool_result_content(content, *is_error),
343 });
344 }
345 _ => {}
346 }
347 }
348
349 flush_buffer(out, &mut buffer);
351}
352
353#[derive(Debug, Clone, Serialize)]
354#[serde(untagged)]
355enum KimiUserContentPart {
356 Text { text: String },
357 ImageUrl { image_url: KimiImageUrl },
358}
359
360#[derive(Debug, Clone, Serialize)]
361struct KimiImageUrl {
362 url: String,
363}
364
365fn tool_result_content(content: &Value, is_error: Option<bool>) -> Value {
366 let prefix = if is_error.unwrap_or(false) {
367 "[tool execution error]\n"
368 } else {
369 ""
370 };
371
372 match content {
373 Value::String(s) => Value::String(format!("{prefix}{s}")),
374 Value::Array(arr) => {
375 let mut parts: Vec<KimiToolResultPart> = Vec::new();
376 if !prefix.is_empty() {
377 parts.push(KimiToolResultPart::Text {
378 text: prefix.to_string(),
379 });
380 }
381 for b in arr {
382 match b.get("type").and_then(|v| v.as_str()) {
383 Some("text") => {
384 let text = b.get("text").and_then(|v| v.as_str()).unwrap_or("");
385 parts.push(KimiToolResultPart::Text {
386 text: text.to_string(),
387 });
388 }
389 Some("image") => {
390 let url = image_block_to_url(b);
391 parts.push(KimiToolResultPart::ImageUrl {
392 image_url: KimiImageUrl { url },
393 });
394 }
395 Some(other) => {
396 parts.push(KimiToolResultPart::Text {
397 text: format!("[unsupported content block omitted: {other}]"),
398 });
399 }
400 None => {}
401 }
402 }
403
404 if parts.len() == 1
406 && let KimiToolResultPart::Text { text } = &parts[0]
407 {
408 return Value::String(text.clone());
409 }
410
411 serde_json::to_value(parts).unwrap_or(Value::String(prefix.to_string()))
412 }
413 _ => Value::String(prefix.to_string()),
414 }
415}
416
417#[derive(Debug, Clone, Serialize)]
418#[serde(untagged)]
419enum KimiToolResultPart {
420 Text { text: String },
421 ImageUrl { image_url: KimiImageUrl },
422}
423
424fn push_assistant_message(out: &mut Vec<KimiMessage>, blocks: &[ContentBlock]) {
425 let mut text_parts: Vec<String> = Vec::new();
426 let mut thinking_parts: Vec<String> = Vec::new();
427 let mut tool_calls: Vec<KimiAssistantToolCall> = Vec::new();
428
429 for block in blocks {
430 match block {
431 ContentBlock::Text { text } => {
432 if !text.is_empty() {
433 text_parts.push(text.clone());
434 }
435 }
436 ContentBlock::Thinking { thinking } => {
437 if !thinking.is_empty() {
438 thinking_parts.push(thinking.clone());
439 }
440 }
441 ContentBlock::ToolUse { id, name, input } => {
442 let args = serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string());
443 tool_calls.push(KimiAssistantToolCall {
444 id: id.clone(),
445 kind: "function".to_string(),
446 function: KimiToolCallFunction {
447 name: name.clone(),
448 arguments: args,
449 },
450 });
451 }
452 _ => {}
454 }
455 }
456
457 if text_parts.is_empty() && tool_calls.is_empty() && thinking_parts.is_empty() {
458 return;
459 }
460
461 let content = if text_parts.is_empty() {
462 Some(String::new())
463 } else {
464 Some(text_parts.join(""))
465 };
466
467 let reasoning_content = if thinking_parts.is_empty() {
468 None
469 } else {
470 Some(thinking_parts.join("\n\n"))
471 };
472
473 let tool_calls_val = if tool_calls.is_empty() {
474 None
475 } else {
476 Some(tool_calls)
477 };
478
479 out.push(KimiMessage::Assistant {
480 role: "assistant".to_string(),
481 content,
482 reasoning_content,
483 tool_calls: tool_calls_val,
484 });
485}
486
487#[cfg(test)]
492mod tests {
493 use super::*;
494 use serde_json::json;
495
496 #[test]
497 fn translate_text_request_defaults_like_reference() {
498 let req: MessagesRequest = serde_json::from_value(json!({
499 "model": "haiku",
500 "max_tokens": 10,
501 "system": "sys",
502 "messages": [{"role": "user", "content": "hello"}],
503 "tools": [{"name":"search","description":"Search","input_schema":{"type":"object"}}],
504 "tool_choice": {"type":"tool", "name":"search"},
505 "output_config": {"effort":"max"}
506 }))
507 .unwrap();
508 let translated = translate_request(
509 &req,
510 TranslateOptions {
511 session_id: Some("sid".into()),
512 },
513 )
514 .unwrap();
515 assert_eq!(translated.model, "kimi-for-coding");
516 assert_eq!(translated.reasoning_effort.as_deref(), Some("high"));
517 assert_eq!(translated.prompt_cache_key.as_deref(), Some("sid"));
518 assert_eq!(translated.max_tokens, 10);
519 }
520
521 #[test]
522 fn translate_tool_result_with_unsupported_blocks() {
523 let req: MessagesRequest = serde_json::from_value(json!({
524 "model": "kimi-k2",
525 "messages": [{
526 "role": "user",
527 "content": [{
528 "type": "tool_result",
529 "tool_use_id": "toolu_1",
530 "content": [
531 {"type": "text", "text": "visible output"},
532 {"type": "thinking", "thinking": "hidden thought"}
533 ]
534 }]
535 }]
536 }))
537 .unwrap();
538 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
539 assert_eq!(translated.messages.len(), 1);
541 match &translated.messages[0] {
542 KimiMessage::Tool {
543 role,
544 tool_call_id,
545 content,
546 } => {
547 assert_eq!(role, "tool");
548 assert_eq!(tool_call_id, "toolu_1");
549 let parts: Vec<&Value> = match content {
551 Value::Array(a) => a.iter().collect(),
552 _ => panic!("expected array content"),
553 };
554 assert_eq!(parts.len(), 2);
555 assert_eq!(
556 parts[0].get("text").and_then(|v| v.as_str()),
557 Some("visible output")
558 );
559 assert_eq!(
560 parts[1].get("text").and_then(|v| v.as_str()),
561 Some("[unsupported content block omitted: thinking]")
562 );
563 }
564 _ => panic!("expected Tool message"),
565 }
566 }
567
568 #[test]
569 fn translate_tool_result_with_image() {
570 let req: MessagesRequest = serde_json::from_value(json!({
571 "model": "kimi-k2",
572 "messages": [{
573 "role": "user",
574 "content": [{
575 "type": "tool_result",
576 "tool_use_id": "toolu_1",
577 "content": [
578 {"type": "text", "text": "caption"},
579 {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc"}}
580 ]
581 }]
582 }]
583 }))
584 .unwrap();
585 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
586 assert_eq!(translated.messages.len(), 1);
587 match &translated.messages[0] {
588 KimiMessage::Tool {
589 role,
590 tool_call_id,
591 content,
592 } => {
593 assert_eq!(role, "tool");
594 assert_eq!(tool_call_id, "toolu_1");
595 let parts: Vec<&Value> = match content {
596 Value::Array(a) => a.iter().collect(),
597 _ => panic!("expected array"),
598 };
599 assert_eq!(parts.len(), 2);
600 assert_eq!(
601 parts[1]
602 .get("image_url")
603 .and_then(|u| u.get("url"))
604 .and_then(|v| v.as_str()),
605 Some("data:image/png;base64,abc")
606 );
607 }
608 _ => panic!("expected Tool message"),
609 }
610 }
611
612 #[test]
613 fn translate_assistant_with_thinking_tool_use_and_text() {
614 let req: MessagesRequest = serde_json::from_value(json!({
615 "model": "kimi-for-coding",
616 "messages": [{
617 "role": "assistant",
618 "content": [
619 {"type": "thinking", "thinking": "let me think..."},
620 {"type": "text", "text": "here's the answer"},
621 {"type": "tool_use", "id": "tu_1", "name": "search", "input": {"q": "rust"}}
622 ]
623 }]
624 }))
625 .unwrap();
626 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
627 assert_eq!(translated.messages.len(), 1);
628 match &translated.messages[0] {
629 KimiMessage::Assistant {
630 role,
631 content,
632 reasoning_content,
633 tool_calls,
634 } => {
635 assert_eq!(role, "assistant");
636 assert_eq!(content.as_deref(), Some("here's the answer"));
637 assert_eq!(reasoning_content.as_deref(), Some("let me think..."));
638 assert!(tool_calls.is_some());
639 let tcs = tool_calls.as_ref().unwrap();
640 assert_eq!(tcs.len(), 1);
641 assert_eq!(tcs[0].function.name, "search");
642 }
643 _ => panic!("expected Assistant message"),
644 }
645 }
646
647 #[test]
648 fn translate_empty_assistant_content_emits_empty_string() {
649 let req: MessagesRequest = serde_json::from_value(json!({
651 "model": "kimi-for-coding",
652 "messages": [{
653 "role": "assistant",
654 "content": [
655 {"type": "thinking", "thinking": "thinking..."}
656 ]
657 }]
658 }))
659 .unwrap();
660 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
661 assert_eq!(translated.messages.len(), 1);
662 match &translated.messages[0] {
663 KimiMessage::Assistant {
664 content,
665 reasoning_content,
666 ..
667 } => {
668 assert_eq!(content.as_deref(), Some(""));
669 assert_eq!(reasoning_content.as_deref(), Some("thinking..."));
670 }
671 _ => panic!("expected Assistant message"),
672 }
673 }
674
675 #[test]
676 fn translate_user_text_and_image_collapse_correctly() {
677 let req: MessagesRequest = serde_json::from_value(json!({
678 "model": "kimi-for-coding",
679 "messages": [{
680 "role": "user",
681 "content": [
682 {"type": "text", "text": "describe this"},
683 {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "xyz"}}
684 ]
685 }]
686 }))
687 .unwrap();
688 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
689 assert_eq!(translated.messages.len(), 1);
690 match &translated.messages[0] {
691 KimiMessage::User { role, content } => {
692 assert_eq!(role, "user");
693 assert!(content.is_array());
695 let parts = content.as_array().unwrap();
696 assert_eq!(parts.len(), 2);
697 assert_eq!(
698 parts[0].get("text").and_then(|v| v.as_str()),
699 Some("describe this")
700 );
701 assert!(parts[1].get("image_url").is_some());
702 }
703 _ => panic!("expected User message"),
704 }
705 }
706
707 #[test]
708 fn translate_text_only_user_collapses_to_string() {
709 let req: MessagesRequest = serde_json::from_value(json!({
710 "model": "kimi-for-coding",
711 "messages": [{
712 "role": "user",
713 "content": "hello"
714 }]
715 }))
716 .unwrap();
717 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
718 match &translated.messages[0] {
719 KimiMessage::User { role, content } => {
720 assert_eq!(role, "user");
721 assert_eq!(content.as_str(), Some("hello"));
722 }
723 _ => panic!("expected User message"),
724 }
725 }
726
727 #[test]
728 fn max_tokens_defaults_to_32000() {
729 let req: MessagesRequest = serde_json::from_value(json!({
730 "model": "kimi-for-coding",
731 "messages": [{"role": "user", "content": "hi"}]
732 }))
733 .unwrap();
734 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
735 assert_eq!(translated.max_tokens, 32000);
736 }
737
738 #[test]
739 fn max_tokens_clamps_at_32000() {
740 let req: MessagesRequest = serde_json::from_value(json!({
741 "model": "kimi-for-coding",
742 "max_tokens": 99999,
743 "messages": [{"role": "user", "content": "hi"}]
744 }))
745 .unwrap();
746 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
747 assert_eq!(translated.max_tokens, 32000);
748 }
749
750 #[test]
751 fn invalid_effort_rejected() {
752 let req: Result<MessagesRequest, _> = serde_json::from_value(json!({
753 "model": "kimi-for-coding",
754 "messages": [{"role": "user", "content": "hi"}],
755 "output_config": {"effort": "extreme"}
756 }));
757 let req = req.unwrap();
759 let result = translate_request(&req, TranslateOptions { session_id: None });
760 assert!(result.is_err());
761 }
762
763 #[test]
764 fn effort_xhigh_maps_to_high() {
765 let req: MessagesRequest = serde_json::from_value(json!({
766 "model": "kimi-for-coding",
767 "messages": [{"role": "user", "content": "hi"}],
768 "output_config": {"effort": "xhigh"}
769 }))
770 .unwrap();
771 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
772 assert_eq!(translated.reasoning_effort.as_deref(), Some("high"));
773 }
774
775 #[test]
776 fn auto_tool_choice_is_collapsed() {
777 let req: MessagesRequest = serde_json::from_value(json!({
778 "model": "kimi-for-coding",
779 "messages": [{"role": "user", "content": "hi"}],
780 "tool_choice": {"type": "auto"}
781 }))
782 .unwrap();
783 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
784 assert!(translated.tool_choice.is_none());
785 }
786
787 #[test]
788 fn any_tool_choice_becomes_required() {
789 let req: MessagesRequest = serde_json::from_value(json!({
790 "model": "kimi-for-coding",
791 "messages": [{"role": "user", "content": "hi"}],
792 "tools": [{"name":"search","input_schema":{"type":"object"}}],
793 "tool_choice": {"type": "any"}
794 }))
795 .unwrap();
796 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
797 assert!(matches!(
798 translated.tool_choice,
799 Some(KimiToolChoice::Required)
800 ));
801 }
802
803 #[test]
804 fn system_text_excludes_billing_headers() {
805 let req: MessagesRequest = serde_json::from_value(json!({
806 "model": "kimi-for-coding",
807 "system": [
808 {"type": "text", "text": "You are a helpful assistant."},
809 {"type": "text", "text": "x-anthropic-billing-header: secret"}
810 ],
811 "messages": [{"role": "user", "content": "hi"}]
812 }))
813 .unwrap();
814 let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap();
815 let system_msg = translated
816 .messages
817 .iter()
818 .find(|m| matches!(m, KimiMessage::System { .. }));
819 assert!(system_msg.is_some());
820 if let Some(KimiMessage::System { content, .. }) = system_msg {
821 assert!(!content.contains("x-anthropic-billing-header"));
822 assert!(content.contains("helpful"));
823 }
824 }
825}