From 69288d17f0cc2cf21db8b922a503da6f2739ee13 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 17 Sep 2026 21:47:00 +0800 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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;