From f70d5b0c2815a475acbb5ebced1ec4519cb1370b Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:59:37 +0800 Subject: [PATCH] feat: add new provider for minimax and qwen portals (#203) Co-authored-by: Haze <709547807@qq.com> --- electron/main/ipc-handlers.ts | 47 +++++++++++++----- electron/utils/device-oauth.ts | 49 +++++++++++++------ electron/utils/openclaw-auth.ts | 4 +- electron/utils/provider-registry.ts | 10 ++++ electron/utils/secure-storage.ts | 2 +- src/App.tsx | 1 + src/assets/providers/index.ts | 1 + src/components/settings/ProvidersSettings.tsx | 46 +++++++++++------ src/i18n/locales/en/settings.json | 3 +- src/i18n/locales/zh/settings.json | 3 +- src/lib/providers.ts | 4 +- src/pages/Setup/index.tsx | 35 +++++++++++-- 12 files changed, 154 insertions(+), 51 deletions(-) diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index ad27e45a2..2cdab15b6 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -59,12 +59,14 @@ import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; * @param providerId - Unique provider ID from secure-storage (UUID-like) * @returns A string like 'custom-a1b2c3d4' or 'openrouter' */ -function getOpenClawProviderKey(type: string, providerId: string): string { +export function getOpenClawProviderKey(type: string, providerId: string): string { if (type === 'custom' || type === 'ollama') { - // Use the first 8 chars of the providerId as a stable short suffix const suffix = providerId.replace(/-/g, '').slice(0, 8); return `${type}-${suffix}`; } + if (type === 'minimax-portal-cn') { + return 'minimax-portal'; + } return type; } @@ -834,6 +836,14 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { * Provider-related IPC handlers */ function registerProviderHandlers(gatewayManager: GatewayManager): void { + // Listen for OAuth success to automatically restart the Gateway with new tokens/configs + deviceOAuthManager.on('oauth:success', (providerType) => { + logger.info(`[IPC] Restarting Gateway after ${providerType} OAuth success...`); + void gatewayManager.restart().catch(err => { + logger.error('Failed to restart Gateway after OAuth success:', err); + }); + }); + // Get all providers with key info ipcMain.handle('provider:list', async () => { return await getAllProvidersWithKeyInfo(); @@ -923,6 +933,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { try { const ock = getOpenClawProviderKey(existing.type, providerId); removeProviderFromOpenClaw(ock); + + // Restart Gateway so it no longer loads the deleted provider's plugin/config + logger.info(`Restarting Gateway after deleting provider "${ock}"`); + void gatewayManager.restart().catch((err) => { + logger.warn('Gateway restart after provider delete failed:', err); + }); } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); } @@ -1114,9 +1130,9 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const ock = getOpenClawProviderKey(provider.type, providerId); const providerKey = await getApiKey(providerId); - // OAuth providers (qwen-portal, minimax-portal) might use OAuth OR a direct API key. + // OAuth providers (qwen-portal, minimax-portal, minimax-portal-cn) might use OAuth OR a direct API key. // Treat them as OAuth only if they don't have a local API key configured. - const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal']; + const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; const isOAuthProvider = OAUTH_PROVIDER_TYPES.includes(provider.type) && !providerKey; if (!isOAuthProvider) { @@ -1141,23 +1157,30 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { saveProviderKeyToOpenClaw(ock, providerKey); } } else { - // OAuth providers (minimax-portal, qwen-portal) + // OAuth providers (minimax-portal, minimax-portal-cn, qwen-portal) const defaultBaseUrl = provider.type === 'minimax-portal' ? 'https://api.minimax.io/anthropic' - : 'https://portal.qwen.ai/v1'; - const api: 'anthropic-messages' | 'openai-completions' = provider.type === 'minimax-portal' - ? 'anthropic-messages' - : 'openai-completions'; + : (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1'); + const api: 'anthropic-messages' | 'openai-completions' = + (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') + ? 'anthropic-messages' + : 'openai-completions'; let baseUrl = provider.baseUrl || defaultBaseUrl; - if (provider.type === 'minimax-portal' && baseUrl && !baseUrl.endsWith('/anthropic')) { + if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl && !baseUrl.endsWith('/anthropic')) { baseUrl = baseUrl.replace(/\/$/, '') + '/anthropic'; } - setOpenClawDefaultModelWithOverride(provider.type, undefined, { + // To ensure the OpenClaw Gateway's internal token refresher works, + // we must save the CN provider under the "minimax-portal" key in openclaw.json + const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') + ? 'minimax-portal' + : provider.type; + + setOpenClawDefaultModelWithOverride(targetProviderKey, undefined, { baseUrl, api, - apiKeyEnv: provider.type === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', }); logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`); diff --git a/electron/utils/device-oauth.ts b/electron/utils/device-oauth.ts index bf2bf0a6d..1f171e8d4 100644 --- a/electron/utils/device-oauth.ts +++ b/electron/utils/device-oauth.ts @@ -33,7 +33,7 @@ import { } from '../../node_modules/openclaw/extensions/qwen-portal-auth/oauth'; import { saveOAuthTokenToOpenClaw, setOpenClawDefaultModelWithOverride } from './openclaw-auth'; -export type OAuthProviderType = 'minimax-portal' | 'qwen-portal'; +export type OAuthProviderType = 'minimax-portal' | 'minimax-portal-cn' | 'qwen-portal'; export type { MiniMaxRegion }; // ───────────────────────────────────────────────────────────── @@ -55,15 +55,17 @@ class DeviceOAuthManager extends EventEmitter { } this.active = true; + this.emit('oauth:start', { provider: provider }); this.activeProvider = provider; try { - if (provider === 'minimax-portal') { - await this.runMiniMaxFlow(region); + if (provider === 'minimax-portal' || provider === 'minimax-portal-cn') { + const actualRegion = provider === 'minimax-portal-cn' ? 'cn' : (region || 'global'); + await this.runMiniMaxFlow(actualRegion, provider); } else if (provider === 'qwen-portal') { await this.runQwenFlow(); } else { - throw new Error(`Unsupported OAuth provider: ${provider}`); + throw new Error(`Unsupported OAuth provider type: ${provider}`); } return true; } catch (error) { @@ -89,7 +91,7 @@ class DeviceOAuthManager extends EventEmitter { // MiniMax flow // ───────────────────────────────────────────────────────── - private async runMiniMaxFlow(region: MiniMaxRegion): Promise { + private async runMiniMaxFlow(region?: MiniMaxRegion, providerType: OAuthProviderType = 'minimax-portal'): Promise { if (!isOpenClawPresent()) { throw new Error('OpenClaw package not found'); } @@ -123,7 +125,7 @@ class DeviceOAuthManager extends EventEmitter { if (!this.active) return; - await this.onSuccess('minimax-portal', { + await this.onSuccess(providerType, { access: token.access, refresh: token.refresh, expires: token.expires, @@ -131,6 +133,7 @@ class DeviceOAuthManager extends EventEmitter { resourceUrl: token.resourceUrl, // MiniMax uses Anthropic Messages API format api: 'anthropic-messages', + region, }); } @@ -189,15 +192,19 @@ class DeviceOAuthManager extends EventEmitter { expires: number; resourceUrl?: string; api: 'anthropic-messages' | 'openai-completions'; + region?: MiniMaxRegion; }) { this.active = false; this.activeProvider = null; logger.info(`[DeviceOAuth] Successfully completed OAuth for ${providerType}`); - // 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format - // (matches what `openclaw models auth login` → upsertAuthProfile writes) + // 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format. + // (matches what `openclaw models auth login` → upsertAuthProfile writes). + // We save both MiniMax providers to the generic "minimax-portal" profile + // so OpenClaw's gateway auto-refresher knows how to find it. try { - saveOAuthTokenToOpenClaw(providerType, { + const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType; + saveOAuthTokenToOpenClaw(tokenProviderId, { access: token.access, refresh: token.refresh, expires: token.expires, @@ -213,18 +220,19 @@ class DeviceOAuthManager extends EventEmitter { // Note: MiniMax Anthropic-compatible API requires the /anthropic suffix. const defaultBaseUrl = providerType === 'minimax-portal' ? 'https://api.minimax.io/anthropic' - : 'https://portal.qwen.ai/v1'; + : (providerType === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1'); let baseUrl = token.resourceUrl || defaultBaseUrl; // If MiniMax returned a resourceUrl (e.g. https://api.minimax.io) but no /anthropic suffix, // we must append it because we use the 'anthropic-messages' API mode - if (providerType === 'minimax-portal' && baseUrl && !baseUrl.endsWith('/anthropic')) { + if (providerType.startsWith('minimax-portal') && baseUrl && !baseUrl.endsWith('/anthropic')) { baseUrl = baseUrl.replace(/\/$/, '') + '/anthropic'; } try { - setOpenClawDefaultModelWithOverride(providerType, undefined, { + const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType; + setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, { baseUrl, api: token.api, // OAuth placeholder — tells Gateway to resolve credentials @@ -232,7 +240,7 @@ class DeviceOAuthManager extends EventEmitter { // This matches what the OpenClaw plugin's configPatch writes: // minimax-portal → apiKey: 'minimax-oauth' // qwen-portal → apiKey: 'qwen-oauth' - apiKeyEnv: providerType === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + apiKeyEnv: tokenProviderId === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', }); } catch (err) { logger.warn(`[DeviceOAuth] Failed to configure openclaw models:`, err); @@ -240,19 +248,28 @@ class DeviceOAuthManager extends EventEmitter { // 3. Save provider record in ClawX's own store so UI shows it as configured const existing = await getProvider(providerType); + const nameMap: Record = { + 'minimax-portal': 'MiniMax (Global)', + 'minimax-portal-cn': 'MiniMax (CN)', + 'qwen-portal': 'Qwen', + }; const providerConfig: ProviderConfig = { id: providerType, - name: providerType === 'minimax-portal' ? 'MiniMax' : 'Qwen', + name: nameMap[providerType as OAuthProviderType] || providerType, type: providerType, enabled: existing?.enabled ?? true, - baseUrl: existing?.baseUrl, + baseUrl, // Save the dynamically resolved URL (Global vs CN) + model: existing?.model || getProviderDefaultModel(providerType), createdAt: existing?.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), }; await saveProvider(providerConfig); - // 4. Emit success to frontend + // 4. Emit success internally so the main process can restart the Gateway + this.emit('oauth:success', providerType); + + // 5. Emit success to frontend if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.webContents.send('oauth:success', { provider: providerType, success: true }); } diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index e4a255a70..63ee839ca 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -175,7 +175,7 @@ export function saveProviderKeyToOpenClaw( // managed by OpenClaw plugins via `openclaw models auth login`. // Skip only if there's no explicit API key — meaning the user is using OAuth. // If the user provided an actual API key, write it normally. - const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal']; + const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; if (OAUTH_PROVIDERS.includes(provider) && !apiKey) { console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`); return; @@ -227,7 +227,7 @@ export function removeProviderKeyFromOpenClaw( ): void { // OAuth providers have their credentials managed by OpenClaw plugins. // Do NOT delete their auth-profiles entries. - const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal']; + const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; if (OAUTH_PROVIDERS.includes(provider)) { console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`); return; diff --git a/electron/utils/provider-registry.ts b/electron/utils/provider-registry.ts index a45f5c58f..94f5c4398 100644 --- a/electron/utils/provider-registry.ts +++ b/electron/utils/provider-registry.ts @@ -13,6 +13,7 @@ export const BUILTIN_PROVIDER_TYPES = [ 'moonshot', 'siliconflow', 'minimax-portal', + 'minimax-portal-cn', 'qwen-portal', 'ollama', ] as const; @@ -110,6 +111,15 @@ const REGISTRY: Record = { apiKeyEnv: 'MINIMAX_API_KEY', }, }, + 'minimax-portal-cn': { + envVar: 'MINIMAX_CN_API_KEY', + defaultModel: 'minimax-portal/MiniMax-M2.1', + providerConfig: { + baseUrl: 'https://api.minimaxi.com/anthropic', + api: 'anthropic-messages', + apiKeyEnv: 'MINIMAX_CN_API_KEY', + }, + }, 'qwen-portal': { envVar: 'QWEN_API_KEY', defaultModel: 'qwen-portal/coder-model', diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index 4c0263e1c..55fa2a2ac 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -234,7 +234,7 @@ export async function getAllProvidersWithKeyInfo(): Promise< // This must match getOpenClawProviderKey() in ipc-handlers.ts exactly. const openClawKey = (provider.type === 'custom' || provider.type === 'ollama') ? `${provider.type}-${provider.id.replace(/-/g, '').slice(0, 8)}` - : provider.type; + : provider.type === 'minimax-portal-cn' ? 'minimax-portal' : provider.type; if (!isBuiltin && !activeOpenClawProviders.has(provider.type) && !activeOpenClawProviders.has(provider.id) && !activeOpenClawProviders.has(openClawKey)) { console.log(`[Sync] Provider ${provider.id} (${provider.type}) missing from OpenClaw, dropping from ClawX UI`); await deleteProvider(provider.id); diff --git a/src/App.tsx b/src/App.tsx index cb6a426fb..825587b14 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -167,6 +167,7 @@ function App() { position="bottom-right" richColors closeButton + style={{ zIndex: 99999 }} /> diff --git a/src/assets/providers/index.ts b/src/assets/providers/index.ts index ce3b5d032..e13feb8e8 100644 --- a/src/assets/providers/index.ts +++ b/src/assets/providers/index.ts @@ -17,6 +17,7 @@ export const providerIcons: Record = { moonshot, siliconflow, 'minimax-portal': minimaxPortal, + 'minimax-portal-cn': minimaxPortal, 'qwen-portal': qwenPortal, ollama, custom, diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index 2e39d4f3a..2fe7ae756 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -487,20 +487,19 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add setOauthFlowing(false); setOauthData(null); setValidationError(null); - const { selectedType: type, typeInfo: info, onAdd: add, onClose: close, t: translate } = latestRef.current; - // Save the provider to the store so the list refreshes automatically - if (type && add) { - try { - await add( - type, - info?.name || type, - '', // OAuth providers don't use a plain API key - { model: info?.defaultModelId } - ); - } catch { - // provider may already exist; ignore duplicate errors - } + + const { onClose: close, t: translate } = latestRef.current; + + // device-oauth.ts already saved the provider config to the backend, + // including the dynamically resolved baseUrl for the region (e.g. CN vs Global). + // If we call add() here with undefined baseUrl, it will overwrite and erase it! + // So we just fetch the latest list from the backend to update the UI. + try { + await useProviderStore.getState().fetchProviders(); + } catch (err) { + console.error('Failed to refresh providers after OAuth:', err); } + close(); toast.success(translate('aiProviders.toast.added')); }; @@ -525,12 +524,22 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add const handleStartOAuth = async () => { if (!selectedType) return; + + if (selectedType === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { + toast.error(t('aiProviders.toast.minimaxConflict')); + return; + } + if (selectedType === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) { + toast.error(t('aiProviders.toast.minimaxConflict')); + return; + } + setOauthFlowing(true); setOauthData(null); setOauthError(null); try { - await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType, 'global'); + await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType); } catch (e) { setOauthError(String(e)); setOauthFlowing(false); @@ -552,6 +561,15 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add const handleAdd = async () => { if (!selectedType) return; + if (selectedType === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { + toast.error(t('aiProviders.toast.minimaxConflict')); + return; + } + if (selectedType === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) { + toast.error(t('aiProviders.toast.minimaxConflict')); + return; + } + setSaving(true); setValidationError(null); diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 0eec667ed..9ad3a967e 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -53,7 +53,8 @@ "updated": "Provider updated", "failedUpdate": "Failed to update provider", "invalidKey": "Invalid API key", - "modelRequired": "Model ID is required" + "modelRequired": "Model ID is required", + "minimaxConflict": "Cannot add both MiniMax (Global) and MiniMax (CN) providers." }, "oauth": { "loginMode": "OAuth Login", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 5853d9a02..ed19ca0a3 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -53,7 +53,8 @@ "updated": "提供商已更新", "failedUpdate": "更新提供商失败", "invalidKey": "无效的 API 密钥", - "modelRequired": "需要模型 ID" + "modelRequired": "需要模型 ID", + "minimaxConflict": "不能同时添加 MiniMax 国际站和国内站的服务商。" }, "oauth": { "loginMode": "OAuth 登录", diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 6c2aad78a..891581e3b 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -13,6 +13,7 @@ export const PROVIDER_TYPES = [ 'moonshot', 'siliconflow', 'minimax-portal', + 'minimax-portal-cn', 'qwen-portal', 'ollama', 'custom', @@ -69,7 +70,8 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [ { id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true }, { id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' }, { id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', defaultModelId: 'Pro/moonshotai/Kimi-K2.5' }, - { id: 'minimax-portal', name: 'MiniMax', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.1' }, + { id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.1' }, + { id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.1' }, { id: 'qwen-portal', name: 'Qwen', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'coder-model' }, { id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required', requiresApiKey: false, defaultBaseUrl: 'http://localhost:11434', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'qwen3:latest' }, { id: 'custom', name: 'Custom', icon: '⚙️', placeholder: 'API key...', requiresApiKey: true, showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'your-provider/model-id' }, diff --git a/src/pages/Setup/index.tsx b/src/pages/Setup/index.tsx index 07ffc2ffe..63a635a2d 100644 --- a/src/pages/Setup/index.tsx +++ b/src/pages/Setup/index.tsx @@ -773,14 +773,28 @@ function ProviderContent({ const handleStartOAuth = async () => { if (!selectedProvider) return; + + try { + const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ type: string }>; + const existingTypes = new Set(list.map(l => l.type)); + if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { + toast.error(t('settings:aiProviders.toast.minimaxConflict')); + return; + } + if (selectedProvider === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) { + toast.error(t('settings:aiProviders.toast.minimaxConflict')); + return; + } + } catch { + // ignore check failure + } + setOauthFlowing(true); setOauthData(null); setOauthError(null); - // Default to global region for MiniMax in setup - const region = 'global'; try { - await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedProvider, region); + await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedProvider); } catch (e) { setOauthError(String(e)); setOauthFlowing(false); @@ -905,6 +919,21 @@ function ProviderContent({ const handleValidateAndSave = async () => { if (!selectedProvider) return; + try { + const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ type: string }>; + const existingTypes = new Set(list.map(l => l.type)); + if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { + toast.error(t('settings:aiProviders.toast.minimaxConflict')); + return; + } + if (selectedProvider === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) { + toast.error(t('settings:aiProviders.toast.minimaxConflict')); + return; + } + } catch { + // ignore check failure + } + setValidating(true); setKeyValid(null);