From 626529451b51456d0717febffe88693a66311b23 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 24 Oct 2026 12:06:29 +0800 Subject: [PATCH] feat(kimi): edit official account models and remove them from Kimi Official Kimi accounts added in the Authorization Center were written straight into config.toml with the model list discovered at sign-in. Kimi Code only refreshes its own managed:kimi-code node, so separate accounts kept that snapshot forever, and their cards could neither be edited nor removed. Official cards can now be edited. The editor hides the connection and fetches the account's own catalog through a new kimi_oauth_models command (token refreshed under Kimi's lock, aliases keyed by the credential slot), so picked models carry the official context window, capabilities and efforts. apply_provider pins an official node's connection to the signed-in one, and ProviderService::update rejects edits that would turn another card into an official account. Official cards can also be removed from config.toml like other Kimi providers: the card and credential stay, services pointing at a separate account move to Kimi's own login, and "Add" restores it. Deleting one still requires removing it first or using the Authorization Center. --- src-tauri/src/commands/kimi_oauth.rs | 9 + src-tauri/src/kimi_config.rs | 29 +++- src-tauri/src/lib.rs | 1 + src-tauri/src/services/kimi_oauth/mod.rs | 80 +++++++++ src-tauri/src/services/provider/mod.rs | 44 ++++- src-tauri/tests/kimi_commands.rs | 65 +++++++- .../providers/forms/KimiProviderForm.tsx | 157 ++++++++++++------ src/components/providers/presentation.ts | 17 +- src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/tr.json | 2 +- src/i18n/locales/zh-TW.json | 3 +- src/i18n/locales/zh.json | 3 +- src/lib/api/kimiOAuth.ts | 4 + tests/components/KimiProviderForm.test.tsx | 77 +++++++-- tests/components/providerPresentation.test.ts | 14 +- 16 files changed, 404 insertions(+), 107 deletions(-) diff --git a/src-tauri/src/commands/kimi_oauth.rs b/src-tauri/src/commands/kimi_oauth.rs index ced9d2f7f..404f5249f 100644 --- a/src-tauri/src/commands/kimi_oauth.rs +++ b/src-tauri/src/commands/kimi_oauth.rs @@ -61,6 +61,15 @@ pub async fn kimi_oauth_quota( state.quota(&account_id).await } +/// The account's current official model catalog, for editing its card. +#[tauri::command(rename_all = "camelCase")] +pub async fn kimi_oauth_models( + account_id: String, + state: State<'_, KimiOAuthManager>, +) -> Result, String> { + state.models(&account_id).await +} + #[tauri::command(rename_all = "camelCase")] pub async fn kimi_oauth_use_account( account_id: String, diff --git a/src-tauri/src/kimi_config.rs b/src-tauri/src/kimi_config.rs index 82b0953eb..870d13397 100644 --- a/src-tauri/src/kimi_config.rs +++ b/src-tauri/src/kimi_config.rs @@ -312,20 +312,31 @@ fn apply_provider( config: Option, create: bool, ) -> Result { - if before + // An official node is created by signing in. Edits may change its models, + // but the connection (endpoint and login reference) stays as signed in. + // Removing it keeps the credential, so the card can be added back. + let official = before .get("providers") .and_then(|providers| providers.get(id)) - .is_some_and(|provider| is_official_provider(&json!({"provider": provider}))) - { - return Err(AppError::InvalidInput( - "Manage Kimi official accounts in the Authorization Center".into(), - )); + .filter(|provider| is_official_provider(&json!({"provider": provider}))) + .cloned(); + let config = match (&official, config) { + (Some(node), Some(mut config)) => { + config["provider"] = node.clone(); + Some(config) + } + (_, config) => config, + }; + let removing = config.is_none(); + let mut after = apply_provider_unchecked(before, id, config, create)?; + if official.is_some() && removing { + repoint_services(before, &mut after, id); } - apply_provider_unchecked(before, id, config, create) + Ok(after) } -/// [`apply_provider`] without the official-account guard; only the account -/// manager may remove an official node this way. +/// [`apply_provider`] without pinning an official node's connection; only the +/// account manager writes official nodes this way. fn apply_provider_unchecked( before: &Value, id: &str, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f01d7ec99..9645507f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1883,6 +1883,7 @@ pub fn run() { commands::kimi_oauth_remove, commands::kimi_oauth_quota, commands::kimi_oauth_use_account, + commands::kimi_oauth_models, // 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/mod.rs b/src-tauri/src/services/kimi_oauth/mod.rs index 77a194983..953315538 100644 --- a/src-tauri/src/services/kimi_oauth/mod.rs +++ b/src-tauri/src/services/kimi_oauth/mod.rs @@ -657,6 +657,12 @@ impl KimiOAuthManager { account::quota(&home, &self.store(), account_id, &device).await } + /// The account's current model catalog, keyed and shaped as signing in + /// would write it, for the card editor. Nothing is saved here. + pub async fn models(&self, account_id: &str) -> Result, String> { + models_at(&kimi_config::data_dir(), &self.store(), account_id).await + } + pub async fn start( &self, region: KimiRegion, @@ -956,6 +962,51 @@ impl KimiOAuthManager { } } +async fn models_at( + home: &Path, + store: &Path, + account_id: &str, +) -> Result, String> { + let device = device_id(home)?; + let config = read_config(home)?; + let provider = account_provider(&config, account_id)?; + let base_url = provider["base_url"] + .as_str() + .ok_or("Kimi account has no base URL")?; + let token = match account::access_token(home, provider, &device).await? { + account::Access::Token(token) => token, + account::Access::Expired => { + return Err("Use this account once in Kimi Code, then try again".into()) + } + account::Access::NeedsReauth => { + return Err("Sign in to this Kimi account again in the Authorization Center".into()) + } + }; + let response = identify( + crate::proxy::http_client::get().get(format!("{base_url}/models")), + &device, + ) + .bearer_auth(token) + .send() + .await + .map_err(|e| network_error("Kimi model discovery failed", base_url, e))?; + let (status, payload) = response_json(response).await?; + if status != 200 { + return Err(format!("Kimi model discovery failed (HTTP {status})")); + } + let slot = account::slot_name(&credential_path(home, &provider["oauth"])?); + let nickname = account::Store::load(store) + .accounts + .get(account_id) + .and_then(|identity| identity.nickname.clone()); + Ok(scope_models( + parse_models(&payload)?, + account_id, + &slot, + nickname.as_deref(), + )) +} + async fn remove_at( home: &Path, store: &Path, @@ -1628,6 +1679,35 @@ mod tests { read_config(dir).unwrap()["providers"]["kimi-code-ccs-test"].clone() } + #[tokio::test] + async fn model_catalog_for_the_editor_uses_the_accounts_slot_and_saves_nothing() { + let server = server(0).await; + let dir = tempfile::tempdir().unwrap(); + let store = dir.path().join("accounts.json"); + let valid = json!({"access_token":"a","refresh_token":"r","expires_at":4_102_444_800i64}); + separate_account(&server, dir.path(), valid); + account::remember( + &store, + "kimi-code-ccs-test", + account::Identity { + user_id: "u".into(), + nickname: Some("Nick".into()), + ..Default::default() + }, + ) + .unwrap(); + let before = fs::read(dir.path().join("config.toml")).unwrap(); + let catalog = models_at(dir.path(), &store, "kimi-code-ccs-test") + .await + .unwrap(); + let model = &catalog["kimi-code-ccs-test/k3"]; + assert_eq!(model["provider"], "kimi-code-ccs-test"); + assert_eq!(model["display_name"], "k3 (Nick)"); + assert_eq!(model["max_context_size"], 1048576); + assert_eq!(fs::read(dir.path().join("config.toml")).unwrap(), before); + assert!(models_at(dir.path(), &store, "custom").await.is_err()); + } + #[cfg(unix)] #[tokio::test] async fn quota_refreshes_an_expired_token_under_kimis_lock() { diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 7b64d62a3..720b0e87a 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -6189,12 +6189,25 @@ impl ProviderService { original_id: Option<&str>, provider: Provider, ) -> Result { + let mut provider = provider; if app_type == AppType::Kimi { - Self::ensure_kimi_provider_editable(original_id.unwrap_or(&provider.id))?; - if crate::kimi_config::is_official_provider(&provider.settings_config) { - return Err(AppError::InvalidInput( - "Manage Kimi official accounts in the Authorization Center".into(), - )); + // Official cards come from signing in. Their models, name and notes are + // editable; the connection stays as signed in, and no edit may turn + // another card into an official one. + let id = original_id.unwrap_or(&provider.id).to_string(); + match Self::kimi_official_connection(state, &id)? { + Some(_) if provider.id != id => { + return Err(AppError::InvalidInput( + "Kimi official account keys cannot be changed".into(), + )); + } + Some(connection) => provider.settings_config["provider"] = connection, + None if crate::kimi_config::is_official_provider(&provider.settings_config) => { + return Err(AppError::InvalidInput( + "Manage Kimi official accounts in the Authorization Center".into(), + )); + } + None => {} } } if app_type == AppType::Pi { @@ -6205,7 +6218,6 @@ impl ProviderService { return omp::update(state, original_id, provider); } - let mut provider = provider; let original_id = original_id.unwrap_or(provider.id.as_str()).to_string(); let provider_id_changed = original_id != provider.id; // 读旧行、判断谁是当前供应商、存行、写 live 都在切换锁里:进入代理和切换都等这次 @@ -6624,7 +6636,8 @@ impl ProviderService { return pi::remove(state, id); } if app_type == AppType::Kimi { - Self::ensure_kimi_provider_editable(id)?; + // An official account leaves Kimi's config but keeps its login and card, + // so「添加」can put it back. return crate::kimi_config::save_and_commit(id, None, false, || { if let Some(mut provider) = state.db.get_provider_by_id(id, "kimi")? { Self::set_provider_live_config_managed(&mut provider, false); @@ -6691,6 +6704,23 @@ impl ProviderService { Ok(()) } + /// The signed-in connection node of a Kimi official card: Kimi's live node + /// first, else the saved card (an official card removed from Kimi's config). + fn kimi_official_connection(state: &AppState, id: &str) -> Result, AppError> { + if let Some(live) = crate::kimi_config::get_providers()?.get(id) { + return Ok( + crate::kimi_config::is_official_provider(live).then(|| live["provider"].clone()) + ); + } + Ok(state + .db + .get_provider_by_id(id, "kimi")? + .filter(|saved| crate::kimi_config::is_official_provider(&saved.settings_config)) + .map(|saved| saved.settings_config["provider"].clone())) + } + + /// Deleting an official account that is still in Kimi's config goes through + /// the Authorization Center (it also deletes the login); remove it first. fn ensure_kimi_provider_editable(id: &str) -> Result<(), AppError> { let native = crate::kimi_config::get_providers()?; if native diff --git a/src-tauri/tests/kimi_commands.rs b/src-tauri/tests/kimi_commands.rs index 4d913a218..9ab1b7a55 100644 --- a/src-tauri/tests/kimi_commands.rs +++ b/src-tauri/tests/kimi_commands.rs @@ -36,6 +36,11 @@ impl Drop for KimiHome { } } +fn live_config(home: &KimiHome) -> serde_json::Value { + let text = fs::read_to_string(home.config()).unwrap(); + serde_json::to_value(toml::from_str::(&text).unwrap()).unwrap() +} + fn provider(id: &str, model: &str) -> Provider { Provider::with_id( id.into(), @@ -46,30 +51,78 @@ fn provider(id: &str, model: &str) -> Provider { } #[test] -fn kimi_official_accounts_are_protected_only_while_present_in_native_config() { +fn kimi_official_accounts_edit_models_leave_kimi_and_are_deleted_only_after_leaving() { let _guard = test_mutex().lock().unwrap(); reset_test_fs(); let home = KimiHome::new(); let state = create_test_state().unwrap(); let mut config = json!({"default_model":"custom/model", "providers":{"custom":{"type":"openai","api_key":"test"}},"models":{"custom/model":{"provider":"custom","model":"test","max_context_size":32000}}}); + let credentials = home.directory.path().join("credentials"); + fs::create_dir_all(&credentials).unwrap(); for id in ["managed:kimi-code", "kimi-code-ccs-second"] { - config["providers"][id] = json!({"type":"kimi","base_url":"https://api.kimi.com/coding/v1","oauth":{"storage":"file","key":format!("oauth/{}", id.replace(':', "-"))}}); + let slot = id.replace(':', "-"); + config["providers"][id] = json!({"type":"kimi","base_url":"https://api.kimi.com/coding/v1","oauth":{"storage":"file","key":format!("oauth/{slot}")}}); config["models"][format!("{id}/k3")] = json!({"provider":id,"model":"k3","max_context_size":1048576}); + fs::write( + credentials.join(format!("{slot}.json")), + r#"{"access_token":"a","refresh_token":"r","expires_at":4102444800}"#, + ) + .unwrap(); } + config["services"]["moonshot_search"] = json!({"base_url":"https://api.kimi.com/coding/v1/search","api_key":"","oauth":{"storage":"file","key":"oauth/kimi-code-ccs-second"}}); fs::write(home.config(), toml::to_string(&config).unwrap()).unwrap(); let entries = ProviderService::list(&state, AppType::Kimi).unwrap(); for id in ["managed:kimi-code", "kimi-code-ccs-second"] { - let original = fs::read(home.config()).unwrap(); assert_eq!(entries[id].category.as_deref(), Some("official")); assert!(entries[id].name.starts_with("Kimi Official")); + // Still in Kimi: deleting goes through the Authorization Center. + let original = fs::read(home.config()).unwrap(); assert!(ProviderService::delete(&state, AppType::Kimi, id).is_err()); - assert!(ProviderService::remove_from_live_config(&state, AppType::Kimi, id).is_err()); + assert_eq!(fs::read(home.config()).unwrap(), original); + + // Models are editable; the signed-in connection is not. let mut edited = entries[id].clone(); edited.settings_config["provider"] = json!({"type":"openai","api_key":"replacement"}); - assert!(ProviderService::update(&state, AppType::Kimi, None, edited).is_err()); - assert_eq!(fs::read(home.config()).unwrap(), original); + edited.settings_config["models"] = + json!({format!("{id}/k3.1"):{"provider":id,"model":"k3.1","max_context_size":1048576}}); + ProviderService::update(&state, AppType::Kimi, None, edited).unwrap(); + let live = live_config(&home); + assert_eq!(live["providers"][id], config["providers"][id]); + assert!(live["models"].get(format!("{id}/k3")).is_none()); + assert_eq!(live["models"][format!("{id}/k3.1")]["model"], "k3.1"); } + + // No edit turns another card into an official account. + let mut forged = entries["custom"].clone(); + forged.settings_config["provider"] = config["providers"]["managed:kimi-code"].clone(); + assert!(ProviderService::update(&state, AppType::Kimi, None, forged).is_err()); + + // Removing keeps the card and login; services move to Kimi's own login. + ProviderService::remove_from_live_config(&state, AppType::Kimi, "kimi-code-ccs-second") + .unwrap(); + let live = live_config(&home); + assert!(live["providers"].get("kimi-code-ccs-second").is_none()); + assert!(live["models"].get("kimi-code-ccs-second/k3.1").is_none()); + assert_eq!( + live["services"]["moonshot_search"]["oauth"]["key"], + "oauth/managed-kimi-code" + ); + assert!(credentials.join("kimi-code-ccs-second.json").exists()); + let saved = state + .db + .get_provider_by_id("kimi-code-ccs-second", "kimi") + .unwrap() + .unwrap(); + assert_eq!(saved.meta.unwrap().live_config_managed, Some(false)); + ProviderService::add_to_live_config(&state, AppType::Kimi, "kimi-code-ccs-second").unwrap(); + let live = live_config(&home); + assert_eq!( + live["providers"]["kimi-code-ccs-second"], + config["providers"]["kimi-code-ccs-second"] + ); + assert_eq!(live["models"]["kimi-code-ccs-second/k3.1"]["model"], "k3.1"); + fs::write(home.config(), "").unwrap(); let entries = ProviderService::list(&state, AppType::Kimi).unwrap(); for id in ["managed:kimi-code", "kimi-code-ccs-second"] { diff --git a/src/components/providers/forms/KimiProviderForm.tsx b/src/components/providers/forms/KimiProviderForm.tsx index 381f7349a..95c653558 100644 --- a/src/components/providers/forms/KimiProviderForm.tsx +++ b/src/components/providers/forms/KimiProviderForm.tsx @@ -25,6 +25,7 @@ import { showFetchModelsError, type FetchedModel, } from "@/lib/api/model-fetch"; +import { kimiOAuthApi } from "@/lib/api/kimiOAuth"; import { toast } from "@/lib/toast"; import { cn } from "@/lib/utils"; import { useProvidersQuery } from "@/lib/query/queries"; @@ -207,7 +208,14 @@ export function KimiProviderForm({ if (!next.delete(key)) next.add(key); return next; }); - const modelKeyPrefix = providerKey || "custom"; + // 官方账号卡:连接和登录归授权中心,只编辑模型;模型键沿用登录时的凭据槽位前缀 + // (分开账号的供应商键是可读名,模型键得保持 ASCII 槽位名)。 + const officialSlot = + isEdit && config.provider.type === "kimi" + ? config.provider.oauth?.key?.replace(/^oauth\//, "") + : undefined; + const isOfficial = Boolean(officialSlot); + const modelKeyPrefix = officialSlot || providerKey || "custom"; const uniqueModelKey = (models: KimiProviderConfig["models"], id: string) => { let key = `${modelKeyPrefix}/${id}`; for (let index = 2; Object.prototype.hasOwnProperty.call(models, key); ) @@ -217,6 +225,8 @@ export function KimiProviderForm({ const [fetchedModels, setFetchedModels] = useState([]); const [isFetchingModels, setIsFetchingModels] = useState(false); + // 官方账号拉到的完整目录:选中时直接用官方给的窗口、能力和思考档位 + const officialCatalog = useRef({}); const modelFetchGeneration = useRef(0); const { type: providerType, base_url: baseUrl = "" } = config.provider; const apiKey = config.provider.api_key ?? ""; @@ -231,6 +241,34 @@ export function KimiProviderForm({ }, [providerType, baseUrl, apiKey, headersKey]); const handleFetchModels = useCallback(() => { + if (isOfficial && providerId) { + const generation = ++modelFetchGeneration.current; + setFetchedModels([]); + setIsFetchingModels(true); + kimiOAuthApi + .models(providerId) + .then((catalog) => { + if (generation !== modelFetchGeneration.current) return; + officialCatalog.current = catalog; + setFetchedModels( + Object.values(catalog).map((model) => ({ + id: model.model, + ownedBy: null, + })), + ); + }) + .catch((err) => { + if (generation !== modelFetchGeneration.current) return; + console.warn("[KimiOAuth] Failed to fetch models:", err); + showFetchModelsError(err, t); + }) + .finally(() => { + if (generation === modelFetchGeneration.current) { + setIsFetchingModels(false); + } + }); + return; + } const endpoint = baseUrl.trim(); const requestHeaders = normalizeRequestHeaders( JSON.parse(headersKey) as Record, @@ -274,7 +312,7 @@ export function KimiProviderForm({ setIsFetchingModels(false); } }); - }, [apiKey, baseUrl, headersKey, providerType, t]); + }, [apiKey, baseUrl, headersKey, isOfficial, providerId, providerType, t]); const fillModelMetadata = useModelMetadataFill({ baseUrl, @@ -311,6 +349,17 @@ export function KimiProviderForm({ const added: [string, string][] = []; for (const id of add) { if (configured.has(id)) continue; + const official = Object.entries(officialCatalog.current).find( + ([, model]) => model.model === id, + ); + if (isOfficial && official) { + const [catalogKey, model] = official; + const key = Object.prototype.hasOwnProperty.call(models, catalogKey) + ? uniqueModelKey(models, id) + : catalogKey; + models[key] = structuredClone(model); + continue; + } const key = uniqueModelKey(models, id); // Kimi 自己按目录导入模型时也默认支持工具调用。 models[key] = { @@ -509,54 +558,62 @@ export function KimiProviderForm({ } /> -
- - {t("opencode.npmPackage")} - - -
- updateProvider({ api_key })} - category={category} - shouldShowLink={Boolean(preset?.apiKeyUrl)} - websiteUrl={preset?.apiKeyUrl ?? ""} - isPartner={preset?.isPartner} - partnerPromotionKey={preset?.partnerPromotionKey} - required={!config.provider.oauth} - /> - updateProvider({ base_url })} - placeholder="https://api.example.com/v1" - /> - - updateProvider({ custom_headers }) - } - /> + {isOfficial ? ( +

+ {t("kimi.oauth.connectionManaged")} +

+ ) : ( + <> +
+ + {t("opencode.npmPackage")} + + +
+ updateProvider({ api_key })} + category={category} + shouldShowLink={Boolean(preset?.apiKeyUrl)} + websiteUrl={preset?.apiKeyUrl ?? ""} + isPartner={preset?.isPartner} + partnerPromotionKey={preset?.partnerPromotionKey} + required={!config.provider.oauth} + /> + updateProvider({ base_url })} + placeholder="https://api.example.com/v1" + /> + + updateProvider({ custom_headers }) + } + /> + + )}
diff --git a/src/components/providers/presentation.ts b/src/components/providers/presentation.ts index 58ff010ab..fde3ae2d1 100644 --- a/src/components/providers/presentation.ts +++ b/src/components/providers/presentation.ts @@ -722,8 +722,10 @@ export function buildAdditiveSections(input: AdditiveInput): ProviderSection[] { if (app === "kimi") { const official = isOfficialAccount(app, p); const live = input.isInConfig(p); - const officialReason = official ? t("kimi.oauth.managedHint") : undefined; - const protectedReason = live ? officialReason : undefined; + // 官方卡可以编辑模型、可以移出 config.toml(登录留着,能再添加);仍在 Kimi 里的 + // 官方卡不能直接删,删号在授权中心(连登录一起删) + const deleteProtectedReason = + official && live ? t("kimi.oauth.managedHint") : undefined; if (official) { chips.push({ key: "official", @@ -753,11 +755,9 @@ export function buildAdditiveSections(input: AdditiveInput): ProviderSection[] { buttons.push({ key: "remove", label: t("providerCard.action.remove"), - disabledReason: - protectedReason ?? - (current - ? t("providerCard.reason.currentCannotRemove") - : undefined), + disabledReason: current + ? t("providerCard.reason.currentCannotRemove") + : undefined, onClick: () => actions.remove(p), }); } @@ -771,9 +771,8 @@ export function buildAdditiveSections(input: AdditiveInput): ProviderSection[] { chips, buttons, menuItems: menuItems.length ? menuItems : undefined, - editDisabledReason: officialReason, deleteDisabledReason: - protectedReason ?? + deleteProtectedReason ?? (current ? t("providerCard.reason.inUseCannotDelete") : undefined), }, }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1bb5fe1a7..39dfaa3c0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -4946,7 +4946,8 @@ "primaryHidden": "Kimi Code's own login was removed from the list; sign in to the same account to bring it back", "removeCurrentHint": "This account is in use. Switch to another account before removing it.", "manageAccounts": "Manage Kimi accounts", - "managedHint": "Manage official Kimi accounts in the Authorization Center.", + "managedHint": "Remove this official account first, or delete it (with its login) in the Authorization Center.", + "connectionManaged": "The connection and login are managed in the Authorization Center; edit only the models here. Fetching models reads the official models currently available to this account.", "current": "In use by Kimi", "primaryShort": "Kimi Code's own login", "empty": "No Kimi accounts yet. The account signed in to Kimi Code shows up here automatically.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 833a2653e..8c26fca85 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -4946,7 +4946,8 @@ "primaryHidden": "Kimi Code 自身のログインは一覧から外されています。同じアカウントで再ログインすると戻ります", "removeCurrentHint": "このアカウントは使用中です。別のアカウントに切り替えてから削除してください", "manageAccounts": "Kimi アカウントを管理", - "managedHint": "Kimi 公式アカウントは認証センターで管理してください。", + "managedHint": "先にこの公式アカウントを「削除」で Kimi の設定から外すか、認証センターでログインごと削除してください。", + "connectionManaged": "接続とログインは認証センターで管理されます。ここではモデルのみ編集できます。モデルを取得すると、このアカウントで現在使える公式モデルを読み込みます。", "current": "Kimi で使用中", "primaryShort": "Kimi Code 自身のログイン", "empty": "Kimi アカウントはまだありません。Kimi Code でログイン中のアカウントは自動的にここに表示されます。", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index e9b97bf4c..4194abcfc 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -4809,7 +4809,7 @@ "cancelled": "Bu giriş iptal edildi veya değiştirildi. Yeniden giriş yapın.", "success": "Kimi Code girişi yapıldı ve resmî modeller eklendi.", "manageAccounts": "Kimi hesaplarını yönet", - "managedHint": "Resmî Kimi hesaplarını Yetkilendirme Merkezi'nde yönetin." + "managedHint": "Önce bu resmî hesabı kaldırın veya Yetkilendirme Merkezi'nde oturumuyla birlikte silin." }, "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.", "contextSize": "Bağlam uzunluğu", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 1dfc6e250..d82e6adce 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -4947,7 +4947,8 @@ "primaryHidden": "Kimi Code 自己的登入已移出列表,重新登入同一帳號即可找回", "removeCurrentHint": "這個帳號正在使用,請先切換到其他帳號再刪除", "manageAccounts": "管理 Kimi 帳號", - "managedHint": "請在授權中心管理 Kimi 官方帳號。", + "managedHint": "先移除這個官方帳號,或到授權中心刪除它(連同登入一起刪)。", + "connectionManaged": "連線和登入由授權中心管理,這裡只編輯模型。拉取模型會讀取這個帳號目前可用的官方模型。", "current": "Kimi 使用中", "primaryShort": "Kimi Code 自己的登入", "empty": "還沒有 Kimi 帳號。Kimi Code 中已登入的帳號會自動出現在這裡。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f6c5b9992..51db7128a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -4946,7 +4946,8 @@ "primaryHidden": "Kimi Code 自己的登录已移出列表,重新登录同一账号即可找回", "removeCurrentHint": "这个账号正在使用,请先切换到其他账号再删除", "manageAccounts": "管理 Kimi 账号", - "managedHint": "请在授权中心管理 Kimi 官方账号。", + "managedHint": "先移除这个官方账号,或到授权中心删除它(连登录一起删)。", + "connectionManaged": "连接和登录由授权中心管理,这里只编辑模型。拉取模型会读取这个账号当前可用的官方模型。", "current": "Kimi 在用", "primaryShort": "Kimi Code 自己的登录", "empty": "还没有 Kimi 账号。Kimi Code 里已登录的号会自动出现在这里。", diff --git a/src/lib/api/kimiOAuth.ts b/src/lib/api/kimiOAuth.ts index a80f53b84..410c2d6bc 100644 --- a/src/lib/api/kimiOAuth.ts +++ b/src/lib/api/kimiOAuth.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import type { SubscriptionQuota } from "@/types/subscription"; +import type { KimiProviderConfig } from "@/config/kimiProviderPresets"; export type KimiRegion = "mainland-cn" | "global"; @@ -50,6 +51,9 @@ export const kimiOAuthApi = { invoke("kimi_oauth_remove", { accountId }), quota: (accountId: string) => invoke("kimi_oauth_quota", { accountId }), + /** 账号当前的官方模型目录(键和内容与登录时写入的一致),只读不存 */ + models: (accountId: string) => + invoke("kimi_oauth_models", { accountId }), useAccount: (accountId: string) => invoke("kimi_oauth_use_account", { accountId }), }; diff --git a/tests/components/KimiProviderForm.test.tsx b/tests/components/KimiProviderForm.test.tsx index 7de5713cc..d507af087 100644 --- a/tests/components/KimiProviderForm.test.tsx +++ b/tests/components/KimiProviderForm.test.tsx @@ -133,9 +133,6 @@ describe("KimiProviderForm", () => { expect( screen.queryByRole("switch", { name: "kimi.capabilities.video_in" }), ).not.toBeInTheDocument(); - fireEvent.change(screen.getByLabelText("opencode.baseUrl"), { - target: { value: "https://updated.example.com/v1" }, - }); fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => expect(submit).toHaveBeenCalledOnce()); const saved = JSON.parse(submit.mock.calls[0][0].settingsConfig); @@ -143,10 +140,7 @@ describe("KimiProviderForm", () => { capabilities: native.models["kimi-code/k3"].capabilities, overrides: native.models["kimi-code/k3"].overrides, }); - expect(saved.provider).toEqual({ - ...config.provider, - base_url: "https://updated.example.com/v1", - }); + expect(saved.provider).toEqual(config.provider); }, ); @@ -264,24 +258,75 @@ describe("KimiProviderForm", () => { }, ); - it("edits native endpoints while preserving OAuth and model overrides", async () => { + it("edits only the models of an official account, keeping its login", async () => { const submit = setup(); - fireEvent.change(screen.getByLabelText("opencode.baseUrl"), { - target: { value: "https://example.com/v1" }, - }); + expect( + screen.getByText("kimi.oauth.connectionManaged"), + ).toBeInTheDocument(); + expect(screen.queryByLabelText("opencode.baseUrl")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("API Key")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => expect(submit).toHaveBeenCalledOnce()); const saved = JSON.parse(submit.mock.calls[0][0].settingsConfig); - expect(saved.provider).toEqual({ - ...config.provider, - base_url: "https://example.com/v1", - }); + expect(saved.provider).toEqual(config.provider); expect(saved.models).toEqual(config.models); expect(submit.mock.calls[0][0].providerKey).toBe("managed:kimi-code"); }); + it("adds models from the official account's own catalog", async () => { + const user = userEvent.setup(); + let requested: unknown; + server.use( + http.post("http://tauri.local/kimi_oauth_models", async ({ request }) => { + requested = await request.json(); + return HttpResponse.json({ + "kimi-code/k3": { + provider: "managed:kimi-code", + model: "k3", + max_context_size: 1048576, + }, + "kimi-code/k3.1-pro": { + provider: "managed:kimi-code", + model: "k3.1-pro", + display_name: "K3.1 Pro", + max_context_size: 1048576, + capabilities: ["tool_use", "thinking"], + support_efforts: ["high", "max"], + default_effort: "max", + }, + }); + }), + ); + const submit = setup(); + await user.click( + screen.getByRole("button", { name: "providerForm.fetchModels" }), + ); + await user.click(await screen.findByRole("checkbox", { name: "k3.1-pro" })); + await user.click( + screen.getByRole("button", { name: "providerForm.modelPicker.apply" }), + ); + expect(requested).toEqual({ accountId: "managed:kimi-code" }); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(submit).toHaveBeenCalledOnce()); + const saved = JSON.parse(submit.mock.calls[0][0].settingsConfig); + expect(saved.models["kimi-code/k3.1-pro"]).toEqual({ + provider: "managed:kimi-code", + model: "k3.1-pro", + display_name: "K3.1 Pro", + max_context_size: 1048576, + capabilities: ["tool_use", "thinking"], + support_efforts: ["high", "max"], + default_effort: "max", + }); + expect(saved.models["kimi-code/k3"]).toEqual(config.models["kimi-code/k3"]); + }); + it("shows request headers inline like Pi", () => { - setup(); + const { oauth: _oauth, ...apiKeyProvider } = config.provider; + setup(vi.fn(), undefined, { + ...config, + provider: { ...apiKeyProvider, type: "openai", api_key: "sk-test" }, + }); expect(screen.getByDisplayValue("X-Custom")).toBeInTheDocument(); expect( screen.queryByRole("button", { diff --git a/tests/components/providerPresentation.test.ts b/tests/components/providerPresentation.test.ts index 186e62062..5986ad353 100644 --- a/tests/components/providerPresentation.test.ts +++ b/tests/components/providerPresentation.test.ts @@ -653,7 +653,7 @@ describe("buildAdditiveSections", () => { ); }); - it("protects every official Kimi OAuth provider, including inactive accounts", () => { + it("lets official Kimi accounts edit models and leave Kimi, but not be deleted while there", () => { const accounts = ["managed:kimi-code", "kimi-code-ccs-second"].map((id) => provider(id, { settingsConfig: { @@ -671,12 +671,16 @@ describe("buildAdditiveSections", () => { }); for (const account of accounts) { const presentation = item(sections, account.id); - expect(presentation.editDisabledReason).toBe("kimi.oauth.managedHint"); + expect(presentation.editDisabledReason).toBeUndefined(); expect(presentation.deleteDisabledReason).toBe("kimi.oauth.managedHint"); - expect(button(sections, account.id, "remove").disabledReason).toBe( - "kimi.oauth.managedHint", - ); } + // 只有正在用的那个号不能移除 + expect(button(sections, accounts[0].id, "remove").disabledReason).toBe( + "providerCard.reason.currentCannotRemove", + ); + expect( + button(sections, accounts[1].id, "remove").disabledReason, + ).toBeUndefined(); expect( item(sections, accounts[1].id).menuItems?.find( (action) => action.key === "setDefault",