Skip to main content

claude_codex/providers/kimi/translate/
request.rs

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// ---------------------------------------------------------------------------
12// Kimi OpenAI-compatible chat-completions types
13// ---------------------------------------------------------------------------
14
15#[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
125// ---------------------------------------------------------------------------
126// Translation entry point
127// ---------------------------------------------------------------------------
128
129pub 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    // Collapse auto tool_choice to None (default behavior)
159    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
181// ---------------------------------------------------------------------------
182// Tool & tool_choice reading
183// ---------------------------------------------------------------------------
184
185fn 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
254// ---------------------------------------------------------------------------
255// Message building
256// ---------------------------------------------------------------------------
257
258fn build_messages(req: &MessagesRequest) -> Result<Vec<KimiMessage>, anyhow::Error> {
259    let mut out: Vec<KimiMessage> = Vec::new();
260
261    // System message
262    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    // Convert each message
270    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                // flush any buffered user content first
335                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 remaining buffer
350    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            // Collapse to string when only one text part
405            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            // Image blocks from assistant are dropped
453            _ => {}
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// ---------------------------------------------------------------------------
488// Tests
489// ---------------------------------------------------------------------------
490
491#[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        // Should have one tool message
540        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                // content should be an array with text parts
550                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        // When there are no text blocks and no tool calls but there is thinking
650        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                // Mixed text+image produces array, not string
694                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        // The serde flatten extra captures it, so it parses. Error comes at translate time.
758        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}