From 69288d17f0cc2cf21db8b922a503da6f2739ee13 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 17 Sep 2026 21:47:00 +0800 Subject: [PATCH 01/13] feat(kimi): support Kimi Code OAuth authentication and configuration profile switching - Implement RFC 8628 device flow for Moonshot AI Kimi Code (client id 17e5f671-d194-4dfb-9706-5516cb48c098) - Add KimiOAuthManager and KimiOAuthService to manage accounts and sync to native ~/.kimi-code - Extend AuthService and cc-switch auth commands with --provider kimi_oauth - Add cc-switch config kimi profile management to snapshot and switch native Kimi Code profiles atomically Signed-off-by: Ferris --- src-tauri/src/cli/commands/auth.rs | 96 +- src-tauri/src/cli/commands/config.rs | 6 + src-tauri/src/cli/commands/config_kimi.rs | 222 +++++ src-tauri/src/cli/commands/mod.rs | 1 + src-tauri/src/cli/mod.rs | 52 +- src-tauri/src/kimi_config.rs | 445 +++++++++ src-tauri/src/lib.rs | 1 + .../src/proxy/providers/kimi_oauth_auth.rs | 917 ++++++++++++++++++ src-tauri/src/proxy/providers/mod.rs | 1 + src-tauri/src/services/auth.rs | 157 ++- src-tauri/src/services/kimi_oauth.rs | 173 ++++ src-tauri/src/services/mod.rs | 2 + 12 files changed, 2031 insertions(+), 42 deletions(-) create mode 100644 src-tauri/src/cli/commands/config_kimi.rs create mode 100644 src-tauri/src/kimi_config.rs create mode 100644 src-tauri/src/proxy/providers/kimi_oauth_auth.rs create mode 100644 src-tauri/src/services/kimi_oauth.rs diff --git a/src-tauri/src/cli/commands/auth.rs b/src-tauri/src/cli/commands/auth.rs index 62913fd6d..ed5ee99d9 100644 --- a/src-tauri/src/cli/commands/auth.rs +++ b/src-tauri/src/cli/commands/auth.rs @@ -6,43 +6,59 @@ use crate::cli::ui::{create_table, info, success, to_json}; use crate::error::AppError; use crate::services::{AuthService, ManagedAuthAccount, ManagedAuthDeviceCodeResponse}; -const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth"; - #[derive(Subcommand, Debug, Clone)] pub enum AuthCommand { /// Show ChatGPT Codex OAuth authentication status Status { + /// Auth provider (codex_oauth, kimi_oauth) + #[arg(long, default_value = "codex_oauth")] + provider: String, /// Print machine-readable JSON #[arg(long)] json: bool, }, - /// List signed-in ChatGPT accounts + /// List signed-in accounts List { + /// Auth provider (codex_oauth, kimi_oauth) + #[arg(long, default_value = "codex_oauth")] + provider: String, /// Print machine-readable JSON #[arg(long)] json: bool, }, - /// Sign in to ChatGPT with the Codex OAuth device flow + /// Sign in with device flow Login { + /// Auth provider (codex_oauth, kimi_oauth) + #[arg(long, default_value = "codex_oauth")] + provider: String, /// Print machine-readable JSON #[arg(long)] json: bool, }, - /// Set the default ChatGPT account + /// Set the default account Default { /// Account id to make default account_id: String, + /// Auth provider (codex_oauth, kimi_oauth) + #[arg(long, default_value = "codex_oauth")] + provider: String, }, - /// Remove a ChatGPT account + /// Remove an account Remove { /// Account id to remove account_id: String, + /// Auth provider (codex_oauth, kimi_oauth) + #[arg(long, default_value = "codex_oauth")] + provider: String, /// Confirm removal without prompting #[arg(long)] yes: bool, }, - /// Remove all ChatGPT Codex OAuth authentication data + /// Remove all authentication data for provider Logout { + /// Auth provider (codex_oauth, kimi_oauth) + #[arg(long, default_value = "codex_oauth")] + provider: String, /// Confirm logout without prompting #[arg(long)] yes: bool, @@ -60,12 +76,19 @@ pub fn execute(cmd: AuthCommand) -> Result<(), AppError> { crate::services::global_proxy::initialize_http_client_from_disk_best_effort(); let runtime = create_runtime()?; match cmd { - AuthCommand::Status { json } => status(&runtime, json), - AuthCommand::List { json } => list_accounts(&runtime, json), - AuthCommand::Login { json } => login(&runtime, json), - AuthCommand::Default { account_id } => set_default(&runtime, &account_id), - AuthCommand::Remove { account_id, yes } => remove_account(&runtime, &account_id, yes), - AuthCommand::Logout { yes } => logout(&runtime, yes), + AuthCommand::Status { provider, json } => status(&runtime, &provider, json), + AuthCommand::List { provider, json } => list_accounts(&runtime, &provider, json), + AuthCommand::Login { provider, json } => login(&runtime, &provider, json), + AuthCommand::Default { + account_id, + provider, + } => set_default(&runtime, &provider, &account_id), + AuthCommand::Remove { + account_id, + provider, + yes, + } => remove_account(&runtime, &provider, &account_id, yes), + AuthCommand::Logout { provider, yes } => logout(&runtime, &provider, yes), } } @@ -76,9 +99,9 @@ fn create_runtime() -> Result { .map_err(|error| AppError::Message(format!("failed to create async runtime: {error}"))) } -fn status(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> { +fn status(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) -> Result<(), AppError> { let status = runtime - .block_on(AuthService::get_status(AUTH_PROVIDER_CODEX_OAUTH)) + .block_on(AuthService::get_status(provider)) .map_err(AppError::Message)?; if json { @@ -89,7 +112,12 @@ fn status(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> return Ok(()); } - println!("Provider: ChatGPT (Codex OAuth)"); + let display_name = match provider { + "kimi_oauth" | "kimi" | "kimi-code" => "Moonshot AI Kimi (Device OAuth)", + _ => "ChatGPT (Codex OAuth)", + }; + + println!("Provider: {display_name}"); println!( "Authenticated: {}", if status.authenticated { "yes" } else { "no" } @@ -115,9 +143,9 @@ fn status(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> Ok(()) } -fn list_accounts(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> { +fn list_accounts(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) -> Result<(), AppError> { let accounts = runtime - .block_on(AuthService::list_accounts(AUTH_PROVIDER_CODEX_OAUTH)) + .block_on(AuthService::list_accounts(provider)) .map_err(AppError::Message)?; if json { @@ -129,7 +157,7 @@ fn list_accounts(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), Ap } if accounts.is_empty() { - println!("{}", info("No ChatGPT accounts are signed in.")); + println!("{}", info(&format!("No accounts are signed in for {provider}."))); return Ok(()); } @@ -137,9 +165,9 @@ fn list_accounts(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), Ap Ok(()) } -fn login(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> { +fn login(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) -> Result<(), AppError> { let device = runtime - .block_on(AuthService::start_login(AUTH_PROVIDER_CODEX_OAUTH)) + .block_on(AuthService::start_login(provider)) .map_err(AppError::Message)?; if json { @@ -153,7 +181,7 @@ fn login(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> println!("{}", info("Waiting for authorization...")); } - let account = poll_until_authorized(runtime, &device)?; + let account = poll_until_authorized(runtime, provider, &device)?; if json { let completed = LoginCompleted { device, account }; @@ -173,6 +201,7 @@ fn login(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> fn poll_until_authorized( runtime: &tokio::runtime::Runtime, + provider: &str, device: &ManagedAuthDeviceCodeResponse, ) -> Result { let expires_at = Instant::now() + Duration::from_secs(device.expires_in); @@ -181,7 +210,7 @@ fn poll_until_authorized( loop { match runtime .block_on(AuthService::poll_for_account( - AUTH_PROVIDER_CODEX_OAUTH, + provider, &device.device_code, )) .map_err(AppError::Message)? @@ -205,51 +234,52 @@ fn poll_interval_seconds(server_interval: u64) -> u64 { server_interval.max(1) } -fn set_default(runtime: &tokio::runtime::Runtime, account_id: &str) -> Result<(), AppError> { +fn set_default(runtime: &tokio::runtime::Runtime, provider: &str, account_id: &str) -> Result<(), AppError> { let account_id = normalize_account_id(account_id)?; runtime .block_on(AuthService::set_default_account( - AUTH_PROVIDER_CODEX_OAUTH, + provider, account_id, )) .map_err(AppError::Message)?; - println!("{}", success("Default ChatGPT account updated.")); + println!("{}", success(&format!("Default account updated for {provider}."))); Ok(()) } fn remove_account( runtime: &tokio::runtime::Runtime, + provider: &str, account_id: &str, yes: bool, ) -> Result<(), AppError> { let account_id = normalize_account_id(account_id)?; - if !yes && !confirm(&format!("Remove ChatGPT account '{account_id}'?"))? { + if !yes && !confirm(&format!("Remove account '{account_id}' from {provider}?"))? { println!("{}", info("Cancelled.")); return Ok(()); } runtime .block_on(AuthService::remove_account( - AUTH_PROVIDER_CODEX_OAUTH, + provider, account_id, )) .map_err(AppError::Message)?; - println!("{}", success("ChatGPT account removed.")); + println!("{}", success(&format!("Account removed from {provider}."))); Ok(()) } -fn logout(runtime: &tokio::runtime::Runtime, yes: bool) -> Result<(), AppError> { - if !yes && !confirm("Remove all ChatGPT Codex OAuth authentication data?")? { +fn logout(runtime: &tokio::runtime::Runtime, provider: &str, yes: bool) -> Result<(), AppError> { + if !yes && !confirm(&format!("Remove all authentication data for {provider}?"))? { println!("{}", info("Cancelled.")); return Ok(()); } runtime - .block_on(AuthService::logout(AUTH_PROVIDER_CODEX_OAUTH)) + .block_on(AuthService::logout(provider)) .map_err(AppError::Message)?; println!( "{}", - success("ChatGPT Codex OAuth authentication data removed.") + success(&format!("Authentication data removed for {provider}.")) ); Ok(()) } diff --git a/src-tauri/src/cli/commands/config.rs b/src-tauri/src/cli/commands/config.rs index 0674d3abd..92e9fc14f 100644 --- a/src-tauri/src/cli/commands/config.rs +++ b/src-tauri/src/cli/commands/config.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use crate::app_config::AppType; use crate::cli::commands::config_common; +use crate::cli::commands::config_kimi; use crate::cli::commands::config_openclaw; use crate::cli::commands::config_s3; use crate::cli::commands::config_webdav; @@ -58,6 +59,10 @@ pub enum ConfigCommand { #[command(name = "openclaw", subcommand)] OpenClaw(config_openclaw::OpenClawCommand), + /// Manage Moonshot Kimi Code directory configuration and profiles + #[command(name = "kimi", subcommand)] + Kimi(config_kimi::KimiConfigCommand), + /// Manage WebDAV sync settings and operations #[command(name = "webdav", subcommand)] WebDav(config_webdav::WebDavCommand), @@ -81,6 +86,7 @@ pub fn execute(cmd: ConfigCommand, app: Option) -> Result<(), AppError> ConfigCommand::Reset => reset_config(), ConfigCommand::Common(cmd) => config_common::execute(cmd, app.unwrap_or(AppType::Claude)), ConfigCommand::OpenClaw(cmd) => config_openclaw::execute(cmd), + ConfigCommand::Kimi(cmd) => config_kimi::execute(cmd), ConfigCommand::WebDav(cmd) => config_webdav::execute(cmd), ConfigCommand::S3(cmd) => config_s3::execute(cmd), } diff --git a/src-tauri/src/cli/commands/config_kimi.rs b/src-tauri/src/cli/commands/config_kimi.rs new file mode 100644 index 000000000..3a07412d9 --- /dev/null +++ b/src-tauri/src/cli/commands/config_kimi.rs @@ -0,0 +1,222 @@ +use clap::Subcommand; +use std::path::PathBuf; + +use crate::cli::ui::{create_table, info, success, to_json}; +use crate::error::AppError; +use crate::kimi_config::{ + get_active_profile_name, get_kimi_config_dir, + get_kimi_profiles_dir, list_profiles, read_native_credentials, remove_profile, save_profile, + switch_profile, KIMI_CONFIG_FILE, KIMI_CREDENTIALS_DIR, KIMI_DEFAULT_CREDENTIAL_FILE, +}; + +#[derive(Subcommand, Debug, Clone)] +pub enum KimiConfigCommand { + /// Show Kimi Code configuration and credential paths + Path { + /// Print machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Show current Kimi Code configuration and active account status + Status { + /// Print machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Manage Kimi Code configuration profiles (work, personal, etc.) + #[command(subcommand)] + Profile(KimiProfileCommand), +} + +#[derive(Subcommand, Debug, Clone)] +pub enum KimiProfileCommand { + /// List all saved Kimi Code profiles + List { + /// Print machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Save current Kimi Code directory configuration as a named profile + Save { + /// Profile name (e.g. work, personal) + name: String, + }, + /// Switch active Kimi Code directory configuration to a named profile + Switch { + /// Profile name to activate + name: String, + }, + /// Remove a saved Kimi Code profile + Remove { + /// Profile name to remove + name: String, + /// Confirm removal without prompting + #[arg(long)] + yes: bool, + }, +} + +#[derive(serde::Serialize)] +struct KimiPathInfo { + home_dir: PathBuf, + config_file: PathBuf, + credentials_file: PathBuf, + profiles_dir: PathBuf, + active_profile: Option, +} + +#[derive(serde::Serialize)] +struct KimiStatusInfo { + home_dir: PathBuf, + active_profile: Option, + has_credentials: bool, + token_expires_at: Option, + profiles_count: usize, +} + +pub fn execute(cmd: KimiConfigCommand) -> Result<(), AppError> { + match cmd { + KimiConfigCommand::Path { json } => show_path(json), + KimiConfigCommand::Status { json } => show_status(json), + KimiConfigCommand::Profile(profile_cmd) => execute_profile(profile_cmd), + } +} + +fn show_path(json: bool) -> Result<(), AppError> { + let home = get_kimi_config_dir(); + let info_obj = KimiPathInfo { + home_dir: home.clone(), + config_file: home.join(KIMI_CONFIG_FILE), + credentials_file: home.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE), + profiles_dir: get_kimi_profiles_dir(), + active_profile: get_active_profile_name(), + }; + + if json { + println!("{}", to_json(&info_obj).map_err(|e| AppError::JsonSerialize { source: e })?); + return Ok(()); + } + + println!("Kimi Code Home: {}", info_obj.home_dir.display()); + println!("Config File: {}", info_obj.config_file.display()); + println!("Credentials File: {}", info_obj.credentials_file.display()); + println!("Profiles Directory: {}", info_obj.profiles_dir.display()); + println!( + "Active Profile: {}", + info_obj.active_profile.as_deref().unwrap_or("-") + ); + + Ok(()) +} + +fn show_status(json: bool) -> Result<(), AppError> { + let home = get_kimi_config_dir(); + let active_profile = get_active_profile_name(); + let creds = read_native_credentials().map_err(|e| AppError::Message(e.to_string()))?; + let profiles = list_profiles().map_err(|e| AppError::Message(e.to_string()))?; + + let info_obj = KimiStatusInfo { + home_dir: home.clone(), + active_profile: active_profile.clone(), + has_credentials: creds.is_some(), + token_expires_at: creds.as_ref().and_then(|c| c.expires_at), + profiles_count: profiles.len(), + }; + + if json { + println!("{}", to_json(&info_obj).map_err(|e| AppError::JsonSerialize { source: e })?); + return Ok(()); + } + + println!("Kimi Code Home: {}", home.display()); + println!( + "Active Profile: {}", + active_profile.as_deref().unwrap_or("(default / unmanaged)") + ); + println!( + "Credentials: {}", + if creds.is_some() { "Present" } else { "None" } + ); + if let Some(exp) = creds.as_ref().and_then(|c| c.expires_at) { + let dt = chrono::DateTime::from_timestamp(exp, 0) + .map(|d| d.to_rfc3339()) + .unwrap_or_else(|| exp.to_string()); + println!("Token Expires At: {}", dt); + } + println!("Profiles Count: {}", profiles.len()); + + Ok(()) +} + +fn execute_profile(cmd: KimiProfileCommand) -> Result<(), AppError> { + match cmd { + KimiProfileCommand::List { json } => { + let profiles = list_profiles().map_err(|e| AppError::Message(e.to_string()))?; + if json { + println!( + "{}", + to_json(&profiles).map_err(|e| AppError::JsonSerialize { source: e })? + ); + return Ok(()); + } + + if profiles.is_empty() { + println!("{}", info("No Kimi Code profiles found. Use `cc-switch config kimi profile save ` to save one.")); + return Ok(()); + } + + let mut table = create_table(); + table.set_header(vec!["Active", "Name", "Has Config", "Has Credentials", "Path"]); + for p in profiles { + table.add_row(vec![ + if p.is_active { "*" } else { " " }, + &p.name, + if p.has_config { "yes" } else { "no" }, + if p.has_credentials { "yes" } else { "no" }, + &p.path.display().to_string(), + ]); + } + println!("{table}"); + Ok(()) + } + KimiProfileCommand::Save { name } => { + let path = save_profile(&name).map_err(|e| AppError::Message(e.to_string()))?; + println!( + "{}", + success(&format!( + "Current Kimi Code configuration saved to profile '{}' ({}).", + name, + path.display() + )) + ); + Ok(()) + } + KimiProfileCommand::Switch { name } => { + switch_profile(&name).map_err(|e| AppError::Message(e.to_string()))?; + println!( + "{}", + success(&format!( + "Switched Kimi Code configuration to profile '{}'.", + name + )) + ); + Ok(()) + } + KimiProfileCommand::Remove { name, yes } => { + if !yes && !confirm(&format!("Remove Kimi Code profile '{name}'?"))? { + println!("{}", info("Cancelled.")); + return Ok(()); + } + remove_profile(&name).map_err(|e| AppError::Message(e.to_string()))?; + println!("{}", success(&format!("Removed Kimi Code profile '{name}'."))); + Ok(()) + } + } +} + +fn confirm(prompt: &str) -> Result { + inquire::Confirm::new(prompt) + .with_default(false) + .prompt() + .map_err(|err| AppError::Message(format!("failed to confirm action: {err}"))) +} diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs index b258b3fe6..2dc76e706 100644 --- a/src-tauri/src/cli/commands/mod.rs +++ b/src-tauri/src/cli/commands/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod completions; pub mod config; mod config_common; +pub(crate) mod config_kimi; pub(crate) mod config_openclaw; pub mod config_s3; pub mod config_webdav; diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index ebe59f4d3..26115041f 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -690,8 +690,22 @@ mod tests { let cli = Cli::parse_from(["cc-switch", "auth", "status", "--json"]); match cli.command { - Some(Commands::Auth(super::commands::auth::AuthCommand::Status { json })) => { + Some(Commands::Auth(super::commands::auth::AuthCommand::Status { json, provider })) => { assert!(json); + assert_eq!(provider, "codex_oauth"); + } + _ => panic!("expected auth status command"), + } + } + + #[test] + fn parses_auth_status_kimi_provider_subcommand() { + let cli = Cli::parse_from(["cc-switch", "auth", "status", "--provider", "kimi_oauth", "--json"]); + + match cli.command { + Some(Commands::Auth(super::commands::auth::AuthCommand::Status { json, provider })) => { + assert!(json); + assert_eq!(provider, "kimi_oauth"); } _ => panic!("expected auth status command"), } @@ -702,8 +716,9 @@ mod tests { let cli = Cli::parse_from(["cc-switch", "auth", "login", "--json"]); match cli.command { - Some(Commands::Auth(super::commands::auth::AuthCommand::Login { json })) => { + Some(Commands::Auth(super::commands::auth::AuthCommand::Login { json, provider })) => { assert!(json); + assert_eq!(provider, "codex_oauth"); } _ => panic!("expected auth login command"), } @@ -721,8 +736,9 @@ mod tests { let cli = Cli::parse_from(["cc-switch", "auth", "default", "acc-123"]); match cli.command { - Some(Commands::Auth(super::commands::auth::AuthCommand::Default { account_id })) => { + Some(Commands::Auth(super::commands::auth::AuthCommand::Default { account_id, provider })) => { assert_eq!(account_id, "acc-123"); + assert_eq!(provider, "codex_oauth"); } _ => panic!("expected auth default command"), } @@ -736,9 +752,11 @@ mod tests { Some(Commands::Auth(super::commands::auth::AuthCommand::Remove { account_id, yes, + provider, })) => { assert_eq!(account_id, "acc-123"); assert!(yes); + assert_eq!(provider, "codex_oauth"); } _ => panic!("expected auth remove command"), } @@ -749,8 +767,9 @@ mod tests { let cli = Cli::parse_from(["cc-switch", "auth", "logout", "--yes"]); match cli.command { - Some(Commands::Auth(super::commands::auth::AuthCommand::Logout { yes })) => { + Some(Commands::Auth(super::commands::auth::AuthCommand::Logout { yes, provider })) => { assert!(yes); + assert_eq!(provider, "codex_oauth"); } _ => panic!("expected auth logout command"), } @@ -1716,6 +1735,31 @@ mod tests { } } + #[test] + fn parses_config_kimi_path_and_profile_subcommands() { + let cli = Cli::parse_from(["cc-switch", "config", "kimi", "path", "--json"]); + match cli.command { + Some(Commands::Config(super::commands::config::ConfigCommand::Kimi( + super::commands::config_kimi::KimiConfigCommand::Path { json }, + ))) => { + assert!(json); + } + _ => panic!("expected config kimi path command"), + } + + let cli_switch = Cli::parse_from(["cc-switch", "config", "kimi", "profile", "switch", "work"]); + match cli_switch.command { + Some(Commands::Config(super::commands::config::ConfigCommand::Kimi( + super::commands::config_kimi::KimiConfigCommand::Profile( + super::commands::config_kimi::KimiProfileCommand::Switch { name }, + ), + ))) => { + assert_eq!(name, "work"); + } + _ => panic!("expected config kimi profile switch command"), + } + } + #[test] fn config_common_set_help_describes_snippet_as_primary_contract() { let mut cmd = Cli::command(); diff --git a/src-tauri/src/kimi_config.rs b/src-tauri/src/kimi_config.rs new file mode 100644 index 000000000..6982b99f0 --- /dev/null +++ b/src-tauri/src/kimi_config.rs @@ -0,0 +1,445 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_KIMI_CONFIG_DIR: &str = ".kimi-code"; +pub const KIMI_HOME_ENV: &str = "KIMI_CODE_HOME"; +pub const KIMI_CREDENTIALS_DIR: &str = "credentials"; +pub const KIMI_DEFAULT_CREDENTIAL_FILE: &str = "kimi-code.json"; +pub const KIMI_CONFIG_FILE: &str = "config.toml"; +pub const KIMI_TUI_FILE: &str = "tui.toml"; +pub const KIMI_PROFILES_DIR_NAME: &str = "kimi_profiles"; +pub const KIMI_ACTIVE_PROFILE_FILE: &str = "kimi_active_profile"; + +/// Kimi Code native 登录认证文件结构 (~/.kimi-code/credentials/kimi-code.json) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct KimiNativeCredentials { + pub access_token: String, + pub refresh_token: String, + #[serde(default)] + pub expires_in: Option, + #[serde(default)] + pub token_type: Option, + #[serde(default)] + pub scope: Option, + #[serde(default)] + pub expires_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct KimiProfileInfo { + pub name: String, + pub path: PathBuf, + pub is_active: bool, + pub has_credentials: bool, + pub has_config: bool, +} + +/// 解析 Kimi Code 根目录路径(遵循 KIMI_CODE_HOME 环境变量,默认 ~/.kimi-code) +pub fn get_kimi_config_dir() -> PathBuf { + if let Some(env_val) = std::env::var_os(KIMI_HOME_ENV) { + if !env_val.is_empty() { + return PathBuf::from(env_val); + } + } + dirs::home_dir() + .map(|p| p.join(DEFAULT_KIMI_CONFIG_DIR)) + .unwrap_or_else(|| PathBuf::from(DEFAULT_KIMI_CONFIG_DIR)) +} + +/// 获取 cc-switch 管理的 Kimi 配置 profiles 存储目录 +pub fn get_kimi_profiles_dir() -> PathBuf { + crate::config::get_app_config_dir().join(KIMI_PROFILES_DIR_NAME) +} + +/// 获取当前激活的 Profile 名称(如果记录过) +pub fn get_active_profile_name() -> Option { + let path = crate::config::get_app_config_dir().join(KIMI_ACTIVE_PROFILE_FILE); + if path.exists() { + fs::read_to_string(path).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) + } else { + None + } +} + +/// 设置当前激活的 Profile 名称记录 +pub fn set_active_profile_name(name: Option<&str>) -> Result<()> { + let path = crate::config::get_app_config_dir().join(KIMI_ACTIVE_PROFILE_FILE); + if let Some(n) = name { + write_file_atomic(&path, n.trim(), 0o644)?; + } else if path.exists() { + let _ = fs::remove_file(path); + } + Ok(()) +} + +/// 读取当前 native credentials +pub fn read_native_credentials() -> Result> { + let cred_path = get_kimi_config_dir() + .join(KIMI_CREDENTIALS_DIR) + .join(KIMI_DEFAULT_CREDENTIAL_FILE); + + if !cred_path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&cred_path) + .with_context(|| format!("读取 Kimi 凭据文件失败: {}", cred_path.display()))?; + + let parsed: KimiNativeCredentials = serde_json::from_str(&content) + .with_context(|| format!("解析 Kimi 凭据文件失败: {}", cred_path.display()))?; + + Ok(Some(parsed)) +} + +/// 原子写入 native credentials +pub fn write_native_credentials(credentials: &KimiNativeCredentials) -> Result<()> { + let dir = get_kimi_config_dir().join(KIMI_CREDENTIALS_DIR); + fs::create_dir_all(&dir) + .with_context(|| format!("创建 Kimi 凭据目录失败: {}", dir.display()))?; + + let cred_path = dir.join(KIMI_DEFAULT_CREDENTIAL_FILE); + let content = serde_json::to_string_pretty(credentials) + .context("序列化 Kimi 凭据失败")?; + + write_file_atomic(&cred_path, &content, 0o600)?; + Ok(()) +} + +/// 同步账号认证信息至 native ~/.kimi-code +pub fn sync_kimi_account_to_native( + access_token: &str, + refresh_token: &str, + expires_in: i64, + expires_at_sec: i64, +) -> Result<()> { + let creds = KimiNativeCredentials { + access_token: access_token.to_string(), + refresh_token: refresh_token.to_string(), + expires_in: Some(expires_in), + token_type: Some("Bearer".to_string()), + scope: None, + expires_at: Some(expires_at_sec), + }; + + write_native_credentials(&creds) +} + +/// 清除 native credentials +pub fn clear_native_credentials() -> Result<()> { + let cred_path = get_kimi_config_dir() + .join(KIMI_CREDENTIALS_DIR) + .join(KIMI_DEFAULT_CREDENTIAL_FILE); + + if cred_path.exists() { + fs::remove_file(&cred_path) + .with_context(|| format!("删除 Kimi 凭据失败: {}", cred_path.display()))?; + } + Ok(()) +} + +/// 列出所有已保存的 Kimi 配置 Profiles +pub fn list_profiles() -> Result> { + let profiles_dir = get_kimi_profiles_dir(); + if !profiles_dir.exists() { + return Ok(Vec::new()); + } + + let active_name = get_active_profile_name(); + let mut profiles = Vec::new(); + + for entry in fs::read_dir(&profiles_dir)? { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_dir() { + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + let has_credentials = path.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE).exists(); + let has_config = path.join(KIMI_CONFIG_FILE).exists(); + let is_active = active_name.as_deref() == Some(&name); + + profiles.push(KimiProfileInfo { + name, + path, + is_active, + has_credentials, + has_config, + }); + } + } + + profiles.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(profiles) +} + +/// 保存当前活动的 ~/.kimi-code 配置到指定名称的 Profile +pub fn save_profile(name: &str) -> Result { + let name = validate_profile_name(name)?; + let src_dir = get_kimi_config_dir(); + let target_dir = get_kimi_profiles_dir().join(name); + + fs::create_dir_all(&target_dir) + .with_context(|| format!("创建 Profile 目录失败: {}", target_dir.display()))?; + + // 复制 config.toml + let src_config = src_dir.join(KIMI_CONFIG_FILE); + if src_config.exists() { + fs::copy(&src_config, target_dir.join(KIMI_CONFIG_FILE))?; + } + + // 复制 tui.toml + let src_tui = src_dir.join(KIMI_TUI_FILE); + if src_tui.exists() { + fs::copy(&src_tui, target_dir.join(KIMI_TUI_FILE))?; + } + + // 复制 credentials + let src_creds_dir = src_dir.join(KIMI_CREDENTIALS_DIR); + if src_creds_dir.exists() { + let target_creds_dir = target_dir.join(KIMI_CREDENTIALS_DIR); + fs::create_dir_all(&target_creds_dir)?; + let src_cred_file = src_creds_dir.join(KIMI_DEFAULT_CREDENTIAL_FILE); + if src_cred_file.exists() { + fs::copy(&src_cred_file, target_creds_dir.join(KIMI_DEFAULT_CREDENTIAL_FILE))?; + } + } + + set_active_profile_name(Some(name))?; + Ok(target_dir) +} + +/// 切换激活指定的 Profile(将该 Profile 写入当前 ~/.kimi-code 根目录) +pub fn switch_profile(name: &str) -> Result<()> { + let name = validate_profile_name(name)?; + let profile_dir = get_kimi_profiles_dir().join(name); + if !profile_dir.exists() { + anyhow::bail!("Profile '{}' 不存在", name); + } + + let target_dir = get_kimi_config_dir(); + fs::create_dir_all(&target_dir) + .with_context(|| format!("创建目标目录失败: {}", target_dir.display()))?; + + // 恢复 config.toml + let p_config = profile_dir.join(KIMI_CONFIG_FILE); + let target_config = target_dir.join(KIMI_CONFIG_FILE); + if p_config.exists() { + fs::copy(&p_config, &target_config)?; + } else if target_config.exists() { + let _ = fs::remove_file(&target_config); + } + + // 恢复 tui.toml + let p_tui = profile_dir.join(KIMI_TUI_FILE); + let target_tui = target_dir.join(KIMI_TUI_FILE); + if p_tui.exists() { + fs::copy(&p_tui, &target_tui)?; + } else if target_tui.exists() { + let _ = fs::remove_file(&target_tui); + } + + // 恢复 credentials + let p_creds_file = profile_dir.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE); + let target_creds_dir = target_dir.join(KIMI_CREDENTIALS_DIR); + let target_creds_file = target_creds_dir.join(KIMI_DEFAULT_CREDENTIAL_FILE); + + if p_creds_file.exists() { + fs::create_dir_all(&target_creds_dir)?; + fs::copy(&p_creds_file, &target_creds_file)?; + } else if target_creds_file.exists() { + let _ = fs::remove_file(&target_creds_file); + } + + set_active_profile_name(Some(name))?; + Ok(()) +} + +/// 删除指定的 Profile +pub fn remove_profile(name: &str) -> Result<()> { + let name = validate_profile_name(name)?; + let profile_dir = get_kimi_profiles_dir().join(name); + if profile_dir.exists() { + fs::remove_dir_all(&profile_dir) + .with_context(|| format!("删除 Profile 目录失败: {}", profile_dir.display()))?; + } + + if get_active_profile_name().as_deref() == Some(name) { + let _ = set_active_profile_name(None); + } + + Ok(()) +} + +fn validate_profile_name(name: &str) -> Result<&str> { + let trimmed = name.trim(); + if trimmed.is_empty() { + anyhow::bail!("Profile 名称不能为空"); + } + if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { + anyhow::bail!("Profile 名称包含非法字符: {}", name); + } + Ok(trimmed) +} + +fn write_file_atomic(path: &Path, content: &str, #[allow(unused_variables)] mode: u32) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("无效的路径: {}", path.display()))?; + + fs::create_dir_all(parent) + .with_context(|| format!("创建目录失败: {}", parent.display()))?; + + let filename = path + .file_name() + .ok_or_else(|| anyhow::anyhow!("无效的文件名: {}", path.display()))? + .to_string_lossy(); + + let temp_path = parent.join(format!( + ".{filename}.tmp.{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&temp_path) + .with_context(|| format!("创建临时文件失败: {}", temp_path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(mode))?; + } + + file.write_all(content.as_bytes())?; + file.sync_all()?; + drop(file); + + fs::rename(&temp_path, path).with_context(|| { + let _ = fs::remove_file(&temp_path); + format!("重命名临时文件到目标文件失败: {}", path.display()) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_write_and_read_native_credentials() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().unwrap(); + let old_env = std::env::var_os(KIMI_HOME_ENV); + std::env::set_var(KIMI_HOME_ENV, temp.path()); + + let creds = KimiNativeCredentials { + access_token: "test_at".to_string(), + refresh_token: "test_rt".to_string(), + expires_in: Some(3600), + token_type: Some("Bearer".to_string()), + scope: None, + expires_at: Some(1720000000), + }; + + write_native_credentials(&creds).unwrap(); + + let read_back = read_native_credentials().unwrap(); + assert_eq!(read_back, Some(creds)); + + clear_native_credentials().unwrap(); + assert_eq!(read_native_credentials().unwrap(), None); + + if let Some(val) = old_env { + std::env::set_var(KIMI_HOME_ENV, val); + } else { + std::env::remove_var(KIMI_HOME_ENV); + } + } + + #[test] + fn test_profile_save_and_switch() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp_home = tempfile::tempdir().unwrap(); + let temp_app = tempfile::tempdir().unwrap(); + + let old_home_env = std::env::var_os(KIMI_HOME_ENV); + let old_app_env = std::env::var_os("CC_SWITCH_CONFIG_DIR"); + + std::env::set_var(KIMI_HOME_ENV, temp_home.path()); + std::env::set_var("CC_SWITCH_CONFIG_DIR", temp_app.path()); + + // 写入初始环境 + let config_file = temp_home.path().join(KIMI_CONFIG_FILE); + fs::write(&config_file, "default_model = 'kimi-k2'").unwrap(); + + let creds = KimiNativeCredentials { + access_token: "work_token".to_string(), + refresh_token: "work_rt".to_string(), + expires_in: Some(3600), + token_type: Some("Bearer".to_string()), + scope: None, + expires_at: None, + }; + write_native_credentials(&creds).unwrap(); + + // 保存为 work profile + save_profile("work").unwrap(); + + let profiles = list_profiles().unwrap(); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].name, "work"); + assert!(profiles[0].is_active); + assert!(profiles[0].has_credentials); + assert!(profiles[0].has_config); + + // 修改当前环境为 personal + fs::write(&config_file, "default_model = 'kimi-k1.5'").unwrap(); + let personal_creds = KimiNativeCredentials { + access_token: "personal_token".to_string(), + refresh_token: "personal_rt".to_string(), + expires_in: Some(3600), + token_type: Some("Bearer".to_string()), + scope: None, + expires_at: None, + }; + write_native_credentials(&personal_creds).unwrap(); + + // 保存为 personal profile + save_profile("personal").unwrap(); + assert_eq!(list_profiles().unwrap().len(), 2); + assert_eq!(get_active_profile_name().as_deref(), Some("personal")); + + // 切换回 work profile + switch_profile("work").unwrap(); + assert_eq!(get_active_profile_name().as_deref(), Some("work")); + + // 验证当前配置和凭据已被还原为 work + let current_config = fs::read_to_string(&config_file).unwrap(); + assert_eq!(current_config, "default_model = 'kimi-k2'"); + let current_creds = read_native_credentials().unwrap().unwrap(); + assert_eq!(current_creds.access_token, "work_token"); + + // 删除 personal profile + remove_profile("personal").unwrap(); + assert_eq!(list_profiles().unwrap().len(), 1); + + if let Some(val) = old_home_env { + std::env::set_var(KIMI_HOME_ENV, val); + } else { + std::env::remove_var(KIMI_HOME_ENV); + } + if let Some(val) = old_app_env { + std::env::set_var("CC_SWITCH_CONFIG_DIR", val); + } else { + std::env::remove_var("CC_SWITCH_CONFIG_DIR"); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cbfb91bc4..89d863097 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ pub mod hermes_config; mod import_export; #[allow(dead_code)] mod init_status; +pub mod kimi_config; mod mcp; mod model_capabilities; mod openclaw_config; diff --git a/src-tauri/src/proxy/providers/kimi_oauth_auth.rs b/src-tauri/src/proxy/providers/kimi_oauth_auth.rs new file mode 100644 index 000000000..0a10059d2 --- /dev/null +++ b/src-tauri/src/proxy/providers/kimi_oauth_auth.rs @@ -0,0 +1,917 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use tokio::sync::{Mutex, RwLock}; + +pub const KIMI_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098"; +pub const KIMI_DEVICE_AUTH_URL: &str = "https://auth.kimi.com/api/oauth/device_authorization"; +pub const KIMI_TOKEN_URL: &str = "https://auth.kimi.com/api/oauth/token"; +pub const KIMI_USER_INFO_URL: &str = "https://api.kimi.com/coding/v1/me"; +pub const KIMI_DEFAULT_VERIFICATION_URL: &str = "https://auth.kimi.com/device"; +pub const KIMI_USER_AGENT: &str = "cc-switch-kimi-oauth"; +const TOKEN_REFRESH_BUFFER_MS: i64 = 60_000; +const DEVICE_CODE_DEFAULT_EXPIRES_IN: u64 = 300; +const POLLING_SAFETY_MARGIN_SECS: u64 = 3; + +#[derive(Debug, thiserror::Error)] +pub enum KimiOAuthError { + #[error("等待用户授权中 (authorization pending)")] + AuthorizationPending, + #[error("用户拒绝授权 (access denied)")] + AccessDenied, + #[error("Device Code 已过期 (expired token)")] + ExpiredToken, + #[error("OAuth Token 获取失败: {0}")] + TokenFetchFailed(String), + #[error("Refresh Token 失效或已过期")] + RefreshTokenInvalid, + #[error("网络错误: {0}")] + NetworkError(String), + #[error("解析错误: {0}")] + ParseError(String), + #[error("IO 错误: {0}")] + IoError(String), + #[error("账号不存在: {0}")] + AccountNotFound(String), +} + +impl From for KimiOAuthError { + fn from(err: reqwest::Error) -> Self { + KimiOAuthError::NetworkError(err.to_string()) + } +} + +impl From for KimiOAuthError { + fn from(err: std::io::Error) -> Self { + KimiOAuthError::IoError(err.to_string()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManagedAuthAccount { + pub id: String, + pub login: String, + pub avatar_url: Option, + pub authenticated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManagedAuthDeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KimiOAuthStatus { + pub accounts: Vec, + pub default_account_id: Option, + pub authenticated: bool, + pub username: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct DeviceCodeResponse { + device_code: String, + user_code: String, + #[serde(default)] + verification_uri: Option, + #[serde(default)] + verification_uri_complete: Option, + #[serde(default)] + interval: Option, + #[serde(default)] + expires_in: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct KimiTokenResponse { + pub access_token: String, + pub refresh_token: Option, + #[serde(default)] + pub expires_in: Option, + #[serde(default)] + pub token_type: Option, + #[serde(default)] + pub scope: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub error_description: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct KimiUserInfoResponse { + #[serde(default)] + user_id: Option, + #[serde(default)] + nickname: Option, + #[serde(default)] + email: Option, + #[serde(default)] + avatar: Option, +} + +#[derive(Debug, Clone)] +struct CachedAccessToken { + token: String, + expires_at_ms: i64, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +struct PendingDeviceCode { + user_code: String, + expires_at_ms: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KimiAccountData { + pub account_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nickname: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + pub refresh_token: String, + pub authenticated_at: i64, +} + +impl From<&KimiAccountData> for ManagedAuthAccount { + fn from(data: &KimiAccountData) -> Self { + let login = if let Some(nickname) = data.nickname.as_deref().filter(|s| !s.trim().is_empty()) { + if let Some(email) = data.email.as_deref().filter(|s| !s.trim().is_empty()) { + format!("{nickname} ({email})") + } else { + nickname.to_string() + } + } else if let Some(email) = data.email.as_deref().filter(|s| !s.trim().is_empty()) { + email.to_string() + } else { + format!("Kimi ({})", &data.account_id) + }; + + Self { + id: data.account_id.clone(), + login, + avatar_url: data.avatar_url.clone(), + authenticated_at: data.authenticated_at, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct KimiOAuthStore { + #[serde(default)] + version: u32, + #[serde(default)] + accounts: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + default_account_id: Option, +} + +pub struct KimiOAuthManager { + accounts: std::sync::Arc>>, + default_account_id: std::sync::Arc>>, + access_tokens: std::sync::Arc>>, + refresh_locks: std::sync::Arc>>>>, + pending_device_codes: std::sync::Arc>>, + storage_path: PathBuf, +} + +impl KimiOAuthManager { + pub fn new(data_dir: PathBuf) -> Self { + let storage_path = data_dir.join("kimi_oauth_auth.json"); + let manager = Self { + accounts: std::sync::Arc::new(RwLock::new(HashMap::new())), + default_account_id: std::sync::Arc::new(RwLock::new(None)), + access_tokens: std::sync::Arc::new(RwLock::new(HashMap::new())), + refresh_locks: std::sync::Arc::new(RwLock::new(HashMap::new())), + pending_device_codes: std::sync::Arc::new(RwLock::new(HashMap::new())), + storage_path, + }; + + if let Err(e) = manager.load_from_disk_sync() { + log::warn!("[KimiOAuth] 加载存储失败: {e}"); + } + + manager + } + + pub async fn start_device_flow( + &self, + ) -> Result { + let params = [("client_id", KIMI_CLIENT_ID)]; + let response = crate::proxy::http_client::get() + .post(KIMI_DEVICE_AUTH_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("User-Agent", KIMI_USER_AGENT) + .form(¶ms) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(KimiOAuthError::NetworkError(format!( + "Kimi Device Code 请求失败: {status} - {text}" + ))); + } + + let device: DeviceCodeResponse = response + .json() + .await + .map_err(|e| KimiOAuthError::ParseError(e.to_string()))?; + + let interval = parse_interval(device.interval.as_ref()); + let expires_in = device.expires_in.unwrap_or(DEVICE_CODE_DEFAULT_EXPIRES_IN); + let expires_at_ms = chrono::Utc::now().timestamp_millis() + (expires_in as i64) * 1000; + + { + let mut pending = self.pending_device_codes.write().await; + let now_ms = chrono::Utc::now().timestamp_millis(); + pending.retain(|_, entry| entry.expires_at_ms > now_ms); + pending.insert( + device.device_code.clone(), + PendingDeviceCode { + user_code: device.user_code.clone(), + expires_at_ms, + }, + ); + } + + let verification_uri = device + .verification_uri_complete + .or(device.verification_uri) + .unwrap_or_else(|| KIMI_DEFAULT_VERIFICATION_URL.to_string()); + + Ok(ManagedAuthDeviceCodeResponse { + device_code: device.device_code, + user_code: device.user_code, + verification_uri, + expires_in, + interval, + }) + } + + pub async fn poll_for_token( + &self, + device_code: &str, + ) -> Result, KimiOAuthError> { + let entry = { + let pending = self.pending_device_codes.read().await; + pending.get(device_code).cloned() + } + .ok_or_else(|| { + KimiOAuthError::TokenFetchFailed( + "未找到对应的 device_code,请重新启动登录流程".to_string(), + ) + })?; + + if entry.expires_at_ms <= chrono::Utc::now().timestamp_millis() { + let mut pending = self.pending_device_codes.write().await; + pending.remove(device_code); + return Err(KimiOAuthError::ExpiredToken); + } + + let params = [ + ("client_id", KIMI_CLIENT_ID), + ("device_code", device_code), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]; + + let poll_response = crate::proxy::http_client::get() + .post(KIMI_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("User-Agent", KIMI_USER_AGENT) + .form(¶ms) + .send() + .await?; + + let status = poll_response.status(); + let body_text = poll_response.text().await.unwrap_or_default(); + + if !status.is_success() && status.as_u16() >= 500 { + return Err(KimiOAuthError::NetworkError(format!( + "Kimi 服务器错误: {status} - {body_text}" + ))); + } + + let token_resp: KimiTokenResponse = match serde_json::from_str(&body_text) { + Ok(parsed) => parsed, + Err(_) => { + return Err(KimiOAuthError::TokenFetchFailed(format!( + "无法解析响应: {status} - {body_text}" + ))); + } + }; + + if let Some(err_code) = token_resp.error.as_deref() { + match err_code { + "authorization_pending" | "slow_down" => { + return Err(KimiOAuthError::AuthorizationPending); + } + "expired_token" => { + let mut pending = self.pending_device_codes.write().await; + pending.remove(device_code); + return Err(KimiOAuthError::ExpiredToken); + } + "access_denied" => { + let mut pending = self.pending_device_codes.write().await; + pending.remove(device_code); + return Err(KimiOAuthError::AccessDenied); + } + other => { + let desc = token_resp + .error_description + .unwrap_or_else(|| other.to_string()); + return Err(KimiOAuthError::TokenFetchFailed(format!( + "Token 请求被拒绝: {desc}" + ))); + } + } + } + + if token_resp.access_token.is_empty() { + return Err(KimiOAuthError::TokenFetchFailed( + "响应缺少 access_token".to_string(), + )); + } + + let refresh_token = token_resp.refresh_token.ok_or_else(|| { + KimiOAuthError::TokenFetchFailed("响应缺少 refresh_token".to_string()) + })?; + + // 成功获得 token,移除 pending device code + { + let mut pending = self.pending_device_codes.write().await; + pending.remove(device_code); + } + + // 获取用户资料 + let user_info = Self::fetch_user_info(&token_resp.access_token).await.ok(); + let account_id = user_info + .as_ref() + .and_then(|u| u.user_id.clone()) + .filter(|id| !id.trim().is_empty()) + .unwrap_or_else(|| { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(refresh_token.as_bytes()); + let result = hasher.finalize(); + format!("kimi_{:x}", &result)[..16].to_string() + }); + + let account = self + .add_account_internal( + account_id, + refresh_token.clone(), + user_info.as_ref().and_then(|u| u.nickname.clone()), + user_info.as_ref().and_then(|u| u.email.clone()), + user_info.as_ref().and_then(|u| u.avatar.clone()), + ) + .await?; + + // 缓存 access token + let expires_in_sec = token_resp.expires_in.unwrap_or(3600); + let expires_at_ms = chrono::Utc::now().timestamp_millis() + expires_in_sec * 1000; + { + let mut tokens = self.access_tokens.write().await; + tokens.insert( + account.id.clone(), + CachedAccessToken { + token: token_resp.access_token.clone(), + expires_at_ms, + }, + ); + } + + // 如果是当前默认账号,同步写入 native ~/.kimi-code + if self.default_account_id().await.as_deref() == Some(&account.id) { + let _ = crate::kimi_config::sync_kimi_account_to_native( + &token_resp.access_token, + &refresh_token, + expires_in_sec, + expires_at_ms / 1000, + ); + } + + Ok(Some(account)) + } + + async fn fetch_user_info(access_token: &str) -> Result { + let response = crate::proxy::http_client::get() + .get(KIMI_USER_INFO_URL) + .header("Authorization", format!("Bearer {access_token}")) + .header("Accept", "application/json") + .header("User-Agent", KIMI_USER_AGENT) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await?; + + if !response.status().is_success() { + return Err(KimiOAuthError::NetworkError(format!( + "获取 Kimi 用户信息失败: HTTP {}", + response.status() + ))); + } + + let user_info: KimiUserInfoResponse = response + .json() + .await + .map_err(|e| KimiOAuthError::ParseError(e.to_string()))?; + + Ok(user_info) + } + + async fn refresh_access_token(&self, refresh_token: &str) -> Result { + let params = [ + ("client_id", KIMI_CLIENT_ID), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ]; + + let response = crate::proxy::http_client::get() + .post(KIMI_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("User-Agent", KIMI_USER_AGENT) + .form(¶ms) + .send() + .await?; + + let status = response.status(); + let body_text = response.text().await.unwrap_or_default(); + + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(KimiOAuthError::RefreshTokenInvalid); + } + + if !status.is_success() { + return Err(KimiOAuthError::TokenFetchFailed(format!( + "Token 刷新失败: {status} - {body_text}" + ))); + } + + let token_resp: KimiTokenResponse = serde_json::from_str(&body_text) + .map_err(|e| KimiOAuthError::ParseError(format!("{e}: {body_text}")))?; + + if token_resp.access_token.is_empty() { + return Err(KimiOAuthError::TokenFetchFailed( + "刷新响应中缺少 access_token".to_string(), + )); + } + + Ok(token_resp) + } + + pub async fn get_valid_token_for_account( + &self, + account_id: &str, + ) -> Result { + let now_ms = chrono::Utc::now().timestamp_millis(); + + { + let tokens = self.access_tokens.read().await; + if let Some(cached) = tokens.get(account_id) { + if cached.expires_at_ms > now_ms + TOKEN_REFRESH_BUFFER_MS { + return Ok(cached.token.clone()); + } + } + } + + let refresh_lock = { + let mut locks = self.refresh_locks.write().await; + locks + .entry(account_id.to_string()) + .or_insert_with(|| std::sync::Arc::new(Mutex::new(()))) + .clone() + }; + + let _guard = refresh_lock.lock().await; + + { + let tokens = self.access_tokens.read().await; + if let Some(cached) = tokens.get(account_id) { + if cached.expires_at_ms > now_ms + TOKEN_REFRESH_BUFFER_MS { + return Ok(cached.token.clone()); + } + } + } + + let refresh_token = { + let accounts = self.accounts.read().await; + accounts + .get(account_id) + .ok_or_else(|| KimiOAuthError::AccountNotFound(account_id.to_string()))? + .refresh_token + .clone() + }; + + let token_resp = self.refresh_access_token(&refresh_token).await?; + let expires_in_sec = token_resp.expires_in.unwrap_or(3600); + let expires_at_ms = chrono::Utc::now().timestamp_millis() + expires_in_sec * 1000; + + { + let mut tokens = self.access_tokens.write().await; + tokens.insert( + account_id.to_string(), + CachedAccessToken { + token: token_resp.access_token.clone(), + expires_at_ms, + }, + ); + } + + if let Some(new_rt) = token_resp.refresh_token.filter(|rt| rt != &refresh_token) { + let mut accounts = self.accounts.write().await; + if let Some(acc) = accounts.get_mut(account_id) { + acc.refresh_token = new_rt; + } + drop(accounts); + self.save_to_disk().await?; + } + + // 如果是当前默认账号,同步写入 native ~/.kimi-code + if self.default_account_id().await.as_deref() == Some(account_id) { + let rt = { + let accounts = self.accounts.read().await; + accounts.get(account_id).map(|a| a.refresh_token.clone()) + }; + if let Some(rt) = rt { + let _ = crate::kimi_config::sync_kimi_account_to_native( + &token_resp.access_token, + &rt, + expires_in_sec, + expires_at_ms / 1000, + ); + } + } + + Ok(token_resp.access_token) + } + + #[allow(dead_code)] + pub async fn get_valid_token(&self) -> Result { + let default_id = self.default_account_id().await; + match default_id { + Some(id) => self.get_valid_token_for_account(&id).await, + None => Err(KimiOAuthError::AccountNotFound( + "未设置默认 Kimi 账号".to_string(), + )), + } + } + + pub async fn remove_account(&self, account_id: &str) -> Result<(), KimiOAuthError> { + { + let mut accounts = self.accounts.write().await; + if accounts.remove(account_id).is_none() { + return Err(KimiOAuthError::AccountNotFound(account_id.to_string())); + } + + let mut default_id = self.default_account_id.write().await; + if default_id.as_deref() == Some(account_id) { + *default_id = accounts.keys().next().cloned(); + } + } + + self.access_tokens.write().await.remove(account_id); + self.refresh_locks.write().await.remove(account_id); + self.save_to_disk().await?; + Ok(()) + } + + pub async fn set_default_account(&self, account_id: &str) -> Result<(), KimiOAuthError> { + { + let accounts = self.accounts.read().await; + if !accounts.contains_key(account_id) { + return Err(KimiOAuthError::AccountNotFound(account_id.to_string())); + } + } + + *self.default_account_id.write().await = Some(account_id.to_string()); + self.save_to_disk().await?; + + // 切换默认账号时,自动同步激活至 native ~/.kimi-code + if let Ok(token) = self.get_valid_token_for_account(account_id).await { + let accounts = self.accounts.read().await; + if let Some(acc) = accounts.get(account_id) { + let tokens = self.access_tokens.read().await; + let expires_at_sec = tokens.get(account_id).map(|t| t.expires_at_ms / 1000).unwrap_or(0); + let _ = crate::kimi_config::sync_kimi_account_to_native( + &token, + &acc.refresh_token, + 3600, + expires_at_sec, + ); + } + } + + Ok(()) + } + + pub async fn clear_auth(&self) -> Result<(), KimiOAuthError> { + self.accounts.write().await.clear(); + *self.default_account_id.write().await = None; + self.access_tokens.write().await.clear(); + self.refresh_locks.write().await.clear(); + self.pending_device_codes.write().await.clear(); + + if self.storage_path.exists() { + std::fs::remove_file(&self.storage_path)?; + } + + Ok(()) + } + + #[allow(dead_code)] + pub async fn is_authenticated(&self) -> bool { + !self.accounts.read().await.is_empty() + } + + pub async fn default_account_id(&self) -> Option { + self.default_account_id.read().await.clone() + } + + pub async fn list_accounts(&self) -> Vec { + let accounts = self.accounts.read().await; + let mut list: Vec = accounts.values().map(ManagedAuthAccount::from).collect(); + list.sort_by(|a, b| b.authenticated_at.cmp(&a.authenticated_at)); + list + } + + pub async fn get_status(&self) -> KimiOAuthStatus { + let accounts = self.list_accounts().await; + let default_id = self.default_account_id().await; + let authenticated = !accounts.is_empty(); + let username = default_id + .as_ref() + .and_then(|id| accounts.iter().find(|a| &a.id == id)) + .map(|a| a.login.clone()); + + KimiOAuthStatus { + accounts, + default_account_id: default_id, + authenticated, + username, + } + } + + async fn add_account_internal( + &self, + account_id: String, + refresh_token: String, + nickname: Option, + email: Option, + avatar_url: Option, + ) -> Result { + let now = chrono::Utc::now().timestamp(); + let account_data = KimiAccountData { + account_id: account_id.clone(), + nickname, + email, + avatar_url, + refresh_token, + authenticated_at: now, + }; + + let result = ManagedAuthAccount::from(&account_data); + + { + let mut accounts = self.accounts.write().await; + accounts.insert(account_id.clone(), account_data); + } + + { + let mut default_id = self.default_account_id.write().await; + if default_id.is_none() { + *default_id = Some(account_id); + } + } + + self.save_to_disk().await?; + Ok(result) + } + + #[cfg(test)] + pub(crate) async fn seed_account_for_tests( + &self, + account_id: &str, + refresh_token: &str, + nickname: Option<&str>, + email: Option<&str>, + access_token: Option<&str>, + expires_at_ms: Option, + ) -> Result<(), KimiOAuthError> { + self.add_account_internal( + account_id.to_string(), + refresh_token.to_string(), + nickname.map(str::to_string), + email.map(str::to_string), + None, + ) + .await?; + + if let Some(token) = access_token { + let mut tokens = self.access_tokens.write().await; + tokens.insert( + account_id.to_string(), + CachedAccessToken { + token: token.to_string(), + expires_at_ms: expires_at_ms.unwrap_or_else(|| { + chrono::Utc::now().timestamp_millis() + 3600 * 1000 + }), + }, + ); + } + + Ok(()) + } + + fn write_store_atomic(&self, content: &str) -> Result<(), KimiOAuthError> { + let parent = self + .storage_path + .parent() + .ok_or_else(|| KimiOAuthError::IoError("无效的存储路径".to_string()))?; + + std::fs::create_dir_all(parent)?; + + let filename = self + .storage_path + .file_name() + .ok_or_else(|| KimiOAuthError::IoError("无效的存储文件名".to_string()))? + .to_string_lossy(); + + let temp_path = parent.join(format!( + ".{filename}.tmp.{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&temp_path)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + + file.write_all(content.as_bytes())?; + file.sync_all()?; + drop(file); + + if let Err(rename_err) = fs::rename(&temp_path, &self.storage_path) { + let _ = fs::remove_file(&temp_path); + return Err(KimiOAuthError::IoError(format!( + "原子重命名持久化存储文件失败: {rename_err}" + ))); + } + + Ok(()) + } + + fn load_from_disk_sync(&self) -> Result<(), KimiOAuthError> { + if !self.storage_path.exists() { + return Ok(()); + } + + let content = fs::read_to_string(&self.storage_path)?; + let store: KimiOAuthStore = serde_json::from_str(&content) + .map_err(|e| KimiOAuthError::ParseError(e.to_string()))?; + + { + let mut accounts = self + .accounts + .try_write() + .map_err(|_| KimiOAuthError::IoError("获取 accounts 写锁失败".to_string()))?; + *accounts = store.accounts; + } + + { + let mut default_id = self + .default_account_id + .try_write() + .map_err(|_| KimiOAuthError::IoError("获取 default_id 写锁失败".to_string()))?; + *default_id = store.default_account_id; + } + + Ok(()) + } + + async fn save_to_disk(&self) -> Result<(), KimiOAuthError> { + let accounts = self.accounts.read().await.clone(); + let default_id = self.default_account_id.read().await.clone(); + + let store = KimiOAuthStore { + version: 1, + accounts, + default_account_id: default_id, + }; + + let json = serde_json::to_string_pretty(&store) + .map_err(|e| KimiOAuthError::ParseError(e.to_string()))?; + + self.write_store_atomic(&json) + } +} + +fn parse_interval(interval: Option<&serde_json::Value>) -> u64 { + let base = match interval { + Some(serde_json::Value::Number(n)) => n.as_u64().unwrap_or(5), + Some(serde_json::Value::String(s)) => s.parse::().unwrap_or(5), + _ => 5, + }; + base + POLLING_SAFETY_MARGIN_SECS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_manager_initial_state() { + let temp = tempfile::tempdir().unwrap(); + let manager = KimiOAuthManager::new(temp.path().to_path_buf()); + assert!(!manager.is_authenticated().await); + assert!(manager.list_accounts().await.is_empty()); + } + + #[tokio::test] + async fn test_manager_save_and_load() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().to_path_buf(); + { + let manager = KimiOAuthManager::new(path.clone()); + manager + .add_account_internal( + "u_123".to_string(), + "rt-kimi-secret".to_string(), + Some("KimiUser".to_string()), + Some("user@kimi.ai".to_string()), + None, + ) + .await + .unwrap(); + } + let manager2 = KimiOAuthManager::new(path); + let accounts = manager2.list_accounts().await; + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, "u_123"); + assert_eq!(accounts[0].login, "KimiUser (user@kimi.ai)"); + } + + #[tokio::test] + async fn test_remove_account_rehomes_default_account() { + let temp = tempfile::tempdir().unwrap(); + let manager = KimiOAuthManager::new(temp.path().to_path_buf()); + + manager + .seed_account_for_tests("u_1", "rt-1", Some("User1"), None, Some("at-1"), None) + .await + .unwrap(); + manager + .seed_account_for_tests("u_2", "rt-2", Some("User2"), None, Some("at-2"), None) + .await + .unwrap(); + manager.set_default_account("u_1").await.unwrap(); + + manager.remove_account("u_1").await.unwrap(); + + let accounts = manager.list_accounts().await; + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, "u_2"); + assert_eq!(manager.default_account_id().await.as_deref(), Some("u_2")); + } + + #[tokio::test] + async fn test_set_default_account() { + let temp = tempfile::tempdir().unwrap(); + let manager = KimiOAuthManager::new(temp.path().to_path_buf()); + + manager + .seed_account_for_tests("u_1", "rt-1", Some("User1"), None, Some("at-1"), None) + .await + .unwrap(); + manager + .seed_account_for_tests("u_2", "rt-2", Some("User2"), None, Some("at-2"), None) + .await + .unwrap(); + + manager.set_default_account("u_2").await.unwrap(); + assert_eq!(manager.default_account_id().await.as_deref(), Some("u_2")); + + let status = manager.get_status().await; + assert_eq!(status.default_account_id.as_deref(), Some("u_2")); + } +} diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 329d5ef4b..6b23284e0 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -5,6 +5,7 @@ mod codex; pub(crate) mod codex_chat_common; pub mod codex_chat_history; pub mod codex_oauth_auth; +pub mod kimi_oauth_auth; pub(crate) mod codex_responses_sse; #[allow(dead_code)] pub mod copilot_auth; diff --git a/src-tauri/src/services/auth.rs b/src-tauri/src/services/auth.rs index 6e28a81b1..d18a91130 100644 --- a/src-tauri/src/services/auth.rs +++ b/src-tauri/src/services/auth.rs @@ -1,7 +1,11 @@ use crate::proxy::providers::codex_oauth_auth::CodexOAuthError; -use crate::services::CodexOAuthService; +use crate::proxy::providers::kimi_oauth_auth::KimiOAuthError; +use crate::services::{CodexOAuthService, KimiOAuthService}; const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth"; +const AUTH_PROVIDER_KIMI_OAUTH: &str = "kimi_oauth"; +const AUTH_PROVIDER_KIMI_CODE: &str = "kimi-code"; +const AUTH_PROVIDER_KIMI: &str = "kimi"; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct ManagedAuthAccount { @@ -35,6 +39,9 @@ pub struct ManagedAuthDeviceCodeResponse { fn ensure_auth_provider(auth_provider: &str) -> Result<&'static str, String> { match auth_provider { AUTH_PROVIDER_CODEX_OAUTH => Ok(AUTH_PROVIDER_CODEX_OAUTH), + AUTH_PROVIDER_KIMI_OAUTH | AUTH_PROVIDER_KIMI_CODE | AUTH_PROVIDER_KIMI => { + Ok(AUTH_PROVIDER_KIMI_OAUTH) + } _ => Err(format!("Unsupported auth provider: {auth_provider}")), } } @@ -54,6 +61,21 @@ fn map_account( } } +fn map_kimi_account( + provider: &str, + account: crate::proxy::providers::kimi_oauth_auth::ManagedAuthAccount, + default_account_id: Option<&str>, +) -> ManagedAuthAccount { + ManagedAuthAccount { + is_default: default_account_id == Some(account.id.as_str()), + id: account.id, + provider: provider.to_string(), + login: account.login, + avatar_url: account.avatar_url, + authenticated_at: account.authenticated_at, + } +} + fn map_device_code_response( provider: &str, response: crate::proxy::providers::codex_oauth_auth::ManagedAuthDeviceCodeResponse, @@ -68,6 +90,20 @@ fn map_device_code_response( } } +fn map_kimi_device_code_response( + provider: &str, + response: crate::proxy::providers::kimi_oauth_auth::ManagedAuthDeviceCodeResponse, +) -> ManagedAuthDeviceCodeResponse { + ManagedAuthDeviceCodeResponse { + provider: provider.to_string(), + device_code: response.device_code, + user_code: response.user_code, + verification_uri: response.verification_uri, + expires_in: response.expires_in, + interval: response.interval, + } +} + pub struct AuthService; impl AuthService { @@ -78,6 +114,10 @@ impl AuthService { .await .map(|response| map_device_code_response(auth_provider, response)) .map_err(|error| error.to_string()), + AUTH_PROVIDER_KIMI_OAUTH => KimiOAuthService::start_device_flow() + .await + .map(|response| map_kimi_device_code_response(auth_provider, response)) + .map_err(|error| error.to_string()), _ => unreachable!(), } } @@ -100,6 +140,18 @@ impl AuthService { Err(CodexOAuthError::AuthorizationPending) => Ok(None), Err(error) => Err(error.to_string()), }, + AUTH_PROVIDER_KIMI_OAUTH => match KimiOAuthService::poll_for_token(device_code).await + { + Ok(account) => { + let default_account_id = + KimiOAuthService::get_status().await.default_account_id; + Ok(account.map(|account| { + map_kimi_account(auth_provider, account, default_account_id.as_deref()) + })) + } + Err(KimiOAuthError::AuthorizationPending) => Ok(None), + Err(error) => Err(error.to_string()), + }, _ => unreachable!(), } } @@ -118,6 +170,17 @@ impl AuthService { }) .collect()) } + AUTH_PROVIDER_KIMI_OAUTH => { + let status = KimiOAuthService::get_status().await; + let default_account_id = status.default_account_id.clone(); + Ok(status + .accounts + .into_iter() + .map(|account| { + map_kimi_account(auth_provider, account, default_account_id.as_deref()) + }) + .collect()) + } _ => unreachable!(), } } @@ -142,6 +205,23 @@ impl AuthService { .collect(), }) } + AUTH_PROVIDER_KIMI_OAUTH => { + let status = KimiOAuthService::get_status().await; + let default_account_id = status.default_account_id.clone(); + Ok(ManagedAuthStatus { + provider: auth_provider.to_string(), + authenticated: status.authenticated, + default_account_id: default_account_id.clone(), + migration_error: None, + accounts: status + .accounts + .into_iter() + .map(|account| { + map_kimi_account(auth_provider, account, default_account_id.as_deref()) + }) + .collect(), + }) + } _ => unreachable!(), } } @@ -152,6 +232,9 @@ impl AuthService { AUTH_PROVIDER_CODEX_OAUTH => CodexOAuthService::remove_account(account_id) .await .map_err(|error| error.to_string()), + AUTH_PROVIDER_KIMI_OAUTH => KimiOAuthService::remove_account(account_id) + .await + .map_err(|error| error.to_string()), _ => unreachable!(), } } @@ -162,6 +245,9 @@ impl AuthService { AUTH_PROVIDER_CODEX_OAUTH => CodexOAuthService::set_default_account(account_id) .await .map_err(|error| error.to_string()), + AUTH_PROVIDER_KIMI_OAUTH => KimiOAuthService::set_default_account(account_id) + .await + .map_err(|error| error.to_string()), _ => unreachable!(), } } @@ -172,6 +258,9 @@ impl AuthService { AUTH_PROVIDER_CODEX_OAUTH => CodexOAuthService::clear_auth() .await .map_err(|error| error.to_string()), + AUTH_PROVIDER_KIMI_OAUTH => KimiOAuthService::clear_auth() + .await + .map_err(|error| error.to_string()), _ => unreachable!(), } } @@ -218,9 +307,67 @@ mod tests { assert_eq!(status.provider, "codex_oauth"); assert!(status.authenticated); assert_eq!(status.default_account_id.as_deref(), Some("acc-456")); - assert_eq!(status.accounts.len(), 2); - assert_eq!(status.accounts[0].id, "acc-456"); - assert!(status.accounts[0].is_default); - assert!(!status.accounts[1].is_default); + let default_account = status + .accounts + .iter() + .find(|account| account.id == "acc-456") + .expect("find default account"); + assert!(default_account.is_default); + } + + #[tokio::test] + #[expect( + clippy::await_holding_lock, + reason = "test serializes global auth manager state" + )] + async fn kimi_auth_status_marks_default_account() { + let _lock = lock_test_home_and_settings(); + let temp_kimi = tempfile::tempdir().expect("create tempdir"); + let old_kimi_env = std::env::var_os("KIMI_CODE_HOME"); + std::env::set_var("KIMI_CODE_HOME", temp_kimi.path()); + + let _manager = KimiOAuthService::test_manager_with_account( + "kimi-123", + "rt-1", + Some("User1"), + Some("u1@example.com"), + Some("at-1"), + None, + ) + .await + .expect("seed first account"); + KimiOAuthService::seed_account_for_tests( + "kimi-456", + "rt-2", + Some("User2"), + Some("u2@example.com"), + Some("at-2"), + None, + ) + .await + .expect("seed second account"); + AuthService::set_default_account("kimi_oauth", "kimi-456") + .await + .expect("set default account"); + + let status = AuthService::get_status("kimi_oauth") + .await + .expect("get auth status"); + + assert_eq!(status.provider, "kimi_oauth"); + assert!(status.authenticated); + assert_eq!(status.default_account_id.as_deref(), Some("kimi-456")); + let default_account = status + .accounts + .iter() + .find(|account| account.id == "kimi-456") + .expect("find default account"); + assert!(default_account.is_default); + + if let Some(val) = old_kimi_env { + std::env::set_var("KIMI_CODE_HOME", val); + } else { + std::env::remove_var("KIMI_CODE_HOME"); + } } } diff --git a/src-tauri/src/services/kimi_oauth.rs b/src-tauri/src/services/kimi_oauth.rs new file mode 100644 index 000000000..7877294fc --- /dev/null +++ b/src-tauri/src/services/kimi_oauth.rs @@ -0,0 +1,173 @@ +use std::path::PathBuf; +use std::sync::{Arc, OnceLock, RwLock}; + +use crate::config::get_app_config_dir; +use crate::proxy::providers::kimi_oauth_auth::{ + KimiOAuthError, KimiOAuthManager, KimiOAuthStatus, ManagedAuthAccount, + ManagedAuthDeviceCodeResponse, +}; + +type KimiOAuthManagerStore = RwLock)>>; + +fn manager_store() -> &'static KimiOAuthManagerStore { + static STORE: OnceLock = OnceLock::new(); + STORE.get_or_init(|| RwLock::new(None)) +} + +#[cfg(test)] +fn test_manager_override() -> &'static RwLock>> { + static STORE: OnceLock>>> = OnceLock::new(); + STORE.get_or_init(|| RwLock::new(None)) +} + +#[cfg(test)] +pub(crate) struct TestKimiOAuthManagerGuard { + _temp: tempfile::TempDir, + _manager: Arc, +} + +#[cfg(test)] +impl Drop for TestKimiOAuthManagerGuard { + fn drop(&mut self) { + KimiOAuthService::reset_for_tests(); + } +} + +pub struct KimiOAuthService; + +impl KimiOAuthService { + pub fn manager() -> Arc { + #[cfg(test)] + { + let guard = test_manager_override() + .read() + .expect("read kimi oauth test manager"); + if let Some(manager) = guard.as_ref() { + return Arc::clone(manager); + } + } + + let path = get_app_config_dir(); + { + let guard = manager_store().read().expect("read kimi oauth manager"); + if let Some((cached_path, manager)) = guard.as_ref() { + if cached_path == &path { + return Arc::clone(manager); + } + } + } + + let manager = Arc::new(KimiOAuthManager::new(path.clone())); + let mut guard = manager_store().write().expect("write kimi oauth manager"); + *guard = Some((path, Arc::clone(&manager))); + manager + } + + #[cfg(test)] + pub(crate) fn set_manager_for_tests(manager: Arc) { + let mut guard = test_manager_override() + .write() + .expect("write kimi oauth test manager"); + *guard = Some(manager); + } + + #[cfg(test)] + pub(crate) fn reset_for_tests() { + let mut guard = test_manager_override() + .write() + .expect("reset kimi oauth test manager"); + *guard = None; + let mut store = manager_store() + .write() + .expect("reset kimi oauth manager store"); + *store = None; + } + + #[cfg(test)] + pub(crate) async fn test_manager_with_account( + account_id: &str, + refresh_token: &str, + nickname: Option<&str>, + email: Option<&str>, + access_token: Option<&str>, + expires_at_ms: Option, + ) -> Result { + let temp = tempfile::tempdir().expect("create tempdir"); + let manager = Arc::new(KimiOAuthManager::new(temp.path().to_path_buf())); + manager + .seed_account_for_tests( + account_id, + refresh_token, + nickname, + email, + access_token, + expires_at_ms, + ) + .await?; + Self::set_manager_for_tests(Arc::clone(&manager)); + Ok(TestKimiOAuthManagerGuard { + _temp: temp, + _manager: manager, + }) + } + + #[cfg(test)] + pub(crate) async fn seed_account_for_tests( + account_id: &str, + refresh_token: &str, + nickname: Option<&str>, + email: Option<&str>, + access_token: Option<&str>, + expires_at_ms: Option, + ) -> Result<(), KimiOAuthError> { + let manager = Self::manager(); + manager + .seed_account_for_tests( + account_id, + refresh_token, + nickname, + email, + access_token, + expires_at_ms, + ) + .await + } + + pub async fn start_device_flow() -> Result { + Self::manager().start_device_flow().await + } + + pub async fn poll_for_token( + device_code: &str, + ) -> Result, KimiOAuthError> { + Self::manager().poll_for_token(device_code).await + } + + pub async fn get_status() -> KimiOAuthStatus { + Self::manager().get_status().await + } + + pub async fn remove_account(account_id: &str) -> Result<(), KimiOAuthError> { + Self::manager().remove_account(account_id).await + } + + pub async fn set_default_account(account_id: &str) -> Result<(), KimiOAuthError> { + Self::manager().set_default_account(account_id).await + } + + pub async fn clear_auth() -> Result<(), KimiOAuthError> { + Self::manager().clear_auth().await + } + + #[allow(dead_code)] + pub async fn get_valid_token_for_account( + account_id: &str, + ) -> Result { + Self::manager().get_valid_token_for_account(account_id).await + } + + #[allow(dead_code)] + pub async fn get_valid_token() -> Result { + Self::manager().get_valid_token().await + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index dd7f9f684..5f6ca332c 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -6,6 +6,7 @@ pub mod codex_oauth_models; pub mod coding_plan; pub mod config; pub mod copilot_auth; +pub mod kimi_oauth; #[cfg(feature = "cli")] pub mod env_checker; #[allow(dead_code)] @@ -45,6 +46,7 @@ pub mod webdav_sync; pub use auth::{AuthService, ManagedAuthAccount, ManagedAuthDeviceCodeResponse, ManagedAuthStatus}; pub use codex_oauth::CodexOAuthService; +pub use kimi_oauth::KimiOAuthService; pub use config::ConfigService; pub use copilot_auth::CopilotAuthService; pub use global_proxy::GlobalOutboundProxyConfig; From 77012dd37720a262b0b279ec940d61eef79959e1 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 17 Sep 2026 23:14:53 +0800 Subject: [PATCH 02/13] fix(kimi): make access_token optional during token response parsing for authorization_pending Signed-off-by: Ferris --- .../src/proxy/providers/kimi_oauth_auth.rs | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/proxy/providers/kimi_oauth_auth.rs b/src-tauri/src/proxy/providers/kimi_oauth_auth.rs index 0a10059d2..06619fab7 100644 --- a/src-tauri/src/proxy/providers/kimi_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/kimi_oauth_auth.rs @@ -91,7 +91,9 @@ struct DeviceCodeResponse { #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct KimiTokenResponse { - pub access_token: String, + #[serde(default)] + pub access_token: Option, + #[serde(default)] pub refresh_token: Option, #[serde(default)] pub expires_in: Option, @@ -338,11 +340,12 @@ impl KimiOAuthManager { } } - if token_resp.access_token.is_empty() { - return Err(KimiOAuthError::TokenFetchFailed( - "响应缺少 access_token".to_string(), - )); - } + let access_token = token_resp + .access_token + .filter(|t| !t.trim().is_empty()) + .ok_or_else(|| { + KimiOAuthError::TokenFetchFailed("响应缺少 access_token".to_string()) + })?; let refresh_token = token_resp.refresh_token.ok_or_else(|| { KimiOAuthError::TokenFetchFailed("响应缺少 refresh_token".to_string()) @@ -355,7 +358,7 @@ impl KimiOAuthManager { } // 获取用户资料 - let user_info = Self::fetch_user_info(&token_resp.access_token).await.ok(); + let user_info = Self::fetch_user_info(&access_token).await.ok(); let account_id = user_info .as_ref() .and_then(|u| u.user_id.clone()) @@ -386,7 +389,7 @@ impl KimiOAuthManager { tokens.insert( account.id.clone(), CachedAccessToken { - token: token_resp.access_token.clone(), + token: access_token.clone(), expires_at_ms, }, ); @@ -395,7 +398,7 @@ impl KimiOAuthManager { // 如果是当前默认账号,同步写入 native ~/.kimi-code if self.default_account_id().await.as_deref() == Some(&account.id) { let _ = crate::kimi_config::sync_kimi_account_to_native( - &token_resp.access_token, + &access_token, &refresh_token, expires_in_sec, expires_at_ms / 1000, @@ -461,7 +464,12 @@ impl KimiOAuthManager { let token_resp: KimiTokenResponse = serde_json::from_str(&body_text) .map_err(|e| KimiOAuthError::ParseError(format!("{e}: {body_text}")))?; - if token_resp.access_token.is_empty() { + if token_resp + .access_token + .as_ref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true) + { return Err(KimiOAuthError::TokenFetchFailed( "刷新响应中缺少 access_token".to_string(), )); @@ -514,6 +522,12 @@ impl KimiOAuthManager { }; let token_resp = self.refresh_access_token(&refresh_token).await?; + let access_token = token_resp + .access_token + .filter(|t| !t.trim().is_empty()) + .ok_or_else(|| { + KimiOAuthError::TokenFetchFailed("刷新响应中缺少 access_token".to_string()) + })?; let expires_in_sec = token_resp.expires_in.unwrap_or(3600); let expires_at_ms = chrono::Utc::now().timestamp_millis() + expires_in_sec * 1000; @@ -522,7 +536,7 @@ impl KimiOAuthManager { tokens.insert( account_id.to_string(), CachedAccessToken { - token: token_resp.access_token.clone(), + token: access_token.clone(), expires_at_ms, }, ); @@ -545,7 +559,7 @@ impl KimiOAuthManager { }; if let Some(rt) = rt { let _ = crate::kimi_config::sync_kimi_account_to_native( - &token_resp.access_token, + &access_token, &rt, expires_in_sec, expires_at_ms / 1000, @@ -553,7 +567,7 @@ impl KimiOAuthManager { } } - Ok(token_resp.access_token) + Ok(access_token) } #[allow(dead_code)] From 4f3be59850ed3ccc4d69df9871907c28142ebb73 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 17 Sep 2026 23:30:03 +0800 Subject: [PATCH 03/13] feat(kimi): integrate usages rate limit inspection into config kimi status Signed-off-by: Ferris --- src-tauri/src/cli/commands/config_kimi.rs | 82 +++++++++++++++++++++++ src-tauri/src/kimi_config.rs | 74 ++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/src-tauri/src/cli/commands/config_kimi.rs b/src-tauri/src/cli/commands/config_kimi.rs index 3a07412d9..11ef500b9 100644 --- a/src-tauri/src/cli/commands/config_kimi.rs +++ b/src-tauri/src/cli/commands/config_kimi.rs @@ -72,6 +72,8 @@ struct KimiStatusInfo { has_credentials: bool, token_expires_at: Option, profiles_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + usages: Option, } pub fn execute(cmd: KimiConfigCommand) -> Result<(), AppError> { @@ -115,12 +117,20 @@ fn show_status(json: bool) -> Result<(), AppError> { let creds = read_native_credentials().map_err(|e| AppError::Message(e.to_string()))?; let profiles = list_profiles().map_err(|e| AppError::Message(e.to_string()))?; + let usages = if let Some(token) = creds.as_ref().map(|c| &c.access_token).filter(|t| !t.is_empty()) { + let rt = tokio::runtime::Runtime::new().ok(); + rt.and_then(|r| r.block_on(crate::kimi_config::fetch_kimi_usages(token)).ok()) + } else { + None + }; + let info_obj = KimiStatusInfo { home_dir: home.clone(), active_profile: active_profile.clone(), has_credentials: creds.is_some(), token_expires_at: creds.as_ref().and_then(|c| c.expires_at), profiles_count: profiles.len(), + usages: usages.clone(), }; if json { @@ -145,6 +155,78 @@ fn show_status(json: bool) -> Result<(), AppError> { } println!("Profiles Count: {}", profiles.len()); + if let Some(u) = &usages { + if let Some(q) = &u.usages { + let now = chrono::Utc::now(); + println!(); + println!("Usage & Rate Limits:"); + + let five_hour_detail = u.limits.iter().find(|l| { + l.window.as_ref().and_then(|w| w.duration) == Some(300) + }).and_then(|l| l.detail.as_ref()); + + if let Some(item) = &q.limit_5h { + let ratio = item.used_ratio.unwrap_or(0.0); + let pct = ratio * 100.0; + let status_tag = if ratio >= 1.0 { + " [EXCEEDED / 5小时额度已耗尽]" + } else { + "" + }; + + let mut extras = Vec::new(); + if !status_tag.is_empty() { + extras.push(status_tag.trim().to_string()); + } + if let Some(d) = five_hour_detail { + match (&d.used, &d.limit, &d.remaining) { + (Some(u), Some(l), Some(r)) => extras.push(format!("[{}/{}, remaining: {}]", u, l, r)), + (Some(u), Some(l), None) => extras.push(format!("[{}/{}]", u, l)), + _ => {} + } + } + + let extras_str = if extras.is_empty() { + String::new() + } else { + format!(" {}", extras.join(" ")) + }; + + let reset_str = if let Some(reset_info) = crate::cli::provider_quota::quota_reset_display(item.reset_time.as_deref(), now) { + let local_time = reset_info.at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S"); + let countdown = reset_info.remaining.map(|r| format!("in {r}")).unwrap_or_else(|| "soon".to_string()); + format!("resets {} at {}", countdown, local_time) + } else { + format!("reset: {}", item.reset_time.as_deref().unwrap_or("-")) + }; + + println!(" 5-Hour Limit: {:.1}% used{} ({})", pct, extras_str, reset_str); + } + if let Some(item) = &q.limit_7d { + let pct = item.used_ratio.unwrap_or(0.0) * 100.0; + let reset_str = if let Some(reset_info) = crate::cli::provider_quota::quota_reset_display(item.reset_time.as_deref(), now) { + let local_time = reset_info.at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S"); + let countdown = reset_info.remaining.map(|r| format!("in {r}")).unwrap_or_else(|| "soon".to_string()); + format!("resets {} at {}", countdown, local_time) + } else { + format!("reset: {}", item.reset_time.as_deref().unwrap_or("-")) + }; + println!(" 7-Day Limit: {:.1}% used ({})", pct, reset_str); + } + if let Some(item) = &q.limit_month_total { + let pct = item.used_ratio.unwrap_or(0.0) * 100.0; + let reset_str = if let Some(reset_info) = crate::cli::provider_quota::quota_reset_display(item.reset_time.as_deref(), now) { + let local_time = reset_info.at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S"); + let countdown = reset_info.remaining.map(|r| format!("in {r}")).unwrap_or_else(|| "soon".to_string()); + format!("resets {} at {}", countdown, local_time) + } else { + format!("reset: {}", item.reset_time.as_deref().unwrap_or("-")) + }; + println!(" Monthly Total: {:.1}% used ({})", pct, reset_str); + } + } + } + Ok(()) } diff --git a/src-tauri/src/kimi_config.rs b/src-tauri/src/kimi_config.rs index 6982b99f0..314e1efbb 100644 --- a/src-tauri/src/kimi_config.rs +++ b/src-tauri/src/kimi_config.rs @@ -257,6 +257,80 @@ pub fn switch_profile(name: &str) -> Result<()> { } /// 删除指定的 Profile +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KimiQuotaItem { + #[serde(default)] + pub used_ratio: Option, + #[serde(default)] + pub reset_time: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KimiQuotaUsages { + #[serde(default)] + pub limit_5h: Option, + #[serde(default)] + pub limit_7d: Option, + #[serde(default)] + pub limit_month_total: Option, + #[serde(default)] + pub limit_month_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KimiLimitDetail { + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub used: Option, + #[serde(default)] + pub remaining: Option, + #[serde(default, rename = "resetTime")] + pub reset_time: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KimiLimitWindowInfo { + #[serde(default)] + pub duration: Option, + #[serde(default, rename = "timeUnit")] + pub time_unit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KimiLimitWindow { + #[serde(default)] + pub window: Option, + #[serde(default)] + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KimiUsagesResponse { + #[serde(default)] + pub limits: Vec, + #[serde(default)] + pub usages: Option, +} + +pub async fn fetch_kimi_usages(access_token: &str) -> Result { + let client = crate::proxy::http_client::get(); + let resp = client + .get("https://api.kimi.com/coding/v1/usages") + .header("Authorization", format!("Bearer {access_token}")) + .header("Accept", "application/json") + .send() + .await + .context("请求 Kimi usages 接口失败")?; + + if !resp.status().is_success() { + anyhow::bail!("Kimi usages 接口返回错误: {}", resp.status()); + } + + let usages: KimiUsagesResponse = resp.json().await.context("解析 Kimi usages 响应失败")?; + Ok(usages) +} + pub fn remove_profile(name: &str) -> Result<()> { let name = validate_profile_name(name)?; let profile_dir = get_kimi_profiles_dir().join(name); From 87014a2f3618ef58dd43ed7966b46f82a163d8e5 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 17 Sep 2026 23:33:29 +0800 Subject: [PATCH 04/13] fix(kimi): handle missing used field when remaining count is full in status Signed-off-by: Ferris --- src-tauri/src/cli/commands/config_kimi.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/cli/commands/config_kimi.rs b/src-tauri/src/cli/commands/config_kimi.rs index 11ef500b9..a654df0b8 100644 --- a/src-tauri/src/cli/commands/config_kimi.rs +++ b/src-tauri/src/cli/commands/config_kimi.rs @@ -179,10 +179,11 @@ fn show_status(json: bool) -> Result<(), AppError> { extras.push(status_tag.trim().to_string()); } if let Some(d) = five_hour_detail { - match (&d.used, &d.limit, &d.remaining) { - (Some(u), Some(l), Some(r)) => extras.push(format!("[{}/{}, remaining: {}]", u, l, r)), - (Some(u), Some(l), None) => extras.push(format!("[{}/{}]", u, l)), - _ => {} + let used_count = d.used.as_deref().unwrap_or("0"); + if let (Some(l), Some(r)) = (&d.limit, &d.remaining) { + extras.push(format!("[{}/{}, remaining: {}]", used_count, l, r)); + } else if let Some(l) = &d.limit { + extras.push(format!("[{}/{}]", used_count, l)); } } From ca3a5f3892254bcddf592466d39be3d19d41262a Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 17 Sep 2026 23:56:24 +0800 Subject: [PATCH 05/13] feat(kimi): support profile account nickname and multi-profile live quota inspection - Resolve and display account nicknames in Kimi profile lists and status views - Add '-q/--quota' to 'config kimi profile list' for parallel 5-hour quota and reset countdown inspection across all profiles - Support 'config kimi status ' and 'config kimi status --all' for inspecting profile statuses without switching - Automatically refresh expired access tokens when querying saved profile quotas Signed-off-by: Ferris --- src-tauri/src/cli/commands/config_kimi.rs | 287 +++++++++++++++--- src-tauri/src/kimi_config.rs | 163 ++++++++++ .../src/proxy/providers/kimi_oauth_auth.rs | 26 ++ 3 files changed, 427 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/cli/commands/config_kimi.rs b/src-tauri/src/cli/commands/config_kimi.rs index a654df0b8..91849393c 100644 --- a/src-tauri/src/cli/commands/config_kimi.rs +++ b/src-tauri/src/cli/commands/config_kimi.rs @@ -17,8 +17,13 @@ pub enum KimiConfigCommand { #[arg(long)] json: bool, }, - /// Show current Kimi Code configuration and active account status + /// Show current Kimi Code configuration and account status (active profile, specified profile, or all) Status { + /// Optional profile name to inspect (defaults to currently active profile) + profile: Option, + /// Inspect status of all saved profiles + #[arg(long)] + all: bool, /// Print machine-readable JSON #[arg(long)] json: bool, @@ -32,6 +37,9 @@ pub enum KimiConfigCommand { pub enum KimiProfileCommand { /// List all saved Kimi Code profiles List { + /// Query and show live 5-hour quota and reset countdown for each profile + #[arg(short = 'q', long)] + quota: bool, /// Print machine-readable JSON #[arg(long)] json: bool, @@ -69,17 +77,21 @@ struct KimiPathInfo { struct KimiStatusInfo { home_dir: PathBuf, active_profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + account: Option, has_credentials: bool, token_expires_at: Option, profiles_count: usize, #[serde(skip_serializing_if = "Option::is_none")] usages: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, } pub fn execute(cmd: KimiConfigCommand) -> Result<(), AppError> { match cmd { KimiConfigCommand::Path { json } => show_path(json), - KimiConfigCommand::Status { json } => show_status(json), + KimiConfigCommand::Status { profile, all, json } => show_status(profile, all, json), KimiConfigCommand::Profile(profile_cmd) => execute_profile(profile_cmd), } } @@ -111,51 +123,27 @@ fn show_path(json: bool) -> Result<(), AppError> { Ok(()) } -fn show_status(json: bool) -> Result<(), AppError> { - let home = get_kimi_config_dir(); - let active_profile = get_active_profile_name(); - let creds = read_native_credentials().map_err(|e| AppError::Message(e.to_string()))?; - let profiles = list_profiles().map_err(|e| AppError::Message(e.to_string()))?; - - let usages = if let Some(token) = creds.as_ref().map(|c| &c.access_token).filter(|t| !t.is_empty()) { - let rt = tokio::runtime::Runtime::new().ok(); - rt.and_then(|r| r.block_on(crate::kimi_config::fetch_kimi_usages(token)).ok()) - } else { - None - }; - - let info_obj = KimiStatusInfo { - home_dir: home.clone(), - active_profile: active_profile.clone(), - has_credentials: creds.is_some(), - token_expires_at: creds.as_ref().and_then(|c| c.expires_at), - profiles_count: profiles.len(), - usages: usages.clone(), - }; - - if json { - println!("{}", to_json(&info_obj).map_err(|e| AppError::JsonSerialize { source: e })?); - return Ok(()); +fn print_status_info(info: &KimiStatusInfo) { + println!("Profile: {}", info.active_profile.as_deref().unwrap_or("(default / unmanaged)")); + if let Some(ref acc) = info.account { + println!("Account: {}", acc); } - - println!("Kimi Code Home: {}", home.display()); - println!( - "Active Profile: {}", - active_profile.as_deref().unwrap_or("(default / unmanaged)") - ); + println!("Directory: {}", info.home_dir.display()); println!( "Credentials: {}", - if creds.is_some() { "Present" } else { "None" } + if info.has_credentials { "Present" } else { "None" } ); - if let Some(exp) = creds.as_ref().and_then(|c| c.expires_at) { + if let Some(exp) = info.token_expires_at { let dt = chrono::DateTime::from_timestamp(exp, 0) .map(|d| d.to_rfc3339()) .unwrap_or_else(|| exp.to_string()); println!("Token Expires At: {}", dt); } - println!("Profiles Count: {}", profiles.len()); + if let Some(ref err) = info.error { + println!("Error: {}", err); + } - if let Some(u) = &usages { + if let Some(u) = &info.usages { if let Some(q) = &u.usages { let now = chrono::Utc::now(); println!(); @@ -227,13 +215,210 @@ fn show_status(json: bool) -> Result<(), AppError> { } } } +} + +fn show_status(profile: Option, all: bool, json: bool) -> Result<(), AppError> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| AppError::Message(format!("无法创建 Tokio 运行时: {e}")))?; + + if all { + let items = rt.block_on(crate::kimi_config::fetch_all_profiles_quota()); + if json { + println!("{}", to_json(&items).map_err(|e| AppError::JsonSerialize { source: e })?); + return Ok(()); + } + + if items.is_empty() { + println!("{}", info("No Kimi Code profiles found.")); + return Ok(()); + } + + for (i, item) in items.iter().enumerate() { + if i > 0 { + println!("\n------------------------------------------------------------\n"); + } + let active_tag = if item.profile.is_active { " (active)" } else { "" }; + let info_obj = KimiStatusInfo { + home_dir: item.profile.path.clone(), + active_profile: Some(format!("{}{}", item.profile.name, active_tag)), + account: item.profile.account.clone(), + has_credentials: item.profile.has_credentials, + token_expires_at: None, + profiles_count: items.len(), + usages: item.usages.clone(), + error: item.error.clone(), + }; + print_status_info(&info_obj); + } + return Ok(()); + } + + if let Some(ref name) = profile { + let profiles = list_profiles().map_err(|e| AppError::Message(e.to_string()))?; + let p = profiles.into_iter().find(|x| &x.name == name).ok_or_else(|| { + AppError::Message(format!("Profile '{name}' 不存在")) + })?; + + let mut creds = crate::kimi_config::read_profile_credentials(name) + .map_err(|e| AppError::Message(e.to_string()))?; + + let (usages, error) = if let Some(ref mut c) = creds { + let cred_path = p.path.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE); + match rt.block_on(crate::kimi_config::get_valid_access_token(c, Some(&cred_path))) { + Ok(token) => match rt.block_on(crate::kimi_config::fetch_kimi_usages(&token)) { + Ok(u) => (Some(u), None), + Err(e) => (None, Some(e.to_string())), + }, + Err(e) => (None, Some(e.to_string())), + } + } else { + (None, None) + }; + + let active_tag = if p.is_active { " (active)" } else { "" }; + let info_obj = KimiStatusInfo { + home_dir: p.path.clone(), + active_profile: Some(format!("{}{}", p.name, active_tag)), + account: p.account.clone(), + has_credentials: creds.is_some(), + token_expires_at: creds.as_ref().and_then(|c| c.expires_at), + profiles_count: 1, + usages, + error, + }; + + if json { + println!("{}", to_json(&info_obj).map_err(|e| AppError::JsonSerialize { source: e })?); + return Ok(()); + } + + print_status_info(&info_obj); + return Ok(()); + } + + // Default: currently active ~/.kimi-code + let home = get_kimi_config_dir(); + let active_profile = get_active_profile_name(); + let mut creds = read_native_credentials().map_err(|e| AppError::Message(e.to_string()))?; + let profiles = list_profiles().map_err(|e| AppError::Message(e.to_string()))?; + + let (usages, error) = if let Some(ref mut c) = creds { + let cred_path = home.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE); + match rt.block_on(crate::kimi_config::get_valid_access_token(c, Some(&cred_path))) { + Ok(token) => match rt.block_on(crate::kimi_config::fetch_kimi_usages(&token)) { + Ok(u) => (Some(u), None), + Err(e) => (None, Some(e.to_string())), + }, + Err(e) => (None, Some(e.to_string())), + } + } else { + (None, None) + }; + + let account = creds.as_ref().and_then(|c| crate::kimi_config::resolve_account_nickname(c)); + + let info_obj = KimiStatusInfo { + home_dir: home.clone(), + active_profile: active_profile.clone(), + account, + has_credentials: creds.is_some(), + token_expires_at: creds.as_ref().and_then(|c| c.expires_at), + profiles_count: profiles.len(), + usages, + error, + }; + + if json { + println!("{}", to_json(&info_obj).map_err(|e| AppError::JsonSerialize { source: e })?); + return Ok(()); + } + + print_status_info(&info_obj); + println!("Profiles Count: {}", info_obj.profiles_count); Ok(()) } +fn format_5h_usage_cell(item: &crate::kimi_config::KimiProfileQuotaItem) -> (String, String) { + if !item.profile.has_credentials { + return ("(no credentials)".to_string(), "-".to_string()); + } + if let Some(ref err) = item.error { + return (format!("error: {err}"), "-".to_string()); + } + if let Some(ref u) = item.usages { + if let Some(ref q) = u.usages { + if let Some(ref l5) = q.limit_5h { + let ratio = l5.used_ratio.unwrap_or(0.0); + let pct = ratio * 100.0; + let five_hour_detail = u.limits.iter().find(|l| { + l.window.as_ref().and_then(|w| w.duration) == Some(300) + }).and_then(|l| l.detail.as_ref()); + + let count_str = if let Some(d) = five_hour_detail { + let used_cnt = d.used.as_deref().unwrap_or("0"); + let limit_cnt = d.limit.as_deref().unwrap_or("100"); + let rem_cnt = d.remaining.as_deref().unwrap_or("0"); + format!(" [{used_cnt}/{limit_cnt}, rem: {rem_cnt}]") + } else { + String::new() + }; + + let status_tag = if ratio >= 1.0 { " [EXCEEDED]" } else { "" }; + let usage_str = format!("{pct:.1}%{status_tag}{count_str}"); + + let reset_str = if let Some(reset_info) = crate::cli::provider_quota::quota_reset_display(l5.reset_time.as_deref(), chrono::Utc::now()) { + let local = reset_info.at.with_timezone(&chrono::Local).format("%H:%M:%S"); + let countdown = reset_info.remaining.unwrap_or_else(|| "soon".to_string()); + format!("{countdown} ({local})") + } else { + l5.reset_time.as_deref().unwrap_or("-").to_string() + }; + + return (usage_str, reset_str); + } + } + } + ("-".to_string(), "-".to_string()) +} + fn execute_profile(cmd: KimiProfileCommand) -> Result<(), AppError> { match cmd { - KimiProfileCommand::List { json } => { + KimiProfileCommand::List { quota, json } => { + if quota { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| AppError::Message(format!("无法创建 Tokio 运行时: {e}")))?; + let items = rt.block_on(crate::kimi_config::fetch_all_profiles_quota()); + if json { + println!( + "{}", + to_json(&items).map_err(|e| AppError::JsonSerialize { source: e })? + ); + return Ok(()); + } + + if items.is_empty() { + println!("{}", info("No Kimi Code profiles found. Use `cc-switch config kimi profile save ` to save one.")); + return Ok(()); + } + + let mut table = create_table(); + table.set_header(vec!["Active", "Profile", "Account", "5-Hour Usage", "Reset In", "Path"]); + for item in items { + let (usage_str, reset_str) = format_5h_usage_cell(&item); + table.add_row(vec![ + if item.profile.is_active { "*" } else { " " }, + &item.profile.name, + item.profile.account.as_deref().unwrap_or("-"), + &usage_str, + &reset_str, + &item.profile.path.display().to_string(), + ]); + } + println!("{table}"); + return Ok(()); + } + let profiles = list_profiles().map_err(|e| AppError::Message(e.to_string()))?; if json { println!( @@ -249,11 +434,12 @@ fn execute_profile(cmd: KimiProfileCommand) -> Result<(), AppError> { } let mut table = create_table(); - table.set_header(vec!["Active", "Name", "Has Config", "Has Credentials", "Path"]); + table.set_header(vec!["Active", "Profile", "Account", "Has Config", "Has Credentials", "Path"]); for p in profiles { table.add_row(vec![ if p.is_active { "*" } else { " " }, &p.name, + p.account.as_deref().unwrap_or("-"), if p.has_config { "yes" } else { "no" }, if p.has_credentials { "yes" } else { "no" }, &p.path.display().to_string(), @@ -286,20 +472,23 @@ fn execute_profile(cmd: KimiProfileCommand) -> Result<(), AppError> { Ok(()) } KimiProfileCommand::Remove { name, yes } => { - if !yes && !confirm(&format!("Remove Kimi Code profile '{name}'?"))? { - println!("{}", info("Cancelled.")); + if !yes { + println!( + "{}", + info(&format!( + "Pass --yes to confirm deletion of Kimi Code profile '{}'.", + name + )) + ); return Ok(()); } + remove_profile(&name).map_err(|e| AppError::Message(e.to_string()))?; - println!("{}", success(&format!("Removed Kimi Code profile '{name}'."))); + println!( + "{}", + success(&format!("Profile '{}' has been removed.", name)) + ); Ok(()) } } } - -fn confirm(prompt: &str) -> Result { - inquire::Confirm::new(prompt) - .with_default(false) - .prompt() - .map_err(|err| AppError::Message(format!("failed to confirm action: {err}"))) -} diff --git a/src-tauri/src/kimi_config.rs b/src-tauri/src/kimi_config.rs index 314e1efbb..8e873ae66 100644 --- a/src-tauri/src/kimi_config.rs +++ b/src-tauri/src/kimi_config.rs @@ -35,6 +35,8 @@ pub struct KimiProfileInfo { pub is_active: bool, pub has_credentials: bool, pub has_config: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account: Option, } /// 解析 Kimi Code 根目录路径(遵循 KIMI_CODE_HOME 环境变量,默认 ~/.kimi-code) @@ -108,6 +110,86 @@ pub fn write_native_credentials(credentials: &KimiNativeCredentials) -> Result<( Ok(()) } +/// 读取指定 profile 的 credentials +pub fn read_profile_credentials(profile_name: &str) -> Result> { + let cred_path = get_kimi_profiles_dir() + .join(profile_name) + .join(KIMI_CREDENTIALS_DIR) + .join(KIMI_DEFAULT_CREDENTIAL_FILE); + + if !cred_path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&cred_path) + .with_context(|| format!("读取 Profile 凭据文件失败: {}", cred_path.display()))?; + + let parsed: KimiNativeCredentials = serde_json::from_str(&content) + .with_context(|| format!("解析 Profile 凭据文件失败: {}", cred_path.display()))?; + + Ok(Some(parsed)) +} + +/// 确保获取有效的 access_token(若已过期,自动尝试 refresh 并写回对应文件) +pub async fn get_valid_access_token( + creds: &mut KimiNativeCredentials, + save_path: Option<&Path>, +) -> Result { + let now = chrono::Utc::now().timestamp(); + let expired = creds.expires_at.map(|exp| exp <= now + 30).unwrap_or(false); + + if !expired && !creds.access_token.is_empty() { + return Ok(creds.access_token.clone()); + } + + if creds.refresh_token.is_empty() { + if !creds.access_token.is_empty() { + return Ok(creds.access_token.clone()); + } + anyhow::bail!("缺少 refresh_token,无法刷新"); + } + + let refreshed = crate::proxy::providers::kimi_oauth_auth::KimiOAuthManager::refresh_token_raw(&creds.refresh_token) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + if let Some(new_at) = refreshed.access_token { + creds.access_token = new_at; + if let Some(exp_in) = refreshed.expires_in { + creds.expires_in = Some(exp_in); + creds.expires_at = Some(now + exp_in); + } + if let Some(new_rt) = refreshed.refresh_token { + creds.refresh_token = new_rt; + } + + if let Some(path) = save_path { + let content = serde_json::to_string_pretty(creds)?; + let _ = write_file_atomic(path, &content, 0o600); + } + } + + Ok(creds.access_token.clone()) +} + +/// 解析 JWT 中的 user_id +pub fn extract_user_id_from_jwt(token: &str) -> Option { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return None; + } + let payload_b64 = parts[1].trim_end_matches('='); + let decoded = URL_SAFE_NO_PAD.decode(payload_b64).ok()?; + let val: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + val.get("user_id") + .or_else(|| val.get("sub")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + /// 同步账号认证信息至 native ~/.kimi-code pub fn sync_kimi_account_to_native( access_token: &str, @@ -140,6 +222,15 @@ pub fn clear_native_credentials() -> Result<()> { Ok(()) } +/// 解析凭据对应的账号昵称 +pub fn resolve_account_nickname(cred: &KimiNativeCredentials) -> Option { + let manager = crate::services::kimi_oauth::KimiOAuthService::manager(); + let user_id = extract_user_id_from_jwt(&cred.access_token); + manager + .find_account_sync(&cred.refresh_token, user_id.as_deref()) + .map(|a| a.login) +} + /// 列出所有已保存的 Kimi 配置 Profiles pub fn list_profiles() -> Result> { let profiles_dir = get_kimi_profiles_dir(); @@ -160,12 +251,19 @@ pub fn list_profiles() -> Result> { let has_config = path.join(KIMI_CONFIG_FILE).exists(); let is_active = active_name.as_deref() == Some(&name); + let account = if has_credentials { + read_profile_credentials(&name).ok().flatten().and_then(|c| resolve_account_nickname(&c)) + } else { + None + }; + profiles.push(KimiProfileInfo { name, path, is_active, has_credentials, has_config, + account, }); } } @@ -174,6 +272,71 @@ pub fn list_profiles() -> Result> { Ok(profiles) } +#[derive(Debug, Clone, Serialize)] +pub struct KimiProfileQuotaItem { + pub profile: KimiProfileInfo, + #[serde(skip_serializing_if = "Option::is_none")] + pub usages: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// 批量查询所有 Profiles 的实时 Quota(并发查询) +pub async fn fetch_all_profiles_quota() -> Vec { + let profiles = match list_profiles() { + Ok(p) => p, + Err(_) => return vec![], + }; + + let mut tasks = Vec::new(); + for p in profiles { + tasks.push(async move { + let mut creds = match read_profile_credentials(&p.name) { + Ok(Some(c)) => c, + Ok(None) => { + return KimiProfileQuotaItem { + profile: p, + usages: None, + error: None, + }; + } + Err(e) => { + return KimiProfileQuotaItem { + profile: p, + usages: None, + error: Some(e.to_string()), + }; + } + }; + + let cred_path = p.path.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE); + match get_valid_access_token(&mut creds, Some(&cred_path)).await { + Ok(token) => { + match fetch_kimi_usages(&token).await { + Ok(u) => KimiProfileQuotaItem { + profile: p, + usages: Some(u), + error: None, + }, + Err(e) => KimiProfileQuotaItem { + profile: p, + usages: None, + error: Some(e.to_string()), + }, + } + } + Err(e) => KimiProfileQuotaItem { + profile: p, + usages: None, + error: Some(e.to_string()), + }, + } + }); + } + + futures::future::join_all(tasks).await +} + /// 保存当前活动的 ~/.kimi-code 配置到指定名称的 Profile pub fn save_profile(name: &str) -> Result { let name = validate_profile_name(name)?; diff --git a/src-tauri/src/proxy/providers/kimi_oauth_auth.rs b/src-tauri/src/proxy/providers/kimi_oauth_auth.rs index 06619fab7..efce0db92 100644 --- a/src-tauri/src/proxy/providers/kimi_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/kimi_oauth_auth.rs @@ -434,6 +434,10 @@ impl KimiOAuthManager { } async fn refresh_access_token(&self, refresh_token: &str) -> Result { + Self::refresh_token_raw(refresh_token).await + } + + pub async fn refresh_token_raw(refresh_token: &str) -> Result { let params = [ ("client_id", KIMI_CLIENT_ID), ("refresh_token", refresh_token), @@ -659,6 +663,28 @@ impl KimiOAuthManager { list } + pub fn find_account_sync( + &self, + refresh_token: &str, + account_id: Option<&str>, + ) -> Option { + if let Ok(accounts) = self.accounts.try_read() { + if !refresh_token.is_empty() { + for acc in accounts.values() { + if acc.refresh_token == refresh_token { + return Some(ManagedAuthAccount::from(acc)); + } + } + } + if let Some(id) = account_id { + if let Some(acc) = accounts.get(id) { + return Some(ManagedAuthAccount::from(acc)); + } + } + } + None + } + pub async fn get_status(&self) -> KimiOAuthStatus { let accounts = self.list_accounts().await; let default_id = self.default_account_id().await; From a4e66e4bb8217d72a930c48509184ed2c145ab4d Mon Sep 17 00:00:00 2001 From: artin Date: Fri, 18 Sep 2026 00:04:14 +0800 Subject: [PATCH 06/13] fix(kimi): isolate unit tests from real ~/.kimi-code directory - Prevent test runners from overwriting user's active native credentials - Guard default account sync during test runs - Provide safe isolated fallback path for get_kimi_config_dir during testing Signed-off-by: Ferris --- src-tauri/src/kimi_config.rs | 19 ++++++++++++++++--- .../src/proxy/providers/kimi_oauth_auth.rs | 3 +++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/kimi_config.rs b/src-tauri/src/kimi_config.rs index 8e873ae66..bd8545116 100644 --- a/src-tauri/src/kimi_config.rs +++ b/src-tauri/src/kimi_config.rs @@ -46,9 +46,17 @@ pub fn get_kimi_config_dir() -> PathBuf { return PathBuf::from(env_val); } } - dirs::home_dir() - .map(|p| p.join(DEFAULT_KIMI_CONFIG_DIR)) - .unwrap_or_else(|| PathBuf::from(DEFAULT_KIMI_CONFIG_DIR)) + #[cfg(test)] + { + // 单元测试未显式设置 KIMI_CODE_HOME 时,绝不能回退到宿主真实目录,防止测试副作用篡改真实凭据 + std::env::temp_dir().join("cc-switch-kimi-test-isolated") + } + #[cfg(not(test))] + { + dirs::home_dir() + .map(|p| p.join(DEFAULT_KIMI_CONFIG_DIR)) + .unwrap_or_else(|| PathBuf::from(DEFAULT_KIMI_CONFIG_DIR)) + } } /// 获取 cc-switch 管理的 Kimi 配置 profiles 存储目录 @@ -197,6 +205,11 @@ pub fn sync_kimi_account_to_native( expires_in: i64, expires_at_sec: i64, ) -> Result<()> { + #[cfg(test)] + if std::env::var_os(KIMI_HOME_ENV).is_none() { + return Ok(()); + } + let creds = KimiNativeCredentials { access_token: access_token.to_string(), refresh_token: refresh_token.to_string(), diff --git a/src-tauri/src/proxy/providers/kimi_oauth_auth.rs b/src-tauri/src/proxy/providers/kimi_oauth_auth.rs index efce0db92..84b056a55 100644 --- a/src-tauri/src/proxy/providers/kimi_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/kimi_oauth_auth.rs @@ -396,6 +396,7 @@ impl KimiOAuthManager { } // 如果是当前默认账号,同步写入 native ~/.kimi-code + #[cfg(not(test))] if self.default_account_id().await.as_deref() == Some(&account.id) { let _ = crate::kimi_config::sync_kimi_account_to_native( &access_token, @@ -556,6 +557,7 @@ impl KimiOAuthManager { } // 如果是当前默认账号,同步写入 native ~/.kimi-code + #[cfg(not(test))] if self.default_account_id().await.as_deref() == Some(account_id) { let rt = { let accounts = self.accounts.read().await; @@ -616,6 +618,7 @@ impl KimiOAuthManager { self.save_to_disk().await?; // 切换默认账号时,自动同步激活至 native ~/.kimi-code + #[cfg(not(test))] if let Ok(token) = self.get_valid_token_for_account(account_id).await { let accounts = self.accounts.read().await; if let Some(acc) = accounts.get(account_id) { From 34bb16f920834f3c1e8a03ed4637a10b33b617bb Mon Sep 17 00:00:00 2001 From: artin Date: Fri, 18 Sep 2026 00:05:12 +0800 Subject: [PATCH 07/13] feat(kimi): display '(unauthenticated)' for profiles without refresh tokens in quota list Signed-off-by: Ferris --- src-tauri/src/cli/commands/config_kimi.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/cli/commands/config_kimi.rs b/src-tauri/src/cli/commands/config_kimi.rs index 91849393c..7a01ed485 100644 --- a/src-tauri/src/cli/commands/config_kimi.rs +++ b/src-tauri/src/cli/commands/config_kimi.rs @@ -344,6 +344,9 @@ fn format_5h_usage_cell(item: &crate::kimi_config::KimiProfileQuotaItem) -> (Str return ("(no credentials)".to_string(), "-".to_string()); } if let Some(ref err) = item.error { + if err.contains("缺少 refresh_token") || err.contains("未登录") { + return ("(unauthenticated)".to_string(), "-".to_string()); + } return (format!("error: {err}"), "-".to_string()); } if let Some(ref u) = item.usages { From 0ae0465b5c331b637e244b7b89162630adfb10ae Mon Sep 17 00:00:00 2001 From: artin Date: Fri, 18 Sep 2026 04:58:44 +0800 Subject: [PATCH 08/13] feat(kimi): show 7-day usage limit in profile list quota view Signed-off-by: Ferris --- src-tauri/src/cli/commands/config_kimi.rs | 44 ++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/cli/commands/config_kimi.rs b/src-tauri/src/cli/commands/config_kimi.rs index 7a01ed485..7502f5957 100644 --- a/src-tauri/src/cli/commands/config_kimi.rs +++ b/src-tauri/src/cli/commands/config_kimi.rs @@ -385,6 +385,39 @@ fn format_5h_usage_cell(item: &crate::kimi_config::KimiProfileQuotaItem) -> (Str ("-".to_string(), "-".to_string()) } +fn format_7d_usage_cell(item: &crate::kimi_config::KimiProfileQuotaItem) -> (String, String) { + if !item.profile.has_credentials { + return ("(no credentials)".to_string(), "-".to_string()); + } + if let Some(ref err) = item.error { + if err.contains("缺少 refresh_token") || err.contains("未登录") { + return ("(unauthenticated)".to_string(), "-".to_string()); + } + return (format!("error: {err}"), "-".to_string()); + } + if let Some(ref u) = item.usages { + if let Some(ref q) = u.usages { + if let Some(ref l7) = q.limit_7d { + let ratio = l7.used_ratio.unwrap_or(0.0); + let pct = ratio * 100.0; + let status_tag = if ratio >= 1.0 { " [EXCEEDED]" } else { "" }; + let usage_str = format!("{pct:.1}%{status_tag}"); + + let reset_str = if let Some(reset_info) = crate::cli::provider_quota::quota_reset_display(l7.reset_time.as_deref(), chrono::Utc::now()) { + let local = reset_info.at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M"); + let countdown = reset_info.remaining.unwrap_or_else(|| "soon".to_string()); + format!("{countdown} ({local})") + } else { + l7.reset_time.as_deref().unwrap_or("-").to_string() + }; + + return (usage_str, reset_str); + } + } + } + ("-".to_string(), "-".to_string()) +} + fn execute_profile(cmd: KimiProfileCommand) -> Result<(), AppError> { match cmd { KimiProfileCommand::List { quota, json } => { @@ -406,15 +439,18 @@ fn execute_profile(cmd: KimiProfileCommand) -> Result<(), AppError> { } let mut table = create_table(); - table.set_header(vec!["Active", "Profile", "Account", "5-Hour Usage", "Reset In", "Path"]); + table.set_header(vec!["Active", "Profile", "Account", "5-Hour Usage", "Reset In", "7-Day Usage", "7D Reset", "Path"]); for item in items { - let (usage_str, reset_str) = format_5h_usage_cell(&item); + let (usage_5h_str, reset_5h_str) = format_5h_usage_cell(&item); + let (usage_7d_str, reset_7d_str) = format_7d_usage_cell(&item); table.add_row(vec![ if item.profile.is_active { "*" } else { " " }, &item.profile.name, item.profile.account.as_deref().unwrap_or("-"), - &usage_str, - &reset_str, + &usage_5h_str, + &reset_5h_str, + &usage_7d_str, + &reset_7d_str, &item.profile.path.display().to_string(), ]); } From 58e3f099295a9a8b26be3c10be1dc8c0e296e735 Mon Sep 17 00:00:00 2001 From: Ferris Date: Fri, 18 Sep 2026 17:11:55 +0800 Subject: [PATCH 09/13] feat(kimi): elevate Kimi Code to a first-class citizen application - Add AppType::Kimi alongside Claude, Codex, Gemini, OpenCode, Hermes, OpenClaw, Pi - Implement TOML provider, model, and MCP editing for ~/.kimi-code/config.toml - Support additive provider management and model aliases for Kimi Code - Wire AppType::Kimi across TUI, CLI commands, settings, and services - Add comprehensive unit and integration tests for Kimi provider configuration Signed-off-by: Ferris --- src-tauri/src/app_config.rs | 49 +- src-tauri/src/cli/commands/config_common.rs | 3 +- src-tauri/src/cli/commands/env.rs | 2 +- src-tauri/src/cli/commands/failover.rs | 2 +- src-tauri/src/cli/commands/provider.rs | 10 +- src-tauri/src/cli/commands/provider_input.rs | 89 ++- .../src/cli/commands/provider_inspect.rs | 22 +- .../src/cli/commands/provider_usage_query.rs | 14 + src-tauri/src/cli/commands/settings.rs | 3 + src-tauri/src/cli/failover_policy.rs | 2 +- src-tauri/src/cli/tui/app/helpers.rs | 1 + src-tauri/src/cli/tui/app/tests.rs | 13 + src-tauri/src/cli/tui/data.rs | 13 + src-tauri/src/cli/tui/form/provider_json.rs | 15 +- src-tauri/src/cli/tui/form/provider_state.rs | 22 +- .../cli/tui/form/provider_state_loading.rs | 25 + .../src/cli/tui/form/provider_templates.rs | 5 +- .../src/cli/tui/runtime_actions/helpers.rs | 1 + src-tauri/src/cli/tui/runtime_actions/mod.rs | 12 + src-tauri/src/cli/tui/tests.rs | 1 + src-tauri/src/cli/tui/theme.rs | 2 + src-tauri/src/cli/tui/ui/forms/provider.rs | 2 +- src-tauri/src/cli/tui/ui/header_tests.rs | 2 + src-tauri/src/cli/tui/ui/tests.rs | 19 + src-tauri/src/cli/ui/colors.rs | 2 + src-tauri/src/database/dao/mcp.rs | 1 + src-tauri/src/database/dao/skills.rs | 2 + src-tauri/src/deeplink/mcp.rs | 2 + src-tauri/src/deeplink/provider.rs | 26 + src-tauri/src/kimi_config.rs | 601 ++++++++++++++++++ src-tauri/src/mcp.rs | 5 + src-tauri/src/prompt_files.rs | 2 + src-tauri/src/provider.rs | 8 +- src-tauri/src/provider_preset_sponsors.rs | 2 +- src-tauri/src/proxy/providers/mod.rs | 3 +- src-tauri/src/services/config.rs | 2 + src-tauri/src/services/local_env_check.rs | 8 +- src-tauri/src/services/mcp.rs | 5 + .../src/services/provider/common_config.rs | 13 +- src-tauri/src/services/provider/live.rs | 63 ++ src-tauri/src/services/provider/mod.rs | 124 +++- src-tauri/src/services/provider/tests.rs | 1 + src-tauri/src/services/provider/usage.rs | 22 + src-tauri/src/services/proxy.rs | 5 +- src-tauri/src/services/skill.rs | 2 + .../services/stream_check/provider_extract.rs | 20 + src-tauri/src/services/visible_apps.rs | 7 +- src-tauri/src/settings.rs | 35 +- src-tauri/src/store.rs | 3 + src-tauri/src/sync_policy.rs | 5 + src-tauri/tests/import_export_sync.rs | 2 + src-tauri/tests/mcp_commands.rs | 6 + src-tauri/tests/openclaw_config.rs | 4 + src-tauri/tests/provider_commands.rs | 1 + src-tauri/tests/provider_service.rs | 3 + src-tauri/tests/settings_current_provider.rs | 2 + src-tauri/tests/settings_visible_apps.rs | 8 + 57 files changed, 1275 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index b5eeb47ad..0fa971ac8 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -17,6 +17,8 @@ pub struct McpApps { pub opencode: bool, #[serde(default)] pub hermes: bool, + #[serde(default)] + pub kimi: bool, } impl McpApps { @@ -30,6 +32,7 @@ impl McpApps { AppType::Hermes => self.hermes, AppType::OpenClaw => false, AppType::Pi => false, + AppType::Kimi => self.kimi, } } @@ -43,6 +46,7 @@ impl McpApps { AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} AppType::Pi => {} + AppType::Kimi => self.kimi = enabled, } } @@ -64,12 +68,15 @@ impl McpApps { if self.hermes { apps.push(AppType::Hermes); } + if self.kimi { + apps.push(AppType::Kimi); + } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes && !self.kimi } } @@ -88,6 +95,8 @@ pub struct SkillApps { pub hermes: bool, #[serde(default)] pub pi: bool, + #[serde(default)] + pub kimi: bool, } impl SkillApps { @@ -100,6 +109,7 @@ impl SkillApps { AppType::Hermes => self.hermes, AppType::OpenClaw => false, AppType::Pi => self.pi, + AppType::Kimi => self.kimi, } } @@ -112,11 +122,12 @@ impl SkillApps { AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} AppType::Pi => self.pi = enabled, + AppType::Kimi => self.kimi = enabled, } } pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes && !self.pi + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes && !self.pi && !self.kimi } pub fn only(app: &AppType) -> Self { @@ -142,6 +153,7 @@ impl SkillApps { self.opencode |= other.opencode; self.hermes |= other.hermes; self.pi |= other.pi; + self.kimi |= other.kimi; } } @@ -252,6 +264,8 @@ pub struct McpRoot { pub hermes: McpConfig, #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub openclaw: McpConfig, + #[serde(default, skip_serializing_if = "McpConfig::is_empty")] + pub kimi: McpConfig, #[serde(skip)] pub pi: McpConfig, } @@ -268,6 +282,7 @@ impl Default for McpRoot { opencode: McpConfig::default(), hermes: McpConfig::default(), openclaw: McpConfig::default(), + kimi: McpConfig::default(), pi: McpConfig::default(), } } @@ -297,6 +312,8 @@ pub struct PromptRoot { pub openclaw: PromptConfig, #[serde(default)] pub pi: PromptConfig, + #[serde(default)] + pub kimi: PromptConfig, } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; @@ -315,6 +332,7 @@ pub enum AppType { Hermes, OpenClaw, Pi, + Kimi, } impl AppType { @@ -327,13 +345,14 @@ impl AppType { AppType::Hermes => "hermes", AppType::OpenClaw => "openclaw", AppType::Pi => "pi", + AppType::Kimi => "kimi", } } pub fn is_additive_mode(&self) -> bool { matches!( self, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi ) } @@ -350,6 +369,7 @@ impl AppType { AppType::Hermes, AppType::OpenClaw, AppType::Pi, + AppType::Kimi, ] .into_iter() } @@ -374,13 +394,14 @@ impl FromStr for AppType { "hermes" => Ok(AppType::Hermes), "openclaw" => Ok(AppType::OpenClaw), "pi" => Ok(AppType::Pi), + "kimi" => Ok(AppType::Kimi), other => Err(AppError::localized( "unsupported_app", format!( - "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw, pi。" + "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw, pi, kimi。" ), format!( - "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw, pi." + "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw, pi, kimi." ), )), } @@ -407,6 +428,9 @@ pub struct CommonConfigSnippets { #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kimi: Option, } impl CommonConfigSnippets { @@ -420,6 +444,7 @@ impl CommonConfigSnippets { AppType::Hermes => self.hermes.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), AppType::Pi => None, + AppType::Kimi => self.kimi.as_ref(), } } @@ -433,6 +458,7 @@ impl CommonConfigSnippets { AppType::Hermes => self.hermes = snippet, AppType::OpenClaw => self.openclaw = snippet, AppType::Pi => {} + AppType::Kimi => self.kimi = snippet, } } } @@ -476,6 +502,7 @@ impl Default for MultiAppConfig { apps.insert("hermes".to_string(), ProviderManager::default()); apps.insert("openclaw".to_string(), ProviderManager::default()); apps.insert("pi".to_string(), ProviderManager::default()); + apps.insert("kimi".to_string(), ProviderManager::default()); Self { version: 2, @@ -590,6 +617,13 @@ impl MultiAppConfig { updated = true; } + if !config.apps.contains_key("kimi") { + config + .apps + .insert("kimi".to_string(), ProviderManager::default()); + updated = true; + } + // 执行 MCP 迁移(v3.6.x → v3.7.0) let migrated = config.migrate_mcp_to_unified()?; if migrated { @@ -657,6 +691,7 @@ impl MultiAppConfig { AppType::Hermes => &self.mcp.hermes, AppType::OpenClaw => &self.mcp.openclaw, AppType::Pi => &self.mcp.pi, + AppType::Kimi => &self.mcp.kimi, } } @@ -670,6 +705,7 @@ impl MultiAppConfig { AppType::Hermes => &mut self.mcp.hermes, AppType::OpenClaw => &mut self.mcp.openclaw, AppType::Pi => &mut self.mcp.pi, + AppType::Kimi => &mut self.mcp.kimi, } } @@ -699,6 +735,7 @@ impl MultiAppConfig { AppType::Gemini, AppType::OpenCode, AppType::Hermes, + AppType::Kimi, ] { let old_servers = match app { AppType::Claude => &self.mcp.claude.servers, @@ -706,6 +743,7 @@ impl MultiAppConfig { AppType::Gemini => &self.mcp.gemini.servers, AppType::OpenCode => &self.mcp.opencode.servers, AppType::Hermes => &self.mcp.hermes.servers, + AppType::Kimi => &self.mcp.kimi.servers, AppType::OpenClaw => continue, AppType::Pi => continue, }; @@ -812,6 +850,7 @@ impl MultiAppConfig { self.mcp.gemini = McpConfig::default(); self.mcp.opencode = McpConfig::default(); self.mcp.hermes = McpConfig::default(); + self.mcp.kimi = McpConfig::default(); Ok(true) } diff --git a/src-tauri/src/cli/commands/config_common.rs b/src-tauri/src/cli/commands/config_common.rs index dc0720ac3..0144e647f 100644 --- a/src-tauri/src/cli/commands/config_common.rs +++ b/src-tauri/src/cli/commands/config_common.rs @@ -194,7 +194,8 @@ fn canonical_common_snippet(app_type: AppType, raw: &str) -> Result { + | AppType::Pi + | AppType::Kimi => { let value: serde_json::Value = serde_json::from_str(raw).map_err(|e| { AppError::InvalidInput(texts::tui_toast_invalid_json(&e.to_string())) })?; diff --git a/src-tauri/src/cli/commands/env.rs b/src-tauri/src/cli/commands/env.rs index e056d5293..fee74b772 100644 --- a/src-tauri/src/cli/commands/env.rs +++ b/src-tauri/src/cli/commands/env.rs @@ -96,7 +96,7 @@ fn run_app_doctor(app_type: &AppType) -> Result<(), AppError> { AppType::Claude => check_claude_doctor(), AppType::Codex => check_codex_doctor(), AppType::Gemini => check_gemini_doctor(), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { println!( "{}", info(&format!( diff --git a/src-tauri/src/cli/commands/failover.rs b/src-tauri/src/cli/commands/failover.rs index efc9d246f..4f85ec870 100644 --- a/src-tauri/src/cli/commands/failover.rs +++ b/src-tauri/src/cli/commands/failover.rs @@ -409,7 +409,7 @@ fn takeover_enabled_for(takeovers: &ProxyTakeoverStatus, app_type: &AppType) -> AppType::Claude => takeovers.claude, AppType::Codex => takeovers.codex, AppType::Gemini => takeovers.gemini, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } diff --git a/src-tauri/src/cli/commands/provider.rs b/src-tauri/src/cli/commands/provider.rs index 201a48065..915cc9d72 100644 --- a/src-tauri/src/cli/commands/provider.rs +++ b/src-tauri/src/cli/commands/provider.rs @@ -498,7 +498,7 @@ fn prompt_and_apply_provider_api_format( match app_type { AppType::Claude => prompt_and_apply_claude_api_format(app_type, provider), AppType::Codex => prompt_and_apply_codex_api_format(app_type, provider), - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { Ok(()) } } @@ -1441,7 +1441,7 @@ fn build_add_settings_config( } Ok(settings) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Kimi => { let current = current.ok_or_else(|| add_additive_requires_config_error(app_type))?; let api_key = non_empty(args.api_key.clone()); let base_url = non_empty(args.base_url.clone()); @@ -1528,7 +1528,7 @@ fn apply_add_provider_api_format( }; apply_codex_api_format(provider, format); } - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { } } Ok(()) @@ -1854,6 +1854,10 @@ fn existing_provider_ids_for_duplicate( .into_iter() .map(|(id, _)| id) .collect::>(), + AppType::Kimi => crate::kimi_config::get_providers()? + .into_iter() + .map(|(id, _)| id) + .collect::>(), _ => Vec::new(), }; ids.extend(live_ids); diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index a61da2022..60111d9e1 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -144,7 +144,7 @@ pub fn common_snippet_has_effective_config( .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|obj| !obj.is_empty()), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } @@ -209,7 +209,7 @@ pub fn provider_add_template_choices(app_type: &AppType) -> Vec { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { vec![ProviderAddTemplateChoice { template: ProviderAddTemplate::Custom, label: "Custom", @@ -657,7 +657,7 @@ fn build_sponsor_template_settings_config( }) } } - AppType::Pi => Err(unsupported_template_error(ProviderAddTemplate::Custom)), + AppType::Pi | AppType::Kimi => Err(unsupported_template_error(ProviderAddTemplate::Custom)), } } @@ -729,6 +729,22 @@ pub fn apply_additive_template_field_overrides( } Ok(updated) } + AppType::Kimi => { + let mut updated = current.clone(); + let object = updated.as_object_mut().ok_or_else(|| { + AppError::InvalidInput("Kimi provider configuration must be an object".to_string()) + })?; + if let Some(api_key) = api_key { + object.insert("api_key".to_string(), Value::String(api_key.to_string())); + } + if let Some(base_url) = base_url { + object.insert("base_url".to_string(), Value::String(base_url.to_string())); + } + if let Some(model) = model { + object.insert("model".to_string(), Value::String(model.to_string())); + } + Ok(updated) + } AppType::Claude | AppType::Codex | AppType::Gemini => Err(AppError::InvalidInput(format!( "{} does not use additive provider settings", app_type.as_str() @@ -3876,6 +3892,52 @@ fn validate_pi_prompt_request_url(current: Option<&Value>, edited: &Value) -> Re Ok(()) } +fn prompt_kimi_config(current: Option<&Value>) -> Result { + println!("\n{}", "Kimi Code".bright_cyan().bold()); + + let default_api_key = current + .and_then(|v| v.get("api_key").or_else(|| v.get("apiKey"))) + .and_then(Value::as_str) + .unwrap_or_default(); + let default_base_url = current + .and_then(|v| v.get("base_url").or_else(|| v.get("baseUrl"))) + .and_then(Value::as_str) + .unwrap_or("https://api.moonshot.cn/v1"); + let default_model = current + .and_then(|v| v.get("model")) + .and_then(Value::as_str) + .unwrap_or("kimi-k2.5"); + + let api_key = Text::new(texts::api_key_label()) + .with_initial_value(default_api_key) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; + + let base_url = Text::new(texts::base_url_label()) + .with_initial_value(default_base_url) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; + + let model = Text::new(texts::model_label()) + .with_initial_value(default_model) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; + + let mut map = serde_json::Map::new(); + map.insert( + "api_key".to_string(), + Value::String(api_key.trim().to_string()), + ); + map.insert( + "base_url".to_string(), + Value::String(base_url.trim().to_string()), + ); + if !model.trim().is_empty() { + map.insert("model".to_string(), Value::String(model.trim().to_string())); + } + Ok(Value::Object(map)) +} + /// 根据应用类型收集 settings_config pub fn prompt_settings_config( app_type: &AppType, @@ -3917,6 +3979,7 @@ pub fn prompt_settings_config( AppType::OpenCode => prompt_opencode_config(current).map(SettingsConfigPromptResult::new), AppType::Hermes => prompt_hermes_config(current).map(SettingsConfigPromptResult::new), AppType::OpenClaw => prompt_openclaw_config(current).map(SettingsConfigPromptResult::new), + AppType::Kimi => prompt_kimi_config(current).map(SettingsConfigPromptResult::new), AppType::Pi => { let mut config = prompt_pi_config(current)?; if current.is_none() { @@ -4681,6 +4744,26 @@ pub fn display_provider_summary(provider: &Provider, app_type: &AppType) { println!(" {}: {}", texts::model_label(), models.len()); } } + AppType::Kimi => { + if let Some(api_key) = provider.configured_api_key(app_type) { + println!(" {}: {}", texts::api_key_display_label(), api_key); + } + if let Some(base_url) = provider + .settings_config + .get("base_url") + .or_else(|| provider.settings_config.get("baseUrl")) + .and_then(|v| v.as_str()) + { + println!(" {}: {}", texts::base_url_display_label(), base_url); + } + if let Some(model) = provider + .settings_config + .get("model") + .and_then(|v| v.as_str()) + { + println!(" {}: {}", texts::model_label(), model); + } + } } // 可选字段 diff --git a/src-tauri/src/cli/commands/provider_inspect.rs b/src-tauri/src/cli/commands/provider_inspect.rs index 758ef6f2d..a96610470 100644 --- a/src-tauri/src/cli/commands/provider_inspect.rs +++ b/src-tauri/src/cli/commands/provider_inspect.rs @@ -925,6 +925,26 @@ fn model_fetch_target( request_headers: None, strategy: ProviderModelFetchStrategy::Bearer, }), + AppType::Kimi => Ok(ModelFetchTarget { + base_url, + is_full_url, + auth_value: Some( + provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + AppError::Message(format!("Missing API key for provider '{}'", provider.id)) + })?, + ), + custom_user_agent, + request_headers: None, + strategy: ProviderModelFetchStrategy::Bearer, + }), AppType::OpenClaw => Ok(ModelFetchTarget { base_url, is_full_url, @@ -1028,7 +1048,7 @@ fn default_one_off_model_fetch_strategy(app_type: &AppType) -> ProviderModelFetc match app_type { AppType::Claude => ProviderModelFetchStrategy::Anthropic, AppType::Gemini => ProviderModelFetchStrategy::GoogleApiKey, - AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { ProviderModelFetchStrategy::Bearer } } diff --git a/src-tauri/src/cli/commands/provider_usage_query.rs b/src-tauri/src/cli/commands/provider_usage_query.rs index 95439650e..ab3130621 100644 --- a/src-tauri/src/cli/commands/provider_usage_query.rs +++ b/src-tauri/src/cli/commands/provider_usage_query.rs @@ -735,6 +735,20 @@ fn provider_comment_credentials<'a>( .or_else(|| settings.get("auth_token")) .and_then(|value| value.as_str()), ), + AppType::Kimi => ( + settings + .get("base_url") + .or_else(|| settings.get("baseUrl")) + .or_else(|| settings.get("baseURL")) + .or_else(|| settings.get("endpoint")) + .and_then(|value| value.as_str()) + .map(str::to_string), + settings + .get("api_key") + .or_else(|| settings.get("apiKey")) + .or_else(|| settings.get("auth_token")) + .and_then(|value| value.as_str()), + ), AppType::OpenClaw => ( settings .get("baseUrl") diff --git a/src-tauri/src/cli/commands/settings.rs b/src-tauri/src/cli/commands/settings.rs index dfacaede4..c93a51272 100644 --- a/src-tauri/src/cli/commands/settings.rs +++ b/src-tauri/src/cli/commands/settings.rs @@ -513,6 +513,7 @@ fn set_visible_apps_list(apps: Vec) -> Result<(), AppError> { hermes: false, openclaw: false, pi: false, + kimi: false, }; for app in apps { visible_apps.set_enabled_for(&app, true); @@ -889,6 +890,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect("save manual visible apps"); @@ -913,6 +915,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect_err("empty visible apps should be rejected"); diff --git a/src-tauri/src/cli/failover_policy.rs b/src-tauri/src/cli/failover_policy.rs index 1b7edf2c5..ed85a8381 100644 --- a/src-tauri/src/cli/failover_policy.rs +++ b/src-tauri/src/cli/failover_policy.rs @@ -85,6 +85,6 @@ fn takeover_enabled_for(takeover: &ProxyTakeoverStatus, app_type: &AppType) -> b AppType::Claude => takeover.claude, AppType::Codex => takeover.codex, AppType::Gemini => takeover.gemini, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } diff --git a/src-tauri/src/cli/tui/app/helpers.rs b/src-tauri/src/cli/tui/app/helpers.rs index da5f05104..94137d333 100644 --- a/src-tauri/src/cli/tui/app/helpers.rs +++ b/src-tauri/src/cli/tui/app/helpers.rs @@ -1761,6 +1761,7 @@ pub(crate) fn app_type_picker_index(app_type: &AppType) -> usize { AppType::Hermes => 4, AppType::OpenClaw => 5, AppType::Pi => 6, + AppType::Kimi => 7, } } diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 1050e5b00..c25752304 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -971,6 +971,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }, }; @@ -1166,6 +1167,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Claude)); @@ -1192,6 +1194,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Claude)); @@ -1226,6 +1229,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Gemini)); @@ -1268,6 +1272,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -1292,6 +1297,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -1320,6 +1326,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -1344,6 +1351,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -11226,6 +11234,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect("save visible apps"); crate::settings::set_visible_apps_mode(crate::settings::VisibleAppsMode::Manual) @@ -11277,6 +11286,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -11315,6 +11325,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -11354,6 +11365,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -11392,6 +11404,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; let mut settings = crate::settings::get_settings(); settings.visible_apps = initial.clone(); diff --git a/src-tauri/src/cli/tui/data.rs b/src-tauri/src/cli/tui/data.rs index a6794aef0..39c36f584 100644 --- a/src-tauri/src/cli/tui/data.rs +++ b/src-tauri/src/cli/tui/data.rs @@ -409,6 +409,7 @@ impl ProxySnapshot { AppType::Hermes => None, AppType::OpenClaw => None, AppType::Pi => None, + AppType::Kimi => None, } } @@ -1742,6 +1743,13 @@ fn extract_api_url(settings_config: &Value, app_type: &AppType) -> Option crate::pi_config::provider_base_url(settings_config).ok(), + AppType::Kimi => settings_config + .get("base_url") + .or_else(|| settings_config.get("baseUrl")) + .or_else(|| settings_config.get("baseURL")) + .or_else(|| settings_config.get("endpoint"))? + .as_str() + .map(|s| s.to_string()), } } @@ -1752,6 +1760,11 @@ fn extract_primary_model_id( ) -> Option { match app_type { AppType::Hermes => hermes_primary_model_id(settings_config), + AppType::Kimi => settings_config + .get("model") + .and_then(Value::as_str) + .filter(|v| !v.trim().is_empty()) + .map(ToOwned::to_owned), AppType::OpenClaw => match openclaw_live_provider { Some(live_provider) => openclaw_primary_model_id(live_provider), None => openclaw_primary_model_id(settings_config), diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs index 8c7d22881..1e6103648 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -865,6 +865,19 @@ impl ProviderAddFormState { } } } + AppType::Kimi => { + let base_url = self + .hermes_base_url + .value + .trim() + .trim_end_matches('/') + .to_string(); + set_or_remove_trimmed(settings_obj, "base_url", &base_url); + set_or_remove_trimmed(settings_obj, "api_key", &self.hermes_api_key.value); + if !self.claude_model.value.trim().is_empty() { + set_or_remove_trimmed(settings_obj, "model", &self.claude_model.value); + } + } } Value::Object(provider_obj) @@ -1503,7 +1516,7 @@ pub(crate) fn strip_common_config_from_settings( ) .map_err(|e| e.to_string())?; } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => {} + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => {} AppType::Codex => { *settings_value = ProviderService::remove_common_config_from_settings_for_preview( app_type, diff --git a/src-tauri/src/cli/tui/form/provider_state.rs b/src-tauri/src/cli/tui/form/provider_state.rs index ceb4923cd..f415cb4f4 100644 --- a/src-tauri/src/cli/tui/form/provider_state.rs +++ b/src-tauri/src/cli/tui/form/provider_state.rs @@ -375,7 +375,7 @@ impl ProviderAddFormState { .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|env| !env.is_empty()), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } @@ -431,7 +431,7 @@ impl ProviderAddFormState { app_type, settings, &snippet, ) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } @@ -579,6 +579,11 @@ impl ProviderAddFormState { fields.push(ProviderAddField::OpenCodeBaseUrl); fields.push(ProviderAddField::OpenClawModels); } + AppType::Kimi => { + fields.push(ProviderAddField::HermesBaseUrl); + fields.push(ProviderAddField::HermesApiKey); + fields.push(ProviderAddField::ClaudeModelConfig); + } } if Self::supports_common_config(&self.app_type) { @@ -1193,7 +1198,8 @@ impl ProviderAddFormState { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => {} + | AppType::Pi + | AppType::Kimi => {} } Ok(()) } @@ -1425,7 +1431,8 @@ impl ProviderAddFormState { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => false, + | AppType::Pi + | AppType::Kimi => false, } } @@ -2231,7 +2238,7 @@ impl ProviderAddFormState { AppType::Claude => self.claude_base_url.value.clone(), AppType::Codex => self.codex_base_url.value.clone(), AppType::Gemini => self.gemini_base_url.value.clone(), - AppType::Hermes => self.hermes_base_url.value.clone(), + AppType::Hermes | AppType::Kimi => self.hermes_base_url.value.clone(), AppType::OpenCode | AppType::OpenClaw => self.opencode_base_url.value.clone(), AppType::Pi => { let provider = self.to_provider_json_value(); @@ -2260,7 +2267,7 @@ impl ProviderAddFormState { AppType::Claude => (&self.claude_api_key.value, &self.claude_base_url.value), AppType::Codex => (&self.codex_api_key.value, &self.codex_base_url.value), AppType::Gemini => (&self.gemini_api_key.value, &self.gemini_base_url.value), - AppType::Hermes => (&self.hermes_api_key.value, &self.hermes_base_url.value), + AppType::Hermes | AppType::Kimi => (&self.hermes_api_key.value, &self.hermes_base_url.value), AppType::OpenCode | AppType::OpenClaw => { (&self.opencode_api_key.value, &self.opencode_base_url.value) } @@ -2518,7 +2525,8 @@ impl ProviderAddFormState { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => false, + | AppType::Pi + | AppType::Kimi => false, } } diff --git a/src-tauri/src/cli/tui/form/provider_state_loading.rs b/src-tauri/src/cli/tui/form/provider_state_loading.rs index d1706cc4f..dd367f8c2 100644 --- a/src-tauri/src/cli/tui/form/provider_state_loading.rs +++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs @@ -29,6 +29,7 @@ pub(super) fn populate_form_from_provider( AppType::Hermes => populate_hermes_form(form, provider), AppType::OpenClaw => populate_openclaw_form(form, provider), AppType::Pi => populate_openclaw_form(form, provider), + AppType::Kimi => populate_kimi_form(form, provider), } form.is_full_url = form.supports_full_url_mode() && provider @@ -438,6 +439,30 @@ fn populate_hermes_form(form: &mut ProviderAddFormState, provider: &Provider) { } } +fn populate_kimi_form(form: &mut ProviderAddFormState, provider: &Provider) { + let settings = &provider.settings_config; + if let Some(base_url) = settings + .get("base_url") + .or_else(|| settings.get("baseUrl")) + .or_else(|| settings.get("baseURL")) + .or_else(|| settings.get("endpoint")) + .and_then(|value| value.as_str()) + { + form.hermes_base_url.set(base_url); + } + if let Some(api_key) = settings + .get("api_key") + .or_else(|| settings.get("apiKey")) + .or_else(|| settings.get("auth_token")) + .and_then(|value| value.as_str()) + { + form.hermes_api_key.set(api_key); + } + if let Some(model) = settings.get("model").and_then(|value| value.as_str()) { + form.claude_model.set(model); + } +} + fn populate_openclaw_form(form: &mut ProviderAddFormState, provider: &Provider) { if let Some(api_key) = provider .settings_config diff --git a/src-tauri/src/cli/tui/form/provider_templates.rs b/src-tauri/src/cli/tui/form/provider_templates.rs index 7945baae9..2dacb8c5d 100644 --- a/src-tauri/src/cli/tui/form/provider_templates.rs +++ b/src-tauri/src/cli/tui/form/provider_templates.rs @@ -260,6 +260,7 @@ pub(super) fn provider_builtin_template_defs(app_type: &AppType) -> &'static [Pr AppType::Hermes => &PROVIDER_TEMPLATE_DEFS_HERMES, AppType::OpenClaw => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, AppType::Pi => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, + AppType::Kimi => &PROVIDER_TEMPLATE_DEFS_HERMES, } } @@ -281,7 +282,7 @@ pub(super) fn provider_after_sponsor_template_defs( match app_type { AppType::Claude => &PROVIDER_TEMPLATE_DEFS_CLAUDE_AFTER_SPONSORS, AppType::Codex => &PROVIDER_TEMPLATE_DEFS_CODEX_AFTER_SPONSORS, - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { &[] } } @@ -848,7 +849,7 @@ impl ProviderAddFormState { self.opencode_model_original_id = None; } } - AppType::Pi => {} + AppType::Pi | AppType::Kimi => {} } if matches!(self.app_type, AppType::Codex) { diff --git a/src-tauri/src/cli/tui/runtime_actions/helpers.rs b/src-tauri/src/cli/tui/runtime_actions/helpers.rs index 679300648..69ed6a9d3 100644 --- a/src-tauri/src/cli/tui/runtime_actions/helpers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/helpers.rs @@ -68,6 +68,7 @@ pub(crate) fn app_display_name(app_type: &AppType) -> &'static str { AppType::Hermes => "Hermes", AppType::OpenClaw => "OpenClaw", AppType::Pi => "Pi", + AppType::Kimi => "Kimi", } } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index 898f8936e..935ffddf6 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -1868,6 +1868,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save initial visible apps"); @@ -1879,6 +1880,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; let mut app = App::new(Some(AppType::OpenClaw)); app.route = Route::ConfigOpenClawTools; @@ -1940,6 +1942,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) .expect("save initial visible apps"); @@ -1963,6 +1966,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }, }, ) @@ -1992,6 +1996,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save initial visible apps"); write_invalid_legacy_config(temp_home.path()); @@ -2004,6 +2009,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; let mut app = App::new(Some(AppType::Claude)); let mut data = UiData::default(); @@ -2042,6 +2048,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) .expect("save initial visible apps"); @@ -2062,6 +2069,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }, }, ) @@ -2092,6 +2100,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -2105,6 +2114,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; let mut app = App::new(Some(AppType::Claude)); let mut data = UiData::default(); @@ -2152,6 +2162,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }; let mut settings = crate::settings::get_settings(); settings.visible_apps = initial_visible_apps.clone(); @@ -2179,6 +2190,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }, selected: 5, }, diff --git a/src-tauri/src/cli/tui/tests.rs b/src-tauri/src/cli/tui/tests.rs index 3682832a2..339f08aa5 100644 --- a/src-tauri/src/cli/tui/tests.rs +++ b/src-tauri/src/cli/tui/tests.rs @@ -5459,6 +5459,7 @@ fn startup_hidden_requested_app_bootstrap_uses_visible_app_normalization_before_ hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); diff --git a/src-tauri/src/cli/tui/theme.rs b/src-tauri/src/cli/tui/theme.rs index a1fe08440..bb2ad3f34 100644 --- a/src-tauri/src/cli/tui/theme.rs +++ b/src-tauri/src/cli/tui/theme.rs @@ -253,6 +253,7 @@ fn accent_rgb(app: &AppType, light: bool) -> (u8, u8, u8) { AppType::Hermes => LIGHT_YELLOW, AppType::OpenClaw => LIGHT_CORAL, AppType::Pi => LIGHT_CYAN, + AppType::Kimi => LIGHT_CYAN, }; } @@ -264,6 +265,7 @@ fn accent_rgb(app: &AppType, light: bool) -> (u8, u8, u8) { AppType::Hermes => DRACULA_YELLOW, AppType::OpenClaw => OPENCLAW_CORAL, AppType::Pi => DRACULA_CYAN, + AppType::Kimi => DRACULA_CYAN, } } diff --git a/src-tauri/src/cli/tui/ui/forms/provider.rs b/src-tauri/src/cli/tui/ui/forms/provider.rs index 7fd4c1383..d79298189 100644 --- a/src-tauri/src/cli/tui/ui/forms/provider.rs +++ b/src-tauri/src/cli/tui/ui/forms/provider.rs @@ -236,7 +236,7 @@ fn common_json_preview_value(app_type: &AppType, common_snippet: &str) -> Option AppType::Gemini => serde_json::from_str::(common_snippet) .ok() .map(|env| json!({ "env": env })), - AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { None } } diff --git a/src-tauri/src/cli/tui/ui/header_tests.rs b/src-tauri/src/cli/tui/ui/header_tests.rs index 9f98904dd..4d40451bf 100644 --- a/src-tauri/src/cli/tui/ui/header_tests.rs +++ b/src-tauri/src/cli/tui/ui/header_tests.rs @@ -197,6 +197,7 @@ fn header_openclaw_sacrifices_tabs_before_losing_the_only_status_badge() { hermes: false, openclaw: true, pi: false, + kimi: false, }); let _lang = use_test_language(Language::English); let _no_color = super::tests::EnvGuard::remove("NO_COLOR"); @@ -238,6 +239,7 @@ fn header_openclaw_truncates_long_default_model_without_fake_proxy_gap() { hermes: false, openclaw: true, pi: false, + kimi: false, }); let _lang = use_test_language(Language::English); let _no_color = super::tests::EnvGuard::remove("NO_COLOR"); diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index e6938451e..6176211aa 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -4155,6 +4155,7 @@ fn installed_skill(directory: &str, name: &str) -> InstalledSkill { opencode: false, hermes: false, pi: false, + kimi: false, }, installed_at: 1, content_hash: None, @@ -4654,6 +4655,7 @@ fn header_only_renders_selected_visible_apps() { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -4684,6 +4686,7 @@ fn header_keeps_all_app_tabs_visible_with_proxy_chip() { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -4714,6 +4717,7 @@ fn settings_page_shows_visible_apps_row_value() { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -5388,6 +5392,7 @@ fn zero_selection_warning_toast_renders_after_picker_rejection() { hermes: false, openclaw: false, pi: false, + kimi: false, }, }; app.push_toast( @@ -5428,6 +5433,7 @@ fn visible_apps_picker_uses_space_toggle_key() { hermes: false, openclaw: false, pi: false, + kimi: false, }, }; @@ -5460,6 +5466,7 @@ fn visible_apps_picker_auto_mode_does_not_append_auto_suffix_to_apps() { hermes: true, openclaw: true, pi: false, + kimi: false, }, }; @@ -5618,6 +5625,7 @@ fn header_centers_tabs_when_room_allows() { hermes: true, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -5664,6 +5672,7 @@ fn header_keeps_title_and_right_badges_visible_without_large_gap_in_chinese() { hermes: true, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -6183,6 +6192,7 @@ fn home_connection_card_labels_mcp_and_skills_with_active_counts() { opencode: false, hermes: false, pi: false, + kimi: false, }, installed_at: 0, content_hash: None, @@ -7224,6 +7234,7 @@ fn skills_page_shows_opencode_summary() { opencode: true, hermes: false, pi: false, + kimi: false, }; data.skills.installed = vec![skill]; @@ -7251,6 +7262,7 @@ fn skills_page_shows_hermes_column_and_summary() { opencode: false, hermes: true, pi: false, + kimi: false, }; data.skills.installed = vec![skill]; @@ -7281,6 +7293,7 @@ fn skill_detail_page_shows_opencode_enabled_state() { opencode: true, hermes: false, pi: false, + kimi: false, }; data.skills.installed = vec![skill]; @@ -7312,6 +7325,7 @@ fn skill_detail_page_shows_hermes_enabled_state() { opencode: false, hermes: true, pi: false, + kimi: false, }; data.skills.installed = vec![skill]; @@ -7375,6 +7389,7 @@ fn mcp_page_renders_opencode_column() { gemini: false, opencode: true, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -7412,6 +7427,7 @@ fn mcp_page_renders_hermes_column() { gemini: false, opencode: false, hermes: true, + kimi: true, }, description: None, homepage: None, @@ -7468,6 +7484,7 @@ fn mcp_page_uses_space_toggle_key() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -7765,6 +7782,7 @@ fn mcp_page_shows_summary_bar() { gemini: false, opencode: true, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -7785,6 +7803,7 @@ fn mcp_page_shows_summary_bar() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/src/cli/ui/colors.rs b/src-tauri/src/cli/ui/colors.rs index d9c147e51..033b14142 100644 --- a/src-tauri/src/cli/ui/colors.rs +++ b/src-tauri/src/cli/ui/colors.rs @@ -37,6 +37,7 @@ fn inquire_color_for_app(app_type: &AppType) -> InquireColor { AppType::Hermes => InquireColor::LightBlue, AppType::OpenClaw => InquireColor::LightRed, AppType::Pi => InquireColor::LightCyan, + AppType::Kimi => InquireColor::LightCyan, } } @@ -90,6 +91,7 @@ fn highlight_color_for_app(app_type: &AppType) -> Color { AppType::Hermes => Color::BrightBlue, AppType::OpenClaw => Color::BrightRed, AppType::Pi => Color::BrightCyan, + AppType::Kimi => Color::BrightCyan, } } diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index 8f5008c5c..39e2f2a19 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -48,6 +48,7 @@ impl Database { gemini: enabled_gemini, opencode: enabled_opencode, hermes: enabled_hermes, + kimi: false, }, description, homepage, diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 7668d9c85..c8a9b95c9 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -46,6 +46,7 @@ impl Database { opencode: row.get(11)?, hermes: row.get(12)?, pi: false, + kimi: false, }, installed_at: row.get(13)?, content_hash: row.get(14)?, @@ -91,6 +92,7 @@ impl Database { opencode: row.get(11)?, hermes: row.get(12)?, pi: false, + kimi: false, }, installed_at: row.get(13)?, content_hash: row.get(14)?, diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 27aae7cf4..41f6ce34d 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -174,6 +174,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { gemini: false, opencode: false, hermes: false, + kimi: false, }; for app in apps_str.split(',') { @@ -187,6 +188,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter"); } "hermes" => apps.hermes = true, + "kimi" => apps.kimi = true, other => { return Err(AppError::InvalidInput(format!( "Invalid app in 'apps': {other}" diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 77621b7c2..8e16cc90d 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -142,6 +142,7 @@ fn build_provider_from_request( AppType::OpenCode => build_opencode_settings(request), AppType::Hermes => build_hermes_settings(request), AppType::OpenClaw => build_openclaw_settings(request), + AppType::Kimi => build_kimi_settings(request), AppType::Pi => { return Err(AppError::InvalidInput( "Pi providers must be added from the Pi provider page".to_string(), @@ -388,6 +389,31 @@ fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value { Value::Object(settings) } +fn build_kimi_settings(request: &DeepLinkImportRequest) -> serde_json::Value { + let endpoint = get_primary_endpoint(request); + let mut settings = serde_json::Map::new(); + settings.insert( + "name".to_string(), + json!(request.name.clone().unwrap_or_else(|| "custom".to_string())), + ); + + if !endpoint.is_empty() { + settings.insert("base_url".to_string(), json!(endpoint)); + } + if let Some(api_key) = &request.api_key { + settings.insert("api_key".to_string(), json!(api_key)); + } + if let Some(model) = request + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + settings.insert("model".to_string(), json!(model)); + } + + Value::Object(settings) +} + fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value { if let Some(config) = &request.openclaw_config { let mut settings = match config { diff --git a/src-tauri/src/kimi_config.rs b/src-tauri/src/kimi_config.rs index bd8545116..b0dea1320 100644 --- a/src-tauri/src/kimi_config.rs +++ b/src-tauri/src/kimi_config.rs @@ -1,8 +1,15 @@ +use crate::app_config::MultiAppConfig; +use crate::error::AppError; use anyhow::{Context, Result}; +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use toml_edit::{DocumentMut, Table}; pub const DEFAULT_KIMI_CONFIG_DIR: &str = ".kimi-code"; pub const KIMI_HOME_ENV: &str = "KIMI_CODE_HOME"; @@ -41,6 +48,9 @@ pub struct KimiProfileInfo { /// 解析 Kimi Code 根目录路径(遵循 KIMI_CODE_HOME 环境变量,默认 ~/.kimi-code) pub fn get_kimi_config_dir() -> PathBuf { + if let Some(override_dir) = crate::settings::get_kimi_override_dir() { + return override_dir; + } if let Some(env_val) = std::env::var_os(KIMI_HOME_ENV) { if !env_val.is_empty() { return PathBuf::from(env_val); @@ -59,6 +69,511 @@ pub fn get_kimi_config_dir() -> PathBuf { } } +/// 获取 Kimi Code 的主配置文件路径 (~/.kimi-code/config.toml) +pub fn get_kimi_config_path() -> PathBuf { + get_kimi_config_dir().join(KIMI_CONFIG_FILE) +} + +/// 获取 Kimi Code 的 MCP 配置文件路径 (~/.kimi-code/mcp.json) +pub fn get_kimi_mcp_path() -> PathBuf { + get_kimi_config_dir().join("mcp.json") +} + +/// 获取 Kimi Code 的 Skills 存放目录 (~/.kimi-code/skills) +pub fn get_kimi_skills_dir() -> PathBuf { + get_kimi_config_dir().join("skills") +} + +/// 获取 Kimi Code 的全局指令文件路径 (~/.kimi-code/AGENTS.md) +pub fn get_kimi_agents_md_path() -> PathBuf { + get_kimi_config_dir().join("AGENTS.md") +} + +pub fn kimi_write_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +pub fn read_kimi_config_source() -> Result, AppError> { + let path = get_kimi_config_path(); + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + Ok(Some(content)) +} + +pub fn write_kimi_config_source(source: &str) -> Result<(), AppError> { + let path = get_kimi_config_path(); + write_file_atomic(&path, source, 0o644).map_err(|e| AppError::Message(e.to_string())) +} + +pub fn read_kimi_config_json() -> Result { + let source = read_kimi_config_source()?.unwrap_or_default(); + if source.trim().is_empty() { + return Ok(json!({})); + } + let toml_val: toml::Value = toml::from_str(&source) + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))?; + let json_val = serde_json::to_value(toml_val) + .map_err(|e| AppError::Config(format!("Failed to convert Kimi config to JSON: {e}")))?; + Ok(json_val) +} + +/// 读取 live config.toml 中配置的所有 providers,转化为统一的 Provider settings_config 格式 +pub fn get_providers() -> Result, AppError> { + let path = get_kimi_config_path(); + if !path.exists() { + return Ok(IndexMap::new()); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + if content.trim().is_empty() { + return Ok(IndexMap::new()); + } + let doc = content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))?; + + let Some(providers_tbl) = doc.get("providers").and_then(|v| v.as_table_like()) else { + return Ok(IndexMap::new()); + }; + + let default_model_opt = doc.get("default_model").and_then(|v| v.as_str()); + + let mut result = IndexMap::new(); + + for (p_id, p_item) in providers_tbl.iter() { + let Some(p_tbl) = p_item.as_table_like() else { + continue; + }; + + let p_type = p_tbl + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("openai"); + let base_url = p_tbl + .get("base_url") + .or_else(|| p_tbl.get("baseUrl")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let api_key = p_tbl + .get("api_key") + .or_else(|| p_tbl.get("apiKey")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Find models pointing to this provider + let mut models = Vec::new(); + if let Some(models_tbl) = doc.get("models").and_then(|v| v.as_table_like()) { + for (m_name, m_item) in models_tbl.iter() { + if let Some(m_tbl) = m_item.as_table_like() { + if m_tbl.get("provider").and_then(|p| p.as_str()) == Some(p_id) { + let display_name = m_tbl + .get("display_name") + .and_then(|v| v.as_str()) + .unwrap_or(m_name); + models.push(json!({ + "id": m_name, + "name": display_name, + })); + } + } + } + } + + // Determine primary model + let primary_model = if let Some(def_m) = default_model_opt { + if models.iter().any(|m| m.get("id").and_then(Value::as_str) == Some(def_m)) { + Some(def_m.to_string()) + } else { + models + .first() + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .map(|s| s.to_string()) + } + } else { + models + .first() + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .map(|s| s.to_string()) + }; + + let mut obj = serde_json::Map::new(); + obj.insert("name".to_string(), json!(p_id)); + obj.insert("type".to_string(), json!(p_type)); + if !base_url.is_empty() { + obj.insert("baseUrl".to_string(), json!(base_url)); + obj.insert("base_url".to_string(), json!(base_url)); + } + if !api_key.is_empty() { + obj.insert("apiKey".to_string(), json!(api_key)); + obj.insert("api_key".to_string(), json!(api_key)); + } + if let Some(model) = primary_model { + obj.insert("model".to_string(), json!(model)); + } + if !models.is_empty() { + obj.insert("models".to_string(), Value::Array(models)); + } + + result.insert(p_id.to_string(), Value::Object(obj)); + } + + Ok(result) +} + +pub fn get_provider(id: &str) -> Result, AppError> { + Ok(get_providers()?.get(id).cloned()) +} + +/// 准备将 provider 变更写入 Kimi 的 config.toml,返回更新后的 TOML 文本 +pub fn prepare_provider(id: &str, provider_config: Value) -> Result { + let path = get_kimi_config_path(); + let content = if path.exists() { + fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))? + } else { + String::new() + }; + let mut doc = if content.trim().is_empty() { + DocumentMut::new() + } else { + content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))? + }; + + if doc.get("providers").is_none() { + doc["providers"] = toml_edit::Item::Table(Table::new()); + } + let providers = doc["providers"].as_table_like_mut().ok_or_else(|| { + AppError::Config("Kimi config.toml [providers] is not a table".into()) + })?; + + if providers.get(id).is_none() { + providers.insert(id, toml_edit::Item::Table(Table::new())); + } + let provider_tbl = providers + .get_mut(id) + .and_then(|v| v.as_table_like_mut()) + .ok_or_else(|| AppError::Config(format!("Kimi provider table '{id}' is invalid")))?; + + let p_type = provider_config + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("openai"); + provider_tbl.insert("type", toml_edit::value(p_type)); + + let base_url = provider_config + .get("baseUrl") + .or_else(|| provider_config.get("base_url")) + .or_else(|| provider_config.get("endpoint")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()); + + if let Some(url) = base_url { + provider_tbl.insert("base_url", toml_edit::value(url)); + } + + let api_key = provider_config + .get("apiKey") + .or_else(|| provider_config.get("api_key")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()); + + if let Some(key) = api_key { + provider_tbl.insert("api_key", toml_edit::value(key)); + } else if provider_config.get("api_key").is_some() || provider_config.get("apiKey").is_some() { + provider_tbl.remove("api_key"); + } + + // Handle models + let mut configured_models = Vec::new(); + if let Some(model_str) = provider_config.get("model").and_then(|v| v.as_str()) { + let m = model_str.trim(); + if !m.is_empty() { + configured_models.push(m.to_string()); + } + } + if let Some(models_arr) = provider_config.get("models").and_then(|v| v.as_array()) { + for item in models_arr { + if let Some(m_id) = item + .get("id") + .and_then(|v| v.as_str()) + .or_else(|| item.as_str()) + { + let m = m_id.trim(); + if !m.is_empty() && !configured_models.contains(&m.to_string()) { + configured_models.push(m.to_string()); + } + } + } + } + + if !configured_models.is_empty() { + if doc.get("models").is_none() { + doc["models"] = toml_edit::Item::Table(Table::new()); + } + if let Some(models) = doc["models"].as_table_like_mut() { + for m in &configured_models { + if models.get(m).is_none() { + let mut tbl = Table::new(); + tbl.insert("provider", toml_edit::value(id)); + tbl.insert("model", toml_edit::value(m.as_str())); + models.insert(m, toml_edit::Item::Table(tbl)); + } else if let Some(tbl) = models.get_mut(m).and_then(|v| v.as_table_like_mut()) { + tbl.insert("provider", toml_edit::value(id)); + tbl.insert("model", toml_edit::value(m.as_str())); + } + } + } + + if doc.get("default_model").is_none() { + doc["default_model"] = toml_edit::value(&configured_models[0]); + } + } + + Ok(doc.to_string()) +} + +/// 将准备好的配置内容原子写入 Kimi 的 config.toml +pub fn write_prepared_config(content: &str) -> Result<(), AppError> { + let _guard = kimi_write_lock() + .lock() + .map_err(|_| AppError::Message("Kimi write lock poisoned".into()))?; + let path = get_kimi_config_path(); + write_file_atomic(&path, content, 0o644).map_err(|e| AppError::Message(e.to_string())) +} + +/// 从 config.toml 中移除指定 provider 及其关联的 models +pub fn remove_provider(id: &str) -> Result<(), AppError> { + let _guard = kimi_write_lock() + .lock() + .map_err(|_| AppError::Message("Kimi write lock poisoned".into()))?; + let path = get_kimi_config_path(); + if !path.exists() { + return Ok(()); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let mut doc = content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))?; + + if let Some(providers) = doc.get_mut("providers").and_then(|v| v.as_table_like_mut()) { + providers.remove(id); + } + + let mut removed_models = HashSet::new(); + if let Some(models) = doc.get_mut("models").and_then(|v| v.as_table_like_mut()) { + let to_remove: Vec = models + .iter() + .filter_map(|(m_name, m_item)| { + if let Some(tbl) = m_item.as_table_like() { + if tbl.get("provider").and_then(|p| p.as_str()) == Some(id) { + return Some(m_name.to_string()); + } + } + None + }) + .collect(); + for m in to_remove { + removed_models.insert(m.clone()); + models.remove(&m); + } + } + + if let Some(def_m) = doc.get("default_model").and_then(|v| v.as_str()) { + if removed_models.contains(def_m) { + let next_model = doc + .get("models") + .and_then(|m| m.as_table_like()) + .and_then(|m| m.iter().next().map(|(k, _)| k.to_string())); + if let Some(next) = next_model { + doc["default_model"] = toml_edit::value(next); + } else { + doc.as_table_mut().remove("default_model"); + } + } + } + + write_file_atomic(&path, &doc.to_string(), 0o644).map_err(|e| AppError::Message(e.to_string())) +} + +/// 获取当前激活的 provider ID(根据 default_model 追溯) +pub fn get_current_provider_id() -> Result, AppError> { + let path = get_kimi_config_path(); + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let doc = content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))?; + let Some(default_model) = doc.get("default_model").and_then(|v| v.as_str()) else { + return Ok(None); + }; + if let Some(models) = doc.get("models").and_then(|m| m.as_table_like()) { + if let Some(target) = models.get(default_model).and_then(|m| m.as_table_like()) { + if let Some(provider_id) = target.get("provider").and_then(|p| p.as_str()) { + return Ok(Some(provider_id.to_string())); + } + } + } + Ok(None) +} + +/// 切换当前激活的 provider,并将其设为 default_model +pub fn set_current_provider(id: &str, provider_config: &Value) -> Result<(), AppError> { + let _guard = kimi_write_lock() + .lock() + .map_err(|_| AppError::Message("Kimi write lock poisoned".into()))?; + let path = get_kimi_config_path(); + let content = if path.exists() { + fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))? + } else { + String::new() + }; + let mut doc = if content.trim().is_empty() { + DocumentMut::new() + } else { + content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))? + }; + + let target_model = provider_config + .get("model") + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + doc.get("models") + .and_then(|m| m.as_table_like()) + .and_then(|models| { + models.iter().find_map(|(m_name, m_item)| { + if let Some(tbl) = m_item.as_table_like() { + if tbl.get("provider").and_then(|p| p.as_str()) == Some(id) { + return Some(m_name.to_string()); + } + } + None + }) + }) + }) + .unwrap_or_else(|| id.to_string()); + + if doc.get("models").is_none() { + doc["models"] = toml_edit::Item::Table(Table::new()); + } + if let Some(models) = doc["models"].as_table_like_mut() { + if models.get(&target_model).is_none() { + let mut tbl = Table::new(); + tbl.insert("provider", toml_edit::value(id)); + tbl.insert("model", toml_edit::value(&target_model)); + models.insert(&target_model, toml_edit::Item::Table(tbl)); + } + } + + doc["default_model"] = toml_edit::value(&target_model); + write_prepared_config(&doc.to_string()) +} + +/// 设置默认模型 +pub fn set_default_model(model_name: &str) -> Result { + let _guard = kimi_write_lock() + .lock() + .map_err(|_| AppError::Message("Kimi write lock poisoned".into()))?; + let path = get_kimi_config_path(); + let content = if path.exists() { + fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))? + } else { + String::new() + }; + let mut doc = if content.trim().is_empty() { + DocumentMut::new() + } else { + content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))? + }; + doc["default_model"] = toml_edit::value(model_name); + write_prepared_config(&doc.to_string())?; + Ok(model_name.to_string()) +} + +/// 读取 Kimi MCP 服务器字典 (~/.kimi-code/mcp.json) +pub fn read_kimi_mcp_servers_map() -> Result, AppError> { + let path = get_kimi_mcp_path(); + if !path.exists() { + return Ok(HashMap::new()); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let val: Value = serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))?; + let servers = val + .get("mcpServers") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }) + .unwrap_or_default(); + Ok(servers) +} + +/// 写入 Kimi MCP 服务器字典 +pub fn set_kimi_mcp_servers_map(servers: &HashMap) -> Result<(), AppError> { + let path = get_kimi_mcp_path(); + let parent = path + .parent() + .ok_or_else(|| AppError::Message("Invalid mcp path".into()))?; + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + + let mut root_obj = if path.exists() { + fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default() + } else { + serde_json::Map::new() + }; + + let servers_val = serde_json::to_value(servers) + .map_err(|e| AppError::Message(format!("Failed to serialize MCP servers: {e}")))?; + root_obj.insert("mcpServers".to_string(), servers_val); + + let formatted = serde_json::to_string_pretty(&Value::Object(root_obj)) + .map_err(|e| AppError::Message(format!("Failed to format MCP JSON: {e}")))?; + write_file_atomic(&path, &formatted, 0o644).map_err(|e| AppError::Message(e.to_string())) +} + +/// 同步单个 MCP 服务器到 Kimi live 配置 +pub fn sync_single_server_to_kimi( + _config: &MultiAppConfig, + id: &str, + server_spec: &Value, +) -> Result<(), AppError> { + if !crate::sync_policy::should_sync_live(&crate::app_config::AppType::Kimi) { + return Ok(()); + } + let mut servers = read_kimi_mcp_servers_map()?; + servers.insert(id.to_string(), server_spec.clone()); + set_kimi_mcp_servers_map(&servers) +} + +/// 从 Kimi live 配置中移除单个 MCP 服务器 +pub fn remove_server_from_kimi(id: &str) -> Result<(), AppError> { + if !crate::sync_policy::should_sync_live(&crate::app_config::AppType::Kimi) { + return Ok(()); + } + let mut servers = read_kimi_mcp_servers_map()?; + servers.remove(id); + set_kimi_mcp_servers_map(&servers) +} + /// 获取 cc-switch 管理的 Kimi 配置 profiles 存储目录 pub fn get_kimi_profiles_dir() -> PathBuf { crate::config::get_app_config_dir().join(KIMI_PROFILES_DIR_NAME) @@ -692,4 +1207,90 @@ mod tests { std::env::remove_var("CC_SWITCH_CONFIG_DIR"); } } + + #[test] + fn test_kimi_provider_crud_and_mcp() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp_home = tempfile::tempdir().unwrap(); + let old_home_env = std::env::var_os(KIMI_HOME_ENV); + std::env::set_var(KIMI_HOME_ENV, temp_home.path()); + + // 1. Initial get_providers on empty directory + let providers = get_providers().unwrap(); + assert!(providers.is_empty()); + + // 2. Prepare and write cortex provider + let provider_config = json!({ + "type": "openai", + "baseUrl": "https://cortex.botiverse.dev/v1", + "apiKey": "sk-cortex-secret", + "model": "devin/swe-2", + "models": [ + { "id": "devin/swe-2", "name": "Devin SWE-2" }, + { "id": "k3", "name": "K3" } + ] + }); + let prepared = prepare_provider("cortex", provider_config).unwrap(); + write_prepared_config(&prepared).unwrap(); + + // Verify providers read back + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 1); + let cortex = providers.get("cortex").unwrap(); + assert_eq!(cortex["name"], "cortex"); + assert_eq!(cortex["type"], "openai"); + assert_eq!(cortex["baseUrl"], "https://cortex.botiverse.dev/v1"); + assert_eq!(cortex["apiKey"], "sk-cortex-secret"); + assert_eq!(cortex["model"], "devin/swe-2"); + + // Verify active provider + let current_id = get_current_provider_id().unwrap(); + assert_eq!(current_id, Some("cortex".to_string())); + + // 3. Add second provider (openrouter) + let or_config = json!({ + "type": "openai", + "baseUrl": "https://openrouter.ai/api/v1", + "apiKey": "sk-or-test", + "model": "anthropic/claude-3.5-sonnet" + }); + let prepared2 = prepare_provider("openrouter", or_config.clone()).unwrap(); + write_prepared_config(&prepared2).unwrap(); + + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 2); + + // Switch to openrouter + set_current_provider("openrouter", &or_config).unwrap(); + assert_eq!(get_current_provider_id().unwrap(), Some("openrouter".to_string())); + + // 4. Remove cortex + remove_provider("cortex").unwrap(); + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 1); + assert!(!providers.contains_key("cortex")); + assert!(providers.contains_key("openrouter")); + assert_eq!(get_current_provider_id().unwrap(), Some("openrouter".to_string())); + + // 5. MCP sync + let dummy_cfg = MultiAppConfig::default(); + let server_spec = json!({ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"] + }); + sync_single_server_to_kimi(&dummy_cfg, "filesystem", &server_spec).unwrap(); + let mcp_map = read_kimi_mcp_servers_map().unwrap(); + assert_eq!(mcp_map.len(), 1); + assert!(mcp_map.contains_key("filesystem")); + + remove_server_from_kimi("filesystem").unwrap(); + let mcp_map = read_kimi_mcp_servers_map().unwrap(); + assert!(mcp_map.is_empty()); + + if let Some(val) = old_home_env { + std::env::set_var(KIMI_HOME_ENV, val); + } else { + std::env::remove_var(KIMI_HOME_ENV); + } + } } diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 833295772..7bd72b544 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -395,6 +395,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -786,6 +788,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result Result Result Result { AppType::Hermes => get_hermes_override_dir().unwrap_or_else(default_hermes_dir), AppType::OpenClaw => get_openclaw_override_dir().unwrap_or_else(default_openclaw_dir), AppType::Pi => crate::pi_config::get_pi_agent_dir()?, + AppType::Kimi => crate::kimi_config::get_kimi_config_dir(), }; let filename = match app { @@ -28,6 +29,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Hermes => "AGENTS.md", AppType::OpenClaw => "AGENTS.md", AppType::Pi => "AGENTS.md", + AppType::Kimi => "AGENTS.md", }; Ok(base_dir.join(filename)) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index dcfb496fb..a6384ef70 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -147,6 +147,10 @@ impl Provider { .or_else(|| self.settings_config.get("api_key")), AppType::OpenClaw => self.settings_config.get("apiKey"), AppType::Pi => self.settings_config.get("apiKey"), + AppType::Kimi => self + .settings_config + .get("apiKey") + .or_else(|| self.settings_config.get("api_key")), }; value @@ -253,7 +257,7 @@ impl Provider { AppType::Claude => "claude", AppType::Codex => "codex", AppType::Gemini => "gemini", - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => return None, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => return None, }; if self.category.as_deref() == Some("official") { @@ -299,7 +303,7 @@ impl Provider { .is_none_or(|value| value.trim().is_empty()); api_key_missing && base_url_missing } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, }; is_official.then_some(tool) diff --git a/src-tauri/src/provider_preset_sponsors.rs b/src-tauri/src/provider_preset_sponsors.rs index 5d1e828e4..e663e08c5 100644 --- a/src-tauri/src/provider_preset_sponsors.rs +++ b/src-tauri/src/provider_preset_sponsors.rs @@ -250,7 +250,7 @@ pub(crate) fn sponsor_provider_presets_for_app( AppType::Codex => &CODEX_SPONSOR_PRESETS, AppType::Gemini => &GEMINI_SPONSOR_PRESETS, AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => &ADDITIVE_SPONSOR_PRESETS, - AppType::Pi => &[], + AppType::Pi | AppType::Kimi => &[], } } diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 6b23284e0..25646ce4e 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -147,7 +147,7 @@ impl ProviderType { } ProviderType::Gemini } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { ProviderType::Codex } } @@ -201,6 +201,7 @@ pub fn get_adapter(app_type: &AppType) -> Option> { AppType::OpenCode => Box::new(CodexAdapter::new()), AppType::Hermes => Box::new(CodexAdapter::new()), AppType::OpenClaw => Box::new(CodexAdapter::new()), + AppType::Kimi => Box::new(CodexAdapter::new()), AppType::Pi => return None, }) } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index ece915908..2d4b682b5 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -278,6 +278,7 @@ impl ConfigService { Self::sync_current_provider_for_app(config, &AppType::OpenCode)?; Self::sync_current_provider_for_app(config, &AppType::Hermes)?; Self::sync_current_provider_for_app(config, &AppType::OpenClaw)?; + Self::sync_current_provider_for_app(config, &AppType::Kimi)?; Ok(()) } @@ -316,6 +317,7 @@ impl ConfigService { AppType::Hermes => {} AppType::OpenClaw => {} AppType::Pi => {} + AppType::Kimi => {} } Ok(()) diff --git a/src-tauri/src/services/local_env_check.rs b/src-tauri/src/services/local_env_check.rs index bcb520841..b6591cb80 100644 --- a/src-tauri/src/services/local_env_check.rs +++ b/src-tauri/src/services/local_env_check.rs @@ -29,10 +29,11 @@ pub enum LocalTool { Hermes, OpenClaw, Pi, + Kimi, } impl LocalTool { - pub const ALL: [LocalTool; 7] = [ + pub const ALL: [LocalTool; 8] = [ LocalTool::Claude, LocalTool::Codex, LocalTool::Gemini, @@ -40,6 +41,7 @@ impl LocalTool { LocalTool::Hermes, LocalTool::OpenClaw, LocalTool::Pi, + LocalTool::Kimi, ]; pub fn all() -> &'static [LocalTool] { @@ -55,6 +57,7 @@ impl LocalTool { LocalTool::Hermes => "Hermes", LocalTool::OpenClaw => "OpenClaw", LocalTool::Pi => "Pi", + LocalTool::Kimi => "Kimi", } } @@ -67,6 +70,7 @@ impl LocalTool { LocalTool::Hermes => "hermes", LocalTool::OpenClaw => "openclaw", LocalTool::Pi => "pi", + LocalTool::Kimi => "kimi", } } @@ -79,6 +83,7 @@ impl LocalTool { LocalTool::Hermes => &["--version", "version"], LocalTool::OpenClaw => &["--version", "version"], LocalTool::Pi => &["--version"], + LocalTool::Kimi => &["--version"], } } @@ -106,6 +111,7 @@ impl LocalTool { AppType::Hermes => LocalTool::Hermes, AppType::OpenClaw => LocalTool::OpenClaw, AppType::Pi => LocalTool::Pi, + AppType::Kimi => LocalTool::Kimi, } } } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index b6a6bdf16..332543842 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -16,6 +16,7 @@ impl McpService { AppType::Gemini, AppType::OpenCode, AppType::Hermes, + AppType::Kimi, ] .into_iter() } @@ -216,6 +217,9 @@ impl McpService { AppType::Hermes => { mcp::sync_single_server_to_hermes(cfg, &server.id, &server.server)?; } + AppType::Kimi => { + crate::kimi_config::sync_single_server_to_kimi(cfg, &server.id, &server.server)?; + } AppType::OpenClaw => {} AppType::Pi => {} } @@ -242,6 +246,7 @@ impl McpService { AppType::Gemini => mcp::remove_server_from_gemini(id)?, AppType::OpenCode => mcp::remove_server_from_opencode(id)?, AppType::Hermes => mcp::remove_server_from_hermes(id)?, + AppType::Kimi => crate::kimi_config::remove_server_from_kimi(id)?, AppType::OpenClaw => {} AppType::Pi => {} } diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index d8b3733aa..f69bcb9a6 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -502,7 +502,7 @@ fn parse_json_object_snippet(app_type: &AppType, snippet: &str) -> Result { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { AppError::localized( "common_config.opencode.invalid_json", format!("OpenCode 通用配置片段不是有效的 JSON:{e}"), @@ -524,7 +524,7 @@ fn parse_json_object_snippet(app_type: &AppType, snippet: &str) -> Result { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { AppError::localized( "common_config.opencode.not_object", "OpenCode 通用配置片段必须是 JSON 对象", @@ -561,7 +561,8 @@ pub(super) fn validate_common_config_snippet( | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => { + | AppType::Pi + | AppType::Kimi => { parse_json_object_snippet(app_type, snippet)?; } AppType::Codex => { @@ -617,7 +618,7 @@ pub(super) fn settings_contain_common_config( } _ => false, }, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } @@ -682,7 +683,7 @@ pub(super) fn apply_common_config_to_settings( } Ok(result) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { Ok(settings.clone()) } } @@ -733,7 +734,7 @@ pub(super) fn remove_common_config_from_settings( } Ok(result) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { Ok(settings.clone()) } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index e1a7da2c6..92f80302c 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -32,6 +32,9 @@ pub(super) enum LiveSnapshot { OpenClaw { config_source: Option, }, + Kimi { + config_source: Option, + }, } impl LiveSnapshot { @@ -107,6 +110,14 @@ impl LiveSnapshot { delete_file(&path)?; } } + LiveSnapshot::Kimi { config_source } => { + let path = crate::kimi_config::get_kimi_config_path(); + if let Some(source) = config_source { + crate::kimi_config::write_kimi_config_source(source)?; + } else if path.exists() { + delete_file(&path)?; + } + } } Ok(()) } @@ -174,6 +185,10 @@ pub(super) fn capture_live_snapshot(app_type: &AppType) -> Result { + let config_source = crate::kimi_config::read_kimi_config_source()?; + Ok(LiveSnapshot::Kimi { config_source }) + } AppType::Pi => Err(AppError::Config( "Pi providers use the Pi provider service".to_string(), )), @@ -228,6 +243,54 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result Result { + let providers = crate::kimi_config::get_providers()?; + if providers.is_empty() { + return Ok(0); + } + + let mut imported = 0usize; + let existing_ids = state.db.get_provider_ids("kimi")?; + + for (id, settings_config) in providers { + if id.trim().is_empty() { + log::warn!("Skipping Kimi provider with empty id"); + continue; + } + if existing_ids.contains(&id) { + log::debug!("Kimi provider '{id}' already exists in database, skipping"); + continue; + } + if !settings_config.is_object() { + log::warn!("Skipping Kimi provider '{id}': config is not an object"); + continue; + } + + let display_name = settings_config + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&id) + .to_string(); + let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None); + provider.meta = Some(ProviderMeta { + live_config_managed: Some(true), + ..Default::default() + }); + + if let Err(err) = state.db.save_provider("kimi", &provider) { + log::warn!("Failed to import Kimi provider '{id}': {err}"); + continue; + } + + imported += 1; + log::info!("Imported Kimi provider '{id}' from live config"); + } + + Ok(imported) +} + pub fn sync_openclaw_providers_from_live(state: &AppState) -> Result { if !crate::openclaw_config::get_openclaw_config_path().exists() { return Ok(0); diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 67a25f74e..49798f472 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -306,6 +306,9 @@ enum PreparedLiveWrite { OpenClaw { models: Value, }, + Kimi { + config: String, + }, } #[derive(Clone)] @@ -317,7 +320,7 @@ enum PreparedCodexAuthWrite { impl ProviderService { pub fn is_provider_key_app(app_type: &AppType) -> bool { - matches!(app_type, AppType::OpenClaw | AppType::Hermes | AppType::Pi) + matches!(app_type, AppType::OpenClaw | AppType::Hermes | AppType::Pi | AppType::Kimi) } pub fn is_valid_provider_key(value: &str) -> bool { @@ -432,6 +435,10 @@ impl ProviderService { .into_iter() .map(|(id, _)| id) .collect(), + AppType::Kimi => crate::kimi_config::get_providers()? + .into_iter() + .map(|(id, _)| id) + .collect(), _ => HashSet::new(), }; Ok(ids) @@ -753,6 +760,8 @@ impl ProviderService { .map(|providers| providers.contains_key(provider_id)), AppType::OpenClaw => Self::valid_openclaw_live_provider_ids() .map(|ids| ids.is_some_and(|ids| ids.contains(provider_id))), + AppType::Kimi => crate::kimi_config::get_providers() + .map(|providers| providers.contains_key(provider_id)), _ => Ok(false), }; @@ -1005,6 +1014,13 @@ impl ProviderService { &prepared.action.provider.id, &prepared.action.provider.settings_config, )?; + } else if prepared.action.activate_provider + && matches!(prepared.action.app_type, AppType::Kimi) + { + crate::kimi_config::set_current_provider( + &prepared.action.provider.id, + &prepared.action.provider.settings_config, + )?; } } PreparedPostCommitEffect::ProxyLiveBackup { @@ -1426,6 +1442,26 @@ impl ProviderService { } state.save()?; } + AppType::Kimi => { + let providers = crate::kimi_config::get_providers()?; + let live_after = providers.get(provider_id).cloned().ok_or_else(|| { + AppError::localized( + "kimi.live.missing_provider", + format!("Kimi live 配置中缺少供应商: {provider_id}"), + format!("Kimi live config missing provider: {provider_id}"), + ) + })?; + + { + let mut guard = state.config.write().map_err(AppError::from)?; + if let Some(manager) = guard.get_manager_mut(app_type) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + } + state.save()?; + } AppType::Pi => {} } Ok(()) @@ -1482,7 +1518,7 @@ impl ProviderService { strict_current_provider_id, old_snippet, ), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => Ok(()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => Ok(()), }; match result { @@ -1612,7 +1648,7 @@ impl ProviderService { } AppType::Gemini => live_settings.get("env") != provider_settings.get("env"), AppType::Claude => live_settings != provider_settings, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } @@ -1770,7 +1806,7 @@ impl ProviderService { AppType::OpenCode => Self::extract_opencode_common_config(settings_config), AppType::Hermes => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), - AppType::Pi => Ok(String::new()), + AppType::Pi | AppType::Kimi => Ok(String::new()), } } @@ -2134,6 +2170,10 @@ impl ProviderService { return crate::hermes_config::get_current_provider_id() .map(|opt| opt.unwrap_or_default()); } + if matches!(app_type, AppType::Kimi) { + return crate::kimi_config::get_current_provider_id() + .map(|opt| opt.unwrap_or_default()); + } if app_type.is_additive_mode() { return Ok(String::new()); } @@ -2478,6 +2518,7 @@ impl ProviderService { AppType::Hermes => unreachable!("additive mode apps are handled earlier"), AppType::OpenClaw => unreachable!("additive mode apps are handled earlier"), AppType::Pi => unreachable!("Pi uses native provider import"), + AppType::Kimi => unreachable!("additive mode apps are handled earlier"), }; let mut provider = Provider::with_id( @@ -2617,6 +2658,17 @@ impl ProviderService { } crate::openclaw_config::read_openclaw_config() } + AppType::Kimi => { + let config_path = crate::kimi_config::get_kimi_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "kimi.config.missing", + "Kimi 配置文件不存在", + "Kimi configuration file not found", + )); + } + crate::kimi_config::read_kimi_config_json() + } AppType::Pi => Err(AppError::InvalidInput( "Pi providers are read from models.json".to_string(), )), @@ -2696,6 +2748,11 @@ impl ProviderService { crate::openclaw_config::remove_provider(provider_id)?; } } + AppType::Kimi => { + if crate::kimi_config::get_kimi_config_dir().exists() { + crate::kimi_config::remove_provider(provider_id)?; + } + } _ => unreachable!("non-additive apps should not enter remove-from-live branch"), } @@ -2744,6 +2801,7 @@ impl ProviderService { AppType::OpenCode => Self::import_opencode_providers_from_live(state), AppType::OpenClaw => Self::import_openclaw_providers_from_live(state), AppType::Hermes => Self::import_hermes_providers_from_live(state), + AppType::Kimi => live::import_kimi_providers_from_live(state), AppType::Pi => Self::import_pi_providers_from_live(state), _ => Self::import_default_config(state, app_type).map(usize::from), } @@ -2760,11 +2818,15 @@ impl ProviderService { Self::switch(state, AppType::Hermes, provider_id)?; Ok(provider_id.to_string()) } + AppType::Kimi => { + Self::switch(state, AppType::Kimi, provider_id)?; + Ok(provider_id.to_string()) + } AppType::OpenClaw => Self::set_openclaw_default_model(provider_id, model_id), _ => Err(AppError::localized( "provider.set_default_model.unsupported", - "只有 Hermes 和 OpenClaw 支持设置默认供应商/模型", - "Only Hermes and OpenClaw support setting a default provider/model", + "只有 Hermes, OpenClaw 和 Kimi 支持设置默认供应商/模型", + "Only Hermes, OpenClaw, and Kimi support setting a default provider/model", )), } } @@ -2849,6 +2911,16 @@ impl ProviderService { "Cannot remove the current default Hermes provider from config", )) } + AppType::Kimi + if crate::kimi_config::get_current_provider_id()?.as_deref() + == Some(provider_id) => + { + Err(AppError::localized( + "provider.remove_from_config.kimi_current", + "不能从配置中移除 Kimi 当前默认供应商", + "Cannot remove the current default Kimi provider from config", + )) + } AppType::OpenClaw if Self::openclaw_default_model_references_provider(provider_id)? => { Err(AppError::localized( "provider.remove_from_config.openclaw_default", @@ -3009,7 +3081,7 @@ impl ProviderService { takeover_active: false, refresh_stale_backup: false, sync_proxy_live: false, - activate_provider: matches!(app_type, AppType::Hermes), + activate_provider: matches!(app_type, AppType::Hermes | AppType::Kimi), }); } @@ -3035,6 +3107,7 @@ impl ProviderService { AppType::OpenCode => unreachable!("additive mode handled above"), AppType::Hermes => unreachable!("additive mode handled above"), AppType::OpenClaw => unreachable!("additive mode handled above"), + AppType::Kimi => unreachable!("additive mode handled above"), AppType::Pi => unreachable!("Pi switch is handled by the native provider service"), }; @@ -3239,6 +3312,20 @@ impl ProviderService { )?; Ok(PreparedLiveWrite::Hermes { providers }) } + AppType::Kimi => { + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.kimi.settings.not_object", + "Kimi 配置必须是 JSON 对象", + "Kimi configuration must be a JSON object", + )); + } + let config = crate::kimi_config::prepare_provider( + &provider.id, + provider.settings_config.clone(), + )?; + Ok(PreparedLiveWrite::Kimi { config }) + } AppType::OpenClaw => { let settings_config = provider.settings_config.clone(); let looks_like_provider = settings_config.get("baseUrl").is_some() @@ -3272,6 +3359,9 @@ impl ProviderService { PreparedLiveWrite::Hermes { providers } => { crate::hermes_config::write_prepared_providers(providers).map(|_| ()) } + PreparedLiveWrite::Kimi { config } => { + crate::kimi_config::write_prepared_config(&config).map(|_| ()) + } PreparedLiveWrite::OpenClaw { models } => { crate::openclaw_config::write_prepared_models(models) .map(|_| ()) @@ -3496,6 +3586,9 @@ impl ProviderService { AppType::Pi => Err(AppError::Config( "Pi does not support proxy takeover backups".into(), )), + AppType::Kimi => Err(AppError::Config( + "Kimi does not support proxy takeover backups".into(), + )), } } @@ -3586,6 +3679,15 @@ impl ProviderService { )); } } + AppType::Kimi => { + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.kimi.settings.not_object", + "Kimi 配置必须是 JSON 对象", + "Kimi configuration must be a JSON object", + )); + } + } AppType::OpenClaw => { let config = Self::parse_openclaw_provider_settings(&provider.settings_config)?; Self::validate_openclaw_provider_models(&provider.id, &config)?; @@ -3752,6 +3854,11 @@ impl ProviderService { crate::openclaw_config::remove_provider(provider_id)?; } } + AppType::Kimi => { + if crate::kimi_config::get_kimi_config_dir().exists() { + crate::kimi_config::remove_provider(provider_id)?; + } + } _ => unreachable!("non-additive apps should not enter additive delete branch"), } @@ -3793,6 +3900,9 @@ impl ProviderService { AppType::OpenClaw => { let _ = provider_snapshot; } + AppType::Kimi => { + let _ = provider_snapshot; + } AppType::Pi => unreachable!("Pi deletion is handled by the native provider service"), } diff --git a/src-tauri/src/services/provider/tests.rs b/src-tauri/src/services/provider/tests.rs index 175b5f044..8de899178 100644 --- a/src-tauri/src/services/provider/tests.rs +++ b/src-tauri/src/services/provider/tests.rs @@ -427,6 +427,7 @@ fn setup_switched_codex_state_with_managed_mcp() -> (TempDir, EnvGuard, AppState gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index 9442eaf9d..c92a3c6f9 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -482,6 +482,19 @@ impl ProviderService { .and_then(Value::as_str) .ok_or_else(|| AppError::InvalidInput("Pi provider API key is missing".to_string())) .map(str::to_string), + AppType::Kimi => provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + AppError::localized( + "provider.kimi.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + }) + .map(|s| s.to_string()), } } @@ -562,6 +575,15 @@ impl ProviderService { .and_then(|v| v.as_str()) .unwrap_or_default() .to_string()), + AppType::Kimi => Ok(provider + .settings_config + .get("baseUrl") + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("base_url")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string()), AppType::Pi => crate::pi_config::provider_base_url(&provider.settings_config), } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 700c4f00e..5367db941 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1755,7 +1755,8 @@ impl ProxyService { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => {} + | AppType::Pi + | AppType::Kimi => {} } if cached != original { self.save_failover_live_snapshot(app_type, &provider.id, &cached) @@ -2588,7 +2589,7 @@ impl ProxyService { (None, _) => Ok(incoming_snapshot), } } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { Ok(backup_snapshot) } } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index cb3ac4e49..6c4d7c62d 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -640,6 +640,7 @@ impl SkillService { AppType::OpenCode, AppType::Hermes, AppType::Pi, + AppType::Kimi, ] .into_iter() } @@ -724,6 +725,7 @@ impl SkillService { AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir().join("skills"), AppType::Pi => crate::pi_config::get_pi_agent_dir()?.join("skills"), + AppType::Kimi => crate::kimi_config::get_kimi_skills_dir(), }) } diff --git a/src-tauri/src/services/stream_check/provider_extract.rs b/src-tauri/src/services/stream_check/provider_extract.rs index d07986cc5..53f2517bc 100644 --- a/src-tauri/src/services/stream_check/provider_extract.rs +++ b/src-tauri/src/services/stream_check/provider_extract.rs @@ -21,6 +21,7 @@ impl StreamCheckService { } AppType::Hermes => Self::extract_hermes_base_url(provider), AppType::OpenClaw => Self::extract_openclaw_base_url(provider), + AppType::Kimi => Self::extract_kimi_base_url(provider), AppType::Pi => crate::pi_config::provider_base_url(&provider.settings_config), AppType::Claude | AppType::Codex | AppType::Gemini => get_adapter(app_type) .expect("proxy-capable app must have an adapter") @@ -97,6 +98,25 @@ impl StreamCheckService { }) } + fn extract_kimi_base_url(provider: &Provider) -> Result { + provider + .settings_config + .get("base_url") + .or_else(|| provider.settings_config.get("baseUrl")) + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|value| value.as_str()) + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::localized( + "kimi_base_url_missing", + "Kimi 供应商缺少 base_url", + "Kimi provider is missing `base_url`", + ) + }) + } + fn extract_openclaw_base_url(provider: &Provider) -> Result { provider .settings_config diff --git a/src-tauri/src/services/visible_apps.rs b/src-tauri/src/services/visible_apps.rs index cb2c4f99e..e84c36734 100644 --- a/src-tauri/src/services/visible_apps.rs +++ b/src-tauri/src/services/visible_apps.rs @@ -34,12 +34,13 @@ pub struct VisibleAppsStartupOutcome { pub should_prompt: bool, } -const CONTROLLED_APPS: [AppType; 5] = [ +const CONTROLLED_APPS: [AppType; 6] = [ AppType::Gemini, AppType::OpenCode, AppType::Hermes, AppType::OpenClaw, AppType::Pi, + AppType::Kimi, ]; pub fn detect_visible_app_installation() -> VisibleAppsDetection { @@ -191,6 +192,7 @@ pub fn app_display_name(app: &AppType) -> &'static str { AppType::Hermes => "Hermes", AppType::OpenClaw => "OpenClaw", AppType::Pi => "Pi", + AppType::Kimi => "Kimi", } } @@ -322,6 +324,7 @@ mod tests { hermes: true, openclaw: false, pi: false, + kimi: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Auto, @@ -365,6 +368,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Manual, @@ -433,6 +437,7 @@ mod tests { hermes: true, openclaw: true, pi: false, + kimi: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Auto, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 06fda8899..38de1d2d6 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -24,6 +24,8 @@ pub struct VisibleApps { pub openclaw: bool, #[serde(default = "default_visible_app_pi")] pub pi: bool, + #[serde(default = "default_visible_app_kimi")] + pub kimi: bool, } fn default_visible_app_claude() -> bool { @@ -54,6 +56,10 @@ fn default_visible_app_pi() -> bool { true } +fn default_visible_app_kimi() -> bool { + true +} + pub fn default_visible_apps() -> VisibleApps { VisibleApps { claude: true, @@ -63,6 +69,7 @@ pub fn default_visible_apps() -> VisibleApps { hermes: true, openclaw: true, pi: true, + kimi: true, } } @@ -130,6 +137,7 @@ impl VisibleApps { AppType::Hermes => self.hermes, AppType::OpenClaw => self.openclaw, AppType::Pi => self.pi, + AppType::Kimi => self.kimi, } } @@ -142,6 +150,7 @@ impl VisibleApps { AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => self.openclaw = enabled, AppType::Pi => self.pi = enabled, + AppType::Kimi => self.kimi = enabled, } } @@ -162,7 +171,7 @@ impl VisibleApps { } } -fn app_order() -> [AppType; 7] { +fn app_order() -> [AppType; 8] { [ AppType::Claude, AppType::Codex, @@ -171,6 +180,7 @@ fn app_order() -> [AppType; 7] { AppType::Hermes, AppType::OpenClaw, AppType::Pi, + AppType::Kimi, ] } @@ -543,6 +553,8 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub pi_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub kimi_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_claude: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_codex: Option, @@ -554,6 +566,8 @@ pub struct AppSettings { pub current_provider_hermes: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_openclaw: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_kimi: Option, #[serde(default = "default_visible_apps")] pub visible_apps: VisibleApps, #[serde(default = "migrated_visible_apps_settings")] @@ -645,12 +659,14 @@ impl Default for AppSettings { hermes_config_dir: None, openclaw_config_dir: None, pi_config_dir: None, + kimi_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, current_provider_opencode: None, current_provider_hermes: None, current_provider_openclaw: None, + current_provider_kimi: None, visible_apps: default_visible_apps(), visible_apps_settings: VisibleAppsSettings::default(), language: None, @@ -733,6 +749,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.kimi_config_dir = self + .kimi_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -1132,6 +1155,14 @@ pub fn get_pi_override_dir() -> Option { .map(|path| resolve_override_path(path)) } +pub fn get_kimi_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .kimi_config_dir + .as_ref() + .map(|path| resolve_override_path(path)) +} + pub fn get_current_provider(app_type: &AppType) -> Option { let settings = settings_store().read().ok()?; match app_type { @@ -1142,6 +1173,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Hermes => settings.current_provider_hermes.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), AppType::Pi => None, + AppType::Kimi => settings.current_provider_kimi.clone(), } } @@ -1156,6 +1188,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Hermes => settings.current_provider_hermes = id.map(|value| value.to_string()), AppType::OpenClaw => settings.current_provider_openclaw = id.map(|value| value.to_string()), AppType::Pi => {} + AppType::Kimi => settings.current_provider_kimi = id.map(|value| value.to_string()), } update_settings(settings) diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index c06acd490..c5d5eabe7 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -533,6 +533,7 @@ fn export_db_to_multi_app_config(db: &Database) -> Result Result config.prompts.hermes.prompts = prompts.into_iter().collect(), AppType::OpenClaw => config.prompts.openclaw.prompts = prompts.into_iter().collect(), AppType::Pi => config.prompts.pi.prompts = prompts.into_iter().collect(), + AppType::Kimi => config.prompts.kimi.prompts = prompts.into_iter().collect(), } // common snippet @@ -587,6 +589,7 @@ fn persist_multi_app_config_to_db_preserving_current_providers( AppType::OpenCode, AppType::Hermes, AppType::OpenClaw, + AppType::Kimi, ] { let app_key = app.as_str(); let manager = config.get_manager(&app); diff --git a/src-tauri/src/sync_policy.rs b/src-tauri/src/sync_policy.rs index 490518660..c8756d657 100644 --- a/src-tauri/src/sync_policy.rs +++ b/src-tauri/src/sync_policy.rs @@ -26,6 +26,11 @@ pub(crate) fn should_sync_live(app_type: &AppType) -> bool { AppType::OpenClaw => get_openclaw_dir().exists(), // Pi live provider writes are owned by the revision-aware native service. AppType::Pi => false, + // Kimi is considered initialized if ~/.kimi-code (or override dir) exists or config.toml exists. + AppType::Kimi => { + crate::kimi_config::get_kimi_config_dir().exists() + || crate::kimi_config::get_kimi_config_path().exists() + } } } diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index c0a057c1c..f3f9a624c 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -827,6 +827,7 @@ command = "echo" gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -956,6 +957,7 @@ fn import_from_claude_merges_into_config() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index bf9b31358..c0d3f5248 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -331,6 +331,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -419,6 +420,7 @@ fn set_mcp_enabled_for_codex_writes_remote_headers_once_as_http_headers() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -554,6 +556,7 @@ fn upsert_server_skips_live_sync_when_gemini_uninitialized() { gemini: true, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -623,6 +626,7 @@ fn upsert_server_disables_app_removes_from_gemini_live() { gemini: true, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -647,6 +651,7 @@ fn upsert_server_disables_app_removes_from_gemini_live() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -710,6 +715,7 @@ fn sync_all_enabled_removes_disabled_gemini_server_from_live_config() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/openclaw_config.rs b/src-tauri/tests/openclaw_config.rs index 9d9d5ca55..9f554db3e 100644 --- a/src-tauri/tests/openclaw_config.rs +++ b/src-tauri/tests/openclaw_config.rs @@ -224,6 +224,8 @@ mod app_config { OpenCode, Hermes, OpenClaw, + Pi, + Kimi, } impl AppType { @@ -235,6 +237,8 @@ mod app_config { Self::OpenCode => "opencode", Self::Hermes => "hermes", Self::OpenClaw => "openclaw", + Self::Pi => "pi", + Self::Kimi => "kimi", } } } diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 84b59176b..030bbbfba 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -1431,6 +1431,7 @@ command = "echo" gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index dee26abe4..bc988b280 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -99,6 +99,7 @@ fn insert_codex_managed_mcp(config: &mut MultiAppConfig) { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -313,6 +314,7 @@ command = "echo" gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -4737,6 +4739,7 @@ fn provider_service_switch_openclaw_ignores_unrelated_mcp_sync_failures() { gemini: false, opencode: true, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/settings_current_provider.rs b/src-tauri/tests/settings_current_provider.rs index ceb71bd89..2ef66de6e 100644 --- a/src-tauri/tests/settings_current_provider.rs +++ b/src-tauri/tests/settings_current_provider.rs @@ -14,6 +14,7 @@ mod app_config { OpenClaw, Hermes, Pi, + Kimi, } impl AppType { @@ -26,6 +27,7 @@ mod app_config { AppType::OpenClaw => "openclaw", AppType::Hermes => "hermes", AppType::Pi => "pi", + AppType::Kimi => "kimi", } } } diff --git a/src-tauri/tests/settings_visible_apps.rs b/src-tauri/tests/settings_visible_apps.rs index 30b18c5f7..c2bc198e3 100644 --- a/src-tauri/tests/settings_visible_apps.rs +++ b/src-tauri/tests/settings_visible_apps.rs @@ -17,6 +17,7 @@ mod app_config { OpenClaw, Hermes, Pi, + Kimi, } impl AppType { @@ -29,6 +30,7 @@ mod app_config { AppType::OpenClaw => "openclaw", AppType::Hermes => "hermes", AppType::Pi => "pi", + AppType::Kimi => "kimi", } } } @@ -414,6 +416,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { opencode: false, openclaw: true, pi: false, + kimi: false, hermes: true, }) .expect("persist visible apps"); @@ -467,6 +470,7 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { opencode: true, openclaw: false, pi: true, + kimi: true, hermes: true, } ); @@ -506,6 +510,7 @@ fn load_partial_visible_apps_object_uses_defaults_for_missing_keys() { opencode: true, openclaw: true, pi: true, + kimi: true, hermes: true, } ); @@ -582,6 +587,7 @@ fn set_visible_apps_rejects_zero_selection() { opencode: false, openclaw: false, pi: false, + kimi: false, hermes: false, }) .expect_err("zero visible apps should be rejected"); @@ -605,6 +611,7 @@ fn update_settings_rejects_all_false_visible_apps() { opencode: false, openclaw: false, pi: false, + kimi: false, hermes: false, }, ..Default::default() @@ -702,6 +709,7 @@ fn next_visible_app_wraps_and_skips_hidden_entries() { opencode: true, openclaw: true, pi: false, + kimi: false, hermes: true, }; From 4876a2982bf3126f2fcd74bf243dcba69409c406 Mon Sep 17 00:00:00 2001 From: Ferris Date: Thu, 24 Sep 2026 03:33:03 +0800 Subject: [PATCH 10/13] fix(kimi): eliminate recursive mutex deadlock on config write set_current_provider and set_default_model acquired kimi_write_lock and then invoked write_prepared_config, which re-acquired the same non-reentrant mutex. Replace with direct write_file_atomic while holding the lock, eliminating the self-deadlock. Signed-off-by: Ferris --- src-tauri/src/kimi_config.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/kimi_config.rs b/src-tauri/src/kimi_config.rs index b0dea1320..7b418dd54 100644 --- a/src-tauri/src/kimi_config.rs +++ b/src-tauri/src/kimi_config.rs @@ -477,7 +477,7 @@ pub fn set_current_provider(id: &str, provider_config: &Value) -> Result<(), App } doc["default_model"] = toml_edit::value(&target_model); - write_prepared_config(&doc.to_string()) + write_file_atomic(&path, &doc.to_string(), 0o644).map_err(|e| AppError::Message(e.to_string())) } /// 设置默认模型 @@ -499,7 +499,7 @@ pub fn set_default_model(model_name: &str) -> Result { .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))? }; doc["default_model"] = toml_edit::value(model_name); - write_prepared_config(&doc.to_string())?; + write_file_atomic(&path, &doc.to_string(), 0o644).map_err(|e| AppError::Message(e.to_string()))?; Ok(model_name.to_string()) } From 2126f66195cc5920c4b0b898c7adf009802b3f9b Mon Sep 17 00:00:00 2001 From: Ferris Date: Thu, 24 Sep 2026 04:08:09 +0800 Subject: [PATCH 11/13] fix(kimi): update local tool specs test to include Kimi Signed-off-by: Ferris --- src-tauri/src/services/local_env_check.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/services/local_env_check.rs b/src-tauri/src/services/local_env_check.rs index b6591cb80..c3c116e1d 100644 --- a/src-tauri/src/services/local_env_check.rs +++ b/src-tauri/src/services/local_env_check.rs @@ -865,8 +865,11 @@ mod tests { assert_eq!( display_names, - vec!["Claude", "Codex", "Gemini", "OpenCode", "Hermes", "OpenClaw", "Pi"] + vec![ + "Claude", "Codex", "Gemini", "OpenCode", "Hermes", "OpenClaw", "Pi", "Kimi" + ] ); + assert_eq!(LocalTool::Kimi.binary_name(), "kimi"); assert_eq!(LocalTool::Hermes.binary_name(), "hermes"); assert_eq!(LocalTool::OpenClaw.binary_name(), "openclaw"); assert_eq!(LocalTool::Pi.binary_name(), "pi"); From 8e623fad5ae47bc85ee99a22a2cc840b584f3459 Mon Sep 17 00:00:00 2001 From: Ferris Date: Thu, 24 Sep 2026 14:56:36 +0800 Subject: [PATCH 12/13] refactor(kimi): streamline Kimi Code integration to minimal thin adapter - Remove redundant RFC 8628 OAuth device flow implementation and service layer - Strip custom 'config kimi profile' directory snapshot mechanism and quota crawler - Simplify kimi_config.rs to thin adapter managing config.toml and mcp.json - Align with Pi/Hermes additive application model and preserve clean concurrency locks Signed-off-by: Ferris --- src-tauri/src/cli/commands/auth.rs | 96 +- src-tauri/src/cli/commands/config.rs | 6 - src-tauri/src/cli/commands/config_kimi.rs | 533 ---------- src-tauri/src/cli/commands/mod.rs | 1 - src-tauri/src/cli/mod.rs | 52 +- src-tauri/src/kimi_config.rs | 652 +----------- .../src/proxy/providers/kimi_oauth_auth.rs | 960 ------------------ src-tauri/src/proxy/providers/mod.rs | 1 - src-tauri/src/services/auth.rs | 157 +-- src-tauri/src/services/kimi_oauth.rs | 173 ---- src-tauri/src/services/mod.rs | 2 - 11 files changed, 78 insertions(+), 2555 deletions(-) delete mode 100644 src-tauri/src/cli/commands/config_kimi.rs delete mode 100644 src-tauri/src/proxy/providers/kimi_oauth_auth.rs delete mode 100644 src-tauri/src/services/kimi_oauth.rs diff --git a/src-tauri/src/cli/commands/auth.rs b/src-tauri/src/cli/commands/auth.rs index ed5ee99d9..62913fd6d 100644 --- a/src-tauri/src/cli/commands/auth.rs +++ b/src-tauri/src/cli/commands/auth.rs @@ -6,59 +6,43 @@ use crate::cli::ui::{create_table, info, success, to_json}; use crate::error::AppError; use crate::services::{AuthService, ManagedAuthAccount, ManagedAuthDeviceCodeResponse}; +const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth"; + #[derive(Subcommand, Debug, Clone)] pub enum AuthCommand { /// Show ChatGPT Codex OAuth authentication status Status { - /// Auth provider (codex_oauth, kimi_oauth) - #[arg(long, default_value = "codex_oauth")] - provider: String, /// Print machine-readable JSON #[arg(long)] json: bool, }, - /// List signed-in accounts + /// List signed-in ChatGPT accounts List { - /// Auth provider (codex_oauth, kimi_oauth) - #[arg(long, default_value = "codex_oauth")] - provider: String, /// Print machine-readable JSON #[arg(long)] json: bool, }, - /// Sign in with device flow + /// Sign in to ChatGPT with the Codex OAuth device flow Login { - /// Auth provider (codex_oauth, kimi_oauth) - #[arg(long, default_value = "codex_oauth")] - provider: String, /// Print machine-readable JSON #[arg(long)] json: bool, }, - /// Set the default account + /// Set the default ChatGPT account Default { /// Account id to make default account_id: String, - /// Auth provider (codex_oauth, kimi_oauth) - #[arg(long, default_value = "codex_oauth")] - provider: String, }, - /// Remove an account + /// Remove a ChatGPT account Remove { /// Account id to remove account_id: String, - /// Auth provider (codex_oauth, kimi_oauth) - #[arg(long, default_value = "codex_oauth")] - provider: String, /// Confirm removal without prompting #[arg(long)] yes: bool, }, - /// Remove all authentication data for provider + /// Remove all ChatGPT Codex OAuth authentication data Logout { - /// Auth provider (codex_oauth, kimi_oauth) - #[arg(long, default_value = "codex_oauth")] - provider: String, /// Confirm logout without prompting #[arg(long)] yes: bool, @@ -76,19 +60,12 @@ pub fn execute(cmd: AuthCommand) -> Result<(), AppError> { crate::services::global_proxy::initialize_http_client_from_disk_best_effort(); let runtime = create_runtime()?; match cmd { - AuthCommand::Status { provider, json } => status(&runtime, &provider, json), - AuthCommand::List { provider, json } => list_accounts(&runtime, &provider, json), - AuthCommand::Login { provider, json } => login(&runtime, &provider, json), - AuthCommand::Default { - account_id, - provider, - } => set_default(&runtime, &provider, &account_id), - AuthCommand::Remove { - account_id, - provider, - yes, - } => remove_account(&runtime, &provider, &account_id, yes), - AuthCommand::Logout { provider, yes } => logout(&runtime, &provider, yes), + AuthCommand::Status { json } => status(&runtime, json), + AuthCommand::List { json } => list_accounts(&runtime, json), + AuthCommand::Login { json } => login(&runtime, json), + AuthCommand::Default { account_id } => set_default(&runtime, &account_id), + AuthCommand::Remove { account_id, yes } => remove_account(&runtime, &account_id, yes), + AuthCommand::Logout { yes } => logout(&runtime, yes), } } @@ -99,9 +76,9 @@ fn create_runtime() -> Result { .map_err(|error| AppError::Message(format!("failed to create async runtime: {error}"))) } -fn status(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) -> Result<(), AppError> { +fn status(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> { let status = runtime - .block_on(AuthService::get_status(provider)) + .block_on(AuthService::get_status(AUTH_PROVIDER_CODEX_OAUTH)) .map_err(AppError::Message)?; if json { @@ -112,12 +89,7 @@ fn status(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) -> Resu return Ok(()); } - let display_name = match provider { - "kimi_oauth" | "kimi" | "kimi-code" => "Moonshot AI Kimi (Device OAuth)", - _ => "ChatGPT (Codex OAuth)", - }; - - println!("Provider: {display_name}"); + println!("Provider: ChatGPT (Codex OAuth)"); println!( "Authenticated: {}", if status.authenticated { "yes" } else { "no" } @@ -143,9 +115,9 @@ fn status(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) -> Resu Ok(()) } -fn list_accounts(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) -> Result<(), AppError> { +fn list_accounts(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> { let accounts = runtime - .block_on(AuthService::list_accounts(provider)) + .block_on(AuthService::list_accounts(AUTH_PROVIDER_CODEX_OAUTH)) .map_err(AppError::Message)?; if json { @@ -157,7 +129,7 @@ fn list_accounts(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) } if accounts.is_empty() { - println!("{}", info(&format!("No accounts are signed in for {provider}."))); + println!("{}", info("No ChatGPT accounts are signed in.")); return Ok(()); } @@ -165,9 +137,9 @@ fn list_accounts(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) Ok(()) } -fn login(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) -> Result<(), AppError> { +fn login(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> { let device = runtime - .block_on(AuthService::start_login(provider)) + .block_on(AuthService::start_login(AUTH_PROVIDER_CODEX_OAUTH)) .map_err(AppError::Message)?; if json { @@ -181,7 +153,7 @@ fn login(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) -> Resul println!("{}", info("Waiting for authorization...")); } - let account = poll_until_authorized(runtime, provider, &device)?; + let account = poll_until_authorized(runtime, &device)?; if json { let completed = LoginCompleted { device, account }; @@ -201,7 +173,6 @@ fn login(runtime: &tokio::runtime::Runtime, provider: &str, json: bool) -> Resul fn poll_until_authorized( runtime: &tokio::runtime::Runtime, - provider: &str, device: &ManagedAuthDeviceCodeResponse, ) -> Result { let expires_at = Instant::now() + Duration::from_secs(device.expires_in); @@ -210,7 +181,7 @@ fn poll_until_authorized( loop { match runtime .block_on(AuthService::poll_for_account( - provider, + AUTH_PROVIDER_CODEX_OAUTH, &device.device_code, )) .map_err(AppError::Message)? @@ -234,52 +205,51 @@ fn poll_interval_seconds(server_interval: u64) -> u64 { server_interval.max(1) } -fn set_default(runtime: &tokio::runtime::Runtime, provider: &str, account_id: &str) -> Result<(), AppError> { +fn set_default(runtime: &tokio::runtime::Runtime, account_id: &str) -> Result<(), AppError> { let account_id = normalize_account_id(account_id)?; runtime .block_on(AuthService::set_default_account( - provider, + AUTH_PROVIDER_CODEX_OAUTH, account_id, )) .map_err(AppError::Message)?; - println!("{}", success(&format!("Default account updated for {provider}."))); + println!("{}", success("Default ChatGPT account updated.")); Ok(()) } fn remove_account( runtime: &tokio::runtime::Runtime, - provider: &str, account_id: &str, yes: bool, ) -> Result<(), AppError> { let account_id = normalize_account_id(account_id)?; - if !yes && !confirm(&format!("Remove account '{account_id}' from {provider}?"))? { + if !yes && !confirm(&format!("Remove ChatGPT account '{account_id}'?"))? { println!("{}", info("Cancelled.")); return Ok(()); } runtime .block_on(AuthService::remove_account( - provider, + AUTH_PROVIDER_CODEX_OAUTH, account_id, )) .map_err(AppError::Message)?; - println!("{}", success(&format!("Account removed from {provider}."))); + println!("{}", success("ChatGPT account removed.")); Ok(()) } -fn logout(runtime: &tokio::runtime::Runtime, provider: &str, yes: bool) -> Result<(), AppError> { - if !yes && !confirm(&format!("Remove all authentication data for {provider}?"))? { +fn logout(runtime: &tokio::runtime::Runtime, yes: bool) -> Result<(), AppError> { + if !yes && !confirm("Remove all ChatGPT Codex OAuth authentication data?")? { println!("{}", info("Cancelled.")); return Ok(()); } runtime - .block_on(AuthService::logout(provider)) + .block_on(AuthService::logout(AUTH_PROVIDER_CODEX_OAUTH)) .map_err(AppError::Message)?; println!( "{}", - success(&format!("Authentication data removed for {provider}.")) + success("ChatGPT Codex OAuth authentication data removed.") ); Ok(()) } diff --git a/src-tauri/src/cli/commands/config.rs b/src-tauri/src/cli/commands/config.rs index 92e9fc14f..0674d3abd 100644 --- a/src-tauri/src/cli/commands/config.rs +++ b/src-tauri/src/cli/commands/config.rs @@ -4,7 +4,6 @@ use std::path::{Path, PathBuf}; use crate::app_config::AppType; use crate::cli::commands::config_common; -use crate::cli::commands::config_kimi; use crate::cli::commands::config_openclaw; use crate::cli::commands::config_s3; use crate::cli::commands::config_webdav; @@ -59,10 +58,6 @@ pub enum ConfigCommand { #[command(name = "openclaw", subcommand)] OpenClaw(config_openclaw::OpenClawCommand), - /// Manage Moonshot Kimi Code directory configuration and profiles - #[command(name = "kimi", subcommand)] - Kimi(config_kimi::KimiConfigCommand), - /// Manage WebDAV sync settings and operations #[command(name = "webdav", subcommand)] WebDav(config_webdav::WebDavCommand), @@ -86,7 +81,6 @@ pub fn execute(cmd: ConfigCommand, app: Option) -> Result<(), AppError> ConfigCommand::Reset => reset_config(), ConfigCommand::Common(cmd) => config_common::execute(cmd, app.unwrap_or(AppType::Claude)), ConfigCommand::OpenClaw(cmd) => config_openclaw::execute(cmd), - ConfigCommand::Kimi(cmd) => config_kimi::execute(cmd), ConfigCommand::WebDav(cmd) => config_webdav::execute(cmd), ConfigCommand::S3(cmd) => config_s3::execute(cmd), } diff --git a/src-tauri/src/cli/commands/config_kimi.rs b/src-tauri/src/cli/commands/config_kimi.rs deleted file mode 100644 index 7502f5957..000000000 --- a/src-tauri/src/cli/commands/config_kimi.rs +++ /dev/null @@ -1,533 +0,0 @@ -use clap::Subcommand; -use std::path::PathBuf; - -use crate::cli::ui::{create_table, info, success, to_json}; -use crate::error::AppError; -use crate::kimi_config::{ - get_active_profile_name, get_kimi_config_dir, - get_kimi_profiles_dir, list_profiles, read_native_credentials, remove_profile, save_profile, - switch_profile, KIMI_CONFIG_FILE, KIMI_CREDENTIALS_DIR, KIMI_DEFAULT_CREDENTIAL_FILE, -}; - -#[derive(Subcommand, Debug, Clone)] -pub enum KimiConfigCommand { - /// Show Kimi Code configuration and credential paths - Path { - /// Print machine-readable JSON - #[arg(long)] - json: bool, - }, - /// Show current Kimi Code configuration and account status (active profile, specified profile, or all) - Status { - /// Optional profile name to inspect (defaults to currently active profile) - profile: Option, - /// Inspect status of all saved profiles - #[arg(long)] - all: bool, - /// Print machine-readable JSON - #[arg(long)] - json: bool, - }, - /// Manage Kimi Code configuration profiles (work, personal, etc.) - #[command(subcommand)] - Profile(KimiProfileCommand), -} - -#[derive(Subcommand, Debug, Clone)] -pub enum KimiProfileCommand { - /// List all saved Kimi Code profiles - List { - /// Query and show live 5-hour quota and reset countdown for each profile - #[arg(short = 'q', long)] - quota: bool, - /// Print machine-readable JSON - #[arg(long)] - json: bool, - }, - /// Save current Kimi Code directory configuration as a named profile - Save { - /// Profile name (e.g. work, personal) - name: String, - }, - /// Switch active Kimi Code directory configuration to a named profile - Switch { - /// Profile name to activate - name: String, - }, - /// Remove a saved Kimi Code profile - Remove { - /// Profile name to remove - name: String, - /// Confirm removal without prompting - #[arg(long)] - yes: bool, - }, -} - -#[derive(serde::Serialize)] -struct KimiPathInfo { - home_dir: PathBuf, - config_file: PathBuf, - credentials_file: PathBuf, - profiles_dir: PathBuf, - active_profile: Option, -} - -#[derive(serde::Serialize)] -struct KimiStatusInfo { - home_dir: PathBuf, - active_profile: Option, - #[serde(skip_serializing_if = "Option::is_none")] - account: Option, - has_credentials: bool, - token_expires_at: Option, - profiles_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - usages: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -pub fn execute(cmd: KimiConfigCommand) -> Result<(), AppError> { - match cmd { - KimiConfigCommand::Path { json } => show_path(json), - KimiConfigCommand::Status { profile, all, json } => show_status(profile, all, json), - KimiConfigCommand::Profile(profile_cmd) => execute_profile(profile_cmd), - } -} - -fn show_path(json: bool) -> Result<(), AppError> { - let home = get_kimi_config_dir(); - let info_obj = KimiPathInfo { - home_dir: home.clone(), - config_file: home.join(KIMI_CONFIG_FILE), - credentials_file: home.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE), - profiles_dir: get_kimi_profiles_dir(), - active_profile: get_active_profile_name(), - }; - - if json { - println!("{}", to_json(&info_obj).map_err(|e| AppError::JsonSerialize { source: e })?); - return Ok(()); - } - - println!("Kimi Code Home: {}", info_obj.home_dir.display()); - println!("Config File: {}", info_obj.config_file.display()); - println!("Credentials File: {}", info_obj.credentials_file.display()); - println!("Profiles Directory: {}", info_obj.profiles_dir.display()); - println!( - "Active Profile: {}", - info_obj.active_profile.as_deref().unwrap_or("-") - ); - - Ok(()) -} - -fn print_status_info(info: &KimiStatusInfo) { - println!("Profile: {}", info.active_profile.as_deref().unwrap_or("(default / unmanaged)")); - if let Some(ref acc) = info.account { - println!("Account: {}", acc); - } - println!("Directory: {}", info.home_dir.display()); - println!( - "Credentials: {}", - if info.has_credentials { "Present" } else { "None" } - ); - if let Some(exp) = info.token_expires_at { - let dt = chrono::DateTime::from_timestamp(exp, 0) - .map(|d| d.to_rfc3339()) - .unwrap_or_else(|| exp.to_string()); - println!("Token Expires At: {}", dt); - } - if let Some(ref err) = info.error { - println!("Error: {}", err); - } - - if let Some(u) = &info.usages { - if let Some(q) = &u.usages { - let now = chrono::Utc::now(); - println!(); - println!("Usage & Rate Limits:"); - - let five_hour_detail = u.limits.iter().find(|l| { - l.window.as_ref().and_then(|w| w.duration) == Some(300) - }).and_then(|l| l.detail.as_ref()); - - if let Some(item) = &q.limit_5h { - let ratio = item.used_ratio.unwrap_or(0.0); - let pct = ratio * 100.0; - let status_tag = if ratio >= 1.0 { - " [EXCEEDED / 5小时额度已耗尽]" - } else { - "" - }; - - let mut extras = Vec::new(); - if !status_tag.is_empty() { - extras.push(status_tag.trim().to_string()); - } - if let Some(d) = five_hour_detail { - let used_count = d.used.as_deref().unwrap_or("0"); - if let (Some(l), Some(r)) = (&d.limit, &d.remaining) { - extras.push(format!("[{}/{}, remaining: {}]", used_count, l, r)); - } else if let Some(l) = &d.limit { - extras.push(format!("[{}/{}]", used_count, l)); - } - } - - let extras_str = if extras.is_empty() { - String::new() - } else { - format!(" {}", extras.join(" ")) - }; - - let reset_str = if let Some(reset_info) = crate::cli::provider_quota::quota_reset_display(item.reset_time.as_deref(), now) { - let local_time = reset_info.at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S"); - let countdown = reset_info.remaining.map(|r| format!("in {r}")).unwrap_or_else(|| "soon".to_string()); - format!("resets {} at {}", countdown, local_time) - } else { - format!("reset: {}", item.reset_time.as_deref().unwrap_or("-")) - }; - - println!(" 5-Hour Limit: {:.1}% used{} ({})", pct, extras_str, reset_str); - } - if let Some(item) = &q.limit_7d { - let pct = item.used_ratio.unwrap_or(0.0) * 100.0; - let reset_str = if let Some(reset_info) = crate::cli::provider_quota::quota_reset_display(item.reset_time.as_deref(), now) { - let local_time = reset_info.at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S"); - let countdown = reset_info.remaining.map(|r| format!("in {r}")).unwrap_or_else(|| "soon".to_string()); - format!("resets {} at {}", countdown, local_time) - } else { - format!("reset: {}", item.reset_time.as_deref().unwrap_or("-")) - }; - println!(" 7-Day Limit: {:.1}% used ({})", pct, reset_str); - } - if let Some(item) = &q.limit_month_total { - let pct = item.used_ratio.unwrap_or(0.0) * 100.0; - let reset_str = if let Some(reset_info) = crate::cli::provider_quota::quota_reset_display(item.reset_time.as_deref(), now) { - let local_time = reset_info.at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S"); - let countdown = reset_info.remaining.map(|r| format!("in {r}")).unwrap_or_else(|| "soon".to_string()); - format!("resets {} at {}", countdown, local_time) - } else { - format!("reset: {}", item.reset_time.as_deref().unwrap_or("-")) - }; - println!(" Monthly Total: {:.1}% used ({})", pct, reset_str); - } - } - } -} - -fn show_status(profile: Option, all: bool, json: bool) -> Result<(), AppError> { - let rt = tokio::runtime::Runtime::new() - .map_err(|e| AppError::Message(format!("无法创建 Tokio 运行时: {e}")))?; - - if all { - let items = rt.block_on(crate::kimi_config::fetch_all_profiles_quota()); - if json { - println!("{}", to_json(&items).map_err(|e| AppError::JsonSerialize { source: e })?); - return Ok(()); - } - - if items.is_empty() { - println!("{}", info("No Kimi Code profiles found.")); - return Ok(()); - } - - for (i, item) in items.iter().enumerate() { - if i > 0 { - println!("\n------------------------------------------------------------\n"); - } - let active_tag = if item.profile.is_active { " (active)" } else { "" }; - let info_obj = KimiStatusInfo { - home_dir: item.profile.path.clone(), - active_profile: Some(format!("{}{}", item.profile.name, active_tag)), - account: item.profile.account.clone(), - has_credentials: item.profile.has_credentials, - token_expires_at: None, - profiles_count: items.len(), - usages: item.usages.clone(), - error: item.error.clone(), - }; - print_status_info(&info_obj); - } - return Ok(()); - } - - if let Some(ref name) = profile { - let profiles = list_profiles().map_err(|e| AppError::Message(e.to_string()))?; - let p = profiles.into_iter().find(|x| &x.name == name).ok_or_else(|| { - AppError::Message(format!("Profile '{name}' 不存在")) - })?; - - let mut creds = crate::kimi_config::read_profile_credentials(name) - .map_err(|e| AppError::Message(e.to_string()))?; - - let (usages, error) = if let Some(ref mut c) = creds { - let cred_path = p.path.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE); - match rt.block_on(crate::kimi_config::get_valid_access_token(c, Some(&cred_path))) { - Ok(token) => match rt.block_on(crate::kimi_config::fetch_kimi_usages(&token)) { - Ok(u) => (Some(u), None), - Err(e) => (None, Some(e.to_string())), - }, - Err(e) => (None, Some(e.to_string())), - } - } else { - (None, None) - }; - - let active_tag = if p.is_active { " (active)" } else { "" }; - let info_obj = KimiStatusInfo { - home_dir: p.path.clone(), - active_profile: Some(format!("{}{}", p.name, active_tag)), - account: p.account.clone(), - has_credentials: creds.is_some(), - token_expires_at: creds.as_ref().and_then(|c| c.expires_at), - profiles_count: 1, - usages, - error, - }; - - if json { - println!("{}", to_json(&info_obj).map_err(|e| AppError::JsonSerialize { source: e })?); - return Ok(()); - } - - print_status_info(&info_obj); - return Ok(()); - } - - // Default: currently active ~/.kimi-code - let home = get_kimi_config_dir(); - let active_profile = get_active_profile_name(); - let mut creds = read_native_credentials().map_err(|e| AppError::Message(e.to_string()))?; - let profiles = list_profiles().map_err(|e| AppError::Message(e.to_string()))?; - - let (usages, error) = if let Some(ref mut c) = creds { - let cred_path = home.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE); - match rt.block_on(crate::kimi_config::get_valid_access_token(c, Some(&cred_path))) { - Ok(token) => match rt.block_on(crate::kimi_config::fetch_kimi_usages(&token)) { - Ok(u) => (Some(u), None), - Err(e) => (None, Some(e.to_string())), - }, - Err(e) => (None, Some(e.to_string())), - } - } else { - (None, None) - }; - - let account = creds.as_ref().and_then(|c| crate::kimi_config::resolve_account_nickname(c)); - - let info_obj = KimiStatusInfo { - home_dir: home.clone(), - active_profile: active_profile.clone(), - account, - has_credentials: creds.is_some(), - token_expires_at: creds.as_ref().and_then(|c| c.expires_at), - profiles_count: profiles.len(), - usages, - error, - }; - - if json { - println!("{}", to_json(&info_obj).map_err(|e| AppError::JsonSerialize { source: e })?); - return Ok(()); - } - - print_status_info(&info_obj); - println!("Profiles Count: {}", info_obj.profiles_count); - - Ok(()) -} - -fn format_5h_usage_cell(item: &crate::kimi_config::KimiProfileQuotaItem) -> (String, String) { - if !item.profile.has_credentials { - return ("(no credentials)".to_string(), "-".to_string()); - } - if let Some(ref err) = item.error { - if err.contains("缺少 refresh_token") || err.contains("未登录") { - return ("(unauthenticated)".to_string(), "-".to_string()); - } - return (format!("error: {err}"), "-".to_string()); - } - if let Some(ref u) = item.usages { - if let Some(ref q) = u.usages { - if let Some(ref l5) = q.limit_5h { - let ratio = l5.used_ratio.unwrap_or(0.0); - let pct = ratio * 100.0; - let five_hour_detail = u.limits.iter().find(|l| { - l.window.as_ref().and_then(|w| w.duration) == Some(300) - }).and_then(|l| l.detail.as_ref()); - - let count_str = if let Some(d) = five_hour_detail { - let used_cnt = d.used.as_deref().unwrap_or("0"); - let limit_cnt = d.limit.as_deref().unwrap_or("100"); - let rem_cnt = d.remaining.as_deref().unwrap_or("0"); - format!(" [{used_cnt}/{limit_cnt}, rem: {rem_cnt}]") - } else { - String::new() - }; - - let status_tag = if ratio >= 1.0 { " [EXCEEDED]" } else { "" }; - let usage_str = format!("{pct:.1}%{status_tag}{count_str}"); - - let reset_str = if let Some(reset_info) = crate::cli::provider_quota::quota_reset_display(l5.reset_time.as_deref(), chrono::Utc::now()) { - let local = reset_info.at.with_timezone(&chrono::Local).format("%H:%M:%S"); - let countdown = reset_info.remaining.unwrap_or_else(|| "soon".to_string()); - format!("{countdown} ({local})") - } else { - l5.reset_time.as_deref().unwrap_or("-").to_string() - }; - - return (usage_str, reset_str); - } - } - } - ("-".to_string(), "-".to_string()) -} - -fn format_7d_usage_cell(item: &crate::kimi_config::KimiProfileQuotaItem) -> (String, String) { - if !item.profile.has_credentials { - return ("(no credentials)".to_string(), "-".to_string()); - } - if let Some(ref err) = item.error { - if err.contains("缺少 refresh_token") || err.contains("未登录") { - return ("(unauthenticated)".to_string(), "-".to_string()); - } - return (format!("error: {err}"), "-".to_string()); - } - if let Some(ref u) = item.usages { - if let Some(ref q) = u.usages { - if let Some(ref l7) = q.limit_7d { - let ratio = l7.used_ratio.unwrap_or(0.0); - let pct = ratio * 100.0; - let status_tag = if ratio >= 1.0 { " [EXCEEDED]" } else { "" }; - let usage_str = format!("{pct:.1}%{status_tag}"); - - let reset_str = if let Some(reset_info) = crate::cli::provider_quota::quota_reset_display(l7.reset_time.as_deref(), chrono::Utc::now()) { - let local = reset_info.at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M"); - let countdown = reset_info.remaining.unwrap_or_else(|| "soon".to_string()); - format!("{countdown} ({local})") - } else { - l7.reset_time.as_deref().unwrap_or("-").to_string() - }; - - return (usage_str, reset_str); - } - } - } - ("-".to_string(), "-".to_string()) -} - -fn execute_profile(cmd: KimiProfileCommand) -> Result<(), AppError> { - match cmd { - KimiProfileCommand::List { quota, json } => { - if quota { - let rt = tokio::runtime::Runtime::new() - .map_err(|e| AppError::Message(format!("无法创建 Tokio 运行时: {e}")))?; - let items = rt.block_on(crate::kimi_config::fetch_all_profiles_quota()); - if json { - println!( - "{}", - to_json(&items).map_err(|e| AppError::JsonSerialize { source: e })? - ); - return Ok(()); - } - - if items.is_empty() { - println!("{}", info("No Kimi Code profiles found. Use `cc-switch config kimi profile save ` to save one.")); - return Ok(()); - } - - let mut table = create_table(); - table.set_header(vec!["Active", "Profile", "Account", "5-Hour Usage", "Reset In", "7-Day Usage", "7D Reset", "Path"]); - for item in items { - let (usage_5h_str, reset_5h_str) = format_5h_usage_cell(&item); - let (usage_7d_str, reset_7d_str) = format_7d_usage_cell(&item); - table.add_row(vec![ - if item.profile.is_active { "*" } else { " " }, - &item.profile.name, - item.profile.account.as_deref().unwrap_or("-"), - &usage_5h_str, - &reset_5h_str, - &usage_7d_str, - &reset_7d_str, - &item.profile.path.display().to_string(), - ]); - } - println!("{table}"); - return Ok(()); - } - - let profiles = list_profiles().map_err(|e| AppError::Message(e.to_string()))?; - if json { - println!( - "{}", - to_json(&profiles).map_err(|e| AppError::JsonSerialize { source: e })? - ); - return Ok(()); - } - - if profiles.is_empty() { - println!("{}", info("No Kimi Code profiles found. Use `cc-switch config kimi profile save ` to save one.")); - return Ok(()); - } - - let mut table = create_table(); - table.set_header(vec!["Active", "Profile", "Account", "Has Config", "Has Credentials", "Path"]); - for p in profiles { - table.add_row(vec![ - if p.is_active { "*" } else { " " }, - &p.name, - p.account.as_deref().unwrap_or("-"), - if p.has_config { "yes" } else { "no" }, - if p.has_credentials { "yes" } else { "no" }, - &p.path.display().to_string(), - ]); - } - println!("{table}"); - Ok(()) - } - KimiProfileCommand::Save { name } => { - let path = save_profile(&name).map_err(|e| AppError::Message(e.to_string()))?; - println!( - "{}", - success(&format!( - "Current Kimi Code configuration saved to profile '{}' ({}).", - name, - path.display() - )) - ); - Ok(()) - } - KimiProfileCommand::Switch { name } => { - switch_profile(&name).map_err(|e| AppError::Message(e.to_string()))?; - println!( - "{}", - success(&format!( - "Switched Kimi Code configuration to profile '{}'.", - name - )) - ); - Ok(()) - } - KimiProfileCommand::Remove { name, yes } => { - if !yes { - println!( - "{}", - info(&format!( - "Pass --yes to confirm deletion of Kimi Code profile '{}'.", - name - )) - ); - return Ok(()); - } - - remove_profile(&name).map_err(|e| AppError::Message(e.to_string()))?; - println!( - "{}", - success(&format!("Profile '{}' has been removed.", name)) - ); - Ok(()) - } - } -} diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs index 2dc76e706..b258b3fe6 100644 --- a/src-tauri/src/cli/commands/mod.rs +++ b/src-tauri/src/cli/commands/mod.rs @@ -3,7 +3,6 @@ pub mod auth; pub mod completions; pub mod config; mod config_common; -pub(crate) mod config_kimi; pub(crate) mod config_openclaw; pub mod config_s3; pub mod config_webdav; diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 26115041f..ebe59f4d3 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -690,22 +690,8 @@ mod tests { let cli = Cli::parse_from(["cc-switch", "auth", "status", "--json"]); match cli.command { - Some(Commands::Auth(super::commands::auth::AuthCommand::Status { json, provider })) => { + Some(Commands::Auth(super::commands::auth::AuthCommand::Status { json })) => { assert!(json); - assert_eq!(provider, "codex_oauth"); - } - _ => panic!("expected auth status command"), - } - } - - #[test] - fn parses_auth_status_kimi_provider_subcommand() { - let cli = Cli::parse_from(["cc-switch", "auth", "status", "--provider", "kimi_oauth", "--json"]); - - match cli.command { - Some(Commands::Auth(super::commands::auth::AuthCommand::Status { json, provider })) => { - assert!(json); - assert_eq!(provider, "kimi_oauth"); } _ => panic!("expected auth status command"), } @@ -716,9 +702,8 @@ mod tests { let cli = Cli::parse_from(["cc-switch", "auth", "login", "--json"]); match cli.command { - Some(Commands::Auth(super::commands::auth::AuthCommand::Login { json, provider })) => { + Some(Commands::Auth(super::commands::auth::AuthCommand::Login { json })) => { assert!(json); - assert_eq!(provider, "codex_oauth"); } _ => panic!("expected auth login command"), } @@ -736,9 +721,8 @@ mod tests { let cli = Cli::parse_from(["cc-switch", "auth", "default", "acc-123"]); match cli.command { - Some(Commands::Auth(super::commands::auth::AuthCommand::Default { account_id, provider })) => { + Some(Commands::Auth(super::commands::auth::AuthCommand::Default { account_id })) => { assert_eq!(account_id, "acc-123"); - assert_eq!(provider, "codex_oauth"); } _ => panic!("expected auth default command"), } @@ -752,11 +736,9 @@ mod tests { Some(Commands::Auth(super::commands::auth::AuthCommand::Remove { account_id, yes, - provider, })) => { assert_eq!(account_id, "acc-123"); assert!(yes); - assert_eq!(provider, "codex_oauth"); } _ => panic!("expected auth remove command"), } @@ -767,9 +749,8 @@ mod tests { let cli = Cli::parse_from(["cc-switch", "auth", "logout", "--yes"]); match cli.command { - Some(Commands::Auth(super::commands::auth::AuthCommand::Logout { yes, provider })) => { + Some(Commands::Auth(super::commands::auth::AuthCommand::Logout { yes })) => { assert!(yes); - assert_eq!(provider, "codex_oauth"); } _ => panic!("expected auth logout command"), } @@ -1735,31 +1716,6 @@ mod tests { } } - #[test] - fn parses_config_kimi_path_and_profile_subcommands() { - let cli = Cli::parse_from(["cc-switch", "config", "kimi", "path", "--json"]); - match cli.command { - Some(Commands::Config(super::commands::config::ConfigCommand::Kimi( - super::commands::config_kimi::KimiConfigCommand::Path { json }, - ))) => { - assert!(json); - } - _ => panic!("expected config kimi path command"), - } - - let cli_switch = Cli::parse_from(["cc-switch", "config", "kimi", "profile", "switch", "work"]); - match cli_switch.command { - Some(Commands::Config(super::commands::config::ConfigCommand::Kimi( - super::commands::config_kimi::KimiConfigCommand::Profile( - super::commands::config_kimi::KimiProfileCommand::Switch { name }, - ), - ))) => { - assert_eq!(name, "work"); - } - _ => panic!("expected config kimi profile switch command"), - } - } - #[test] fn config_common_set_help_describes_snippet_as_primary_contract() { let mut cmd = Cli::command(); diff --git a/src-tauri/src/kimi_config.rs b/src-tauri/src/kimi_config.rs index 7b418dd54..608ba3188 100644 --- a/src-tauri/src/kimi_config.rs +++ b/src-tauri/src/kimi_config.rs @@ -2,7 +2,6 @@ use crate::app_config::MultiAppConfig; use crate::error::AppError; use anyhow::{Context, Result}; use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::fs; @@ -13,38 +12,7 @@ use toml_edit::{DocumentMut, Table}; pub const DEFAULT_KIMI_CONFIG_DIR: &str = ".kimi-code"; pub const KIMI_HOME_ENV: &str = "KIMI_CODE_HOME"; -pub const KIMI_CREDENTIALS_DIR: &str = "credentials"; -pub const KIMI_DEFAULT_CREDENTIAL_FILE: &str = "kimi-code.json"; pub const KIMI_CONFIG_FILE: &str = "config.toml"; -pub const KIMI_TUI_FILE: &str = "tui.toml"; -pub const KIMI_PROFILES_DIR_NAME: &str = "kimi_profiles"; -pub const KIMI_ACTIVE_PROFILE_FILE: &str = "kimi_active_profile"; - -/// Kimi Code native 登录认证文件结构 (~/.kimi-code/credentials/kimi-code.json) -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct KimiNativeCredentials { - pub access_token: String, - pub refresh_token: String, - #[serde(default)] - pub expires_in: Option, - #[serde(default)] - pub token_type: Option, - #[serde(default)] - pub scope: Option, - #[serde(default)] - pub expires_at: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct KimiProfileInfo { - pub name: String, - pub path: PathBuf, - pub is_active: bool, - pub has_credentials: bool, - pub has_config: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub account: Option, -} /// 解析 Kimi Code 根目录路径(遵循 KIMI_CODE_HOME 环境变量,默认 ~/.kimi-code) pub fn get_kimi_config_dir() -> PathBuf { @@ -298,6 +266,7 @@ pub fn prepare_provider(id: &str, provider_config: Value) -> Result Result<(), AppError> { set_kimi_mcp_servers_map(&servers) } -/// 获取 cc-switch 管理的 Kimi 配置 profiles 存储目录 -pub fn get_kimi_profiles_dir() -> PathBuf { - crate::config::get_app_config_dir().join(KIMI_PROFILES_DIR_NAME) -} - -/// 获取当前激活的 Profile 名称(如果记录过) -pub fn get_active_profile_name() -> Option { - let path = crate::config::get_app_config_dir().join(KIMI_ACTIVE_PROFILE_FILE); - if path.exists() { - fs::read_to_string(path).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) - } else { - None - } -} - -/// 设置当前激活的 Profile 名称记录 -pub fn set_active_profile_name(name: Option<&str>) -> Result<()> { - let path = crate::config::get_app_config_dir().join(KIMI_ACTIVE_PROFILE_FILE); - if let Some(n) = name { - write_file_atomic(&path, n.trim(), 0o644)?; - } else if path.exists() { - let _ = fs::remove_file(path); - } - Ok(()) -} - -/// 读取当前 native credentials -pub fn read_native_credentials() -> Result> { - let cred_path = get_kimi_config_dir() - .join(KIMI_CREDENTIALS_DIR) - .join(KIMI_DEFAULT_CREDENTIAL_FILE); - - if !cred_path.exists() { - return Ok(None); - } - - let content = fs::read_to_string(&cred_path) - .with_context(|| format!("读取 Kimi 凭据文件失败: {}", cred_path.display()))?; - - let parsed: KimiNativeCredentials = serde_json::from_str(&content) - .with_context(|| format!("解析 Kimi 凭据文件失败: {}", cred_path.display()))?; - - Ok(Some(parsed)) -} - -/// 原子写入 native credentials -pub fn write_native_credentials(credentials: &KimiNativeCredentials) -> Result<()> { - let dir = get_kimi_config_dir().join(KIMI_CREDENTIALS_DIR); - fs::create_dir_all(&dir) - .with_context(|| format!("创建 Kimi 凭据目录失败: {}", dir.display()))?; - - let cred_path = dir.join(KIMI_DEFAULT_CREDENTIAL_FILE); - let content = serde_json::to_string_pretty(credentials) - .context("序列化 Kimi 凭据失败")?; - - write_file_atomic(&cred_path, &content, 0o600)?; - Ok(()) -} - -/// 读取指定 profile 的 credentials -pub fn read_profile_credentials(profile_name: &str) -> Result> { - let cred_path = get_kimi_profiles_dir() - .join(profile_name) - .join(KIMI_CREDENTIALS_DIR) - .join(KIMI_DEFAULT_CREDENTIAL_FILE); - - if !cred_path.exists() { - return Ok(None); - } - - let content = fs::read_to_string(&cred_path) - .with_context(|| format!("读取 Profile 凭据文件失败: {}", cred_path.display()))?; - - let parsed: KimiNativeCredentials = serde_json::from_str(&content) - .with_context(|| format!("解析 Profile 凭据文件失败: {}", cred_path.display()))?; - - Ok(Some(parsed)) -} - -/// 确保获取有效的 access_token(若已过期,自动尝试 refresh 并写回对应文件) -pub async fn get_valid_access_token( - creds: &mut KimiNativeCredentials, - save_path: Option<&Path>, -) -> Result { - let now = chrono::Utc::now().timestamp(); - let expired = creds.expires_at.map(|exp| exp <= now + 30).unwrap_or(false); - - if !expired && !creds.access_token.is_empty() { - return Ok(creds.access_token.clone()); - } - - if creds.refresh_token.is_empty() { - if !creds.access_token.is_empty() { - return Ok(creds.access_token.clone()); - } - anyhow::bail!("缺少 refresh_token,无法刷新"); - } - - let refreshed = crate::proxy::providers::kimi_oauth_auth::KimiOAuthManager::refresh_token_raw(&creds.refresh_token) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - if let Some(new_at) = refreshed.access_token { - creds.access_token = new_at; - if let Some(exp_in) = refreshed.expires_in { - creds.expires_in = Some(exp_in); - creds.expires_at = Some(now + exp_in); - } - if let Some(new_rt) = refreshed.refresh_token { - creds.refresh_token = new_rt; - } - - if let Some(path) = save_path { - let content = serde_json::to_string_pretty(creds)?; - let _ = write_file_atomic(path, &content, 0o600); - } - } - - Ok(creds.access_token.clone()) -} - -/// 解析 JWT 中的 user_id -pub fn extract_user_id_from_jwt(token: &str) -> Option { - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use base64::Engine; - - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() < 2 { - return None; - } - let payload_b64 = parts[1].trim_end_matches('='); - let decoded = URL_SAFE_NO_PAD.decode(payload_b64).ok()?; - let val: serde_json::Value = serde_json::from_slice(&decoded).ok()?; - val.get("user_id") - .or_else(|| val.get("sub")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) -} - -/// 同步账号认证信息至 native ~/.kimi-code -pub fn sync_kimi_account_to_native( - access_token: &str, - refresh_token: &str, - expires_in: i64, - expires_at_sec: i64, -) -> Result<()> { - #[cfg(test)] - if std::env::var_os(KIMI_HOME_ENV).is_none() { - return Ok(()); - } - - let creds = KimiNativeCredentials { - access_token: access_token.to_string(), - refresh_token: refresh_token.to_string(), - expires_in: Some(expires_in), - token_type: Some("Bearer".to_string()), - scope: None, - expires_at: Some(expires_at_sec), - }; - - write_native_credentials(&creds) -} - -/// 清除 native credentials -pub fn clear_native_credentials() -> Result<()> { - let cred_path = get_kimi_config_dir() - .join(KIMI_CREDENTIALS_DIR) - .join(KIMI_DEFAULT_CREDENTIAL_FILE); - - if cred_path.exists() { - fs::remove_file(&cred_path) - .with_context(|| format!("删除 Kimi 凭据失败: {}", cred_path.display()))?; - } - Ok(()) -} - -/// 解析凭据对应的账号昵称 -pub fn resolve_account_nickname(cred: &KimiNativeCredentials) -> Option { - let manager = crate::services::kimi_oauth::KimiOAuthService::manager(); - let user_id = extract_user_id_from_jwt(&cred.access_token); - manager - .find_account_sync(&cred.refresh_token, user_id.as_deref()) - .map(|a| a.login) -} - -/// 列出所有已保存的 Kimi 配置 Profiles -pub fn list_profiles() -> Result> { - let profiles_dir = get_kimi_profiles_dir(); - if !profiles_dir.exists() { - return Ok(Vec::new()); - } - - let active_name = get_active_profile_name(); - let mut profiles = Vec::new(); - - for entry in fs::read_dir(&profiles_dir)? { - let entry = entry?; - let file_type = entry.file_type()?; - if file_type.is_dir() { - let name = entry.file_name().to_string_lossy().to_string(); - let path = entry.path(); - let has_credentials = path.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE).exists(); - let has_config = path.join(KIMI_CONFIG_FILE).exists(); - let is_active = active_name.as_deref() == Some(&name); - - let account = if has_credentials { - read_profile_credentials(&name).ok().flatten().and_then(|c| resolve_account_nickname(&c)) - } else { - None - }; - - profiles.push(KimiProfileInfo { - name, - path, - is_active, - has_credentials, - has_config, - account, - }); - } - } - - profiles.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(profiles) -} - -#[derive(Debug, Clone, Serialize)] -pub struct KimiProfileQuotaItem { - pub profile: KimiProfileInfo, - #[serde(skip_serializing_if = "Option::is_none")] - pub usages: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// 批量查询所有 Profiles 的实时 Quota(并发查询) -pub async fn fetch_all_profiles_quota() -> Vec { - let profiles = match list_profiles() { - Ok(p) => p, - Err(_) => return vec![], - }; - - let mut tasks = Vec::new(); - for p in profiles { - tasks.push(async move { - let mut creds = match read_profile_credentials(&p.name) { - Ok(Some(c)) => c, - Ok(None) => { - return KimiProfileQuotaItem { - profile: p, - usages: None, - error: None, - }; - } - Err(e) => { - return KimiProfileQuotaItem { - profile: p, - usages: None, - error: Some(e.to_string()), - }; - } - }; - - let cred_path = p.path.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE); - match get_valid_access_token(&mut creds, Some(&cred_path)).await { - Ok(token) => { - match fetch_kimi_usages(&token).await { - Ok(u) => KimiProfileQuotaItem { - profile: p, - usages: Some(u), - error: None, - }, - Err(e) => KimiProfileQuotaItem { - profile: p, - usages: None, - error: Some(e.to_string()), - }, - } - } - Err(e) => KimiProfileQuotaItem { - profile: p, - usages: None, - error: Some(e.to_string()), - }, - } - }); - } - - futures::future::join_all(tasks).await -} - -/// 保存当前活动的 ~/.kimi-code 配置到指定名称的 Profile -pub fn save_profile(name: &str) -> Result { - let name = validate_profile_name(name)?; - let src_dir = get_kimi_config_dir(); - let target_dir = get_kimi_profiles_dir().join(name); - - fs::create_dir_all(&target_dir) - .with_context(|| format!("创建 Profile 目录失败: {}", target_dir.display()))?; - - // 复制 config.toml - let src_config = src_dir.join(KIMI_CONFIG_FILE); - if src_config.exists() { - fs::copy(&src_config, target_dir.join(KIMI_CONFIG_FILE))?; - } - - // 复制 tui.toml - let src_tui = src_dir.join(KIMI_TUI_FILE); - if src_tui.exists() { - fs::copy(&src_tui, target_dir.join(KIMI_TUI_FILE))?; - } - - // 复制 credentials - let src_creds_dir = src_dir.join(KIMI_CREDENTIALS_DIR); - if src_creds_dir.exists() { - let target_creds_dir = target_dir.join(KIMI_CREDENTIALS_DIR); - fs::create_dir_all(&target_creds_dir)?; - let src_cred_file = src_creds_dir.join(KIMI_DEFAULT_CREDENTIAL_FILE); - if src_cred_file.exists() { - fs::copy(&src_cred_file, target_creds_dir.join(KIMI_DEFAULT_CREDENTIAL_FILE))?; - } - } - - set_active_profile_name(Some(name))?; - Ok(target_dir) -} - -/// 切换激活指定的 Profile(将该 Profile 写入当前 ~/.kimi-code 根目录) -pub fn switch_profile(name: &str) -> Result<()> { - let name = validate_profile_name(name)?; - let profile_dir = get_kimi_profiles_dir().join(name); - if !profile_dir.exists() { - anyhow::bail!("Profile '{}' 不存在", name); - } - - let target_dir = get_kimi_config_dir(); - fs::create_dir_all(&target_dir) - .with_context(|| format!("创建目标目录失败: {}", target_dir.display()))?; - - // 恢复 config.toml - let p_config = profile_dir.join(KIMI_CONFIG_FILE); - let target_config = target_dir.join(KIMI_CONFIG_FILE); - if p_config.exists() { - fs::copy(&p_config, &target_config)?; - } else if target_config.exists() { - let _ = fs::remove_file(&target_config); - } - - // 恢复 tui.toml - let p_tui = profile_dir.join(KIMI_TUI_FILE); - let target_tui = target_dir.join(KIMI_TUI_FILE); - if p_tui.exists() { - fs::copy(&p_tui, &target_tui)?; - } else if target_tui.exists() { - let _ = fs::remove_file(&target_tui); - } - - // 恢复 credentials - let p_creds_file = profile_dir.join(KIMI_CREDENTIALS_DIR).join(KIMI_DEFAULT_CREDENTIAL_FILE); - let target_creds_dir = target_dir.join(KIMI_CREDENTIALS_DIR); - let target_creds_file = target_creds_dir.join(KIMI_DEFAULT_CREDENTIAL_FILE); - - if p_creds_file.exists() { - fs::create_dir_all(&target_creds_dir)?; - fs::copy(&p_creds_file, &target_creds_file)?; - } else if target_creds_file.exists() { - let _ = fs::remove_file(&target_creds_file); - } - - set_active_profile_name(Some(name))?; - Ok(()) -} - -/// 删除指定的 Profile -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KimiQuotaItem { - #[serde(default)] - pub used_ratio: Option, - #[serde(default)] - pub reset_time: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KimiQuotaUsages { - #[serde(default)] - pub limit_5h: Option, - #[serde(default)] - pub limit_7d: Option, - #[serde(default)] - pub limit_month_total: Option, - #[serde(default)] - pub limit_month_code: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KimiLimitDetail { - #[serde(default)] - pub limit: Option, - #[serde(default)] - pub used: Option, - #[serde(default)] - pub remaining: Option, - #[serde(default, rename = "resetTime")] - pub reset_time: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KimiLimitWindowInfo { - #[serde(default)] - pub duration: Option, - #[serde(default, rename = "timeUnit")] - pub time_unit: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KimiLimitWindow { - #[serde(default)] - pub window: Option, - #[serde(default)] - pub detail: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KimiUsagesResponse { - #[serde(default)] - pub limits: Vec, - #[serde(default)] - pub usages: Option, -} - -pub async fn fetch_kimi_usages(access_token: &str) -> Result { - let client = crate::proxy::http_client::get(); - let resp = client - .get("https://api.kimi.com/coding/v1/usages") - .header("Authorization", format!("Bearer {access_token}")) - .header("Accept", "application/json") - .send() - .await - .context("请求 Kimi usages 接口失败")?; - - if !resp.status().is_success() { - anyhow::bail!("Kimi usages 接口返回错误: {}", resp.status()); - } - - let usages: KimiUsagesResponse = resp.json().await.context("解析 Kimi usages 响应失败")?; - Ok(usages) -} - -pub fn remove_profile(name: &str) -> Result<()> { - let name = validate_profile_name(name)?; - let profile_dir = get_kimi_profiles_dir().join(name); - if profile_dir.exists() { - fs::remove_dir_all(&profile_dir) - .with_context(|| format!("删除 Profile 目录失败: {}", profile_dir.display()))?; - } - - if get_active_profile_name().as_deref() == Some(name) { - let _ = set_active_profile_name(None); - } - - Ok(()) -} - -fn validate_profile_name(name: &str) -> Result<&str> { - let trimmed = name.trim(); - if trimmed.is_empty() { - anyhow::bail!("Profile 名称不能为空"); - } - if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { - anyhow::bail!("Profile 名称包含非法字符: {}", name); - } - Ok(trimmed) -} - fn write_file_atomic(path: &Path, content: &str, #[allow(unused_variables)] mode: u32) -> Result<()> { let parent = path .parent() @@ -1098,116 +593,6 @@ fn write_file_atomic(path: &Path, content: &str, #[allow(unused_variables)] mode mod tests { use super::*; - #[test] - fn test_write_and_read_native_credentials() { - let _lock = crate::test_support::lock_test_home_and_settings(); - let temp = tempfile::tempdir().unwrap(); - let old_env = std::env::var_os(KIMI_HOME_ENV); - std::env::set_var(KIMI_HOME_ENV, temp.path()); - - let creds = KimiNativeCredentials { - access_token: "test_at".to_string(), - refresh_token: "test_rt".to_string(), - expires_in: Some(3600), - token_type: Some("Bearer".to_string()), - scope: None, - expires_at: Some(1720000000), - }; - - write_native_credentials(&creds).unwrap(); - - let read_back = read_native_credentials().unwrap(); - assert_eq!(read_back, Some(creds)); - - clear_native_credentials().unwrap(); - assert_eq!(read_native_credentials().unwrap(), None); - - if let Some(val) = old_env { - std::env::set_var(KIMI_HOME_ENV, val); - } else { - std::env::remove_var(KIMI_HOME_ENV); - } - } - - #[test] - fn test_profile_save_and_switch() { - let _lock = crate::test_support::lock_test_home_and_settings(); - let temp_home = tempfile::tempdir().unwrap(); - let temp_app = tempfile::tempdir().unwrap(); - - let old_home_env = std::env::var_os(KIMI_HOME_ENV); - let old_app_env = std::env::var_os("CC_SWITCH_CONFIG_DIR"); - - std::env::set_var(KIMI_HOME_ENV, temp_home.path()); - std::env::set_var("CC_SWITCH_CONFIG_DIR", temp_app.path()); - - // 写入初始环境 - let config_file = temp_home.path().join(KIMI_CONFIG_FILE); - fs::write(&config_file, "default_model = 'kimi-k2'").unwrap(); - - let creds = KimiNativeCredentials { - access_token: "work_token".to_string(), - refresh_token: "work_rt".to_string(), - expires_in: Some(3600), - token_type: Some("Bearer".to_string()), - scope: None, - expires_at: None, - }; - write_native_credentials(&creds).unwrap(); - - // 保存为 work profile - save_profile("work").unwrap(); - - let profiles = list_profiles().unwrap(); - assert_eq!(profiles.len(), 1); - assert_eq!(profiles[0].name, "work"); - assert!(profiles[0].is_active); - assert!(profiles[0].has_credentials); - assert!(profiles[0].has_config); - - // 修改当前环境为 personal - fs::write(&config_file, "default_model = 'kimi-k1.5'").unwrap(); - let personal_creds = KimiNativeCredentials { - access_token: "personal_token".to_string(), - refresh_token: "personal_rt".to_string(), - expires_in: Some(3600), - token_type: Some("Bearer".to_string()), - scope: None, - expires_at: None, - }; - write_native_credentials(&personal_creds).unwrap(); - - // 保存为 personal profile - save_profile("personal").unwrap(); - assert_eq!(list_profiles().unwrap().len(), 2); - assert_eq!(get_active_profile_name().as_deref(), Some("personal")); - - // 切换回 work profile - switch_profile("work").unwrap(); - assert_eq!(get_active_profile_name().as_deref(), Some("work")); - - // 验证当前配置和凭据已被还原为 work - let current_config = fs::read_to_string(&config_file).unwrap(); - assert_eq!(current_config, "default_model = 'kimi-k2'"); - let current_creds = read_native_credentials().unwrap().unwrap(); - assert_eq!(current_creds.access_token, "work_token"); - - // 删除 personal profile - remove_profile("personal").unwrap(); - assert_eq!(list_profiles().unwrap().len(), 1); - - if let Some(val) = old_home_env { - std::env::set_var(KIMI_HOME_ENV, val); - } else { - std::env::remove_var(KIMI_HOME_ENV); - } - if let Some(val) = old_app_env { - std::env::set_var("CC_SWITCH_CONFIG_DIR", val); - } else { - std::env::remove_var("CC_SWITCH_CONFIG_DIR"); - } - } - #[test] fn test_kimi_provider_crud_and_mcp() { let _lock = crate::test_support::lock_test_home_and_settings(); @@ -1293,4 +678,39 @@ mod tests { std::env::remove_var(KIMI_HOME_ENV); } } + + #[test] + fn test_kimi_set_default_model() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp_home = tempfile::tempdir().unwrap(); + let old_home_env = std::env::var_os(KIMI_HOME_ENV); + std::env::set_var(KIMI_HOME_ENV, temp_home.path()); + + let provider_config = json!({ + "type": "openai", + "baseUrl": "https://api.moonshot.cn/v1", + "apiKey": "sk-test", + "model": "moonshot-v1-8k", + "models": [ + { "id": "moonshot-v1-8k", "name": "Moonshot 8k" }, + { "id": "moonshot-v1-32k", "name": "Moonshot 32k" } + ] + }); + let prepared = prepare_provider("moonshot", provider_config).unwrap(); + write_prepared_config(&prepared).unwrap(); + + assert_eq!(get_current_provider_id().unwrap(), Some("moonshot".to_string())); + + set_default_model("moonshot-v1-32k").unwrap(); + assert_eq!(get_current_provider_id().unwrap(), Some("moonshot".to_string())); + + let json = read_kimi_config_json().unwrap(); + assert_eq!(json["default_model"], "moonshot-v1-32k"); + + if let Some(val) = old_home_env { + std::env::set_var(KIMI_HOME_ENV, val); + } else { + std::env::remove_var(KIMI_HOME_ENV); + } + } } diff --git a/src-tauri/src/proxy/providers/kimi_oauth_auth.rs b/src-tauri/src/proxy/providers/kimi_oauth_auth.rs deleted file mode 100644 index 84b056a55..000000000 --- a/src-tauri/src/proxy/providers/kimi_oauth_auth.rs +++ /dev/null @@ -1,960 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::io::Write; -use std::path::PathBuf; -use tokio::sync::{Mutex, RwLock}; - -pub const KIMI_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098"; -pub const KIMI_DEVICE_AUTH_URL: &str = "https://auth.kimi.com/api/oauth/device_authorization"; -pub const KIMI_TOKEN_URL: &str = "https://auth.kimi.com/api/oauth/token"; -pub const KIMI_USER_INFO_URL: &str = "https://api.kimi.com/coding/v1/me"; -pub const KIMI_DEFAULT_VERIFICATION_URL: &str = "https://auth.kimi.com/device"; -pub const KIMI_USER_AGENT: &str = "cc-switch-kimi-oauth"; -const TOKEN_REFRESH_BUFFER_MS: i64 = 60_000; -const DEVICE_CODE_DEFAULT_EXPIRES_IN: u64 = 300; -const POLLING_SAFETY_MARGIN_SECS: u64 = 3; - -#[derive(Debug, thiserror::Error)] -pub enum KimiOAuthError { - #[error("等待用户授权中 (authorization pending)")] - AuthorizationPending, - #[error("用户拒绝授权 (access denied)")] - AccessDenied, - #[error("Device Code 已过期 (expired token)")] - ExpiredToken, - #[error("OAuth Token 获取失败: {0}")] - TokenFetchFailed(String), - #[error("Refresh Token 失效或已过期")] - RefreshTokenInvalid, - #[error("网络错误: {0}")] - NetworkError(String), - #[error("解析错误: {0}")] - ParseError(String), - #[error("IO 错误: {0}")] - IoError(String), - #[error("账号不存在: {0}")] - AccountNotFound(String), -} - -impl From for KimiOAuthError { - fn from(err: reqwest::Error) -> Self { - KimiOAuthError::NetworkError(err.to_string()) - } -} - -impl From for KimiOAuthError { - fn from(err: std::io::Error) -> Self { - KimiOAuthError::IoError(err.to_string()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManagedAuthAccount { - pub id: String, - pub login: String, - pub avatar_url: Option, - pub authenticated_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManagedAuthDeviceCodeResponse { - pub device_code: String, - pub user_code: String, - pub verification_uri: String, - pub expires_in: u64, - pub interval: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KimiOAuthStatus { - pub accounts: Vec, - pub default_account_id: Option, - pub authenticated: bool, - pub username: Option, -} - -#[derive(Debug, Clone, Deserialize)] -struct DeviceCodeResponse { - device_code: String, - user_code: String, - #[serde(default)] - verification_uri: Option, - #[serde(default)] - verification_uri_complete: Option, - #[serde(default)] - interval: Option, - #[serde(default)] - expires_in: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct KimiTokenResponse { - #[serde(default)] - pub access_token: Option, - #[serde(default)] - pub refresh_token: Option, - #[serde(default)] - pub expires_in: Option, - #[serde(default)] - pub token_type: Option, - #[serde(default)] - pub scope: Option, - #[serde(default)] - pub error: Option, - #[serde(default)] - pub error_description: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct KimiUserInfoResponse { - #[serde(default)] - user_id: Option, - #[serde(default)] - nickname: Option, - #[serde(default)] - email: Option, - #[serde(default)] - avatar: Option, -} - -#[derive(Debug, Clone)] -struct CachedAccessToken { - token: String, - expires_at_ms: i64, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -struct PendingDeviceCode { - user_code: String, - expires_at_ms: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KimiAccountData { - pub account_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub nickname: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub email: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub avatar_url: Option, - pub refresh_token: String, - pub authenticated_at: i64, -} - -impl From<&KimiAccountData> for ManagedAuthAccount { - fn from(data: &KimiAccountData) -> Self { - let login = if let Some(nickname) = data.nickname.as_deref().filter(|s| !s.trim().is_empty()) { - if let Some(email) = data.email.as_deref().filter(|s| !s.trim().is_empty()) { - format!("{nickname} ({email})") - } else { - nickname.to_string() - } - } else if let Some(email) = data.email.as_deref().filter(|s| !s.trim().is_empty()) { - email.to_string() - } else { - format!("Kimi ({})", &data.account_id) - }; - - Self { - id: data.account_id.clone(), - login, - avatar_url: data.avatar_url.clone(), - authenticated_at: data.authenticated_at, - } - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -struct KimiOAuthStore { - #[serde(default)] - version: u32, - #[serde(default)] - accounts: HashMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - default_account_id: Option, -} - -pub struct KimiOAuthManager { - accounts: std::sync::Arc>>, - default_account_id: std::sync::Arc>>, - access_tokens: std::sync::Arc>>, - refresh_locks: std::sync::Arc>>>>, - pending_device_codes: std::sync::Arc>>, - storage_path: PathBuf, -} - -impl KimiOAuthManager { - pub fn new(data_dir: PathBuf) -> Self { - let storage_path = data_dir.join("kimi_oauth_auth.json"); - let manager = Self { - accounts: std::sync::Arc::new(RwLock::new(HashMap::new())), - default_account_id: std::sync::Arc::new(RwLock::new(None)), - access_tokens: std::sync::Arc::new(RwLock::new(HashMap::new())), - refresh_locks: std::sync::Arc::new(RwLock::new(HashMap::new())), - pending_device_codes: std::sync::Arc::new(RwLock::new(HashMap::new())), - storage_path, - }; - - if let Err(e) = manager.load_from_disk_sync() { - log::warn!("[KimiOAuth] 加载存储失败: {e}"); - } - - manager - } - - pub async fn start_device_flow( - &self, - ) -> Result { - let params = [("client_id", KIMI_CLIENT_ID)]; - let response = crate::proxy::http_client::get() - .post(KIMI_DEVICE_AUTH_URL) - .header("Content-Type", "application/x-www-form-urlencoded") - .header("User-Agent", KIMI_USER_AGENT) - .form(¶ms) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let text = response.text().await.unwrap_or_default(); - return Err(KimiOAuthError::NetworkError(format!( - "Kimi Device Code 请求失败: {status} - {text}" - ))); - } - - let device: DeviceCodeResponse = response - .json() - .await - .map_err(|e| KimiOAuthError::ParseError(e.to_string()))?; - - let interval = parse_interval(device.interval.as_ref()); - let expires_in = device.expires_in.unwrap_or(DEVICE_CODE_DEFAULT_EXPIRES_IN); - let expires_at_ms = chrono::Utc::now().timestamp_millis() + (expires_in as i64) * 1000; - - { - let mut pending = self.pending_device_codes.write().await; - let now_ms = chrono::Utc::now().timestamp_millis(); - pending.retain(|_, entry| entry.expires_at_ms > now_ms); - pending.insert( - device.device_code.clone(), - PendingDeviceCode { - user_code: device.user_code.clone(), - expires_at_ms, - }, - ); - } - - let verification_uri = device - .verification_uri_complete - .or(device.verification_uri) - .unwrap_or_else(|| KIMI_DEFAULT_VERIFICATION_URL.to_string()); - - Ok(ManagedAuthDeviceCodeResponse { - device_code: device.device_code, - user_code: device.user_code, - verification_uri, - expires_in, - interval, - }) - } - - pub async fn poll_for_token( - &self, - device_code: &str, - ) -> Result, KimiOAuthError> { - let entry = { - let pending = self.pending_device_codes.read().await; - pending.get(device_code).cloned() - } - .ok_or_else(|| { - KimiOAuthError::TokenFetchFailed( - "未找到对应的 device_code,请重新启动登录流程".to_string(), - ) - })?; - - if entry.expires_at_ms <= chrono::Utc::now().timestamp_millis() { - let mut pending = self.pending_device_codes.write().await; - pending.remove(device_code); - return Err(KimiOAuthError::ExpiredToken); - } - - let params = [ - ("client_id", KIMI_CLIENT_ID), - ("device_code", device_code), - ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), - ]; - - let poll_response = crate::proxy::http_client::get() - .post(KIMI_TOKEN_URL) - .header("Content-Type", "application/x-www-form-urlencoded") - .header("User-Agent", KIMI_USER_AGENT) - .form(¶ms) - .send() - .await?; - - let status = poll_response.status(); - let body_text = poll_response.text().await.unwrap_or_default(); - - if !status.is_success() && status.as_u16() >= 500 { - return Err(KimiOAuthError::NetworkError(format!( - "Kimi 服务器错误: {status} - {body_text}" - ))); - } - - let token_resp: KimiTokenResponse = match serde_json::from_str(&body_text) { - Ok(parsed) => parsed, - Err(_) => { - return Err(KimiOAuthError::TokenFetchFailed(format!( - "无法解析响应: {status} - {body_text}" - ))); - } - }; - - if let Some(err_code) = token_resp.error.as_deref() { - match err_code { - "authorization_pending" | "slow_down" => { - return Err(KimiOAuthError::AuthorizationPending); - } - "expired_token" => { - let mut pending = self.pending_device_codes.write().await; - pending.remove(device_code); - return Err(KimiOAuthError::ExpiredToken); - } - "access_denied" => { - let mut pending = self.pending_device_codes.write().await; - pending.remove(device_code); - return Err(KimiOAuthError::AccessDenied); - } - other => { - let desc = token_resp - .error_description - .unwrap_or_else(|| other.to_string()); - return Err(KimiOAuthError::TokenFetchFailed(format!( - "Token 请求被拒绝: {desc}" - ))); - } - } - } - - let access_token = token_resp - .access_token - .filter(|t| !t.trim().is_empty()) - .ok_or_else(|| { - KimiOAuthError::TokenFetchFailed("响应缺少 access_token".to_string()) - })?; - - let refresh_token = token_resp.refresh_token.ok_or_else(|| { - KimiOAuthError::TokenFetchFailed("响应缺少 refresh_token".to_string()) - })?; - - // 成功获得 token,移除 pending device code - { - let mut pending = self.pending_device_codes.write().await; - pending.remove(device_code); - } - - // 获取用户资料 - let user_info = Self::fetch_user_info(&access_token).await.ok(); - let account_id = user_info - .as_ref() - .and_then(|u| u.user_id.clone()) - .filter(|id| !id.trim().is_empty()) - .unwrap_or_else(|| { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(refresh_token.as_bytes()); - let result = hasher.finalize(); - format!("kimi_{:x}", &result)[..16].to_string() - }); - - let account = self - .add_account_internal( - account_id, - refresh_token.clone(), - user_info.as_ref().and_then(|u| u.nickname.clone()), - user_info.as_ref().and_then(|u| u.email.clone()), - user_info.as_ref().and_then(|u| u.avatar.clone()), - ) - .await?; - - // 缓存 access token - let expires_in_sec = token_resp.expires_in.unwrap_or(3600); - let expires_at_ms = chrono::Utc::now().timestamp_millis() + expires_in_sec * 1000; - { - let mut tokens = self.access_tokens.write().await; - tokens.insert( - account.id.clone(), - CachedAccessToken { - token: access_token.clone(), - expires_at_ms, - }, - ); - } - - // 如果是当前默认账号,同步写入 native ~/.kimi-code - #[cfg(not(test))] - if self.default_account_id().await.as_deref() == Some(&account.id) { - let _ = crate::kimi_config::sync_kimi_account_to_native( - &access_token, - &refresh_token, - expires_in_sec, - expires_at_ms / 1000, - ); - } - - Ok(Some(account)) - } - - async fn fetch_user_info(access_token: &str) -> Result { - let response = crate::proxy::http_client::get() - .get(KIMI_USER_INFO_URL) - .header("Authorization", format!("Bearer {access_token}")) - .header("Accept", "application/json") - .header("User-Agent", KIMI_USER_AGENT) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await?; - - if !response.status().is_success() { - return Err(KimiOAuthError::NetworkError(format!( - "获取 Kimi 用户信息失败: HTTP {}", - response.status() - ))); - } - - let user_info: KimiUserInfoResponse = response - .json() - .await - .map_err(|e| KimiOAuthError::ParseError(e.to_string()))?; - - Ok(user_info) - } - - async fn refresh_access_token(&self, refresh_token: &str) -> Result { - Self::refresh_token_raw(refresh_token).await - } - - pub async fn refresh_token_raw(refresh_token: &str) -> Result { - let params = [ - ("client_id", KIMI_CLIENT_ID), - ("refresh_token", refresh_token), - ("grant_type", "refresh_token"), - ]; - - let response = crate::proxy::http_client::get() - .post(KIMI_TOKEN_URL) - .header("Content-Type", "application/x-www-form-urlencoded") - .header("User-Agent", KIMI_USER_AGENT) - .form(¶ms) - .send() - .await?; - - let status = response.status(); - let body_text = response.text().await.unwrap_or_default(); - - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err(KimiOAuthError::RefreshTokenInvalid); - } - - if !status.is_success() { - return Err(KimiOAuthError::TokenFetchFailed(format!( - "Token 刷新失败: {status} - {body_text}" - ))); - } - - let token_resp: KimiTokenResponse = serde_json::from_str(&body_text) - .map_err(|e| KimiOAuthError::ParseError(format!("{e}: {body_text}")))?; - - if token_resp - .access_token - .as_ref() - .map(|s| s.trim().is_empty()) - .unwrap_or(true) - { - return Err(KimiOAuthError::TokenFetchFailed( - "刷新响应中缺少 access_token".to_string(), - )); - } - - Ok(token_resp) - } - - pub async fn get_valid_token_for_account( - &self, - account_id: &str, - ) -> Result { - let now_ms = chrono::Utc::now().timestamp_millis(); - - { - let tokens = self.access_tokens.read().await; - if let Some(cached) = tokens.get(account_id) { - if cached.expires_at_ms > now_ms + TOKEN_REFRESH_BUFFER_MS { - return Ok(cached.token.clone()); - } - } - } - - let refresh_lock = { - let mut locks = self.refresh_locks.write().await; - locks - .entry(account_id.to_string()) - .or_insert_with(|| std::sync::Arc::new(Mutex::new(()))) - .clone() - }; - - let _guard = refresh_lock.lock().await; - - { - let tokens = self.access_tokens.read().await; - if let Some(cached) = tokens.get(account_id) { - if cached.expires_at_ms > now_ms + TOKEN_REFRESH_BUFFER_MS { - return Ok(cached.token.clone()); - } - } - } - - let refresh_token = { - let accounts = self.accounts.read().await; - accounts - .get(account_id) - .ok_or_else(|| KimiOAuthError::AccountNotFound(account_id.to_string()))? - .refresh_token - .clone() - }; - - let token_resp = self.refresh_access_token(&refresh_token).await?; - let access_token = token_resp - .access_token - .filter(|t| !t.trim().is_empty()) - .ok_or_else(|| { - KimiOAuthError::TokenFetchFailed("刷新响应中缺少 access_token".to_string()) - })?; - let expires_in_sec = token_resp.expires_in.unwrap_or(3600); - let expires_at_ms = chrono::Utc::now().timestamp_millis() + expires_in_sec * 1000; - - { - let mut tokens = self.access_tokens.write().await; - tokens.insert( - account_id.to_string(), - CachedAccessToken { - token: access_token.clone(), - expires_at_ms, - }, - ); - } - - if let Some(new_rt) = token_resp.refresh_token.filter(|rt| rt != &refresh_token) { - let mut accounts = self.accounts.write().await; - if let Some(acc) = accounts.get_mut(account_id) { - acc.refresh_token = new_rt; - } - drop(accounts); - self.save_to_disk().await?; - } - - // 如果是当前默认账号,同步写入 native ~/.kimi-code - #[cfg(not(test))] - if self.default_account_id().await.as_deref() == Some(account_id) { - let rt = { - let accounts = self.accounts.read().await; - accounts.get(account_id).map(|a| a.refresh_token.clone()) - }; - if let Some(rt) = rt { - let _ = crate::kimi_config::sync_kimi_account_to_native( - &access_token, - &rt, - expires_in_sec, - expires_at_ms / 1000, - ); - } - } - - Ok(access_token) - } - - #[allow(dead_code)] - pub async fn get_valid_token(&self) -> Result { - let default_id = self.default_account_id().await; - match default_id { - Some(id) => self.get_valid_token_for_account(&id).await, - None => Err(KimiOAuthError::AccountNotFound( - "未设置默认 Kimi 账号".to_string(), - )), - } - } - - pub async fn remove_account(&self, account_id: &str) -> Result<(), KimiOAuthError> { - { - let mut accounts = self.accounts.write().await; - if accounts.remove(account_id).is_none() { - return Err(KimiOAuthError::AccountNotFound(account_id.to_string())); - } - - let mut default_id = self.default_account_id.write().await; - if default_id.as_deref() == Some(account_id) { - *default_id = accounts.keys().next().cloned(); - } - } - - self.access_tokens.write().await.remove(account_id); - self.refresh_locks.write().await.remove(account_id); - self.save_to_disk().await?; - Ok(()) - } - - pub async fn set_default_account(&self, account_id: &str) -> Result<(), KimiOAuthError> { - { - let accounts = self.accounts.read().await; - if !accounts.contains_key(account_id) { - return Err(KimiOAuthError::AccountNotFound(account_id.to_string())); - } - } - - *self.default_account_id.write().await = Some(account_id.to_string()); - self.save_to_disk().await?; - - // 切换默认账号时,自动同步激活至 native ~/.kimi-code - #[cfg(not(test))] - if let Ok(token) = self.get_valid_token_for_account(account_id).await { - let accounts = self.accounts.read().await; - if let Some(acc) = accounts.get(account_id) { - let tokens = self.access_tokens.read().await; - let expires_at_sec = tokens.get(account_id).map(|t| t.expires_at_ms / 1000).unwrap_or(0); - let _ = crate::kimi_config::sync_kimi_account_to_native( - &token, - &acc.refresh_token, - 3600, - expires_at_sec, - ); - } - } - - Ok(()) - } - - pub async fn clear_auth(&self) -> Result<(), KimiOAuthError> { - self.accounts.write().await.clear(); - *self.default_account_id.write().await = None; - self.access_tokens.write().await.clear(); - self.refresh_locks.write().await.clear(); - self.pending_device_codes.write().await.clear(); - - if self.storage_path.exists() { - std::fs::remove_file(&self.storage_path)?; - } - - Ok(()) - } - - #[allow(dead_code)] - pub async fn is_authenticated(&self) -> bool { - !self.accounts.read().await.is_empty() - } - - pub async fn default_account_id(&self) -> Option { - self.default_account_id.read().await.clone() - } - - pub async fn list_accounts(&self) -> Vec { - let accounts = self.accounts.read().await; - let mut list: Vec = accounts.values().map(ManagedAuthAccount::from).collect(); - list.sort_by(|a, b| b.authenticated_at.cmp(&a.authenticated_at)); - list - } - - pub fn find_account_sync( - &self, - refresh_token: &str, - account_id: Option<&str>, - ) -> Option { - if let Ok(accounts) = self.accounts.try_read() { - if !refresh_token.is_empty() { - for acc in accounts.values() { - if acc.refresh_token == refresh_token { - return Some(ManagedAuthAccount::from(acc)); - } - } - } - if let Some(id) = account_id { - if let Some(acc) = accounts.get(id) { - return Some(ManagedAuthAccount::from(acc)); - } - } - } - None - } - - pub async fn get_status(&self) -> KimiOAuthStatus { - let accounts = self.list_accounts().await; - let default_id = self.default_account_id().await; - let authenticated = !accounts.is_empty(); - let username = default_id - .as_ref() - .and_then(|id| accounts.iter().find(|a| &a.id == id)) - .map(|a| a.login.clone()); - - KimiOAuthStatus { - accounts, - default_account_id: default_id, - authenticated, - username, - } - } - - async fn add_account_internal( - &self, - account_id: String, - refresh_token: String, - nickname: Option, - email: Option, - avatar_url: Option, - ) -> Result { - let now = chrono::Utc::now().timestamp(); - let account_data = KimiAccountData { - account_id: account_id.clone(), - nickname, - email, - avatar_url, - refresh_token, - authenticated_at: now, - }; - - let result = ManagedAuthAccount::from(&account_data); - - { - let mut accounts = self.accounts.write().await; - accounts.insert(account_id.clone(), account_data); - } - - { - let mut default_id = self.default_account_id.write().await; - if default_id.is_none() { - *default_id = Some(account_id); - } - } - - self.save_to_disk().await?; - Ok(result) - } - - #[cfg(test)] - pub(crate) async fn seed_account_for_tests( - &self, - account_id: &str, - refresh_token: &str, - nickname: Option<&str>, - email: Option<&str>, - access_token: Option<&str>, - expires_at_ms: Option, - ) -> Result<(), KimiOAuthError> { - self.add_account_internal( - account_id.to_string(), - refresh_token.to_string(), - nickname.map(str::to_string), - email.map(str::to_string), - None, - ) - .await?; - - if let Some(token) = access_token { - let mut tokens = self.access_tokens.write().await; - tokens.insert( - account_id.to_string(), - CachedAccessToken { - token: token.to_string(), - expires_at_ms: expires_at_ms.unwrap_or_else(|| { - chrono::Utc::now().timestamp_millis() + 3600 * 1000 - }), - }, - ); - } - - Ok(()) - } - - fn write_store_atomic(&self, content: &str) -> Result<(), KimiOAuthError> { - let parent = self - .storage_path - .parent() - .ok_or_else(|| KimiOAuthError::IoError("无效的存储路径".to_string()))?; - - std::fs::create_dir_all(parent)?; - - let filename = self - .storage_path - .file_name() - .ok_or_else(|| KimiOAuthError::IoError("无效的存储文件名".to_string()))? - .to_string_lossy(); - - let temp_path = parent.join(format!( - ".{filename}.tmp.{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - - let mut file = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&temp_path)?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - file.set_permissions(fs::Permissions::from_mode(0o600))?; - } - - file.write_all(content.as_bytes())?; - file.sync_all()?; - drop(file); - - if let Err(rename_err) = fs::rename(&temp_path, &self.storage_path) { - let _ = fs::remove_file(&temp_path); - return Err(KimiOAuthError::IoError(format!( - "原子重命名持久化存储文件失败: {rename_err}" - ))); - } - - Ok(()) - } - - fn load_from_disk_sync(&self) -> Result<(), KimiOAuthError> { - if !self.storage_path.exists() { - return Ok(()); - } - - let content = fs::read_to_string(&self.storage_path)?; - let store: KimiOAuthStore = serde_json::from_str(&content) - .map_err(|e| KimiOAuthError::ParseError(e.to_string()))?; - - { - let mut accounts = self - .accounts - .try_write() - .map_err(|_| KimiOAuthError::IoError("获取 accounts 写锁失败".to_string()))?; - *accounts = store.accounts; - } - - { - let mut default_id = self - .default_account_id - .try_write() - .map_err(|_| KimiOAuthError::IoError("获取 default_id 写锁失败".to_string()))?; - *default_id = store.default_account_id; - } - - Ok(()) - } - - async fn save_to_disk(&self) -> Result<(), KimiOAuthError> { - let accounts = self.accounts.read().await.clone(); - let default_id = self.default_account_id.read().await.clone(); - - let store = KimiOAuthStore { - version: 1, - accounts, - default_account_id: default_id, - }; - - let json = serde_json::to_string_pretty(&store) - .map_err(|e| KimiOAuthError::ParseError(e.to_string()))?; - - self.write_store_atomic(&json) - } -} - -fn parse_interval(interval: Option<&serde_json::Value>) -> u64 { - let base = match interval { - Some(serde_json::Value::Number(n)) => n.as_u64().unwrap_or(5), - Some(serde_json::Value::String(s)) => s.parse::().unwrap_or(5), - _ => 5, - }; - base + POLLING_SAFETY_MARGIN_SECS -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_manager_initial_state() { - let temp = tempfile::tempdir().unwrap(); - let manager = KimiOAuthManager::new(temp.path().to_path_buf()); - assert!(!manager.is_authenticated().await); - assert!(manager.list_accounts().await.is_empty()); - } - - #[tokio::test] - async fn test_manager_save_and_load() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().to_path_buf(); - { - let manager = KimiOAuthManager::new(path.clone()); - manager - .add_account_internal( - "u_123".to_string(), - "rt-kimi-secret".to_string(), - Some("KimiUser".to_string()), - Some("user@kimi.ai".to_string()), - None, - ) - .await - .unwrap(); - } - let manager2 = KimiOAuthManager::new(path); - let accounts = manager2.list_accounts().await; - assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0].id, "u_123"); - assert_eq!(accounts[0].login, "KimiUser (user@kimi.ai)"); - } - - #[tokio::test] - async fn test_remove_account_rehomes_default_account() { - let temp = tempfile::tempdir().unwrap(); - let manager = KimiOAuthManager::new(temp.path().to_path_buf()); - - manager - .seed_account_for_tests("u_1", "rt-1", Some("User1"), None, Some("at-1"), None) - .await - .unwrap(); - manager - .seed_account_for_tests("u_2", "rt-2", Some("User2"), None, Some("at-2"), None) - .await - .unwrap(); - manager.set_default_account("u_1").await.unwrap(); - - manager.remove_account("u_1").await.unwrap(); - - let accounts = manager.list_accounts().await; - assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0].id, "u_2"); - assert_eq!(manager.default_account_id().await.as_deref(), Some("u_2")); - } - - #[tokio::test] - async fn test_set_default_account() { - let temp = tempfile::tempdir().unwrap(); - let manager = KimiOAuthManager::new(temp.path().to_path_buf()); - - manager - .seed_account_for_tests("u_1", "rt-1", Some("User1"), None, Some("at-1"), None) - .await - .unwrap(); - manager - .seed_account_for_tests("u_2", "rt-2", Some("User2"), None, Some("at-2"), None) - .await - .unwrap(); - - manager.set_default_account("u_2").await.unwrap(); - assert_eq!(manager.default_account_id().await.as_deref(), Some("u_2")); - - let status = manager.get_status().await; - assert_eq!(status.default_account_id.as_deref(), Some("u_2")); - } -} diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 25646ce4e..4d300687b 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -5,7 +5,6 @@ mod codex; pub(crate) mod codex_chat_common; pub mod codex_chat_history; pub mod codex_oauth_auth; -pub mod kimi_oauth_auth; pub(crate) mod codex_responses_sse; #[allow(dead_code)] pub mod copilot_auth; diff --git a/src-tauri/src/services/auth.rs b/src-tauri/src/services/auth.rs index d18a91130..6e28a81b1 100644 --- a/src-tauri/src/services/auth.rs +++ b/src-tauri/src/services/auth.rs @@ -1,11 +1,7 @@ use crate::proxy::providers::codex_oauth_auth::CodexOAuthError; -use crate::proxy::providers::kimi_oauth_auth::KimiOAuthError; -use crate::services::{CodexOAuthService, KimiOAuthService}; +use crate::services::CodexOAuthService; const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth"; -const AUTH_PROVIDER_KIMI_OAUTH: &str = "kimi_oauth"; -const AUTH_PROVIDER_KIMI_CODE: &str = "kimi-code"; -const AUTH_PROVIDER_KIMI: &str = "kimi"; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct ManagedAuthAccount { @@ -39,9 +35,6 @@ pub struct ManagedAuthDeviceCodeResponse { fn ensure_auth_provider(auth_provider: &str) -> Result<&'static str, String> { match auth_provider { AUTH_PROVIDER_CODEX_OAUTH => Ok(AUTH_PROVIDER_CODEX_OAUTH), - AUTH_PROVIDER_KIMI_OAUTH | AUTH_PROVIDER_KIMI_CODE | AUTH_PROVIDER_KIMI => { - Ok(AUTH_PROVIDER_KIMI_OAUTH) - } _ => Err(format!("Unsupported auth provider: {auth_provider}")), } } @@ -61,21 +54,6 @@ fn map_account( } } -fn map_kimi_account( - provider: &str, - account: crate::proxy::providers::kimi_oauth_auth::ManagedAuthAccount, - default_account_id: Option<&str>, -) -> ManagedAuthAccount { - ManagedAuthAccount { - is_default: default_account_id == Some(account.id.as_str()), - id: account.id, - provider: provider.to_string(), - login: account.login, - avatar_url: account.avatar_url, - authenticated_at: account.authenticated_at, - } -} - fn map_device_code_response( provider: &str, response: crate::proxy::providers::codex_oauth_auth::ManagedAuthDeviceCodeResponse, @@ -90,20 +68,6 @@ fn map_device_code_response( } } -fn map_kimi_device_code_response( - provider: &str, - response: crate::proxy::providers::kimi_oauth_auth::ManagedAuthDeviceCodeResponse, -) -> ManagedAuthDeviceCodeResponse { - ManagedAuthDeviceCodeResponse { - provider: provider.to_string(), - device_code: response.device_code, - user_code: response.user_code, - verification_uri: response.verification_uri, - expires_in: response.expires_in, - interval: response.interval, - } -} - pub struct AuthService; impl AuthService { @@ -114,10 +78,6 @@ impl AuthService { .await .map(|response| map_device_code_response(auth_provider, response)) .map_err(|error| error.to_string()), - AUTH_PROVIDER_KIMI_OAUTH => KimiOAuthService::start_device_flow() - .await - .map(|response| map_kimi_device_code_response(auth_provider, response)) - .map_err(|error| error.to_string()), _ => unreachable!(), } } @@ -140,18 +100,6 @@ impl AuthService { Err(CodexOAuthError::AuthorizationPending) => Ok(None), Err(error) => Err(error.to_string()), }, - AUTH_PROVIDER_KIMI_OAUTH => match KimiOAuthService::poll_for_token(device_code).await - { - Ok(account) => { - let default_account_id = - KimiOAuthService::get_status().await.default_account_id; - Ok(account.map(|account| { - map_kimi_account(auth_provider, account, default_account_id.as_deref()) - })) - } - Err(KimiOAuthError::AuthorizationPending) => Ok(None), - Err(error) => Err(error.to_string()), - }, _ => unreachable!(), } } @@ -170,17 +118,6 @@ impl AuthService { }) .collect()) } - AUTH_PROVIDER_KIMI_OAUTH => { - let status = KimiOAuthService::get_status().await; - let default_account_id = status.default_account_id.clone(); - Ok(status - .accounts - .into_iter() - .map(|account| { - map_kimi_account(auth_provider, account, default_account_id.as_deref()) - }) - .collect()) - } _ => unreachable!(), } } @@ -205,23 +142,6 @@ impl AuthService { .collect(), }) } - AUTH_PROVIDER_KIMI_OAUTH => { - let status = KimiOAuthService::get_status().await; - let default_account_id = status.default_account_id.clone(); - Ok(ManagedAuthStatus { - provider: auth_provider.to_string(), - authenticated: status.authenticated, - default_account_id: default_account_id.clone(), - migration_error: None, - accounts: status - .accounts - .into_iter() - .map(|account| { - map_kimi_account(auth_provider, account, default_account_id.as_deref()) - }) - .collect(), - }) - } _ => unreachable!(), } } @@ -232,9 +152,6 @@ impl AuthService { AUTH_PROVIDER_CODEX_OAUTH => CodexOAuthService::remove_account(account_id) .await .map_err(|error| error.to_string()), - AUTH_PROVIDER_KIMI_OAUTH => KimiOAuthService::remove_account(account_id) - .await - .map_err(|error| error.to_string()), _ => unreachable!(), } } @@ -245,9 +162,6 @@ impl AuthService { AUTH_PROVIDER_CODEX_OAUTH => CodexOAuthService::set_default_account(account_id) .await .map_err(|error| error.to_string()), - AUTH_PROVIDER_KIMI_OAUTH => KimiOAuthService::set_default_account(account_id) - .await - .map_err(|error| error.to_string()), _ => unreachable!(), } } @@ -258,9 +172,6 @@ impl AuthService { AUTH_PROVIDER_CODEX_OAUTH => CodexOAuthService::clear_auth() .await .map_err(|error| error.to_string()), - AUTH_PROVIDER_KIMI_OAUTH => KimiOAuthService::clear_auth() - .await - .map_err(|error| error.to_string()), _ => unreachable!(), } } @@ -307,67 +218,9 @@ mod tests { assert_eq!(status.provider, "codex_oauth"); assert!(status.authenticated); assert_eq!(status.default_account_id.as_deref(), Some("acc-456")); - let default_account = status - .accounts - .iter() - .find(|account| account.id == "acc-456") - .expect("find default account"); - assert!(default_account.is_default); - } - - #[tokio::test] - #[expect( - clippy::await_holding_lock, - reason = "test serializes global auth manager state" - )] - async fn kimi_auth_status_marks_default_account() { - let _lock = lock_test_home_and_settings(); - let temp_kimi = tempfile::tempdir().expect("create tempdir"); - let old_kimi_env = std::env::var_os("KIMI_CODE_HOME"); - std::env::set_var("KIMI_CODE_HOME", temp_kimi.path()); - - let _manager = KimiOAuthService::test_manager_with_account( - "kimi-123", - "rt-1", - Some("User1"), - Some("u1@example.com"), - Some("at-1"), - None, - ) - .await - .expect("seed first account"); - KimiOAuthService::seed_account_for_tests( - "kimi-456", - "rt-2", - Some("User2"), - Some("u2@example.com"), - Some("at-2"), - None, - ) - .await - .expect("seed second account"); - AuthService::set_default_account("kimi_oauth", "kimi-456") - .await - .expect("set default account"); - - let status = AuthService::get_status("kimi_oauth") - .await - .expect("get auth status"); - - assert_eq!(status.provider, "kimi_oauth"); - assert!(status.authenticated); - assert_eq!(status.default_account_id.as_deref(), Some("kimi-456")); - let default_account = status - .accounts - .iter() - .find(|account| account.id == "kimi-456") - .expect("find default account"); - assert!(default_account.is_default); - - if let Some(val) = old_kimi_env { - std::env::set_var("KIMI_CODE_HOME", val); - } else { - std::env::remove_var("KIMI_CODE_HOME"); - } + assert_eq!(status.accounts.len(), 2); + assert_eq!(status.accounts[0].id, "acc-456"); + assert!(status.accounts[0].is_default); + assert!(!status.accounts[1].is_default); } } diff --git a/src-tauri/src/services/kimi_oauth.rs b/src-tauri/src/services/kimi_oauth.rs deleted file mode 100644 index 7877294fc..000000000 --- a/src-tauri/src/services/kimi_oauth.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::path::PathBuf; -use std::sync::{Arc, OnceLock, RwLock}; - -use crate::config::get_app_config_dir; -use crate::proxy::providers::kimi_oauth_auth::{ - KimiOAuthError, KimiOAuthManager, KimiOAuthStatus, ManagedAuthAccount, - ManagedAuthDeviceCodeResponse, -}; - -type KimiOAuthManagerStore = RwLock)>>; - -fn manager_store() -> &'static KimiOAuthManagerStore { - static STORE: OnceLock = OnceLock::new(); - STORE.get_or_init(|| RwLock::new(None)) -} - -#[cfg(test)] -fn test_manager_override() -> &'static RwLock>> { - static STORE: OnceLock>>> = OnceLock::new(); - STORE.get_or_init(|| RwLock::new(None)) -} - -#[cfg(test)] -pub(crate) struct TestKimiOAuthManagerGuard { - _temp: tempfile::TempDir, - _manager: Arc, -} - -#[cfg(test)] -impl Drop for TestKimiOAuthManagerGuard { - fn drop(&mut self) { - KimiOAuthService::reset_for_tests(); - } -} - -pub struct KimiOAuthService; - -impl KimiOAuthService { - pub fn manager() -> Arc { - #[cfg(test)] - { - let guard = test_manager_override() - .read() - .expect("read kimi oauth test manager"); - if let Some(manager) = guard.as_ref() { - return Arc::clone(manager); - } - } - - let path = get_app_config_dir(); - { - let guard = manager_store().read().expect("read kimi oauth manager"); - if let Some((cached_path, manager)) = guard.as_ref() { - if cached_path == &path { - return Arc::clone(manager); - } - } - } - - let manager = Arc::new(KimiOAuthManager::new(path.clone())); - let mut guard = manager_store().write().expect("write kimi oauth manager"); - *guard = Some((path, Arc::clone(&manager))); - manager - } - - #[cfg(test)] - pub(crate) fn set_manager_for_tests(manager: Arc) { - let mut guard = test_manager_override() - .write() - .expect("write kimi oauth test manager"); - *guard = Some(manager); - } - - #[cfg(test)] - pub(crate) fn reset_for_tests() { - let mut guard = test_manager_override() - .write() - .expect("reset kimi oauth test manager"); - *guard = None; - let mut store = manager_store() - .write() - .expect("reset kimi oauth manager store"); - *store = None; - } - - #[cfg(test)] - pub(crate) async fn test_manager_with_account( - account_id: &str, - refresh_token: &str, - nickname: Option<&str>, - email: Option<&str>, - access_token: Option<&str>, - expires_at_ms: Option, - ) -> Result { - let temp = tempfile::tempdir().expect("create tempdir"); - let manager = Arc::new(KimiOAuthManager::new(temp.path().to_path_buf())); - manager - .seed_account_for_tests( - account_id, - refresh_token, - nickname, - email, - access_token, - expires_at_ms, - ) - .await?; - Self::set_manager_for_tests(Arc::clone(&manager)); - Ok(TestKimiOAuthManagerGuard { - _temp: temp, - _manager: manager, - }) - } - - #[cfg(test)] - pub(crate) async fn seed_account_for_tests( - account_id: &str, - refresh_token: &str, - nickname: Option<&str>, - email: Option<&str>, - access_token: Option<&str>, - expires_at_ms: Option, - ) -> Result<(), KimiOAuthError> { - let manager = Self::manager(); - manager - .seed_account_for_tests( - account_id, - refresh_token, - nickname, - email, - access_token, - expires_at_ms, - ) - .await - } - - pub async fn start_device_flow() -> Result { - Self::manager().start_device_flow().await - } - - pub async fn poll_for_token( - device_code: &str, - ) -> Result, KimiOAuthError> { - Self::manager().poll_for_token(device_code).await - } - - pub async fn get_status() -> KimiOAuthStatus { - Self::manager().get_status().await - } - - pub async fn remove_account(account_id: &str) -> Result<(), KimiOAuthError> { - Self::manager().remove_account(account_id).await - } - - pub async fn set_default_account(account_id: &str) -> Result<(), KimiOAuthError> { - Self::manager().set_default_account(account_id).await - } - - pub async fn clear_auth() -> Result<(), KimiOAuthError> { - Self::manager().clear_auth().await - } - - #[allow(dead_code)] - pub async fn get_valid_token_for_account( - account_id: &str, - ) -> Result { - Self::manager().get_valid_token_for_account(account_id).await - } - - #[allow(dead_code)] - pub async fn get_valid_token() -> Result { - Self::manager().get_valid_token().await - } -} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 5f6ca332c..dd7f9f684 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -6,7 +6,6 @@ pub mod codex_oauth_models; pub mod coding_plan; pub mod config; pub mod copilot_auth; -pub mod kimi_oauth; #[cfg(feature = "cli")] pub mod env_checker; #[allow(dead_code)] @@ -46,7 +45,6 @@ pub mod webdav_sync; pub use auth::{AuthService, ManagedAuthAccount, ManagedAuthDeviceCodeResponse, ManagedAuthStatus}; pub use codex_oauth::CodexOAuthService; -pub use kimi_oauth::KimiOAuthService; pub use config::ConfigService; pub use copilot_auth::CopilotAuthService; pub use global_proxy::GlobalOutboundProxyConfig; From c4ce99eec2dc7c25ff2391d7b32ff128e399b2fe Mon Sep 17 00:00:00 2001 From: Ferris Date: Wed, 30 Sep 2026 18:04:43 +0800 Subject: [PATCH 13/13] fix: preserve Kimi native configuration and local enablement Signed-off-by: Ferris --- docs/kimi-sync-compatibility.md | 21 + src-tauri/src/cli/commands/provider_input.rs | 27 +- src-tauri/src/cli/tui/form/provider_json.rs | 76 ++- .../cli/tui/form/provider_state_loading.rs | 15 +- src-tauri/src/kimi_config.rs | 580 +++++++++++++++--- src-tauri/src/services/skill.rs | 27 + src-tauri/src/store.rs | 86 ++- 7 files changed, 719 insertions(+), 113 deletions(-) create mode 100644 docs/kimi-sync-compatibility.md diff --git a/docs/kimi-sync-compatibility.md b/docs/kimi-sync-compatibility.md new file mode 100644 index 000000000..255f52bb9 --- /dev/null +++ b/docs/kimi-sync-compatibility.md @@ -0,0 +1,21 @@ +# Kimi configuration and database synchronization + +The Kimi adapter uses the existing `providers` table with `app_type = "kimi"` and JSON `settings_config`. It does not add tables or columns, or change schema version 18. The native provider attributes are retained in `providerConfig`; each model retains its local alias in `id` and its native attributes (including the upstream `model` and context size) in `config`. Existing flat provider input remains accepted. Cross-provider alias collisions are rejected before writing live configuration. + +The database SQL export/import path carries these provider rows unchanged. The regression test covers export for sync, import into another database and reconstruction in an empty Kimi directory. This demonstrates this CLI's round-trip behavior; it does not claim that another upstream client understands Kimi or that all upstream versions retain unknown app rows. + +Kimi MCP/Skills enablement is **local deployment state**. The existing shared database has no Kimi enable columns. As with Pi skills, Kimi skill enablement is reconstructed from the native skill directory. MCP enablement is reconstructed from membership in the local `mcp.json`. Restarting retains these states without a schema fork. SQL/WebDAV synchronization carries the shared MCP definitions and skill catalog, but does **not** copy the Kimi enable flags to another machine. Enable the desired definitions on that machine explicitly. Native OAuth files remain managed by Kimi CLI. + +## 中文说明 + +Kimi Provider 继续使用现有 providers 表和 settings_config JSON,数据库 schema 保持 18,不新增表或列。模型别名与真实模型 ID 分开保存,原始扩展字段随配置保留;跨供应商同名模型冲突会在写入前被拒绝。回归测试覆盖数据库同步导出、导入另一数据库以及空目录恢复,但不代表其他上游客户端已支持 Kimi 或保证保留未知 app 数据。 + +Kimi 的 MCP/Skills 开关属于本机部署状态,从本机 mcp.json 和 skills 目录恢复。重启不会丢失,但数据库/WebDAV 不会将这些开关自动复制到另一机器;目标机器需显式启用。共享定义与 Provider 配置仍走既有数据库同步。Kimi CLI 继续负责原生 OAuth 文件。 + +## Original CC-Switch revision checked + +The upstream named here is **farion1231/cc-switch**, not the CLI fork. At original main `36d950411e622129f285635f43c17e5f35462413`, `src-tauri/src/database/mod.rs:53` declares schema **19**, while this CLI's base declares **18**. The upstream v18→v19 migration adds `enabled_mcode` to MCP/Skills. Its SQL exporter serializes table rows without filtering the provider `app_type`; its AppType enum does not include Kimi. Provider payload storage is opaque JSON, and the providers table has no app-type CHECK constraint. + +This PR does not add a further schema divergence. It does **not** resolve the existing fork-wide v19 compatibility gap: a database migrated by the current original application is rejected as too new by this CLI on return. Aligning the shared schema/migrations requires a separate upstream-coordinated change, not a Kimi-only schema increment. Do not advertise latest-original ↔ CLI bidirectional synchronization as verified by this PR. + +核对的原版为 farion1231/cc-switch,固定到上述完整 SHA:原版 schema 19、CLI 基线 schema 18。原版升版后的整库回传会触发 CLI 的新版本拒绝保护。这是两项目既有的版本差异,本 Kimi 修复不私自改共享 schema,也不能据此宣称与原版最新版双向同步已经通过。原版尚无 Kimi AppType;其通用 SQL 导出没有按 app_type 过滤 Provider 行。 diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index 60111d9e1..1e3c75c72 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -144,7 +144,9 @@ pub fn common_snippet_has_effective_config( .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|obj| !obj.is_empty()), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { + false + } } } @@ -3896,12 +3898,10 @@ fn prompt_kimi_config(current: Option<&Value>) -> Result { println!("\n{}", "Kimi Code".bright_cyan().bold()); let default_api_key = current - .and_then(|v| v.get("api_key").or_else(|| v.get("apiKey"))) - .and_then(Value::as_str) + .and_then(crate::kimi_config::provider_api_key) .unwrap_or_default(); let default_base_url = current - .and_then(|v| v.get("base_url").or_else(|| v.get("baseUrl"))) - .and_then(Value::as_str) + .and_then(crate::kimi_config::provider_base_url) .unwrap_or("https://api.moonshot.cn/v1"); let default_model = current .and_then(|v| v.get("model")) @@ -3923,18 +3923,11 @@ fn prompt_kimi_config(current: Option<&Value>) -> Result { .prompt() .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; - let mut map = serde_json::Map::new(); - map.insert( - "api_key".to_string(), - Value::String(api_key.trim().to_string()), - ); - map.insert( - "base_url".to_string(), - Value::String(base_url.trim().to_string()), - ); - if !model.trim().is_empty() { - map.insert("model".to_string(), Value::String(model.trim().to_string())); - } + let mut map = current + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + crate::kimi_config::set_editor_fields(&mut map, &base_url, &api_key, &model); Ok(Value::Object(map)) } diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs index 1e6103648..7674dadcd 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -872,11 +872,12 @@ impl ProviderAddFormState { .trim() .trim_end_matches('/') .to_string(); - set_or_remove_trimmed(settings_obj, "base_url", &base_url); - set_or_remove_trimmed(settings_obj, "api_key", &self.hermes_api_key.value); - if !self.claude_model.value.trim().is_empty() { - set_or_remove_trimmed(settings_obj, "model", &self.claude_model.value); - } + crate::kimi_config::set_editor_fields( + settings_obj, + &base_url, + &self.hermes_api_key.value, + &self.claude_model.value, + ); } } @@ -1657,3 +1658,68 @@ fn set_or_remove_f64(obj: &mut serde_json::Map, key: &str, raw: & obj.remove(key); } } + +#[cfg(test)] +mod kimi_roundtrip_tests { + use super::*; + #[test] + fn kimi_native_only_noop_edit_keeps_connection_and_clear_is_explicit() { + let provider = crate::provider::Provider::with_id( + "native".into(), + "native".into(), + json!({ + "providerConfig":{"type":"kimi","base_url":"https://example.invalid/v1","api_key":"native-secret"}, + "model":" fast ", "models":[{"id":" fast ","config":{"model":"kimi-k2.5"}}] + }), + None, + ); + let mut form = ProviderAddFormState::from_provider(AppType::Kimi, &provider); + let value = form.to_provider_json_value(); + assert_eq!( + value["settingsConfig"]["base_url"], + "https://example.invalid/v1" + ); + assert_eq!(value["settingsConfig"]["api_key"], "native-secret"); + assert_eq!(value["settingsConfig"]["model"], " fast "); + form.hermes_api_key.set(""); + let cleared = form.to_provider_json_value(); + assert!(!cleared["settingsConfig"] + .to_string() + .contains("native-secret")); + } + + #[test] + fn kimi_form_keeps_native_metadata_and_replaces_imported_alias_fields() { + let provider = crate::provider::Provider::with_id( + "native".into(), + "native".into(), + json!({ + "type":"kimi", "baseUrl":"https://old.invalid", "base_url":"https://old.invalid", + "apiKey":"old", "api_key":"old", "model":" fast ", + "providerConfig":{"type":"kimi","custom":"preserve","api_key":"superseded-secret","base_url":"https://old.invalid"}, + "models":[{"id":" fast ","config":{"model":"kimi-k2.5","max_context_size":262144}}] + }), + None, + ); + let mut form = ProviderAddFormState::from_provider(AppType::Kimi, &provider); + form.hermes_base_url.set("https://new.invalid"); + form.hermes_api_key.set(""); + let value = form.to_provider_json_value(); + let settings = &value["settingsConfig"]; + assert_eq!(settings["base_url"], "https://new.invalid"); + assert_eq!(settings["api_key"], ""); + assert_eq!(settings["model"], " fast "); + assert!(settings.get("apiKey").is_none()); + assert!(settings.get("baseUrl").is_none()); + assert_eq!(settings["models"], provider.settings_config["models"]); + assert_eq!( + settings["providerConfig"], + json!({"type":"kimi","custom":"preserve"}) + ); + assert!(!settings.to_string().contains("superseded-secret")); + form.hermes_api_key.set("replacement-secret"); + let replaced = form.to_provider_json_value(); + assert_eq!(replaced["settingsConfig"]["api_key"], "replacement-secret"); + assert!(!replaced.to_string().contains("superseded-secret")); + } +} diff --git a/src-tauri/src/cli/tui/form/provider_state_loading.rs b/src-tauri/src/cli/tui/form/provider_state_loading.rs index dd367f8c2..efbcb8f4d 100644 --- a/src-tauri/src/cli/tui/form/provider_state_loading.rs +++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs @@ -441,21 +441,10 @@ fn populate_hermes_form(form: &mut ProviderAddFormState, provider: &Provider) { fn populate_kimi_form(form: &mut ProviderAddFormState, provider: &Provider) { let settings = &provider.settings_config; - if let Some(base_url) = settings - .get("base_url") - .or_else(|| settings.get("baseUrl")) - .or_else(|| settings.get("baseURL")) - .or_else(|| settings.get("endpoint")) - .and_then(|value| value.as_str()) - { + if let Some(base_url) = crate::kimi_config::provider_base_url(settings) { form.hermes_base_url.set(base_url); } - if let Some(api_key) = settings - .get("api_key") - .or_else(|| settings.get("apiKey")) - .or_else(|| settings.get("auth_token")) - .and_then(|value| value.as_str()) - { + if let Some(api_key) = crate::kimi_config::provider_api_key(settings) { form.hermes_api_key.set(api_key); } if let Some(model) = settings.get("model").and_then(|value| value.as_str()) { diff --git a/src-tauri/src/kimi_config.rs b/src-tauri/src/kimi_config.rs index 608ba3188..cc1d91d38 100644 --- a/src-tauri/src/kimi_config.rs +++ b/src-tauri/src/kimi_config.rs @@ -14,6 +14,54 @@ pub const DEFAULT_KIMI_CONFIG_DIR: &str = ".kimi-code"; pub const KIMI_HOME_ENV: &str = "KIMI_CODE_HOME"; pub const KIMI_CONFIG_FILE: &str = "config.toml"; +const BASE_URL_FIELDS: &[&str] = &["base_url", "baseUrl", "baseURL", "endpoint"]; +const API_KEY_FIELDS: &[&str] = &["api_key", "apiKey", "auth_token"]; + +fn connection_field<'a>(settings: &'a Value, names: &[&str]) -> Option<&'a str> { + names + .iter() + .find_map(|name| settings.get(*name)) + .or_else(|| { + settings + .get("providerConfig") + .and_then(|native| names.iter().find_map(|name| native.get(*name))) + }) + .and_then(Value::as_str) +} + +pub(crate) fn provider_base_url(settings: &Value) -> Option<&str> { + connection_field(settings, BASE_URL_FIELDS) +} + +pub(crate) fn provider_api_key(settings: &Value) -> Option<&str> { + connection_field(settings, API_KEY_FIELDS) +} + +/// Canonicalize edited connection fields without retaining superseded aliases or secrets. +pub(crate) fn set_editor_fields( + settings: &mut serde_json::Map, + base_url: &str, + api_key: &str, + model: &str, +) { + for name in BASE_URL_FIELDS.iter().chain(API_KEY_FIELDS) { + settings.remove(*name); + } + if let Some(native) = settings + .get_mut("providerConfig") + .and_then(Value::as_object_mut) + { + for name in BASE_URL_FIELDS.iter().chain(API_KEY_FIELDS) { + native.remove(*name); + } + } + settings.insert("base_url".into(), json!(base_url.trim())); + settings.insert("api_key".into(), json!(api_key.trim())); + if !model.is_empty() { + settings.insert("model".into(), json!(model)); + } +} + /// 解析 Kimi Code 根目录路径(遵循 KIMI_CODE_HOME 环境变量,默认 ~/.kimi-code) pub fn get_kimi_config_dir() -> PathBuf { if let Some(override_dir) = crate::settings::get_kimi_override_dir() { @@ -25,16 +73,12 @@ pub fn get_kimi_config_dir() -> PathBuf { } } #[cfg(test)] + if crate::test_support::test_home_override().is_none() + && std::env::var_os("CC_SWITCH_TEST_HOME").is_none() { - // 单元测试未显式设置 KIMI_CODE_HOME 时,绝不能回退到宿主真实目录,防止测试副作用篡改真实凭据 - std::env::temp_dir().join("cc-switch-kimi-test-isolated") - } - #[cfg(not(test))] - { - dirs::home_dir() - .map(|p| p.join(DEFAULT_KIMI_CONFIG_DIR)) - .unwrap_or_else(|| PathBuf::from(DEFAULT_KIMI_CONFIG_DIR)) + return std::env::temp_dir().join("cc-switch-kimi-test-isolated"); } + crate::config::get_home_dir().join(DEFAULT_KIMI_CONFIG_DIR) } /// 获取 Kimi Code 的主配置文件路径 (~/.kimi-code/config.toml) @@ -73,7 +117,7 @@ pub fn read_kimi_config_source() -> Result, AppError> { pub fn write_kimi_config_source(source: &str) -> Result<(), AppError> { let path = get_kimi_config_path(); - write_file_atomic(&path, source, 0o644).map_err(|e| AppError::Message(e.to_string())) + write_file_atomic(&path, source, 0o600).map_err(|e| AppError::Message(e.to_string())) } pub fn read_kimi_config_json() -> Result { @@ -106,6 +150,11 @@ pub fn get_providers() -> Result, AppError> { return Ok(IndexMap::new()); }; + let native: toml::Value = toml::from_str(&content) + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))?; + let native = serde_json::to_value(native) + .map_err(|e| AppError::Config(format!("Failed to convert Kimi config: {e}")))?; + let default_model_opt = doc.get("default_model").and_then(|v| v.as_str()); let mut result = IndexMap::new(); @@ -143,6 +192,8 @@ pub fn get_providers() -> Result, AppError> { models.push(json!({ "id": m_name, "name": display_name, + // The table key is a local alias, not the upstream model ID. + "config": native["models"][m_name].clone(), })); } } @@ -151,7 +202,10 @@ pub fn get_providers() -> Result, AppError> { // Determine primary model let primary_model = if let Some(def_m) = default_model_opt { - if models.iter().any(|m| m.get("id").and_then(Value::as_str) == Some(def_m)) { + if models + .iter() + .any(|m| m.get("id").and_then(Value::as_str) == Some(def_m)) + { Some(def_m.to_string()) } else { models @@ -169,6 +223,10 @@ pub fn get_providers() -> Result, AppError> { }; let mut obj = serde_json::Map::new(); + obj.insert( + "providerConfig".to_string(), + native["providers"][p_id].clone(), + ); obj.insert("name".to_string(), json!(p_id)); obj.insert("type".to_string(), json!(p_type)); if !base_url.is_empty() { @@ -196,6 +254,63 @@ pub fn get_provider(id: &str) -> Result, AppError> { Ok(get_providers()?.get(id).cloned()) } +fn validate_native_fields(config: &Value, model: bool) -> Result<(), AppError> { + let invalid = || { + AppError::localized( + "kimi.native.invalid_fields", + "Kimi 原生配置字段类型无效", + "Invalid field types in Kimi native configuration", + ) + }; + let fields = config.as_object().ok_or_else(invalid)?; + let string_fields: &[&str] = if model { + &["model", "provider", "display_name"] + } else { + &[ + "type", + "base_url", + "baseUrl", + "baseURL", + "endpoint", + "api_key", + "apiKey", + "auth_token", + ] + }; + for key in string_fields { + if let Some(value) = fields.get(*key) { + if !value.is_string() || (*key == "model" && value.as_str().unwrap().trim().is_empty()) + { + return Err(invalid()); + } + } + } + if model { + if let Some(size) = fields.get("max_context_size") { + if size.as_u64().is_none_or(|size| size == 0) { + return Err(invalid()); + } + } + if let Some(capabilities) = fields.get("capabilities") { + if capabilities + .as_array() + .is_none_or(|values| values.iter().any(|v| !v.is_string())) + { + return Err(invalid()); + } + } + } + Ok(()) +} + +fn native_document(config: &Value) -> Result { + let source = toml::to_string(config) + .map_err(|e| AppError::Config(format!("Invalid Kimi native config: {e}")))?; + source + .parse::() + .map_err(|e| AppError::Config(format!("Invalid Kimi native config: {e}"))) +} + /// 准备将 provider 变更写入 Kimi 的 config.toml,返回更新后的 TOML 文本 pub fn prepare_provider(id: &str, provider_config: Value) -> Result { let path = get_kimi_config_path(); @@ -215,9 +330,9 @@ pub fn prepare_provider(id: &str, provider_config: Value) -> Result Result Result Result Result<(), AppError> { .lock() .map_err(|_| AppError::Message("Kimi write lock poisoned".into()))?; let path = get_kimi_config_path(); - write_file_atomic(&path, content, 0o644).map_err(|e| AppError::Message(e.to_string())) + write_file_atomic(&path, content, 0o600).map_err(|e| AppError::Message(e.to_string())) } /// 从 config.toml 中移除指定 provider 及其关联的 models @@ -368,7 +536,7 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> { } } - write_file_atomic(&path, &doc.to_string(), 0o644).map_err(|e| AppError::Message(e.to_string())) + write_file_atomic(&path, &doc.to_string(), 0o600).map_err(|e| AppError::Message(e.to_string())) } /// 获取当前激活的 provider ID(根据 default_model 追溯) @@ -436,17 +604,26 @@ pub fn set_current_provider(id: &str, provider_config: &Value) -> Result<(), App if doc.get("models").is_none() { doc["models"] = toml_edit::Item::Table(Table::new()); } - if let Some(models) = doc["models"].as_table_like_mut() { - if models.get(&target_model).is_none() { - let mut tbl = Table::new(); - tbl.insert("provider", toml_edit::value(id)); - tbl.insert("model", toml_edit::value(&target_model)); - models.insert(&target_model, toml_edit::Item::Table(tbl)); + let models = doc["models"] + .as_table_like_mut() + .ok_or_else(|| AppError::Config("Kimi models must be a table".into()))?; + { + let owner = models + .get(&target_model) + .and_then(|v| v.as_table_like()) + .and_then(|tbl| tbl.get("provider")) + .and_then(|v| v.as_str()); + if owner != Some(id) { + return Err(AppError::localized( + "kimi.model.invalid_selection", + "所选模型不存在或不属于此供应商", + "Selected model is missing or belongs to another provider", + )); } } doc["default_model"] = toml_edit::value(&target_model); - write_file_atomic(&path, &doc.to_string(), 0o644).map_err(|e| AppError::Message(e.to_string())) + write_file_atomic(&path, &doc.to_string(), 0o600).map_err(|e| AppError::Message(e.to_string())) } /// 设置默认模型 @@ -467,8 +644,21 @@ pub fn set_default_model(model_name: &str) -> Result { .parse::() .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))? }; + if doc + .get("models") + .and_then(|m| m.as_table_like()) + .and_then(|m| m.get(model_name)) + .is_none() + { + return Err(AppError::localized( + "kimi.model.missing", + "所选模型不存在", + "Selected model does not exist", + )); + } doc["default_model"] = toml_edit::value(model_name); - write_file_atomic(&path, &doc.to_string(), 0o644).map_err(|e| AppError::Message(e.to_string()))?; + write_file_atomic(&path, &doc.to_string(), 0o600) + .map_err(|e| AppError::Message(e.to_string()))?; Ok(model_name.to_string()) } @@ -480,15 +670,28 @@ pub fn read_kimi_mcp_servers_map() -> Result, AppError> { } let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; let val: Value = serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))?; - let servers = val - .get("mcpServers") - .and_then(|v| v.as_object()) - .map(|obj| { - obj.iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>() - }) - .unwrap_or_default(); + let root = val.as_object().ok_or_else(|| { + AppError::localized( + "kimi.mcp.invalid_root", + "Kimi MCP 配置必须是对象", + "Kimi MCP config must be an object", + ) + })?; + let servers = match root.get("mcpServers") { + None => HashMap::new(), + Some(value) => value + .as_object() + .ok_or_else(|| { + AppError::localized( + "kimi.mcp.invalid_servers", + "Kimi mcpServers 必须是对象", + "Kimi mcpServers must be an object", + ) + })? + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + }; Ok(servers) } @@ -501,11 +704,12 @@ pub fn set_kimi_mcp_servers_map(servers: &HashMap) -> Result<(), fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; let mut root_obj = if path.exists() { - fs::read_to_string(&path) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v.as_object().cloned()) - .unwrap_or_default() + let source = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + serde_json::from_str::(&source) + .map_err(|e| AppError::json(&path, e))? + .as_object() + .cloned() + .ok_or_else(|| AppError::Config("Kimi MCP config must be an object".into()))? } else { serde_json::Map::new() }; @@ -516,7 +720,7 @@ pub fn set_kimi_mcp_servers_map(servers: &HashMap) -> Result<(), let formatted = serde_json::to_string_pretty(&Value::Object(root_obj)) .map_err(|e| AppError::Message(format!("Failed to format MCP JSON: {e}")))?; - write_file_atomic(&path, &formatted, 0o644).map_err(|e| AppError::Message(e.to_string())) + write_file_atomic(&path, &formatted, 0o600).map_err(|e| AppError::Message(e.to_string())) } /// 同步单个 MCP 服务器到 Kimi live 配置 @@ -543,13 +747,16 @@ pub fn remove_server_from_kimi(id: &str) -> Result<(), AppError> { set_kimi_mcp_servers_map(&servers) } -fn write_file_atomic(path: &Path, content: &str, #[allow(unused_variables)] mode: u32) -> Result<()> { +fn write_file_atomic( + path: &Path, + content: &str, + #[allow(unused_variables)] mode: u32, +) -> Result<()> { let parent = path .parent() .ok_or_else(|| anyhow::anyhow!("无效的路径: {}", path.display()))?; - fs::create_dir_all(parent) - .with_context(|| format!("创建目录失败: {}", parent.display()))?; + fs::create_dir_all(parent).with_context(|| format!("创建目录失败: {}", parent.display()))?; let filename = path .file_name() @@ -589,10 +796,217 @@ fn write_file_atomic(path: &Path, content: &str, #[allow(unused_variables)] mode Ok(()) } +#[cfg(test)] +pub(crate) mod test_support { + pub(crate) struct TestKimiDir { + _dir: tempfile::TempDir, + previous: Option, + } + impl TestKimiDir { + // Caller holds the shared test home/settings lock. + pub(crate) fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let previous = std::env::var_os(super::KIMI_HOME_ENV); + std::env::set_var(super::KIMI_HOME_ENV, dir.path()); + Self { + _dir: dir, + previous, + } + } + } + impl Drop for TestKimiDir { + fn drop(&mut self) { + crate::test_support::restore_env(super::KIMI_HOME_ENV, &self.previous); + } + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn kimi_roundtrip_preserves_native_model_and_provider_attributes() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + let original = r#"default_model = "fast" +[providers.native] +type = "kimi" +base_url = "https://example.invalid/v1" +api_key = "fixture-only" +custom = "preserved" +[providers.native.oauth] +storage = "native-only" +[models.fast] +provider = "native" +model = "kimi-k2.5" +max_context_size = 262144 +capabilities = ["thinking", "image_in"] +"#; + write_kimi_config_source(original).unwrap(); + let imported = get_provider("native").unwrap().unwrap(); + // Database serialization followed by same-machine edit and fresh-machine restore. + let imported: Value = + serde_json::from_str(&serde_json::to_string(&imported).unwrap()).unwrap(); + let expected = read_kimi_config_json().unwrap(); + write_prepared_config(&prepare_provider("native", imported.clone()).unwrap()).unwrap(); + assert_eq!(read_kimi_config_json().unwrap(), expected); + fs::remove_file(get_kimi_config_path()).unwrap(); + write_prepared_config(&prepare_provider("native", imported).unwrap()).unwrap(); + assert_eq!(read_kimi_config_json().unwrap(), expected); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(get_kimi_config_path()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[test] + fn kimi_quoted_alias_restores_verbatim_without_colliding_with_trimmed_alias() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + let original = r#"default_model = " fast " +[providers.native] +type = "kimi" +[models." fast "] +provider = "native" +model = "kimi-k2.5" +max_context_size = 262144 +capabilities = ["thinking"] +"#; + write_kimi_config_source(original).unwrap(); + let mut imported = get_provider("native").unwrap().unwrap(); + set_editor_fields(imported.as_object_mut().unwrap(), "", "", " fast "); + let expected = read_kimi_config_json().unwrap(); + fs::remove_file(get_kimi_config_path()).unwrap(); + write_prepared_config(&prepare_provider("native", imported.clone()).unwrap()).unwrap(); + assert_eq!(read_kimi_config_json().unwrap(), expected); + write_prepared_config(&prepare_provider("other", json!({"model":"fast"})).unwrap()) + .unwrap(); + set_current_provider("native", &imported).unwrap(); + let current = read_kimi_config_json().unwrap(); + assert_eq!(current["default_model"], " fast "); + assert_eq!(current["models"][" fast "]["model"], "kimi-k2.5"); + assert_eq!(current["models"]["fast"]["provider"], "other"); + } + + #[test] + fn kimi_native_provider_type_survives_without_flat_duplicate() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + let native = json!({"providerConfig":{"type":"kimi"},"model":"fast","models":[{"id":"fast","config":{"model":"kimi-k2.5"}}]}); + write_prepared_config(&prepare_provider("native", native.clone()).unwrap()).unwrap(); + assert_eq!( + read_kimi_config_json().unwrap()["providers"]["native"]["type"], + "kimi" + ); + let mut overridden = native; + overridden["type"] = json!("openai"); + write_prepared_config(&prepare_provider("native", overridden).unwrap()).unwrap(); + assert_eq!( + read_kimi_config_json().unwrap()["providers"]["native"]["type"], + "openai" + ); + } + + #[test] + fn kimi_native_connection_fields_survive_noop_edit_and_explicit_clear() { + for api_name in API_KEY_FIELDS { + let mut native = json!({"providerConfig":{"type":"kimi","base_url":"https://example.invalid/v1"},"model":"fast"}); + native["providerConfig"][*api_name] = json!("native-secret"); + let base = provider_base_url(&native).unwrap().to_string(); + let key = provider_api_key(&native).unwrap().to_string(); + assert_eq!(key, "native-secret"); + set_editor_fields(native.as_object_mut().unwrap(), &base, &key, "fast"); + assert_eq!(provider_api_key(&native), Some("native-secret")); + assert_eq!( + provider_base_url(&native), + Some("https://example.invalid/v1") + ); + set_editor_fields(native.as_object_mut().unwrap(), "", "", "fast"); + assert_eq!(provider_api_key(&native), Some("")); + assert!(!native.to_string().contains("native-secret")); + } + } + + #[test] + fn kimi_model_conflict_and_invalid_selection_do_not_change_live_config() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + let config = json!({"type":"openai", "model":"shared"}); + write_prepared_config(&prepare_provider("a", config.clone()).unwrap()).unwrap(); + let before = read_kimi_config_source().unwrap(); + assert!(prepare_provider("b", config.clone()).is_err()); + assert!(set_current_provider("b", &config).is_err()); + assert!(set_default_model("missing").is_err()); + assert_eq!(read_kimi_config_source().unwrap(), before); + assert_eq!(get_current_provider_id().unwrap().as_deref(), Some("a")); + } + + #[test] + fn kimi_native_credential_aliases_are_removed_after_import_and_edit() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + let original = "[providers.native]\ntype = 'openai'\napiKey = 'superseded-native-secret'\nbaseUrl = 'https://old.invalid'\n"; + for replacement in ["replacement-secret", ""] { + write_kimi_config_source(original).unwrap(); + let mut imported = get_provider("native").unwrap().unwrap(); + set_editor_fields( + imported.as_object_mut().unwrap(), + "https://new.invalid", + replacement, + "", + ); + assert!(!imported.to_string().contains("superseded-native-secret")); + assert!(!imported.to_string().contains("https://old.invalid")); + write_prepared_config(&prepare_provider("native", imported).unwrap()).unwrap(); + let live = read_kimi_config_source().unwrap().unwrap(); + assert!(!live.contains("superseded-native-secret")); + assert!(!live.contains("https://old.invalid")); + let current = get_provider("native").unwrap().unwrap(); + assert_eq!( + current.get("api_key").and_then(Value::as_str).unwrap_or(""), + replacement + ); + } + } + + #[test] + fn kimi_invalid_native_fields_preserve_existing_live_bytes() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + write_prepared_config(&prepare_provider("a", json!({"model":"fast"})).unwrap()).unwrap(); + let before = read_kimi_config_source().unwrap(); + for config in [ + json!({"model":123}), + json!({"model":""}), + json!({"max_context_size":"bad"}), + json!({"capabilities":[123]}), + ] { + let invalid = json!({"model":"fast", "models":[{"id":"fast","config":config}]}); + assert!(prepare_provider("a", invalid).is_err()); + assert_eq!(read_kimi_config_source().unwrap(), before); + } + assert!(prepare_provider("a", json!({"providerConfig":{"api_key":123}})).is_err()); + assert_eq!(read_kimi_config_source().unwrap(), before); + } + + #[test] + fn kimi_mcp_write_preserves_malformed_input() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + fs::write(get_kimi_mcp_path(), "{broken").unwrap(); + assert!(set_kimi_mcp_servers_map(&HashMap::new()).is_err()); + assert_eq!(fs::read_to_string(get_kimi_mcp_path()).unwrap(), "{broken"); + } + #[test] fn test_kimi_provider_crud_and_mcp() { let _lock = crate::test_support::lock_test_home_and_settings(); @@ -647,7 +1061,10 @@ mod tests { // Switch to openrouter set_current_provider("openrouter", &or_config).unwrap(); - assert_eq!(get_current_provider_id().unwrap(), Some("openrouter".to_string())); + assert_eq!( + get_current_provider_id().unwrap(), + Some("openrouter".to_string()) + ); // 4. Remove cortex remove_provider("cortex").unwrap(); @@ -655,7 +1072,10 @@ mod tests { assert_eq!(providers.len(), 1); assert!(!providers.contains_key("cortex")); assert!(providers.contains_key("openrouter")); - assert_eq!(get_current_provider_id().unwrap(), Some("openrouter".to_string())); + assert_eq!( + get_current_provider_id().unwrap(), + Some("openrouter".to_string()) + ); // 5. MCP sync let dummy_cfg = MultiAppConfig::default(); @@ -699,10 +1119,16 @@ mod tests { let prepared = prepare_provider("moonshot", provider_config).unwrap(); write_prepared_config(&prepared).unwrap(); - assert_eq!(get_current_provider_id().unwrap(), Some("moonshot".to_string())); + assert_eq!( + get_current_provider_id().unwrap(), + Some("moonshot".to_string()) + ); set_default_model("moonshot-v1-32k").unwrap(); - assert_eq!(get_current_provider_id().unwrap(), Some("moonshot".to_string())); + assert_eq!( + get_current_provider_id().unwrap(), + Some("moonshot".to_string()) + ); let json = read_kimi_config_json().unwrap(); assert_eq!(json["default_model"], "moonshot-v1-32k"); diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 6c4d7c62d..464a1bcd3 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -846,6 +846,7 @@ impl SkillService { let mut installed = db.get_all_installed_skills()?; for skill in installed.values_mut() { skill.apps.pi = Self::skill_exists_in_app(&skill.directory, &AppType::Pi); + skill.apps.kimi = Self::skill_exists_in_app(&skill.directory, &AppType::Kimi); } let skills: HashMap = installed .into_values() @@ -2685,6 +2686,7 @@ impl SkillService { .ok_or_else(|| AppError::Message(format!("Skill not found: {skill_id}")))?; let directory = Self::require_valid_directory(&skill.directory)?; skill.apps.pi = Self::skill_exists_in_app(&skill.directory, &AppType::Pi); + skill.apps.kimi = Self::skill_exists_in_app(&skill.directory, &AppType::Kimi); let (owner, name) = match (&skill.repo_owner, &skill.repo_name) { (Some(owner), Some(name)) => (owner.clone(), name.clone()), @@ -2729,6 +2731,7 @@ impl SkillService { AppError::Message(format!("Skill was removed during update: {skill_id}")) })?; current.apps.pi = Self::skill_exists_in_app(¤t.directory, &AppType::Pi); + current.apps.kimi = Self::skill_exists_in_app(¤t.directory, &AppType::Kimi); if current.directory != skill.directory || current.repo_owner != skill.repo_owner || current.repo_name != skill.repo_name @@ -3496,6 +3499,7 @@ impl SkillService { let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name); let mut apps = selection.apps; apps.pi = Self::skill_exists_in_app(&dir_name, &AppType::Pi); + apps.kimi = Self::skill_exists_in_app(&dir_name, &AppType::Kimi); let (id, repo_owner, repo_name, repo_branch, readme_url) = build_repo_info_from_lock(&agents_lock, &dir_name); @@ -3759,6 +3763,29 @@ mod tests { use super::*; use serial_test::serial; + #[test] + #[serial] + fn kimi_skill_enablement_survives_reload_and_disable() { + let home = tempfile::tempdir().unwrap(); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let _kimi = crate::kimi_config::test_support::TestKimiDir::new(); + let destination = SkillService::get_app_skills_dir(&AppType::Kimi) + .unwrap() + .join("demo"); + fs::create_dir_all(&destination).unwrap(); + fs::write(destination.join("SKILL.md"), "fixture").unwrap(); + SkillService::import_from_apps(vec![ImportSkillSelection { + directory: "demo".into(), + apps: SkillApps::only(&AppType::Kimi), + }]) + .unwrap(); + assert!(SkillService::load_index().unwrap().skills["demo"].apps.kimi); + SkillService::toggle_app("demo", &AppType::Kimi, false).unwrap(); + assert!(!SkillService::load_index().unwrap().skills["demo"].apps.kimi); + SkillService::toggle_app("demo", &AppType::Kimi, true).unwrap(); + assert!(SkillService::load_index().unwrap().skills["demo"].apps.kimi); + } + #[test] fn skill_state_lock_allows_snapshots_but_excludes_writers() { let first_reader = skill_state_read_guard(); diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index c5d5eabe7..2cb9929f6 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -560,7 +560,17 @@ fn export_db_to_multi_app_config(db: &Database) -> Result { + for (id, server) in &mut servers { + server.apps.kimi = kimi_servers.contains_key(id); + } + } + // A malformed unrelated app must not prevent CC-Switch startup. MCP writes still fail closed. + Err(error) => log::warn!("Could not read Kimi MCP enablement: {error}"), + } config.mcp.servers = Some(servers.into_iter().collect()); Ok(config) @@ -764,6 +774,7 @@ fn migrate_legacy_codex_configs(db: &Database, config: &mut MultiAppConfig) { #[cfg(test)] mod tests { use super::AppState; + use crate::app_config::AppType; use crate::database::Database; use crate::provider::Provider; use crate::services::skill::{SkillService, SkillStore}; @@ -791,6 +802,79 @@ mod tests { .expect("write json file"); } + #[test] + #[serial(home_settings)] + fn kimi_provider_survives_database_sync_and_fresh_live_restore() { + let home = TempDir::new().unwrap(); + let _env = TestEnvGuard::isolated(home.path()); + let _kimi = crate::kimi_config::test_support::TestKimiDir::new(); + let original = r#"default_model = "fast" +[providers.native] +type = "kimi" +base_url = "https://example.invalid/v1" +[models.fast] +provider = "native" +model = "kimi-k2.5" +max_context_size = 262144 +"#; + crate::kimi_config::write_kimi_config_source(original).unwrap(); + let expected = crate::kimi_config::read_kimi_config_json().unwrap(); + let config = crate::kimi_config::get_provider("native").unwrap().unwrap(); + let db = Database::memory().unwrap(); + db.save_provider( + "kimi", + &Provider::with_id("native".into(), "native".into(), config, None), + ) + .unwrap(); + let sql = db.export_sql_string_for_sync().unwrap(); + let restored = Database::memory().unwrap(); + restored.import_sql_string_for_sync(&sql).unwrap(); + let provider = restored + .get_provider_by_id("native", "kimi") + .unwrap() + .unwrap(); + std::fs::remove_file(crate::kimi_config::get_kimi_config_path()).unwrap(); + crate::kimi_config::write_prepared_config( + &crate::kimi_config::prepare_provider("native", provider.settings_config).unwrap(), + ) + .unwrap(); + assert_eq!( + crate::kimi_config::read_kimi_config_json().unwrap(), + expected + ); + } + + #[test] + #[serial(home_settings)] + fn kimi_mcp_enablement_survives_restart_without_schema_changes() { + let home = TempDir::new().unwrap(); + let _env = TestEnvGuard::isolated(home.path()); + let _kimi = crate::kimi_config::test_support::TestKimiDir::new(); + let state = AppState::try_new().unwrap(); + let server: crate::app_config::McpServer = serde_json::from_value(json!({ + "id":"native-test", "name":"native-test", "server":{"command":"echo"}, "apps":{"kimi":true} + })).unwrap(); + crate::kimi_config::write_kimi_config_source("").unwrap(); + crate::services::McpService::upsert_server(&state, server).unwrap(); + drop(state); + let state = AppState::try_new().unwrap(); + assert!( + crate::services::McpService::get_all_servers(&state).unwrap()["native-test"] + .apps + .kimi + ); + crate::services::McpService::toggle_app(&state, "native-test", AppType::Kimi, false) + .unwrap(); + drop(state); + let state = AppState::try_new().unwrap(); + assert!( + !crate::services::McpService::get_all_servers(&state).unwrap()["native-test"] + .apps + .kimi + ); + assert_eq!(crate::database::SCHEMA_VERSION, 18); + } + #[test] #[serial(home_settings)] fn existing_database_records_default_repo_initialization_before_user_deletes_repos() {