From 8221e05db1ec13334f495f8f355c33f3f1d9b2cc Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Sat, 22 Aug 2026 14:00:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(oauth):=20v0.7.7=20=E2=80=94=20=E5=8F=8C?= =?UTF-8?q?=E5=8C=BA=E5=9F=9F=20OAuth=20=E6=94=AF=E6=8C=81=EF=BC=88?= =?UTF-8?q?=E9=80=82=E9=85=8D=20kimi-code=200.38.0=20global=20=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=EF=BC=89+=20=E4=BF=AE=E5=A4=8D=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E5=88=AB=E5=90=8D=E8=B7=9F=E9=9A=8F=20+=20mo?= =?UTF-8?q?dels.dev=20=E5=BF=AB=E7=85=A7=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- release-notes-v0.7.7.md | 18 + src-tauri/Cargo.lock | 3 +- src-tauri/Cargo.toml | 5 +- src-tauri/src/commands.rs | 30 +- src-tauri/src/kimi_code_io.rs | 78 +- src-tauri/src/oauth.rs | 521 +- src-tauri/src/services/coding_plan.rs | 58 +- src-tauri/src/services/mod.rs | 28 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 73 +- src/components/KimiOAuthDialog.tsx | 97 +- src/i18n/en.ts | 4 + src/i18n/zh.ts | 4 + src/lib/models-dev-full.json | 12203 +++++++++++++++++++++--- src/lib/models-dev.json | 10344 +++++++++++++++++--- 16 files changed, 20602 insertions(+), 2868 deletions(-) create mode 100644 release-notes-v0.7.7.md diff --git a/package.json b/package.json index e71b12c..db51536 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.7.6", + "version": "0.7.7", "type": "module", "scripts": { "dev": "vite", diff --git a/release-notes-v0.7.7.md b/release-notes-v0.7.7.md new file mode 100644 index 0000000..57bf815 --- /dev/null +++ b/release-notes-v0.7.7.md @@ -0,0 +1,18 @@ +## v0.7.7 + +### 双区域 OAuth 支持(适配 kimi-code 0.38.0) + +- 适配官方 CLI v0.38.0 引入的双区域登录(mainland-cn `auth.kimi.com` / global `auth.kimi.ai`,#2862) +- **凭据动态解析**:按 provider 的 oauth ref(`key` / `oauthHost`)推导凭据文件与 token 刷新端点,global 账号(`credentials/kimi-code-env-.json`)可正常查询用量;无 oauth ref 的老配置完全走旧路径,零回归 +- **用量查询区域适配**:`api.kimi.ai/coding` 识别为官方套餐,usages 查询按 provider base_url 拼接 +- **内置登录区域选择**:应用内 Kimi 登录对话框新增「中国大陆 / 国际版 (kimi.ai)」选择,登录成功后同步落盘凭据 + 按官方 CLI 行为 provision `[providers."managed:kimi-code"]`(global 写 `oauthHost`,cn 不写,保持区域信号正确) +- scoped key 推导与官方 CLI 逐字节一致(`JSON.stringify({oauthHost, baseUrl})` 的 sha256 前 16 位 hex),hash 值经独立复算验证 + +### 修复 + +- 修复手动添加模型后模型用量显示为 `xxx/新模型` 而非真实模型名:填入实际模型 ID 时别名自动跟随为 `/`(含输入中间态跟随),连续添加占位条目不再互相覆盖 +- 修复 managed(OAuth)供应商自填 `api_key` 在保存配置时被清空的问题:无 key 时按官方 provisioned 形态写空行,用户自填 key 完整保留 + +### 模型数据更新 + +- models.dev 快照刷新(**7246 模型 / 193 供应商**):新增 **DeepSeek V4 Flash Vision Exp**(官方定价与 v4-flash 一致)、**Ox Alpha Free**(opencode-go,免费)等 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 57807de..a215e8c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1978,7 +1978,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.7.6" +version = "0.7.7" dependencies = [ "anyhow", "chrono", @@ -1991,6 +1991,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-opener", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 68eb756..b5ca9ba 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kimiswitch" -version = "0.7.6" +version = "0.7.7" description = "Kimi Switch - model config manager" authors = ["codingplan.site"] edition = "2021" @@ -29,6 +29,9 @@ tokio = { version = "1", features = ["sync", "fs", "io-util"] } # Plugin marketplace: zip extraction (deflate-only; no extra compression # backends needed for plugin archives). zip = { version = "2", default-features = false, features = ["deflate"] } +# Scoped OAuth credential keys (oauth/kimi-code-env-) for non-mainland +# regions, mirroring the official CLI's resolveKimiCodeOAuthKey. +sha2 = "0.10" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index add68b1..b9f9447 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -658,7 +658,12 @@ pub async fn query_provider_usage( }); let mut oauth_err: Option = None; if api_key.is_none() && provider.managed { - match crate::oauth::get_valid_access_token().await { + // Resolve the credential slot + refresh host from the provider's oauth + // ref (region-aware); with no ref this falls back to the mainland + // default, preserving pre-region behavior. + let oauth_ref = crate::oauth::oauth_ref_from_provider(provider); + let oauth_base_url = provider.base_url.as_deref().unwrap_or(""); + match crate::oauth::get_valid_access_token(oauth_ref.as_ref(), oauth_base_url).await { Ok(token) => api_key = Some(token), Err(e) => oauth_err = Some(e), } @@ -1249,22 +1254,29 @@ pub fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), Strin // Kimi OAuth device-code sign-in (in-app replacement for `kimi login`) // --------------------------------------------------------------------------- -/// Step 1: ask auth.kimi.com for a user_code + verification URI. +/// Step 1: ask the region's OAuth host for a user_code + verification URI. +/// `region` is `"cn"` (default) or `"global"`. #[tauri::command] -pub async fn kimi_oauth_start() -> Result { - crate::oauth::start_device_authorization().await +pub async fn kimi_oauth_start( + region: Option, +) -> Result { + let region = crate::oauth::KimiRegion::from_opt(region.as_deref()); + crate::oauth::start_device_authorization(region).await } -/// Step 2: poll the token endpoint until the user approves. On success the -/// tokens are written to the CLI credentials file (`kimi login` no longer -/// needed). Errors are transient (network); deterministic outcomes -/// (pending / success / expired / denied / timeout) come back in the enum. +/// Step 2: poll the region's token endpoint until the user approves. On +/// success the tokens are written to the region's credentials file and the +/// provider is provisioned in config.toml (`kimi login` no longer needed). +/// Errors are transient (network); deterministic outcomes (pending / success / +/// expired / denied / timeout) come back in the enum. #[tauri::command] pub async fn kimi_oauth_poll( device_code: String, interval: i64, + region: Option, ) -> Result { - crate::oauth::poll_device_token(&device_code, interval).await + let region = crate::oauth::KimiRegion::from_opt(region.as_deref()); + crate::oauth::poll_device_token(&device_code, interval, region).await } // --------------------------------------------------------------------------- diff --git a/src-tauri/src/kimi_code_io.rs b/src-tauri/src/kimi_code_io.rs index 7794ec5..d28a7bb 100644 --- a/src-tauri/src/kimi_code_io.rs +++ b/src-tauri/src/kimi_code_io.rs @@ -261,7 +261,15 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) { pt.insert("base_url".to_string(), TomlValue::String(base_url)); } - if let Some(api_key) = provider.api_key.clone().filter(|s| !s.is_empty()) { + // Managed (OAuth) providers persist an empty api_key line so the block + // matches the official CLI's provisioned shape (apiKey: ''). A + // user-set api_key on a managed provider (api_key outranks OAuth in + // the CLI's credential priority) must survive the round-trip. + if provider.managed + && provider.api_key.as_deref().map_or(true, |s| s.is_empty()) + { + pt.insert("api_key".to_string(), TomlValue::String("".to_string())); + } else if let Some(api_key) = provider.api_key.clone().filter(|s| !s.is_empty()) { pt.insert("api_key".to_string(), TomlValue::String(api_key)); } pt.insert("enabled".to_string(), TomlValue::Boolean(provider.enabled)); @@ -864,4 +872,72 @@ max_context_size = 1048576 "private default_model must not leak into config.toml" ); } + + #[test] + fn kimi_code_export_managed_api_key_roundtrip() { + // Regression: a managed (OAuth) provider keeps the official provisioned + // shape (empty api_key line) when it has no key, but a user-set api_key + // on a managed provider (api_key outranks OAuth in the CLI's credential + // priority) must survive the round-trip. + let managed_provider = |api_key: Option<&str>| Provider { + name: "managed:kimi-code".to_string(), + provider_type: ProviderType::Kimi, + base_url: Some("https://api.kimi.com/coding/v1".to_string()), + api_key: api_key.map(String::from), + env: IndexMap::new(), + note: None, + official_url: None, + managed: true, + enabled: true, + active: true, + icon: None, + icon_color: None, + raw_other: serde_json::json!({ + "oauth": {"storage": "file", "key": "oauth/kimi-code"} + }), + usage_kinds: None, + usage_config: None, + }; + + // No api_key → the empty provisioned line is written. + let config = Config { + default_model: None, + providers: IndexMap::from([( + "managed:kimi-code".to_string(), + managed_provider(None), + )]), + models: IndexMap::new(), + raw_other: Value::Null, + }; + let exported = config_to_kimi_code(&config, None); + let provider = exported + .as_table().unwrap() + .get("providers").unwrap() + .as_table().unwrap() + .get("managed:kimi-code").unwrap() + .as_table().unwrap(); + assert_eq!(provider.get("api_key").and_then(|v| v.as_str()), Some("")); + + // User-set api_key on a managed provider survives. + let config = Config { + default_model: None, + providers: IndexMap::from([( + "managed:kimi-code".to_string(), + managed_provider(Some("sk-user-key")), + )]), + models: IndexMap::new(), + raw_other: Value::Null, + }; + let exported = config_to_kimi_code(&config, None); + let provider = exported + .as_table().unwrap() + .get("providers").unwrap() + .as_table().unwrap() + .get("managed:kimi-code").unwrap() + .as_table().unwrap(); + assert_eq!( + provider.get("api_key").and_then(|v| v.as_str()), + Some("sk-user-key") + ); + } } diff --git a/src-tauri/src/oauth.rs b/src-tauri/src/oauth.rs index da0d6ea..0e32a63 100644 --- a/src-tauri/src/oauth.rs +++ b/src-tauri/src/oauth.rs @@ -30,7 +30,16 @@ //! 2. user opens the verification URI in a browser and approves; //! 3. `poll_device_token()` polls POST /api/oauth/token with the device_code //! grant until the tokens arrive, then writes them to the same -//! `~/.kimi-code/credentials/kimi-code.json` file the CLI uses. +//! `~/.kimi-code/credentials/.json` file the CLI uses. +//! +//! v4 mirrors the official CLI's dual-region OAuth (v0.38.0, #2862): the login +//! flow and credential lookup are scoped by region. The mainland-cn region +//! (default) uses the shared `oauth/kimi-code` slot (`credentials/kimi-code.json`) +//! and persists no `oauthHost`; the global region derives a scoped key +//! `oauth/kimi-code-env-` from (oauthHost, baseUrl), writes +//! `credentials/.json`, and persists `oauthHost` in config.toml. Usage +//! queries and token refresh resolve their credentials path + refresh endpoint +//! from the provider's oauth ref instead of always assuming mainland. use serde::{Deserialize, Serialize}; @@ -40,12 +49,181 @@ use crate::kimi_code_io::kimi_code_config_dir; /// mid-request counts as expired. const EXPIRY_LEEWAY_SECS: i64 = 30; -/// OAuth token endpoint (confirmed in the official kimi.exe binary). -const TOKEN_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/token"; -/// Device authorization endpoint (RFC 8628). -const DEVICE_AUTHORIZATION_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/device_authorization"; +/// Region endpoints (mirror `packages/oauth/src/region.ts`). +const CN_OAUTH_HOST: &str = "https://auth.kimi.com"; +const CN_BASE_URL: &str = "https://api.kimi.com/coding/v1"; +const GLOBAL_OAUTH_HOST: &str = "https://auth.kimi.ai"; +const GLOBAL_BASE_URL: &str = "https://api.kimi.ai/coding/v1"; +/// Shared mainland credential slot (mirror `KIMI_CODE_OAUTH_KEY`). +const DEFAULT_OAUTH_KEY: &str = "oauth/kimi-code"; +/// Prefix of region-scoped credential keys (mirror `KIMI_CODE_SCOPED_OAUTH_KEY_PREFIX`). +const SCOPED_OAUTH_KEY_PREFIX: &str = "oauth/kimi-code-env-"; +/// OAuth token endpoint host suffix (remaining path after the oauth host). +const TOKEN_PATH: &str = "/api/oauth/token"; +/// Device authorization endpoint suffix (RFC 8628). +const DEVICE_AUTHORIZATION_PATH: &str = "/api/oauth/device_authorization"; /// Public OAuth client id used by the official CLI (from kimi.exe). const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098"; +/// The managed Kimi Code provider name in config.toml. +const MANAGED_PROVIDER_NAME: &str = "managed:kimi-code"; + +/// A Kimi Code account region (mainland `.com` vs global `.ai`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KimiRegion { + /// Mainland China — `auth.kimi.com` / `api.kimi.com`. + Cn, + /// International — `auth.kimi.ai` / `api.kimi.ai`. + Global, +} + +impl KimiRegion { + /// Parse a region hint from the frontend (`"cn"` default, `"global"`). + pub fn from_opt(s: Option<&str>) -> KimiRegion { + match s.map(|s| s.trim().to_ascii_lowercase()).as_deref() { + Some("global") => KimiRegion::Global, + _ => KimiRegion::Cn, + } + } + + pub fn oauth_host(self) -> &'static str { + match self { + KimiRegion::Cn => CN_OAUTH_HOST, + KimiRegion::Global => GLOBAL_OAUTH_HOST, + } + } + + /// Managed API base (`/coding/v1`): usages host for this region. + pub fn base_url(self) -> &'static str { + match self { + KimiRegion::Cn => CN_BASE_URL, + KimiRegion::Global => GLOBAL_BASE_URL, + } + } +} + +/// The oauth ref stored under a provider's `raw_other["oauth"]` +/// (`storage`/`key`/`oauthHost`), all optional for lenient parsing. +/// `storage` is not read (Kimi Switch only ever uses `file`). +#[derive(Debug, Clone, Default)] +pub struct OAuthRef { + pub key: Option, + pub oauth_host: Option, +} + +/// Extract the oauth ref from a provider's `raw_other["oauth"]` block, if any. +pub fn oauth_ref_from_provider(provider: &crate::models::Provider) -> Option { + let oauth = provider.raw_other.get("oauth")?.as_object()?; + Some(OAuthRef { + key: oauth.get("key").and_then(|v| v.as_str()).map(String::from), + oauth_host: oauth + .get("oauthHost") + .and_then(|v| v.as_str()) + .map(String::from), + }) +} + +/// Map an oauth credential `key` to the credentials-file storage name, mirroring +/// the official CLI's `resolveKimiTokenStorageName`: +/// - `"kimi-code"` / `"oauth/kimi-code"` → `"kimi-code"` (file kimi-code.json) +/// - `"oauth/"` → `` +/// - `` (no `/`, not `.`-prefixed) → `` verbatim +/// - anything else → Err +pub fn resolve_storage_name(key: &str) -> Result { + if key == "kimi-code" || key == DEFAULT_OAUTH_KEY { + return Ok("kimi-code".to_string()); + } + if let Some(rest) = key.strip_prefix("oauth/") { + if !rest.is_empty() { + return Ok(rest.to_string()); + } + } + if !key.contains('/') && !key.starts_with('.') { + return Ok(key.to_string()); + } + Err(format!("Invalid Kimi OAuth token key: {key}")) +} + +/// `trim().replace(/\/+$/, '')`, matching the CLI's `normalizeEndpoint`. +fn normalize_endpoint(s: &str) -> String { + s.trim().trim_end_matches('/').to_string() +} + +/// Derive the oauth credential key for an (oauth_host, base_url) pair, mirroring +/// the official CLI's `resolveKimiCodeOAuthKey`: the mainland defaults map to the +/// shared `oauth/kimi-code` slot; anything else gets a scoped slot +/// `oauth/kimi-code-env-` of `JSON.stringify({oauthHost, baseUrl})`. +/// +/// The payload must byte-match JS `JSON.stringify({ oauthHost, baseUrl })` +/// (oauthHost first, no spaces), so it is hand-built with `format!` rather than +/// `serde_json::json!` (which can't guarantee field order or exact whitespace). +pub fn derive_scoped_key(oauth_host: &str, base_url: &str) -> String { + let oauth_host = normalize_endpoint(oauth_host); + let base_url = normalize_endpoint(base_url); + if oauth_host == CN_OAUTH_HOST && base_url == CN_BASE_URL { + return DEFAULT_OAUTH_KEY.to_string(); + } + let payload = format!( + "{{\"oauthHost\":\"{oauth_host}\",\"baseUrl\":\"{base_url}\"}}" + ); + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(payload.as_bytes()); + let hex = digest.iter().map(|b| format!("{b:02x}")).collect::(); + format!("{SCOPED_OAUTH_KEY_PREFIX}{}", &hex[..16]) +} + +/// Resolved credential context for a usage query / token refresh: which +/// credentials file to read/write and which OAuth host to refresh against. +struct OAuthContext { + storage_name: String, + oauth_host: String, +} + +impl OAuthContext { + /// Resolve from an optional oauth ref + provider base_url. + /// + /// - No ref → the legacy mainland default (`credentials/kimi-code.json` + + /// `auth.kimi.com`), so a provider with no oauth block behaves exactly as + /// before the region work. + /// - Ref with a `key` → use that key's storage (scoped slot for global). + /// - Ref with only `oauthHost` (no key) → derive the scoped key from + /// (oauthHost, base_url), matching the CLI's `resolveKimiCodeOAuthRef`. + fn from(oauth_ref: Option<&OAuthRef>, base_url: &str) -> OAuthContext { + let (key, oauth_host) = match oauth_ref { + Some(r) => { + let host = r + .oauth_host + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| CN_OAUTH_HOST.to_string()); + let k = r + .key + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| derive_scoped_key(&host, base_url)); + (k, host) + } + None => (DEFAULT_OAUTH_KEY.to_string(), CN_OAUTH_HOST.to_string()), + }; + let storage_name = + resolve_storage_name(&key).unwrap_or_else(|_| "kimi-code".to_string()); + OAuthContext { + storage_name, + oauth_host, + } + } + + fn token_endpoint(&self) -> String { + format!("{}{TOKEN_PATH}", self.oauth_host.trim_end_matches('/')) + } +} + +/// Credentials file path for a given storage name. +fn credentials_path_for_storage(storage_name: &str) -> std::path::PathBuf { + kimi_code_config_dir() + .join("credentials") + .join(format!("{storage_name}.json")) +} + /// Refresh request timeout; refresh is rare, a bit more headroom than the 8s /// query default is fine. const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -80,17 +258,11 @@ impl OAuthCredentials { } } -fn credentials_path() -> std::path::PathBuf { - kimi_code_config_dir() - .join("credentials") - .join("kimi-code.json") -} - -/// Load the Kimi Code OAuth session. Errors are deterministic (missing file / -/// unreadable JSON), never transient — the caller turns them into -/// `Ok(success:false)`. -pub fn load_kimi_code_credentials() -> Result { - let path = credentials_path(); +/// Load OAuth session from the credentials file for a storage name. Errors are +/// deterministic (missing file / unreadable JSON), never transient — the caller +/// turns them into `Ok(success:false)`. +fn load_credentials_for_storage(storage_name: &str) -> Result { + let path = credentials_path_for_storage(storage_name); let content = std::fs::read_to_string(&path).map_err(|e| { format!( "Kimi Code OAuth credentials not found at {}: {e}. Run `kimi login` first.", @@ -101,6 +273,11 @@ pub fn load_kimi_code_credentials() -> Result { .map_err(|e| format!("Failed to parse Kimi Code OAuth credentials: {e}")) } +/// Load the legacy mainland Kimi Code OAuth session (`kimi-code.json`). +pub fn load_kimi_code_credentials() -> Result { + load_credentials_for_storage("kimi-code") +} + #[derive(Debug, Deserialize)] struct TokenResponse { access_token: String, @@ -118,10 +295,16 @@ fn refresh_lock() -> &'static tokio::sync::Mutex<()> { } /// Return a usable access token, refreshing via the refresh_token grant when -/// the stored one is expired. Errors are deterministic (missing/dead session) -/// — the caller surfaces them as `Ok(success:false)`. -pub async fn get_valid_access_token() -> Result { - let creds = load_kimi_code_credentials()?; +/// the stored one is expired. The credential file and refresh endpoint are +/// resolved from the provider's oauth ref + base_url (see [`OAuthContext`]); +/// with no ref this is the legacy mainland default. Errors are deterministic +/// (missing/dead session) — the caller surfaces them as `Ok(success:false)`. +pub async fn get_valid_access_token( + oauth_ref: Option<&OAuthRef>, + base_url: &str, +) -> Result { + let ctx = OAuthContext::from(oauth_ref, base_url); + let creds = load_credentials_for_storage(&ctx.storage_name)?; if !creds.is_expired() { return Ok(creds.access_token); } @@ -129,11 +312,11 @@ pub async fn get_valid_access_token() -> Result { let _guard = refresh_lock().lock().await; // Re-read under the lock: the CLI or a previous waiter may have refreshed // while we were waiting. - let creds = load_kimi_code_credentials()?; + let creds = load_credentials_for_storage(&ctx.storage_name)?; if !creds.is_expired() { return Ok(creds.access_token); } - refresh_credentials(&creds).await + refresh_credentials(&creds, &ctx).await } /// Merge a token endpoint response into the existing credentials JSON, @@ -181,8 +364,9 @@ fn merge_token_response(current_json: &str, resp: &TokenResponse) -> String { } /// Call the token endpoint with the refresh_token grant and persist the -/// rotated tokens. Caller must hold [`refresh_lock`]. -async fn refresh_credentials(creds: &OAuthCredentials) -> Result { +/// rotated tokens. The refresh endpoint + file come from the resolved context. +/// Caller must hold [`refresh_lock`]. +async fn refresh_credentials(creds: &OAuthCredentials, ctx: &OAuthContext) -> Result { let refresh_token = creds .refresh_token .clone() @@ -198,7 +382,7 @@ async fn refresh_credentials(creds: &OAuthCredentials) -> Result // Tokens only ever go into the request body — never into logs or errors. let resp = client - .post(TOKEN_ENDPOINT) + .post(ctx.token_endpoint()) .form(&[ ("grant_type", "refresh_token"), ("client_id", CLIENT_ID), @@ -226,7 +410,7 @@ async fn refresh_credentials(creds: &OAuthCredentials) -> Result // Re-read right before writing: if the CLI refreshed meanwhile, its newer // (rotated) tokens win — overwriting them would kill its next refresh. - let path = credentials_path(); + let path = credentials_path_for_storage(&ctx.storage_name); let current = std::fs::read_to_string(&path).unwrap_or_default(); if let Ok(latest) = serde_json::from_str::(¤t) { if !latest.is_expired() && latest.access_token != creds.access_token { @@ -326,17 +510,20 @@ fn identity_headers() -> Vec<(&'static str, String)> { headers } -/// Step 1 of the device flow: ask the server for a user_code + device_code. -/// No user interaction required here; the frontend shows the code + URL. -pub async fn start_device_authorization() -> Result { +/// Step 1 of the device flow: ask the region's server for a user_code + +/// device_code. No user interaction required here; the frontend shows the +/// code + URL. +pub async fn start_device_authorization(region: KimiRegion) -> Result { let client = reqwest::Client::builder() .timeout(REFRESH_TIMEOUT) .build() .map_err(|e| format!("Failed to build HTTP client: {e}"))?; - let mut req = client - .post(DEVICE_AUTHORIZATION_ENDPOINT) - .form(&[("client_id", CLIENT_ID)]); + let endpoint = format!( + "{}{DEVICE_AUTHORIZATION_PATH}", + region.oauth_host().trim_end_matches('/') + ); + let mut req = client.post(&endpoint).form(&[("client_id", CLIENT_ID)]); for (name, value) in identity_headers() { req = req.header(name, value); } @@ -356,24 +543,30 @@ pub async fn start_device_authorization() -> Result .map_err(|e| format!("Failed to parse device authorization response: {e}")) } -/// Step 2 of the device flow: poll the token endpoint until the user +/// Step 2 of the device flow: poll the region's token endpoint until the user /// approves (or the flow fails). On success the tokens are merged into the -/// CLI credentials file, making `kimi login` unnecessary. +/// region's credentials file and the provider is provisioned, making +/// `kimi login` unnecessary. pub async fn poll_device_token( device_code: &str, initial_interval: i64, + region: KimiRegion, ) -> Result { let client = reqwest::Client::builder() .timeout(REFRESH_TIMEOUT) .build() .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + let token_endpoint = format!( + "{}{TOKEN_PATH}", + region.oauth_host().trim_end_matches('/') + ); let deadline = std::time::Instant::now() + POLL_TIMEOUT; let mut interval = initial_interval.max(1); loop { let mut req = client - .post(TOKEN_ENDPOINT) + .post(&token_endpoint) .form(&[ ("grant_type", DEVICE_GRANT_TYPE), ("client_id", CLIENT_ID), @@ -393,7 +586,7 @@ pub async fn poll_device_token( Ok(t) => t, Err(e) => return Err(format!("Failed to parse token response: {e}")), }; - persist_device_token(&token)?; + persist_device_token(&token, region)?; return Ok(DevicePollStatus::Success); } @@ -424,17 +617,89 @@ pub async fn poll_device_token( } } -/// Write a freshly obtained token set into the CLI credentials file, -/// preserving unrelated fields and using the same snake_case shape. -fn persist_device_token(token: &TokenResponse) -> Result<(), String> { - let path = credentials_path(); +/// Write a freshly obtained token set into the region's credentials file, +/// preserving unrelated fields and using the same snake_case shape, then +/// provision the managed provider in config.toml (mirroring the CLI). +/// +/// cn writes the shared `credentials/kimi-code.json`; global resolves the +/// scoped key from (oauthHost, baseUrl) and writes `credentials/.json`. +fn persist_device_token(token: &TokenResponse, region: KimiRegion) -> Result<(), String> { + let key = derive_scoped_key(region.oauth_host(), region.base_url()); + let storage_name = resolve_storage_name(&key)?; + let path = credentials_path_for_storage(&storage_name); if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } let current = std::fs::read_to_string(&path).unwrap_or_default(); let merged = merge_token_response(¤t, token); std::fs::write(&path, merged) - .map_err(|e| format!("Failed to write Kimi credentials: {e}")) + .map_err(|e| format!("Failed to write Kimi credentials: {e}"))?; + provision_managed_provider(region, &key) +} + +/// Update `[providers."managed:kimi-code"]` in config.toml so the official CLI +/// can use the freshly obtained credentials (mirror the CLI's post-login +/// provisioning / `authService.provisionProvider`): ensure the provider exists, +/// `type="kimi"`, `base_url=`, `api_key=""`, and the oauth ref +/// block `{storage="file", key=}` — `oauthHost` is persisted only +/// for global (cn writes none, so `key == "oauth/kimi-code"` stays the +/// explicit-mainland signal). Other fields (icon, ...) are left untouched; the +/// round-trip preserves every unrelated section. +fn provision_managed_provider(region: KimiRegion, oauth_key: &str) -> Result<(), String> { + use indexmap::IndexMap; + + let mut config = crate::kimi_code_io::load_kimi_code_config_as_config() + .map_err(|e| format!("Failed to read Kimi Code config: {e}"))?; + + let provider = config + .providers + .entry(MANAGED_PROVIDER_NAME.to_string()) + .or_insert_with(|| crate::models::Provider { + name: MANAGED_PROVIDER_NAME.to_string(), + provider_type: crate::models::ProviderType::Kimi, + base_url: None, + api_key: None, + env: IndexMap::new(), + note: None, + official_url: None, + managed: true, + enabled: true, + active: false, + icon: None, + icon_color: None, + raw_other: serde_json::Value::Object(serde_json::Map::new()), + usage_kinds: None, + usage_config: None, + }); + + provider.provider_type = crate::models::ProviderType::Kimi; + provider.base_url = Some(region.base_url().to_string()); + provider.api_key = Some(String::new()); + provider.managed = true; + + let mut oauth = serde_json::Map::new(); + oauth.insert( + "storage".to_string(), + serde_json::Value::String("file".to_string()), + ); + oauth.insert( + "key".to_string(), + serde_json::Value::String(oauth_key.to_string()), + ); + if region == KimiRegion::Global { + oauth.insert( + "oauthHost".to_string(), + serde_json::Value::String(region.oauth_host().to_string()), + ); + } + if let Some(obj) = provider.raw_other.as_object_mut() { + obj.insert("oauth".to_string(), serde_json::Value::Object(oauth)); + } else { + provider.raw_other = serde_json::json!({ "oauth": oauth }); + } + + crate::kimi_code_io::save_config_as_kimi_code(&config) + .map_err(|e| format!("Failed to write Kimi Code config: {e}")) } #[cfg(test)] @@ -569,4 +834,178 @@ mod tests { let success = serde_json::to_value(DevicePollStatus::Success).unwrap(); assert_eq!(success["status"], "success"); } + + // ── region / credential-key resolution ──────────────────────────────── + + #[test] + fn resolve_storage_name_maps_all_branches() { + // Default slot. + assert_eq!(resolve_storage_name("kimi-code").unwrap(), "kimi-code"); + assert_eq!(resolve_storage_name("oauth/kimi-code").unwrap(), "kimi-code"); + // oauth/ strips the prefix. + assert_eq!(resolve_storage_name("oauth/foo").unwrap(), "foo"); + // Bare name without '/' is kept verbatim. + assert_eq!(resolve_storage_name("custom").unwrap(), "custom"); + // Invalid keys → Err. + assert!(resolve_storage_name("oauth/").is_err(), "empty suffix"); + assert!(resolve_storage_name(".hidden").is_err(), "dot-prefixed"); + assert!(resolve_storage_name("a/b").is_err(), "contains slash"); + } + + #[test] + fn derive_scoped_key_returns_default_for_mainland() { + assert_eq!( + derive_scoped_key("https://auth.kimi.com", "https://api.kimi.com/coding/v1"), + "oauth/kimi-code" + ); + // Trailing slashes / whitespace normalize to the defaults. + assert_eq!( + derive_scoped_key(" https://auth.kimi.com/ ", "https://api.kimi.com/coding/v1/"), + "oauth/kimi-code" + ); + } + + #[test] + fn derive_scoped_key_scopes_by_endpoint_pair() { + let key = derive_scoped_key("https://auth.kimi.ai", "https://api.kimi.ai/coding/v1"); + assert!(key.starts_with("oauth/kimi-code-env-"), "key: {key}"); + let hex = &key["oauth/kimi-code-env-".len()..]; + assert_eq!(hex.len(), 16, "key: {key}"); + assert!(hex.chars().all(|c| c.is_ascii_hexdigit()), "key: {key}"); + // Byte-exact vs JS JSON.stringify({oauthHost, baseUrl}) — precomputed, + // guards against serde field order / whitespace drift. + assert_eq!(key, "oauth/kimi-code-env-0e4f99c69cc27850"); + } + + #[test] + fn oauth_context_defaults_without_ref() { + let ctx = OAuthContext::from(None, "https://api.kimi.com/coding/v1"); + assert_eq!(ctx.storage_name, "kimi-code"); + assert_eq!(ctx.oauth_host, "https://auth.kimi.com"); + assert_eq!(ctx.token_endpoint(), "https://auth.kimi.com/api/oauth/token"); + } + + #[test] + fn oauth_context_defaults_without_ref_even_for_custom_base() { + // Zero-regression: a ref-less provider must keep using the legacy + // mainland slot regardless of its base_url (derive only kicks in when + // an oauth ref carries an oauthHost). + let ctx = OAuthContext::from(None, "https://proxy.example.com/coding/v1"); + assert_eq!(ctx.storage_name, "kimi-code"); + assert_eq!(ctx.oauth_host, "https://auth.kimi.com"); + } + + #[test] + fn oauth_context_derives_scoped_key_when_ref_has_only_oauth_host() { + let r = OAuthRef { + key: None, + oauth_host: Some("https://auth.kimi.ai".to_string()), + }; + let ctx = OAuthContext::from(Some(&r), "https://api.kimi.ai/coding/v1"); + assert_eq!(ctx.storage_name, "kimi-code-env-0e4f99c69cc27850"); + assert_eq!(ctx.oauth_host, "https://auth.kimi.ai"); + } + + #[test] + fn oauth_context_follows_ref_key_and_host() { + let r = OAuthRef { + key: Some("oauth/kimi-code-env-0e4f99c69cc27850".to_string()), + oauth_host: Some("https://auth.kimi.ai".to_string()), + }; + let ctx = OAuthContext::from(Some(&r), "https://api.kimi.ai/coding/v1"); + assert_eq!(ctx.storage_name, "kimi-code-env-0e4f99c69cc27850"); + assert_eq!(ctx.oauth_host, "https://auth.kimi.ai"); + assert_eq!(ctx.token_endpoint(), "https://auth.kimi.ai/api/oauth/token"); + } + + // ── login persistence + provisioning (via KIMI_CODE_HOME temp dir) ───── + + /// Run `f` with `KIMI_CODE_HOME` pointed at a fresh temp dir (the config + /// dir). A process-wide mutex serializes env mutation so parallel tests in + /// this binary can't observe a stale override. + fn with_kimi_code_home(f: impl FnOnce(&std::path::Path) -> T) -> T { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + let _guard = LOCK.get_or_init(|| std::sync::Mutex::new(())).lock().unwrap(); + let home = tempfile::tempdir().unwrap(); + std::env::set_var("KIMI_CODE_HOME", home.path()); + let out = f(home.path()); + std::env::remove_var("KIMI_CODE_HOME"); + out + } + + #[test] + fn persist_cn_login_writes_default_slot_and_provisions_without_oauth_host() { + with_kimi_code_home(|dir| { + // Pre-existing config with an unrelated section must survive. + std::fs::write(dir.join("config.toml"), "[thinking]\nenabled = true\n").unwrap(); + + let token = TokenResponse { + access_token: "cn-access".to_string(), + refresh_token: Some("cn-refresh".to_string()), + expires_in: Some(900), + scope: Some("kimi-code".to_string()), + token_type: None, + }; + persist_device_token(&token, KimiRegion::Cn).unwrap(); + + // Credentials land in the shared default slot. + let cred = + std::fs::read_to_string(dir.join("credentials/kimi-code.json")).unwrap(); + assert!(cred.contains("cn-access"), "cred: {cred}"); + + // Provider provisioned with the cn base_url and NO oauthHost. + let cfg_toml = std::fs::read_to_string(dir.join("config.toml")).unwrap(); + let cfg: toml::Value = cfg_toml.parse().unwrap(); + let provider = &cfg["providers"]["managed:kimi-code"]; + assert_eq!(provider["type"].as_str(), Some("kimi")); + assert_eq!( + provider["base_url"].as_str(), + Some("https://api.kimi.com/coding/v1") + ); + assert_eq!(provider["api_key"].as_str(), Some("")); + let oauth = &provider["oauth"]; + assert_eq!(oauth["storage"].as_str(), Some("file")); + assert_eq!(oauth["key"].as_str(), Some("oauth/kimi-code")); + assert!( + oauth.get("oauthHost").is_none(), + "cn must not persist oauthHost" + ); + // Unrelated section preserved by the round-trip. + assert_eq!(cfg["thinking"]["enabled"].as_bool(), Some(true)); + }); + } + + #[test] + fn persist_global_login_writes_scoped_slot_and_provisions_oauth_host() { + with_kimi_code_home(|dir| { + std::fs::write(dir.join("config.toml"), "").unwrap(); + + let token = TokenResponse { + access_token: "global-access".to_string(), + refresh_token: Some("global-refresh".to_string()), + expires_in: Some(900), + scope: Some("kimi-code".to_string()), + token_type: None, + }; + persist_device_token(&token, KimiRegion::Global).unwrap(); + + let key = derive_scoped_key("https://auth.kimi.ai", "https://api.kimi.ai/coding/v1"); + assert_eq!(key, "oauth/kimi-code-env-0e4f99c69cc27850"); + let storage = resolve_storage_name(&key).unwrap(); + let cred = + std::fs::read_to_string(dir.join(format!("credentials/{storage}.json"))).unwrap(); + assert!(cred.contains("global-access"), "cred: {cred}"); + + let cfg_toml = std::fs::read_to_string(dir.join("config.toml")).unwrap(); + let cfg: toml::Value = cfg_toml.parse().unwrap(); + let provider = &cfg["providers"]["managed:kimi-code"]; + assert_eq!( + provider["base_url"].as_str(), + Some("https://api.kimi.ai/coding/v1") + ); + let oauth = &provider["oauth"]; + assert_eq!(oauth["key"].as_str(), Some(key.as_str())); + assert_eq!(oauth["oauthHost"].as_str(), Some("https://auth.kimi.ai")); + }); + } } diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 47f956b..e6f2c46 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -67,18 +67,30 @@ fn parse_f64(value: &serde_json::Value) -> Option { } // ── Kimi For Coding ───────────────────────────────────────── -// GET https://api.kimi.com/coding/v1/usages +// GET {base_url}/usages +// 默认 https://api.kimi.com/coding/v1/usages +// global: https://api.kimi.ai/coding/v1/usages // Response: { limits: [{ detail: { limit, remaining, resetTime } }], // usage: { limit, remaining, resetTime } } -pub async fn query_kimi_coding(api_key: &str, timeout: Duration) -> Result { - match get_json( - "https://api.kimi.com/coding/v1/usages", - api_key, - AuthStyle::Bearer, - timeout, - ) - .await? +/// 由 base_url 拼接 usages 查询 URL;base_url 为空/空白时回退大陆默认。 +/// 纯函数,便于单测。 +fn kimi_coding_usages_url(base_url: &str) -> String { + let base = if base_url.trim().is_empty() { + "https://api.kimi.com/coding/v1" + } else { + base_url.trim_end_matches('/') + }; + format!("{base}/usages") +} + +pub async fn query_kimi_coding( + base_url: &str, + api_key: &str, + timeout: Duration, +) -> Result { + let url = kimi_coding_usages_url(base_url); + match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? { Fetched::Body(body) => { let tiers = parse_kimi_coding(&body); @@ -396,6 +408,34 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn kimi_coding_usages_url_uses_base_and_falls_back() { + // global base → .ai usages URL + assert_eq!( + kimi_coding_usages_url("https://api.kimi.ai/coding/v1"), + "https://api.kimi.ai/coding/v1/usages" + ); + // 尾斜杠去掉 + assert_eq!( + kimi_coding_usages_url("https://api.kimi.ai/coding/v1/"), + "https://api.kimi.ai/coding/v1/usages" + ); + // 空串/空白回退大陆默认 + assert_eq!( + kimi_coding_usages_url(""), + "https://api.kimi.com/coding/v1/usages" + ); + assert_eq!( + kimi_coding_usages_url(" "), + "https://api.kimi.com/coding/v1/usages" + ); + // 大陆 base 原样拼接 + assert_eq!( + kimi_coding_usages_url("https://api.kimi.com/coding/v1"), + "https://api.kimi.com/coding/v1/usages" + ); + } + #[test] fn kimi_coding_flattens_limits_and_usage() { let body = json!({ diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 1fdb46d..f149ff8 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -108,7 +108,9 @@ pub fn detect_provider(base_url: &str) -> Vec { if url.contains("api.moonshot.cn") || url.contains("api.moonshot.ai") { kinds.push(UsageKind::BalanceKimi); } - if url.contains("api.kimi.com") && url.contains("/coding") { + if (url.contains("api.kimi.com") || url.contains("api.kimi.ai")) + && url.contains("/coding") + { kinds.push(UsageKind::PlanKimiCoding); } if url.contains("open.bigmodel.cn") || url.contains("api.z.ai") { @@ -169,7 +171,9 @@ pub async fn query_kind( .unwrap_or(base_url); balance::query_newapi(url, token, uid, timeout).await } - UsageKind::PlanKimiCoding => coding_plan::query_kimi_coding(api_key, timeout).await, + UsageKind::PlanKimiCoding => { + coding_plan::query_kimi_coding(base_url, api_key, timeout).await + } UsageKind::PlanZhipu => coding_plan::query_zhipu(base_url, api_key, timeout).await, UsageKind::PlanMinimax => { coding_plan::query_minimax(api_key, !lower.contains("minimax.io"), timeout).await @@ -222,6 +226,26 @@ mod tests { assert!(detect_provider("https://api.kimi.com/v1").is_empty()); } + #[test] + fn detect_provider_kimi_coding_matches_global_ai_host() { + // global 站 api.kimi.ai + /coding 命中套餐 + assert_eq!( + detect_provider("https://api.kimi.ai/coding/v1"), + vec![UsageKind::PlanKimiCoding] + ); + assert_eq!( + detect_provider("https://api.kimi.ai/coding/v1/usages"), + vec![UsageKind::PlanKimiCoding] + ); + // api.kimi.ai 但无 /coding 路径 → 不命中(也不命中 Moonshot 余额) + assert!(detect_provider("https://api.kimi.ai/v1").is_empty()); + // .com 老路径不受影响 + assert_eq!( + detect_provider("https://api.kimi.com/coding/v1"), + vec![UsageKind::PlanKimiCoding] + ); + } + #[test] fn detect_provider_opencode_go_excludes_payg_zen() { // /zen/go 命中套餐查询 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1b1ab18..767ad3d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.7.6", + "version": "0.7.7", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index efdb818..823b112 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -706,18 +706,44 @@ export default function App() { onModelChange={(model) => { updateConfig((cfg) => { const models = { ...cfg.models }; + // Auto-fix the placeholder alias from onModelAdd: once the + // real model id is filled in, rename "/新模型" (and + // the mid-typing "/" states) to + // "/" so the CLI records usage under the + // real model name instead of the placeholder. + let effective = model; + const safeProvider = model.provider.replace(/\//g, "-"); + const prefix = `${safeProvider}/`; + const modelId = model.model.trim(); + const tail = model.alias.startsWith(prefix) + ? model.alias.slice(prefix.length) + : ""; + const targetAlias = `${prefix}${modelId}`; + if ( + modelId !== "" && + model.alias !== targetAlias && + (tail === "新模型" || + tail.startsWith("新模型-") || + (tail !== "" && modelId.startsWith(tail))) && + !(targetAlias in models) + ) { + effective = { ...model, alias: targetAlias }; + delete models[model.alias]; + } const existingKeys = Object.keys(models).filter( (k) => models[k].provider === currentProvider.name ); const oldKey = existingKeys.find((k) => - model.alias === k ? true : models[k].alias === model.alias + effective.alias === k ? true : models[k].alias === effective.alias ); - if (oldKey && oldKey !== model.alias) { + if (oldKey && oldKey !== effective.alias) { delete models[oldKey]; } - models[model.alias] = model; + models[effective.alias] = effective; let default_model = cfg.default_model; - if (default_model === oldKey) default_model = model.alias; + if (default_model === oldKey) default_model = effective.alias; + else if (effective !== model && default_model === model.alias) + default_model = effective.alias; return { ...cfg, models, default_model }; }); }} @@ -735,22 +761,31 @@ export default function App() { }} onModelAdd={() => { const safeProvider = currentProvider.name.replace(/\//g, "-"); - const alias = `${safeProvider}/新模型`; - updateConfig((cfg) => ({ - ...cfg, - models: { - ...cfg.models, - [alias]: { - alias, - provider: currentProvider.name, - model: "", - max_context_size: getDefaultMaxContextSize(alias), - display_name: null, - supports_1m: false, - capabilities: agent === "kimi_code" ? ["thinking"] : [], + updateConfig((cfg) => { + // Deduplicate the placeholder key: a second "add model" click + // while the previous placeholder is still unedited must not + // silently overwrite it. + let alias = `${safeProvider}/新模型`; + let n = 1; + while (alias in cfg.models) { + alias = `${safeProvider}/新模型-${++n}`; + } + return { + ...cfg, + models: { + ...cfg.models, + [alias]: { + alias, + provider: currentProvider.name, + model: "", + max_context_size: getDefaultMaxContextSize(alias), + display_name: null, + supports_1m: false, + capabilities: agent === "kimi_code" ? ["thinking"] : [], + }, }, - }, - })); + }; + }); }} onBulkAdd={(models) => { updateConfig((cfg) => { diff --git a/src/components/KimiOAuthDialog.tsx b/src/components/KimiOAuthDialog.tsx index 6351349..f00461d 100644 --- a/src/components/KimiOAuthDialog.tsx +++ b/src/components/KimiOAuthDialog.tsx @@ -25,6 +25,8 @@ interface KimiOAuthDialogProps { onClose: () => void; } +type OAuthRegion = "cn" | "global"; + /** Kimi device-code sign-in dialog (in-app `kimi login`). */ export function KimiOAuthDialog({ open, onClose }: KimiOAuthDialogProps) { const { t } = useTranslation(); @@ -33,10 +35,12 @@ export function KimiOAuthDialog({ open, onClose }: KimiOAuthDialogProps) { const [done, setDone] = useState(false); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); + const [region, setRegion] = useState("cn"); + const [started, setStarted] = useState(false); const activeRef = useRef(false); const intervalRef = useRef(5); - // Start a fresh device authorization when the dialog opens. + // Reset to the "pick a region" screen when the dialog (re)opens. useEffect(() => { if (!open) return; activeRef.current = true; @@ -45,8 +49,26 @@ export function KimiOAuthDialog({ open, onClose }: KimiOAuthDialogProps) { setDone(false); setError(null); setCopied(false); + setRegion("cn"); + setStarted(false); intervalRef.current = 5; - invoke("kimi_oauth_start") + return () => { + activeRef.current = false; + }; + }, [open]); + + // Begin the device flow for a region. Locks the picker; a later restart is + // the way to switch region. + const start = (r: OAuthRegion) => { + setRegion(r); + setStarted(true); + setAuth(null); + setPolling(false); + setDone(false); + setError(null); + setCopied(false); + intervalRef.current = 5; + invoke("kimi_oauth_start", { region: r }) .then((a) => { if (!activeRef.current) return; setAuth(a); @@ -56,11 +78,10 @@ export function KimiOAuthDialog({ open, onClose }: KimiOAuthDialogProps) { .catch((e) => { if (!activeRef.current) return; setError(e instanceof Error ? e.message : String(e)); + // Allow picking the region again after a failed launch. + setStarted(false); }); - return () => { - activeRef.current = false; - }; - }, [open]); + }; // Poll the token endpoint while the user authorizes in the browser. useEffect(() => { @@ -73,6 +94,7 @@ export function KimiOAuthDialog({ open, onClose }: KimiOAuthDialogProps) { const res = await invoke("kimi_oauth_poll", { deviceCode: auth.device_code, interval: intervalRef.current, + region, }); if (cancelled) return; switch (res.status) { @@ -119,22 +141,8 @@ export function KimiOAuthDialog({ open, onClose }: KimiOAuthDialogProps) { }, [open, polling, auth]); const restart = () => { - setAuth(null); - setPolling(false); - setDone(false); - setError(null); - intervalRef.current = 5; - invoke("kimi_oauth_start") - .then((a) => { - if (!activeRef.current) return; - setAuth(a); - if (a.interval && a.interval > 0) intervalRef.current = a.interval; - setPolling(true); - }) - .catch((e) => { - if (!activeRef.current) return; - setError(e instanceof Error ? e.message : String(e)); - }); + // Restart always resets to the default region (mainland China). + start("cn"); }; const copyCode = () => { @@ -214,13 +222,56 @@ export function KimiOAuthDialog({ open, onClose }: KimiOAuthDialogProps) {

{t("kimiOAuthWaiting")}

+ +

+ {region === "global" + ? t("kimiOAuthRegionGlobal") + : t("kimiOAuthRegionCn")} +

)} - {!auth && !done && ( + {started && !auth && !done && (
)} + {!started && !done && ( +
+

{t("kimiOAuthRegionLabel")}

+
+ + +
+ +
+ )} +
{error && !done && (