diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index 50de6cf..d4e1519 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -14,185 +14,22 @@ import { } from '../../utils/secure-storage'; import { getProviderConfig, - getProviderDefaultModel, } from '../../utils/provider-registry'; -import { - removeProviderFromOpenClaw, - saveProviderKeyToOpenClaw, - setOpenClawDefaultModel, - setOpenClawDefaultModelWithOverride, - syncProviderConfigToOpenClaw, - updateAgentModelProvider, -} from '../../utils/openclaw-auth'; import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; -import { proxyAwareFetch } from '../../utils/proxy-fetch'; - -function getOpenClawProviderKey(type: string, providerId: string): string { - if (type === 'custom' || type === 'ollama') { - const suffix = providerId.replace(/-/g, '').slice(0, 8); - return `${type}-${suffix}`; - } - if (type === 'minimax-portal-cn') { - return 'minimax-portal'; - } - return type; -} - -function getProviderModelRef(config: ProviderConfig): string | undefined { - const providerKey = getOpenClawProviderKey(config.type, config.id); - if (config.model) { - return config.model.startsWith(`${providerKey}/`) - ? config.model - : `${providerKey}/${config.model}`; - } - return getProviderDefaultModel(config.type); -} - -async function getProviderFallbackModelRefs(config: ProviderConfig): Promise { - const allProviders = await getAllProvidersWithKeyInfo(); - const providerMap = new Map(allProviders.map((provider) => [provider.id, provider])); - const seen = new Set(); - const results: string[] = []; - const providerKey = getOpenClawProviderKey(config.type, config.id); - - for (const fallbackModel of config.fallbackModels ?? []) { - const normalizedModel = fallbackModel.trim(); - if (!normalizedModel) continue; - const modelRef = normalizedModel.startsWith(`${providerKey}/`) - ? normalizedModel - : `${providerKey}/${normalizedModel}`; - if (seen.has(modelRef)) continue; - seen.add(modelRef); - results.push(modelRef); - } - - for (const fallbackId of config.fallbackProviderIds ?? []) { - if (!fallbackId || fallbackId === config.id) continue; - const fallbackProvider = providerMap.get(fallbackId); - if (!fallbackProvider) continue; - const modelRef = getProviderModelRef(fallbackProvider); - if (!modelRef || seen.has(modelRef)) continue; - seen.add(modelRef); - results.push(modelRef); - } - - return results; -} - -type ValidationProfile = 'openai-compatible' | 'google-query-key' | 'anthropic-header' | 'openrouter' | 'none'; - -function getValidationProfile(providerType: string): ValidationProfile { - switch (providerType) { - case 'anthropic': - return 'anthropic-header'; - case 'google': - return 'google-query-key'; - case 'openrouter': - return 'openrouter'; - case 'ollama': - return 'none'; - default: - return 'openai-compatible'; - } -} - -function normalizeBaseUrl(baseUrl: string): string { - return baseUrl.trim().replace(/\/+$/, ''); -} - -function classifyAuthResponse(status: number, data: unknown): { valid: boolean; error?: string } { - if (status >= 200 && status < 300) return { valid: true }; - if (status === 429) return { valid: true }; - if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' }; - const obj = data as { error?: { message?: string }; message?: string } | null; - return { valid: false, error: obj?.error?.message || obj?.message || `API error: ${status}` }; -} - -async function performProviderValidationRequest( - url: string, - headers: Record, -): Promise<{ valid: boolean; error?: string }> { - try { - const response = await proxyAwareFetch(url, { headers }); - const data = await response.json().catch(() => ({})); - return classifyAuthResponse(response.status, data); - } catch (error) { - return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` }; - } -} - -async function performChatCompletionsProbe( - url: string, - headers: Record, -): Promise<{ valid: boolean; error?: string }> { - try { - const response = await proxyAwareFetch(url, { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'validation-probe', - messages: [{ role: 'user', content: 'hi' }], - max_tokens: 1, - }), - }); - const data = await response.json().catch(() => ({})); - if (response.status === 401 || response.status === 403) { - return { valid: false, error: 'Invalid API key' }; - } - if ((response.status >= 200 && response.status < 300) || response.status === 400 || response.status === 429) { - return { valid: true }; - } - return classifyAuthResponse(response.status, data); - } catch (error) { - return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` }; - } -} - -async function validateApiKeyWithProvider( - providerType: string, - apiKey: string, - options?: { baseUrl?: string }, -): Promise<{ valid: boolean; error?: string }> { - const profile = getValidationProfile(providerType); - if (profile === 'none') return { valid: true }; - const trimmedKey = apiKey.trim(); - if (!trimmedKey) return { valid: false, error: 'API key is required' }; - - switch (profile) { - case 'openai-compatible': { - const trimmedBaseUrl = options?.baseUrl?.trim(); - if (!trimmedBaseUrl) { - return { valid: false, error: `Base URL is required for provider "${providerType}" validation` }; - } - const headers = { Authorization: `Bearer ${trimmedKey}` }; - const modelsUrl = `${normalizeBaseUrl(trimmedBaseUrl)}/models?limit=1`; - const modelsResult = await performProviderValidationRequest(modelsUrl, headers); - if (modelsResult.error?.includes('API error: 404')) { - return performChatCompletionsProbe(`${normalizeBaseUrl(trimmedBaseUrl)}/chat/completions`, headers); - } - return modelsResult; - } - case 'google-query-key': { - const base = normalizeBaseUrl(options?.baseUrl || 'https://generativelanguage.googleapis.com/v1beta'); - return performProviderValidationRequest(`${base}/models?pageSize=1&key=${encodeURIComponent(trimmedKey)}`, {}); - } - case 'anthropic-header': { - const base = normalizeBaseUrl(options?.baseUrl || 'https://api.anthropic.com/v1'); - return performProviderValidationRequest(`${base}/models?limit=1`, { - 'x-api-key': trimmedKey, - 'anthropic-version': '2023-06-01', - }); - } - case 'openrouter': - return performProviderValidationRequest('https://openrouter.ai/api/v1/auth/key', { - Authorization: `Bearer ${trimmedKey}`, - }); - default: - return { valid: false, error: `Unsupported provider validation profile: ${providerType}` }; - } -} +import { + syncDefaultProviderToRuntime, + syncDeletedProviderApiKeyToRuntime, + syncDeletedProviderToRuntime, + syncProviderApiKeyToRuntime, + syncSavedProviderToRuntime, + syncUpdatedProviderToRuntime, +} from '../../services/providers/provider-runtime-sync'; +import { validateApiKeyWithProvider } from '../../services/providers/provider-validation'; +import { getProviderService } from '../../services/providers/provider-service'; +import { providerAccountToConfig } from '../../services/providers/provider-store'; +import type { ProviderAccount } from '../../shared/providers/types'; export async function handleProviderRoutes( req: IncomingMessage, @@ -200,6 +37,90 @@ export async function handleProviderRoutes( url: URL, ctx: HostApiContext, ): Promise { + const providerService = getProviderService(); + + if (url.pathname === '/api/provider-vendors' && req.method === 'GET') { + sendJson(res, 200, await providerService.listVendors()); + return true; + } + + if (url.pathname === '/api/provider-accounts' && req.method === 'GET') { + sendJson(res, 200, await providerService.listAccounts()); + return true; + } + + if (url.pathname === '/api/provider-accounts' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req); + const account = await providerService.createAccount(body.account, body.apiKey); + await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager); + sendJson(res, 200, { success: true, account }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') { + sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null }); + return true; + } + + if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') { + try { + const body = await parseJsonBody<{ accountId: string }>(req); + await providerService.setDefaultAccount(body.accountId); + await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') { + const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length)); + sendJson(res, 200, await providerService.getAccount(accountId)); + return true; + } + + if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') { + const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length)); + try { + const body = await parseJsonBody<{ updates: Partial; apiKey?: string }>(req); + const existing = await providerService.getAccount(accountId); + if (!existing) { + sendJson(res, 404, { success: false, error: 'Provider account not found' }); + return true; + } + const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey); + await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager); + sendJson(res, 200, { success: true, account: nextAccount }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') { + const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length)); + try { + const existing = await providerService.getAccount(accountId); + if (url.searchParams.get('apiKeyOnly') === '1') { + await providerService.deleteLegacyProviderApiKey(accountId); + await syncDeletedProviderApiKeyToRuntime(existing ? providerAccountToConfig(existing) : null, accountId); + sendJson(res, 200, { success: true }); + return true; + } + await providerService.deleteAccount(accountId); + await syncDeletedProviderToRuntime(existing ? providerAccountToConfig(existing) : null, accountId, ctx.gatewayManager); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + if (url.pathname === '/api/providers' && req.method === 'GET') { sendJson(res, 200, await getAllProvidersWithKeyInfo()); return true; @@ -214,50 +135,7 @@ export async function handleProviderRoutes( try { const body = await parseJsonBody<{ providerId: string }>(req); await setDefaultProvider(body.providerId); - const provider = await getProvider(body.providerId); - if (provider) { - const ock = getOpenClawProviderKey(provider.type, body.providerId); - const providerKey = await getApiKey(body.providerId); - const fallbackModels = await getProviderFallbackModelRefs(provider); - const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; - const isOAuthProvider = oauthTypes.includes(provider.type) && !providerKey; - if (!isOAuthProvider) { - const modelOverride = provider.model - ? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`) - : undefined; - if (provider.type === 'custom' || provider.type === 'ollama') { - await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: provider.baseUrl, - api: 'openai-completions', - }, fallbackModels); - } else { - await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); - } - if (providerKey) { - await saveProviderKeyToOpenClaw(ock, providerKey); - } - } else { - const defaultBaseUrl = provider.type === 'minimax-portal' - ? 'https://api.minimax.io/anthropic' - : (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1'); - let baseUrl = provider.baseUrl || defaultBaseUrl; - if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) { - baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; - } - const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') - ? 'minimax-portal' - : provider.type; - await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), { - baseUrl, - api: targetProviderKey === 'minimax-portal' ? 'anthropic-messages' : 'openai-completions', - authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, - apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', - }, fallbackModels); - } - if (ctx.gatewayManager.getStatus().state !== 'stopped') { - ctx.gatewayManager.debouncedRestart(); - } - } + await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -281,8 +159,16 @@ export async function handleProviderRoutes( if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') { try { - const body = await parseJsonBody<{ provider: OAuthProviderType; region?: 'global' | 'cn' }>(req); - await deviceOAuthManager.startFlow(body.provider, body.region); + const body = await parseJsonBody<{ + provider: OAuthProviderType; + region?: 'global' | 'cn'; + accountId?: string; + label?: string; + }>(req); + await deviceOAuthManager.startFlow(body.provider, body.region, { + accountId: body.accountId, + label: body.label, + }); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -305,37 +191,14 @@ export async function handleProviderRoutes( const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req); const config = body.config; await saveProvider(config); - const ock = getOpenClawProviderKey(config.type, config.id); if (body.apiKey !== undefined) { const trimmedKey = body.apiKey.trim(); if (trimmedKey) { await storeApiKey(config.id, trimmedKey); - await saveProviderKeyToOpenClaw(ock, trimmedKey); + await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey); } } - const meta = getProviderConfig(config.type); - const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; - if (api) { - await syncProviderConfigToOpenClaw(ock, config.model, { - baseUrl: config.baseUrl || meta?.baseUrl, - api, - apiKeyEnv: meta?.apiKeyEnv, - headers: meta?.headers, - }); - if (config.type === 'custom' || config.type === 'ollama') { - const resolvedKey = body.apiKey !== undefined ? (body.apiKey.trim() || null) : await getApiKey(config.id); - if (resolvedKey && config.baseUrl) { - const modelId = config.model; - await updateAgentModelProvider(ock, { - baseUrl: config.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: resolvedKey, - }); - } - } - ctx.gatewayManager.debouncedRestart(); - } + await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -369,42 +232,18 @@ export async function handleProviderRoutes( return true; } const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() }; - const ock = getOpenClawProviderKey(nextConfig.type, providerId); await saveProvider(nextConfig); if (body.apiKey !== undefined) { const trimmedKey = body.apiKey.trim(); if (trimmedKey) { await storeApiKey(providerId, trimmedKey); - await saveProviderKeyToOpenClaw(ock, trimmedKey); + await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey); } else { await deleteApiKey(providerId); - await removeProviderFromOpenClaw(ock); + await syncDeletedProviderApiKeyToRuntime(existing, providerId); } } - const fallbackModels = await getProviderFallbackModelRefs(nextConfig); - const meta = getProviderConfig(nextConfig.type); - const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api; - if (api) { - await syncProviderConfigToOpenClaw(ock, nextConfig.model, { - baseUrl: nextConfig.baseUrl || meta?.baseUrl, - api, - apiKeyEnv: meta?.apiKeyEnv, - headers: meta?.headers, - }); - const defaultProviderId = await getDefaultProvider(); - if (defaultProviderId === providerId) { - const modelOverride = nextConfig.model ? `${ock}/${nextConfig.model}` : undefined; - if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') { - await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); - } else { - await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: nextConfig.baseUrl, - api: 'openai-completions', - }, fallbackModels); - } - } - ctx.gatewayManager.debouncedRestart(); - } + await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -418,17 +257,12 @@ export async function handleProviderRoutes( const existing = await getProvider(providerId); if (url.searchParams.get('apiKeyOnly') === '1') { await deleteApiKey(providerId); - if (existing?.type) { - await removeProviderFromOpenClaw(getOpenClawProviderKey(existing.type, providerId)); - } + await syncDeletedProviderApiKeyToRuntime(existing, providerId); sendJson(res, 200, { success: true }); return true; } await deleteProvider(providerId); - if (existing?.type) { - await removeProviderFromOpenClaw(getOpenClawProviderKey(existing.type, providerId)); - ctx.gatewayManager.debouncedRestart(); - } + await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); diff --git a/electron/main/index.ts b/electron/main/index.ts index 55e825d..ec067c8 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -269,8 +269,8 @@ async function initialize(): Promise { hostEventBus.emit('oauth:start', payload); }); - deviceOAuthManager.on('oauth:success', (provider) => { - hostEventBus.emit('oauth:success', { provider, success: true }); + deviceOAuthManager.on('oauth:success', (payload) => { + hostEventBus.emit('oauth:success', { ...payload, success: true }); }); deviceOAuthManager.on('oauth:error', (error) => { diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 2db3489..25b547d 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -10,17 +10,6 @@ import crypto from 'node:crypto'; import { GatewayManager } from '../gateway/manager'; import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub'; import { - storeApiKey, - getApiKey, - deleteApiKey, - hasApiKey, - saveProvider, - getProvider, - getAllProviders, - deleteProvider, - setDefaultProvider, - getDefaultProvider, - getAllProvidersWithKeyInfo, type ProviderConfig, } from '../utils/secure-storage'; import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir, ensureDir } from '../utils/paths'; @@ -29,10 +18,6 @@ import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings import { saveProviderKeyToOpenClaw, removeProviderFromOpenClaw, - setOpenClawDefaultModel, - setOpenClawDefaultModelWithOverride, - syncProviderConfigToOpenClaw, - updateAgentModelProvider, } from '../utils/openclaw-auth'; import { logger } from '../utils/logger'; import { @@ -49,80 +34,20 @@ import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-set import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { getProviderConfig } from '../utils/provider-registry'; -import { getProviderDefaultModel } from '../utils/provider-registry'; import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; import { applyProxySettings } from './proxy'; -import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; import { getProviderService } from '../services/providers/provider-service'; - -/** - * For custom/ollama providers, derive a unique key for OpenClaw config files - * so that multiple instances of the same type don't overwrite each other. - * For all other providers the key is simply the provider type. - * - * @param type - Provider type (e.g. 'custom', 'ollama', 'openrouter') - * @param providerId - Unique provider ID from secure-storage (UUID-like) - * @returns A string like 'custom-a1b2c3d4' or 'openrouter' - */ -export function getOpenClawProviderKey(type: string, providerId: string): string { - if (type === 'custom' || type === 'ollama') { - const suffix = providerId.replace(/-/g, '').slice(0, 8); - return `${type}-${suffix}`; - } - if (type === 'minimax-portal-cn') { - return 'minimax-portal'; - } - return type; -} - -function getProviderModelRef(config: ProviderConfig): string | undefined { - const providerKey = getOpenClawProviderKey(config.type, config.id); - - if (config.model) { - return config.model.startsWith(`${providerKey}/`) - ? config.model - : `${providerKey}/${config.model}`; - } - - return getProviderDefaultModel(config.type); -} - -async function getProviderFallbackModelRefs(config: ProviderConfig): Promise { - const allProviders = await getAllProviders(); - const providerMap = new Map(allProviders.map((provider) => [provider.id, provider])); - const seen = new Set(); - const results: string[] = []; - const providerKey = getOpenClawProviderKey(config.type, config.id); - - for (const fallbackModel of config.fallbackModels ?? []) { - const normalizedModel = fallbackModel.trim(); - if (!normalizedModel) continue; - - const modelRef = normalizedModel.startsWith(`${providerKey}/`) - ? normalizedModel - : `${providerKey}/${normalizedModel}`; - - if (seen.has(modelRef)) continue; - seen.add(modelRef); - results.push(modelRef); - } - - for (const fallbackId of config.fallbackProviderIds ?? []) { - if (!fallbackId || fallbackId === config.id) continue; - - const fallbackProvider = providerMap.get(fallbackId); - if (!fallbackProvider) continue; - - const modelRef = getProviderModelRef(fallbackProvider); - if (!modelRef || seen.has(modelRef)) continue; - - seen.add(modelRef); - results.push(modelRef); - } - - return results; -} +import { + getOpenClawProviderKey, + syncDefaultProviderToRuntime, + syncDeletedProviderApiKeyToRuntime, + syncDeletedProviderToRuntime, + syncProviderApiKeyToRuntime, + syncSavedProviderToRuntime, + syncUpdatedProviderToRuntime, +} from '../services/providers/provider-runtime-sync'; +import { validateApiKeyWithProvider } from '../services/providers/provider-validation'; /** * Register all IPC handlers @@ -969,16 +894,24 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { deviceOAuthManager.setWindow(mainWindow); // Request Provider OAuth initialization - ipcMain.handle('provider:requestOAuth', async (_, provider: OAuthProviderType, region?: 'global' | 'cn') => { + ipcMain.handle( + 'provider:requestOAuth', + async ( + _, + provider: OAuthProviderType, + region?: 'global' | 'cn', + options?: { accountId?: string; label?: string }, + ) => { try { logger.info(`provider:requestOAuth for ${provider}`); - await deviceOAuthManager.startFlow(provider, region); + await deviceOAuthManager.startFlow(provider, region, options); 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 () => { @@ -1003,14 +936,14 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // and then calls debouncedRestart(2s) — has time to fire and coalesce into a single // restart. Without this, the OAuth restart fires first with stale config, and the // subsequent provider:setDefault restart is deferred and dropped. - deviceOAuthManager.on('oauth:success', (providerType) => { - logger.info(`[IPC] Scheduling Gateway restart after ${providerType} OAuth success...`); + deviceOAuthManager.on('oauth:success', ({ provider, accountId }) => { + logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`); gatewayManager.debouncedRestart(8000); }); // Get all providers with key info ipcMain.handle('provider:list', async () => { - return await getAllProvidersWithKeyInfo(); + return await providerService.listLegacyProvidersWithKeyInfo(); }); // New provider-service endpoints used by the account-based refactor. @@ -1028,27 +961,24 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Get a specific provider ipcMain.handle('provider:get', async (_, providerId: string) => { - return await getProvider(providerId); + return await providerService.getLegacyProvider(providerId); }); // Save a provider configuration ipcMain.handle('provider:save', async (_, config: ProviderConfig, apiKey?: string) => { try { // Save the provider config - await saveProvider(config); - - // Derive the unique OpenClaw key for this provider instance - const ock = getOpenClawProviderKey(config.type, config.id); + await providerService.saveLegacyProvider(config); // Store the API key if provided if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { - await storeApiKey(config.id, trimmedKey); + await providerService.setLegacyProviderApiKey(config.id, trimmedKey); // Also write to OpenClaw auth-profiles.json so the gateway can use it try { - await saveProviderKeyToOpenClaw(ock, trimmedKey); + await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey); } catch (err) { console.warn('Failed to save key to OpenClaw auth-profiles:', err); } @@ -1057,37 +987,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Sync the provider configuration to openclaw.json so Gateway knows about it try { - const meta = getProviderConfig(config.type); - const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; - - if (api) { - await syncProviderConfigToOpenClaw(ock, config.model, { - baseUrl: config.baseUrl || meta?.baseUrl, - api, - apiKeyEnv: meta?.apiKeyEnv, - headers: meta?.headers, - }); - - if (config.type === 'custom' || config.type === 'ollama') { - const resolvedKey = apiKey !== undefined - ? (apiKey.trim() || null) - : await getApiKey(config.id); - if (resolvedKey && config.baseUrl) { - const modelId = config.model; - await updateAgentModelProvider(ock, { - baseUrl: config.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: resolvedKey, - }); - } - } - - // Debounced restart so the gateway picks up new config/env vars. - // Multiple rapid provider saves (e.g. during setup) are coalesced. - logger.info(`Scheduling Gateway restart after saving provider "${ock}" config`); - gatewayManager.debouncedRestart(); - } + await syncSavedProviderToRuntime(config, apiKey, gatewayManager); } catch (err) { console.warn('Failed to sync openclaw provider config:', err); } @@ -1101,18 +1001,13 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Delete a provider ipcMain.handle('provider:delete', async (_, providerId: string) => { try { - const existing = await getProvider(providerId); - await deleteProvider(providerId); + const existing = await providerService.getLegacyProvider(providerId); + await providerService.deleteLegacyProvider(providerId); // Best-effort cleanup in OpenClaw auth profiles & openclaw.json config if (existing?.type) { try { - const ock = getOpenClawProviderKey(existing.type, providerId); - await removeProviderFromOpenClaw(ock); - - // Debounced restart so the gateway stops loading the deleted provider. - logger.info(`Scheduling Gateway restart after deleting provider "${ock}"`); - gatewayManager.debouncedRestart(); + await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); } @@ -1127,14 +1022,13 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Update API key for a provider ipcMain.handle('provider:setApiKey', async (_, providerId: string, apiKey: string) => { try { - await storeApiKey(providerId, apiKey); + await providerService.setLegacyProviderApiKey(providerId, apiKey); // Also write to OpenClaw auth-profiles.json - const provider = await getProvider(providerId); + const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; - const ock = getOpenClawProviderKey(providerType, providerId); try { - await saveProviderKeyToOpenClaw(ock, apiKey); + await syncProviderApiKeyToRuntime(providerType, providerId, apiKey); } catch (err) { console.warn('Failed to save key to OpenClaw auth-profiles:', err); } @@ -1154,12 +1048,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { updates: Partial, apiKey?: string ) => { - const existing = await getProvider(providerId); + const existing = await providerService.getLegacyProvider(providerId); if (!existing) { return { success: false, error: 'Provider not found' }; } - const previousKey = await getApiKey(providerId); + const previousKey = await providerService.getLegacyProviderApiKey(providerId); const previousOck = getOpenClawProviderKey(existing.type, providerId); try { @@ -1171,68 +1065,22 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const ock = getOpenClawProviderKey(nextConfig.type, providerId); - await saveProvider(nextConfig); + await providerService.saveLegacyProvider(nextConfig); if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { - await storeApiKey(providerId, trimmedKey); - await saveProviderKeyToOpenClaw(ock, trimmedKey); + await providerService.setLegacyProviderApiKey(providerId, trimmedKey); + await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey); } else { - await deleteApiKey(providerId); + await providerService.deleteLegacyProviderApiKey(providerId); await removeProviderFromOpenClaw(ock); } } // Sync the provider configuration to openclaw.json so Gateway knows about it try { - const fallbackModels = await getProviderFallbackModelRefs(nextConfig); - const meta = getProviderConfig(nextConfig.type); - const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api; - - if (api) { - await syncProviderConfigToOpenClaw(ock, nextConfig.model, { - baseUrl: nextConfig.baseUrl || meta?.baseUrl, - api, - apiKeyEnv: meta?.apiKeyEnv, - headers: meta?.headers, - }); - - if (nextConfig.type === 'custom' || nextConfig.type === 'ollama') { - const resolvedKey = apiKey !== undefined - ? (apiKey.trim() || null) - : await getApiKey(providerId); - if (resolvedKey && nextConfig.baseUrl) { - const modelId = nextConfig.model; - await updateAgentModelProvider(ock, { - baseUrl: nextConfig.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: resolvedKey, - }); - } - } - } - - // If this provider is the current default, update the primary model - const defaultProviderId = await getDefaultProvider(); - if (defaultProviderId === providerId) { - const modelOverride = nextConfig.model - ? `${ock}/${nextConfig.model}` - : undefined; - if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') { - await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); - } else { - await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: nextConfig.baseUrl, - api: 'openai-completions', - }, fallbackModels); - } - } - - // Debounced restart so the gateway picks up updated config/env vars. - logger.info(`Scheduling Gateway restart after updating provider "${ock}" config`); - gatewayManager.debouncedRestart(); + await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager); } catch (err) { console.warn('Failed to sync openclaw config after provider update:', err); } @@ -1241,12 +1089,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { } catch (error) { // Best-effort rollback to keep config/key consistent. try { - await saveProvider(existing); + await providerService.saveLegacyProvider(existing); if (previousKey) { - await storeApiKey(providerId, previousKey); + await providerService.setLegacyProviderApiKey(providerId, previousKey); await saveProviderKeyToOpenClaw(previousOck, previousKey); } else { - await deleteApiKey(providerId); + await providerService.deleteLegacyProviderApiKey(providerId); await removeProviderFromOpenClaw(previousOck); } } catch (rollbackError) { @@ -1261,16 +1109,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Delete API key for a provider ipcMain.handle('provider:deleteApiKey', async (_, providerId: string) => { try { - await deleteApiKey(providerId); + await providerService.deleteLegacyProviderApiKey(providerId); // Keep OpenClaw auth-profiles.json in sync with local key storage - const provider = await getProvider(providerId); - const providerType = provider?.type || providerId; - const ock = getOpenClawProviderKey(providerType, providerId); + const provider = await providerService.getLegacyProvider(providerId); try { - if (ock) { - await removeProviderFromOpenClaw(ock); - } + await syncDeletedProviderApiKeyToRuntime(provider, providerId); } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); } @@ -1283,126 +1127,24 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Check if a provider has an API key ipcMain.handle('provider:hasApiKey', async (_, providerId: string) => { - return await hasApiKey(providerId); + return await providerService.hasLegacyProviderApiKey(providerId); }); // Get the actual API key (for internal use only - be careful!) ipcMain.handle('provider:getApiKey', async (_, providerId: string) => { - return await getApiKey(providerId); + return await providerService.getLegacyProviderApiKey(providerId); }); // Set default provider and update OpenClaw default model ipcMain.handle('provider:setDefault', async (_, providerId: string) => { try { - await setDefaultProvider(providerId); + await providerService.setDefaultLegacyProvider(providerId); // Update OpenClaw config to use this provider's default model - const provider = await getProvider(providerId); - if (provider) { - try { - const ock = getOpenClawProviderKey(provider.type, providerId); - const providerKey = await getApiKey(providerId); - const fallbackModels = await getProviderFallbackModelRefs(provider); - - // 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', 'minimax-portal-cn']; - const isOAuthProvider = OAUTH_PROVIDER_TYPES.includes(provider.type) && !providerKey; - - if (!isOAuthProvider) { - // Build the full model string: "openclawKey/modelId" - const modelOverride = provider.model - ? (provider.model.startsWith(`${ock}/`) - ? provider.model - : `${ock}/${provider.model}`) - : undefined; - - if (provider.type === 'custom' || provider.type === 'ollama') { - await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: provider.baseUrl, - api: 'openai-completions', - }, fallbackModels); - } else { - await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); - } - - // Keep auth-profiles in sync with the default provider instance. - if (providerKey) { - await saveProviderKeyToOpenClaw(ock, providerKey); - } - } else { - // OAuth providers (minimax-portal, minimax-portal-cn, qwen-portal) - const defaultBaseUrl = provider.type === 'minimax-portal' - ? 'https://api.minimax.io/anthropic' - : (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' || provider.type === 'minimax-portal-cn') && baseUrl) { - baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; - } - - // 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; - - await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), { - baseUrl, - api, - authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, - // Relies on OpenClaw Gateway native auth-profiles syncing - apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', - }, fallbackModels); - - logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`); - - // Also write models.json directly so pi-ai picks up the correct baseUrl and - // authHeader immediately, without waiting for Gateway to sync openclaw.json. - try { - const defaultModelId = provider.model?.split('/').pop(); - await updateAgentModelProvider(targetProviderKey, { - baseUrl, - api, - authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, - apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', - models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [], - }); - } catch (err) { - logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err); - } - } - - // For custom/ollama providers, also update the per-agent models.json - if ( - (provider.type === 'custom' || provider.type === 'ollama') && - providerKey && - provider.baseUrl - ) { - const modelId = provider.model; - await updateAgentModelProvider(ock, { - baseUrl: provider.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: providerKey, - }); - } - - // Debounced restart so the gateway picks up the new default provider. - // Because OAuth success triggers a debounced restart, the gateway might not be - // currently connected ('starting' or 'reconnecting'). Checking if it is simply - // not 'stopped' ensures the restart request is correctly queued or coalesced. - if (gatewayManager.getStatus().state !== 'stopped') { - logger.info(`Scheduling Gateway restart after provider switch to "${ock}"`); - gatewayManager.debouncedRestart(); - } - } catch (err) { - console.warn('Failed to set OpenClaw default model:', err); - } + try { + await syncDefaultProviderToRuntime(providerId, gatewayManager); + } catch (err) { + console.warn('Failed to set OpenClaw default model:', err); } return { success: true }; @@ -1415,7 +1157,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Get default provider ipcMain.handle('provider:getDefault', async () => { - return await getDefaultProvider(); + return await providerService.getDefaultLegacyProvider(); }); // Validate API key by making a real test request to the provider. @@ -1430,7 +1172,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { ) => { try { // First try to get existing provider - const provider = await getProvider(providerId); + const provider = await providerService.getLegacyProvider(providerId); // Use provider.type if provider exists, otherwise use providerId as the type // This allows validation during setup when provider hasn't been saved yet @@ -1450,266 +1192,6 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { ); } -type ValidationProfile = 'openai-compatible' | 'google-query-key' | 'anthropic-header' | 'openrouter' | 'none'; - -/** - * Validate API key using lightweight model-listing endpoints (zero token cost). - * Providers are grouped into 3 auth styles: - * - openai-compatible: Bearer auth + /models - * - google-query-key: ?key=... + /models - * - anthropic-header: x-api-key + anthropic-version + /models - */ -async function validateApiKeyWithProvider( - providerType: string, - apiKey: string, - options?: { baseUrl?: string } -): Promise<{ valid: boolean; error?: string }> { - const profile = getValidationProfile(providerType); - if (profile === 'none') { - return { valid: true }; - } - - const trimmedKey = apiKey.trim(); - if (!trimmedKey) { - return { valid: false, error: 'API key is required' }; - } - - try { - switch (profile) { - case 'openai-compatible': - return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl); - case 'google-query-key': - return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl); - case 'anthropic-header': - return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl); - case 'openrouter': - return await validateOpenRouterKey(providerType, trimmedKey); - default: - return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` }; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { valid: false, error: errorMessage }; - } -} - -function logValidationStatus(provider: string, status: number): void { - console.log(`[clawx-validate] ${provider} HTTP ${status}`); -} - -function maskSecret(secret: string): string { - if (!secret) return ''; - if (secret.length <= 8) return `${secret.slice(0, 2)}***`; - return `${secret.slice(0, 4)}***${secret.slice(-4)}`; -} - -function sanitizeValidationUrl(rawUrl: string): string { - try { - const url = new URL(rawUrl); - const key = url.searchParams.get('key'); - if (key) url.searchParams.set('key', maskSecret(key)); - return url.toString(); - } catch { - return rawUrl; - } -} - -function sanitizeHeaders(headers: Record): Record { - const next = { ...headers }; - if (next.Authorization?.startsWith('Bearer ')) { - const token = next.Authorization.slice('Bearer '.length); - next.Authorization = `Bearer ${maskSecret(token)}`; - } - if (next['x-api-key']) { - next['x-api-key'] = maskSecret(next['x-api-key']); - } - return next; -} - -function normalizeBaseUrl(baseUrl: string): string { - return baseUrl.trim().replace(/\/+$/, ''); -} - -function buildOpenAiModelsUrl(baseUrl: string): string { - return `${normalizeBaseUrl(baseUrl)}/models?limit=1`; -} - -function logValidationRequest( - provider: string, - method: string, - url: string, - headers: Record -): void { - console.log( - `[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}` - ); -} - -function getValidationProfile(providerType: string): ValidationProfile { - switch (providerType) { - case 'anthropic': - return 'anthropic-header'; - case 'google': - return 'google-query-key'; - case 'openrouter': - return 'openrouter'; - case 'ollama': - return 'none'; - default: - return 'openai-compatible'; - } -} - -async function performProviderValidationRequest( - providerLabel: string, - url: string, - headers: Record -): Promise<{ valid: boolean; error?: string }> { - try { - logValidationRequest(providerLabel, 'GET', url, headers); - const response = await proxyAwareFetch(url, { headers }); - logValidationStatus(providerLabel, response.status); - const data = await response.json().catch(() => ({})); - return classifyAuthResponse(response.status, data); - } catch (error) { - return { - valid: false, - error: `Connection error: ${error instanceof Error ? error.message : String(error)}`, - }; - } -} - -/** - * Helper: classify an HTTP response as valid / invalid / error. - * 200 / 429 → valid (key works, possibly rate-limited). - * 401 / 403 → invalid. - * Everything else → return the API error message. - */ -function classifyAuthResponse( - status: number, - data: unknown -): { valid: boolean; error?: string } { - if (status >= 200 && status < 300) return { valid: true }; - if (status === 429) return { valid: true }; // rate-limited but key is valid - if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' }; - - // Try to extract an error message - const obj = data as { error?: { message?: string }; message?: string } | null; - const msg = obj?.error?.message || obj?.message || `API error: ${status}`; - return { valid: false, error: msg }; -} - -async function validateOpenAiCompatibleKey( - providerType: string, - apiKey: string, - baseUrl?: string -): Promise<{ valid: boolean; error?: string }> { - const trimmedBaseUrl = baseUrl?.trim(); - if (!trimmedBaseUrl) { - return { valid: false, error: `Base URL is required for provider "${providerType}" validation` }; - } - - const headers = { Authorization: `Bearer ${apiKey}` }; - - // Try /models first (standard OpenAI-compatible endpoint) - const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl); - const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers); - - // If /models returned 404, the provider likely doesn't implement it (e.g. MiniMax). - // Fall back to a minimal /chat/completions POST which almost all providers support. - if (modelsResult.error?.includes('API error: 404')) { - console.log( - `[clawx-validate] ${providerType} /models returned 404, falling back to /chat/completions probe` - ); - const base = normalizeBaseUrl(trimmedBaseUrl); - const chatUrl = `${base}/chat/completions`; - return await performChatCompletionsProbe(providerType, chatUrl, headers); - } - - return modelsResult; -} - -/** - * Fallback validation: send a minimal /chat/completions request. - * We intentionally use max_tokens=1 to minimise cost. The goal is only to - * distinguish auth errors (401/403) from a working key (200/400/429). - * A 400 "invalid model" still proves the key itself is accepted. - */ -async function performChatCompletionsProbe( - providerLabel: string, - url: string, - headers: Record -): Promise<{ valid: boolean; error?: string }> { - try { - logValidationRequest(providerLabel, 'POST', url, headers); - const response = await proxyAwareFetch(url, { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'validation-probe', - messages: [{ role: 'user', content: 'hi' }], - max_tokens: 1, - }), - }); - logValidationStatus(providerLabel, response.status); - const data = await response.json().catch(() => ({})); - - // 401/403 → invalid key - if (response.status === 401 || response.status === 403) { - return { valid: false, error: 'Invalid API key' }; - } - // 200, 400 (bad model but key accepted), 429 → key is valid - if ( - (response.status >= 200 && response.status < 300) || - response.status === 400 || - response.status === 429 - ) { - return { valid: true }; - } - return classifyAuthResponse(response.status, data); - } catch (error) { - return { - valid: false, - error: `Connection error: ${error instanceof Error ? error.message : String(error)}`, - }; - } -} - -async function validateGoogleQueryKey( - providerType: string, - apiKey: string, - baseUrl?: string -): Promise<{ valid: boolean; error?: string }> { - // Default to the official Google Gemini API base URL if none is provided - const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta'); - const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`; - return await performProviderValidationRequest(providerType, url, {}); -} - -async function validateAnthropicHeaderKey( - providerType: string, - apiKey: string, - baseUrl?: string -): Promise<{ valid: boolean; error?: string }> { - const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1'); - const url = `${base}/models?limit=1`; - const headers = { - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }; - return await performProviderValidationRequest(providerType, url, headers); -} - -async function validateOpenRouterKey( - providerType: string, - apiKey: string -): Promise<{ valid: boolean; error?: string }> { - // Use OpenRouter's auth check endpoint instead of public /models - const url = 'https://openrouter.ai/api/v1/auth/key'; - const headers = { Authorization: `Bearer ${apiKey}` }; - return await performProviderValidationRequest(providerType, url, headers); -} - /** * Shell-related IPC handlers */ diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts new file mode 100644 index 0000000..967cb03 --- /dev/null +++ b/electron/services/providers/provider-runtime-sync.ts @@ -0,0 +1,322 @@ +import type { GatewayManager } from '../../gateway/manager'; +import type { ProviderConfig } from '../../utils/secure-storage'; +import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage'; +import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry'; +import { + removeProviderFromOpenClaw, + saveProviderKeyToOpenClaw, + setOpenClawDefaultModel, + setOpenClawDefaultModelWithOverride, + syncProviderConfigToOpenClaw, + updateAgentModelProvider, +} from '../../utils/openclaw-auth'; +import { logger } from '../../utils/logger'; + +export function getOpenClawProviderKey(type: string, providerId: string): string { + if (type === 'custom' || type === 'ollama') { + const suffix = providerId.replace(/-/g, '').slice(0, 8); + return `${type}-${suffix}`; + } + if (type === 'minimax-portal-cn') { + return 'minimax-portal'; + } + return type; +} + +export function getProviderModelRef(config: ProviderConfig): string | undefined { + const providerKey = getOpenClawProviderKey(config.type, config.id); + + if (config.model) { + return config.model.startsWith(`${providerKey}/`) + ? config.model + : `${providerKey}/${config.model}`; + } + + return getProviderDefaultModel(config.type); +} + +export async function getProviderFallbackModelRefs(config: ProviderConfig): Promise { + const allProviders = await getAllProviders(); + const providerMap = new Map(allProviders.map((provider) => [provider.id, provider])); + const seen = new Set(); + const results: string[] = []; + const providerKey = getOpenClawProviderKey(config.type, config.id); + + for (const fallbackModel of config.fallbackModels ?? []) { + const normalizedModel = fallbackModel.trim(); + if (!normalizedModel) continue; + + const modelRef = normalizedModel.startsWith(`${providerKey}/`) + ? normalizedModel + : `${providerKey}/${normalizedModel}`; + + if (seen.has(modelRef)) continue; + seen.add(modelRef); + results.push(modelRef); + } + + for (const fallbackId of config.fallbackProviderIds ?? []) { + if (!fallbackId || fallbackId === config.id) continue; + + const fallbackProvider = providerMap.get(fallbackId); + if (!fallbackProvider) continue; + + const modelRef = getProviderModelRef(fallbackProvider); + if (!modelRef || seen.has(modelRef)) continue; + + seen.add(modelRef); + results.push(modelRef); + } + + return results; +} + +function scheduleGatewayRestart( + gatewayManager: GatewayManager | undefined, + message: string, + options?: { delayMs?: number; onlyIfRunning?: boolean }, +): void { + if (!gatewayManager) { + return; + } + + if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') { + return; + } + + logger.info(message); + gatewayManager.debouncedRestart(options?.delayMs); +} + +export async function syncProviderApiKeyToRuntime( + providerType: string, + providerId: string, + apiKey: string, +): Promise { + const ock = getOpenClawProviderKey(providerType, providerId); + await saveProviderKeyToOpenClaw(ock, apiKey); +} + +export async function syncSavedProviderToRuntime( + config: ProviderConfig, + apiKey: string | undefined, + gatewayManager?: GatewayManager, +): Promise { + const ock = getOpenClawProviderKey(config.type, config.id); + + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await saveProviderKeyToOpenClaw(ock, trimmedKey); + } + } + + const meta = getProviderConfig(config.type); + const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; + + if (!api) { + return; + } + + await syncProviderConfigToOpenClaw(ock, config.model, { + baseUrl: config.baseUrl || meta?.baseUrl, + api, + apiKeyEnv: meta?.apiKeyEnv, + headers: meta?.headers, + }); + + if (config.type === 'custom' || config.type === 'ollama') { + const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id); + if (resolvedKey && config.baseUrl) { + const modelId = config.model; + await updateAgentModelProvider(ock, { + baseUrl: config.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: resolvedKey, + }); + } + } + + scheduleGatewayRestart( + gatewayManager, + `Scheduling Gateway restart after saving provider "${ock}" config`, + ); +} + +export async function syncUpdatedProviderToRuntime( + config: ProviderConfig, + apiKey: string | undefined, + gatewayManager?: GatewayManager, +): Promise { + const ock = getOpenClawProviderKey(config.type, config.id); + const fallbackModels = await getProviderFallbackModelRefs(config); + const meta = getProviderConfig(config.type); + const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; + + if (!api) { + return; + } + + await syncProviderConfigToOpenClaw(ock, config.model, { + baseUrl: config.baseUrl || meta?.baseUrl, + api, + apiKeyEnv: meta?.apiKeyEnv, + headers: meta?.headers, + }); + + if (config.type === 'custom' || config.type === 'ollama') { + const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id); + if (resolvedKey && config.baseUrl) { + const modelId = config.model; + await updateAgentModelProvider(ock, { + baseUrl: config.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: resolvedKey, + }); + } + } + + const defaultProviderId = await getDefaultProvider(); + if (defaultProviderId === config.id) { + const modelOverride = config.model ? `${ock}/${config.model}` : undefined; + if (config.type !== 'custom' && config.type !== 'ollama') { + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); + } else { + await setOpenClawDefaultModelWithOverride(ock, modelOverride, { + baseUrl: config.baseUrl, + api: 'openai-completions', + }, fallbackModels); + } + } + + scheduleGatewayRestart( + gatewayManager, + `Scheduling Gateway restart after updating provider "${ock}" config`, + ); +} + +export async function syncDeletedProviderToRuntime( + provider: ProviderConfig | null, + providerId: string, + gatewayManager?: GatewayManager, +): Promise { + if (!provider?.type) { + return; + } + + const ock = getOpenClawProviderKey(provider.type, providerId); + await removeProviderFromOpenClaw(ock); + + scheduleGatewayRestart( + gatewayManager, + `Scheduling Gateway restart after deleting provider "${ock}"`, + ); +} + +export async function syncDeletedProviderApiKeyToRuntime( + provider: ProviderConfig | null, + providerId: string, +): Promise { + if (!provider?.type) { + return; + } + + const ock = getOpenClawProviderKey(provider.type, providerId); + await removeProviderFromOpenClaw(ock); +} + +export async function syncDefaultProviderToRuntime( + providerId: string, + gatewayManager?: GatewayManager, +): Promise { + const provider = await getProvider(providerId); + if (!provider) { + return; + } + + const ock = getOpenClawProviderKey(provider.type, providerId); + const providerKey = await getApiKey(providerId); + const fallbackModels = await getProviderFallbackModelRefs(provider); + const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; + const isOAuthProvider = oauthTypes.includes(provider.type) && !providerKey; + + if (!isOAuthProvider) { + const modelOverride = provider.model + ? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`) + : undefined; + + if (provider.type === 'custom' || provider.type === 'ollama') { + await setOpenClawDefaultModelWithOverride(ock, modelOverride, { + baseUrl: provider.baseUrl, + api: 'openai-completions', + }, fallbackModels); + } else { + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); + } + + if (providerKey) { + await saveProviderKeyToOpenClaw(ock, providerKey); + } + } else { + const defaultBaseUrl = provider.type === 'minimax-portal' + ? 'https://api.minimax.io/anthropic' + : (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' || provider.type === 'minimax-portal-cn') && baseUrl) { + baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; + } + + const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') + ? 'minimax-portal' + : provider.type; + + await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), { + baseUrl, + api, + authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + }, fallbackModels); + + logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`); + + try { + const defaultModelId = provider.model?.split('/').pop(); + await updateAgentModelProvider(targetProviderKey, { + baseUrl, + api, + authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [], + }); + } catch (err) { + logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err); + } + } + + if ( + (provider.type === 'custom' || provider.type === 'ollama') && + providerKey && + provider.baseUrl + ) { + const modelId = provider.model; + await updateAgentModelProvider(ock, { + baseUrl: provider.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: providerKey, + }); + } + + scheduleGatewayRestart( + gatewayManager, + `Scheduling Gateway restart after provider switch to "${ock}"`, + { onlyIfRunning: true }, + ); +} diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index b59a8ef..6831e64 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -12,10 +12,25 @@ import { getDefaultProviderAccountId, getProviderAccount, listProviderAccounts, + providerAccountToConfig, providerConfigToAccount, saveProviderAccount, setDefaultProviderAccount, } from './provider-store'; +import { + deleteApiKey, + deleteProvider, + getAllProviders, + getAllProvidersWithKeyInfo, + getApiKey, + getDefaultProvider, + getProvider, + hasApiKey, + saveProvider, + setDefaultProvider, + storeApiKey, +} from '../../utils/secure-storage'; +import type { ProviderWithKeyInfo } from '../../shared/providers/types'; export class ProviderService { async listVendors(): Promise { @@ -34,7 +49,52 @@ export class ProviderService { async getDefaultAccountId(): Promise { await ensureProviderStoreMigrated(); - return getDefaultProviderAccountId(); + return (await getDefaultProvider()) ?? getDefaultProviderAccountId(); + } + + async createAccount(account: ProviderAccount, apiKey?: string): Promise { + await ensureProviderStoreMigrated(); + await saveProvider(providerAccountToConfig(account)); + if (apiKey !== undefined && apiKey.trim()) { + await storeApiKey(account.id, apiKey.trim()); + } + return (await getProviderAccount(account.id)) ?? account; + } + + async updateAccount( + accountId: string, + patch: Partial, + apiKey?: string, + ): Promise { + await ensureProviderStoreMigrated(); + const existing = await getProviderAccount(accountId); + if (!existing) { + throw new Error('Provider account not found'); + } + + const nextAccount: ProviderAccount = { + ...existing, + ...patch, + id: accountId, + updatedAt: patch.updatedAt ?? new Date().toISOString(), + }; + + await saveProvider(providerAccountToConfig(nextAccount)); + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await storeApiKey(accountId, trimmedKey); + } else { + await deleteApiKey(accountId); + } + } + + return (await getProviderAccount(accountId)) ?? nextAccount; + } + + async deleteAccount(accountId: string): Promise { + await ensureProviderStoreMigrated(); + return deleteProvider(accountId); } async syncLegacyProvider(config: ProviderConfig, options?: { isDefault?: boolean }): Promise { @@ -44,9 +104,54 @@ export class ProviderService { return account; } + async listLegacyProviders(): Promise { + return getAllProviders(); + } + + async listLegacyProvidersWithKeyInfo(): Promise { + return getAllProvidersWithKeyInfo(); + } + + async getLegacyProvider(providerId: string): Promise { + return getProvider(providerId); + } + + async saveLegacyProvider(config: ProviderConfig): Promise { + await saveProvider(config); + } + + async deleteLegacyProvider(providerId: string): Promise { + return deleteProvider(providerId); + } + + async setDefaultLegacyProvider(providerId: string): Promise { + await setDefaultProvider(providerId); + } + + async getDefaultLegacyProvider(): Promise { + return getDefaultProvider(); + } + + async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise { + return storeApiKey(providerId, apiKey); + } + + async getLegacyProviderApiKey(providerId: string): Promise { + return getApiKey(providerId); + } + + async deleteLegacyProviderApiKey(providerId: string): Promise { + return deleteApiKey(providerId); + } + + async hasLegacyProviderApiKey(providerId: string): Promise { + return hasApiKey(providerId); + } + async setDefaultAccount(accountId: string): Promise { await ensureProviderStoreMigrated(); await setDefaultProviderAccount(accountId); + await setDefaultProvider(accountId); } getVendorDefinition(vendorId: string): ProviderDefinition | undefined { diff --git a/electron/services/providers/provider-validation.ts b/electron/services/providers/provider-validation.ts new file mode 100644 index 0000000..ce298ae --- /dev/null +++ b/electron/services/providers/provider-validation.ts @@ -0,0 +1,238 @@ +import { proxyAwareFetch } from '../../utils/proxy-fetch'; + +type ValidationProfile = + | 'openai-compatible' + | 'google-query-key' + | 'anthropic-header' + | 'openrouter' + | 'none'; + +function logValidationStatus(provider: string, status: number): void { + console.log(`[clawx-validate] ${provider} HTTP ${status}`); +} + +function maskSecret(secret: string): string { + if (!secret) return ''; + if (secret.length <= 8) return `${secret.slice(0, 2)}***`; + return `${secret.slice(0, 4)}***${secret.slice(-4)}`; +} + +function sanitizeValidationUrl(rawUrl: string): string { + try { + const url = new URL(rawUrl); + const key = url.searchParams.get('key'); + if (key) url.searchParams.set('key', maskSecret(key)); + return url.toString(); + } catch { + return rawUrl; + } +} + +function sanitizeHeaders(headers: Record): Record { + const next = { ...headers }; + if (next.Authorization?.startsWith('Bearer ')) { + const token = next.Authorization.slice('Bearer '.length); + next.Authorization = `Bearer ${maskSecret(token)}`; + } + if (next['x-api-key']) { + next['x-api-key'] = maskSecret(next['x-api-key']); + } + return next; +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.trim().replace(/\/+$/, ''); +} + +function buildOpenAiModelsUrl(baseUrl: string): string { + return `${normalizeBaseUrl(baseUrl)}/models?limit=1`; +} + +function logValidationRequest( + provider: string, + method: string, + url: string, + headers: Record, +): void { + console.log( + `[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}`, + ); +} + +function getValidationProfile(providerType: string): ValidationProfile { + switch (providerType) { + case 'anthropic': + return 'anthropic-header'; + case 'google': + return 'google-query-key'; + case 'openrouter': + return 'openrouter'; + case 'ollama': + return 'none'; + default: + return 'openai-compatible'; + } +} + +async function performProviderValidationRequest( + providerLabel: string, + url: string, + headers: Record, +): Promise<{ valid: boolean; error?: string }> { + try { + logValidationRequest(providerLabel, 'GET', url, headers); + const response = await proxyAwareFetch(url, { headers }); + logValidationStatus(providerLabel, response.status); + const data = await response.json().catch(() => ({})); + return classifyAuthResponse(response.status, data); + } catch (error) { + return { + valid: false, + error: `Connection error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +function classifyAuthResponse( + status: number, + data: unknown, +): { valid: boolean; error?: string } { + if (status >= 200 && status < 300) return { valid: true }; + if (status === 429) return { valid: true }; + if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' }; + + const obj = data as { error?: { message?: string }; message?: string } | null; + const msg = obj?.error?.message || obj?.message || `API error: ${status}`; + return { valid: false, error: msg }; +} + +async function validateOpenAiCompatibleKey( + providerType: string, + apiKey: string, + baseUrl?: string, +): Promise<{ valid: boolean; error?: string }> { + const trimmedBaseUrl = baseUrl?.trim(); + if (!trimmedBaseUrl) { + return { valid: false, error: `Base URL is required for provider "${providerType}" validation` }; + } + + const headers = { Authorization: `Bearer ${apiKey}` }; + const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl); + const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers); + + if (modelsResult.error?.includes('API error: 404')) { + console.log( + `[clawx-validate] ${providerType} /models returned 404, falling back to /chat/completions probe`, + ); + const base = normalizeBaseUrl(trimmedBaseUrl); + const chatUrl = `${base}/chat/completions`; + return await performChatCompletionsProbe(providerType, chatUrl, headers); + } + + return modelsResult; +} + +async function performChatCompletionsProbe( + providerLabel: string, + url: string, + headers: Record, +): Promise<{ valid: boolean; error?: string }> { + try { + logValidationRequest(providerLabel, 'POST', url, headers); + const response = await proxyAwareFetch(url, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'validation-probe', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 1, + }), + }); + logValidationStatus(providerLabel, response.status); + const data = await response.json().catch(() => ({})); + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: 'Invalid API key' }; + } + if ( + (response.status >= 200 && response.status < 300) || + response.status === 400 || + response.status === 429 + ) { + return { valid: true }; + } + return classifyAuthResponse(response.status, data); + } catch (error) { + return { + valid: false, + error: `Connection error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +async function validateGoogleQueryKey( + providerType: string, + apiKey: string, + baseUrl?: string, +): Promise<{ valid: boolean; error?: string }> { + const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta'); + const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`; + return await performProviderValidationRequest(providerType, url, {}); +} + +async function validateAnthropicHeaderKey( + providerType: string, + apiKey: string, + baseUrl?: string, +): Promise<{ valid: boolean; error?: string }> { + const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1'); + const url = `${base}/models?limit=1`; + const headers = { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }; + return await performProviderValidationRequest(providerType, url, headers); +} + +async function validateOpenRouterKey( + providerType: string, + apiKey: string, +): Promise<{ valid: boolean; error?: string }> { + const url = 'https://openrouter.ai/api/v1/auth/key'; + const headers = { Authorization: `Bearer ${apiKey}` }; + return await performProviderValidationRequest(providerType, url, headers); +} + +export async function validateApiKeyWithProvider( + providerType: string, + apiKey: string, + options?: { baseUrl?: string }, +): Promise<{ valid: boolean; error?: string }> { + const profile = getValidationProfile(providerType); + if (profile === 'none') { + return { valid: true }; + } + + const trimmedKey = apiKey.trim(); + if (!trimmedKey) { + return { valid: false, error: 'API key is required' }; + } + + try { + switch (profile) { + case 'openai-compatible': + return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl); + case 'google-query-key': + return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl); + case 'anthropic-header': + return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl); + case 'openrouter': + return await validateOpenRouterKey(providerType, trimmedKey); + default: + return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { valid: false, error: errorMessage }; + } +} diff --git a/electron/utils/device-oauth.ts b/electron/utils/device-oauth.ts index 1287282..4589f2f 100644 --- a/electron/utils/device-oauth.ts +++ b/electron/utils/device-oauth.ts @@ -42,6 +42,8 @@ export type { MiniMaxRegion }; class DeviceOAuthManager extends EventEmitter { private activeProvider: OAuthProviderType | null = null; + private activeAccountId: string | null = null; + private activeLabel: string | null = null; private active: boolean = false; private mainWindow: BrowserWindow | null = null; @@ -49,14 +51,20 @@ class DeviceOAuthManager extends EventEmitter { this.mainWindow = window; } - async startFlow(provider: OAuthProviderType, region: MiniMaxRegion = 'global'): Promise { + async startFlow( + provider: OAuthProviderType, + region: MiniMaxRegion = 'global', + options?: { accountId?: string; label?: string }, + ): Promise { if (this.active) { await this.stopFlow(); } this.active = true; - this.emit('oauth:start', { provider: provider }); + this.emit('oauth:start', { provider, accountId: options?.accountId || provider }); this.activeProvider = provider; + this.activeAccountId = options?.accountId || provider; + this.activeLabel = options?.label || null; try { if (provider === 'minimax-portal' || provider === 'minimax-portal-cn') { @@ -77,6 +85,8 @@ class DeviceOAuthManager extends EventEmitter { this.emitError(error instanceof Error ? error.message : String(error)); this.active = false; this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; return false; } } @@ -84,6 +94,8 @@ class DeviceOAuthManager extends EventEmitter { async stopFlow(): Promise { this.active = false; this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; logger.info('[DeviceOAuth] Flow explicitly stopped'); } @@ -194,8 +206,12 @@ class DeviceOAuthManager extends EventEmitter { api: 'anthropic-messages' | 'openai-completions'; region?: MiniMaxRegion; }) { + const accountId = this.activeAccountId || providerType; + const accountLabel = this.activeLabel; this.active = false; this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; logger.info(`[DeviceOAuth] Successfully completed OAuth for ${providerType}`); // 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format. @@ -254,15 +270,15 @@ 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 existing = await getProvider(accountId); const nameMap: Record = { 'minimax-portal': 'MiniMax (Global)', 'minimax-portal-cn': 'MiniMax (CN)', 'qwen-portal': 'Qwen', }; const providerConfig: ProviderConfig = { - id: providerType, - name: nameMap[providerType as OAuthProviderType] || providerType, + id: accountId, + name: accountLabel || nameMap[providerType as OAuthProviderType] || providerType, type: providerType, enabled: existing?.enabled ?? true, baseUrl, // Save the dynamically resolved URL (Global vs CN) @@ -274,11 +290,11 @@ class DeviceOAuthManager extends EventEmitter { await saveProvider(providerConfig); // 4. Emit success internally so the main process can restart the Gateway - this.emit('oauth:success', providerType); + this.emit('oauth:success', { provider: providerType, accountId }); // 5. Emit success to frontend if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send('oauth:success', { provider: providerType, success: true }); + this.mainWindow.webContents.send('oauth:success', { provider: providerType, accountId, success: true }); } } diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index 2963ff5..102bf5c 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -2,7 +2,7 @@ * Providers Settings Component * Manage AI provider configurations and API keys */ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Plus, Trash2, @@ -24,7 +24,12 @@ import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; -import { useProviderStore, type ProviderConfig, type ProviderWithKeyInfo } from '@/stores/providers'; +import { + useProviderStore, + type ProviderAccount, + type ProviderConfig, + type ProviderVendorInfo, +} from '@/stores/providers'; import { PROVIDER_TYPE_INFO, type ProviderType, @@ -34,6 +39,11 @@ import { shouldShowProviderModelId, shouldInvertInDark, } from '@/lib/providers'; +import { + buildProviderAccountId, + buildProviderListItems, + type ProviderListItem, +} from '@/lib/provider-accounts'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -61,23 +71,46 @@ function fallbackModelsEqual(a?: string[], b?: string[]): boolean { return left.length === right.length && left.every((model, index) => model === right[index]); } +function getAuthModeLabel(authMode: ProviderAccount['authMode']): string { + switch (authMode) { + case 'api_key': + return 'API Key'; + case 'oauth_device': + return 'OAuth Device'; + case 'oauth_browser': + return 'OAuth Browser'; + case 'local': + return 'Local'; + default: + return authMode; + } +} + export function ProvidersSettings() { const { t } = useTranslation('settings'); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const { providers, - defaultProviderId, + accounts, + vendors, + defaultAccountId, loading, fetchProviders, - addProvider, - deleteProvider, - updateProviderWithKey, - setDefaultProvider, + addAccount, + deleteAccount, + updateAccount, + setDefaultAccount, validateApiKey, } = useProviderStore(); const [showAddDialog, setShowAddDialog] = useState(false); const [editingProvider, setEditingProvider] = useState(null); + const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); + const existingVendorIds = new Set(accounts.map((account) => account.vendorId)); + const displayProviders = useMemo( + () => buildProviderListItems(accounts, providers, vendors, defaultAccountId), + [accounts, providers, vendors, defaultAccountId], + ); // Fetch providers on mount useEffect(() => { @@ -88,28 +121,29 @@ export function ProvidersSettings() { type: ProviderType, name: string, apiKey: string, - options?: { baseUrl?: string; model?: string } + options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode'] } ) => { - // Only custom supports multiple instances. - // Built-in providers remain singleton by type. - const id = type === 'custom' ? `custom-${crypto.randomUUID()}` : type; + const vendor = vendorMap.get(type); + const id = buildProviderAccountId(type, null, vendors); const effectiveApiKey = resolveProviderApiKeyForSave(type, apiKey); try { - await addProvider( - { - id, - type, - name, - baseUrl: options?.baseUrl, - model: options?.model, - enabled: true, - }, - effectiveApiKey - ); + await addAccount({ + id, + vendorId: type, + label: name, + authMode: options?.authMode || vendor?.defaultAuthMode || (type === 'ollama' ? 'local' : 'api_key'), + baseUrl: options?.baseUrl, + apiProtocol: type === 'custom' || type === 'ollama' ? 'openai-completions' : undefined, + model: options?.model, + enabled: true, + isDefault: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, effectiveApiKey); // Auto-set as default if no default is currently configured - if (!defaultProviderId) { - await setDefaultProvider(id); + if (!defaultAccountId) { + await setDefaultAccount(id); } setShowAddDialog(false); @@ -121,7 +155,7 @@ export function ProvidersSettings() { const handleDeleteProvider = async (providerId: string) => { try { - await deleteProvider(providerId); + await deleteAccount(providerId); toast.success(t('aiProviders.toast.deleted')); } catch (error) { toast.error(`${t('aiProviders.toast.failedDelete')}: ${error}`); @@ -130,7 +164,7 @@ export function ProvidersSettings() { const handleSetDefault = async (providerId: string) => { try { - await setDefaultProvider(providerId); + await setDefaultAccount(providerId); toast.success(t('aiProviders.toast.defaultUpdated')); } catch (error) { toast.error(`${t('aiProviders.toast.failedDefault')}: ${error}`); @@ -139,6 +173,14 @@ export function ProvidersSettings() { return (
+ {accounts.length > 0 && ( + + )} +