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",