feat: support OAuth & API key for Qwen/MiniMax providers (#177)

This commit is contained in:
paisley
2026-02-26 15:11:37 +08:00
committed by GitHub
Unverified
parent e1ae68ce7e
commit 7b16b6af14
12 changed files with 1581 additions and 479 deletions

View File

@@ -27,7 +27,7 @@ import { getOpenClawCliCommand, installOpenClawCliMac } from '../utils/openclaw-
import { getSetting } from '../utils/store'; import { getSetting } from '../utils/store';
import { import {
saveProviderKeyToOpenClaw, saveProviderKeyToOpenClaw,
removeProviderKeyFromOpenClaw, removeProviderFromOpenClaw,
setOpenClawDefaultModel, setOpenClawDefaultModel,
setOpenClawDefaultModelWithOverride, setOpenClawDefaultModelWithOverride,
updateAgentModelProvider, updateAgentModelProvider,
@@ -47,6 +47,7 @@ import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-set
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { getProviderConfig } from '../utils/provider-registry'; import { getProviderConfig } from '../utils/provider-registry';
import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
/** /**
* Register all IPC handlers * Register all IPC handlers
@@ -95,6 +96,9 @@ export function registerIpcHandlers(
// WhatsApp handlers // WhatsApp handlers
registerWhatsAppHandlers(mainWindow); registerWhatsAppHandlers(mainWindow);
// Device OAuth handlers (Code Plan)
registerDeviceOAuthHandlers(mainWindow);
// File staging handlers (upload/send separation) // File staging handlers (upload/send separation)
registerFileHandlers(); registerFileHandlers();
} }
@@ -777,6 +781,35 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void {
}); });
} }
/**
* Device OAuth Handlers (Code Plan)
*/
function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void {
deviceOAuthManager.setWindow(mainWindow);
// Request Provider OAuth initialization
ipcMain.handle('provider:requestOAuth', async (_, provider: OAuthProviderType, region?: 'global' | 'cn') => {
try {
logger.info(`provider:requestOAuth for ${provider}`);
await deviceOAuthManager.startFlow(provider, region);
return { success: true };
} catch (error) {
logger.error('provider:requestOAuth failed', error);
return { success: false, error: String(error) };
}
});
// Cancel Provider OAuth
ipcMain.handle('provider:cancelOAuth', async () => {
try {
await deviceOAuthManager.stopFlow();
return { success: true };
} catch (error) {
logger.error('provider:cancelOAuth failed', error);
return { success: false, error: String(error) };
}
});
}
/** /**
* Provider-related IPC handlers * Provider-related IPC handlers
@@ -822,12 +855,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
const existing = await getProvider(providerId); const existing = await getProvider(providerId);
await deleteProvider(providerId); await deleteProvider(providerId);
// Best-effort cleanup in OpenClaw auth profiles // Best-effort cleanup in OpenClaw auth profiles & openclaw.json config
if (existing?.type) { if (existing?.type) {
try { try {
removeProviderKeyFromOpenClaw(existing.type); removeProviderFromOpenClaw(existing.type);
} catch (err) { } catch (err) {
console.warn('Failed to remove key from OpenClaw auth-profiles:', err); console.warn('Failed to completely remove provider from OpenClaw:', err);
} }
} }
@@ -891,7 +924,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
saveProviderKeyToOpenClaw(nextConfig.type, trimmedKey); saveProviderKeyToOpenClaw(nextConfig.type, trimmedKey);
} else { } else {
await deleteApiKey(providerId); await deleteApiKey(providerId);
removeProviderKeyFromOpenClaw(nextConfig.type); removeProviderFromOpenClaw(nextConfig.type);
} }
} }
@@ -942,7 +975,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
saveProviderKeyToOpenClaw(previousProviderType, previousKey); saveProviderKeyToOpenClaw(previousProviderType, previousKey);
} else { } else {
await deleteApiKey(providerId); await deleteApiKey(providerId);
removeProviderKeyFromOpenClaw(previousProviderType); removeProviderFromOpenClaw(previousProviderType);
} }
} catch (rollbackError) { } catch (rollbackError) {
console.warn('Failed to rollback provider updateWithKey:', rollbackError); console.warn('Failed to rollback provider updateWithKey:', rollbackError);
@@ -962,9 +995,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
const provider = await getProvider(providerId); const provider = await getProvider(providerId);
const providerType = provider?.type || providerId; const providerType = provider?.type || providerId;
try { try {
removeProviderKeyFromOpenClaw(providerType); if (providerType) {
removeProviderFromOpenClaw(providerType);
}
} catch (err) { } catch (err) {
console.warn('Failed to remove key from OpenClaw auth-profiles:', err); console.warn('Failed to completely remove provider from OpenClaw:', err);
} }
return { success: true }; return { success: true };
@@ -992,10 +1027,21 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
const provider = await getProvider(providerId); const provider = await getProvider(providerId);
if (provider) { if (provider) {
try { try {
// OAuth providers (qwen-portal, minimax-portal) have their openclaw.json
// model config already written by `openclaw models auth login --set-default`.
// Non-OAuth providers need us to write it here.
const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal'];
const isOAuthProvider = OAUTH_PROVIDER_TYPES.includes(provider.type);
if (!isOAuthProvider) {
// If the provider has a user-specified model (e.g. siliconflow), // If the provider has a user-specified model (e.g. siliconflow),
// build the full model string: "providerType/modelId" // build the full model string: "providerType/modelId"
// Guard against double-prefixing: provider.model may already
// include the provider type (e.g. "siliconflow/DeepSeek-V3").
const modelOverride = provider.model const modelOverride = provider.model
? `${provider.type}/${provider.model}` ? (provider.model.startsWith(`${provider.type}/`)
? provider.model
: `${provider.type}/${provider.model}`)
: undefined; : undefined;
if (provider.type === 'custom' || provider.type === 'ollama') { if (provider.type === 'custom' || provider.type === 'ollama') {
@@ -1016,6 +1062,32 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
if (providerKey) { if (providerKey) {
saveProviderKeyToOpenClaw(provider.type, providerKey); saveProviderKeyToOpenClaw(provider.type, providerKey);
} }
} else {
// OAuth providers (minimax-portal, qwen-portal): write the provider config
// using the model and baseUrl stored by device-oauth.ts at login time.
// These providers use their own API format (not standard OpenAI completions).
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';
let baseUrl = provider.baseUrl || defaultBaseUrl;
if (provider.type === 'minimax-portal' && baseUrl && !baseUrl.endsWith('/anthropic')) {
baseUrl = baseUrl.replace(/\/$/, '') + '/anthropic';
}
setOpenClawDefaultModelWithOverride(provider.type, undefined, {
baseUrl,
api,
// OAuth placeholder — Gateway uses this to look up OAuth credentials
// from auth-profiles.json instead of a static API key.
apiKeyEnv: provider.type === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
});
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
}
// For custom/ollama providers, also update the per-agent models.json // For custom/ollama providers, also update the per-agent models.json
// so the gateway picks up the new baseUrl + key immediately. // so the gateway picks up the new baseUrl + key immediately.
@@ -1056,6 +1128,8 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
} }
}); });
// Get default provider // Get default provider
ipcMain.handle('provider:getDefault', async () => { ipcMain.handle('provider:getDefault', async () => {
return await getDefaultProvider(); return await getDefaultProvider();

View File

@@ -77,6 +77,8 @@ const electronAPI = {
'provider:setDefault', 'provider:setDefault',
'provider:getDefault', 'provider:getDefault',
'provider:validateKey', 'provider:validateKey',
'provider:requestOAuth',
'provider:cancelOAuth',
// Cron // Cron
'cron:list', 'cron:list',
'cron:create', 'cron:create',
@@ -163,6 +165,9 @@ const electronAPI = {
'update:error', 'update:error',
'update:auto-install-countdown', 'update:auto-install-countdown',
'cron:updated', 'cron:updated',
'oauth:code',
'oauth:success',
'oauth:error',
]; ];
if (validChannels.includes(channel)) { if (validChannels.includes(channel)) {
@@ -202,6 +207,9 @@ const electronAPI = {
'update:downloaded', 'update:downloaded',
'update:error', 'update:error',
'update:auto-install-countdown', 'update:auto-install-countdown',
'oauth:code',
'oauth:success',
'oauth:error',
]; ];
if (validChannels.includes(channel)) { if (validChannels.includes(channel)) {

View File

@@ -0,0 +1,322 @@
/**
* Device OAuth Manager
*
* Delegates MiniMax and Qwen OAuth to the OpenClaw extension oauth.ts functions
* imported directly from the bundled openclaw package at build time.
*
* This approach:
* - Avoids hardcoding client_id (lives in openclaw extension)
* - Avoids duplicating HTTP OAuth logic
* - Avoids spawning CLI process (which requires interactive TTY)
* - Works identically on macOS, Windows, and Linux
*
* The extension oauth.ts files only use `node:crypto` and global `fetch` —
* they are pure Node.js HTTP functions, no TTY, no prompter needed.
*
* We provide our own callbacks (openUrl/note/progress) that hook into
* the Electron IPC system to display UI in the ClawX frontend.
*/
import { EventEmitter } from 'events';
import { BrowserWindow, shell } from 'electron';
import { logger } from './logger';
import { saveProvider, getProvider, ProviderConfig } from './secure-storage';
import { getProviderDefaultModel } from './provider-registry';
import { isOpenClawPresent } from './paths';
import {
loginMiniMaxPortalOAuth,
type MiniMaxOAuthToken,
type MiniMaxRegion,
} from '../../node_modules/openclaw/extensions/minimax-portal-auth/oauth';
import {
loginQwenPortalOAuth,
type QwenOAuthToken,
} from '../../node_modules/openclaw/extensions/qwen-portal-auth/oauth';
import { saveOAuthTokenToOpenClaw, setOpenClawDefaultModelWithOverride } from './openclaw-auth';
export type OAuthProviderType = 'minimax-portal' | 'qwen-portal';
export type { MiniMaxRegion };
// ─────────────────────────────────────────────────────────────
// DeviceOAuthManager
// ─────────────────────────────────────────────────────────────
class DeviceOAuthManager extends EventEmitter {
private activeProvider: OAuthProviderType | null = null;
private active: boolean = false;
private mainWindow: BrowserWindow | null = null;
setWindow(window: BrowserWindow) {
this.mainWindow = window;
}
async startFlow(provider: OAuthProviderType, region: MiniMaxRegion = 'global'): Promise<boolean> {
if (this.active) {
await this.stopFlow();
}
this.active = true;
this.activeProvider = provider;
try {
if (provider === 'minimax-portal') {
await this.runMiniMaxFlow(region);
} else if (provider === 'qwen-portal') {
await this.runQwenFlow();
} else {
throw new Error(`Unsupported OAuth provider: ${provider}`);
}
return true;
} catch (error) {
if (!this.active) {
// Flow was cancelled — not an error
return false;
}
logger.error(`[DeviceOAuth] Flow error for ${provider}:`, error);
this.emitError(error instanceof Error ? error.message : String(error));
this.active = false;
this.activeProvider = null;
return false;
}
}
async stopFlow(): Promise<void> {
this.active = false;
this.activeProvider = null;
logger.info('[DeviceOAuth] Flow explicitly stopped');
}
// ─────────────────────────────────────────────────────────
// MiniMax flow
// ─────────────────────────────────────────────────────────
private async runMiniMaxFlow(region: MiniMaxRegion): Promise<void> {
if (!isOpenClawPresent()) {
throw new Error('OpenClaw package not found');
}
const provider = this.activeProvider!;
const token: MiniMaxOAuthToken = await loginMiniMaxPortalOAuth({
region,
openUrl: async (url) => {
logger.info(`[DeviceOAuth] MiniMax opening browser: ${url}`);
// Open the authorization URL in the system browser
shell.openExternal(url).catch((err) =>
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
);
},
note: async (message, _title) => {
if (!this.active) return;
// The extension calls note() with a message containing
// the user_code and verification_uri — parse them for the UI
const { verificationUri, userCode } = this.parseNote(message);
if (verificationUri && userCode) {
this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 });
} else {
logger.info(`[DeviceOAuth] MiniMax note: ${message}`);
}
},
progress: {
update: (msg) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`),
stop: (msg) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`),
},
});
if (!this.active) return;
await this.onSuccess('minimax-portal', {
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',
});
}
// ─────────────────────────────────────────────────────────
// Qwen flow
// ─────────────────────────────────────────────────────────
private async runQwenFlow(): Promise<void> {
if (!isOpenClawPresent()) {
throw new Error('OpenClaw package not found');
}
const provider = this.activeProvider!;
const token: QwenOAuthToken = await loginQwenPortalOAuth({
openUrl: async (url) => {
logger.info(`[DeviceOAuth] Qwen opening browser: ${url}`);
shell.openExternal(url).catch((err) =>
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
);
},
note: async (message, _title) => {
if (!this.active) return;
const { verificationUri, userCode } = this.parseNote(message);
if (verificationUri && userCode) {
this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 });
} else {
logger.info(`[DeviceOAuth] Qwen note: ${message}`);
}
},
progress: {
update: (msg) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`),
stop: (msg) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`),
},
});
if (!this.active) return;
await this.onSuccess('qwen-portal', {
access: token.access,
refresh: token.refresh,
expires: token.expires,
// Qwen returns a per-account resourceUrl as the API base URL
resourceUrl: token.resourceUrl,
// Qwen uses OpenAI Completions API format
api: 'openai-completions',
});
}
// ─────────────────────────────────────────────────────────
// Success handler
// ─────────────────────────────────────────────────────────
private async onSuccess(providerType: OAuthProviderType, token: {
access: string;
refresh: string;
expires: number;
resourceUrl?: string;
api: 'anthropic-messages' | 'openai-completions';
}) {
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)
try {
saveOAuthTokenToOpenClaw(providerType, {
access: token.access,
refresh: token.refresh,
expires: token.expires,
});
} catch (err) {
logger.warn(`[DeviceOAuth] Failed to save OAuth token to OpenClaw:`, err);
}
// 2. Write openclaw.json: set default model + provider config (baseUrl/api/models)
// This mirrors what the OpenClaw plugin's configPatch does after CLI login.
// The baseUrl comes from token.resourceUrl (per-account URL from the OAuth server)
// or falls back to the provider's default public endpoint.
// Note: MiniMax Anthropic-compatible API requires the /anthropic suffix.
const defaultBaseUrl = providerType === 'minimax-portal'
? 'https://api.minimax.io/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')) {
baseUrl = baseUrl.replace(/\/$/, '') + '/anthropic';
}
try {
setOpenClawDefaultModelWithOverride(providerType, 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',
});
} 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 providerConfig: ProviderConfig = {
id: providerType,
name: providerType === 'minimax-portal' ? 'MiniMax' : 'Qwen',
type: providerType,
enabled: existing?.enabled ?? true,
baseUrl: existing?.baseUrl,
model: existing?.model || getProviderDefaultModel(providerType),
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await saveProvider(providerConfig);
// 4. Emit success to frontend
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:success', { provider: providerType, success: true });
}
}
// ─────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────
/**
* Parse user_code and verification_uri from the note message sent by
* the OpenClaw extension's loginXxxPortalOAuth function.
*
* Note format (minimax-portal-auth/oauth.ts):
* "Open https://platform.minimax.io/oauth-authorize?user_code=dyMj_wOhpK&client=... to approve access.\n"
* "If prompted, enter the code dyMj_wOhpK.\n"
* ...
*
* user_code format: mixed-case alphanumeric with underscore, e.g. "dyMj_wOhpK"
*/
private parseNote(message: string): { verificationUri?: string; userCode?: string } {
// Primary: extract URL (everything between "Open " and " to")
const urlMatch = message.match(/Open\s+(https?:\/\/\S+?)\s+to/i);
const verificationUri = urlMatch?.[1];
let userCode: string | undefined;
// Method 1: extract user_code from URL query param (most reliable)
if (verificationUri) {
try {
const parsed = new URL(verificationUri);
const qp = parsed.searchParams.get('user_code');
if (qp) userCode = qp;
} catch {
// fall through to text-based extraction
}
}
// Method 2: text-based extraction — matches mixed-case alnum + underscore/hyphen codes
if (!userCode) {
const codeMatch = message.match(/enter.*?code\s+([A-Za-z0-9][A-Za-z0-9_-]{3,})/i);
if (codeMatch?.[1]) userCode = codeMatch[1].replace(/\.$/, ''); // strip trailing period
}
return { verificationUri, userCode };
}
private emitCode(data: {
provider: string;
verificationUri: string;
userCode: string;
expiresIn: number;
}) {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:code', data);
}
}
private emitError(message: string) {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:error', { message });
}
}
}
export const deviceOAuthManager = new DeviceOAuthManager();

View File

@@ -24,12 +24,23 @@ interface AuthProfileEntry {
key: string; key: string;
} }
/**
* Auth profile entry for an OAuth token (matches OpenClaw plugin format)
*/
interface OAuthProfileEntry {
type: 'oauth';
provider: string;
access: string;
refresh: string;
expires: number;
}
/** /**
* Auth profiles store format * Auth profiles store format
*/ */
interface AuthProfilesStore { interface AuthProfilesStore {
version: number; version: number;
profiles: Record<string, AuthProfileEntry>; profiles: Record<string, AuthProfileEntry | OAuthProfileEntry>;
order?: Record<string, string[]>; order?: Record<string, string[]>;
lastGood?: Record<string, string>; lastGood?: Record<string, string>;
} }
@@ -96,6 +107,52 @@ function discoverAgentIds(): string[] {
} }
} }
/**
* Save an OAuth token to OpenClaw's auth-profiles.json.
* Writes in OpenClaw's native OAuth credential format (type: 'oauth'),
* matching exactly what `openclaw models auth login` (upsertAuthProfile) writes.
*
* @param provider - Provider type (e.g. 'minimax-portal', 'qwen-portal')
* @param token - OAuth token from the provider's login function
* @param agentId - Optional single agent ID. When omitted, writes to every agent.
*/
export function saveOAuthTokenToOpenClaw(
provider: string,
token: { access: string; refresh: string; expires: number },
agentId?: string
): void {
const agentIds = agentId ? [agentId] : discoverAgentIds();
if (agentIds.length === 0) agentIds.push('main');
for (const id of agentIds) {
const store = readAuthProfiles(id);
const profileId = `${provider}:default`;
const entry: OAuthProfileEntry = {
type: 'oauth',
provider,
access: token.access,
refresh: token.refresh,
expires: token.expires,
};
store.profiles[profileId] = entry;
if (!store.order) store.order = {};
if (!store.order[provider]) store.order[provider] = [];
if (!store.order[provider].includes(profileId)) {
store.order[provider].push(profileId);
}
if (!store.lastGood) store.lastGood = {};
store.lastGood[provider] = profileId;
writeAuthProfiles(store, id);
}
console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
}
/** /**
* Save a provider API key to OpenClaw's auth-profiles.json * Save a provider API key to OpenClaw's auth-profiles.json
* This writes the key in the format OpenClaw expects so the gateway * This writes the key in the format OpenClaw expects so the gateway
@@ -109,10 +166,20 @@ function discoverAgentIds(): string[] {
* @param agentId - Optional single agent ID. When omitted, writes to every agent. * @param agentId - Optional single agent ID. When omitted, writes to every agent.
*/ */
export function saveProviderKeyToOpenClaw( export function saveProviderKeyToOpenClaw(
provider: string, provider: string,
apiKey: string, apiKey: string,
agentId?: string agentId?: string
): void { ): void {
// OAuth providers (qwen-portal, minimax-portal) typically have their credentials
// 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'];
if (OAUTH_PROVIDERS.includes(provider) && !apiKey) {
console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`);
return;
}
const agentIds = agentId ? [agentId] : discoverAgentIds(); const agentIds = agentId ? [agentId] : discoverAgentIds();
if (agentIds.length === 0) agentIds.push('main'); if (agentIds.length === 0) agentIds.push('main');
@@ -158,6 +225,13 @@ export function removeProviderKeyFromOpenClaw(
provider: string, provider: string,
agentId?: string agentId?: string
): void { ): void {
// OAuth providers have their credentials managed by OpenClaw plugins.
// Do NOT delete their auth-profiles entries.
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal'];
if (OAUTH_PROVIDERS.includes(provider)) {
console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`);
return;
}
const agentIds = agentId ? [agentId] : discoverAgentIds(); const agentIds = agentId ? [agentId] : discoverAgentIds();
if (agentIds.length === 0) agentIds.push('main'); if (agentIds.length === 0) agentIds.push('main');
@@ -183,6 +257,60 @@ export function removeProviderKeyFromOpenClaw(
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
} }
/**
* Remove a provider completely from OpenClaw (delete config, disable plugins, delete keys)
*/
export function removeProviderFromOpenClaw(provider: string): void {
// 1. Remove from auth-profiles.json
const agentIds = discoverAgentIds();
if (agentIds.length === 0) agentIds.push('main');
for (const id of agentIds) {
const store = readAuthProfiles(id);
const profileId = `${provider}:default`;
if (store.profiles[profileId]) {
delete store.profiles[profileId];
if (store.order?.[provider]) {
store.order[provider] = store.order[provider].filter((aid) => aid !== profileId);
if (store.order[provider].length === 0) delete store.order[provider];
}
if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider];
writeAuthProfiles(store, id);
}
}
// 2. Remove from openclaw.json
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
try {
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
let modified = false;
// Disable plugin (for OAuth like qwen-portal-auth)
if (config.plugins?.entries) {
const pluginName = `${provider}-auth`;
if (config.plugins.entries[pluginName]) {
config.plugins.entries[pluginName].enabled = false;
modified = true;
console.log(`Disabled OpenClaw plugin: ${pluginName}`);
}
}
// Remove from models.providers
if (config.models?.providers?.[provider]) {
delete config.models.providers[provider];
modified = true;
console.log(`Removed OpenClaw provider config: ${provider}`);
}
if (modified) {
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
}
}
} catch (err) {
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
}
}
/** /**
* Build environment variables object with all stored API keys * Build environment variables object with all stored API keys
* for passing to the Gateway process * for passing to the Gateway process
@@ -386,6 +514,16 @@ export function setOpenClawDefaultModelWithOverride(
} }
config.gateway = gateway; config.gateway = gateway;
// Ensure the extension plugin is marked as enabled in openclaw.json
// Without this, the OpenClaw Gateway will silently wipe the provider config on startup
if (provider === 'minimax-portal' || provider === 'qwen-portal') {
const plugins = (config.plugins || {}) as Record<string, unknown>;
const entries = (plugins.entries || {}) as Record<string, unknown>;
entries[`${provider}-auth`] = { enabled: true };
plugins.entries = entries;
config.plugins = plugins;
}
const dir = join(configPath, '..'); const dir = join(configPath, '..');
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
@@ -397,6 +535,52 @@ export function setOpenClawDefaultModelWithOverride(
); );
} }
/**
* Get a set of all active provider IDs configured in openclaw.json and auth-profiles.json.
* This is used to sync ClawX's local provider list with the actual OpenClaw engine state.
*/
export function getActiveOpenClawProviders(): Set<string> {
const activeProviders = new Set<string>();
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
// 1. Read openclaw.json models.providers
try {
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
const providers = config.models?.providers;
if (providers && typeof providers === 'object') {
for (const key of Object.keys(providers)) {
activeProviders.add(key);
}
}
}
} catch (err) {
console.warn('Failed to read openclaw.json for active providers:', err);
}
// 2. Read openclaw.json plugins.entries for OAuth providers
try {
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
const plugins = config.plugins?.entries;
if (plugins && typeof plugins === 'object') {
for (const [pluginId, meta] of Object.entries(plugins)) {
// If the plugin ends with -auth and is enabled, it's an OAuth provider
// e.g. 'qwen-portal-auth' implies provider 'qwen-portal'
if (pluginId.endsWith('-auth') && (meta as Record<string, unknown>).enabled) {
const providerId = pluginId.replace(/-auth$/, '');
activeProviders.add(providerId);
}
}
}
}
} catch (err) {
console.warn('Failed to read openclaw.json for active plugins:', err);
}
return activeProviders;
}
// Re-export for backwards compatibility // Re-export for backwards compatibility
/** /**
* Write the ClawX gateway token into ~/.openclaw/openclaw.json so the * Write the ClawX gateway token into ~/.openclaw/openclaw.json so the

View File

@@ -12,6 +12,8 @@ export const BUILTIN_PROVIDER_TYPES = [
'openrouter', 'openrouter',
'moonshot', 'moonshot',
'siliconflow', 'siliconflow',
'minimax-portal',
'qwen-portal',
'ollama', 'ollama',
] as const; ] as const;
export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number]; export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
@@ -94,6 +96,12 @@ const REGISTRY: Record<string, ProviderBackendMeta> = {
apiKeyEnv: 'SILICONFLOW_API_KEY', apiKeyEnv: 'SILICONFLOW_API_KEY',
}, },
}, },
'minimax-portal': {
defaultModel: 'minimax-portal/MiniMax-M2.1',
},
'qwen-portal': {
defaultModel: 'qwen-portal/coder-model',
},
custom: { custom: {
envVar: 'CUSTOM_API_KEY', envVar: 'CUSTOM_API_KEY',
}, },

View File

@@ -4,6 +4,9 @@
* Keys are stored in plain text alongside provider configs in a single electron-store. * Keys are stored in plain text alongside provider configs in a single electron-store.
*/ */
import type { ProviderType } from './provider-registry';
import { getActiveOpenClawProviders } from './openclaw-auth';
// Lazy-load electron-store (ESM module) // Lazy-load electron-store (ESM module)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let providerStore: any = null; let providerStore: any = null;
@@ -29,7 +32,7 @@ async function getProviderStore() {
export interface ProviderConfig { export interface ProviderConfig {
id: string; id: string;
name: string; name: string;
type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'moonshot' | 'siliconflow' | 'ollama' | 'custom'; type: ProviderType;
baseUrl?: string; baseUrl?: string;
model?: string; model?: string;
enabled: boolean; enabled: boolean;
@@ -204,14 +207,32 @@ export async function getProviderWithKeyInfo(
/** /**
* Get all providers with key info (for UI display) * Get all providers with key info (for UI display)
* Also synchronizes ClawX local provider list with OpenClaw's actual config.
*/ */
export async function getAllProvidersWithKeyInfo(): Promise< export async function getAllProvidersWithKeyInfo(): Promise<
Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }>
> { > {
const providers = await getAllProviders(); const providers = await getAllProviders();
const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = []; const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = [];
const activeOpenClawProviders = getActiveOpenClawProviders();
// We need to avoid deleting native ones like 'anthropic' or 'google'
// that don't need to exist in openclaw.json models.providers
const OpenClawBuiltinList = [
'anthropic', 'openai', 'google', 'moonshot', 'siliconflow', 'ollama'
];
for (const provider of providers) { for (const provider of providers) {
// Sync check: If it's a custom/OAuth provider and it no longer exists in OpenClaw config
// (e.g. wiped by Gateway due to missing plugin, or manually deleted by user)
// we should remove it from ClawX UI to stay consistent.
const isBuiltin = OpenClawBuiltinList.includes(provider.type);
if (!isBuiltin && !activeOpenClawProviders.has(provider.type) && !activeOpenClawProviders.has(provider.id)) {
console.log(`[Sync] Provider ${provider.id} (${provider.type}) missing from OpenClaw, dropping from ClawX UI`);
await deleteProvider(provider.id);
continue;
}
const apiKey = await getApiKey(provider.id); const apiKey = await getApiKey(provider.id);
let keyMasked: string | null = null; let keyMasked: string | null = null;
@@ -232,3 +253,4 @@ export async function getAllProvidersWithKeyInfo(): Promise<
return results; return results;
} }

View File

@@ -2,7 +2,7 @@
* Providers Settings Component * Providers Settings Component
* Manage AI provider configurations and API keys * Manage AI provider configurations and API keys
*/ */
import { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Plus, Plus,
Trash2, Trash2,
@@ -14,6 +14,9 @@ import {
Loader2, Loader2,
Star, Star,
Key, Key,
ExternalLink,
Copy,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -76,8 +79,8 @@ export function ProvidersSettings() {
apiKey.trim() || undefined apiKey.trim() || undefined
); );
// Auto-set as default if this is the first provider // Auto-set as default if no default is currently configured
if (providers.length === 0) { if (!defaultProviderId) {
await setDefaultProvider(id); await setDefaultProvider(id);
} }
@@ -370,6 +373,13 @@ function ProviderCard({
) : ( ) : (
<div className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2"> <div className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{typeInfo?.isOAuth ? (
<>
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
</>
) : (
<>
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> <Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-mono text-muted-foreground truncate"> <span className="text-sm font-mono text-muted-foreground truncate">
{provider.hasKey {provider.hasKey
@@ -381,6 +391,8 @@ function ProviderCard({
{provider.hasKey && ( {provider.hasKey && (
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge> <Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
)} )}
</>
)}
</div> </div>
<div className="flex gap-0.5 shrink-0 ml-2"> <div className="flex gap-0.5 shrink-0 ml-2">
<Button <Button
@@ -441,7 +453,96 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
// OAuth Flow State
const [oauthFlowing, setOauthFlowing] = useState(false);
const [oauthData, setOauthData] = useState<{
verificationUri: string;
userCode: string;
expiresIn: number;
} | null>(null);
const [oauthError, setOauthError] = useState<string | null>(null);
// For providers that support both OAuth and API key, let the user choose
const [authMode, setAuthMode] = useState<'oauth' | 'apikey'>('oauth');
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === selectedType); const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === selectedType);
const isOAuth = typeInfo?.isOAuth ?? false;
const supportsApiKey = typeInfo?.supportsApiKey ?? false;
// Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected
const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth');
// Keep a ref to the latest values so the effect closure can access them
const latestRef = React.useRef({ selectedType, typeInfo, onAdd, onClose, t });
useEffect(() => {
latestRef.current = { selectedType, typeInfo, onAdd, onClose, t };
});
// Manage OAuth events
useEffect(() => {
const handleCode = (data: unknown) => {
setOauthData(data as { verificationUri: string; userCode: string; expiresIn: number });
setOauthError(null);
};
const handleSuccess = async () => {
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
}
}
close();
toast.success(translate('aiProviders.toast.added'));
};
const handleError = (data: unknown) => {
setOauthError((data as { message: string }).message);
setOauthData(null);
};
window.electron.ipcRenderer.on('oauth:code', handleCode);
window.electron.ipcRenderer.on('oauth:success', handleSuccess);
window.electron.ipcRenderer.on('oauth:error', handleError);
return () => {
if (typeof window.electron.ipcRenderer.off === 'function') {
window.electron.ipcRenderer.off('oauth:code', handleCode);
window.electron.ipcRenderer.off('oauth:success', handleSuccess);
window.electron.ipcRenderer.off('oauth:error', handleError);
}
};
}, []);
const handleStartOAuth = async () => {
if (!selectedType) return;
setOauthFlowing(true);
setOauthData(null);
setOauthError(null);
try {
await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType, 'global');
} catch (e) {
setOauthError(String(e));
setOauthFlowing(false);
}
};
const handleCancelOAuth = async () => {
setOauthFlowing(false);
setOauthData(null);
setOauthError(null);
await window.electron.ipcRenderer.invoke('provider:cancelOAuth');
};
// Only custom can be added multiple times. // Only custom can be added multiple times.
const availableTypes = PROVIDER_TYPE_INFO.filter( const availableTypes = PROVIDER_TYPE_INFO.filter(
@@ -562,6 +663,32 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
/> />
</div> </div>
{/* Auth mode toggle for providers supporting both */}
{isOAuth && supportsApiKey && (
<div className="flex rounded-lg border overflow-hidden text-sm">
<button
onClick={() => setAuthMode('oauth')}
className={cn(
'flex-1 py-2 px-3 transition-colors',
authMode === 'oauth' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground'
)}
>
{t('aiProviders.oauth.loginMode')}
</button>
<button
onClick={() => setAuthMode('apikey')}
className={cn(
'flex-1 py-2 px-3 transition-colors',
authMode === 'apikey' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground'
)}
>
{t('aiProviders.oauth.apikeyMode')}
</button>
</div>
)}
{/* API Key input — shown for non-OAuth providers or when apikey mode is selected */}
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="apiKey">{t('aiProviders.dialog.apiKey')}</Label> <Label htmlFor="apiKey">{t('aiProviders.dialog.apiKey')}</Label>
<div className="relative"> <div className="relative">
@@ -591,6 +718,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
{t('aiProviders.dialog.apiKeyStored')} {t('aiProviders.dialog.apiKeyStored')}
</p> </p>
</div> </div>
)}
{typeInfo?.showBaseUrl && ( {typeInfo?.showBaseUrl && (
<div className="space-y-2"> <div className="space-y-2">
@@ -618,6 +746,98 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
/> />
</div> </div>
)} )}
{/* Device OAuth Trigger — only shown when in OAuth mode */}
{useOAuthFlow && (
<div className="space-y-4 pt-2">
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4 text-center">
<p className="text-sm text-blue-200 mb-3 block">
{t('aiProviders.oauth.loginPrompt')}
</p>
<Button
onClick={handleStartOAuth}
disabled={oauthFlowing}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{oauthFlowing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('aiProviders.oauth.waiting')}</>
) : (
t('aiProviders.oauth.loginButton')
)}
</Button>
</div>
{/* OAuth Active State Modal / Inline View */}
{oauthFlowing && (
<div className="mt-4 p-4 border rounded-xl bg-card relative overflow-hidden">
{/* Background pulse effect */}
<div className="absolute inset-0 bg-primary/5 animate-pulse" />
<div className="relative z-10 flex flex-col items-center justify-center text-center space-y-4">
{oauthError ? (
<div className="text-red-400 space-y-2">
<XCircle className="h-8 w-8 mx-auto" />
<p className="font-medium">{t('aiProviders.oauth.authFailed')}</p>
<p className="text-sm opacity-80">{oauthError}</p>
<Button variant="outline" size="sm" onClick={handleCancelOAuth} className="mt-2 text-foreground">
Try Again
</Button>
</div>
) : !oauthData ? (
<div className="space-y-3 py-4">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
<p className="text-sm text-muted-foreground animate-pulse">{t('aiProviders.oauth.requestingCode')}</p>
</div>
) : (
<div className="space-y-4 w-full">
<div className="space-y-1">
<h3 className="font-medium text-lg text-foreground">{t('aiProviders.oauth.approveLogin')}</h3>
<div className="text-sm text-muted-foreground text-left mt-2 space-y-1">
<p>1. {t('aiProviders.oauth.step1')}</p>
<p>2. {t('aiProviders.oauth.step2')}</p>
<p>3. {t('aiProviders.oauth.step3')}</p>
</div>
</div>
<div className="flex items-center justify-center gap-2 p-3 bg-background border rounded-lg">
<code className="text-2xl font-mono tracking-widest font-bold text-primary">
{oauthData.userCode}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => {
navigator.clipboard.writeText(oauthData.userCode);
toast.success(t('aiProviders.oauth.codeCopied'));
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<Button
variant="secondary"
className="w-full"
onClick={() => window.electron.ipcRenderer.invoke('shell:openExternal', oauthData.verificationUri)}
>
<ExternalLink className="h-4 w-4 mr-2" />
{t('aiProviders.oauth.openLoginPage')}
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground pt-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{t('aiProviders.oauth.waitingApproval')}</span>
</div>
<Button variant="ghost" size="sm" className="w-full mt-2" onClick={handleCancelOAuth}>
Cancel
</Button>
</div>
)}
</div>
</div>
)}
</div>
)}
</div> </div>
)} )}
@@ -629,6 +849,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
</Button> </Button>
<Button <Button
onClick={handleAdd} onClick={handleAdd}
className={cn(useOAuthFlow && "hidden")}
disabled={!selectedType || saving || ((typeInfo?.showModelId ?? false) && modelId.trim().length === 0)} disabled={!selectedType || saving || ((typeInfo?.showModelId ?? false) && modelId.trim().length === 0)}
> >
{saving ? ( {saving ? (

View File

@@ -54,6 +54,24 @@
"failedUpdate": "Failed to update provider", "failedUpdate": "Failed to update provider",
"invalidKey": "Invalid API key", "invalidKey": "Invalid API key",
"modelRequired": "Model ID is required" "modelRequired": "Model ID is required"
},
"oauth": {
"loginMode": "OAuth Login",
"apikeyMode": "API Key",
"loginPrompt": "This provider requires signing in via your browser.",
"loginButton": "Login with Browser",
"waiting": "Waiting...",
"openLoginPage": "Open Login Page",
"waitingApproval": "Waiting for approval in browser...",
"cancel": "Cancel",
"codeCopied": "Code copied to clipboard",
"authFailed": "Authentication Failed",
"tryAgain": "Try Again",
"approveLogin": "Approve Login",
"step1": "Copy the authorization code below.",
"step2": "Open the login page in your browser.",
"step3": "Paste the code to approve access.",
"requestingCode": "Requesting secure login code..."
} }
}, },
"gateway": { "gateway": {

View File

@@ -54,6 +54,24 @@
"failedUpdate": "プロバイダーの更新に失敗しました", "failedUpdate": "プロバイダーの更新に失敗しました",
"invalidKey": "無効な API キー", "invalidKey": "無効な API キー",
"modelRequired": "モデル ID が必要です" "modelRequired": "モデル ID が必要です"
},
"oauth": {
"loginMode": "OAuthログイン",
"apikeyMode": "APIキー",
"loginPrompt": "このプロバイダーはブラウザからのサインインが必要です。",
"loginButton": "ブラウザでログイン",
"waiting": "待機中...",
"openLoginPage": "ログインページを開く",
"waitingApproval": "ブラウザの承認を待っています...",
"cancel": "キャンセル",
"codeCopied": "コードをクリップボードにコピーしました",
"authFailed": "認証に失敗しました",
"tryAgain": "再試行",
"approveLogin": "ログインを承認",
"step1": "以下の認証コードをコピーしてください。",
"step2": "ブラウザでログインページを開いてください。",
"step3": "コードを貼り付けてアクセスを承認してください。",
"requestingCode": "セキュアログインコードを取得中..."
} }
}, },
"gateway": { "gateway": {

View File

@@ -54,6 +54,24 @@
"failedUpdate": "更新提供商失败", "failedUpdate": "更新提供商失败",
"invalidKey": "无效的 API 密钥", "invalidKey": "无效的 API 密钥",
"modelRequired": "需要模型 ID" "modelRequired": "需要模型 ID"
},
"oauth": {
"loginMode": "OAuth 登录",
"apikeyMode": "API 密钥",
"loginPrompt": "此提供商需要通过浏览器登录授权。",
"loginButton": "浏览器登录",
"waiting": "等待中...",
"openLoginPage": "打开登录页面",
"waitingApproval": "等待浏览器中的授权...",
"cancel": "取消",
"codeCopied": "代码已复制到剪贴板",
"authFailed": "认证失败",
"tryAgain": "重试",
"approveLogin": "确认登录",
"step1": "复制下方的授权码。",
"step2": "在浏览器中打开登录页面。",
"step3": "粘贴授权码以批准访问。",
"requestingCode": "正在获取安全登录码..."
} }
}, },
"gateway": { "gateway": {

View File

@@ -12,6 +12,8 @@ export const PROVIDER_TYPES = [
'openrouter', 'openrouter',
'moonshot', 'moonshot',
'siliconflow', 'siliconflow',
'minimax-portal',
'qwen-portal',
'ollama', 'ollama',
'custom', 'custom',
] as const; ] as const;
@@ -51,6 +53,10 @@ export interface ProviderTypeInfo {
modelIdPlaceholder?: string; modelIdPlaceholder?: string;
/** Default model ID to pre-fill */ /** Default model ID to pre-fill */
defaultModelId?: string; defaultModelId?: string;
/** Whether this provider uses OAuth device flow instead of an API key */
isOAuth?: boolean;
/** Whether this provider also accepts a direct API key (in addition to OAuth) */
supportsApiKey?: boolean;
} }
import { providerIcons } from '@/assets/providers'; import { providerIcons } from '@/assets/providers';
@@ -63,6 +69,8 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true }, { 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: '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: '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 (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.1' },
{ id: 'qwen-portal', name: 'Qwen (CN)', 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: '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' }, { id: 'custom', name: 'Custom', icon: '⚙️', placeholder: 'API key...', requiresApiKey: true, showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'your-provider/model-id' },
]; ];

View File

@@ -19,6 +19,7 @@ import {
XCircle, XCircle,
ExternalLink, ExternalLink,
BookOpen, BookOpen,
Copy,
} from 'lucide-react'; } from 'lucide-react';
import { TitleBar } from '@/components/layout/TitleBar'; import { TitleBar } from '@/components/layout/TitleBar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -715,6 +716,84 @@ function ProviderContent({
const [providerMenuOpen, setProviderMenuOpen] = useState(false); const [providerMenuOpen, setProviderMenuOpen] = useState(false);
const providerMenuRef = useRef<HTMLDivElement | null>(null); const providerMenuRef = useRef<HTMLDivElement | null>(null);
const [authMode, setAuthMode] = useState<'oauth' | 'apikey'>('oauth');
// OAuth Flow State
const [oauthFlowing, setOauthFlowing] = useState(false);
const [oauthData, setOauthData] = useState<{
verificationUri: string;
userCode: string;
expiresIn: number;
} | null>(null);
const [oauthError, setOauthError] = useState<string | null>(null);
// Manage OAuth events
useEffect(() => {
const handleCode = (data: unknown) => {
setOauthData(data as { verificationUri: string; userCode: string; expiresIn: number });
setOauthError(null);
};
const handleSuccess = async () => {
setOauthFlowing(false);
setOauthData(null);
setKeyValid(true);
if (selectedProvider) {
try {
await window.electron.ipcRenderer.invoke('provider:setDefault', selectedProvider);
} catch (error) {
console.error('Failed to set default provider:', error);
}
}
onConfiguredChange(true);
toast.success(t('provider.valid'));
};
const handleError = (data: unknown) => {
setOauthError((data as { message: string }).message);
setOauthData(null);
};
window.electron.ipcRenderer.on('oauth:code', handleCode);
window.electron.ipcRenderer.on('oauth:success', handleSuccess);
window.electron.ipcRenderer.on('oauth:error', handleError);
return () => {
// Clean up manually if the API provides removeListener, though `on` in preloads might not return an unsub.
// Easiest is to just let it be, or if they have `off`:
if (typeof window.electron.ipcRenderer.off === 'function') {
window.electron.ipcRenderer.off('oauth:code', handleCode);
window.electron.ipcRenderer.off('oauth:success', handleSuccess);
window.electron.ipcRenderer.off('oauth:error', handleError);
}
};
}, [onConfiguredChange, t, selectedProvider]);
const handleStartOAuth = async () => {
if (!selectedProvider) return;
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);
} catch (e) {
setOauthError(String(e));
setOauthFlowing(false);
}
};
const handleCancelOAuth = async () => {
setOauthFlowing(false);
setOauthData(null);
setOauthError(null);
await window.electron.ipcRenderer.invoke('provider:cancelOAuth');
};
// On mount, try to restore previously configured provider // On mount, try to restore previously configured provider
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -819,6 +898,9 @@ function ProviderContent({
const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false; const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false;
const showModelIdField = selectedProviderData?.showModelId ?? false; const showModelIdField = selectedProviderData?.showModelId ?? false;
const requiresKey = selectedProviderData?.requiresApiKey ?? false; const requiresKey = selectedProviderData?.requiresApiKey ?? false;
const isOAuth = selectedProviderData?.isOAuth ?? false;
const supportsApiKey = selectedProviderData?.supportsApiKey ?? false;
const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth');
const handleValidateAndSave = async () => { const handleValidateAndSave = async () => {
if (!selectedProvider) return; if (!selectedProvider) return;
@@ -904,7 +986,8 @@ function ProviderContent({
const canSubmit = const canSubmit =
selectedProvider selectedProvider
&& (requiresKey ? apiKey.length > 0 : true) && (requiresKey ? apiKey.length > 0 : true)
&& (showModelIdField ? modelId.trim().length > 0 : true); && (showModelIdField ? modelId.trim().length > 0 : true)
&& !useOAuthFlow;
const handleSelectProvider = (providerId: string) => { const handleSelectProvider = (providerId: string) => {
onSelectProvider(providerId); onSelectProvider(providerId);
@@ -913,6 +996,7 @@ function ProviderContent({
onApiKeyChange(''); onApiKeyChange('');
setKeyValid(null); setKeyValid(null);
setProviderMenuOpen(false); setProviderMenuOpen(false);
setAuthMode('oauth');
}; };
return ( return (
@@ -1047,8 +1131,32 @@ function ProviderContent({
</div> </div>
)} )}
{/* Auth mode toggle for providers supporting both */}
{isOAuth && supportsApiKey && (
<div className="flex rounded-lg border overflow-hidden text-sm">
<button
onClick={() => setAuthMode('oauth')}
className={cn(
'flex-1 py-2 px-3 transition-colors',
authMode === 'oauth' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground'
)}
>
{t('settings:aiProviders.oauth.loginMode')}
</button>
<button
onClick={() => setAuthMode('apikey')}
className={cn(
'flex-1 py-2 px-3 transition-colors',
authMode === 'apikey' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground'
)}
>
{t('settings:aiProviders.oauth.apikeyMode')}
</button>
</div>
)}
{/* API Key field (hidden for ollama) */} {/* API Key field (hidden for ollama) */}
{requiresKey && ( {(!isOAuth || (supportsApiKey && authMode === 'apikey')) && requiresKey && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="apiKey">{t('provider.apiKey')}</Label> <Label htmlFor="apiKey">{t('provider.apiKey')}</Label>
<div className="relative"> <div className="relative">
@@ -1076,11 +1184,104 @@ function ProviderContent({
</div> </div>
)} )}
{/* Device OAuth Trigger */}
{useOAuthFlow && (
<div className="space-y-4 pt-2">
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4 text-center">
<p className="text-sm text-blue-200 mb-3 block">
This provider requires signing in via your browser.
</p>
<Button
onClick={handleStartOAuth}
disabled={oauthFlowing}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{oauthFlowing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Waiting...</>
) : (
'Login with Browser'
)}
</Button>
</div>
{/* OAuth Active State Modal / Inline View */}
{oauthFlowing && (
<div className="mt-4 p-4 border rounded-xl bg-card relative overflow-hidden">
{/* Background pulse effect */}
<div className="absolute inset-0 bg-primary/5 animate-pulse" />
<div className="relative z-10 flex flex-col items-center justify-center text-center space-y-4">
{oauthError ? (
<div className="text-red-400 space-y-2">
<XCircle className="h-8 w-8 mx-auto" />
<p className="font-medium">Authentication Failed</p>
<p className="text-sm opacity-80">{oauthError}</p>
<Button variant="outline" size="sm" onClick={handleCancelOAuth} className="mt-2">
Try Again
</Button>
</div>
) : !oauthData ? (
<div className="space-y-3 py-4">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
<p className="text-sm text-muted-foreground animate-pulse">Requesting secure login code...</p>
</div>
) : (
<div className="space-y-4 w-full">
<div className="space-y-1">
<h3 className="font-medium text-lg">Approve Login</h3>
<div className="text-sm text-muted-foreground text-left mt-2 space-y-1">
<p>1. Copy the authorization code below.</p>
<p>2. Open the login page in your browser.</p>
<p>3. Paste the code to approve access.</p>
</div>
</div>
<div className="flex items-center justify-center gap-2 p-3 bg-background border rounded-lg">
<code className="text-2xl font-mono tracking-widest font-bold text-primary">
{oauthData.userCode}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => {
navigator.clipboard.writeText(oauthData.userCode);
toast.success('Code copied to clipboard');
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<Button
variant="secondary"
className="w-full"
onClick={() => window.electron.ipcRenderer.invoke('shell:openExternal', oauthData.verificationUri)}
>
<ExternalLink className="h-4 w-4 mr-2" />
Open Login Page
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground pt-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Waiting for approval in browser...</span>
</div>
<Button variant="ghost" size="sm" className="w-full mt-2" onClick={handleCancelOAuth}>
Cancel
</Button>
</div>
)}
</div>
</div>
)}
</div>
)}
{/* Validate & Save */} {/* Validate & Save */}
<Button <Button
onClick={handleValidateAndSave} onClick={handleValidateAndSave}
disabled={!canSubmit || validating} disabled={!canSubmit || validating}
className="w-full" className={cn("w-full", useOAuthFlow && "hidden")}
> >
{validating ? ( {validating ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" /> <Loader2 className="h-4 w-4 animate-spin mr-2" />