Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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}"`);
Expand Down
49 changes: 33 additions & 16 deletions electron/utils/device-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

// ─────────────────────────────────────────────────────────────
Expand All @@ -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) {
Expand All @@ -89,7 +91,7 @@ class DeviceOAuthManager extends EventEmitter {
// MiniMax flow
// ─────────────────────────────────────────────────────────

private async runMiniMaxFlow(region: MiniMaxRegion): Promise<void> {
private async runMiniMaxFlow(region?: MiniMaxRegion, providerType: OAuthProviderType = 'minimax-portal'): Promise<void> {
if (!isOpenClawPresent()) {
throw new Error('OpenClaw package not found');
}
Expand Down Expand Up @@ -123,14 +125,15 @@ 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,
// MiniMax returns a per-account resourceUrl as the API base URL
resourceUrl: token.resourceUrl,
// MiniMax uses Anthropic Messages API format
api: 'anthropic-messages',
region,
});
}

Expand Down Expand Up @@ -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,
Expand All @@ -213,46 +220,56 @@ 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
// from auth-profiles.json (type: 'oauth') instead of a static API key.
// 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);
}

// 3. Save provider record in ClawX's own store so UI shows it as configured
const existing = await getProvider(providerType);
const nameMap: Record<OAuthProviderType, string> = {
'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 });
}
Expand Down
4 changes: 2 additions & 2 deletions electron/utils/openclaw-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions electron/utils/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const BUILTIN_PROVIDER_TYPES = [
'moonshot',
'siliconflow',
'minimax-portal',
'minimax-portal-cn',
'qwen-portal',
'ollama',
] as const;
Expand Down Expand Up @@ -110,6 +111,15 @@ const REGISTRY: Record<string, ProviderBackendMeta> = {
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',
Expand Down
2 changes: 1 addition & 1 deletion electron/utils/secure-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ function App() {
position="bottom-right"
richColors
closeButton
style={{ zIndex: 99999 }}
/>
</TooltipProvider>
</ErrorBoundary>
Expand Down
1 change: 1 addition & 0 deletions src/assets/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const providerIcons: Record<string, string> = {
moonshot,
siliconflow,
'minimax-portal': minimaxPortal,
'minimax-portal-cn': minimaxPortal,
'qwen-portal': qwenPortal,
ollama,
custom,
Expand Down
46 changes: 32 additions & 14 deletions src/components/settings/ProvidersSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
};
Expand All @@ -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);
Expand All @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/zh/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
"updated": "提供商已更新",
"failedUpdate": "更新提供商失败",
"invalidKey": "无效的 API 密钥",
"modelRequired": "需要模型 ID"
"modelRequired": "需要模型 ID",
"minimaxConflict": "不能同时添加 MiniMax 国际站和国内站的服务商。"
},
"oauth": {
"loginMode": "OAuth 登录",
Expand Down
Loading