From 7da255387f601b5b1f8db2d98aefaa95aab3de90 Mon Sep 17 00:00:00 2001 From: funkpopo Date: Thu, 22 Oct 2026 23:35:49 +0800 Subject: [PATCH] feat(kimi): add official OAuth login and provider switching (#8710) --- docs/kimi-code-integration-zh.md | 23 +- src-tauri/src/commands/kimi_oauth.rs | 43 + src-tauri/src/commands/mod.rs | 2 + src-tauri/src/kimi_config.rs | 492 +++++++++- src-tauri/src/lib.rs | 7 + src-tauri/src/services/kimi_oauth.rs | 917 ++++++++++++++++++ src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/provider/mod.rs | 15 +- src-tauri/tests/kimi_commands.rs | 97 ++ .../providers/AddProviderDialog.tsx | 7 +- src/components/providers/KimiAccountLogin.tsx | 268 +++++ src/components/providers/ProviderCard.tsx | 6 +- src/components/providers/ProviderList.tsx | 2 + .../providers/forms/KimiProviderForm.tsx | 126 ++- .../providers/forms/ProviderForm.tsx | 2 + .../forms/ProviderPresetSelector.tsx | 18 +- .../providers/forms/hooks/useKimiOAuth.ts | 193 ++++ .../providers/forms/presetGroups.ts | 2 + src/components/providers/presentation.ts | 50 +- src/config/kimiProviderPresets.test.ts | 23 + src/config/kimiProviderPresets.ts | 14 + src/hooks/useProviderActions.ts | 3 +- src/i18n/locales/en.json | 29 +- src/i18n/locales/ja.json | 29 +- src/i18n/locales/tr.json | 29 +- src/i18n/locales/zh-TW.json | 29 +- src/i18n/locales/zh.json | 29 +- src/lib/api/kimiOAuth.ts | 32 + src/utils/providerCapabilities.test.ts | 39 + src/utils/providerCapabilities.ts | 10 + tests/components/KimiAccountLogin.test.tsx | 247 +++++ .../components/KimiAddProviderDialog.test.tsx | 185 ++++ tests/components/ProviderList.test.tsx | 153 ++- 33 files changed, 3054 insertions(+), 68 deletions(-) create mode 100644 src-tauri/src/commands/kimi_oauth.rs create mode 100644 src-tauri/src/services/kimi_oauth.rs create mode 100644 src/components/providers/KimiAccountLogin.tsx create mode 100644 src/components/providers/forms/hooks/useKimiOAuth.ts create mode 100644 src/lib/api/kimiOAuth.ts create mode 100644 tests/components/KimiAccountLogin.test.tsx create mode 100644 tests/components/KimiAddProviderDialog.test.tsx diff --git a/docs/kimi-code-integration-zh.md b/docs/kimi-code-integration-zh.md index 87b0df197..5729fa94e 100644 --- a/docs/kimi-code-integration-zh.md +++ b/docs/kimi-code-integration-zh.md @@ -6,15 +6,16 @@ Kimi Code 是独立应用,应用 ID 为 `kimi`。接入面向当前的 [Moonsh 1. 在「应用」页安装 Kimi Code,或先按[官方说明](https://moonshotai.github.io/kimi-code/en/guides/getting-started)安装。CC Switch 使用官方独立程序安装器,自带运行时,不依赖系统 Node 版本;已有客户端升级使用 `kimi upgrade --yes`,静态兜底重跑官方安装器。手动使用 npm 包 `@moonshot-ai/kimi-code` 时需满足其 Node 要求,2.3.0 要求 Node ≥22.19.0,Node 22.12.0 会因缺少 `createZstdDecompress` 无法启动。 2. 在侧栏选择 **Kimi Code**。已有原生供应商和模型会自动出现在列表中。 -3. 添加供应商:和 Pi 等应用一样,从「模型厂商」「第三方平台」「云服务商」「本地」分组中选择具体供应商,或使用各组共用的「自定义配置」。预设保留厂商的套餐、地区版本、模型参数、请求头和获取 API Key 的链接,并转换为 Kimi 原生字段。「模型厂商」中的 **Kimi** 包含开放平台、编程套餐及国内/海外版本,替代此前独立的 Kimi Code、Moonshot 和通用第三方条目。每个模型必须填写模型 ID 和正整数的上下文长度。 -4. 点击「添加」将供应商及模型写入 Kimi 配置。多个供应商可以同时启用。在 Kimi Code 内使用 `/model` 选择默认模型;没有默认模型时,首次添加会选择该供应商的第一个模型。 -5. 「移除」会从原生配置移除供应商和所属模型,并保留 CC Switch 中的档案。主模型或子代理模型池仍引用的模型不能移除,需要先在 Kimi Code 中调整选择。 +3. 使用编程套餐时,可直接在「添加供应商」的「账号登录」分组中选择 **Kimi Code 官方登录**,完成授权后自动添加模型。使用 API Key 时,和 Pi 等应用一样,从「模型厂商」「第三方平台」「云服务商」「本地」分组中选择具体供应商,或使用各组共用的「自定义配置」。预设保留厂商的套餐、地区版本、模型参数、请求头和获取 API Key 的链接,并转换为 Kimi 原生字段。「模型厂商」中的 **Kimi** 包含开放平台、编程套餐及国内/海外版本,替代此前独立的 Kimi Code、Moonshot 和通用第三方条目。每个模型必须填写模型 ID 和正整数的上下文长度。 +4. 点击「添加」将供应商及模型写入 Kimi 配置。多个供应商可以同时启用。供应商卡片上的「切换」会选择该供应商的模型,当前供应商以蓝色「使用中」标识。已有当前模型属于目标供应商时保留该模型,否则选择目标供应商的第一个原生模型;需要切换到其他具体模型时,可在 Kimi Code 内使用 `/model`。没有默认模型时,首次添加会选择该供应商的第一个模型。 +5. 「移除」位于卡片的「更多」菜单,会从原生配置移除供应商和所属模型,并保留 CC Switch 中的档案。主模型或子代理模型池仍引用的模型不能移除,需要先在 Kimi Code 中调整选择。 ## 配置与能力 | 能力 | 原生位置/行为 | | --- | --- | | 供应商和模型 | `~/.kimi-code/config.toml` 的 `providers`、`models` 表 | +| 官方 OAuth | 设备码授权,凭据保存到 `~/.kimi-code/credentials/`,由 Kimi Code 自动刷新 | | 协议 | `kimi`、`openai`、`openai_responses`、`anthropic`、`google-genai`、`vertexai` | | 模型能力 | 提供图片输入、思考、始终思考、工具调用、按需加载工具的配置;音视频等其他能力由 Kimi 原生管理,已有字段原样保留 | | 供应商目录 | 复用已维护的 Pi 供应商信息并转换为 Kimi 原生协议和模型字段;AWS Bedrock 的专有签名协议不兼容,不提供该预设 | @@ -27,10 +28,24 @@ Kimi Code 是独立应用,应用 ID 为 `kimi`。接入面向当前的 [Moonsh 设置环境变量 `KIMI_CODE_HOME` 后,上述位置全部跟随该目录。CC Switch 进程需要继承这个环境变量。 -Kimi 使用原生多供应商配置,因此此接入采用共存模式。当前不提供 CC Switch 的本地路由或聚合模式。官方 OAuth 登录和凭据刷新仍由 Kimi Code 管理;CC Switch 保留配置中的 OAuth 引用,不写入或替换凭据文件。 +Kimi 使用原生多供应商配置,因此此接入采用共存模式。当前不提供 CC Switch 的本地路由或聚合模式。 Ollama、LM Studio 和 vLLM 预设使用各自的本地 OpenAI 兼容地址,并预填 `local` 占位 Key,供默认不校验凭据的服务使用;服务器已开启鉴权时应替换为实际 Key。模型 ID 和上下文长度需要与本机已加载的模型一致。Microsoft Foundry 的资源名占位符需要改为自己的资源名。 +## 官方 OAuth 登录 + +官方账号供应商默认显示为 **Kimi Official**,使用 Kimi 图标并带有「官方」徽标。已有自动导入的条目会在刷新列表时补齐名称、图标和官方分类;自定义名称和图标保留。原生供应商 ID 仍为 `managed:kimi-code`,以兼容 Kimi Code 的 OAuth 模型刷新及现有模型引用。 + +点击「添加供应商」,在「账号登录」分组中选择 **Kimi Code 官方登录**,进入独立的账号登录页。选择国内版或海外版后点击预设条右侧的「登录 Kimi」,在浏览器中输入授权码并确认。若浏览器没有自动打开,可使用页面上的授权链接和复制授权码按钮。登录成功后,点击「使用此账号」选中官方模型并返回供应商列表(单纯登录仍保留此前的默认模型);此流程无需填写 API Key、模型或 JSON 配置,也不显示普通供应商表单的「添加」按钮。 + +授权成功后,CC Switch 从官方 `/models` 获取当前账号的模型和能力,添加原生 `managed:kimi-code` 供应商、`kimi-code/<模型 ID>` 模型,以及缺失的联网搜索和抓取服务。已有的默认模型、用户模型别名、模型 overrides、自定义服务及其他供应商配置会保留。配置写入失败时不会替换旧登录;凭据保存失败时恢复原配置,可点击「重试」继续完成当前授权。 + +凭据按官方格式保存在 `~/.kimi-code/credentials/`,仅后端读写,不进入供应商档案或前端响应。国内版使用 `kimi-code.json`;海外版及自定义环境使用官方规则生成的独立凭据槽位。`KIMI_CODE_HOME`、`KIMI_CODE_OAUTH_HOST`(兼容 `KIMI_OAUTH_HOST`)和 `KIMI_CODE_BASE_URL` 会被尊重。授权后的令牌刷新仍由 Kimi Code 管理,CC Switch 不与客户端并行轮换 refresh token。 + +账号登录页会读取 Kimi Code 已有的文件登录状态,支持刷新、取消、重新登录和退出登录。退出登录会移除当前区域/环境的本机凭据,并让 Kimi Code 退出该登录;保留供应商、模型与模型选择。切换区域重新登录只更新目标区域的凭据,其他区域的凭据文件保留。 + +协议及存储格式依据:[官方 OAuth 实现](https://github.com/MoonshotAI/kimi-code/tree/44cdf8f4039e2ff20d3fe8a4e7868bd90fd786e1/packages/oauth/src)。 + ## 数据保留规则 - 编辑供应商只修改对应的供应商节点和所属模型,保留其他供应商、全局设置、服务配置及未修改表的 TOML 注释。 diff --git a/src-tauri/src/commands/kimi_oauth.rs b/src-tauri/src/commands/kimi_oauth.rs new file mode 100644 index 000000000..9d3a26997 --- /dev/null +++ b/src-tauri/src/commands/kimi_oauth.rs @@ -0,0 +1,43 @@ +use crate::services::kimi_oauth::{ + KimiLoginPoll, KimiLoginStart, KimiOAuthManager, KimiOAuthStatus, KimiRegion, +}; +use tauri::State; + +#[tauri::command] +pub fn kimi_oauth_status(state: State<'_, KimiOAuthManager>) -> Result { + state.status() +} + +#[tauri::command] +pub async fn kimi_oauth_start( + region: KimiRegion, + state: State<'_, KimiOAuthManager>, +) -> Result { + state.start(region).await +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn kimi_oauth_poll( + flow_id: String, + state: State<'_, KimiOAuthManager>, +) -> Result { + state.poll(&flow_id).await +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn kimi_oauth_cancel( + flow_id: String, + state: State<'_, KimiOAuthManager>, +) -> Result { + Ok(state.cancel(&flow_id).await) +} + +#[tauri::command] +pub async fn kimi_oauth_logout(state: State<'_, KimiOAuthManager>) -> Result<(), String> { + state.logout().await +} + +#[tauri::command] +pub async fn kimi_oauth_use_account(state: State<'_, KimiOAuthManager>) -> Result<(), String> { + state.use_account().await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 29ae6a0d6..03018fe86 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -14,6 +14,7 @@ mod failover; mod global_proxy; mod hermes; mod import_export; +mod kimi_oauth; mod mcp; mod misc; mod model_fetch; @@ -53,6 +54,7 @@ pub use failover::*; pub use global_proxy::*; pub use hermes::*; pub use import_export::*; +pub use kimi_oauth::*; pub use mcp::*; pub use misc::*; pub use model_fetch::*; diff --git a/src-tauri/src/kimi_config.rs b/src-tauri/src/kimi_config.rs index 516a5fa9e..8a9a11954 100644 --- a/src-tauri/src/kimi_config.rs +++ b/src-tauri/src/kimi_config.rs @@ -2,6 +2,7 @@ //! A saved CCS provider contains `{ provider: LLMProvider, models: { key: LLMModel } }`. use crate::config::{atomic_write_private, get_home_dir}; use crate::error::AppError; +use crate::provider::Provider; use indexmap::IndexMap; use serde_json::{json, Value}; use std::{ @@ -11,6 +12,57 @@ use std::{ }; static WRITE_LOCK: Mutex<()> = Mutex::new(()); +pub(crate) const OFFICIAL_PROVIDER_ID: &str = "managed:kimi-code"; +const OFFICIAL_PROVIDER_NAME: &str = "Kimi Official"; + +/// Native login creates a managed node, not a CCS display profile. Fill in its +/// identity on import and repair older auto-imports without replacing user names +/// or icons. Never rename the native id: Kimi uses it for OAuth model refresh. +pub(crate) fn normalize_provider_identity(provider: &mut Provider) { + if provider.id != OFFICIAL_PROVIDER_ID { + return; + } + let oauth = provider + .settings_config + .pointer("/provider/oauth/key") + .and_then(Value::as_str) + .is_some_and(|key| !key.trim().is_empty()); + if !oauth { + if provider.category.as_deref() == Some("official") { + provider.category = None; + } + return; + } + provider.category = Some("official".into()); + if provider.name.trim().is_empty() || provider.name == OFFICIAL_PROVIDER_ID { + provider.name = OFFICIAL_PROVIDER_NAME.into(); + } + if provider + .icon + .as_deref() + .is_none_or(|icon| icon.trim().is_empty()) + { + provider.icon = Some("kimi".into()); + } + let global = provider + .settings_config + .pointer("/provider/base_url") + .and_then(Value::as_str) + .and_then(|base| url::Url::parse(base).ok()) + .is_some_and(|url| url.host_str() == Some("api.kimi.ai")); + if provider.website_url.as_deref().is_none_or(|url| { + url.trim().is_empty() || matches!(url, "https://www.kimi.com" | "https://www.kimi.ai") + }) { + provider.website_url = Some( + if global { + "https://www.kimi.ai" + } else { + "https://www.kimi.com" + } + .into(), + ); + } +} pub(crate) fn data_dir() -> PathBuf { std::env::var_os("KIMI_CODE_HOME") @@ -97,7 +149,7 @@ pub(crate) fn validate_provider(id: &str, config: &Value) -> Result<(), AppError "Enter a valid Kimi Code endpoint URL".into(), )); } - // OAuth remains owned by Kimi; imported references must survive edits unchanged. + // Kimi refreshes native OAuth credentials; references must survive provider edits. if provider .get("api_key") .and_then(Value::as_str) @@ -192,7 +244,7 @@ fn write(path: &Path, before: &Value, after: &Value) -> Result<(), AppError> { let next = serialized .parse::() .map_err(|e| AppError::Config(e.to_string()))?; - for section in ["providers", "models"] { + for section in ["providers", "models", "services"] { let old = before.get(section).and_then(Value::as_object); let new = after.get(section).and_then(Value::as_object); if old == new { @@ -224,10 +276,23 @@ fn write(path: &Path, before: &Value, after: &Value) -> Result<(), AppError> { } fn update(path: &Path, id: &str, config: Option, create: bool) -> Result<(), AppError> { + let before = read(path)?; + let after = apply_provider(&before, id, config, create)?; + if before == after { + return Ok(()); + } + write(path, &before, &after) +} + +fn apply_provider( + before: &Value, + id: &str, + config: Option, + create: bool, +) -> Result { if let Some(config) = &config { validate_provider(id, config)?; } - let before = read(path)?; let mut document = before.clone(); if config.is_none() && document @@ -235,7 +300,7 @@ fn update(path: &Path, id: &str, config: Option, create: bool) -> Result< .and_then(|providers| providers.get(id)) .is_none() { - return Ok(()); + return Ok(before.clone()); } if create && document.get("providers").and_then(|p| p.get(id)).is_some() { return Err(AppError::InvalidInput(format!( @@ -323,7 +388,7 @@ fn update(path: &Path, id: &str, config: Option, create: bool) -> Result< } else { document["providers"].as_object_mut().unwrap().remove(id); } - write(path, &before, &document) + Ok(document) } pub(crate) fn save_and_commit( @@ -347,13 +412,430 @@ pub(crate) fn remove_provider(id: &str) -> Result<(), AppError> { save_and_commit(id, None, false, || Ok(())) } +/// Resolve the selected native alias through models, not by splitting its name: +/// aliases can be arbitrary and provider ids may contain punctuation. +pub(crate) fn current_provider() -> Result { + let document = read(&config_path())?; + Ok(current_provider_in(&document).unwrap_or_default().into()) +} + +fn current_provider_in(document: &Value) -> Option<&str> { + let selected = document.get("default_model")?.as_str()?; + let id = document + .get("models")? + .get(selected)? + .get("provider")? + .as_str()?; + document.get("providers")?.get(id)?; + Some(id) +} + +fn select_provider_model(document: &mut Value, id: &str) -> Result<(), AppError> { + let models = document + .get("models") + .and_then(Value::as_object) + .ok_or_else(|| AppError::InvalidInput("The Kimi Code provider has no models".into()))?; + let belongs_to_provider = |key: &str| { + models + .get(key) + .and_then(|model| model.get("provider")) + .and_then(Value::as_str) + == Some(id) + }; + let selected = document + .get("default_model") + .and_then(Value::as_str) + .filter(|key| belongs_to_provider(key)) + .or_else(|| { + models + .keys() + .find(|key| belongs_to_provider(key)) + .map(String::as_str) + }) + .ok_or_else(|| AppError::InvalidInput("The Kimi Code provider has no models".into()))? + .to_string(); + document["default_model"] = json!(selected); + Ok(()) +} + +pub(crate) fn switch_and_commit( + id: &str, + archived_config: Value, + commit: impl FnOnce(Value) -> Result, +) -> Result { + let _guard = WRITE_LOCK + .lock() + .map_err(|e| AppError::Message(e.to_string()))?; + let path = config_path(); + switch_at(&path, id, archived_config, commit) +} + +fn switch_at( + path: &Path, + id: &str, + archived_config: Value, + commit: impl FnOnce(Value) -> Result, +) -> Result { + let before = read(path)?; + // Already-live providers belong to Kimi. A card click must not overwrite + // changes Kimi or the user made since the last supplier-list refresh. + let mut after = if before + .get("providers") + .and_then(|providers| providers.get(id)) + .is_some() + { + before.clone() + } else { + apply_provider(&before, id, Some(archived_config), false)? + }; + select_provider_model(&mut after, id)?; + let models: serde_json::Map = after["models"] + .as_object() + .unwrap() + .iter() + .filter(|(_, model)| model.get("provider").and_then(Value::as_str) == Some(id)) + .map(|(key, model)| (key.clone(), model.clone())) + .collect(); + let config = json!({"provider": after["providers"][id], "models": models}); + crate::mcode_config::write_and_commit(path, || write(path, &before, &after), || commit(config)) +} + +/// Selecting the account is explicit: signing in alone preserves the current +/// model, while "Use this account" selects one of its official model aliases. +pub(crate) fn use_oauth_account(home: &Path) -> Result<(), AppError> { + let _guard = WRITE_LOCK + .lock() + .map_err(|e| AppError::Message(e.to_string()))?; + let path = home.join("config.toml"); + let before = read(&path)?; + if before + .pointer("/providers/managed:kimi-code/oauth/key") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + return Err(AppError::InvalidInput( + "Sign in to Kimi Code to add official models first".into(), + )); + } + let mut after = before.clone(); + select_provider_model(&mut after, "managed:kimi-code")?; + write(&path, &before, &after) +} + +/// Provision an official login under the same lock as provider edits. Keep user +/// aliases, overrides, unrelated services, comments and the current model choice. +/// If credentials cannot be saved, restore the exact original configuration. +pub(crate) fn save_oauth_login( + home: &Path, + token_path: &Path, + token: &[u8], + base_url: &str, + oauth: Value, + models: serde_json::Map, +) -> Result<(), AppError> { + let _guard = WRITE_LOCK + .lock() + .map_err(|e| AppError::Message(e.to_string()))?; + let path = home.join("config.toml"); + let before = read(&path)?; + let mut after = before.clone(); + for section in ["providers", "models", "services"] { + if after.get(section).is_none() { + after[section] = json!({}); + } + if !after[section].is_object() { + return Err(AppError::Config(format!( + "Invalid Kimi Code {section} table" + ))); + } + } + let provider_id = "managed:kimi-code"; + let mut merged_models = serde_json::Map::new(); + for (key, model) in &models { + let mut merged = after["models"].get(key).cloned().unwrap_or(json!({})); + if merged.get("provider").is_some_and(|id| id != provider_id) { + return Err(AppError::InvalidInput(format!( + "Kimi Code model key '{key}' belongs to another provider" + ))); + } + let fields = merged + .as_object_mut() + .ok_or_else(|| AppError::Config(format!("Invalid Kimi Code model '{key}'")))?; + // These fields are owned by the official model catalog. User overrides + // and fields outside this set survive a subsequent login. + for field in [ + "capabilities", + "support_efforts", + "default_effort", + "protocol", + "beta_api", + "adaptive_thinking", + ] { + fields.remove(field); + } + fields.extend(model.as_object().unwrap().clone()); + merged_models.insert(key.clone(), merged); + } + let provider = json!({"type": "kimi", "base_url": base_url, "api_key": "", "oauth": oauth}); + validate_provider( + provider_id, + &json!({"provider": provider, "models": merged_models}), + )?; + after["providers"][provider_id] = provider; + for (key, model) in merged_models { + after["models"][key] = model; + } + if after + .get("default_model") + .and_then(Value::as_str) + .is_none_or(|key| after["models"].get(key).is_none()) + { + after["default_model"] = json!(models.keys().next().unwrap()); + } + for (name, suffix) in [("moonshot_search", "search"), ("moonshot_fetch", "fetch")] { + // A custom service is user-owned. Only provision missing services or + // update services already attached to the official login. + if after["services"].get(name).is_none() + || after["services"][name] + .pointer("/oauth/key") + .and_then(Value::as_str) + .is_some_and(|key| key == "kimi-code" || key.starts_with("oauth/kimi-code")) + { + after["services"][name] = json!({ + "base_url": format!("{base_url}/{suffix}"), "api_key": "", "oauth": oauth + }); + } + } + crate::mcode_config::write_and_commit( + &path, + || write(&path, &before, &after), + || { + if let Some(directory) = token_path.parent() { + fs::create_dir_all(directory).map_err(|e| AppError::io(directory, e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)) + .map_err(|e| AppError::io(directory, e))?; + } + } + atomic_write_private(token_path, token) + }, + ) +} + #[cfg(test)] mod tests { use super::*; + + #[test] + fn official_identity_repairs_imported_name_icon_and_category_without_renaming_native_keys() { + let config = json!({"provider":{"type":"kimi","base_url":"https://api.kimi.com/coding/v1","oauth":{"storage":"file","key":"oauth/kimi-code"}},"models":{}}); + let mut provider = Provider::with_id( + OFFICIAL_PROVIDER_ID.into(), + OFFICIAL_PROVIDER_ID.into(), + config.clone(), + None, + ); + normalize_provider_identity(&mut provider); + assert_eq!(provider.id, OFFICIAL_PROVIDER_ID); + assert_eq!(provider.name, "Kimi Official"); + assert_eq!(provider.icon.as_deref(), Some("kimi")); + assert_eq!(provider.category.as_deref(), Some("official")); + assert_eq!( + provider.website_url.as_deref(), + Some("https://www.kimi.com") + ); + assert_eq!(provider.settings_config, config); + } + + #[test] + fn official_identity_preserves_custom_display_fields_and_follows_global_website() { + let config = json!({"provider":{"type":"kimi","base_url":"https://api.kimi.ai/coding/v1","oauth":{"storage":"file","key":"oauth/kimi-code-env-0e4f99c69cc27850"}},"models":{}}); + let mut provider = Provider::with_id( + OFFICIAL_PROVIDER_ID.into(), + "My Kimi account".into(), + config, + Some("https://www.kimi.com".into()), + ); + provider.icon = Some("user-selected-icon".into()); + provider.notes = Some("Keep this note".into()); + normalize_provider_identity(&mut provider); + assert_eq!(provider.name, "My Kimi account"); + assert_eq!(provider.icon.as_deref(), Some("user-selected-icon")); + assert_eq!(provider.notes.as_deref(), Some("Keep this note")); + assert_eq!(provider.website_url.as_deref(), Some("https://www.kimi.ai")); + provider.website_url = Some("https://my-bookmark.example".into()); + normalize_provider_identity(&mut provider); + assert_eq!( + provider.website_url.as_deref(), + Some("https://my-bookmark.example") + ); + provider.settings_config["provider"] + .as_object_mut() + .unwrap() + .remove("oauth"); + normalize_provider_identity(&mut provider); + assert!(provider.category.is_none()); + } + + fn login_models() -> serde_json::Map { + serde_json::from_value(json!({"kimi-code/k3": { + "provider": "managed:kimi-code", "model": "k3", "max_context_size": 1048576, + "capabilities": ["thinking", "tool_use"] + }})) + .unwrap() + } + + #[test] + fn oauth_login_preserves_custom_config_and_uses_native_credentials() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let credential = dir.path().join("credentials/kimi-code.json"); + let initial = "# preserve this comment\ndefault_model = 'custom/model'\n[providers.custom]\ntype = 'openai'\napi_key = 'test'\n[models.'custom/model']\nprovider = 'custom'\nmodel = 'custom'\nmax_context_size = 32000\n[models.'kimi-code/k3']\nprovider = 'managed:kimi-code'\nmodel = 'k3'\nmax_context_size = 262144\n[models.'kimi-code/k3'.overrides]\ndefault_effort = 'max'\n[services.moonshot_search]\nbase_url = 'https://custom.example/search'\napi_key = 'custom-search'\n[thinking]\neffort = 'high'\n"; + fs::write(&path, initial).unwrap(); + let oauth = json!({"storage": "file", "key": "oauth/kimi-code"}); + save_oauth_login( + dir.path(), + &credential, + b"test-credential", + "https://api.kimi.com/coding/v1", + oauth.clone(), + login_models(), + ) + .unwrap(); + let config = read(&path).unwrap(); + assert_eq!(config["default_model"], "custom/model"); + assert_eq!(config["thinking"]["effort"], "high"); + assert_eq!( + config["models"]["kimi-code/k3"]["overrides"]["default_effort"], + "max" + ); + assert_eq!( + config["models"]["kimi-code/k3"]["max_context_size"], + 1048576 + ); + assert_eq!(config["providers"]["managed:kimi-code"]["oauth"], oauth); + assert_eq!( + config["services"]["moonshot_search"]["api_key"], + "custom-search" + ); + assert_eq!(config["services"]["moonshot_fetch"]["oauth"], oauth); + assert!(fs::read_to_string(path) + .unwrap() + .contains("# preserve this comment")); + assert_eq!(fs::read(credential).unwrap(), b"test-credential"); + } + + #[test] + fn oauth_login_rolls_back_config_on_credential_write_failure() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let initial = "# original\n[thinking]\nenabled = true\n"; + fs::write(&path, initial).unwrap(); + let blocked = dir.path().join("credentials"); + fs::write(&blocked, "not a directory").unwrap(); + assert!(save_oauth_login( + dir.path(), + &blocked.join("kimi-code.json"), + b"new", + "https://api.kimi.com/coding/v1", + json!({"storage":"file","key":"oauth/kimi-code"}), + login_models() + ) + .is_err()); + assert_eq!(fs::read_to_string(path).unwrap(), initial); + assert_eq!(fs::read_to_string(blocked).unwrap(), "not a directory"); + } + + #[test] + fn using_oauth_account_selects_official_model_only_after_explicit_selection() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let initial = "# preserve\ndefault_model = 'custom/model'\n[models.'custom/model']\nprovider = 'custom'\nmodel = 'custom'\nmax_context_size = 32000\n[thinking]\neffort = 'high'\n"; + fs::write(&path, initial).unwrap(); + assert!(use_oauth_account(dir.path()).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), initial); + save_oauth_login( + dir.path(), + &dir.path().join("credentials/kimi-code.json"), + b"credential", + "https://api.kimi.com/coding/v1", + json!({"storage":"file","key":"oauth/kimi-code"}), + login_models(), + ) + .unwrap(); + assert_eq!(read(&path).unwrap()["default_model"], "custom/model"); + use_oauth_account(dir.path()).unwrap(); + let config = read(&path).unwrap(); + assert_eq!(config["default_model"], "kimi-code/k3"); + assert_eq!(config["thinking"]["effort"], "high"); + assert_eq!(config["models"]["custom/model"]["model"], "custom"); + assert!(fs::read_to_string(path).unwrap().contains("# preserve")); + } + + #[test] + fn oauth_login_rejects_model_collisions_before_replacing_credentials() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let credential = dir.path().join("kimi-code.json"); + let initial = "[models.'kimi-code/k3']\nprovider = 'custom'\nmodel = 'my-model'\nmax_context_size = 1000\n"; + fs::write(&path, initial).unwrap(); + fs::write(&credential, "old").unwrap(); + assert!(save_oauth_login( + dir.path(), + &credential, + b"new", + "https://api.kimi.com/coding/v1", + json!({"storage":"file","key":"oauth/kimi-code"}), + login_models() + ) + .is_err()); + assert_eq!(fs::read_to_string(path).unwrap(), initial); + assert_eq!(fs::read_to_string(credential).unwrap(), "old"); + } + fn node() -> Value { json!({"provider":{"type":"openai","base_url":"https://example.com/v1","api_key":"test"},"models":{"test/model.v1":{"model":"model.v1","max_context_size":128000}}}) } + #[test] + fn kimi_switch_uses_fresh_native_models_and_preserves_the_selected_alias() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let native = "# preserve\ndefault_model = 'arbitrary-alias'\n[providers.custom]\ntype = 'openai'\nbase_url = 'https://fresh.example/v1'\napi_key = 'native'\n[models.'arbitrary-alias']\nprovider = 'custom'\nmodel = 'fresh-model'\nmax_context_size = 32000\n[models.'arbitrary-alias'.overrides]\ndefault_effort = 'high'\n"; + fs::write(&path, native).unwrap(); + let config = switch_at(&path, "custom", json!({}), Ok).unwrap(); + assert_eq!(config["provider"]["base_url"], "https://fresh.example/v1"); + assert_eq!( + config["models"]["arbitrary-alias"]["overrides"]["default_effort"], + "high" + ); + assert_eq!(read(&path).unwrap()["default_model"], "arbitrary-alias"); + assert_eq!(current_provider_in(&read(&path).unwrap()), Some("custom")); + assert_eq!(fs::read_to_string(path).unwrap(), native); + } + + #[test] + fn kimi_switch_rolls_back_membership_and_default_selection_when_commit_fails() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let native = "# original\ndefault_model = 'old-alias'\n[providers.original]\ntype = 'openai'\napi_key = 'test'\n[models.old-alias]\nprovider = 'original'\nmodel = 'old'\nmax_context_size = 1000\n"; + fs::write(&path, native).unwrap(); + let failed = switch_at(&path, "new", node(), |_| { + Err::<(), _>(AppError::Message("database failed".into())) + }); + assert!(failed.is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), native); + switch_at(&path, "new", node(), |_| Ok(())).unwrap(); + let config = read(&path).unwrap(); + assert_eq!(current_provider_in(&config), Some("new")); + assert_eq!(config["default_model"], "test/model.v1"); + assert!(config["providers"].get("original").is_some()); + assert!(config["models"].get("old-alias").is_some()); + assert!(fs::read_to_string(path).unwrap().contains("# original")); + } + #[test] fn kimi_native_roundtrip_preserves_login_other_models_and_comments() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 846309658..c95e16953 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1261,6 +1261,7 @@ pub fn run() { ); // 将同一个实例注入到全局状态,避免重复创建导致的不一致 app.manage(app_state); + app.manage(services::kimi_oauth::KimiOAuthManager::default()); // 初始化 SkillService let skill_service = SkillService::new(); @@ -1864,6 +1865,12 @@ pub fn run() { commands::auth_logout, commands::agy_account_is_running, commands::agy_account_quota, + commands::kimi_oauth_status, + commands::kimi_oauth_start, + commands::kimi_oauth_poll, + commands::kimi_oauth_cancel, + commands::kimi_oauth_logout, + commands::kimi_oauth_use_account, // Copilot OAuth commands (multi-account support) commands::copilot_start_device_flow, commands::copilot_poll_for_auth, diff --git a/src-tauri/src/services/kimi_oauth.rs b/src-tauri/src/services/kimi_oauth.rs new file mode 100644 index 000000000..95390ef92 --- /dev/null +++ b/src-tauri/src/services/kimi_oauth.rs @@ -0,0 +1,917 @@ +//! Official Kimi Code device login. Credentials stay in Kimi's native file +//! store; Kimi owns refresh-token rotation and subsequent model requests. +use std::{ + fs, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; + +use crate::{config::atomic_write_private, kimi_config}; + +const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const PROVIDER_ID: &str = kimi_config::OFFICIAL_PROVIDER_ID; + +#[derive(Clone, Copy, Default, Deserialize, Serialize, PartialEq, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum KimiRegion { + #[default] + MainlandCn, + Global, +} + +impl KimiRegion { + fn endpoints(self) -> Endpoints { + let (oauth_host, base_url) = match self { + Self::MainlandCn => ("https://auth.kimi.com", "https://api.kimi.com/coding/v1"), + Self::Global => ("https://auth.kimi.ai", "https://api.kimi.ai/coding/v1"), + }; + Endpoints { + oauth_host: oauth_host.into(), + base_url: base_url.into(), + } + } +} + +#[derive(Clone)] +struct Endpoints { + oauth_host: String, + base_url: String, +} + +impl Endpoints { + fn with_env(mut self) -> Result { + if let Some(host) = std::env::var("KIMI_CODE_OAUTH_HOST") + .ok() + .or_else(|| std::env::var("KIMI_OAUTH_HOST").ok()) + { + self.oauth_host = host.trim_end_matches('/').into(); + } + if let Ok(base) = std::env::var("KIMI_CODE_BASE_URL") { + self.base_url = base.trim_end_matches('/').into(); + } + for endpoint in [&self.oauth_host, &self.base_url] { + let url = + url::Url::parse(endpoint).map_err(|_| "Invalid Kimi Code endpoint override")?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err("Invalid Kimi Code endpoint override".into()); + } + } + Ok(self) + } + + fn oauth_ref(&self) -> Value { + // Match resolveKimiCodeOAuthKey in @moonshot-ai/kimi-code-oauth, + // including JSON property order and the 16-digit environment digest. + if self.oauth_host == "https://auth.kimi.com" + && self.base_url == "https://api.kimi.com/coding/v1" + { + json!({"storage": "file", "key": "oauth/kimi-code"}) + } else { + let environment = json!({"oauthHost": self.oauth_host, "baseUrl": self.base_url}); + let digest = format!("{:x}", Sha256::digest(environment.to_string().as_bytes())); + json!({"storage": "file", "key": format!("oauth/kimi-code-env-{}", &digest[..16]), "oauth_host": self.oauth_host}) + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KimiOAuthStatus { + pub authenticated: bool, + pub region: KimiRegion, + pub expires_at: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KimiLoginStart { + pub flow_id: String, + pub user_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KimiLoginPoll { + pub authenticated: bool, + pub interval: u64, + pub ended: Option<&'static str>, +} + +impl KimiLoginPoll { + fn pending(interval: u64) -> Self { + Self { + authenticated: false, + interval, + ended: None, + } + } + fn ended(reason: &'static str) -> Self { + Self { + authenticated: false, + interval: 0, + ended: Some(reason), + } + } +} + +enum PollOutcome { + Pending, + Ready(Map), + Ended(&'static str), +} + +#[derive(Clone)] +struct PendingLogin { + polling: bool, + id: String, + device_code: String, + expires: Instant, + next_poll: Instant, + interval: u64, + home: PathBuf, + endpoints: Endpoints, + device_id: String, + // Keep a successful token in memory if model discovery or a disk write + // fails, so retry never consumes the single-use device grant again. + credential: Option, +} + +#[derive(Default)] +pub struct KimiOAuthManager { + pending: Mutex>, +} + +fn credential_path(home: &Path, oauth: &Value) -> Result { + let key = oauth + .get("key") + .and_then(Value::as_str) + .ok_or("Missing Kimi OAuth reference")?; + let name = key.strip_prefix("oauth/").unwrap_or(key); + if name.is_empty() + || name.starts_with('.') + || !name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + { + return Err("Invalid Kimi OAuth credential name".into()); + } + if oauth + .get("storage") + .and_then(Value::as_str) + .is_some_and(|s| s != "file") + { + return Err("This Kimi login uses a keyring; manage it in Kimi Code".into()); + } + Ok(home.join("credentials").join(format!("{name}.json"))) +} + +fn native_login(home: &Path) -> Result<(KimiRegion, Value), String> { + let path = home.join("config.toml"); + let config = match fs::read_to_string(&path) { + Ok(text) => toml::from_str::(&text) + .map_err(|_| "Invalid Kimi Code TOML configuration".to_string())?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => json!({}), + Err(e) => return Err(format!("Cannot read Kimi Code configuration: {e}")), + }; + let provider = &config["providers"][PROVIDER_ID]; + let global = provider["base_url"] + .as_str() + .is_some_and(|url| url.starts_with("https://api.kimi.ai/")) + || provider["oauth"]["oauth_host"].as_str() == Some("https://auth.kimi.ai"); + let region = if global + || (provider.is_null() + && fs::read_to_string(home.join("region")).is_ok_and(|s| s.trim() == "global")) + { + KimiRegion::Global + } else { + KimiRegion::MainlandCn + }; + let has_env = [ + "KIMI_CODE_OAUTH_HOST", + "KIMI_OAUTH_HOST", + "KIMI_CODE_BASE_URL", + ] + .iter() + .any(|key| std::env::var_os(key).is_some()); + let oauth = if has_env { + let mut endpoints = region.endpoints(); + if let Some(base) = provider["base_url"].as_str() { + endpoints.base_url = base.into(); + } + endpoints.with_env()?.oauth_ref() + } else { + provider + .get("oauth") + .cloned() + .unwrap_or_else(|| region.endpoints().oauth_ref()) + }; + Ok((region, oauth)) +} + +fn local_status(home: &Path) -> Result { + let (region, oauth) = native_login(home)?; + let path = credential_path(home, &oauth)?; + let credential = match fs::read(&path) { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map_err(|_| "Invalid Kimi Code credentials; sign in again".to_string())?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => json!({}), + Err(e) => return Err(format!("Cannot read Kimi Code credentials: {e}")), + }; + let expires_at = credential["expires_at"].as_i64(); + let has_access = credential["access_token"] + .as_str() + .is_some_and(|s| !s.is_empty()); + let can_refresh = credential["refresh_token"] + .as_str() + .is_some_and(|s| !s.is_empty()); + // An expired access token with a refresh token is still a usable native + // login. Never rotate it here: Kimi coordinates refresh across processes. + Ok(KimiOAuthStatus { + authenticated: can_refresh + || (has_access && expires_at.is_some_and(|t| t > chrono::Utc::now().timestamp())), + region, + expires_at, + }) +} + +fn device_id(home: &Path) -> Result { + let path = home.join("device_id"); + match fs::read_to_string(&path) { + Ok(id) if !id.trim().is_empty() => Ok(id.trim().to_string()), + Ok(_) => create_device_id(&path), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => create_device_id(&path), + Err(e) => Err(format!("Cannot read Kimi device identity: {e}")), + } +} + +fn create_device_id(path: &Path) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + atomic_write_private(path, id.as_bytes()).map_err(|e| e.to_string())?; + Ok(id) +} + +fn request(endpoints: &Endpoints, path: &str, device_id: &str) -> reqwest::RequestBuilder { + identify( + crate::proxy::http_client::get().post(format!("{}{path}", endpoints.oauth_host)), + device_id, + ) +} + +fn identify(request: reqwest::RequestBuilder, device_id: &str) -> reqwest::RequestBuilder { + let hostname = std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .unwrap_or_else(|_| "Unknown".into()); + let hostname: String = hostname + .chars() + .filter(char::is_ascii) + .filter(|c| !c.is_ascii_control()) + .take(128) + .collect(); + request + .timeout(Duration::from_secs(30)) + .header("Accept", "application/json") + .header( + "User-Agent", + concat!("cc-switch/", env!("CARGO_PKG_VERSION")), + ) + .header("X-Msh-Platform", "kimi_code_cli") + .header("X-Msh-Version", env!("CARGO_PKG_VERSION")) + .header("X-Msh-Device-Id", device_id) + .header( + "X-Msh-Device-Name", + if hostname.is_empty() { + "Unknown" + } else { + &hostname + }, + ) + .header( + "X-Msh-Device-Model", + format!("{} {}", std::env::consts::OS, std::env::consts::ARCH), + ) + .header("X-Msh-Os-Version", std::env::consts::OS) +} + +async fn response_json(response: reqwest::Response) -> Result<(u16, Value), String> { + let status = response.status().as_u16(); + let payload = response + .json::() + .await + .map_err(|_| format!("Unexpected Kimi response (HTTP {status})"))?; + Ok((status, payload)) +} + +fn required_string<'a>(payload: &'a Value, key: &str) -> Result<&'a str, String> { + payload[key] + .as_str() + .filter(|s| !s.is_empty()) + .ok_or_else(|| format!("Kimi response missing {key}")) +} + +fn seconds(payload: &Value, key: &str, fallback: Option) -> Result { + payload[key] + .as_u64() + .or_else(|| payload[key].as_str()?.parse().ok()) + .or(fallback) + .filter(|n| *n > 0 && *n <= 31_536_000) + .ok_or_else(|| format!("Kimi response has invalid {key}")) +} + +fn parse_credential(payload: &Value) -> Result { + let access = required_string(payload, "access_token")?; + let refresh = required_string(payload, "refresh_token")?; + let expires = seconds(payload, "expires_in", None)?; + Ok(json!({ + "access_token": access, "refresh_token": refresh, + "expires_at": chrono::Utc::now().timestamp() + expires as i64, + "expires_in": expires, "token_type": payload["token_type"].as_str().unwrap_or("Bearer"), + "scope": payload["scope"].as_str().unwrap_or("") + })) +} + +fn parse_models(payload: &Value) -> Result, String> { + let data = payload["data"] + .as_array() + .filter(|models| !models.is_empty()) + .ok_or("No models available for Kimi Code")?; + let mut models = Map::new(); + for model in data { + let id = required_string(model, "id")?; + let context = model["limit"]["context"] + .as_u64() + .or_else(|| model["limit"]["context"].as_str()?.parse().ok()) + .filter(|n| *n > 0 && *n <= i64::MAX as u64) + .ok_or_else(|| format!("Kimi model '{id}' has no valid context length"))?; + let mut capabilities = Vec::new(); + if model["supports_tool_use"] != false { + capabilities.push("tool_use"); + } + for (modality, capability) in [ + ("image", "image_in"), + ("video", "video_in"), + ("audio", "audio_in"), + ] { + if model["modalities"]["input"] + .as_array() + .is_some_and(|inputs| inputs.iter().any(|v| v == modality)) + { + capabilities.push(capability); + } + } + let thinking = model["reasoning"]["type"].as_str(); + if matches!(thinking, Some("only" | "both")) { + capabilities.push("thinking"); + } + if thinking == Some("only") { + capabilities.push("always_thinking"); + } + if model["tools"]["dynamic_load"] == true { + capabilities.push("dynamically_loaded_tools"); + } + let mut alias = json!({"provider": PROVIDER_ID, "model": id, "max_context_size": context, "capabilities": capabilities}); + if let Some(name) = model["display_name"].as_str() { + alias["display_name"] = json!(name); + } + let protocol = match model["protocol"].as_str() { + Some("response") => Some("openai_responses"), + Some("anthropic") => Some("anthropic"), + _ => None, + }; + if let Some(protocol) = protocol { + alias["protocol"] = json!(protocol); + if protocol == "anthropic" { + alias["beta_api"] = json!(true); + if matches!(thinking, Some("only" | "both")) { + alias["adaptive_thinking"] = json!(true); + } + } + } + if let Some(efforts) = model["reasoning"]["effort"]["valid"].as_array() { + let efforts: Vec<&str> = efforts + .iter() + .filter_map(Value::as_str) + .filter(|s| !s.is_empty()) + .collect(); + if !efforts.is_empty() { + if let Some(default) = model["reasoning"]["effort"]["default"] + .as_str() + .filter(|s| efforts.contains(s)) + { + alias["default_effort"] = json!(default); + } + alias["support_efforts"] = json!(efforts); + } + } + models.insert(format!("kimi-code/{id}"), alias); + } + Ok(models) +} + +impl KimiOAuthManager { + pub fn status(&self) -> Result { + local_status(&kimi_config::data_dir()) + } + + pub async fn start(&self, region: KimiRegion) -> Result { + self.start_with(kimi_config::data_dir(), region.endpoints().with_env()?) + .await + } + + async fn start_with( + &self, + home: PathBuf, + endpoints: Endpoints, + ) -> Result { + let mut pending = self.pending.lock().await; + // Starting again always invalidates the previous flow before any I/O. + *pending = None; + let identity = device_id(&home)?; + let response = request(&endpoints, "/api/oauth/device_authorization", &identity) + .form(&[("client_id", CLIENT_ID)]) + .send() + .await + .map_err(|e| format!("Kimi authorization request failed: {e}"))?; + let (status, payload) = response_json(response).await?; + if status != 200 { + return Err(format!("Kimi device authorization failed (HTTP {status})")); + } + let device_code = required_string(&payload, "device_code")?.to_string(); + let user_code = required_string(&payload, "user_code")?.to_string(); + let uri = payload["verification_uri_complete"] + .as_str() + .filter(|s| !s.is_empty()) + .or_else(|| payload["verification_uri"].as_str()) + .ok_or("Kimi response missing verification URI")?; + let url = url::Url::parse(uri).map_err(|_| "Invalid Kimi verification URL")?; + if url.scheme() != "https" || url.host_str().is_none() { + return Err("Invalid Kimi verification URL".into()); + } + let expires_in = seconds(&payload, "expires_in", Some(600))?; + let interval = seconds(&payload, "interval", Some(5))?; + let id = uuid::Uuid::new_v4().to_string(); + *pending = Some(PendingLogin { + polling: false, + id: id.clone(), + device_code, + expires: Instant::now() + Duration::from_secs(expires_in), + next_poll: Instant::now() + Duration::from_secs(interval), + interval, + home, + endpoints, + device_id: identity, + credential: None, + }); + Ok(KimiLoginStart { + flow_id: id, + user_code, + verification_uri: uri.to_string(), + expires_in, + interval, + }) + } + + pub async fn poll(&self, flow_id: &str) -> Result { + let mut flow = { + let mut pending = self.pending.lock().await; + let Some(flow) = pending.as_mut().filter(|p| p.id == flow_id) else { + return Ok(KimiLoginPoll::ended("cancelled")); + }; + if Instant::now() >= flow.expires { + *pending = None; + return Ok(KimiLoginPoll::ended("expired")); + } + if flow.polling || Instant::now() < flow.next_poll { + return Ok(KimiLoginPoll::pending(flow.interval)); + } + flow.polling = true; + flow.clone() + }; + // No lock across HTTP: cancellation immediately removes the flow, even + // while token/model requests are in flight. Check its identity again + // before committing anything to disk. + let outcome = Self::advance(&mut flow).await; + let mut pending = self.pending.lock().await; + if pending.as_ref().is_none_or(|p| p.id != flow_id) { + return Ok(KimiLoginPoll::ended("cancelled")); + } + flow.polling = false; + match outcome { + Ok(PollOutcome::Ready(models)) => { + let credential = flow.credential.as_ref().unwrap(); + let oauth = flow.endpoints.oauth_ref(); + let path = credential_path(&flow.home, &oauth)?; + let result = kimi_config::save_oauth_login( + &flow.home, + &path, + &serde_json::to_vec_pretty(credential).map_err(|e| e.to_string())?, + &flow.endpoints.base_url, + oauth, + models, + ) + .map_err(|e| e.to_string()); + if let Err(error) = result { + *pending = Some(flow); + return Err(error); + } + *pending = None; + Ok(KimiLoginPoll { + authenticated: true, + interval: 0, + ended: None, + }) + } + Ok(PollOutcome::Ended(reason)) => { + *pending = None; + Ok(KimiLoginPoll::ended(reason)) + } + Ok(PollOutcome::Pending) => { + let interval = flow.interval; + *pending = Some(flow); + Ok(KimiLoginPoll::pending(interval)) + } + Err(error) => { + // Preserve the token after a successful exchange, so a retry + // only repeats discovery/persistence, never the device grant. + *pending = Some(flow); + Err(error) + } + } + } + + async fn advance(flow: &mut PendingLogin) -> Result { + flow.next_poll = Instant::now() + Duration::from_secs(flow.interval); + if flow.credential.is_none() { + let response = request(&flow.endpoints, "/api/oauth/token", &flow.device_id) + .form(&[ + ("client_id", CLIENT_ID), + ("device_code", flow.device_code.as_str()), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]) + .send() + .await + .map_err(|e| format!("Kimi token request failed: {e}"))?; + let (status, payload) = response_json(response).await?; + if status == 200 { + flow.credential = Some(parse_credential(&payload)?); + } else { + match payload["error"].as_str() { + Some("authorization_pending") => {} + Some("slow_down") => { + flow.interval += 5; + flow.next_poll = Instant::now() + Duration::from_secs(flow.interval); + } + Some("expired_token") => return Ok(PollOutcome::Ended("expired")), + Some("access_denied") => return Ok(PollOutcome::Ended("denied")), + _ => return Err(format!("Kimi token request failed (HTTP {status})")), + } + return Ok(PollOutcome::Pending); + } + } + let credential = flow.credential.as_ref().unwrap(); + let response = identify( + crate::proxy::http_client::get().get(format!("{}/models", flow.endpoints.base_url)), + &flow.device_id, + ) + .bearer_auth(required_string(credential, "access_token")?) + .send() + .await + .map_err(|e| format!("Kimi model discovery failed: {e}"))?; + let (status, payload) = response_json(response).await?; + if status != 200 { + return Err(format!("Kimi model discovery failed (HTTP {status})")); + } + Ok(PollOutcome::Ready(parse_models(&payload)?)) + } + + pub async fn cancel(&self, flow_id: &str) -> bool { + let mut pending = self.pending.lock().await; + if pending.as_ref().is_some_and(|p| p.id == flow_id) { + *pending = None; + true + } else { + false + } + } + + pub async fn logout(&self) -> Result<(), String> { + let mut pending = self.pending.lock().await; + *pending = None; + let home = kimi_config::data_dir(); + let (_, oauth) = native_login(&home)?; + let path = credential_path(&home, &oauth)?; + // Keep model selections and references intact. Kimi recognizes the + // missing credential as signed out and can provision it on next login. + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Cannot remove Kimi Code credentials: {e}")), + } + } + + pub async fn use_account(&self) -> Result<(), String> { + let _pending = self.pending.lock().await; + let home = kimi_config::data_dir(); + if !local_status(&home)?.authenticated { + return Err("Sign in to Kimi Code first".into()); + } + kimi_config::use_oauth_account(&home).map_err(|e| e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + extract::State, + http::StatusCode, + routing::{get, post}, + Form, Json, Router, + }; + use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + }; + use tokio::sync::Notify; + + #[derive(Default)] + struct MockState { + mode: AtomicUsize, + tokens: AtomicUsize, + models: AtomicUsize, + discovery_started: Notify, + resume: Notify, + } + + struct Server { + state: Arc, + endpoints: Endpoints, + task: tokio::task::JoinHandle<()>, + } + impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } + } + + async fn server(mode: usize) -> Server { + let state = Arc::new(MockState::default()); + state.mode.store(mode, Ordering::SeqCst); + let router = Router::new() + .route("/api/oauth/device_authorization", post(|Form(form): Form>| async move { + assert_eq!(form["client_id"], CLIENT_ID); + Json(json!({"device_code":"private-device-code", "user_code":"ABCD-EFGH", + "verification_uri_complete":"https://auth.kimi.com/verify?user_code=ABCD-EFGH", "expires_in":600,"interval":1})) + })) + .route("/api/oauth/token", post(|State(state): State>, Form(form): Form>| async move { + assert_eq!(form["client_id"], CLIENT_ID); + assert_eq!(form["grant_type"], "urn:ietf:params:oauth:grant-type:device_code"); + assert_eq!(form["device_code"], "private-device-code"); + let attempt = state.tokens.fetch_add(1, Ordering::SeqCst); + let error = match (state.mode.load(Ordering::SeqCst), attempt) { + (1, 0) => Some("authorization_pending"), (1, 1) => Some("slow_down"), + (2, _) => Some("access_denied"), (4, _) => Some("expired_token"), _ => None, + }; + if let Some(error) = error { (StatusCode::BAD_REQUEST, Json(json!({"error":error}))) } + else { (StatusCode::OK, Json(json!({"access_token":"private-access", "refresh_token":"private-refresh", "expires_in":3600}))) } + })) + .route("/models", get(|State(state): State>| async move { + let attempt = state.models.fetch_add(1, Ordering::SeqCst); + if state.mode.load(Ordering::SeqCst) == 3 { + state.discovery_started.notify_one(); + state.resume.notified().await; + } + if state.mode.load(Ordering::SeqCst) == 5 && attempt == 0 { + return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error":"temporary"}))); + } + (StatusCode::OK, Json(json!({"data":[{"id":"k3","limit":{"context":1048576},"protocol":"response", + "reasoning":{"type":"only","effort":{"valid":["max"],"default":"max"}},"modalities":{"input":["text","image"]}}]}))) + })) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + Server { + state, + endpoints: Endpoints { + oauth_host: host.clone(), + base_url: host, + }, + task, + } + } + + async fn due(manager: &KimiOAuthManager) { + manager.pending.lock().await.as_mut().unwrap().next_poll = Instant::now(); + } + + #[test] + fn anthropic_models_write_beta_api_and_clear_it_after_protocol_change() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let mut payload = json!({"data": [{ + "id": "k3", "limit": {"context": 1048576}, + "protocol": "anthropic", "reasoning": {"type": "both"} + }]}); + let save_models = |payload: &Value| { + kimi_config::save_oauth_login( + dir.path(), + &dir.path().join("credentials/kimi-code.json"), + b"test-credential", + "https://api.kimi.com/coding/v1", + json!({"storage": "file", "key": "oauth/kimi-code"}), + parse_models(payload).unwrap(), + ) + .unwrap(); + }; + + save_models(&payload); + let native = fs::read_to_string(&path).unwrap(); + let config: Value = toml::from_str(&native).unwrap(); + let model = &config["models"]["kimi-code/k3"]; + assert_eq!(model["protocol"], "anthropic"); + assert_eq!(model["beta_api"], true); + assert_eq!(model["adaptive_thinking"], true); + assert!(model.get("use_beta").is_none()); + assert!(native.contains("beta_api = true")); + + payload["data"][0]["protocol"] = json!("response"); + save_models(&payload); + let config: Value = toml::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + let model = &config["models"]["kimi-code/k3"]; + assert_eq!(model["protocol"], "openai_responses"); + assert!(model.get("beta_api").is_none()); + assert!(model.get("adaptive_thinking").is_none()); + } + + #[tokio::test] + async fn official_device_flow_obeys_interval_slowdown_and_provisions_native_login() { + let server = server(1).await; + let dir = tempfile::tempdir().unwrap(); + let manager = KimiOAuthManager::default(); + let start = manager + .start_with(dir.path().into(), server.endpoints.clone()) + .await + .unwrap(); + assert!(!serde_json::to_string(&start) + .unwrap() + .contains("private-device-code")); + assert_eq!(start.user_code, "ABCD-EFGH"); + assert!(!manager.poll(&start.flow_id).await.unwrap().authenticated); + assert_eq!(server.state.tokens.load(Ordering::SeqCst), 0); + due(&manager).await; + assert_eq!(manager.poll(&start.flow_id).await.unwrap().interval, 1); + due(&manager).await; + assert_eq!(manager.poll(&start.flow_id).await.unwrap().interval, 6); + assert_eq!(manager.poll(&start.flow_id).await.unwrap().interval, 6); + assert_eq!(server.state.tokens.load(Ordering::SeqCst), 2); + due(&manager).await; + assert!(manager.poll(&start.flow_id).await.unwrap().authenticated); + let status = local_status(dir.path()).unwrap(); + assert!(status.authenticated); + assert!(!serde_json::to_string(&status).unwrap().contains("private-")); + let credential: Value = serde_json::from_slice( + &fs::read(credential_path(dir.path(), &server.endpoints.oauth_ref()).unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(credential["refresh_token"], "private-refresh"); + let config: Value = + toml::from_str(&fs::read_to_string(dir.path().join("config.toml")).unwrap()).unwrap(); + assert_eq!( + config["models"]["kimi-code/k3"]["protocol"], + "openai_responses" + ); + assert_eq!(config["default_model"], "kimi-code/k3"); + assert_eq!( + config["services"]["moonshot_search"]["oauth"], + server.endpoints.oauth_ref() + ); + } + + #[tokio::test] + async fn cancellation_during_model_discovery_prevents_all_login_writes() { + let server = server(3).await; + let dir = tempfile::tempdir().unwrap(); + let manager = Arc::new(KimiOAuthManager::default()); + let start = manager + .start_with(dir.path().into(), server.endpoints.clone()) + .await + .unwrap(); + due(&manager).await; + let poller = manager.clone(); + let id = start.flow_id.clone(); + let task = tokio::spawn(async move { poller.poll(&id).await }); + server.state.discovery_started.notified().await; + assert!(manager.cancel(&start.flow_id).await); + server.state.resume.notify_one(); + assert_eq!(task.await.unwrap().unwrap().ended, Some("cancelled")); + assert!(!dir.path().join("config.toml").exists()); + assert!(!dir.path().join("credentials").exists()); + } + + #[tokio::test] + async fn retry_after_model_failure_does_not_redeem_device_grant_twice() { + let server = server(5).await; + let dir = tempfile::tempdir().unwrap(); + let manager = KimiOAuthManager::default(); + let start = manager + .start_with(dir.path().into(), server.endpoints.clone()) + .await + .unwrap(); + due(&manager).await; + assert!(manager + .poll(&start.flow_id) + .await + .unwrap_err() + .contains("503")); + assert!(!dir.path().join("config.toml").exists()); + due(&manager).await; + assert!(manager.poll(&start.flow_id).await.unwrap().authenticated); + assert_eq!(server.state.tokens.load(Ordering::SeqCst), 1); + assert_eq!(server.state.models.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn denied_expired_and_replaced_flows_do_not_save_credentials() { + for (mode, reason) in [(2, "denied"), (4, "expired")] { + let server = server(mode).await; + let dir = tempfile::tempdir().unwrap(); + let manager = KimiOAuthManager::default(); + let old = manager + .start_with(dir.path().into(), server.endpoints.clone()) + .await + .unwrap(); + let start = manager + .start_with(dir.path().into(), server.endpoints.clone()) + .await + .unwrap(); + assert_eq!( + manager.poll(&old.flow_id).await.unwrap().ended, + Some("cancelled") + ); + due(&manager).await; + assert_eq!( + manager.poll(&start.flow_id).await.unwrap().ended, + Some(reason) + ); + assert!(!dir.path().join("credentials").exists()); + } + } + + #[test] + fn scoped_global_credentials_match_official_client_and_reject_path_traversal() { + assert_eq!( + KimiRegion::MainlandCn.endpoints().oauth_ref()["key"], + "oauth/kimi-code" + ); + assert_eq!( + KimiRegion::Global.endpoints().oauth_ref()["key"], + "oauth/kimi-code-env-0e4f99c69cc27850" + ); + let home = Path::new("kimi"); + for key in [ + "../outside", + "oauth/../../outside", + "oauth/.hidden", + "C:\\outside", + ] { + assert!(credential_path(home, &json!({"key":key})).is_err()); + } + assert_eq!( + credential_path(home, &json!({"key":"oauth/kimi-code"})).unwrap(), + home.join("credentials/kimi-code.json") + ); + assert!(parse_credential(&json!({"access_token":"x", "expires_in":3600})).is_err()); + assert!(parse_models(&json!({"data":[{"id":"bad","limit":{"context":0}}]})).is_err()); + } + + #[test] + fn existing_expired_access_with_refresh_is_signed_in_and_tombstone_is_signed_out() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials/kimi-code.json"); + atomic_write_private( + &path, + br#"{"access_token":"old","refresh_token":"refresh","expires_at":1}"#, + ) + .unwrap(); + assert!(local_status(dir.path()).unwrap().authenticated); + atomic_write_private( + &path, + br#"{"access_token":"","refresh_token":"","expires_at":0}"#, + ) + .unwrap(); + assert!(!local_status(dir.path()).unwrap().authenticated); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 16dada876..76d7ead43 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -7,6 +7,7 @@ pub mod coding_plan; pub mod config; pub mod env_checker; pub mod env_manager; +pub mod kimi_oauth; pub mod mcp; pub mod model_fetch; pub mod model_pricing; diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index c13b38602..af41402b4 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -5482,6 +5482,9 @@ impl ProviderService { provider.settings_config = config.clone(); } Self::set_provider_live_config_managed(provider, native.contains_key(id)); + if app_type == AppType::Kimi { + crate::kimi_config::normalize_provider_identity(provider); + } state.db.save_provider(app_type.as_str(), provider)?; } return Ok(saved); @@ -5497,6 +5500,9 @@ impl ProviderService { /// /// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。 pub fn current(state: &AppState, app_type: AppType) -> Result { + if app_type == AppType::Kimi { + return crate::kimi_config::current_provider(); + } // Additive mode apps have no "current" provider concept if app_type.is_additive_mode() { return Ok(String::new()); @@ -6659,7 +6665,14 @@ impl ProviderService { if app_type == AppType::Kimi { let mut provider = _provider.clone(); Self::set_provider_live_config_managed(&mut provider, true); - Self::save_native_provider(state, &app_type, &provider, true, false)?; + crate::kimi_config::switch_and_commit( + id, + provider.settings_config.clone(), + |config| { + provider.settings_config = config; + state.db.save_provider("kimi", &provider) + }, + )?; return Ok(SwitchResult::default()); } diff --git a/src-tauri/tests/kimi_commands.rs b/src-tauri/tests/kimi_commands.rs index fb54eadf5..e9734bf80 100644 --- a/src-tauri/tests/kimi_commands.rs +++ b/src-tauri/tests/kimi_commands.rs @@ -45,6 +45,40 @@ fn provider(id: &str, model: &str) -> Provider { ) } +#[test] +fn kimi_official_import_repairs_existing_display_identity_without_rewriting_native_config() { + let _guard = test_mutex().lock().unwrap(); + reset_test_fs(); + let home = KimiHome::new(); + let state = create_test_state().unwrap(); + let native = "# native OAuth\ndefault_model = 'kimi-code/k3'\n[providers.'managed:kimi-code']\ntype = 'kimi'\nbase_url = 'https://api.kimi.com/coding/v1'\napi_key = ''\n[providers.'managed:kimi-code'.oauth]\nkey = 'oauth/kimi-code'\nstorage = 'file'\n[models.'kimi-code/k3']\nprovider = 'managed:kimi-code'\nmodel = 'k3'\nmax_context_size = 1048576\n"; + fs::write(home.config(), native).unwrap(); + let id = "managed:kimi-code"; + let mut legacy = Provider::with_id(id.into(), id.into(), json!({}), None); + legacy.notes = Some("my note".into()); + state.db.save_provider("kimi", &legacy).unwrap(); + let providers = ProviderService::list(&state, AppType::Kimi).unwrap(); + let official = &providers[id]; + assert_eq!(official.name, "Kimi Official"); + assert_eq!(official.icon.as_deref(), Some("kimi")); + assert_eq!(official.category.as_deref(), Some("official")); + assert_eq!( + official.website_url.as_deref(), + Some("https://www.kimi.com") + ); + assert_eq!(official.notes.as_deref(), Some("my note")); + assert_eq!( + official.settings_config["models"]["kimi-code/k3"]["provider"], + id + ); + assert_eq!(ProviderService::current(&state, AppType::Kimi).unwrap(), id); + assert_eq!(fs::read_to_string(home.config()).unwrap(), native); + let persisted = state.db.get_provider_by_id(id, "kimi").unwrap().unwrap(); + assert_eq!(persisted.name, "Kimi Official"); + assert_eq!(persisted.category.as_deref(), Some("official")); + assert_eq!(persisted.icon.as_deref(), Some("kimi")); +} + #[test] fn kimi_provider_lifecycle_imports_native_config_and_tracks_external_removal() { let _guard = test_mutex().lock().unwrap(); @@ -80,6 +114,12 @@ fn kimi_provider_lifecycle_imports_native_config_and_tracks_external_removal() { let text = fs::read_to_string(home.config()).unwrap(); assert!(text.contains("updated.example.com")); assert!(text.contains("# user settings")); + assert_eq!( + ProviderService::current(&state, AppType::Kimi).unwrap(), + "custom" + ); + // Select the original native provider before removing the now-current one. + ProviderService::switch(&state, AppType::Kimi, "official").unwrap(); ProviderService::remove_from_live_config(&state, AppType::Kimi, "custom").unwrap(); assert_eq!( ProviderService::list(&state, AppType::Kimi).unwrap()["custom"] @@ -148,6 +188,63 @@ fn kimi_provider_add_rejects_collisions_and_protects_the_default_model() { assert_eq!(fs::read(home.config()).unwrap(), bytes); } +#[test] +fn kimi_switch_selects_native_model_and_current_tracks_external_changes() { + let _guard = test_mutex().lock().unwrap(); + reset_test_fs(); + let home = KimiHome::new(); + let state = create_test_state().unwrap(); + ProviderService::add( + &state, + AppType::Kimi, + provider("first", "alias-without-provider-prefix"), + true, + ) + .unwrap(); + ProviderService::add( + &state, + AppType::Kimi, + provider("second", "another-alias"), + false, + ) + .unwrap(); + assert_eq!( + ProviderService::current(&state, AppType::Kimi).unwrap(), + "first" + ); + ProviderService::switch(&state, AppType::Kimi, "second").unwrap(); + assert_eq!( + ProviderService::current(&state, AppType::Kimi).unwrap(), + "second" + ); + assert!(ProviderService::remove_from_live_config(&state, AppType::Kimi, "second").is_err()); + let mut native: toml::Value = + toml::from_str(&fs::read_to_string(home.config()).unwrap()).unwrap(); + native["default_model"] = toml::Value::String("alias-without-provider-prefix".into()); + native["providers"]["first"]["base_url"] = + toml::Value::String("https://edited-in-kimi.example/v1".into()); + fs::write(home.config(), toml::to_string(&native).unwrap()).unwrap(); + assert_eq!( + ProviderService::current(&state, AppType::Kimi).unwrap(), + "first" + ); + ProviderService::switch(&state, AppType::Kimi, "first").unwrap(); + let saved = state + .db + .get_provider_by_id("first", "kimi") + .unwrap() + .unwrap(); + assert_eq!( + saved.settings_config["provider"]["base_url"], + "https://edited-in-kimi.example/v1" + ); + native["default_model"] = toml::Value::String("missing-alias".into()); + fs::write(home.config(), toml::to_string(&native).unwrap()).unwrap(); + assert!(ProviderService::current(&state, AppType::Kimi) + .unwrap() + .is_empty()); +} + #[test] fn kimi_import_and_mutations_preserve_native_capabilities() { let _guard = test_mutex().lock().unwrap(); diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 122ea561c..af9397300 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -97,11 +97,13 @@ export function AddProviderDialog({ const [isFormSubmitting, setIsFormSubmitting] = useState(false); // 表单用了聚合的简化布局:页头应用名后标「聚合模式」 const [stackLayout, setStackLayout] = useState(false); + const [accountLogin, setAccountLogin] = useState(false); const [authSettingsTarget, setAuthSettingsTarget] = useState(null); useEffect(() => { setAuthSettingsTarget(null); + setAccountLogin(false); }, [appId, open]); // Claude:预设的关键字段套在当前 live 上显示(去掉当前供应商的关键字段),保存时 @@ -480,7 +482,7 @@ export function AddProviderDialog({ }, [open, waitingForClaudeBase, appId]); const footer = - step === "form" ? ( + step === "form" && !accountLogin ? ( <> + ); + + return ( +
+ {actionSlot ? ( + createPortal(loginButton, actionSlot) + ) : actionSlot === undefined ? ( +
{loginButton}
+ ) : null} +

{t("kimi.oauth.description")}

+
+ + + + {auth.status.isPending + ? t("common.loading") + : auth.status.isError + ? t("kimi.oauth.statusFailed") + : signedIn + ? t("kimi.oauth.signedIn", { + region: t(`kimi.oauth.${auth.status.data.region}`), + }) + : t("kimi.oauth.signedOut")} + + +
+ {signedIn && ( +
+
+ +
+
+ + {KIMI_OFFICIAL_PROVIDER_NAME} + + + {t("providerCard.chip.official")} + +
+

+ {t("kimi.oauth.signedIn", { + region: t( + `kimi.oauth.${auth.status.data?.region ?? "mainland-cn"}`, + ), + })} +

+
+ + +
+
+ )} + {auth.starting && ( + + )} + {auth.flow && ( +
+
+ )} + {(auth.error || auth.status.isError) && ( +
+ + {t("common.retry")} + + ) : undefined + } + > + {auth.error || extractErrorMessage(auth.status.error)} + +
+ )} + auth.logout.mutate()} + onCancel={() => setConfirmLogout(false)} + /> +
+ ); +} diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index ff8a38ac4..a6c627090 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -30,7 +30,10 @@ import { } from "@/utils/providerConfigUtils"; import { resolveManagedAccountId } from "@/lib/authBinding"; import { isMac } from "@/lib/platform"; -import { resolveCodexOfficialIdentity } from "@/utils/providerCapabilities"; +import { + isOfficialAccount, + resolveCodexOfficialIdentity, +} from "@/utils/providerCapabilities"; import { useProviderHealth } from "@/lib/query/failover"; import { useUsageQuery } from "@/lib/query/queries"; import { resolveProviderIcon } from "@/utils/providerIcon"; @@ -115,6 +118,7 @@ function HealthChip({ /** 判断是否为官方供应商(无自定义 base URL / API key,直连官方 API) */ function isOfficialProvider(provider: Provider, appId: AppId): boolean { + if (appId === "kimi") return isOfficialAccount(appId, provider); if (provider.category === "official") { return true; } diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 4938faf88..cd336db00 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -374,6 +374,7 @@ export function ProviderList({ isHermesManaged: (provider) => isHermesReadOnlyProvider(provider.settingsConfig), piStateUnavailable: appId === "pi" && !isPiStateReady, + kimiCurrentId: currentProviderId, actions: { add: onSwitch, remove: (provider) => @@ -402,6 +403,7 @@ export function ProviderList({ currentOmoSlimId, hermesModelConfig?.provider, isPiStateReady, + currentProviderId, onRemoveFromConfig, onDelete, onDisableOmo, diff --git a/src/components/providers/forms/KimiProviderForm.tsx b/src/components/providers/forms/KimiProviderForm.tsx index c6d35f178..bed0e8297 100644 --- a/src/components/providers/forms/KimiProviderForm.tsx +++ b/src/components/providers/forms/KimiProviderForm.tsx @@ -23,6 +23,8 @@ import { emptyKimiConfig, kimiConfigSchema, kimiProviderPresets, + kimiAccountPreset, + KIMI_ACCOUNT_PRESET_ID, KIMI_PROVIDER_TYPES, KIMI_CAPABILITIES, type KimiProviderConfig, @@ -33,11 +35,16 @@ import { ApiKeySection } from "./shared"; import { RequestHeadersEditor } from "./RequestHeadersEditor"; import { ProviderPresetSelector } from "./ProviderPresetSelector"; import type { ProviderFormProps } from "./ProviderForm"; +import { usePresetStep } from "./presetStep"; +import { KimiAccountLogin } from "../KimiAccountLogin"; -const PRESETS = kimiProviderPresets.map((preset, index) => ({ - id: String(index), - preset, -})); +const PRESETS = [ + { id: KIMI_ACCOUNT_PRESET_ID, preset: kimiAccountPreset }, + ...kimiProviderPresets.map((preset, index) => ({ + id: String(index), + preset, + })), +]; export function KimiProviderForm({ providerId, @@ -48,6 +55,7 @@ export function KimiProviderForm({ showButtons = true, onSubmittingChange, onSubmitReadyChange, + onAccountLoginChange, }: ProviderFormProps) { const { t } = useTranslation(); const darkMode = useDarkMode(); @@ -65,6 +73,15 @@ export function KimiProviderForm({ const [error, setError] = useState(""); const { data: existing } = useProvidersQuery("kimi"); const isEdit = Boolean(initialData); + const step = usePresetStep(); + const isAccountLogin = !isEdit && presetId === KIMI_ACCOUNT_PRESET_ID; + const showAccountLogin = isAccountLogin && (!step || step.step === "form"); + const [accountActionHost, setAccountActionHost] = + useState(null); + useEffect(() => { + onAccountLoginChange?.(showAccountLogin); + return () => onAccountLoginChange?.(false); + }, [showAccountLogin, onAccountLoginChange]); const duplicate = !isEdit && Object.prototype.hasOwnProperty.call( @@ -95,7 +112,8 @@ export function KimiProviderForm({ model.max_context_size > 0, ); const ready = Boolean( - jsonValid && + !isAccountLogin && + jsonValid && name.trim() && keyValid && !duplicate && @@ -134,6 +152,64 @@ export function KimiProviderForm({ ...config, models: { ...config.models, [key]: { ...config.models[key], ...patch } }, }); + const presetSelector = ( + : undefined + } + presetCategoryLabels={{ + custom: t("providerPreset.custom"), + cn_official: t("providerForm.categoryCnOfficial"), + third_party: t("providerPreset.group.thirdparty"), + aggregator: t("providerPreset.group.thirdparty"), + cloud_provider: t("providerPreset.group.cloud"), + local: t("providerPreset.group.local"), + }} + category={category} + onPresetChange={(id) => { + setPresetId(id); + if (id === KIMI_ACCOUNT_PRESET_ID) return; + const preset = + id === "custom" ? undefined : kimiProviderPresets[Number(id)]; + setCategory(preset?.category ?? "custom"); + update( + kimiConfigSchema.parse( + structuredClone(preset?.settingsConfig ?? emptyKimiConfig()), + ), + ); + setProviderKey(preset?.providerKey ?? ""); + form.reset({ + name: preset?.nameKey ? t(preset.nameKey) : (preset?.name ?? ""), + notes: "", + websiteUrl: preset?.websiteUrl ?? "", + icon: preset?.icon ?? "", + iconColor: preset?.iconColor ?? "", + settingsConfig: "", + }); + }} + /> + ); + if (isAccountLogin) { + return ( +
+ event.preventDefault()} + > + {presetSelector} + {showAccountLogin && ( + + )} + + + ); + } return (
- {!isEdit && ( - { - setPresetId(id); - const preset = - id === "custom" ? undefined : kimiProviderPresets[Number(id)]; - setCategory(preset?.category ?? "custom"); - update( - kimiConfigSchema.parse( - structuredClone(preset?.settingsConfig ?? emptyKimiConfig()), - ), - ); - setProviderKey(preset?.providerKey ?? ""); - form.reset({ - name: preset?.nameKey - ? t(preset.nameKey) - : (preset?.name ?? ""), - notes: "", - websiteUrl: preset?.websiteUrl ?? "", - icon: preset?.icon ?? "", - iconColor: preset?.iconColor ?? "", - settingsConfig: "", - }); - }} - /> - )} + {!isEdit && presetSelector} {error && (

{error} @@ -470,7 +510,7 @@ export function KimiProviderForm({ {submitLabel} - )} + )}{" "}

); diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index d8a8b8870..729d38589 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -292,6 +292,8 @@ export interface ProviderFormProps { onManageAuthAccounts?: (target: ManagedAuthProvider) => void; onSubmittingChange?: (isSubmitting: boolean) => void; onSubmitReadyChange?: (isReady: boolean) => void; + /** Native account login supplies its own actions instead of the provider-save footer. */ + onAccountLoginChange?: (accountLogin: boolean) => void; initialData?: { name?: string; websiteUrl?: string; diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index e936086f7..e67fb4e9f 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -134,6 +134,7 @@ interface ProviderPresetSelectorProps { onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void; onManageUniversalProviders?: () => void; category?: ProviderCategory; + barAction?: React.ReactNode; } /** @@ -146,6 +147,7 @@ export function ProviderPresetSelector({ onPresetChange, onUniversalPresetSelect, onManageUniversalProviders, + barAction, }: Readonly) { const step = usePresetStep(); const registerSelector = step?.registerSelector; @@ -201,6 +203,7 @@ export function ProviderPresetSelector({ return ( void; onVersionChange: (id: string) => void; + action?: React.ReactNode; }) { const { t } = useTranslation(); const family = versions.length > 1 ? entry?.preset.family : undefined; @@ -306,9 +311,16 @@ function PresetBar({ )} - + {action ?? ( + + )} {entry && family && ( (null); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(null); + const [retryable, setRetryable] = useState(false); + const generation = useRef(0); + const activeFlow = useRef(null); + const timer = useRef | null>(null); + const deadline = useRef(0); + + const invalidate = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: STATUS_KEY }), + queryClient.invalidateQueries({ queryKey: ["providers", "kimi"] }), + ]); + }, [queryClient]); + + const clearTimer = useCallback(() => { + if (timer.current) clearTimeout(timer.current); + timer.current = null; + }, []); + + const cancelBackend = useCallback( + async (id: string) => { + try { + await kimiOAuthApi.cancel(id); + } catch (e) { + console.debug("[KimiOAuth] Failed to cancel login:", e); + } + await invalidate(); + }, + [invalidate], + ); + + const cancel = useCallback(() => { + generation.current += 1; + clearTimer(); + const id = activeFlow.current; + activeFlow.current = null; + setFlow(null); + setStarting(false); + setError(null); + setRetryable(false); + if (id) void cancelBackend(id); + }, [cancelBackend, clearTimer]); + + useEffect( + () => () => { + generation.current += 1; + clearTimer(); + const id = activeFlow.current; + activeFlow.current = null; + if (id) void cancelBackend(id); + }, + [cancelBackend, clearTimer], + ); + + const poll = useCallback( + async function pollLogin(id: string, current: number) { + if (current !== generation.current) return; + if (Date.now() >= deadline.current) { + setError(t("kimi.oauth.expired")); + setRetryable(false); + setFlow(null); + activeFlow.current = null; + void cancelBackend(id); + return; + } + try { + const result = await kimiOAuthApi.poll(id); + if (current !== generation.current) return; + if (result.ended) { + activeFlow.current = null; + setFlow(null); + setError(t(`kimi.oauth.${result.ended}`)); + setRetryable(false); + } else if (result.authenticated) { + activeFlow.current = null; + setFlow(null); + setError(null); + setRetryable(false); + await invalidate(); + toast.success(t("kimi.oauth.success")); + } else { + timer.current = setTimeout( + () => void pollLogin(id, current), + Math.max(1, result.interval) * 1000, + ); + } + } catch (e) { + if (current !== generation.current) return; + // Retrying resumes this flow: a token may already have been issued while + // model discovery or disk persistence failed. Do not redeem it twice. + setError(extractErrorMessage(e)); + setRetryable(true); + } + }, + [cancelBackend, invalidate, t], + ); + + const start = useCallback( + async (region: KimiRegion) => { + const current = ++generation.current; + clearTimer(); + const previous = activeFlow.current; + activeFlow.current = null; + setFlow(null); + setError(null); + setRetryable(false); + setStarting(true); + try { + if (previous) await cancelBackend(previous); + if (current !== generation.current) return; + const next = await kimiOAuthApi.start(region); + if (current !== generation.current) { + void cancelBackend(next.flowId); + return; + } + activeFlow.current = next.flowId; + deadline.current = Date.now() + next.expiresIn * 1000; + setFlow(next); + setStarting(false); + // Keep the manual link and code visible even when the browser cannot open. + void settingsApi.openExternal(next.verificationUri).catch((e) => { + console.debug("[KimiOAuth] Could not open browser:", e); + }); + timer.current = setTimeout( + () => void poll(next.flowId, current), + Math.max(1, next.interval) * 1000, + ); + } catch (e) { + if (current !== generation.current) return; + setStarting(false); + setError(extractErrorMessage(e)); + } + }, + [cancelBackend, clearTimer, poll], + ); + + const retry = () => { + const id = activeFlow.current; + if (!id) return; + setError(null); + setRetryable(false); + void poll(id, generation.current); + }; + + const logout = useMutation({ + mutationFn: kimiOAuthApi.logout, + onSuccess: invalidate, + onError: (e) => setError(extractErrorMessage(e)), + }); + + const useAccount = useMutation({ + mutationFn: kimiOAuthApi.useAccount, + onSuccess: invalidate, + onError: (e) => setError(extractErrorMessage(e)), + }); + + return { + status, + flow, + starting, + error, + retryable, + start, + cancel, + retry, + logout, + useAccount, + }; +} diff --git a/src/components/providers/forms/presetGroups.ts b/src/components/providers/forms/presetGroups.ts index 80b1efc8b..cb36ea40b 100644 --- a/src/components/providers/forms/presetGroups.ts +++ b/src/components/providers/forms/presetGroups.ts @@ -472,6 +472,8 @@ export function loginAccountKey( return "google"; case "grokbuild": return "xai"; + case "kimi": + return "kimi"; default: return "claude"; } diff --git a/src/components/providers/presentation.ts b/src/components/providers/presentation.ts index f84193f39..f7c52bd21 100644 --- a/src/components/providers/presentation.ts +++ b/src/components/providers/presentation.ts @@ -678,7 +678,7 @@ export function buildLaunchSections(input: LaunchInput): ProviderSection[] { ]; } -// ─── 共存式应用(OpenCode / OpenClaw / Hermes / Pi / MiniMax Code)─────────── +// ─── 共存式应用(OpenCode / OpenClaw / Hermes / Pi / MiniMax Code / Kimi)───── export interface AdditiveInput { app: AppId; @@ -697,6 +697,8 @@ export interface AdditiveInput { isHermesManaged?: (provider: Provider) => boolean; /** Pi:读不到当前配置时一律不能改 */ piStateUnavailable?: boolean; + /** Kimi:原生 default_model 别名所属的供应商。 */ + kimiCurrentId?: string | null; actions: { add: (provider: Provider) => void; remove: (provider: Provider) => void; @@ -716,6 +718,52 @@ export function buildAdditiveSections(input: AdditiveInput): ProviderSection[] { for (const p of providers) { const chips: CardChip[] = []; + if (app === "kimi") { + if (isOfficialAccount(app, p)) { + chips.push({ + key: "official", + label: t("providerCard.chip.official"), + tone: "outline", + }); + } + const live = input.isInConfig(p); + const current = live && p.id === input.kimiCurrentId; + (live ? added : available).push({ + provider: p, + presentation: { + tone: current ? "direct" : undefined, + status: current + ? { label: t("providerCard.status.inUse"), dot: "direct" } + : undefined, + chips, + buttons: current + ? [] + : [ + { + key: "switch", + label: t("providerCard.action.switch"), + onClick: () => actions.add(p), + }, + ], + menuItems: live + ? [ + { + key: "remove", + label: t("providerCard.action.remove"), + disabledReason: current + ? t("providerCard.reason.currentCannotRemove") + : undefined, + onSelect: () => actions.remove(p), + }, + ] + : undefined, + deleteDisabledReason: current + ? t("providerCard.reason.inUseCannotDelete") + : undefined, + }, + }); + continue; + } const isOmo = p.category === "omo"; const isOmoSlim = p.category === "omo-slim"; if (isOmo) chips.push({ key: "omo", label: "OMO", tone: "outline" }); diff --git a/src/config/kimiProviderPresets.test.ts b/src/config/kimiProviderPresets.test.ts index 53ebed3bc..0b7d9e4f2 100644 --- a/src/config/kimiProviderPresets.test.ts +++ b/src/config/kimiProviderPresets.test.ts @@ -3,6 +3,8 @@ import { KIMI_CAPABILITIES, kimiConfigSchema, kimiProviderPresets, + kimiAccountPreset, + KIMI_ACCOUNT_PRESET_ID, } from "./kimiProviderPresets"; import { piProviderPresets } from "./piProviderPresets"; import { getVisiblePresetRows } from "@/components/providers/forms/ProviderPresetSelector"; @@ -21,6 +23,27 @@ const model = (name: string, id: string) => { }; describe("kimiProviderPresets", () => { + it("keeps official account login separate from the API-key vendor family", () => { + const t = ((key: string) => key) as TFunction; + const rows = getVisiblePresetRows( + [ + { id: KIMI_ACCOUNT_PRESET_ID, preset: kimiAccountPreset }, + ...kimiProviderPresets.map((preset) => ({ + id: preset.providerKey, + preset, + })), + ], + { query: "Kimi", t }, + ); + const login = rows.find( + ({ row }) => row.key === KIMI_ACCOUNT_PRESET_ID, + )!.row; + expect(presetRowGroup(login)).toBe("login"); + expect(login.family).toBeUndefined(); + expect( + rows.find(({ row }) => row.family === "kimi")!.row.versions, + ).toHaveLength(4); + }); it("provides the compatible catalog across vendors, platforms, cloud and local groups", () => { const compatible = piProviderPresets.filter( (preset) => preset.settingsConfig.api !== "bedrock-converse-stream", diff --git a/src/config/kimiProviderPresets.ts b/src/config/kimiProviderPresets.ts index 9bdcb69cd..08a0a8fd3 100644 --- a/src/config/kimiProviderPresets.ts +++ b/src/config/kimiProviderPresets.ts @@ -74,6 +74,20 @@ export interface KimiProviderPreset settingsConfig: KimiProviderConfig; } +export const KIMI_ACCOUNT_PRESET_ID = "kimi-official-account"; +export const KIMI_OFFICIAL_PROVIDER_NAME = "Kimi Official"; + +// An action in the account-login group, separate from the API-key catalog. +export const kimiAccountPreset: KimiProviderPreset = { + name: KIMI_OFFICIAL_PROVIDER_NAME, + nameKey: "kimi.oauth.title", + providerKey: KIMI_ACCOUNT_PRESET_ID, + websiteUrl: "https://www.kimi.com", + icon: "kimi", + category: "official", + settingsConfig: emptyKimiConfig(), +}; + type KimiProviderType = KimiProviderConfig["provider"]["type"]; type KimiModel = KimiProviderConfig["models"][string]; diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 17dd063c7..5850a05dd 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -268,8 +268,7 @@ export function useProviderActions( } else if ( activeApp === "opencode" || activeApp === "openclaw" || - activeApp === "mcode" || - activeApp === "kimi" + activeApp === "mcode" ) { messageKey = "notifications.addToConfigSuccess"; defaultMessage = "已添加到配置"; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8111bcbbe..86c7906b3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2124,6 +2124,7 @@ "universalDetail": "Synced to Claude Code, Codex, Gemini CLI", "manageUniversal": "Manage existing universal providers ›", "loginWith": { + "kimi": "Sign in with a Kimi account", "claude": "Sign in with a Claude account", "chatgpt": "Sign in with a ChatGPT account", "github": "Sign in with a GitHub account", @@ -4911,7 +4912,33 @@ } }, "kimi": { - "modelSelectionHint": "After enabling a provider, select the default model with /model in Kimi Code.", + "oauth": { + "useAccount": "Use this account", + "title": "Kimi Code official sign-in", + "description": "Sign in to your Kimi coding plan to add the official models automatically.", + "region": "Sign-in region", + "mainland-cn": "Mainland China", + "global": "Global", + "signIn": "Sign in to Kimi", + "signInAgain": "Sign in again", + "signOut": "Sign out", + "signedIn": "Signed in · {{region}}", + "signedOut": "Signed out", + "statusFailed": "Cannot read sign-in status", + "refresh": "Refresh Kimi sign-in status", + "waiting": "Waiting for browser authorization", + "copyCode": "Copy authorization code", + "copied": "Authorization code copied", + "openPage": "Open authorization page", + "manualHint": "This page updates after authorization. If the browser did not open, visit the link below and enter the code.", + "error": "Kimi sign-in did not complete", + "expired": "The authorization code expired. Please sign in again.", + "denied": "Authorization was denied. Please sign in again.", + "cancelled": "This sign-in was cancelled or replaced. Please sign in again.", + "success": "Signed in to Kimi Code and added the official models.", + "signOutConfirm": "This removes the local credentials for the current Kimi Code region and signs Kimi Code out. Providers, models and the current model selection are kept." + }, + "modelSelectionHint": "Use the switch button on a provider card to select it. To choose another model, use /model in Kimi Code.", "providerType": "Provider type", "baseUrl": "Base URL", "models": "Models", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d2ee040f9..b94d01589 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2124,6 +2124,7 @@ "universalDetail": "Claude Code・Codex・Gemini CLI に同期", "manageUniversal": "既存の統合プロバイダーを管理 ›", "loginWith": { + "kimi": "Kimi アカウントでログイン", "claude": "Claude アカウントでログイン", "chatgpt": "ChatGPT アカウントでログイン", "github": "GitHub アカウントでログイン", @@ -4911,7 +4912,33 @@ } }, "kimi": { - "modelSelectionHint": "有効にした後、Kimi Code の /model で既定のモデルを選択します。", + "oauth": { + "useAccount": "このアカウントを使用", + "title": "Kimi Code 公式ログイン", + "description": "Kimi のコーディングプランにログインすると、公式モデルが自動的に追加されます。", + "region": "ログイン地域", + "mainland-cn": "中国本土版", + "global": "グローバル版", + "signIn": "Kimi にログイン", + "signInAgain": "再ログイン", + "signOut": "ログアウト", + "signedIn": "ログイン済み · {{region}}", + "signedOut": "未ログイン", + "statusFailed": "ログイン状態を取得できません", + "refresh": "Kimi のログイン状態を更新", + "waiting": "ブラウザーでの認証を待っています", + "copyCode": "認証コードをコピー", + "copied": "認証コードをコピーしました", + "openPage": "認証ページを開く", + "manualHint": "認証が完了すると自動的に更新されます。ブラウザーが開かない場合は、下のリンクを開いてコードを入力してください。", + "error": "Kimi のログインが完了しませんでした", + "expired": "認証コードの有効期限が切れました。再ログインしてください。", + "denied": "認証が拒否されました。再ログインしてください。", + "cancelled": "ログインがキャンセルされたか、別のログインに置き換えられました。再ログインしてください。", + "success": "Kimi Code にログインし、公式モデルを追加しました。", + "signOutConfirm": "現在の地域のローカル認証情報を削除し、Kimi Code からもログアウトします。プロバイダー、モデル、現在のモデル選択は保持されます。" + }, + "modelSelectionHint": "プロバイダーカードの切り替えボタンで既定のプロバイダーを選択します。別のモデルを選ぶには、Kimi Code で /model を使用してください。", "providerType": "プロバイダーの種類", "baseUrl": "リクエスト URL", "models": "モデル", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 0e827b682..8c279a350 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -2070,6 +2070,7 @@ "universalDetail": "Claude Code, Codex, Gemini CLI ile eşitle edildi", "manageUniversal": "Mevcut evrensel sağlayıcıları yönetin ›", "loginWith": { + "kimi": "Kimi hesabıyla giriş yap", "claude": "Claude hesabıyla oturum açın", "chatgpt": "ChatGPT hesabıyla oturum açın", "github": "GitHub hesabıyla oturum açın", @@ -4771,7 +4772,33 @@ } }, "kimi": { - "modelSelectionHint": "Etkinleştirdikten sonra Kimi Code içinde /model ile varsayılan modeli seçin.", + "oauth": { + "useAccount": "Bu hesabı kullan", + "title": "Kimi Code resmî girişi", + "description": "Resmî modelleri otomatik eklemek için Kimi kodlama planınıza giriş yapın.", + "region": "Giriş bölgesi", + "mainland-cn": "Çin ana karası", + "global": "Küresel", + "signIn": "Kimi hesabına giriş yap", + "signInAgain": "Yeniden giriş yap", + "signOut": "Çıkış yap", + "signedIn": "Giriş yapıldı · {{region}}", + "signedOut": "Giriş yapılmadı", + "statusFailed": "Giriş durumu okunamadı", + "refresh": "Kimi giriş durumunu yenile", + "waiting": "Tarayıcı yetkilendirmesi bekleniyor", + "copyCode": "Yetkilendirme kodunu kopyala", + "copied": "Yetkilendirme kodu kopyalandı", + "openPage": "Yetkilendirme sayfasını aç", + "manualHint": "Yetkilendirmeden sonra bu sayfa güncellenir. Tarayıcı açılmadıysa aşağıdaki bağlantıyı ziyaret edip kodu girin.", + "error": "Kimi girişi tamamlanamadı", + "expired": "Yetkilendirme kodunun süresi doldu. Yeniden giriş yapın.", + "denied": "Yetkilendirme reddedildi. Yeniden giriş yapın.", + "cancelled": "Bu giriş iptal edildi veya değiştirildi. Yeniden giriş yapın.", + "success": "Kimi Code girişi yapıldı ve resmî modeller eklendi.", + "signOutConfirm": "Bu işlem, geçerli Kimi Code bölgesinin yerel kimlik bilgilerini kaldırır ve Kimi Code oturumunu kapatır. Sağlayıcılar, modeller ve geçerli model seçimi korunur." + }, + "modelSelectionHint": "Sağlayıcıyı seçmek için karttaki geçiş düğmesini kullanın. Başka bir model seçmek için Kimi Code içinde /model komutunu kullanın.", "providerType": "Sağlayıcı türü", "baseUrl": "İstek adresi", "models": "Modeller", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 6d29ac79e..eb1666457 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2125,6 +2125,7 @@ "universalDetail": "同步到 Claude Code、Codex、Gemini CLI", "manageUniversal": "管理現有的統一供應商 ›", "loginWith": { + "kimi": "使用 Kimi 帳號登入", "claude": "用 Claude 帳號登入", "chatgpt": "用 ChatGPT 帳號登入", "github": "用 GitHub 帳號登入", @@ -4912,7 +4913,33 @@ } }, "kimi": { - "modelSelectionHint": "啟用後,在 Kimi Code 中使用 /model 選擇預設模型。", + "oauth": { + "useAccount": "使用此帳號", + "title": "Kimi Code 官方登入", + "description": "使用 Kimi 帳號登入程式開發方案,登入完成後自動新增官方模型。", + "region": "登入區域", + "mainland-cn": "中國大陸版", + "global": "海外版", + "signIn": "登入 Kimi", + "signInAgain": "重新登入", + "signOut": "登出", + "signedIn": "已登入 · {{region}}", + "signedOut": "尚未登入", + "statusFailed": "無法讀取登入狀態", + "refresh": "重新整理 Kimi 登入狀態", + "waiting": "等待瀏覽器授權", + "copyCode": "複製授權碼", + "copied": "已複製授權碼", + "openPage": "開啟授權頁面", + "manualHint": "在瀏覽器完成授權後會自動更新。若瀏覽器未開啟,可手動前往下方連結並輸入授權碼。", + "error": "Kimi 登入未完成", + "expired": "授權碼已過期,請重新登入。", + "denied": "授權已遭拒絕,請重新登入。", + "cancelled": "登入已取消或被其他登入取代,請重新登入。", + "success": "Kimi Code 登入成功,已新增官方模型。", + "signOutConfirm": "這會移除 Kimi Code 目前區域的本機登入憑證,Kimi Code 也將登出。供應商、模型及目前的模型選擇會保留。" + }, + "modelSelectionHint": "點擊供應商卡片的切換按鈕選擇預設供應商;若要指定其他模型,請在 Kimi Code 中使用 /model。", "providerType": "供應商類型", "baseUrl": "請求位址", "models": "模型", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 843f858dd..b4027d801 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2124,6 +2124,7 @@ "universalDetail": "同步到 Claude Code、Codex、Gemini CLI", "manageUniversal": "管理已有的统一供应商 ›", "loginWith": { + "kimi": "使用 Kimi 账号登录", "claude": "用 Claude 账号登录", "chatgpt": "用 ChatGPT 账号登录", "github": "用 GitHub 账号登录", @@ -4911,7 +4912,33 @@ } }, "kimi": { - "modelSelectionHint": "启用后,在 Kimi Code 中使用 /model 选择默认模型。", + "oauth": { + "useAccount": "使用此账号", + "title": "Kimi Code 官方登录", + "description": "使用 Kimi 账号登录编程套餐,登录完成后自动添加官方模型。", + "region": "登录区域", + "mainland-cn": "国内版", + "global": "海外版", + "signIn": "登录 Kimi", + "signInAgain": "重新登录", + "signOut": "退出登录", + "signedIn": "已登录 · {{region}}", + "signedOut": "未登录", + "statusFailed": "无法读取登录状态", + "refresh": "刷新 Kimi 登录状态", + "waiting": "等待浏览器授权", + "copyCode": "复制授权码", + "copied": "授权码已复制", + "openPage": "打开授权页面", + "manualHint": "在浏览器完成授权后会自动更新。浏览器未打开时,可手动访问下方链接并输入授权码。", + "error": "Kimi 登录未完成", + "expired": "授权码已过期,请重新登录。", + "denied": "授权已被拒绝,请重新登录。", + "cancelled": "登录已取消或被其他登录替代,请重新登录。", + "success": "Kimi Code 登录成功,已添加官方模型。", + "signOutConfirm": "这会移除 Kimi Code 当前区域的本机登录凭据,Kimi Code 也将退出登录。供应商、模型和当前模型选择会保留。" + }, + "modelSelectionHint": "点击供应商卡片的切换按钮选择默认供应商;需要指定其他模型时,在 Kimi Code 中使用 /model。", "providerType": "供应商类型", "baseUrl": "请求地址", "models": "模型", diff --git a/src/lib/api/kimiOAuth.ts b/src/lib/api/kimiOAuth.ts new file mode 100644 index 000000000..ea013a4d0 --- /dev/null +++ b/src/lib/api/kimiOAuth.ts @@ -0,0 +1,32 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type KimiRegion = "mainland-cn" | "global"; + +export interface KimiOAuthStatus { + authenticated: boolean; + region: KimiRegion; + expiresAt: number | null; +} + +export interface KimiLoginStart { + flowId: string; + userCode: string; + verificationUri: string; + expiresIn: number; + interval: number; +} + +export const kimiOAuthApi = { + status: () => invoke("kimi_oauth_status"), + start: (region: KimiRegion) => + invoke("kimi_oauth_start", { region }), + poll: (flowId: string) => + invoke<{ + authenticated: boolean; + interval: number; + ended: "expired" | "denied" | "cancelled" | null; + }>("kimi_oauth_poll", { flowId }), + cancel: (flowId: string) => invoke("kimi_oauth_cancel", { flowId }), + logout: () => invoke("kimi_oauth_logout"), + useAccount: () => invoke("kimi_oauth_use_account"), +}; diff --git a/src/utils/providerCapabilities.test.ts b/src/utils/providerCapabilities.test.ts index 5c63cb5e9..bfdde2382 100644 --- a/src/utils/providerCapabilities.test.ts +++ b/src/utils/providerCapabilities.test.ts @@ -5,12 +5,51 @@ import { providerNeedsRouting, resolveCodexOfficialIdentity, supportsOfficialProxyTakeover, + isOfficialAccount, } from "@/utils/providerCapabilities"; function mkProvider(overrides: Partial = {}): Provider { return { id: "p1", name: "Test", settingsConfig: {}, ...overrides }; } +describe("Kimi official account identity", () => { + it.each([ + "oauth/kimi-code", + "kimi-code", + "oauth/kimi-code-env-0e4f99c69cc27850", + ])( + "recognizes a native OAuth reference %s without relying on a legacy category", + (key) => { + const provider = mkProvider({ + id: "managed:kimi-code", + settingsConfig: { provider: { oauth: { key } } }, + }); + expect(isOfficialAccount("kimi", provider)).toBe(true); + }, + ); + it("does not mark an API-key or custom OAuth provider as the official login", () => { + expect( + isOfficialAccount( + "kimi", + mkProvider({ + id: "managed:kimi-code", + category: "official", + settingsConfig: { provider: { api_key: "test-key" } }, + }), + ), + ).toBe(false); + expect( + isOfficialAccount( + "kimi", + mkProvider({ + id: "my-provider", + settingsConfig: { provider: { oauth: { key: "oauth/kimi-code" } } }, + }), + ), + ).toBe(false); + }); +}); + // wire_api 取自 config.toml;chat_completions 需转换(需路由),responses 直连。 const codexConfig = (wireApi: "chat_completions" | "responses") => `model_provider = "custom"\n\n[model_providers.custom]\nname = "X"\nbase_url = "https://x.example/v1"\nwire_api = "${wireApi}"\n`; diff --git a/src/utils/providerCapabilities.ts b/src/utils/providerCapabilities.ts index d9f743107..614cb761f 100644 --- a/src/utils/providerCapabilities.ts +++ b/src/utils/providerCapabilities.ts @@ -13,6 +13,7 @@ import { export const CODEX_OFFICIAL_PROVIDER_ID = "codex-official"; export const GROKBUILD_OFFICIAL_PROVIDER_ID = "grokbuild-official"; +export const KIMI_OFFICIAL_PROVIDER_ID = "managed:kimi-code"; export type CodexOfficialIdentity = | "native_login" @@ -92,6 +93,15 @@ export function isOfficialAccount( appId: AppId, provider: Pick, ): boolean { + if (appId === "kimi") { + const config = provider.settingsConfig.provider as + | { oauth?: { key?: unknown } } + | undefined; + return ( + provider.id === KIMI_OFFICIAL_PROVIDER_ID && + nonEmptyString(config?.oauth?.key) + ); + } return ( provider.category === "official" || resolveCodexOfficialIdentity(appId, provider) !== null diff --git a/tests/components/KimiAccountLogin.test.tsx b/tests/components/KimiAccountLogin.test.tsx new file mode 100644 index 000000000..270b4c09a --- /dev/null +++ b/tests/components/KimiAccountLogin.test.tsx @@ -0,0 +1,247 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { KimiAccountLogin } from "@/components/providers/KimiAccountLogin"; +import { kimiOAuthApi, type KimiOAuthStatus } from "@/lib/api/kimiOAuth"; +import { createTestQueryClient } from "../utils/testQueryClient"; + +const mocks = vi.hoisted(() => ({ openExternal: vi.fn(), copy: vi.fn() })); +vi.mock("@/lib/api/kimiOAuth", () => ({ + kimiOAuthApi: { + status: vi.fn(), + start: vi.fn(), + poll: vi.fn(), + cancel: vi.fn(), + logout: vi.fn(), + useAccount: vi.fn(), + }, +})); +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + settingsApi: { ...actual.settingsApi, openExternal: mocks.openExternal }, + }; +}); +vi.mock("@/lib/clipboard", () => ({ copyText: mocks.copy })); + +const signedOut: KimiOAuthStatus = { + authenticated: false, + region: "mainland-cn", + expiresAt: null, +}; +const device = { + flowId: "flow-1", + userCode: "ABCD-EFGH", + verificationUri: "https://auth.kimi.com/verify?code=ABCD-EFGH", + expiresIn: 600, + interval: 1, +}; +const pending = { authenticated: false, interval: 1, ended: null }; + +const flush = async () => { + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); +}; + +async function setup(status = signedOut) { + const client = createTestQueryClient(); + client.setQueryData(["kimi-oauth-status"], status); + vi.mocked(kimiOAuthApi.status).mockResolvedValue(status); + const view = render( + + + , + ); + await flush(); + return { ...view, client }; +} + +async function start() { + fireEvent.click(screen.getByRole("button", { name: "kimi.oauth.signIn" })); + await flush(); +} + +describe("Kimi official account login", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(kimiOAuthApi.status).mockResolvedValue(signedOut); + vi.mocked(kimiOAuthApi.start).mockResolvedValue(device); + vi.mocked(kimiOAuthApi.poll).mockResolvedValue(pending); + vi.mocked(kimiOAuthApi.cancel).mockResolvedValue(true); + vi.mocked(kimiOAuthApi.useAccount).mockResolvedValue(undefined); + vi.mocked(kimiOAuthApi.logout).mockResolvedValue(undefined); + mocks.openExternal.mockResolvedValue(undefined); + mocks.copy.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps the authorization code and manual link available when opening the browser fails", async () => { + mocks.openExternal.mockRejectedValue(new Error("No browser")); + await setup(); + await start(); + expect(kimiOAuthApi.start).toHaveBeenCalledWith("mainland-cn"); + expect(screen.getByText(device.userCode)).toBeInTheDocument(); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + device.verificationUri, + ); + expect(mocks.openExternal).toHaveBeenCalledWith(device.verificationUri); + fireEvent.click( + screen.getByRole("button", { name: "kimi.oauth.copyCode" }), + ); + await flush(); + expect(mocks.copy).toHaveBeenCalledWith(device.userCode); + expect(kimiOAuthApi.poll).not.toHaveBeenCalled(); + }); + + it("refreshes both native login status and the supplier list on successful authorization", async () => { + const { client } = await setup(); + const invalidate = vi.spyOn(client, "invalidateQueries"); + await start(); + vi.mocked(kimiOAuthApi.poll).mockResolvedValue({ + authenticated: true, + interval: 0, + ended: null, + }); + vi.mocked(kimiOAuthApi.status).mockResolvedValue({ + ...signedOut, + authenticated: true, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + await flush(); + expect(screen.queryByText(device.userCode)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "kimi.oauth.signInAgain" }), + ).toBeEnabled(); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: ["providers", "kimi"], + }); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: ["kimi-oauth-status"], + }); + }); + + it("honors a longer server polling interval without overlapping requests", async () => { + await setup(); + await start(); + vi.mocked(kimiOAuthApi.poll).mockResolvedValue({ ...pending, interval: 6 }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(kimiOAuthApi.poll).toHaveBeenCalledTimes(1); + await act(async () => { + await vi.advanceTimersByTimeAsync(5999); + }); + expect(kimiOAuthApi.poll).toHaveBeenCalledTimes(1); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(kimiOAuthApi.poll).toHaveBeenCalledTimes(2); + }); + + it("cancels on user action and ignores a poll response that arrives later", async () => { + let resolvePoll!: (value: { + authenticated: boolean; + interval: number; + ended: null; + }) => void; + vi.mocked(kimiOAuthApi.poll).mockImplementation( + () => + new Promise((resolve) => { + resolvePoll = resolve; + }), + ); + await setup(); + await start(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + fireEvent.click(screen.getByRole("button", { name: "common.cancel" })); + await flush(); + expect(kimiOAuthApi.cancel).toHaveBeenCalledWith(device.flowId); + await act(async () => { + resolvePoll({ authenticated: true, interval: 0, ended: null }); + }); + expect(screen.queryByText(device.userCode)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "kimi.oauth.signIn" }), + ).toBeEnabled(); + }); + + it("cancels a device flow received after the card is unmounted", async () => { + let resolveStart!: (value: typeof device) => void; + vi.mocked(kimiOAuthApi.start).mockImplementation( + () => + new Promise((resolve) => { + resolveStart = resolve; + }), + ); + const { unmount } = await setup(); + await start(); + unmount(); + await act(async () => { + resolveStart(device); + }); + expect(kimiOAuthApi.cancel).toHaveBeenCalledWith(device.flowId); + expect(mocks.openExternal).not.toHaveBeenCalled(); + }); + + it("retries model discovery within the existing flow after a transient failure", async () => { + await setup(); + await start(); + vi.mocked(kimiOAuthApi.poll).mockRejectedValueOnce( + "Model discovery failed", + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(screen.getByRole("alert")).toHaveTextContent( + "Model discovery failed", + ); + fireEvent.click(screen.getByRole("button", { name: "common.retry" })); + await flush(); + expect(kimiOAuthApi.start).toHaveBeenCalledTimes(1); + expect(kimiOAuthApi.poll).toHaveBeenCalledTimes(2); + expect(kimiOAuthApi.poll).toHaveBeenLastCalledWith(device.flowId); + }); + + it.each(["expired", "denied", "cancelled"] as const)( + "allows a fresh sign-in after authorization is %s", + async (ended) => { + await setup(); + await start(); + vi.mocked(kimiOAuthApi.poll).mockResolvedValue({ ...pending, ended }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(screen.getByRole("alert")).toHaveTextContent( + `kimi.oauth.${ended}`, + ); + expect(screen.queryByText(device.userCode)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "kimi.oauth.signIn" }), + ).toBeEnabled(); + }, + ); + + it("requires confirmation before removing the native login", async () => { + await setup({ ...signedOut, authenticated: true }); + fireEvent.click(screen.getByRole("button", { name: "kimi.oauth.signOut" })); + expect(kimiOAuthApi.logout).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toHaveTextContent( + "kimi.oauth.signOutConfirm", + ); + fireEvent.click( + screen.getAllByRole("button", { name: "kimi.oauth.signOut" }).at(-1)!, + ); + await flush(); + expect(kimiOAuthApi.logout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/components/KimiAddProviderDialog.test.tsx b/tests/components/KimiAddProviderDialog.test.tsx new file mode 100644 index 000000000..2a05bd2f6 --- /dev/null +++ b/tests/components/KimiAddProviderDialog.test.tsx @@ -0,0 +1,185 @@ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; +import { kimiOAuthApi } from "@/lib/api/kimiOAuth"; +import { createTestQueryClient } from "../utils/testQueryClient"; + +vi.mock("@/lib/api/kimiOAuth", () => ({ + kimiOAuthApi: { + status: vi.fn(), + start: vi.fn(), + poll: vi.fn(), + cancel: vi.fn(), + logout: vi.fn(), + useAccount: vi.fn(), + }, +})); +vi.mock("@/components/JsonEditor", () => ({ + default: ({ + id, + value, + onChange, + }: { + id: string; + value: string; + onChange: (text: string) => void; + }) => ( +