Add Kimi OAuth provider support

Adds Moonshot/Kimi provider support with Kimi CLI OAuth reuse and review fixes for secure refresh writes, model completion, CLI auth, and secret-store behavior.
This commit is contained in:
Hunter Bown
2026-05-25 17:48:05 -05:00
committed by GitHub
parent a6bd5ac08b
commit 37dd821f33
13 changed files with 751 additions and 12 deletions
Generated
+1
View File
@@ -4090,6 +4090,7 @@ dependencies = [
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
+11
View File
@@ -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,
+18 -1
View File
@@ -31,6 +31,7 @@ enum ProviderArg {
Openrouter,
Novita,
Fireworks,
Moonshot,
Sglang,
Vllm,
Ollama,
@@ -47,6 +48,7 @@ impl From<ProviderArg> 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,
+153 -6
View File
@@ -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<String>,
pub base_url: Option<String>,
pub model: Option<String>,
pub auth_mode: Option<String>,
#[serde(default)]
pub http_headers: BTreeMap<String, String>,
}
@@ -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<ProviderKind>,
model: Option<String>,
wanjie_ark_model: Option<String>,
moonshot_model: Option<String>,
output_mode: Option<String>,
auth_mode: Option<String>,
log_level: Option<String>,
@@ -1688,6 +1735,7 @@ struct EnvRuntimeOverrides {
openrouter_base_url: Option<String>,
novita_base_url: Option<String>,
fireworks_base_url: Option<String>,
moonshot_base_url: Option<String>,
sglang_base_url: Option<String>,
vllm_base_url: Option<String>,
ollama_base_url: Option<String>,
@@ -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<String> {
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<OsString>,
fireworks_api_key: Option<OsString>,
fireworks_base_url: Option<OsString>,
moonshot_api_key: Option<OsString>,
moonshot_base_url: Option<OsString>,
moonshot_model: Option<OsString>,
kimi_api_key: Option<OsString>,
kimi_base_url: Option<OsString>,
kimi_model: Option<OsString>,
kimi_model_name: Option<OsString>,
sglang_api_key: Option<OsString>,
sglang_base_url: Option<OsString>,
vllm_api_key: Option<OsString>,
@@ -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();
+1
View File
@@ -535,6 +535,7 @@ pub fn env_for(name: &str) -> Option<String> {
&["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"],
+1 -1
View File
@@ -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"] }
+3
View File
@@ -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!({
+470 -1
View File
@@ -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<HashMap<String, String>>,
pub default_text_model: Option<String>,
pub auth_mode: Option<String>,
/// DeepSeek reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
/// Defaults to `"max"` at runtime if unset.
pub reasoning_effort: Option<String>,
@@ -1209,6 +1224,7 @@ pub struct ProviderConfig {
pub api_key: Option<String>,
pub base_url: Option<String>,
pub model: Option<String>,
pub auth_mode: Option<String>,
pub http_headers: Option<HashMap<String, String>>,
}
@@ -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<PathBuf>
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<PathBuf>
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<PathBuf>
Ok(config_path)
}
pub fn save_provider_auth_mode_for(provider: ApiProvider, auth_mode: &str) -> Result<PathBuf> {
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<String>,
refresh_token: Option<String>,
expires_at: Option<f64>,
expires_in: Option<f64>,
scope: Option<String>,
token_type: Option<String>,
}
fn kimi_cli_oauth_access_token() -> Result<String> {
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<KimiOAuthCredential> {
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(&params)
.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<PathBuf> {
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<f64> {
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<OsString>,
fireworks_api_key: Option<OsString>,
fireworks_base_url: Option<OsString>,
moonshot_api_key: Option<OsString>,
moonshot_base_url: Option<OsString>,
moonshot_model: Option<OsString>,
kimi_api_key: Option<OsString>,
kimi_base_url: Option<OsString>,
kimi_model: Option<OsString>,
kimi_model_name: Option<OsString>,
kimi_share_dir: Option<OsString>,
kimi_code_oauth_host: Option<OsString>,
kimi_oauth_host: Option<OsString>,
sglang_api_key: Option<OsString>,
sglang_base_url: Option<OsString>,
sglang_model: Option<OsString>,
@@ -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();
+1
View File
@@ -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",
+10
View File
@@ -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",
+10 -1
View File
@@ -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"
+67 -2
View File
@@ -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;
+5
View File
@@ -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,