diff --git a/Cargo.lock b/Cargo.lock index 2d5bd8e1..9da75e3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4090,6 +4090,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 928973c0..c20bb618 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -151,6 +151,17 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "kimi-k2.6".to_string(), + provider: ProviderKind::Moonshot, + aliases: vec![ + "kimi".to_string(), + "kimi-k2".to_string(), + "moonshot-kimi-k2.6".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "deepseek-ai/DeepSeek-V4-Pro".to_string(), provider: ProviderKind::Sglang, diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index c27d699f..69afe196 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -31,6 +31,7 @@ enum ProviderArg { Openrouter, Novita, Fireworks, + Moonshot, Sglang, Vllm, Ollama, @@ -47,6 +48,7 @@ impl From for ProviderKind { ProviderArg::Openrouter => ProviderKind::Openrouter, ProviderArg::Novita => ProviderKind::Novita, ProviderArg::Fireworks => ProviderKind::Fireworks, + ProviderArg::Moonshot => ProviderKind::Moonshot, ProviderArg::Sglang => ProviderKind::Sglang, ProviderArg::Vllm => ProviderKind::Vllm, ProviderArg::Ollama => ProviderKind::Ollama, @@ -737,6 +739,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str { ProviderKind::Openrouter => "openrouter", ProviderKind::Novita => "novita", ProviderKind::Fireworks => "fireworks", + ProviderKind::Moonshot => "moonshot", ProviderKind::Sglang => "sglang", ProviderKind::Vllm => "vllm", ProviderKind::Ollama => "ollama", @@ -744,7 +747,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str { } /// Provider order used by the `auth list` and `auth status` outputs. -const PROVIDER_LIST: [ProviderKind; 11] = [ +const PROVIDER_LIST: [ProviderKind; 12] = [ ProviderKind::Deepseek, ProviderKind::NvidiaNim, ProviderKind::Openai, @@ -753,6 +756,7 @@ const PROVIDER_LIST: [ProviderKind; 11] = [ ProviderKind::Openrouter, ProviderKind::Novita, ProviderKind::Fireworks, + ProviderKind::Moonshot, ProviderKind::Sglang, ProviderKind::Vllm, ProviderKind::Ollama, @@ -807,6 +811,7 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] { ProviderKind::Novita => &["NOVITA_API_KEY"], ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"], ProviderKind::Fireworks => &["FIREWORKS_API_KEY"], + ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"], ProviderKind::Sglang => &["SGLANG_API_KEY"], ProviderKind::Vllm => &["VLLM_API_KEY"], ProviderKind::Ollama => &["OLLAMA_API_KEY"], @@ -2126,6 +2131,18 @@ mod tests { })) )); + let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Set { + provider: ProviderArg::Moonshot, + api_key: None, + api_key_stdin: false, + } + })) + )); + let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]); assert!(matches!( cli.command, diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 9bfb089e..c1adc8da 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -30,6 +30,10 @@ const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro"; const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro"; +const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.6"; +const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1"; +const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding"; +const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1"; const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; @@ -68,6 +72,7 @@ pub enum ProviderKind { Openrouter, Novita, Fireworks, + Moonshot, Sglang, Vllm, Ollama, @@ -85,6 +90,7 @@ impl ProviderKind { Self::Openrouter => "openrouter", Self::Novita => "novita", Self::Fireworks => "fireworks", + Self::Moonshot => "moonshot", Self::Sglang => "sglang", Self::Vllm => "vllm", Self::Ollama => "ollama", @@ -104,6 +110,7 @@ impl ProviderKind { "openrouter" | "open_router" => Some(Self::Openrouter), "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), + "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot), "sglang" | "sg-lang" => Some(Self::Sglang), "vllm" | "v-llm" => Some(Self::Vllm), "ollama" | "ollama-local" => Some(Self::Ollama), @@ -117,6 +124,7 @@ pub struct ProviderConfigToml { pub api_key: Option, pub base_url: Option, pub model: Option, + pub auth_mode: Option, #[serde(default)] pub http_headers: BTreeMap, } @@ -140,6 +148,8 @@ pub struct ProvidersToml { #[serde(default)] pub fireworks: ProviderConfigToml, #[serde(default)] + pub moonshot: ProviderConfigToml, + #[serde(default)] pub sglang: ProviderConfigToml, #[serde(default)] pub vllm: ProviderConfigToml, @@ -159,6 +169,7 @@ impl ProvidersToml { ProviderKind::Openrouter => &self.openrouter, ProviderKind::Novita => &self.novita, ProviderKind::Fireworks => &self.fireworks, + ProviderKind::Moonshot => &self.moonshot, ProviderKind::Sglang => &self.sglang, ProviderKind::Vllm => &self.vllm, ProviderKind::Ollama => &self.ollama, @@ -175,6 +186,7 @@ impl ProvidersToml { ProviderKind::Openrouter => &mut self.openrouter, ProviderKind::Novita => &mut self.novita, ProviderKind::Fireworks => &mut self.fireworks, + ProviderKind::Moonshot => &mut self.moonshot, ProviderKind::Sglang => &mut self.sglang, ProviderKind::Vllm => &mut self.vllm, ProviderKind::Ollama => &mut self.ollama, @@ -979,6 +991,12 @@ impl ConfigToml { let root_deepseek_model = (provider == ProviderKind::Deepseek) .then(|| self.default_text_model.clone()) .flatten(); + let auth_mode = cli + .auth_mode + .clone() + .or_else(|| env.auth_mode.clone()) + .or_else(|| provider_cfg.auth_mode.clone()) + .or_else(|| self.auth_mode.clone()); let base_url = cli .base_url .clone() @@ -994,23 +1012,29 @@ impl ConfigToml { ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(), ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(), ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(), + ProviderKind::Moonshot => { + if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) { + DEFAULT_KIMI_CODE_BASE_URL.to_string() + } else { + DEFAULT_MOONSHOT_BASE_URL.to_string() + } + } ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(), ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(), ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(), }); - let auth_mode = cli - .auth_mode - .clone() - .or_else(|| env.auth_mode.clone()) - .or_else(|| self.auth_mode.clone()); // CLI flag wins outright. Otherwise: config-file → injected secrets/env. // This makes `deepseek auth set` a reliable fix even when the user's // shell still exports an old key. When the file is empty, the injected // secrets façade recovers configured secret-store credentials before // falling back to ambient env. + let uses_kimi_oauth = provider == ProviderKind::Moonshot + && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth); let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key); let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() { (Some(value), Some(RuntimeApiKeySource::Cli)) + } else if uses_kimi_oauth { + (None, None) } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) { (Some(value), Some(RuntimeApiKeySource::ConfigFile)) } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) { @@ -1045,7 +1069,15 @@ impl ConfigToml { .or_else(|| provider_cfg.model.clone()) .or(root_deepseek_model) .or_else(|| self.model.clone()) - .unwrap_or_else(|| default_model_for_provider(provider).to_string()); + .unwrap_or_else(|| { + if provider == ProviderKind::Moonshot + && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) + { + DEFAULT_KIMI_CODE_MODEL.to_string() + } else { + default_model_for_provider(provider).to_string() + } + }); let model = if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) { model.trim().to_string() @@ -1174,6 +1206,7 @@ fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => { DEFAULT_FIREWORKS_MODEL.to_string() } + (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2") => DEFAULT_MOONSHOT_MODEL.to_string(), (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => { DEFAULT_SGLANG_MODEL.to_string() } @@ -1204,6 +1237,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL, ProviderKind::Novita => DEFAULT_NOVITA_MODEL, ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL, + ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL, ProviderKind::Sglang => DEFAULT_SGLANG_MODEL, ProviderKind::Vllm => DEFAULT_VLLM_MODEL, ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL, @@ -1220,6 +1254,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL, ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL, ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL, ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL, ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL, @@ -1282,6 +1317,17 @@ fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool { ) } +fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool { + matches!( + auth_mode + .trim() + .to_ascii_lowercase() + .replace('-', "_") + .as_str(), + "kimi" | "kimi_oauth" | "kimi_cli" | "oauth" + ) +} + fn base_url_uses_local_host(base_url: &str) -> bool { let Some(host) = base_url_host(base_url) else { return false; @@ -1672,6 +1718,7 @@ struct EnvRuntimeOverrides { provider: Option, model: Option, wanjie_ark_model: Option, + moonshot_model: Option, output_mode: Option, auth_mode: Option, log_level: Option, @@ -1688,6 +1735,7 @@ struct EnvRuntimeOverrides { openrouter_base_url: Option, novita_base_url: Option, fireworks_base_url: Option, + moonshot_base_url: Option, sglang_base_url: Option, vllm_base_url: Option, ollama_base_url: Option, @@ -1705,6 +1753,11 @@ impl EnvRuntimeOverrides { .or_else(|_| std::env::var("WANJIE_MAAS_MODEL")) .ok() .filter(|v| !v.trim().is_empty()), + moonshot_model: std::env::var("MOONSHOT_MODEL") + .or_else(|_| std::env::var("KIMI_MODEL_NAME")) + .or_else(|_| std::env::var("KIMI_MODEL")) + .ok() + .filter(|v| !v.trim().is_empty()), output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(), auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(), log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(), @@ -1748,6 +1801,10 @@ impl EnvRuntimeOverrides { fireworks_base_url: std::env::var("FIREWORKS_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + moonshot_base_url: std::env::var("MOONSHOT_BASE_URL") + .or_else(|_| std::env::var("KIMI_BASE_URL")) + .ok() + .filter(|v| !v.trim().is_empty()), sglang_base_url: std::env::var("SGLANG_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), @@ -1772,6 +1829,7 @@ impl EnvRuntimeOverrides { ProviderKind::Openrouter => self.openrouter_base_url.clone(), ProviderKind::Novita => self.novita_base_url.clone(), ProviderKind::Fireworks => self.fireworks_base_url.clone(), + ProviderKind::Moonshot => self.moonshot_base_url.clone(), ProviderKind::Sglang => self.sglang_base_url.clone(), ProviderKind::Vllm => self.vllm_base_url.clone(), ProviderKind::Ollama => self.ollama_base_url.clone(), @@ -1781,6 +1839,7 @@ impl EnvRuntimeOverrides { fn model_for(&self, provider: ProviderKind) -> Option { match provider { ProviderKind::WanjieArk => self.wanjie_ark_model.clone(), + ProviderKind::Moonshot => self.moonshot_model.clone(), _ => None, } } @@ -1839,6 +1898,13 @@ mod tests { novita_base_url: Option, fireworks_api_key: Option, fireworks_base_url: Option, + moonshot_api_key: Option, + moonshot_base_url: Option, + moonshot_model: Option, + kimi_api_key: Option, + kimi_base_url: Option, + kimi_model: Option, + kimi_model_name: Option, sglang_api_key: Option, sglang_base_url: Option, vllm_api_key: Option, @@ -1874,6 +1940,13 @@ mod tests { novita_base_url: env::var_os("NOVITA_BASE_URL"), fireworks_api_key: env::var_os("FIREWORKS_API_KEY"), fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"), + moonshot_api_key: env::var_os("MOONSHOT_API_KEY"), + moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"), + moonshot_model: env::var_os("MOONSHOT_MODEL"), + kimi_api_key: env::var_os("KIMI_API_KEY"), + kimi_base_url: env::var_os("KIMI_BASE_URL"), + kimi_model: env::var_os("KIMI_MODEL"), + kimi_model_name: env::var_os("KIMI_MODEL_NAME"), sglang_api_key: env::var_os("SGLANG_API_KEY"), sglang_base_url: env::var_os("SGLANG_BASE_URL"), vllm_api_key: env::var_os("VLLM_API_KEY"), @@ -1907,6 +1980,13 @@ mod tests { env::remove_var("NOVITA_BASE_URL"); env::remove_var("FIREWORKS_API_KEY"); env::remove_var("FIREWORKS_BASE_URL"); + env::remove_var("MOONSHOT_API_KEY"); + env::remove_var("MOONSHOT_BASE_URL"); + env::remove_var("MOONSHOT_MODEL"); + env::remove_var("KIMI_API_KEY"); + env::remove_var("KIMI_BASE_URL"); + env::remove_var("KIMI_MODEL"); + env::remove_var("KIMI_MODEL_NAME"); env::remove_var("SGLANG_API_KEY"); env::remove_var("SGLANG_BASE_URL"); env::remove_var("VLLM_API_KEY"); @@ -1954,6 +2034,13 @@ mod tests { Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take()); Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take()); + Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take()); + Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take()); + Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take()); + Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take()); + Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take()); + Self::restore_var("KIMI_MODEL", self.kimi_model.take()); + Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take()); Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take()); Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take()); Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take()); @@ -2356,6 +2443,11 @@ mod tests { ProviderKind::parse("fireworks-ai"), Some(ProviderKind::Fireworks) ); + assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot)); + assert_eq!( + ProviderKind::parse("moonshot-ai"), + Some(ProviderKind::Moonshot) + ); assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang)); assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm)); assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm)); @@ -2442,6 +2534,42 @@ mod tests { assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL); } + #[test] + fn moonshot_provider_defaults_to_kimi_k2() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL); + assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL); + } + + #[test] + fn moonshot_kimi_oauth_uses_kimi_code_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth")); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.api_key, None); + assert_eq!(resolved.api_key_source, None); + } + #[test] fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() { let _lock = env_lock(); @@ -2556,6 +2684,25 @@ mod tests { assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]); } + #[test] + fn moonshot_api_key_mode_can_use_secret_store_by_default() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key")); + let secrets = Secrets::new(store.clone()); + let config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + + assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring)); + assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]); + } + #[test] fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() { let _lock = env_lock(); diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index f2616391..0254aa61 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -535,6 +535,7 @@ pub fn env_for(name: &str) -> Option { &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"] } "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"], + "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"], "sglang" | "sg-lang" => &["SGLANG_API_KEY"], "vllm" | "v-llm" => &["VLLM_API_KEY"], "ollama" | "ollama-local" => &["OLLAMA_API_KEY"], diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index f78b57a4..63720042 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -45,7 +45,7 @@ fd-lock = "4.0.4" futures-util = "0.3.31" ratatui = "0.30" regex = "1.11" -reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "rustls", "http2", "gzip", "brotli"] } +reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "form", "rustls", "http2", "gzip", "brotli"] } similar = "2" rustyline = "15.0.0" serde = { version = "1.0.228", features = ["derive"] } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 8ecd3e4c..570b2762 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -904,6 +904,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Moonshot | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ @@ -941,6 +942,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Moonshot | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ @@ -970,6 +972,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Moonshot | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index b4171255..12926b70 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -6,6 +6,7 @@ use std::fs; #[cfg(unix)] use std::io::Write as _; use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -50,6 +51,10 @@ pub const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; pub const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1"; pub const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro"; pub const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1"; +pub const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.6"; +pub const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1"; +pub const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding"; +pub const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1"; pub const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; pub const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; pub const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1"; @@ -88,6 +93,7 @@ pub enum ApiProvider { Openrouter, Novita, Fireworks, + Moonshot, Sglang, Vllm, Ollama, @@ -109,6 +115,7 @@ impl ApiProvider { "openrouter" | "open_router" => Some(Self::Openrouter), "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), + "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot), "sglang" | "sg-lang" => Some(Self::Sglang), "vllm" | "v-llm" => Some(Self::Vllm), "ollama" | "ollama-local" => Some(Self::Ollama), @@ -128,6 +135,7 @@ impl ApiProvider { Self::Openrouter => "openrouter", Self::Novita => "novita", Self::Fireworks => "fireworks", + Self::Moonshot => "moonshot", Self::Sglang => "sglang", Self::Vllm => "vllm", Self::Ollama => "ollama", @@ -147,6 +155,7 @@ impl ApiProvider { Self::Openrouter => "OpenRouter", Self::Novita => "Novita AI", Self::Fireworks => "Fireworks AI", + Self::Moonshot => "Moonshot/Kimi", Self::Sglang => "SGLang", Self::Vllm => "vLLM", Self::Ollama => "Ollama", @@ -165,6 +174,7 @@ impl ApiProvider { Self::Openrouter, Self::Novita, Self::Fireworks, + Self::Moonshot, Self::Sglang, Self::Vllm, Self::Ollama, @@ -233,7 +243,10 @@ pub enum RequestPayloadMode { /// in the API payload (after normalization / provider-specific mapping). #[must_use] pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability { - if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) { + if matches!( + provider, + ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Moonshot + ) { return ProviderCapability { provider, resolved_model: resolved_model.to_string(), @@ -420,6 +433,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL], ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL], ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL], + ApiProvider::Moonshot => vec![DEFAULT_MOONSHOT_MODEL], ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL], ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL], ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL], @@ -922,6 +936,7 @@ pub struct Config { /// Optional extra HTTP headers sent to model API requests. pub http_headers: Option>, pub default_text_model: Option, + pub auth_mode: Option, /// DeepSeek reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`. /// Defaults to `"max"` at runtime if unset. pub reasoning_effort: Option, @@ -1209,6 +1224,7 @@ pub struct ProviderConfig { pub api_key: Option, pub base_url: Option, pub model: Option, + pub auth_mode: Option, pub http_headers: Option>, } @@ -1233,6 +1249,8 @@ pub struct ProvidersConfig { #[serde(default)] pub fireworks: ProviderConfig, #[serde(default)] + pub moonshot: ProviderConfig, + #[serde(default)] pub sglang: ProviderConfig, #[serde(default)] pub vllm: ProviderConfig, @@ -1343,6 +1361,7 @@ impl Config { ApiProvider::Openrouter => "providers.openrouter", ApiProvider::Novita => "providers.novita", ApiProvider::Fireworks => "providers.fireworks", + ApiProvider::Moonshot => "providers.moonshot", ApiProvider::Sglang => "providers.sglang", ApiProvider::Vllm => "providers.vllm", ApiProvider::Ollama => "providers.ollama", @@ -1484,6 +1503,7 @@ impl Config { ApiProvider::Openrouter => &providers.openrouter, ApiProvider::Novita => &providers.novita, ApiProvider::Fireworks => &providers.fireworks, + ApiProvider::Moonshot => &providers.moonshot, ApiProvider::Sglang => &providers.sglang, ApiProvider::Vllm => &providers.vllm, ApiProvider::Ollama => &providers.ollama, @@ -1550,6 +1570,13 @@ impl Config { { return model_for_provider(provider, normalized); } + if provider == ApiProvider::Moonshot + && self + .provider_config() + .is_some_and(provider_config_uses_kimi_oauth) + { + return DEFAULT_KIMI_CODE_MODEL.to_string(); + } match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL, @@ -1560,6 +1587,7 @@ impl Config { ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL, ApiProvider::Novita => DEFAULT_NOVITA_MODEL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL, + ApiProvider::Moonshot => DEFAULT_MOONSHOT_MODEL, ApiProvider::Sglang => DEFAULT_SGLANG_MODEL, ApiProvider::Vllm => DEFAULT_VLLM_MODEL, ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL, @@ -1591,6 +1619,7 @@ impl Config { | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks + | ApiProvider::Moonshot | ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => None, @@ -1606,6 +1635,16 @@ impl Config { ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ApiProvider::Moonshot => { + if self + .provider_config() + .is_some_and(provider_config_uses_kimi_oauth) + { + DEFAULT_KIMI_CODE_BASE_URL + } else { + DEFAULT_MOONSHOT_BASE_URL + } + } ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL, @@ -1639,6 +1678,7 @@ impl Config { ApiProvider::Openrouter => "openrouter", ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", + ApiProvider::Moonshot => "moonshot", ApiProvider::Sglang => "sglang", ApiProvider::Vllm => "vllm", ApiProvider::Ollama => "ollama", @@ -1655,6 +1695,14 @@ impl Config { return Ok(configured.clone()); } + if provider == ApiProvider::Moonshot + && self + .provider_config_for(provider) + .is_some_and(provider_config_uses_kimi_oauth) + { + return kimi_cli_oauth_access_token(); + } + // 1. Config file (provider-scoped slot). This intentionally wins // over ambient env so `codewhale auth set` fixes stale shell exports. if let Some(configured) = self @@ -1721,6 +1769,11 @@ impl Config { "Fireworks AI API key not found. Run 'codewhale auth set --provider fireworks', \ set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml." ), + ApiProvider::Moonshot => anyhow::bail!( + "Moonshot/Kimi API key not found. Run 'codewhale auth set --provider moonshot', \ + set MOONSHOT_API_KEY/KIMI_API_KEY, add [providers.moonshot] api_key, \ + or run `kimi login` and set [providers.moonshot] auth_mode = \"kimi_oauth\"." + ), // Self-hosted deployments commonly run without auth on localhost. // Return an empty key and let the client omit the Authorization header. ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()), @@ -2262,6 +2315,13 @@ fn apply_env_overrides(config: &mut Config) { .fireworks .base_url = Some(value); } + ApiProvider::Moonshot => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .moonshot + .base_url = Some(value); + } ApiProvider::Sglang => { config .providers @@ -2368,6 +2428,17 @@ fn apply_env_overrides(config: &mut Config) { .fireworks .base_url = Some(value); } + if matches!(config.api_provider(), ApiProvider::Moonshot) + && let Ok(value) = + std::env::var("MOONSHOT_BASE_URL").or_else(|_| std::env::var("KIMI_BASE_URL")) + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .moonshot + .base_url = Some(value); + } if matches!(config.api_provider(), ApiProvider::Sglang) && let Ok(value) = std::env::var("SGLANG_BASE_URL") && !value.trim().is_empty() @@ -2410,6 +2481,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, @@ -2468,6 +2540,17 @@ fn apply_env_overrides(config: &mut Config) { .wanjie_ark .model = Some(value); } + if matches!(config.api_provider(), ApiProvider::Moonshot) + && let Ok(value) = std::env::var("MOONSHOT_MODEL") + .or_else(|_| std::env::var("KIMI_MODEL_NAME")) + .or_else(|_| std::env::var("KIMI_MODEL")) + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .moonshot + .model = Some(value); + } if let Ok(value) = std::env::var("DEEPSEEK_MODEL").or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")) { @@ -2497,6 +2580,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, @@ -2724,6 +2808,12 @@ fn normalize_model_config(config: &mut Config) { { providers.fireworks.model = Some(normalized); } + if let Some(model) = providers.moonshot.model.as_deref() + && !provider_entry_uses_custom_base_url(ApiProvider::Moonshot, &providers.moonshot) + && let Some(normalized) = normalize_model_for_provider(ApiProvider::Moonshot, model) + { + providers.moonshot.model = Some(normalized); + } if let Some(model) = providers.sglang.model.as_deref() && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang) && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model) @@ -2752,6 +2842,7 @@ pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool { ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Moonshot | ApiProvider::Ollama ) } @@ -2774,6 +2865,7 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ApiProvider::Moonshot => DEFAULT_MOONSHOT_BASE_URL, ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL, @@ -2788,6 +2880,27 @@ fn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &st base_url_is_custom_for_provider(provider, base_url) } +fn provider_config_uses_kimi_oauth(config: &ProviderConfig) -> bool { + config + .auth_mode + .as_deref() + .is_some_and(auth_mode_uses_kimi_oauth) +} + +fn auth_mode_uses_kimi_oauth(mode: &str) -> bool { + matches!( + normalize_auth_mode(mode).as_str(), + "kimi" | "kimi_oauth" | "kimi_cli" | "oauth" + ) +} + +fn normalize_auth_mode(mode: &str) -> String { + mode.trim() + .to_ascii_lowercase() + .replace('-', "_") + .replace(' ', "_") +} + fn base_url_uses_local_host(base_url: &str) -> bool { let Some(host) = base_url_host(base_url) else { return false; @@ -2902,6 +3015,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { base_url: override_cfg.base_url.or(base.base_url), http_headers: override_cfg.http_headers.or(base.http_headers), default_text_model: override_cfg.default_text_model.or(base.default_text_model), + auth_mode: override_cfg.auth_mode.or(base.auth_mode), reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort), tools_file: override_cfg.tools_file.or(base.tools_file), skills_dir: override_cfg.skills_dir.or(base.skills_dir), @@ -2979,6 +3093,7 @@ fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> api_key: override_cfg.api_key.or(base.api_key), base_url: override_cfg.base_url.or(base.base_url), model: override_cfg.model.or(base.model), + auth_mode: override_cfg.auth_mode.or(base.auth_mode), http_headers: override_cfg.http_headers.or(base.http_headers), } } @@ -3001,6 +3116,7 @@ fn merge_providers( openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter), novita: merge_provider_config(base.novita, override_cfg.novita), fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks), + moonshot: merge_provider_config(base.moonshot, override_cfg.moonshot), sglang: merge_provider_config(base.sglang, override_cfg.sglang), vllm: merge_provider_config(base.vllm, override_cfg.vllm), ollama: merge_provider_config(base.ollama, override_cfg.ollama), @@ -3373,6 +3489,14 @@ pub fn has_api_key(config: &Config) -> bool { pub fn active_provider_has_config_api_key(config: &Config) -> bool { let provider = config.api_provider(); + if provider == ApiProvider::Moonshot + && config + .provider_config_for(provider) + .is_some_and(provider_config_uses_kimi_oauth) + { + return kimi_cli_credentials_present(); + } + if config .provider_config_for(provider) .and_then(|entry| entry.api_key.as_ref()) @@ -3414,6 +3538,10 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool { ApiProvider::Fireworks => { std::env::var("FIREWORKS_API_KEY").is_ok_and(|k| !k.trim().is_empty()) } + ApiProvider::Moonshot => { + std::env::var("MOONSHOT_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + || std::env::var("KIMI_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + } ApiProvider::Sglang => std::env::var("SGLANG_API_KEY").is_ok_and(|k| !k.trim().is_empty()), ApiProvider::Vllm => std::env::var("VLLM_API_KEY").is_ok_and(|k| !k.trim().is_empty()), ApiProvider::Ollama => std::env::var("OLLAMA_API_KEY").is_ok_and(|k| !k.trim().is_empty()), @@ -3439,6 +3567,7 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", + ApiProvider::Moonshot => "MOONSHOT_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", ApiProvider::Ollama => "OLLAMA_API_KEY", @@ -3457,6 +3586,19 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { { return true; } + if matches!(provider, ApiProvider::Moonshot) + && std::env::var("KIMI_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + { + return true; + } + + if provider == ApiProvider::Moonshot + && config + .provider_config_for(provider) + .is_some_and(provider_config_uses_kimi_oauth) + { + return kimi_cli_credentials_present(); + } // Self-hosted providers typically run without authentication. if matches!( @@ -3519,6 +3661,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Openrouter => "providers.openrouter", ApiProvider::Novita => "providers.novita", ApiProvider::Fireworks => "providers.fireworks", + ApiProvider::Moonshot => "providers.moonshot", ApiProvider::Sglang => "providers.sglang", ApiProvider::Vllm => "providers.vllm", ApiProvider::Ollama => "providers.ollama", @@ -3555,6 +3698,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Openrouter => "openrouter", ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", + ApiProvider::Moonshot => "moonshot", ApiProvider::Sglang => "sglang", ApiProvider::Vllm => "vllm", ApiProvider::Ollama => "ollama", @@ -3584,6 +3728,215 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result Ok(config_path) } +pub fn save_provider_auth_mode_for(provider: ApiProvider, auth_mode: &str) -> Result { + let config_path = default_config_path() + .context("Failed to resolve config path: home directory not found.")?; + ensure_parent_dir(&config_path)?; + + let mut doc: toml::Value = if config_path.exists() { + let raw = fs::read_to_string(&config_path)?; + toml::from_str(&raw) + .with_context(|| format!("Failed to parse config at {}", config_path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + + let table = doc + .as_table_mut() + .context("Config root must be a TOML table.")?; + let providers = table + .entry("providers".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .context("`providers` must be a table.")?; + let key_inside = provider_config_key(provider).context("provider auth mode key")?; + let entry = providers + .entry(key_inside.to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .with_context(|| format!("`providers.{key_inside}` must be a table."))?; + entry.insert( + "auth_mode".to_string(), + toml::Value::String(auth_mode.to_string()), + ); + + let serialized = toml::to_string_pretty(&doc).context("failed to serialize updated config")?; + write_config_file_secure(&config_path, &serialized) + .with_context(|| format!("Failed to write config to {}", config_path.display()))?; + log_sensitive_event( + "credential.auth_mode.set", + json!({ + "backend": "config_file", + "provider": provider.as_str(), + "auth_mode": auth_mode, + "config_path": config_path.display().to_string(), + }), + ); + Ok(config_path) +} + +fn provider_config_key(provider: ApiProvider) -> Result<&'static str> { + match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => { + anyhow::bail!("DeepSeek stores auth at the root config level") + } + ApiProvider::NvidiaNim => Ok("nvidia_nim"), + ApiProvider::Openai => Ok("openai"), + ApiProvider::Atlascloud => Ok("atlascloud"), + ApiProvider::WanjieArk => Ok("wanjie_ark"), + ApiProvider::Openrouter => Ok("openrouter"), + ApiProvider::Novita => Ok("novita"), + ApiProvider::Fireworks => Ok("fireworks"), + ApiProvider::Moonshot => Ok("moonshot"), + ApiProvider::Sglang => Ok("sglang"), + ApiProvider::Vllm => Ok("vllm"), + ApiProvider::Ollama => Ok("ollama"), + } +} + +const KIMI_CODE_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const KIMI_CODE_CREDENTIAL_FILE: &str = "kimi-code.json"; + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct KimiOAuthCredential { + access_token: Option, + refresh_token: Option, + expires_at: Option, + expires_in: Option, + scope: Option, + token_type: Option, +} + +fn kimi_cli_oauth_access_token() -> Result { + let path = kimi_cli_oauth_credentials_path()?; + let raw = fs::read_to_string(&path).with_context(|| { + format!( + "Kimi OAuth credentials not found at {}. Run `kimi login`, then set \ + [providers.moonshot] auth_mode = \"kimi_oauth\".", + path.display() + ) + })?; + let mut credential: KimiOAuthCredential = + serde_json::from_str(&raw).context("Failed to parse Kimi OAuth credentials")?; + + if kimi_oauth_access_token_is_fresh(&credential) { + return credential + .access_token + .filter(|token| !token.trim().is_empty()) + .context("Kimi OAuth access token is empty"); + } + + let refresh_token = credential + .refresh_token + .as_deref() + .filter(|token| !token.trim().is_empty()) + .context("Kimi OAuth refresh token is empty. Run `kimi login` again.")?; + credential = refresh_kimi_oauth_token(refresh_token)?; + write_kimi_oauth_credential(&path, &credential)?; + credential + .access_token + .filter(|token| !token.trim().is_empty()) + .context("Kimi OAuth refresh returned an empty access token") +} + +fn kimi_oauth_access_token_is_fresh(credential: &KimiOAuthCredential) -> bool { + let Some(now) = now_unix_secs() else { + return false; + }; + + credential + .access_token + .as_deref() + .is_some_and(|token| !token.trim().is_empty()) + && credential + .expires_at + .is_some_and(|expires_at| expires_at - now > 60.0) +} + +fn refresh_kimi_oauth_token(refresh_token: &str) -> Result { + let oauth_host = std::env::var("KIMI_CODE_OAUTH_HOST") + .or_else(|_| std::env::var("KIMI_OAUTH_HOST")) + .unwrap_or_else(|_| "https://auth.kimi.com".to_string()); + let url = format!("{}/api/oauth/token", oauth_host.trim_end_matches('/')); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .context("Failed to build Kimi OAuth refresh client")?; + let params = [ + ("client_id", KIMI_CODE_CLIENT_ID), + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ]; + let response = client + .post(url) + .header("X-Msh-Platform", "kimi_cli") + .header("X-Msh-Version", env!("CARGO_PKG_VERSION")) + .form(¶ms) + .send() + .context("Kimi OAuth refresh request failed")?; + let status = response.status(); + if !status.is_success() { + anyhow::bail!("Kimi OAuth refresh failed with HTTP {status}. Run `kimi login` again."); + } + + let mut refreshed: KimiOAuthCredential = response + .json() + .context("Failed to parse Kimi OAuth refresh response")?; + if let Some(expires_in) = refreshed.expires_in + && let Some(now) = now_unix_secs() + { + refreshed.expires_at = Some(now + expires_in); + } + Ok(refreshed) +} + +fn kimi_cli_oauth_credentials_path() -> Result { + let share_dir = std::env::var("KIMI_SHARE_DIR") + .map(PathBuf::from) + .or_else(|_| { + effective_home_dir() + .map(|home| home.join(".kimi")) + .ok_or(std::env::VarError::NotPresent) + }) + .context("Failed to resolve Kimi share directory")?; + Ok(share_dir + .join("credentials") + .join(KIMI_CODE_CREDENTIAL_FILE)) +} + +fn write_kimi_oauth_credential(path: &Path, credential: &KimiOAuthCredential) -> Result<()> { + let serialized = serde_json::to_vec_pretty(credential) + .context("Failed to serialize Kimi OAuth credentials")?; + crate::utils::write_atomic(path, &serialized).with_context(|| { + format!( + "Failed to write Kimi OAuth credentials to {}", + path.display() + ) + })?; + #[cfg(unix)] + if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) { + tracing::warn!( + target: "codewhale::config", + path = %path.display(), + error = %err, + "could not enforce 0o600 on Kimi OAuth credentials; relying on host ACLs" + ); + } + Ok(()) +} + +fn now_unix_secs() -> Option { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs_f64()) + .ok() +} + +#[must_use] +pub fn kimi_cli_credentials_present() -> bool { + kimi_cli_oauth_credentials_path().is_ok_and(|path| path.exists()) +} + /// Clear the API key from config-file storage. /// /// `/logout` calls this to wipe credentials so the next request can't @@ -3735,6 +4088,16 @@ mod tests { novita_base_url: Option, fireworks_api_key: Option, fireworks_base_url: Option, + moonshot_api_key: Option, + moonshot_base_url: Option, + moonshot_model: Option, + kimi_api_key: Option, + kimi_base_url: Option, + kimi_model: Option, + kimi_model_name: Option, + kimi_share_dir: Option, + kimi_code_oauth_host: Option, + kimi_oauth_host: Option, sglang_api_key: Option, sglang_base_url: Option, sglang_model: Option, @@ -3787,6 +4150,16 @@ mod tests { let novita_base_url_prev = env::var_os("NOVITA_BASE_URL"); let fireworks_api_key_prev = env::var_os("FIREWORKS_API_KEY"); let fireworks_base_url_prev = env::var_os("FIREWORKS_BASE_URL"); + let moonshot_api_key_prev = env::var_os("MOONSHOT_API_KEY"); + let moonshot_base_url_prev = env::var_os("MOONSHOT_BASE_URL"); + let moonshot_model_prev = env::var_os("MOONSHOT_MODEL"); + let kimi_api_key_prev = env::var_os("KIMI_API_KEY"); + let kimi_base_url_prev = env::var_os("KIMI_BASE_URL"); + let kimi_model_prev = env::var_os("KIMI_MODEL"); + let kimi_model_name_prev = env::var_os("KIMI_MODEL_NAME"); + let kimi_share_dir_prev = env::var_os("KIMI_SHARE_DIR"); + let kimi_code_oauth_host_prev = env::var_os("KIMI_CODE_OAUTH_HOST"); + let kimi_oauth_host_prev = env::var_os("KIMI_OAUTH_HOST"); let sglang_api_key_prev = env::var_os("SGLANG_API_KEY"); let sglang_base_url_prev = env::var_os("SGLANG_BASE_URL"); let sglang_model_prev = env::var_os("SGLANG_MODEL"); @@ -3834,6 +4207,16 @@ mod tests { env::remove_var("NOVITA_BASE_URL"); env::remove_var("FIREWORKS_API_KEY"); env::remove_var("FIREWORKS_BASE_URL"); + env::remove_var("MOONSHOT_API_KEY"); + env::remove_var("MOONSHOT_BASE_URL"); + env::remove_var("MOONSHOT_MODEL"); + env::remove_var("KIMI_API_KEY"); + env::remove_var("KIMI_BASE_URL"); + env::remove_var("KIMI_MODEL"); + env::remove_var("KIMI_MODEL_NAME"); + env::remove_var("KIMI_SHARE_DIR"); + env::remove_var("KIMI_CODE_OAUTH_HOST"); + env::remove_var("KIMI_OAUTH_HOST"); env::remove_var("SGLANG_API_KEY"); env::remove_var("SGLANG_BASE_URL"); env::remove_var("SGLANG_MODEL"); @@ -3881,6 +4264,16 @@ mod tests { novita_base_url: novita_base_url_prev, fireworks_api_key: fireworks_api_key_prev, fireworks_base_url: fireworks_base_url_prev, + moonshot_api_key: moonshot_api_key_prev, + moonshot_base_url: moonshot_base_url_prev, + moonshot_model: moonshot_model_prev, + kimi_api_key: kimi_api_key_prev, + kimi_base_url: kimi_base_url_prev, + kimi_model: kimi_model_prev, + kimi_model_name: kimi_model_name_prev, + kimi_share_dir: kimi_share_dir_prev, + kimi_code_oauth_host: kimi_code_oauth_host_prev, + kimi_oauth_host: kimi_oauth_host_prev, sglang_api_key: sglang_api_key_prev, sglang_base_url: sglang_base_url_prev, sglang_model: sglang_model_prev, @@ -3937,6 +4330,16 @@ mod tests { Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take()); Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take()); + Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take()); + Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take()); + Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take()); + Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take()); + Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take()); + Self::restore_var("KIMI_MODEL", self.kimi_model.take()); + Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take()); + Self::restore_var("KIMI_SHARE_DIR", self.kimi_share_dir.take()); + Self::restore_var("KIMI_CODE_OAUTH_HOST", self.kimi_code_oauth_host.take()); + Self::restore_var("KIMI_OAUTH_HOST", self.kimi_oauth_host.take()); Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take()); Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take()); Self::restore_var("SGLANG_MODEL", self.sglang_model.take()); @@ -4830,6 +5233,14 @@ api_key = "old-openrouter-key" ); } + #[test] + fn model_completion_names_for_moonshot_excludes_oauth_only_kimi_code_model() { + assert_eq!( + model_completion_names_for_provider(ApiProvider::Moonshot), + vec![DEFAULT_MOONSHOT_MODEL] + ); + } + #[test] fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() { assert!(normalize_model_name("gpt-4o").is_none()); @@ -5965,6 +6376,64 @@ api_key = "novita-table-key" Ok(()) } + #[test] + fn moonshot_kimi_oauth_reads_fresh_cli_credential() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-kimi-oauth-key-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let kimi_share_dir = temp_root.join(".kimi"); + let credential_dir = kimi_share_dir.join("credentials"); + fs::create_dir_all(&credential_dir)?; + unsafe { env::set_var("KIMI_SHARE_DIR", &kimi_share_dir) }; + + let expires_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs_f64() + + 3600.0; + let credential = json!({ + "access_token": "fresh-oauth-token", + "refresh_token": "refresh-token", + "expires_at": expires_at, + "scope": "openid profile email", + "token_type": "Bearer", + }); + fs::write( + credential_dir.join(KIMI_CODE_CREDENTIAL_FILE), + serde_json::to_string(&credential)?, + )?; + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"provider = "moonshot" + +[providers.moonshot] +auth_mode = "kimi_oauth" +api_key = "stale-api-key" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL); + assert_eq!(config.deepseek_api_key()?, "fresh-oauth-token"); + assert!(has_api_key_for(&config, ApiProvider::Moonshot)); + Ok(()) + } + #[test] fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> { let _lock = lock_test_env(); diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 202cd164..7aad7ae6 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -371,6 +371,7 @@ impl Engine { ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", + ApiProvider::Moonshot => "MOONSHOT_API_KEY/KIMI_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", ApiProvider::Ollama => "OLLAMA_API_KEY", diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 34cb7fce..c53cdaca 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1861,6 +1861,10 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "FIREWORKS_API_KEY", "codewhale auth set --provider fireworks --api-key \"...\"", ), + crate::config::ApiProvider::Moonshot => ( + "MOONSHOT_API_KEY/KIMI_API_KEY", + "codewhale auth set --provider moonshot --api-key \"...\"", + ), crate::config::ApiProvider::Sglang => ( "SGLANG_API_KEY", "codewhale auth set --provider sglang --api-key \"...\"", @@ -1887,6 +1891,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { crate::config::ApiProvider::Openrouter => "openrouter", crate::config::ApiProvider::Novita => "novita", crate::config::ApiProvider::Fireworks => "fireworks", + crate::config::ApiProvider::Moonshot => "moonshot", crate::config::ApiProvider::Sglang => "sglang", crate::config::ApiProvider::Vllm => "vllm", crate::config::ApiProvider::Ollama => "ollama", @@ -2154,6 +2159,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt "fireworks", &["FIREWORKS_API_KEY"][..], ), + ( + crate::config::ApiProvider::Moonshot, + "moonshot", + &["MOONSHOT_API_KEY", "KIMI_API_KEY"][..], + ), ( crate::config::ApiProvider::Sglang, "sglang", diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index ecf9f722..b2ac79e6 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -26,7 +26,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, Widget}, }; -use crate::config::{ApiProvider, Config, has_api_key_for}; +use crate::config::{ApiProvider, Config, has_api_key_for, kimi_cli_credentials_present}; use crate::palette; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; @@ -94,6 +94,7 @@ impl ProviderPickerView { ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", + ApiProvider::Moonshot => "MOONSHOT_API_KEY / KIMI_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", ApiProvider::Ollama => "OLLAMA_API_KEY", @@ -102,6 +103,9 @@ impl ProviderPickerView { fn provider_hint(provider: ApiProvider, has_key: bool) -> String { match provider { + ApiProvider::Moonshot if kimi_cli_credentials_present() => { + "(Kimi CLI OAuth ready)".to_string() + } ApiProvider::Ollama => "self-hosted; defaults to http://localhost:11434".to_string(), ApiProvider::Sglang | ApiProvider::Vllm if has_key => { "(configured; optional key)".to_string() @@ -287,6 +291,10 @@ impl ModalView for ProviderPickerView { let provider = self.selected_provider(); if self.selected_has_key() { ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { provider }) + } else if provider == ApiProvider::Moonshot && kimi_cli_credentials_present() { + ViewAction::EmitAndClose(ViewEvent::ProviderPickerKimiOAuthEnabled { + provider, + }) } else { self.stage = Stage::KeyEntry; self.api_key_input.clear(); @@ -400,6 +408,7 @@ mod tests { "OpenRouter", "Novita AI", "Fireworks AI", + "Moonshot/Kimi", "SGLang", "vLLM", "Ollama" diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 25e1fdb1..f1ebabfe 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -38,7 +38,10 @@ use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, sp use crate::client::{DeepSeekClient, build_cache_warmup_request}; use crate::commands; use crate::compaction::estimate_input_tokens_conservative; -use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL}; +use crate::config::{ + ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, ProviderConfig, ProvidersConfig, + save_provider_auth_mode_for, +}; use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent}; use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; @@ -5730,6 +5733,7 @@ fn render(f: &mut Frame, app: &mut App) { crate::config::ApiProvider::Openrouter => Some("OR"), crate::config::ApiProvider::Novita => Some("Novita"), crate::config::ApiProvider::Fireworks => Some("Fireworks"), + crate::config::ApiProvider::Moonshot => Some("Kimi"), crate::config::ApiProvider::Sglang => Some("SGLang"), crate::config::ApiProvider::Vllm => Some("vLLM"), crate::config::ApiProvider::Ollama => Some("Ollama"), @@ -6270,6 +6274,17 @@ async fn handle_view_events( ViewEvent::ProviderPickerApiKeySubmitted { provider, api_key } => { apply_provider_picker_api_key(app, engine_handle, config, provider, api_key).await; } + ViewEvent::ProviderPickerKimiOAuthEnabled { provider } => { + apply_provider_picker_auth_mode( + app, + engine_handle, + config, + provider, + "kimi_oauth", + "Linked Kimi CLI OAuth", + ) + .await; + } ViewEvent::ModeSelected { mode } => { let msg = commands::switch_mode(app, mode); app.add_message(HistoryCell::System { content: msg }); @@ -6477,7 +6492,7 @@ async fn apply_provider_picker_api_key( provider: ApiProvider, api_key: String, ) { - use crate::config::{ProviderConfig, ProvidersConfig, save_api_key_for}; + use crate::config::save_api_key_for; match save_api_key_for(provider, &api_key) { Ok(path) => { @@ -6519,6 +6534,7 @@ async fn apply_provider_picker_api_key( ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, @@ -6529,6 +6545,55 @@ async fn apply_provider_picker_api_key( switch_provider(app, engine_handle, config, provider, None).await; } +async fn apply_provider_picker_auth_mode( + app: &mut App, + engine_handle: &mut EngineHandle, + config: &mut Config, + provider: ApiProvider, + auth_mode: &str, + status_prefix: &str, +) { + match save_provider_auth_mode_for(provider, auth_mode) { + Ok(path) => { + set_provider_auth_mode_in_memory(config, provider, auth_mode.to_string()); + app.status_message = Some(format!("{status_prefix}; saved to {}", path.display())); + app.api_key_env_only = false; + } + Err(err) => { + app.add_message(HistoryCell::System { + content: format!( + "Failed to save {} auth mode: {err}\nProvider unchanged.", + provider.as_str() + ), + }); + return; + } + } + + switch_provider(app, engine_handle, config, provider, None).await; +} + +fn set_provider_auth_mode_in_memory(config: &mut Config, provider: ApiProvider, auth_mode: String) { + let providers = config + .providers + .get_or_insert_with(ProvidersConfig::default); + let entry: &mut ProviderConfig = match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => return, + ApiProvider::NvidiaNim => &mut providers.nvidia_nim, + ApiProvider::Openai => &mut providers.openai, + ApiProvider::Atlascloud => &mut providers.atlascloud, + ApiProvider::WanjieArk => &mut providers.wanjie_ark, + ApiProvider::Openrouter => &mut providers.openrouter, + ApiProvider::Novita => &mut providers.novita, + ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Moonshot => &mut providers.moonshot, + ApiProvider::Sglang => &mut providers.sglang, + ApiProvider::Vllm => &mut providers.vllm, + ApiProvider::Ollama => &mut providers.ollama, + }; + entry.auth_mode = Some(auth_mode); +} + fn apply_loaded_session(app: &mut App, config: &Config, session: &SavedSession) -> bool { let (messages, recovered_draft) = recover_interrupted_user_tail(&session.messages); app.api_messages = messages; diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index c50c83c0..e9b91740 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -157,6 +157,11 @@ pub enum ViewEvent { provider: crate::config::ApiProvider, api_key: String, }, + /// Emitted by the `/provider` picker when Kimi CLI OAuth credentials can + /// be reused for Moonshot/Kimi dispatch. + ProviderPickerKimiOAuthEnabled { + provider: crate::config::ApiProvider, + }, /// Emitted by the `/mode` picker when the user chooses a mode. ModeSelected { mode: crate::tui::app::AppMode,