mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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:
parent
950a2d06a7
commit
5ba3535a1c
14 changed files with 374 additions and 28 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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}]}"#;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue