Add Kimi, Z.AI, and Minimax provider support

All three providers use the OpenAI Chat Completions protocol via
OpenAiCompatibleAdapter:
- Kimi (KIMI_API_KEY) → api.moonshot.ai/v1
- Z.AI (ZAI_API_KEY) → api.z.ai/api/coding/paas/v4
- Minimax (MINIMAX_API_KEY) → api.minimax.io/v1

Key changes:
- Extend Provider enum with Kimi, Zai, Minimax variants
- Add with_name() to AnthropicAdapter for non-Anthropic providers
  using the Messages protocol (conditional Bearer vs x-api-key auth)
- Add complete_via_stream() for providers requiring stream=true
- Add with_provider() to AnthropicProfile and OpenAiProfile so the
  session routes requests to the correct adapter
- Add kimi-k2.5, glm-4.7, minimax-m2.5 to model catalog
- Handle reasoning_content in OpenAI compatible adapter (capture in
  stream, store as ContentPart::Thinking, echo back in assistant
  messages) — required by Kimi for multi-turn tool use
- Handle missing [DONE] sentinel in SSE streams (Minimax omits it)
- Wire up all exhaustive Provider matches across agent and attractor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-28 04:06:20 -05:00
parent 950a2d06a7
commit 5ba3535a1c
14 changed files with 374 additions and 28 deletions

View file

@ -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<Client>) ->
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<dyn ProviderProfile> = 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]

View file

@ -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 {

View file

@ -43,6 +43,14 @@ impl OpenAiProfile {
pub fn set_reasoning_effort(&mut self, effort: Option<String>) {
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 {

View file

@ -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"],
};

View file

@ -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<dyn Pr
match provider {
Provider::Anthropic => 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<dyn ProviderProfile> = {
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()));

View file

@ -76,6 +76,9 @@ impl AgentBackend {
let factory: SessionFactory = Arc::new(move || {
let child_profile: Arc<dyn ProviderProfile> = 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<dyn ProviderProfile> {
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,

View file

@ -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<CliResponse> {
/// Parse CLI output, choosing the right parser based on provider.
pub fn parse_cli_response(provider: Provider, output: &str) -> Option<CliResponse> {
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),
}

View file

@ -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(),
});

View file

@ -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"]
}
]

View file

@ -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();

View file

@ -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)
}

View file

@ -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::<Provider>().unwrap(), Provider::Kimi);
}
#[test]
fn parse_zai() {
assert_eq!("zai".parse::<Provider>().unwrap(), Provider::Zai);
}
#[test]
fn parse_minimax() {
assert_eq!("minimax".parse::<Provider>().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");
}
}

View file

@ -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<String>) -> 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<String>) -> Self {
self.provider_name = name.into();
self
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> 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<String, String>) -> 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<Response, SdkError> {
use futures::StreamExt;
let mut stream = self.stream(request).await?;
let mut response: Option<Response> = 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<Response, SdkError> {
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<ContentPart> = 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));

View file

@ -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<String>,
/// Reasoning/thinking content echoed back for providers that require it (Kimi).
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -126,6 +129,7 @@ struct ApiChoice {
#[derive(serde::Deserialize)]
struct ApiChoiceMessage {
content: Option<String>,
reasoning_content: Option<String>,
tool_calls: Option<Vec<ApiToolCall>>,
}
@ -167,6 +171,8 @@ struct StreamChoice {
#[derive(serde::Deserialize)]
struct StreamDelta {
content: Option<String>,
/// Reasoning/thinking content (used by Kimi and other reasoning models).
reasoning_content: Option<String>,
tool_calls: Option<Vec<StreamToolCall>>,
}
@ -264,9 +270,30 @@ fn translate_messages(messages: &[Message]) -> Vec<ChatMessage> {
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::<Vec<_>>()
.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<AccumulatedToolCall>,
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<crate::types::RateLimitInfo>,
}
@ -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<StreamEvent> {
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}]}"#;