diff --git a/crates/agent/src/cli.rs b/crates/agent/src/cli.rs index b50082521..278368f69 100644 --- a/crates/agent/src/cli.rs +++ b/crates/agent/src/cli.rs @@ -18,7 +18,7 @@ struct Cli { /// Task prompt prompt: String, - /// LLM provider (anthropic, openai, gemini) + /// LLM provider (anthropic, openai, gemini, kimi, zai, minimax) #[arg(long, default_value = "anthropic")] provider: String, @@ -69,6 +69,9 @@ fn default_model(provider: Provider) -> &'static str { Provider::OpenAi => "gpt-5.2-codex", Provider::Gemini => "gemini-3.1-pro-preview", Provider::Anthropic => "claude-opus-4-6", + Provider::Kimi => "kimi-k2.5", + Provider::Zai => "glm-4.7", + Provider::Minimax => "minimax-m2.5", } } @@ -147,6 +150,9 @@ fn summarizer_model_id(provider: Provider) -> ModelId { Provider::OpenAi => ModelId::new(Provider::OpenAi, "gpt-4o-mini"), Provider::Gemini => ModelId::new(Provider::Gemini, "gemini-2.0-flash"), Provider::Anthropic => ModelId::new(Provider::Anthropic, "claude-haiku-4-5-20251001"), + Provider::Kimi => ModelId::new(Provider::Kimi, "kimi-k2.5"), + Provider::Zai => ModelId::new(Provider::Zai, "glm-4.7"), + Provider::Minimax => ModelId::new(Provider::Minimax, "minimax-m2.5"), } } @@ -162,6 +168,9 @@ fn build_profile(provider: Provider, model: &str, llm_client: Option) -> let summarizer = build_summarizer(provider, llm_client); match provider { Provider::OpenAi => Box::new(OpenAiProfile::with_summarizer(model, summarizer)), + Provider::Kimi | Provider::Zai | Provider::Minimax => Box::new( + OpenAiProfile::with_summarizer(model, summarizer).with_provider(provider), + ), Provider::Gemini => Box::new(GeminiProfile::with_summarizer(model, summarizer)), Provider::Anthropic => Box::new(AnthropicProfile::with_summarizer(model, summarizer)), } @@ -174,6 +183,9 @@ fn validate_api_key(provider: Provider) -> bool { Provider::Gemini => { std::env::var("GEMINI_API_KEY").is_ok() || std::env::var("GOOGLE_API_KEY").is_ok() } + Provider::Kimi => std::env::var("KIMI_API_KEY").is_ok(), + Provider::Zai => std::env::var("ZAI_API_KEY").is_ok(), + Provider::Minimax => std::env::var("MINIMAX_API_KEY").is_ok(), } } @@ -382,9 +394,17 @@ pub async fn run() -> anyhow::Result<()> { let factory: SessionFactory = Arc::new(move || { let child_summarizer = build_summarizer(provider, Some(factory_client.clone())); let child_profile: Arc = match provider { - Provider::OpenAi => Arc::new(OpenAiProfile::with_summarizer(&factory_model, child_summarizer)), + Provider::OpenAi => { + Arc::new(OpenAiProfile::with_summarizer(&factory_model, child_summarizer)) + } + Provider::Kimi | Provider::Zai | Provider::Minimax => Arc::new( + OpenAiProfile::with_summarizer(&factory_model, child_summarizer) + .with_provider(provider), + ), Provider::Gemini => Arc::new(GeminiProfile::with_summarizer(&factory_model, child_summarizer)), - Provider::Anthropic => Arc::new(AnthropicProfile::with_summarizer(&factory_model, child_summarizer)), + Provider::Anthropic => { + Arc::new(AnthropicProfile::with_summarizer(&factory_model, child_summarizer)) + } }; Session::new( factory_client.clone(), @@ -606,6 +626,21 @@ mod tests { assert_eq!(default_model(Provider::Gemini), "gemini-3.1-pro-preview"); } + #[test] + fn default_model_kimi() { + assert_eq!(default_model(Provider::Kimi), "kimi-k2.5"); + } + + #[test] + fn default_model_zai() { + assert_eq!(default_model(Provider::Zai), "glm-4.7"); + } + + #[test] + fn default_model_minimax() { + assert_eq!(default_model(Provider::Minimax), "minimax-m2.5"); + } + // build_tool_approval non-interactive tests #[test] diff --git a/crates/agent/src/profiles/anthropic.rs b/crates/agent/src/profiles/anthropic.rs index ec8336b2d..117c38ff7 100644 --- a/crates/agent/src/profiles/anthropic.rs +++ b/crates/agent/src/profiles/anthropic.rs @@ -39,6 +39,14 @@ impl AnthropicProfile { }, } } + + /// Override the provider identity (e.g. for Kimi, which uses the Anthropic + /// Messages protocol but routes to a different adapter). + #[must_use] + pub fn with_provider(mut self, provider: Provider) -> Self { + self.base.provider = provider; + self + } } impl ProviderProfile for AnthropicProfile { diff --git a/crates/agent/src/profiles/openai.rs b/crates/agent/src/profiles/openai.rs index 836f9ab0c..538f6eda6 100644 --- a/crates/agent/src/profiles/openai.rs +++ b/crates/agent/src/profiles/openai.rs @@ -43,6 +43,14 @@ impl OpenAiProfile { pub fn set_reasoning_effort(&mut self, effort: Option) { self.reasoning_effort = effort; } + + /// Override the provider identity (e.g. for Z.AI or Minimax, which use the + /// OpenAI Chat Completions protocol but route to different adapters). + #[must_use] + pub fn with_provider(mut self, provider: Provider) -> Self { + self.base.provider = provider; + self + } } impl ProviderProfile for OpenAiProfile { diff --git a/crates/agent/src/project_docs.rs b/crates/agent/src/project_docs.rs index 6ae4d3c04..c34569de1 100644 --- a/crates/agent/src/project_docs.rs +++ b/crates/agent/src/project_docs.rs @@ -13,7 +13,9 @@ pub async fn discover_project_docs( let candidate_filenames: Vec<&str> = match provider { Provider::Anthropic => vec!["AGENTS.md", "CLAUDE.md"], - Provider::OpenAi => vec!["AGENTS.md", ".codex/instructions.md"], + Provider::OpenAi | Provider::Kimi | Provider::Zai | Provider::Minimax => { + vec!["AGENTS.md", ".codex/instructions.md"] + } Provider::Gemini => vec!["AGENTS.md", "GEMINI.md"], }; diff --git a/crates/agent/tests/parity_matrix.rs b/crates/agent/tests/parity_matrix.rs index 8ed6d76cf..9934d9134 100644 --- a/crates/agent/tests/parity_matrix.rs +++ b/crates/agent/tests/parity_matrix.rs @@ -10,9 +10,13 @@ use llm::provider::{ModelId, Provider}; fn summarizer_model_id(provider: Provider) -> ModelId { match provider { - Provider::OpenAi => ModelId::new(Provider::OpenAi, "gpt-4o-mini"), + Provider::OpenAi | Provider::Kimi | Provider::Zai | Provider::Minimax => { + ModelId::new(Provider::OpenAi, "gpt-4o-mini") + } Provider::Gemini => ModelId::new(Provider::Gemini, "gemini-2.0-flash"), - Provider::Anthropic => ModelId::new(Provider::Anthropic, "claude-haiku-4-5-20251001"), + Provider::Anthropic => { + ModelId::new(Provider::Anthropic, "claude-haiku-4-5-20251001") + } } } @@ -28,6 +32,9 @@ fn build_profile(provider: Provider, model: &str, client: &Client) -> Box Box::new(AnthropicProfile::with_summarizer(model, summarizer)), Provider::OpenAi => Box::new(OpenAiProfile::with_summarizer(model, summarizer)), + Provider::Kimi | Provider::Zai | Provider::Minimax => Box::new( + OpenAiProfile::with_summarizer(model, summarizer).with_provider(provider), + ), Provider::Gemini => Box::new(GeminiProfile::with_summarizer(model, summarizer)), } } @@ -47,9 +54,19 @@ async fn make_session(provider: Provider, model: &str, cwd: &Path) -> Session { let sub_profile: Arc = { let summarizer = Some(build_summarizer(provider, &factory_client)); match provider { - Provider::Anthropic => Arc::new(AnthropicProfile::with_summarizer(&factory_model, summarizer)), - Provider::OpenAi => Arc::new(OpenAiProfile::with_summarizer(&factory_model, summarizer)), - Provider::Gemini => Arc::new(GeminiProfile::with_summarizer(&factory_model, summarizer)), + Provider::Anthropic => { + Arc::new(AnthropicProfile::with_summarizer(&factory_model, summarizer)) + } + Provider::OpenAi => { + Arc::new(OpenAiProfile::with_summarizer(&factory_model, summarizer)) + } + Provider::Kimi | Provider::Zai | Provider::Minimax => Arc::new( + OpenAiProfile::with_summarizer(&factory_model, summarizer) + .with_provider(provider), + ), + Provider::Gemini => { + Arc::new(GeminiProfile::with_summarizer(&factory_model, summarizer)) + } } }; let sub_env = Arc::new(LocalExecutionEnvironment::new(factory_cwd.clone())); diff --git a/crates/attractor/src/cli/backend.rs b/crates/attractor/src/cli/backend.rs index aafc651ea..a733e0887 100644 --- a/crates/attractor/src/cli/backend.rs +++ b/crates/attractor/src/cli/backend.rs @@ -76,6 +76,9 @@ impl AgentBackend { let factory: SessionFactory = Arc::new(move || { let child_profile: Arc = match factory_provider { Provider::OpenAi => Arc::new(OpenAiProfile::new(&factory_model)), + Provider::Kimi | Provider::Zai | Provider::Minimax => Arc::new( + OpenAiProfile::new(&factory_model).with_provider(factory_provider), + ), Provider::Gemini => Arc::new(GeminiProfile::new(&factory_model)), Provider::Anthropic => Arc::new(AnthropicProfile::new(&factory_model)), }; @@ -101,6 +104,9 @@ impl AgentBackend { fn build_profile(&self) -> Box { match self.provider { Provider::OpenAi => Box::new(OpenAiProfile::new(&self.model)), + Provider::Kimi | Provider::Zai | Provider::Minimax => { + Box::new(OpenAiProfile::new(&self.model).with_provider(self.provider)) + } Provider::Gemini => Box::new(GeminiProfile::new(&self.model)), Provider::Anthropic => Box::new(AnthropicProfile::new(&self.model)), } @@ -125,6 +131,8 @@ impl CodergenBackend for AgentBackend { .map(String::from) .or_else(|| Some(self.provider.as_str().to_string())); + let max_tokens = llm::catalog::get_model_info(model).and_then(|m| m.max_output); + let request = llm::types::Request { model: model.to_string(), messages: vec![llm::types::Message::user(prompt)], @@ -135,7 +143,7 @@ impl CodergenBackend for AgentBackend { response_format: None, temperature: None, top_p: None, - max_tokens: None, + max_tokens, stop_sequences: None, metadata: None, provider_options: None, diff --git a/crates/attractor/src/cli/cli_backend.rs b/crates/attractor/src/cli/cli_backend.rs index 3f0ac9366..a76879294 100644 --- a/crates/attractor/src/cli/cli_backend.rs +++ b/crates/attractor/src/cli/cli_backend.rs @@ -31,13 +31,17 @@ pub fn cli_command_for_provider(provider: Provider, model: &str, prompt_file: &s String::new() } else { match provider { - Provider::OpenAi | Provider::Gemini => format!(" -m {model}"), + Provider::OpenAi | Provider::Gemini | Provider::Kimi | Provider::Zai | Provider::Minimax => { + format!(" -m {model}") + } Provider::Anthropic => format!(" --model {model}"), } }; match provider { // --full-auto: sandboxed auto-execution, escalates on request - Provider::OpenAi => format!("codex exec --json --full-auto{model_flag} < {prompt_file}"), + Provider::OpenAi | Provider::Kimi | Provider::Zai | Provider::Minimax => { + format!("codex exec --json --full-auto{model_flag} < {prompt_file}") + } // --yolo: auto-approve all tool calls Provider::Gemini => format!("gemini -o json --yolo{model_flag} < {prompt_file}"), // --dangerously-skip-permissions: bypass all permission checks (required for non-interactive use). @@ -172,7 +176,9 @@ fn parse_gemini_json(output: &str) -> Option { /// Parse CLI output, choosing the right parser based on provider. pub fn parse_cli_response(provider: Provider, output: &str) -> Option { match provider { - Provider::OpenAi => parse_codex_ndjson(output), + Provider::OpenAi | Provider::Kimi | Provider::Zai | Provider::Minimax => { + parse_codex_ndjson(output) + } Provider::Gemini => parse_gemini_json(output), Provider::Anthropic => parse_claude_ndjson(output), } diff --git a/crates/attractor/src/cli/run.rs b/crates/attractor/src/cli/run.rs index 1368bf70e..5c2967bde 100644 --- a/crates/attractor/src/cli/run.rs +++ b/crates/attractor/src/cli/run.rs @@ -392,6 +392,9 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu .unwrap_or_else(|| match provider.as_deref() { Some("openai") => "gpt-5.2".to_string(), Some("gemini") => "gemini-3.1-pro-preview".to_string(), + Some("kimi") => "kimi-k2.5".to_string(), + Some("zai") => "glm-4.7".to_string(), + Some("minimax") => "minimax-m2.5".to_string(), _ => "claude-opus-4-6".to_string(), }); diff --git a/crates/llm/src/catalog.json b/crates/llm/src/catalog.json index 5a88b35b3..e1d9203f3 100644 --- a/crates/llm/src/catalog.json +++ b/crates/llm/src/catalog.json @@ -115,5 +115,44 @@ "input_cost_per_million": null, "output_cost_per_million": null, "aliases": ["gemini-flash"] + }, + { + "id": "kimi-k2.5", + "provider": "kimi", + "display_name": "Kimi K2.5", + "context_window": 262144, + "max_output": 16000, + "supports_tools": true, + "supports_vision": true, + "supports_reasoning": false, + "input_cost_per_million": null, + "output_cost_per_million": null, + "aliases": ["kimi"] + }, + { + "id": "glm-4.7", + "provider": "zai", + "display_name": "GLM 4.7", + "context_window": 202752, + "max_output": 16384, + "supports_tools": true, + "supports_vision": false, + "supports_reasoning": false, + "input_cost_per_million": null, + "output_cost_per_million": null, + "aliases": ["glm", "glm4"] + }, + { + "id": "minimax-m2.5", + "provider": "minimax", + "display_name": "Minimax M2.5", + "context_window": 196608, + "max_output": 16384, + "supports_tools": true, + "supports_vision": false, + "supports_reasoning": false, + "input_cost_per_million": null, + "output_cost_per_million": null, + "aliases": ["minimax"] } ] diff --git a/crates/llm/src/catalog.rs b/crates/llm/src/catalog.rs index b263d5b2f..ccc177981 100644 --- a/crates/llm/src/catalog.rs +++ b/crates/llm/src/catalog.rs @@ -62,7 +62,7 @@ mod tests { #[test] fn list_models_all() { let models = list_models(None); - assert_eq!(models.len(), 9); + assert_eq!(models.len(), 12); } #[test] @@ -81,6 +81,31 @@ mod tests { assert!(unknown.is_empty()); } + #[test] + fn kimi_k2_5_in_catalog() { + let m = get_model_info("kimi-k2.5").unwrap(); + assert_eq!(m.provider, "kimi"); + assert_eq!(m.max_output, Some(16000)); + assert_eq!(m.context_window, 262144); + } + + #[test] + fn kimi_alias() { + assert_eq!(get_model_info("kimi").unwrap().id, "kimi-k2.5"); + } + + #[test] + fn glm_4_7_in_catalog() { + let m = get_model_info("glm-4.7").unwrap(); + assert_eq!(m.provider, "zai"); + } + + #[test] + fn minimax_m2_5_in_catalog() { + let m = get_model_info("minimax-m2.5").unwrap(); + assert_eq!(m.provider, "minimax"); + } + #[test] fn model_info_costs() { let claude = get_model_info("claude-opus-4-6").unwrap(); diff --git a/crates/llm/src/client.rs b/crates/llm/src/client.rs index 8f0ffac3f..1d86b8e8f 100644 --- a/crates/llm/src/client.rs +++ b/crates/llm/src/client.rs @@ -73,6 +73,26 @@ impl Client { } client.register_provider(Arc::new(adapter)).await?; } + if let Ok(key) = std::env::var("KIMI_API_KEY") { + let adapter = + providers::OpenAiCompatibleAdapter::new(key, "https://api.moonshot.ai/v1") + .with_name("kimi"); + client.register_provider(Arc::new(adapter)).await?; + } + if let Ok(key) = std::env::var("ZAI_API_KEY") { + let adapter = providers::OpenAiCompatibleAdapter::new( + key, + "https://api.z.ai/api/coding/paas/v4", + ) + .with_name("zai"); + client.register_provider(Arc::new(adapter)).await?; + } + if let Ok(key) = std::env::var("MINIMAX_API_KEY") { + let adapter = + providers::OpenAiCompatibleAdapter::new(key, "https://api.minimax.io/v1") + .with_name("minimax"); + client.register_provider(Arc::new(adapter)).await?; + } Ok(client) } diff --git a/crates/llm/src/provider.rs b/crates/llm/src/provider.rs index 8eef74d1a..898244889 100644 --- a/crates/llm/src/provider.rs +++ b/crates/llm/src/provider.rs @@ -17,6 +17,9 @@ pub enum Provider { Anthropic, OpenAi, Gemini, + Kimi, + Zai, + Minimax, } impl Provider { @@ -28,6 +31,9 @@ impl Provider { Self::Anthropic => "anthropic", Self::OpenAi => "openai", Self::Gemini => "gemini", + Self::Kimi => "kimi", + Self::Zai => "zai", + Self::Minimax => "minimax", } } } @@ -46,6 +52,9 @@ impl FromStr for Provider { "anthropic" => Ok(Self::Anthropic), "openai" | "open_ai" => Ok(Self::OpenAi), "gemini" => Ok(Self::Gemini), + "kimi" => Ok(Self::Kimi), + "zai" => Ok(Self::Zai), + "minimax" => Ok(Self::Minimax), other => Err(format!("unknown provider: {other}")), } } @@ -139,3 +148,38 @@ pub fn validate_tool_choice( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_kimi() { + assert_eq!("kimi".parse::().unwrap(), Provider::Kimi); + } + + #[test] + fn parse_zai() { + assert_eq!("zai".parse::().unwrap(), Provider::Zai); + } + + #[test] + fn parse_minimax() { + assert_eq!("minimax".parse::().unwrap(), Provider::Minimax); + } + + #[test] + fn kimi_as_str() { + assert_eq!(Provider::Kimi.as_str(), "kimi"); + } + + #[test] + fn zai_as_str() { + assert_eq!(Provider::Zai.as_str(), "zai"); + } + + #[test] + fn minimax_as_str() { + assert_eq!(Provider::Minimax.as_str(), "minimax"); + } +} diff --git a/crates/llm/src/providers/anthropic.rs b/crates/llm/src/providers/anthropic.rs index 399e496e1..77124388c 100644 --- a/crates/llm/src/providers/anthropic.rs +++ b/crates/llm/src/providers/anthropic.rs @@ -14,6 +14,7 @@ use crate::types::{ /// Provider adapter for the Anthropic Messages API. pub struct Adapter { pub(crate) http: super::http_api::HttpApi, + provider_name: String, } impl Adapter { @@ -21,9 +22,16 @@ impl Adapter { pub fn new(api_key: impl Into) -> Self { Self { http: super::http_api::HttpApi::new(api_key, DEFAULT_BASE_URL), + provider_name: "anthropic".to_string(), } } + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.provider_name = name.into(); + self + } + #[must_use] pub fn with_base_url(mut self, base_url: impl Into) -> Self { self.http.base_url = base_url.into(); @@ -32,17 +40,37 @@ impl Adapter { #[must_use] pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { - Self { http: self.http.with_default_headers(headers) } + Self { http: self.http.with_default_headers(headers), ..self } } #[must_use] pub fn with_timeout(self, timeout: crate::types::AdapterTimeout) -> Self { - Self { http: self.http.with_timeout(timeout) } + Self { http: self.http.with_timeout(timeout), ..self } } fn messages_url(&self) -> String { format!("{}/messages", self.http.base_url) } + + /// Collect a streaming response into a single [`Response`]. + /// + /// Used by non-Anthropic providers (e.g. Kimi) that require `stream=true`. + async fn complete_via_stream(&self, request: &Request) -> Result { + use futures::StreamExt; + + let mut stream = self.stream(request).await?; + let mut response: Option = None; + + while let Some(event) = stream.next().await { + if let StreamEvent::Finish { response: r, .. } = event? { + response = Some(*r); + } + } + + response.ok_or_else(|| SdkError::Stream { + message: "complete_via_stream: stream ended without a Finish event".to_string(), + }) + } } const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1"; @@ -1056,12 +1084,17 @@ fn build_api_request( for (key, value) in &adapter.http.default_headers { req_builder = req_builder.header(key, value); } - req_builder = req_builder - .header("x-api-key", &adapter.http.api_key) - .header("anthropic-version", "2023-06-01"); - if let Some(beta_str) = build_beta_header(request.provider_options.as_ref(), auto_cache) { - req_builder = req_builder.header("anthropic-beta", beta_str); + if adapter.provider_name == "anthropic" { + req_builder = req_builder + .header("x-api-key", &adapter.http.api_key) + .header("anthropic-version", "2023-06-01"); + + if let Some(beta_str) = build_beta_header(request.provider_options.as_ref(), auto_cache) { + req_builder = req_builder.header("anthropic-beta", beta_str); + } + } else { + req_builder = req_builder.bearer_auth(&adapter.http.api_key); } let req_builder = req_builder.json(&merge_provider_options(&api_request, request.provider_options.as_ref())); @@ -1071,13 +1104,20 @@ fn build_api_request( #[async_trait::async_trait] impl ProviderAdapter for Adapter { fn name(&self) -> &str { - "anthropic" + &self.provider_name } async fn complete(&self, request: &Request) -> Result { if let Some(tc) = &request.tool_choice { crate::provider::validate_tool_choice(self, tc)?; } + + // Non-Anthropic providers (e.g. Kimi) require stream=true even for + // blocking calls. Collect the stream into a single Response. + if self.provider_name != "anthropic" { + return self.complete_via_stream(request).await; + } + let (_api_request, req_builder) = build_api_request(self, request, false); let mut req = req_builder; @@ -1085,11 +1125,11 @@ impl ProviderAdapter for Adapter { req = req.timeout(t); } let (body, headers) = - send_and_read_response(req, "anthropic", "type").await?; + send_and_read_response(req, &self.provider_name, "type").await?; let api_resp: ApiResponse = serde_json::from_str(&body).map_err(|e| SdkError::Network { - message: format!("failed to parse Anthropic response: {e}"), + message: format!("failed to parse {} response: {e}", self.provider_name), })?; let content_parts: Vec = api_resp @@ -1118,7 +1158,7 @@ impl ProviderAdapter for Adapter { Ok(Response { id: api_resp.id, model: api_resp.model, - provider: "anthropic".to_string(), + provider: self.provider_name.clone(), message: Message { role: Role::Assistant, content: content_parts, @@ -1161,7 +1201,7 @@ impl ProviderAdapter for Adapter { return Err(crate::error::error_from_status_code( status.as_u16(), msg, - "anthropic".to_string(), + self.provider_name.clone(), code, raw, retry_after, @@ -1224,6 +1264,18 @@ impl ProviderAdapter for Adapter { mod tests { use super::*; + #[test] + fn adapter_with_name() { + let adapter = Adapter::new("key").with_name("kimi"); + assert_eq!(adapter.name(), "kimi"); + } + + #[test] + fn adapter_default_name() { + let adapter = Adapter::new("key"); + assert_eq!(adapter.name(), "anthropic"); + } + #[test] fn auto_cache_enabled_by_default() { assert!(is_auto_cache_enabled(None)); diff --git a/crates/llm/src/providers/openai_compatible.rs b/crates/llm/src/providers/openai_compatible.rs index 78c965d93..ff8f9936a 100644 --- a/crates/llm/src/providers/openai_compatible.rs +++ b/crates/llm/src/providers/openai_compatible.rs @@ -7,7 +7,7 @@ use crate::providers::common::{ }; use crate::types::{ ContentPart, FinishReason, Message, Request, Response, ResponseFormat, ResponseFormatType, - Role, StreamEvent, ToolCall, ToolChoice, ToolDefinition, Usage, + Role, StreamEvent, ThinkingData, ToolCall, ToolChoice, ToolDefinition, Usage, }; /// `OpenAI`-compatible Chat Completions adapter (Section 7.10). @@ -87,6 +87,9 @@ struct ChatMessage { role: String, #[serde(skip_serializing_if = "Option::is_none")] content: Option, + /// Reasoning/thinking content echoed back for providers that require it (Kimi). + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_content: Option, #[serde(skip_serializing_if = "Option::is_none")] tool_call_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -126,6 +129,7 @@ struct ApiChoice { #[derive(serde::Deserialize)] struct ApiChoiceMessage { content: Option, + reasoning_content: Option, tool_calls: Option>, } @@ -167,6 +171,8 @@ struct StreamChoice { #[derive(serde::Deserialize)] struct StreamDelta { content: Option, + /// Reasoning/thinking content (used by Kimi and other reasoning models). + reasoning_content: Option, tool_calls: Option>, } @@ -264,9 +270,30 @@ fn translate_messages(messages: &[Message]) -> Vec { Some(tool_calls) }; + // Extract reasoning/thinking content for assistant messages. + let reasoning_content = if msg.role == Role::Assistant { + let reasoning: String = msg + .content + .iter() + .filter_map(|part| match part { + ContentPart::Thinking(t) if !t.redacted => Some(t.text.as_str()), + _ => None, + }) + .collect::>() + .join(""); + if reasoning.is_empty() { + None + } else { + Some(reasoning) + } + } else { + None + }; + ChatMessage { role: role.to_string(), content, + reasoning_content, tool_call_id: msg.tool_call_id.clone(), tool_calls, } @@ -410,6 +437,15 @@ impl ProviderAdapter for Adapter { })?; let mut content_parts = Vec::new(); + if let Some(reasoning) = &choice.message.reasoning_content { + if !reasoning.is_empty() { + content_parts.push(ContentPart::Thinking(ThinkingData { + text: reasoning.clone(), + signature: None, + redacted: false, + })); + } + } if let Some(text) = &choice.message.content { if !text.is_empty() { content_parts.push(ContentPart::text(text)); @@ -502,7 +538,19 @@ impl ProviderAdapter for Adapter { loop { let line = match state.next_line().await { Ok(Some(line)) => line, - Ok(None) => return None, + Ok(None) => { + // Stream ended without [DONE]. Some providers + // (e.g. Minimax) omit the sentinel. Emit + // accumulated finish events if we have content + // and haven't already emitted them. + if !state.finished + && (state.text_started || !state.tool_calls.is_empty()) + { + let events = state.finish_events(); + return Some((Ok(events), state)); + } + return None; + } Err(e) => return Some((Err(e), state)), }; @@ -585,11 +633,14 @@ struct StreamState { response_id: String, response_model: String, accumulated_text: String, + accumulated_reasoning: String, tool_calls: Vec, usage: Usage, finish_reason: FinishReason, text_started: bool, done: bool, + /// True after `finish_events()` has been called (guards against duplicates). + finished: bool, rate_limit: Option, } @@ -608,11 +659,13 @@ impl StreamState { response_id: String::new(), response_model: String::new(), accumulated_text: String::new(), + accumulated_reasoning: String::new(), tool_calls: Vec::new(), usage: Usage::default(), finish_reason: FinishReason::Stop, text_started: false, done: false, + finished: false, rate_limit, } } @@ -667,6 +720,13 @@ impl StreamState { let delta = choice.delta.as_ref()?; + // Accumulate reasoning/thinking content (Kimi, etc.). + if let Some(reasoning) = &delta.reasoning_content { + if !reasoning.is_empty() { + self.accumulated_reasoning.push_str(reasoning); + } + } + // Handle text content delta. if let Some(content) = &delta.content { if !content.is_empty() { @@ -737,6 +797,7 @@ impl StreamState { /// Generate the final events when `[DONE]` is received. fn finish_events(&mut self) -> Vec { + self.finished = true; let mut events = Vec::new(); // End text segment if it was started. @@ -747,6 +808,15 @@ impl StreamState { // End all tool calls with complete data. let mut content_parts = Vec::new(); + // Include reasoning/thinking content if present (Kimi, etc.). + if !self.accumulated_reasoning.is_empty() { + content_parts.push(ContentPart::Thinking(ThinkingData { + text: std::mem::take(&mut self.accumulated_reasoning), + signature: None, + redacted: false, + })); + } + if !self.accumulated_text.is_empty() { content_parts.push(ContentPart::text(&self.accumulated_text)); } @@ -806,6 +876,15 @@ impl StreamState { mod tests { use super::*; + #[test] + fn stream_chunk_minimax_format() { + let json = r#"{"id":"abc","choices":[{"index":0,"delta":{"content":"hello","role":"assistant","name":"MiniMax AI","audio_content":""}}],"created":1772268546,"model":"MiniMax-M2.5","object":"chat.completion.chunk","usage":null,"input_sensitive":false,"output_sensitive":false}"#; + let chunk: StreamChunk = serde_json::from_str(json).unwrap(); + let choices = chunk.choices.unwrap(); + let delta = choices[0].delta.as_ref().unwrap(); + assert_eq!(delta.content.as_deref(), Some("hello")); + } + #[test] fn stream_chunk_text_delta_parsing() { let json = r#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}"#;