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, 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// ---------------------------------------------------------------------------
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    #[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
127// ---------------------------------------------------------------------------
128// Translation entry point
129// ---------------------------------------------------------------------------
130
131pub 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    // Collapse auto tool_choice to None (default behavior)
163    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        // K3's context is 1M tokens. Kimi Code passes max_context_size as
173        // max_tokens and lets the server clamp to the remaining output budget.
174        // Pass through the requested value (or the full context window).
175        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        // k3 supports: low, high, max (default: high)
187        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
202// ---------------------------------------------------------------------------
203// Tool & tool_choice reading
204// ---------------------------------------------------------------------------
205
206fn 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
275// ---------------------------------------------------------------------------
276// Message building
277// ---------------------------------------------------------------------------
278
279fn 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    // For k3: collect system text and prepend to first user message
284    // For other models: emit as system role
285    let mut k3_system_parts: Vec<String> = Vec::new();
286
287    // System field
288    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    // Convert each message
300    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                    // Emit tool results as tool-role messages
309                    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                    // Build user content from text + image blocks, prepending system text
325                    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                        // Mixed content: build array with system prefix as first text part
334                        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                        // Tool-result-only turn: emit system text as its own user message
373                        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    // Flush any remaining system text (e.g. system-only conversation or trailing system messages)
410    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                // flush any buffered user content first
472                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 remaining buffer
487    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            // Collapse to string when only one text part
542            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            // Image blocks from assistant are dropped
590            _ => {}
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// ---------------------------------------------------------------------------
625// Tests
626// ---------------------------------------------------------------------------
627
628#[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        // Should have one tool message
677        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                // content should be an array with text parts
687                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        // When there are no text blocks and no tool calls but there is thinking
787        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                // Mixed text+image produces array, not string
831                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        // The serde flatten extra captures it, so it parses. Error comes at translate time.
953        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}