From 6821436b5d2343a58e9923c1d39ef1ee0e6b13fc Mon Sep 17 00:00:00 2001 From: devingearing Date: Thu, 23 Jul 2026 15:06:04 -0400 Subject: [PATCH 1/2] add Kimi K3 model support K3 differs from kimi-for-coding in several ways that required changes across the request translation, stream reducer, and model registry: - Model ID: K3's upstream API model is "k3", not "kimi-k3". Added kimi-k3/k3 aliases that resolve to the upstream "k3" identifier. - System messages: K3 rejects system/developer role messages with a tokenization error. System content is now merged into the first user message for K3, with buffering to handle mid-conversation system messages (e.g. Claude Code's system-reminders between tool calls and tool results). - Tool result preservation: When system text is buffered and merged into a user message, tool_result blocks were being dropped. Fixed to emit tool results as proper tool-role messages while only merging system text with text content. - Stream reducer tool index mismatch: Continuation chunks for streamed tool call arguments were matched by block_index (the Anthropic content index) instead of tc_index (the OpenAI tool_calls array index). This caused argument fragments to be silently dropped when thinking/text blocks preceded tool calls. - Effort mapping: K3 supports low/high/max (not medium). The existing mapping downgraded "max" to "high", but K3 natively supports max effort. Default effort is now "high" for K3. - Max tokens: Raised from 32K to 1M for K3 to match its context window. --- .../kimi/translate/model_allowlist.rs | 38 ++++- src/providers/kimi/translate/reducer.rs | 5 +- src/providers/kimi/translate/request.rs | 130 +++++++++++++++--- src/registry.rs | 2 +- 4 files changed, 149 insertions(+), 26 deletions(-) diff --git a/src/providers/kimi/translate/model_allowlist.rs b/src/providers/kimi/translate/model_allowlist.rs index 1a041c8b..ea997fc4 100644 --- a/src/providers/kimi/translate/model_allowlist.rs +++ b/src/providers/kimi/translate/model_allowlist.rs @@ -17,9 +17,13 @@ static ALIAS_TARGETS: once_cell::sync::Lazy> m.insert("fable", KIMI_DEFAULT_MODEL); m.insert("claude-fable-5", KIMI_DEFAULT_MODEL); m.insert("kimi-for-coding", KIMI_DEFAULT_MODEL); + m.insert("kimi-k3", "k3"); + m.insert("k3", "k3"); m }); +const ALLOWED_MODELS: &[&str] = &["kimi-for-coding", "k3"]; + pub fn resolve_model(model: &str) -> String { ALIAS_TARGETS .get(model) @@ -28,13 +32,18 @@ pub fn resolve_model(model: &str) -> String { .to_string() } +pub fn is_k3(model: &str) -> bool { + model == "k3" +} + pub fn assert_allowed_model(model: &str) -> Result<(), ModelNotAllowedError> { - if model != KIMI_DEFAULT_MODEL { - return Err(ModelNotAllowedError { + if ALLOWED_MODELS.contains(&model) { + Ok(()) + } else { + Err(ModelNotAllowedError { model: model.to_string(), - }); + }) } - Ok(()) } #[derive(Debug)] @@ -81,11 +90,32 @@ mod tests { assert_eq!(resolve_model("kimi-for-coding"), KIMI_DEFAULT_MODEL); } + #[test] + fn resolve_kimi_k3_to_k3() { + assert_eq!(resolve_model("kimi-k3"), "k3"); + } + + #[test] + fn resolve_k3_to_k3() { + assert_eq!(resolve_model("k3"), "k3"); + } + + #[test] + fn k3_detected() { + assert!(is_k3("k3")); + assert!(!is_k3("kimi-for-coding")); + } + #[test] fn assert_allowed_accepts_default() { assert!(assert_allowed_model(KIMI_DEFAULT_MODEL).is_ok()); } + #[test] + fn assert_allowed_accepts_k3() { + assert!(assert_allowed_model("k3").is_ok()); + } + #[test] fn assert_allowed_rejects_other() { assert!(assert_allowed_model("kimi-k2.6").is_err()); diff --git a/src/providers/kimi/translate/reducer.rs b/src/providers/kimi/translate/reducer.rs index 89fc449a..24c310ef 100644 --- a/src/providers/kimi/translate/reducer.rs +++ b/src/providers/kimi/translate/reducer.rs @@ -159,6 +159,7 @@ struct StreamError { #[allow(dead_code)] struct ToolSlot { + tc_index: usize, block_index: usize, id: String, name: String, @@ -270,7 +271,7 @@ pub fn reduce_upstream_bytes(input: &[u8]) -> Result, Upstream } for tc in tool_calls { - let existing_pos = tool_slots.iter().position(|s| s.block_index == tc.index); + let existing_pos = tool_slots.iter().position(|s| s.tc_index == tc.index); let block_index = if let Some(pos) = existing_pos { tool_slots[pos].block_index } else { @@ -281,13 +282,13 @@ pub fn reduce_upstream_bytes(input: &[u8]) -> Result, Upstream .and_then(|f| f.name.clone()) .unwrap_or_default(); if id.is_empty() || name.is_empty() { - // Defensive: skip out-of-order fragments continue; } saw_tool_calls = true; let bi = next_block_index; next_block_index += 1; tool_slots.push(ToolSlot { + tc_index: tc.index, block_index: bi, id: id.clone(), name: name.clone(), diff --git a/src/providers/kimi/translate/request.rs b/src/providers/kimi/translate/request.rs index 7509b032..213031da 100644 --- a/src/providers/kimi/translate/request.rs +++ b/src/providers/kimi/translate/request.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::model_allowlist::{KIMI_DEFAULT_MODEL, assert_allowed_model, resolve_model}; +use super::model_allowlist::{KIMI_DEFAULT_MODEL, assert_allowed_model, is_k3, resolve_model}; use crate::anthropic::schema::MessagesRequest; use crate::providers::translate_shared::{ ContentBlock, flatten_system_text, image_block_to_url, image_source_to_url, normalize_content, @@ -134,7 +134,8 @@ pub fn translate_request( let resolved = resolve_model(model); assert_allowed_model(&resolved).map_err(|e| anyhow::anyhow!("{e}"))?; - let messages = build_messages(req)?; + let k3 = is_k3(&resolved); + let messages = build_messages(req, &resolved)?; let tools = read_tools(req)?; let tool_choice = read_tool_choice(req)?; @@ -145,8 +146,8 @@ pub fn translate_request( stream_options: KimiStreamOptions { include_usage: true, }, - max_tokens: clamp_max_tokens(req.max_tokens), - reasoning_effort: Some(map_reasoning_effort(read_effort(req)?)), + max_tokens: clamp_max_tokens(req.max_tokens, k3), + reasoning_effort: Some(map_reasoning_effort(read_effort(req)?, k3)), thinking: Some(KimiThinking { kind: "enabled".to_string(), }), @@ -163,18 +164,31 @@ pub fn translate_request( Ok(out) } -fn clamp_max_tokens(requested: Option) -> u32 { +const K3_MAX_TOKENS: u32 = 1_048_576; + +fn clamp_max_tokens(requested: Option, k3: bool) -> u32 { + let cap = if k3 { K3_MAX_TOKENS } else { DEFAULT_MAX_TOKENS }; match requested { - Some(v) if v > 0 => v.min(DEFAULT_MAX_TOKENS), - _ => DEFAULT_MAX_TOKENS, + Some(v) if v > 0 => v.min(cap), + _ => cap, } } -fn map_reasoning_effort(effort: Option<&str>) -> String { - match effort { - Some("max" | "xhigh") => "high".to_string(), - Some(v) => v.to_string(), - None => "medium".to_string(), +fn map_reasoning_effort(effort: Option<&str>, k3: bool) -> String { + if k3 { + // k3 supports: low, high, max (default: high) + match effort { + Some("max") => "max".to_string(), + Some("xhigh" | "high") => "high".to_string(), + Some("low") => "low".to_string(), + _ => "high".to_string(), + } + } else { + match effort { + Some("max" | "xhigh") => "high".to_string(), + Some(v) => v.to_string(), + None => "medium".to_string(), + } } } @@ -255,29 +269,107 @@ fn read_tools(req: &MessagesRequest) -> Result, anyhow::Error> { // Message building // --------------------------------------------------------------------------- -fn build_messages(req: &MessagesRequest) -> Result, anyhow::Error> { +fn build_messages(req: &MessagesRequest, model: &str) -> Result, anyhow::Error> { let mut out: Vec = Vec::new(); + let k3_mode = is_k3(model); + + // For k3: collect system text and prepend to first user message + // For other models: emit as system role + let mut k3_system_parts: Vec = Vec::new(); - // System message + // System field if let Some(system) = flatten_system_text(req.extra.get("system")) { - out.push(KimiMessage::System { - role: "system".to_string(), - content: system, - }); + if k3_mode { + k3_system_parts.push(system); + } else { + out.push(KimiMessage::System { + role: "system".to_string(), + content: system, + }); + } } + let mut first_user_seen = false; + // Convert each message for msg in &req.messages { let blocks = normalize_content(&msg.content, serde_json::json!({})); match msg.role.as_str() { - "user" => push_user_messages(&mut out, &blocks), + "user" => { + if k3_mode && !k3_system_parts.is_empty() { + let system_prefix = k3_system_parts.join("\n\n"); + k3_system_parts.clear(); + // Check if this user message has tool results + let has_tool_results = blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. })); + if has_tool_results { + // Emit tool results normally, prepend system text to any text content + for block in &blocks { + match block { + ContentBlock::ToolResult { tool_use_id, content, is_error } => { + out.push(KimiMessage::Tool { + role: "tool".to_string(), + tool_call_id: tool_use_id.clone(), + content: tool_result_content(content, *is_error), + }); + } + ContentBlock::Text { text } if !text.is_empty() => { + let merged = format!("{system_prefix}\n\n{text}"); + out.push(KimiMessage::User { + role: "user".to_string(), + content: serde_json::Value::String(merged), + }); + } + _ => {} + } + } + } else { + let user_text: String = blocks.iter().filter_map(|b| match b { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }).collect::>().join(""); + let merged = format!("{system_prefix}\n\n{user_text}"); + out.push(KimiMessage::User { + role: "user".to_string(), + content: serde_json::Value::String(merged), + }); + } + } else { + push_user_messages(&mut out, &blocks); + } + first_user_seen = true; + } "assistant" => push_assistant_message(&mut out, &blocks), + "system" | "developer" => { + let text = blocks.iter().filter_map(|b| match b { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }).collect::>().join("\n"); + if !text.is_empty() { + if k3_mode { + k3_system_parts.push(text); + } else { + out.push(KimiMessage::System { + role: "system".to_string(), + content: text, + }); + } + } + } other => { anyhow::bail!("unexpected message role: {other}"); } } } + // Flush any remaining system text (e.g. system-only conversation or trailing system messages) + if k3_mode && !k3_system_parts.is_empty() { + let text = k3_system_parts.join("\n\n"); + out.push(KimiMessage::User { + role: "user".to_string(), + content: serde_json::Value::String(text), + }); + } + Ok(out) } diff --git a/src/registry.rs b/src/registry.rs index 4b149a3e..1e70f38a 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -48,7 +48,7 @@ pub(crate) const CODEX_MODELS: &[&str] = &[ "gpt-5.6-terra", ]; -pub(crate) const KIMI_MODELS: &[&str] = &["kimi-for-coding", "kimi-k2.6", "k2.6"]; +pub(crate) const KIMI_MODELS: &[&str] = &["kimi-for-coding", "kimi-k2.6", "kimi-k3", "k2.6", "k3"]; pub(crate) const GROK_MODELS: &[&str] = &["grok-composer-2.5-fast", "grok-4.5"]; pub struct Registry { From ce464445d0797d1f43c3e13adfd62dfa1f4cc6a2 Mon Sep 17 00:00:00 2001 From: devingearing Date: Thu, 23 Jul 2026 18:39:50 -0400 Subject: [PATCH 2/2] address review feedback on k3 support - Remove unused `first_user_seen` variable (clippy clean) - Preserve image blocks when merging system text into user messages - Emit buffered system text as user message on tool-result-only turns to prevent silent loss of system reminders - Pass through max_tokens for k3 without clamping; default to context window (1M), matching Kimi Code's behavior where the server clamps to the remaining output budget - Add tests covering all five review items: system merge, image preservation, tool-result-only reminder, effort mapping, max_tokens --- src/providers/kimi/translate/request.rs | 195 +++++++++++++++++++++--- 1 file changed, 170 insertions(+), 25 deletions(-) diff --git a/src/providers/kimi/translate/request.rs b/src/providers/kimi/translate/request.rs index 213031da..1afcd74b 100644 --- a/src/providers/kimi/translate/request.rs +++ b/src/providers/kimi/translate/request.rs @@ -164,13 +164,17 @@ pub fn translate_request( Ok(out) } -const K3_MAX_TOKENS: u32 = 1_048_576; - fn clamp_max_tokens(requested: Option, k3: bool) -> u32 { - let cap = if k3 { K3_MAX_TOKENS } else { DEFAULT_MAX_TOKENS }; - match requested { - Some(v) if v > 0 => v.min(cap), - _ => cap, + if k3 { + // K3's context is 1M tokens. Kimi Code passes max_context_size as + // max_tokens and lets the server clamp to the remaining output budget. + // Pass through the requested value (or the full context window). + requested.filter(|v| *v > 0).unwrap_or(1_048_576) + } else { + match requested { + Some(v) if v > 0 => v.min(DEFAULT_MAX_TOKENS), + _ => DEFAULT_MAX_TOKENS, + } } } @@ -289,8 +293,6 @@ fn build_messages(req: &MessagesRequest, model: &str) -> Result } } - let mut first_user_seen = false; - // Convert each message for msg in &req.messages { let blocks = normalize_content(&msg.content, serde_json::json!({})); @@ -299,30 +301,46 @@ fn build_messages(req: &MessagesRequest, model: &str) -> Result if k3_mode && !k3_system_parts.is_empty() { let system_prefix = k3_system_parts.join("\n\n"); k3_system_parts.clear(); - // Check if this user message has tool results - let has_tool_results = blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. })); - if has_tool_results { - // Emit tool results normally, prepend system text to any text content + + // Emit tool results as tool-role messages + for block in &blocks { + if let ContentBlock::ToolResult { tool_use_id, content, is_error } = block { + out.push(KimiMessage::Tool { + role: "tool".to_string(), + tool_call_id: tool_use_id.clone(), + content: tool_result_content(content, *is_error), + }); + } + } + + // Build user content from text + image blocks, prepending system text + let has_text = blocks.iter().any(|b| matches!(b, ContentBlock::Text { .. })); + let has_images = blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. })); + + if has_images { + // Mixed content: build array with system prefix as first text part + let mut parts: Vec = Vec::new(); + parts.push(KimiUserContentPart::Text { text: system_prefix }); for block in &blocks { match block { - ContentBlock::ToolResult { tool_use_id, content, is_error } => { - out.push(KimiMessage::Tool { - role: "tool".to_string(), - tool_call_id: tool_use_id.clone(), - content: tool_result_content(content, *is_error), - }); - } ContentBlock::Text { text } if !text.is_empty() => { - let merged = format!("{system_prefix}\n\n{text}"); - out.push(KimiMessage::User { - role: "user".to_string(), - content: serde_json::Value::String(merged), + parts.push(KimiUserContentPart::Text { text: text.clone() }); + } + ContentBlock::Image { source } => { + parts.push(KimiUserContentPart::ImageUrl { + image_url: KimiImageUrl { + url: image_source_to_url(source), + }, }); } _ => {} } } - } else { + out.push(KimiMessage::User { + role: "user".to_string(), + content: serde_json::to_value(parts).unwrap_or_default(), + }); + } else if has_text { let user_text: String = blocks.iter().filter_map(|b| match b { ContentBlock::Text { text } => Some(text.as_str()), _ => None, @@ -332,11 +350,16 @@ fn build_messages(req: &MessagesRequest, model: &str) -> Result role: "user".to_string(), content: serde_json::Value::String(merged), }); + } else { + // Tool-result-only turn: emit system text as its own user message + out.push(KimiMessage::User { + role: "user".to_string(), + content: serde_json::Value::String(system_prefix), + }); } } else { push_user_messages(&mut out, &blocks); } - first_user_seen = true; } "assistant" => push_assistant_message(&mut out, &blocks), "system" | "developer" => { @@ -914,4 +937,126 @@ mod tests { assert!(content.contains("helpful")); } } + + #[test] + fn k3_system_merged_into_user() { + let req: MessagesRequest = serde_json::from_value(json!({ + "model": "kimi-k3", + "messages": [{"role": "user", "content": "hello"}], + "system": "Be helpful." + })) + .unwrap(); + let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap(); + assert_eq!(translated.model, "k3"); + assert!(!translated.messages.iter().any(|m| matches!(m, KimiMessage::System { .. }))); + match &translated.messages[0] { + KimiMessage::User { content, .. } => { + let text = content.as_str().unwrap(); + assert!(text.contains("Be helpful.")); + assert!(text.contains("hello")); + } + _ => panic!("expected User message"), + } + } + + #[test] + fn k3_system_with_images_preserves_image_blocks() { + let req: MessagesRequest = serde_json::from_value(json!({ + "model": "kimi-k3", + "system": "Describe images.", + "messages": [{"role": "user", "content": [ + {"type": "text", "text": "what is this?"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc"}} + ]}] + })) + .unwrap(); + let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap(); + match &translated.messages[0] { + KimiMessage::User { content, .. } => { + let parts = content.as_array().expect("expected array for mixed content"); + assert!(parts.len() >= 3); + assert!(parts.iter().any(|p| p.get("image_url").is_some()), "image block missing"); + let texts: Vec<&str> = parts.iter() + .filter_map(|p| p.get("text").and_then(|t| t.as_str())) + .collect(); + assert!(texts.iter().any(|t| t.contains("Describe images.")), "system text missing"); + assert!(texts.iter().any(|t| t.contains("what is this?")), "user text missing"); + } + _ => panic!("expected User message"), + } + } + + #[test] + fn k3_system_not_lost_on_tool_result_only_turn() { + let req: MessagesRequest = serde_json::from_value(json!({ + "model": "kimi-k3", + "messages": [ + {"role": "user", "content": "do something"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"cmd": "ls"}} + ]}, + {"role": "system", "content": [{"type": "text", "text": "reminder text"}]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "output"} + ]} + ] + })) + .unwrap(); + let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap(); + let user_msgs: Vec<&KimiMessage> = translated.messages.iter() + .filter(|m| matches!(m, KimiMessage::User { .. })) + .collect(); + let all_text: String = user_msgs.iter().map(|m| match m { + KimiMessage::User { content, .. } => content.as_str().unwrap_or("").to_string(), + _ => String::new(), + }).collect(); + assert!(all_text.contains("reminder text"), "system reminder lost on tool-result-only turn"); + assert!(translated.messages.iter().any(|m| matches!(m, KimiMessage::Tool { .. })), "tool result missing"); + } + + #[test] + fn k3_effort_defaults_to_high() { + let req: MessagesRequest = serde_json::from_value(json!({ + "model": "kimi-k3", + "messages": [{"role": "user", "content": "hi"}] + })) + .unwrap(); + let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap(); + assert_eq!(translated.reasoning_effort.as_deref(), Some("high")); + } + + #[test] + fn k3_effort_max_preserved() { + let req: MessagesRequest = serde_json::from_value(json!({ + "model": "kimi-k3", + "messages": [{"role": "user", "content": "hi"}], + "output_config": {"effort": "max"} + })) + .unwrap(); + let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap(); + assert_eq!(translated.reasoning_effort.as_deref(), Some("max")); + } + + #[test] + fn k3_max_tokens_passes_through() { + let req: MessagesRequest = serde_json::from_value(json!({ + "model": "kimi-k3", + "max_tokens": 500000, + "messages": [{"role": "user", "content": "hi"}] + })) + .unwrap(); + let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap(); + assert_eq!(translated.max_tokens, 500000); + } + + #[test] + fn k3_max_tokens_defaults_to_context_window() { + let req: MessagesRequest = serde_json::from_value(json!({ + "model": "kimi-k3", + "messages": [{"role": "user", "content": "hi"}] + })) + .unwrap(); + let translated = translate_request(&req, TranslateOptions { session_id: None }).unwrap(); + assert_eq!(translated.max_tokens, 1_048_576); + } }