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:
Generated
+1
@@ -4090,6 +4090,7 @@ dependencies = [
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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(¶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<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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user