diff --git a/README.md b/README.md index 1d1f4f2..bbe2792 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ I tried crabcode specifically for these providers: - [x] **minimax** - [x] **fireworks** - [x] **baseten** +- [x] **kimi-for-coding** (API key, Anthropic-protocol endpoint) > Feel free to create an issue / add to this list if you tried @@ -119,7 +120,6 @@ I tried crabcode specifically for these providers: > I might work harder to support these in the future. -- Kimi For Coding Subscription - I keep getting 401 but it works in OpenCode, I may have to contact them first. **might support later** - Gemini - It's OAuth + also very unsure. So currently no. - Claude Code Subscription - Known to explicitly not like harnesses. So never will, sorry. diff --git a/src/aisdk/providers/anthropic.rs b/src/aisdk/providers/anthropic.rs index d252f88..00028e1 100644 --- a/src/aisdk/providers/anthropic.rs +++ b/src/aisdk/providers/anthropic.rs @@ -18,6 +18,7 @@ pub struct Anthropic { model_name: String, provider_name: String, reasoning_effort: Option, + extra_headers: HashMap, } impl Anthropic { @@ -33,6 +34,7 @@ pub struct AnthropicBuilder { model_name: Option, provider_name: Option, reasoning_effort: Option, + extra_headers: HashMap, } impl AnthropicBuilder { @@ -61,6 +63,13 @@ impl AnthropicBuilder { self } + /// Extra headers declared by the provider catalog (e.g. a vendor-specific + /// `User-Agent`). Applied last so they can override defaults. + pub fn headers(mut self, headers: HashMap) -> Self { + self.extra_headers = headers; + self + } + pub fn build(self) -> Result { Ok(Anthropic { base_url: self @@ -74,6 +83,7 @@ impl AnthropicBuilder { .provider_name .unwrap_or_else(|| "anthropic".to_string()), reasoning_effort: self.reasoning_effort, + extra_headers: self.extra_headers, }) } } @@ -183,15 +193,7 @@ impl Provider for Anthropic { body["output_config"] = serde_json::json!({ "effort": effort }); } - let mut request_headers = reqwest::header::HeaderMap::new(); - request_headers.insert( - reqwest::header::CONTENT_TYPE, - "application/json".parse().unwrap(), - ); - if !self.api_key.is_empty() { - request_headers.insert("x-api-key", self.api_key.parse().unwrap()); - } - request_headers.insert("anthropic-version", "2023-06-01".parse().unwrap()); + let request_headers = build_anthropic_request_headers(&self.api_key, &self.extra_headers); let client = reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs( @@ -252,6 +254,31 @@ impl Provider for Anthropic { } } +fn build_anthropic_request_headers( + api_key: &str, + extra_headers: &HashMap, +) -> reqwest::header::HeaderMap { + let mut request_headers = reqwest::header::HeaderMap::new(); + request_headers.insert( + reqwest::header::CONTENT_TYPE, + "application/json".parse().unwrap(), + ); + if !api_key.is_empty() { + request_headers.insert("x-api-key", api_key.parse().unwrap()); + } + request_headers.insert("anthropic-version", "2023-06-01".parse().unwrap()); + + for (name, value) in extra_headers { + if let (Ok(hn), Ok(hv)) = ( + reqwest::header::HeaderName::from_bytes(name.as_bytes()), + reqwest::header::HeaderValue::from_str(value), + ) { + request_headers.insert(hn, hv); + } + } + request_headers +} + fn anthropic_stream_chunk( event_type: &str, value: &serde_json::Value, @@ -548,6 +575,25 @@ mod tests { } )); } + + #[test] + fn kimi_for_coding_sends_cli_user_agent() { + let mut extra = HashMap::new(); + extra.insert("User-Agent".to_string(), "KimiCLI/1.5".to_string()); + let headers = build_anthropic_request_headers("", &extra); + assert_eq!( + headers + .get(reqwest::header::USER_AGENT) + .map(|v| v.to_str().unwrap()), + Some("KimiCLI/1.5") + ); + } + + #[test] + fn anthropic_default_sends_no_extra_headers() { + let headers = build_anthropic_request_headers("", &HashMap::new()); + assert!(headers.get(reqwest::header::USER_AGENT).is_none()); + } } fn anthropic_tool_output_content(tool: &crate::message::ToolOutputMessage) -> serde_json::Value { diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 98b304b..89f1f01 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -128,6 +128,7 @@ pub fn handle_connect<'a>( doc: String::new(), env: Vec::new(), npm: String::new(), + header: vec![], models: HashMap::new(), }, ); @@ -142,6 +143,7 @@ pub fn handle_connect<'a>( doc: String::new(), env: Vec::new(), npm: String::new(), + header: vec![], models: HashMap::new(), }, ); diff --git a/src/llm/client.rs b/src/llm/client.rs index 8fde5f1..3366392 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1249,6 +1249,15 @@ async fn stream_provider_request( if let Some(key) = config.api_key.as_deref() { builder = builder.api_key(key); } + if let Some(provider_meta) = + crate::model::extensions::ModelExtensions::provider_for_request( + &config.provider_name, + ) + { + if !provider_meta.header.is_empty() { + builder = builder.headers(provider_meta.header.into_iter().collect()); + } + } let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; stream_with_tools( provider, diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 08098f0..f87dc86 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -25,6 +25,8 @@ pub struct Provider { #[serde(default)] pub npm: String, #[serde(default)] + pub header: Vec<(String, String)>, + #[serde(default)] pub models: HashMap, } @@ -502,6 +504,7 @@ impl Discovery { doc: String::new(), env: Vec::new(), npm: String::new(), + header: vec![], models: HashMap::new(), }, ); @@ -548,6 +551,7 @@ impl Discovery { doc: String::new(), env: Vec::new(), npm: String::new(), + header: vec![], models: HashMap::new(), }); @@ -966,6 +970,7 @@ mod tests { doc: "https://catalog.example/docs".to_string(), env: vec!["CATALOG_KEY".to_string()], npm: "@ai-sdk/openai-compatible".to_string(), + header: vec![], models: HashMap::from([( "vision-model".to_string(), Model { @@ -1218,6 +1223,7 @@ mod tests { doc: String::new(), env: Vec::new(), npm: String::new(), + header: vec![], models: HashMap::new(), }, ); @@ -1356,6 +1362,7 @@ mod tests { doc: String::new(), env: vec!["OPENCODE_API_KEY".to_string()], npm: "@ai-sdk/openai-compatible".to_string(), + header: vec![], models, }, ); @@ -1397,6 +1404,7 @@ mod tests { doc: String::new(), env: vec!["XAI_API_KEY".to_string()], npm: "@ai-sdk/xai".to_string(), + header: vec![], models: HashMap::new(), }, ); @@ -1435,6 +1443,7 @@ mod tests { doc: String::new(), env: Vec::new(), npm: String::new(), + header: vec![], models: HashMap::new(), }, ); @@ -1468,6 +1477,7 @@ mod tests { doc: String::new(), env: Vec::new(), npm: String::new(), + header: vec![], models: HashMap::new(), }, )]); diff --git a/src/model/extensions/commandcode.rs b/src/model/extensions/commandcode.rs index bbc7d71..cf57e4d 100644 --- a/src/model/extensions/commandcode.rs +++ b/src/model/extensions/commandcode.rs @@ -114,6 +114,7 @@ pub fn provider_from_models(models: Vec) -> crate::model::disc doc: DOC_URL.to_string(), env: vec![API_KEY_ENV.to_string()], npm: NPM_PACKAGE.to_string(), + header: vec![], models: models .into_iter() .filter(|model| !model.id.trim().is_empty()) diff --git a/src/model/extensions/kimicode.rs b/src/model/extensions/kimicode.rs new file mode 100644 index 0000000..95b8c91 --- /dev/null +++ b/src/model/extensions/kimicode.rs @@ -0,0 +1,226 @@ +use crate::model::discovery::{Modalities, Model, ModelProvider, Provider}; +use reqwest::Client; +use std::collections::HashMap; + +/// "Kimi For Coding" is a separate Moonshot product that is **not** part of the +/// general Moonshot (api.moonshot.ai) API. It exposes the Anthropic Messages +/// protocol at `https://api.kimi.com/coding/v1` and is authenticated with a +/// distinct `sk-kimi-...` key. The `moonshotai` provider therefore cannot reach +/// it (it hits api.moonshot.ai with OpenAI-compatible chat/completions), which +/// is why a bare key produced a 401. +/// +/// This extension registers `kimi-for-coding` as a first-class provider that +/// uses the Anthropic transport, so the user only needs to drop a key into +/// auth.json (mirroring how OpenCode wires it up): +/// +/// ```json +/// "kimi-for-coding": { "type": "api", "key": "sk-kimi-..." } +/// ``` +pub const PROVIDER_ID: &str = "kimi-for-coding"; +pub const PROVIDER_NAME: &str = "Kimi For Coding"; +pub const BASE_URL: &str = "https://api.kimi.com/coding/v1"; +pub const DOC_URL: &str = "https://www.kimi.com/code/docs/en/kimi-code/models"; +pub const NPM_PACKAGE: &str = "@ai-sdk/anthropic"; +pub const API_KEY_ENV: &str = "KIMI_API_KEY"; + +pub static EXTENSION: Extension = Extension; + +pub struct Extension; + +impl crate::model::extensions::ProviderCatalogExtension for Extension { + fn provider_id(&self) -> &'static str { + PROVIDER_ID + } + + fn provider_name(&self) -> &'static str { + PROVIDER_NAME + } +} + +impl crate::model::extensions::PersistentProviderCatalogExtension for Extension { + fn augment<'a>( + &'a self, + providers: &'a mut HashMap, + _cached: Option<&'a HashMap>, + _client: &'a Client, + ) -> std::pin::Pin + Send + 'a>> { + Box::pin(async move { + if providers.contains_key(PROVIDER_ID) { + return false; + } + providers.insert(PROVIDER_ID.to_string(), static_provider()); + true + }) + } +} + +fn anthropic_override() -> ModelProvider { + ModelProvider { + npm: Some(NPM_PACKAGE.to_string()), + api: Some(BASE_URL.to_string()), + } +} + +fn static_provider() -> Provider { + let mut models = HashMap::new(); + + models.insert( + "kimi-for-coding".to_string(), + Model { + id: "kimi-for-coding".to_string(), + name: "Kimi K2.7 Code".to_string(), + family: "kimi".to_string(), + attachment: true, + reasoning: false, + reasoning_options: Vec::new(), + tool_call: true, + structured_output: false, + temperature: true, + knowledge: String::new(), + release_date: String::new(), + last_updated: String::new(), + status: Some("stable".to_string()), + modalities: Some(Modalities { + input: vec!["text".to_string(), "image".to_string(), "video".to_string()], + output: vec!["text".to_string()], + }), + open_weights: false, + cost: None, + limit: Some(crate::model::discovery::Limit { + context: 256_000, + output: 16_384, + }), + provider: Some(anthropic_override()), + }, + ); + + models.insert( + "kimi-for-coding-highspeed".to_string(), + Model { + id: "kimi-for-coding-highspeed".to_string(), + name: "Kimi For Coding HighSpeed".to_string(), + family: "kimi".to_string(), + attachment: true, + reasoning: false, + reasoning_options: Vec::new(), + tool_call: true, + structured_output: false, + temperature: true, + knowledge: String::new(), + release_date: String::new(), + last_updated: String::new(), + status: Some("stable".to_string()), + modalities: Some(Modalities { + input: vec!["text".to_string(), "image".to_string(), "video".to_string()], + output: vec!["text".to_string()], + }), + open_weights: false, + cost: None, + limit: Some(crate::model::discovery::Limit { + context: 256_000, + output: 16_384, + }), + provider: Some(anthropic_override()), + }, + ); + + models.insert( + "k3".to_string(), + Model { + id: "k3".to_string(), + name: "Kimi K3".to_string(), + family: "kimi".to_string(), + attachment: true, + reasoning: true, + reasoning_options: vec![crate::model::reasoning::ReasoningOption { + kind: "effort".to_string(), + values: vec!["low".to_string(), "high".to_string(), "max".to_string()], + }], + tool_call: true, + structured_output: false, + temperature: true, + knowledge: String::new(), + release_date: String::new(), + last_updated: String::new(), + status: Some("stable".to_string()), + modalities: Some(Modalities { + input: vec!["text".to_string(), "image".to_string(), "video".to_string()], + output: vec!["text".to_string()], + }), + open_weights: false, + cost: None, + limit: Some(crate::model::discovery::Limit { + context: 1_000_000, + output: 16_384, + }), + provider: Some(anthropic_override()), + }, + ); + + models.insert( + "k3-256k".to_string(), + Model { + id: "k3-256k".to_string(), + name: "Kimi K3 256K".to_string(), + family: "kimi".to_string(), + attachment: true, + reasoning: true, + reasoning_options: vec![crate::model::reasoning::ReasoningOption { + kind: "effort".to_string(), + values: vec!["low".to_string(), "high".to_string(), "max".to_string()], + }], + tool_call: true, + structured_output: false, + temperature: true, + knowledge: String::new(), + release_date: String::new(), + last_updated: String::new(), + status: Some("stable".to_string()), + modalities: Some(Modalities { + input: vec!["text".to_string(), "image".to_string()], + output: vec!["text".to_string()], + }), + open_weights: false, + cost: None, + limit: Some(crate::model::discovery::Limit { + context: 256_000, + output: 16_384, + }), + provider: Some(anthropic_override()), + }, + ); + + Provider { + id: PROVIDER_ID.to_string(), + name: PROVIDER_NAME.to_string(), + api: BASE_URL.to_string(), + doc: DOC_URL.to_string(), + env: vec![API_KEY_ENV.to_string()], + npm: NPM_PACKAGE.to_string(), + header: vec![("User-Agent".to_string(), "KimiCLI/1.5".to_string())], + models, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_uses_anthropic_transport() { + let provider = static_provider(); + assert_eq!(provider.id, PROVIDER_ID); + assert_eq!(provider.npm, "@ai-sdk/anthropic"); + assert_eq!(provider.api, "https://api.kimi.com/coding/v1"); + } + + #[test] + fn all_models_route_through_anthropic() { + let provider = static_provider(); + for model in provider.models.values() { + let route = model.provider.as_ref().expect("model provider override"); + assert_eq!(route.npm.as_deref(), Some("@ai-sdk/anthropic")); + assert_eq!(route.api.as_deref(), Some("https://api.kimi.com/coding/v1")); + } + } +} diff --git a/src/model/extensions/mod.rs b/src/model/extensions/mod.rs index 98e4a17..a608c3f 100644 --- a/src/model/extensions/mod.rs +++ b/src/model/extensions/mod.rs @@ -5,12 +5,16 @@ use std::collections::HashMap; use crate::model::discovery::Provider; pub mod commandcode; +pub mod kimicode; pub mod ollama; const CATALOG_EXTENSIONS_JSON: &str = include_str!("catalog_extensions.json"); static CATALOG_JSON_EXTENSION: CatalogJsonExtension = CatalogJsonExtension; -static PERSISTENT_EXTENSIONS: [&dyn PersistentProviderCatalogExtension; 2] = - [&commandcode::EXTENSION, &CATALOG_JSON_EXTENSION]; +static PERSISTENT_EXTENSIONS: [&dyn PersistentProviderCatalogExtension; 3] = [ + &commandcode::EXTENSION, + &kimicode::EXTENSION, + &CATALOG_JSON_EXTENSION, +]; static RUNTIME_EXTENSIONS: [&dyn RuntimeProviderCatalogExtension; 1] = [&ollama::EXTENSION]; /// Model provider catalog extensions that are not available directly from @@ -328,6 +332,7 @@ mod tests { doc: String::new(), env: vec!["XAI_API_KEY".to_string()], npm: "@ai-sdk/xai".to_string(), + header: vec![], models: HashMap::new(), }, ); diff --git a/src/model/extensions/ollama.rs b/src/model/extensions/ollama.rs index f48be64..8d69744 100644 --- a/src/model/extensions/ollama.rs +++ b/src/model/extensions/ollama.rs @@ -85,6 +85,7 @@ pub fn provider() -> crate::model::discovery::Provider { doc: "https://ollama.com".to_string(), env: Vec::new(), npm: NPM_PACKAGE.to_string(), + header: vec![], models: cached_discovery_models().unwrap_or_default(), } }