From 1b508d5bdeada26fcd4bb01a93786725a84463c4 Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:33:33 -0800 Subject: [PATCH] feat(provider): mainly support moonshot / siliconflow on setup (#43) --- electron/gateway/manager.ts | 28 +- electron/main/ipc-handlers.ts | 483 +++++++++--------- electron/preload/index.ts | 2 +- electron/utils/channel-config.ts | 2 +- electron/utils/openclaw-auth.ts | 238 ++++++--- electron/utils/provider-registry.ts | 133 +++++ electron/utils/secure-storage.ts | 123 ++--- electron/utils/whatsapp-login.ts | 2 +- src/components/settings/ProvidersSettings.tsx | 292 +++++++++-- src/components/ui/select.tsx | 30 ++ src/lib/providers.ts | 74 +++ src/pages/Setup/index.tsx | 361 ++++++++----- src/stores/chat.ts | 2 + src/stores/gateway.ts | 114 +++-- src/stores/providers.ts | 53 +- src/types/channel.ts | 2 +- 16 files changed, 1305 insertions(+), 634 deletions(-) create mode 100644 electron/utils/provider-registry.ts create mode 100644 src/components/ui/select.tsx create mode 100644 src/lib/providers.ts diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 5b0132452..889ab84a5 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -16,8 +16,8 @@ import { isOpenClawPresent } from '../utils/paths'; import { getSetting } from '../utils/store'; -import { getApiKey } from '../utils/secure-storage'; -import { getProviderEnvVar } from '../utils/openclaw-auth'; +import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage'; +import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry'; import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol'; import { logger } from '../utils/logger'; import { getUvMirrorEnv } from '../utils/uv-env'; @@ -521,10 +521,30 @@ export class GatewayManager extends EventEmitter { ? `${binPath}${path.delimiter}${process.env.PATH || ''}` : process.env.PATH || ''; - // Load provider API keys from secure storage to pass as environment variables + // Load provider API keys from storage to pass as environment variables const providerEnv: Record = {}; - const providerTypes = ['anthropic', 'openai', 'google', 'openrouter']; + const providerTypes = getKeyableProviderTypes(); let loadedProviderKeyCount = 0; + + // Prefer the selected default provider key when provider IDs are instance-based. + try { + const defaultProviderId = await getDefaultProvider(); + if (defaultProviderId) { + const defaultProvider = await getProvider(defaultProviderId); + const defaultProviderType = defaultProvider?.type; + const defaultProviderKey = await getApiKey(defaultProviderId); + if (defaultProviderType && defaultProviderKey) { + const envVar = getProviderEnvVar(defaultProviderType); + if (envVar) { + providerEnv[envVar] = defaultProviderKey; + loadedProviderKeyCount++; + } + } + } + } catch (err) { + logger.warn('Failed to load default provider key for environment injection:', err); + } + for (const providerType of providerTypes) { try { const key = await getApiKey(providerType); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index c2cc5dec8..c96cd37ad 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -13,18 +13,21 @@ import { hasApiKey, saveProvider, getProvider, - deleteProvider, setDefaultProvider, getDefaultProvider, getAllProvidersWithKeyInfo, - isEncryptionAvailable, type ProviderConfig, } from '../utils/secure-storage'; import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir } from '../utils/paths'; import { getOpenClawCliCommand, installOpenClawCliMac } from '../utils/openclaw-cli'; import { getSetting } from '../utils/store'; -import { saveProviderKeyToOpenClaw, setOpenClawDefaultModel } from '../utils/openclaw-auth'; +import { + saveProviderKeyToOpenClaw, + removeProviderKeyFromOpenClaw, + setOpenClawDefaultModel, + setOpenClawDefaultModelWithOverride, +} from '../utils/openclaw-auth'; import { logger } from '../utils/logger'; import { saveChannelConfig, @@ -686,11 +689,6 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void { * Provider-related IPC handlers */ function registerProviderHandlers(): void { - // Check if encryption is available - ipcMain.handle('provider:encryptionAvailable', () => { - return isEncryptionAvailable(); - }); - // Get all providers with key info ipcMain.handle('provider:list', async () => { return await getAllProvidersWithKeyInfo(); @@ -719,13 +717,6 @@ function registerProviderHandlers(): void { } } - // Set the default model in OpenClaw config based on provider type - try { - setOpenClawDefaultModel(config.type); - } catch (err) { - console.warn('Failed to set OpenClaw default model:', err); - } - return { success: true }; } catch (error) { return { success: false, error: String(error) }; @@ -735,7 +726,18 @@ function registerProviderHandlers(): void { // Delete a provider ipcMain.handle('provider:delete', async (_, providerId: string) => { try { + const existing = await getProvider(providerId); await deleteProvider(providerId); + + // Best-effort cleanup in OpenClaw auth profiles + if (existing?.type) { + try { + removeProviderKeyFromOpenClaw(existing.type); + } catch (err) { + console.warn('Failed to remove key from OpenClaw auth-profiles:', err); + } + } + return { success: true }; } catch (error) { return { success: false, error: String(error) }; @@ -763,10 +765,78 @@ function registerProviderHandlers(): void { } }); + // Atomically update provider config and API key + ipcMain.handle( + 'provider:updateWithKey', + async ( + _, + providerId: string, + updates: Partial, + apiKey?: string + ) => { + const existing = await getProvider(providerId); + if (!existing) { + return { success: false, error: 'Provider not found' }; + } + + const previousKey = await getApiKey(providerId); + const previousProviderType = existing.type; + + try { + const nextConfig: ProviderConfig = { + ...existing, + ...updates, + updatedAt: new Date().toISOString(), + }; + + await saveProvider(nextConfig); + + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await storeApiKey(providerId, trimmedKey); + saveProviderKeyToOpenClaw(nextConfig.type, trimmedKey); + } else { + await deleteApiKey(providerId); + removeProviderKeyFromOpenClaw(nextConfig.type); + } + } + + return { success: true }; + } catch (error) { + // Best-effort rollback to keep config/key consistent. + try { + await saveProvider(existing); + if (previousKey) { + await storeApiKey(providerId, previousKey); + saveProviderKeyToOpenClaw(previousProviderType, previousKey); + } else { + await deleteApiKey(providerId); + removeProviderKeyFromOpenClaw(previousProviderType); + } + } catch (rollbackError) { + console.warn('Failed to rollback provider updateWithKey:', rollbackError); + } + + return { success: false, error: String(error) }; + } + } + ); + // Delete API key for a provider ipcMain.handle('provider:deleteApiKey', async (_, providerId: string) => { try { await deleteApiKey(providerId); + + // Keep OpenClaw auth-profiles.json in sync with local key storage + const provider = await getProvider(providerId); + const providerType = provider?.type || providerId; + try { + removeProviderKeyFromOpenClaw(providerType); + } catch (err) { + console.warn('Failed to remove key from OpenClaw auth-profiles:', err); + } + return { success: true }; } catch (error) { return { success: false, error: String(error) }; @@ -783,10 +853,42 @@ function registerProviderHandlers(): void { return await getApiKey(providerId); }); - // Set default provider + // Set default provider and update OpenClaw default model ipcMain.handle('provider:setDefault', async (_, providerId: string) => { try { await setDefaultProvider(providerId); + + // Update OpenClaw config to use this provider's default model + const provider = await getProvider(providerId); + if (provider) { + try { + // If the provider has a user-specified model (e.g. siliconflow), + // build the full model string: "providerType/modelId" + const modelOverride = provider.model + ? `${provider.type}/${provider.model}` + : undefined; + + if (provider.type === 'custom' || provider.type === 'ollama') { + // For runtime-configured providers, use user-entered base URL/api. + setOpenClawDefaultModelWithOverride(provider.type, modelOverride, { + baseUrl: provider.baseUrl, + api: 'openai-completions', + }); + } else { + setOpenClawDefaultModel(provider.type, modelOverride); + } + + // Keep auth-profiles in sync with the default provider instance. + // This is especially important when multiple custom providers exist. + const providerKey = await getApiKey(providerId); + if (providerKey) { + saveProviderKeyToOpenClaw(provider.type, providerKey); + } + } catch (err) { + console.warn('Failed to set OpenClaw default model:', err); + } + } + return { success: true }; } catch (error) { return { success: false, error: String(error) }; @@ -809,7 +911,7 @@ function registerProviderHandlers(): void { // This allows validation during setup when provider hasn't been saved yet const providerType = provider?.type || providerId; - console.log(`Validating API key for provider type: ${providerType}`); + console.log(`[clawx-validate] validating provider type: ${providerType}`); return await validateApiKeyWithProvider(providerType, apiKey); } catch (error) { console.error('Validation error:', error); @@ -819,8 +921,8 @@ function registerProviderHandlers(): void { } /** - * Validate API key by making a real chat completion API call to the provider - * This sends a minimal "hi" message to verify the key works + * Validate API key using lightweight model-listing endpoints (zero token cost). + * Falls back to accepting the key for unknown/custom provider types. */ async function validateApiKeyWithProvider( providerType: string, @@ -841,11 +943,16 @@ async function validateApiKeyWithProvider( return await validateGoogleKey(trimmedKey); case 'openrouter': return await validateOpenRouterKey(trimmedKey); + case 'moonshot': + return await validateMoonshotKey(trimmedKey); + case 'siliconflow': + return await validateSiliconFlowKey(trimmedKey); case 'ollama': // Ollama doesn't require API key validation return { valid: true }; default: // For custom providers, just check the key is not empty + console.log(`[clawx-validate] ${providerType} uses local non-empty validation only`); return { valid: true }; } } catch (error) { @@ -854,265 +961,169 @@ async function validateApiKeyWithProvider( } } -/** - * Parse error message from API response - */ -function parseApiError(data: unknown): string { - if (!data || typeof data !== 'object') return 'Unknown error'; +function logValidationStatus(provider: string, status: number): void { + console.log(`[clawx-validate] ${provider} HTTP ${status}`); +} - // Anthropic format: { error: { message: "..." } } - // OpenAI format: { error: { message: "..." } } - // Google format: { error: { message: "..." } } - const obj = data as { error?: { message?: string; type?: string }; message?: string }; +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)}`; +} - if (obj.error?.message) return obj.error.message; - if (obj.message) return obj.message; +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; + } +} - return 'Unknown error'; +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 logValidationRequest( + provider: string, + method: string, + url: string, + headers: Record +): void { + console.log( + `[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}` + ); } /** - * Validate Anthropic API key by making a minimal chat completion request + * 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 }; +} + +/** + * Validate Anthropic API key via GET /v1/models (zero cost) */ async function validateAnthropicKey(apiKey: string): Promise<{ valid: boolean; error?: string }> { try { - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: 'claude-3-haiku-20240307', - max_tokens: 1, - messages: [{ role: 'user', content: 'hi' }], - }), - }); - + const url = 'https://api.anthropic.com/v1/models?limit=1'; + const headers = { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }; + logValidationRequest('anthropic', 'GET', url, headers); + const response = await fetch(url, { headers }); + logValidationStatus('anthropic', response.status); const data = await response.json().catch(() => ({})); - - if (response.ok) { - return { valid: true }; - } - - // Authentication error - if (response.status === 401) { - return { valid: false, error: 'Invalid API key' }; - } - - // Permission error (invalid key format, etc.) - if (response.status === 403) { - return { valid: false, error: parseApiError(data) }; - } - - // Rate limit or overloaded - key is valid but service is busy - if (response.status === 429 || response.status === 529) { - return { valid: true }; - } - - // Model not found or bad request but auth passed - key is valid - if (response.status === 400 || response.status === 404) { - const errorType = (data as { error?: { type?: string } })?.error?.type; - if (errorType === 'authentication_error' || errorType === 'invalid_api_key') { - return { valid: false, error: 'Invalid API key' }; - } - // Other errors like invalid_request_error mean the key is valid - return { valid: true }; - } - - return { valid: false, error: parseApiError(data) || `API error: ${response.status}` }; + return classifyAuthResponse(response.status, data); } catch (error) { return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` }; } } /** - * Validate OpenAI API key by making a minimal chat completion request + * Validate OpenAI API key via GET /v1/models (zero cost) */ async function validateOpenAIKey(apiKey: string): Promise<{ valid: boolean; error?: string }> { try { - const response = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: 'gpt-4o-mini', - max_tokens: 1, - messages: [{ role: 'user', content: 'hi' }], - }), - }); - + const url = 'https://api.openai.com/v1/models?limit=1'; + const headers = { Authorization: `Bearer ${apiKey}` }; + logValidationRequest('openai', 'GET', url, headers); + const response = await fetch(url, { headers }); + logValidationStatus('openai', response.status); const data = await response.json().catch(() => ({})); - - if (response.ok) { - return { valid: true }; - } - - // Authentication error - if (response.status === 401) { - return { valid: false, error: 'Invalid API key' }; - } - - // Rate limit - key is valid - if (response.status === 429) { - return { valid: true }; - } - - // Model not found or bad request but auth passed - key is valid - if (response.status === 400 || response.status === 404) { - const errorCode = (data as { error?: { code?: string } })?.error?.code; - if (errorCode === 'invalid_api_key') { - return { valid: false, error: 'Invalid API key' }; - } - return { valid: true }; - } - - return { valid: false, error: parseApiError(data) || `API error: ${response.status}` }; + return classifyAuthResponse(response.status, data); } catch (error) { return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` }; } } /** - * Validate Google (Gemini) API key by making a minimal generate content request + * Validate Google (Gemini) API key via GET /v1beta/models (zero cost) */ async function validateGoogleKey(apiKey: string): Promise<{ valid: boolean; error?: string }> { try { - const response = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - contents: [{ parts: [{ text: 'hi' }] }], - generationConfig: { maxOutputTokens: 1 }, - }), - } - ); - + const url = `https://generativelanguage.googleapis.com/v1beta/models?pageSize=1&key=${apiKey}`; + logValidationRequest('google', 'GET', url, {}); + const response = await fetch(url); + logValidationStatus('google', response.status); const data = await response.json().catch(() => ({})); - - if (response.ok) { - return { valid: true }; - } - - // Authentication error - if (response.status === 400 || response.status === 401 || response.status === 403) { - const errorStatus = (data as { error?: { status?: string } })?.error?.status; - if (errorStatus === 'UNAUTHENTICATED' || errorStatus === 'PERMISSION_DENIED') { - return { valid: false, error: 'Invalid API key' }; - } - // Check if it's actually an auth error - const errorMessage = parseApiError(data).toLowerCase(); - if (errorMessage.includes('api key') || errorMessage.includes('invalid') || errorMessage.includes('unauthorized')) { - return { valid: false, error: parseApiError(data) }; - } - // Other errors mean key is valid - return { valid: true }; - } - - // Rate limit - key is valid - if (response.status === 429) { - return { valid: true }; - } - - return { valid: false, error: parseApiError(data) || `API error: ${response.status}` }; + return classifyAuthResponse(response.status, data); } catch (error) { return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` }; } } /** - * Validate OpenRouter API key by making a minimal chat completion request + * Validate OpenRouter API key via GET /api/v1/models (zero cost) */ async function validateOpenRouterKey(apiKey: string): Promise<{ valid: boolean; error?: string }> { try { - // Use a popular free model for validation - const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - 'HTTP-Referer': 'https://clawx.app', - 'X-Title': 'ClawX', - }, - body: JSON.stringify({ - model: 'meta-llama/llama-3.2-3b-instruct:free', - max_tokens: 1, - messages: [{ role: 'user', content: 'hi' }], - }), - }); - + const url = 'https://openrouter.ai/api/v1/models'; + const headers = { Authorization: `Bearer ${apiKey}` }; + logValidationRequest('openrouter', 'GET', url, headers); + const response = await fetch(url, { headers }); + logValidationStatus('openrouter', response.status); const data = await response.json().catch(() => ({})); - console.log('OpenRouter validation response:', response.status, JSON.stringify(data)); + return classifyAuthResponse(response.status, data); + } catch (error) { + return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` }; + } +} - // Helper to check if error message indicates auth failure - const isAuthError = (d: unknown): boolean => { - const errorObj = (d as { error?: { message?: string; code?: number | string; type?: string } })?.error; - if (!errorObj) return false; +/** + * Validate Moonshot API key via GET /v1/models (zero cost) + */ +async function validateMoonshotKey(apiKey: string): Promise<{ valid: boolean; error?: string }> { + try { + const url = 'https://api.moonshot.cn/v1/models'; + const headers = { Authorization: `Bearer ${apiKey}` }; + logValidationRequest('moonshot', 'GET', url, headers); + const response = await fetch(url, { headers }); + logValidationStatus('moonshot', 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)}` }; + } +} - const message = (errorObj.message || '').toLowerCase(); - const code = errorObj.code; - const type = (errorObj.type || '').toLowerCase(); - - // Check for explicit auth-related errors - if (code === 401 || code === '401' || code === 403 || code === '403') return true; - if (type.includes('auth') || type.includes('invalid')) return true; - if (message.includes('invalid api key') || message.includes('invalid key') || - message.includes('unauthorized') || message.includes('authentication') || - message.includes('invalid credentials') || message.includes('api key is not valid')) { - return true; - } - return false; - }; - - if (response.ok) { - return { valid: true }; - } - - // Always check for auth errors in the response body first - if (isAuthError(data)) { - // Return user-friendly message instead of raw API errors like "User not found." - return { valid: false, error: 'Invalid API key' }; - } - - // Authentication error status codes - always return user-friendly message - if (response.status === 401 || response.status === 403) { - return { valid: false, error: 'Invalid API key' }; - } - - // Rate limit - key is valid - if (response.status === 429) { - return { valid: true }; - } - - // Payment required or insufficient credits - key format is valid - if (response.status === 402) { - return { valid: true }; - } - - // For 400/404, we must be very careful - only consider valid if clearly not an auth issue - if (response.status === 400 || response.status === 404) { - // If we got here without detecting auth error, it might be a model issue - // But be conservative - require explicit success indication - const errorObj = (data as { error?: { message?: string; code?: number } })?.error; - const message = (errorObj?.message || '').toLowerCase(); - - // Only consider valid if the error is clearly about the model, not the key - if (message.includes('model') && !message.includes('key') && !message.includes('auth')) { - return { valid: true }; - } - - // Default to invalid for ambiguous 400/404 errors - return { valid: false, error: parseApiError(data) || 'Invalid API key or request' }; - } - - return { valid: false, error: parseApiError(data) || `API error: ${response.status}` }; +/** + * Validate SiliconFlow API key via GET /v1/models (zero cost) + */ +async function validateSiliconFlowKey(apiKey: string): Promise<{ valid: boolean; error?: string }> { + try { + const url = 'https://api.siliconflow.com/v1/models'; + const headers = { Authorization: `Bearer ${apiKey}` }; + logValidationRequest('siliconflow', 'GET', url, headers); + const response = await fetch(url, { headers }); + logValidationStatus('siliconflow', 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)}` }; } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f2e3eb211..972c20ed7 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -64,12 +64,12 @@ const electronAPI = { 'env:setApiKey', 'env:deleteApiKey', // Provider - 'provider:encryptionAvailable', 'provider:list', 'provider:get', 'provider:save', 'provider:delete', 'provider:setApiKey', + 'provider:updateWithKey', 'provider:deleteApiKey', 'provider:hasApiKey', 'provider:getApiKey', diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 5614363cc..c757aa06c 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -655,4 +655,4 @@ export async function validateChannelConfig(channelType: string): Promise; } -/** - * Provider type to environment variable name mapping - */ -const PROVIDER_ENV_VARS: Record = { - anthropic: 'ANTHROPIC_API_KEY', - openai: 'OPENAI_API_KEY', - google: 'GEMINI_API_KEY', - openrouter: 'OPENROUTER_API_KEY', - groq: 'GROQ_API_KEY', - deepgram: 'DEEPGRAM_API_KEY', - cerebras: 'CEREBRAS_API_KEY', - xai: 'XAI_API_KEY', - mistral: 'MISTRAL_API_KEY', -}; - /** * Get the path to the auth-profiles.json for a given agent */ @@ -139,10 +129,30 @@ export function saveProviderKeyToOpenClaw( } /** - * Get the environment variable name for a provider type + * Remove a provider API key from OpenClaw auth-profiles.json */ -export function getProviderEnvVar(provider: string): string | undefined { - return PROVIDER_ENV_VARS[provider]; +export function removeProviderKeyFromOpenClaw( + provider: string, + agentId = 'main' +): void { + const store = readAuthProfiles(agentId); + const profileId = `${provider}:default`; + + delete store.profiles[profileId]; + + if (store.order?.[provider]) { + store.order[provider] = store.order[provider].filter((id) => id !== profileId); + if (store.order[provider].length === 0) { + delete store.order[provider]; + } + } + + if (store.lastGood?.[provider] === profileId) { + delete store.lastGood[provider]; + } + + writeAuthProfiles(store, agentId); + console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agent: ${agentId})`); } /** @@ -153,7 +163,7 @@ export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: st const env: Record = {}; for (const { type, apiKey } of providers) { - const envVar = PROVIDER_ENV_VARS[type]; + const envVar = getProviderEnvVar(type); if (envVar && apiKey) { env[envVar] = apiKey; } @@ -162,46 +172,15 @@ export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: st return env; } -/** - * Provider type to default model mapping - * Used to set the gateway's default model when the user selects a provider - */ -const PROVIDER_DEFAULT_MODELS: Record = { - anthropic: 'anthropic/claude-opus-4-6', - openai: 'openai/gpt-5.2', - google: 'google/gemini-3-pro-preview', - openrouter: 'openrouter/anthropic/claude-opus-4.6', -}; - -/** - * Provider configurations needed for model resolution. - * OpenClaw resolves models by checking cfg.models.providers[provider]. - * Without this, any model for the provider returns "Unknown model". - */ -const PROVIDER_CONFIGS: Record = { - openrouter: { - baseUrl: 'https://openrouter.ai/api/v1', - api: 'openai-completions', - apiKeyEnv: 'OPENROUTER_API_KEY', - }, - openai: { - baseUrl: 'https://api.openai.com/v1', - api: 'openai-responses', - apiKeyEnv: 'OPENAI_API_KEY', - }, - google: { - baseUrl: 'https://generativelanguage.googleapis.com/v1beta', - api: 'google', - apiKeyEnv: 'GEMINI_API_KEY', - }, - // anthropic is built-in to OpenClaw's model registry, no provider config needed -}; - /** * Update the OpenClaw config to use the given provider and model * Writes to ~/.openclaw/openclaw.json + * + * @param provider - Provider type (e.g. 'anthropic', 'siliconflow') + * @param modelOverride - Optional model string to use instead of the registry default. + * For siliconflow this is the user-supplied model ID prefixed with "siliconflow/". */ -export function setOpenClawDefaultModel(provider: string): void { +export function setOpenClawDefaultModel(provider: string, modelOverride?: string): void { const configPath = join(homedir(), '.openclaw', 'openclaw.json'); let config: Record = {}; @@ -214,11 +193,15 @@ export function setOpenClawDefaultModel(provider: string): void { console.warn('Failed to read openclaw.json, creating fresh config:', err); } - const model = PROVIDER_DEFAULT_MODELS[provider]; + const model = modelOverride || getProviderDefaultModel(provider); if (!model) { console.warn(`No default model mapping for provider "${provider}"`); return; } + + const modelId = model.startsWith(`${provider}/`) + ? model.slice(provider.length + 1) + : model; // Set the default model for the agents // model must be an object: { primary: "provider/model", fallbacks?: [] } @@ -228,24 +211,44 @@ export function setOpenClawDefaultModel(provider: string): void { agents.defaults = defaults; config.agents = agents; - // Configure models.providers for providers that need explicit registration - // Without this, OpenClaw returns "Unknown model" because it can't resolve - // the provider's baseUrl and API type - const providerCfg = PROVIDER_CONFIGS[provider]; + // Configure models.providers for providers that need explicit registration. + // For built-in providers this comes from registry; for custom/ollama-like + // providers callers can supply runtime overrides. + const providerCfg = getProviderConfig(provider); if (providerCfg) { const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; - - // Only set if not already configured - if (!providers[provider]) { - providers[provider] = { - baseUrl: providerCfg.baseUrl, - api: providerCfg.api, - apiKey: providerCfg.apiKeyEnv, - models: [], - }; - console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}`); + + const existingProvider = + providers[provider] && typeof providers[provider] === 'object' + ? (providers[provider] as Record) + : {}; + + const existingModels = Array.isArray(existingProvider.models) + ? (existingProvider.models as Array>) + : []; + const registryModels = (providerCfg.models ?? []).map((m) => ({ ...m })) as Array>; + + // Merge model entries by id and ensure the selected/default model id exists. + const mergedModels = [...registryModels]; + for (const item of existingModels) { + const id = typeof item?.id === 'string' ? item.id : ''; + if (id && !mergedModels.some((m) => m.id === id)) { + mergedModels.push(item); + } } + if (modelId && !mergedModels.some((m) => m.id === modelId)) { + mergedModels.push({ id: modelId, name: modelId }); + } + + providers[provider] = { + ...existingProvider, + baseUrl: providerCfg.baseUrl, + api: providerCfg.api, + apiKey: providerCfg.apiKeyEnv, + models: mergedModels, + }; + console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`); models.providers = providers; config.models = models; @@ -267,3 +270,98 @@ export function setOpenClawDefaultModel(provider: string): void { writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`); } + +interface RuntimeProviderConfigOverride { + baseUrl?: string; + api?: string; + apiKeyEnv?: string; +} + +/** + * Update OpenClaw model + provider config using runtime config values. + * Useful for user-configurable providers (custom/ollama-like) where + * baseUrl/model are not in the static registry. + */ +export function setOpenClawDefaultModelWithOverride( + provider: string, + modelOverride: string | undefined, + override: RuntimeProviderConfigOverride +): void { + const configPath = join(homedir(), '.openclaw', 'openclaw.json'); + + let config: Record = {}; + try { + if (existsSync(configPath)) { + config = JSON.parse(readFileSync(configPath, 'utf-8')); + } + } catch (err) { + console.warn('Failed to read openclaw.json, creating fresh config:', err); + } + + const model = modelOverride || getProviderDefaultModel(provider); + if (!model) { + console.warn(`No default model mapping for provider "${provider}"`); + return; + } + + const modelId = model.startsWith(`${provider}/`) + ? model.slice(provider.length + 1) + : model; + + const agents = (config.agents || {}) as Record; + const defaults = (agents.defaults || {}) as Record; + defaults.model = { primary: model }; + agents.defaults = defaults; + config.agents = agents; + + if (override.baseUrl && override.api) { + const models = (config.models || {}) as Record; + const providers = (models.providers || {}) as Record; + + const existingProvider = + providers[provider] && typeof providers[provider] === 'object' + ? (providers[provider] as Record) + : {}; + + const existingModels = Array.isArray(existingProvider.models) + ? (existingProvider.models as Array>) + : []; + const mergedModels = [...existingModels]; + if (modelId && !mergedModels.some((m) => m.id === modelId)) { + mergedModels.push({ id: modelId, name: modelId }); + } + + const nextProvider: Record = { + ...existingProvider, + baseUrl: override.baseUrl, + api: override.api, + models: mergedModels, + }; + if (override.apiKeyEnv) { + nextProvider.apiKey = override.apiKeyEnv; + } + + providers[provider] = nextProvider; + models.providers = providers; + config.models = models; + } + + const gateway = (config.gateway || {}) as Record; + if (!gateway.mode) { + gateway.mode = 'local'; + } + config.gateway = gateway; + + const dir = join(configPath, '..'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + console.log( + `Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)` + ); +} + +// Re-export for backwards compatibility +export { getProviderEnvVar } from './provider-registry'; diff --git a/electron/utils/provider-registry.ts b/electron/utils/provider-registry.ts new file mode 100644 index 000000000..0d218b4f7 --- /dev/null +++ b/electron/utils/provider-registry.ts @@ -0,0 +1,133 @@ +/** + * Provider Registry — single source of truth for backend provider metadata. + * Centralizes env var mappings, default models, and OpenClaw provider configs. + * + * NOTE: When adding a new provider type, also update src/lib/providers.ts + */ + +export const BUILTIN_PROVIDER_TYPES = [ + 'anthropic', + 'openai', + 'google', + 'openrouter', + 'moonshot', + 'siliconflow', + 'ollama', +] as const; +export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number]; +export type ProviderType = BuiltinProviderType | 'custom'; + +interface ProviderModelEntry extends Record { + id: string; + name: string; +} + + +interface ProviderBackendMeta { + envVar?: string; + defaultModel?: string; + /** OpenClaw models.providers config (omit for built-in providers like anthropic) */ + providerConfig?: { + baseUrl: string; + api: string; + apiKeyEnv: string; + models?: ProviderModelEntry[]; + }; +} + +const REGISTRY: Record = { + anthropic: { + envVar: 'ANTHROPIC_API_KEY', + defaultModel: 'anthropic/claude-opus-4-6', + // anthropic is built-in to OpenClaw's model registry, no provider config needed + }, + openai: { + envVar: 'OPENAI_API_KEY', + defaultModel: 'openai/gpt-5.2', + providerConfig: { + baseUrl: 'https://api.openai.com/v1', + api: 'openai-responses', + apiKeyEnv: 'OPENAI_API_KEY', + }, + }, + google: { + envVar: 'GEMINI_API_KEY', + defaultModel: 'google/gemini-3-pro-preview', + providerConfig: { + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + api: 'google', + apiKeyEnv: 'GEMINI_API_KEY', + }, + }, + openrouter: { + envVar: 'OPENROUTER_API_KEY', + defaultModel: 'openrouter/anthropic/claude-opus-4.6', + providerConfig: { + baseUrl: 'https://openrouter.ai/api/v1', + api: 'openai-completions', + apiKeyEnv: 'OPENROUTER_API_KEY', + }, + }, + moonshot: { + envVar: 'MOONSHOT_API_KEY', + defaultModel: 'moonshot/kimi-k2.5', + providerConfig: { + baseUrl: 'https://api.moonshot.cn/v1', + api: 'openai-completions', + apiKeyEnv: 'MOONSHOT_API_KEY', + models: [ + { + id: 'kimi-k2.5', + name: 'Kimi K2.5', + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 256000, + maxTokens: 8192, + }, + ], + }, + }, + siliconflow: { + envVar: 'SILICONFLOW_API_KEY', + defaultModel: 'siliconflow/deepseek-ai/DeepSeek-V3', + providerConfig: { + baseUrl: 'https://api.siliconflow.com/v1', + api: 'openai-completions', + apiKeyEnv: 'SILICONFLOW_API_KEY', + }, + }, + // Additional providers with env var mappings but no default model + groq: { envVar: 'GROQ_API_KEY' }, + deepgram: { envVar: 'DEEPGRAM_API_KEY' }, + cerebras: { envVar: 'CEREBRAS_API_KEY' }, + xai: { envVar: 'XAI_API_KEY' }, + mistral: { envVar: 'MISTRAL_API_KEY' }, +}; + +/** Get the environment variable name for a provider type */ +export function getProviderEnvVar(type: string): string | undefined { + return REGISTRY[type]?.envVar; +} + +/** Get the default model string for a provider type */ +export function getProviderDefaultModel(type: string): string | undefined { + return REGISTRY[type]?.defaultModel; +} + +/** Get the OpenClaw provider config (baseUrl, api, apiKeyEnv, models) */ +export function getProviderConfig( + type: string +): { baseUrl: string; api: string; apiKeyEnv: string; models?: ProviderModelEntry[] } | undefined { + return REGISTRY[type]?.providerConfig; +} + +/** + * All provider types that have env var mappings. + * Used by GatewayManager to inject API keys as env vars. + */ +export function getKeyableProviderTypes(): string[] { + return Object.entries(REGISTRY) + .filter(([, meta]) => meta.envVar) + .map(([type]) => type); +} diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index a9b28d6c8..cc6f0089b 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -1,35 +1,22 @@ /** - * Secure Storage Utility - * Uses Electron's safeStorage for encrypting sensitive data like API keys + * Provider Storage + * Manages provider configurations and API keys. + * Keys are stored in plain text alongside provider configs in a single electron-store. */ -import { safeStorage } from 'electron'; // Lazy-load electron-store (ESM module) // eslint-disable-next-line @typescript-eslint/no-explicit-any -let store: any = null; -// eslint-disable-next-line @typescript-eslint/no-explicit-any let providerStore: any = null; -async function getStore() { - if (!store) { - const Store = (await import('electron-store')).default; - store = new Store({ - name: 'clawx-secure', - defaults: { - encryptedKeys: {}, - }, - }); - } - return store; -} - async function getProviderStore() { if (!providerStore) { const Store = (await import('electron-store')).default; providerStore = new Store({ name: 'clawx-providers', defaults: { - providers: {}, + providers: {} as Record, + apiKeys: {} as Record, + defaultProvider: null as string | null, }, }); } @@ -42,7 +29,7 @@ async function getProviderStore() { export interface ProviderConfig { id: string; name: string; - type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'ollama' | 'custom'; + type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'moonshot' | 'siliconflow' | 'ollama' | 'custom'; baseUrl?: string; model?: string; enabled: boolean; @@ -50,35 +37,17 @@ export interface ProviderConfig { updatedAt: string; } -/** - * Check if encryption is available - */ -export function isEncryptionAvailable(): boolean { - return safeStorage.isEncryptionAvailable(); -} +// ==================== API Key Storage ==================== /** - * Store an API key securely + * Store an API key */ export async function storeApiKey(providerId: string, apiKey: string): Promise { try { - const s = await getStore(); - - if (!safeStorage.isEncryptionAvailable()) { - console.warn('Encryption not available, storing key in plain text'); - // Fallback to plain storage (not recommended for production) - const keys = s.get('encryptedKeys') as Record; - keys[providerId] = Buffer.from(apiKey).toString('base64'); - s.set('encryptedKeys', keys); - return true; - } - - // Encrypt the API key - const encrypted = safeStorage.encryptString(apiKey); - const keys = s.get('encryptedKeys') as Record; - keys[providerId] = encrypted.toString('base64'); - s.set('encryptedKeys', keys); - + const s = await getProviderStore(); + const keys = (s.get('apiKeys') || {}) as Record; + keys[providerId] = apiKey; + s.set('apiKeys', keys); return true; } catch (error) { console.error('Failed to store API key:', error); @@ -91,22 +60,9 @@ export async function storeApiKey(providerId: string, apiKey: string): Promise { try { - const s = await getStore(); - const keys = s.get('encryptedKeys') as Record; - const encryptedBase64 = keys[providerId]; - - if (!encryptedBase64) { - return null; - } - - if (!safeStorage.isEncryptionAvailable()) { - // Fallback for plain storage - return Buffer.from(encryptedBase64, 'base64').toString('utf-8'); - } - - // Decrypt the API key - const encrypted = Buffer.from(encryptedBase64, 'base64'); - return safeStorage.decryptString(encrypted); + const s = await getProviderStore(); + const keys = (s.get('apiKeys') || {}) as Record; + return keys[providerId] || null; } catch (error) { console.error('Failed to retrieve API key:', error); return null; @@ -118,10 +74,10 @@ export async function getApiKey(providerId: string): Promise { */ export async function deleteApiKey(providerId: string): Promise { try { - const s = await getStore(); - const keys = s.get('encryptedKeys') as Record; + const s = await getProviderStore(); + const keys = (s.get('apiKeys') || {}) as Record; delete keys[providerId]; - s.set('encryptedKeys', keys); + s.set('apiKeys', keys); return true; } catch (error) { console.error('Failed to delete API key:', error); @@ -133,8 +89,8 @@ export async function deleteApiKey(providerId: string): Promise { * Check if an API key exists for a provider */ export async function hasApiKey(providerId: string): Promise { - const s = await getStore(); - const keys = s.get('encryptedKeys') as Record; + const s = await getProviderStore(); + const keys = (s.get('apiKeys') || {}) as Record; return providerId in keys; } @@ -142,8 +98,8 @@ export async function hasApiKey(providerId: string): Promise { * List all provider IDs that have stored keys */ export async function listStoredKeyIds(): Promise { - const s = await getStore(); - const keys = s.get('encryptedKeys') as Record; + const s = await getProviderStore(); + const keys = (s.get('apiKeys') || {}) as Record; return Object.keys(keys); } @@ -178,24 +134,24 @@ export async function getAllProviders(): Promise { } /** - * Delete a provider configuration + * Delete a provider configuration and its API key */ export async function deleteProvider(providerId: string): Promise { try { - // Delete the API key first + // Delete the API key await deleteApiKey(providerId); - + // Delete the provider config const s = await getProviderStore(); const providers = s.get('providers') as Record; delete providers[providerId]; s.set('providers', providers); - + // Clear default if this was the default if (s.get('defaultProvider') === providerId) { s.delete('defaultProvider'); } - + return true; } catch (error) { console.error('Failed to delete provider:', error); @@ -222,22 +178,23 @@ export async function getDefaultProvider(): Promise { /** * Get provider with masked key info (for UI display) */ -export async function getProviderWithKeyInfo(providerId: string): Promise<(ProviderConfig & { hasKey: boolean; keyMasked: string | null }) | null> { +export async function getProviderWithKeyInfo( + providerId: string +): Promise<(ProviderConfig & { hasKey: boolean; keyMasked: string | null }) | null> { const provider = await getProvider(providerId); if (!provider) return null; - + const apiKey = await getApiKey(providerId); let keyMasked: string | null = null; - + if (apiKey) { - // Show first 4 and last 4 characters if (apiKey.length > 12) { keyMasked = `${apiKey.substring(0, 4)}${'*'.repeat(apiKey.length - 8)}${apiKey.substring(apiKey.length - 4)}`; } else { keyMasked = '*'.repeat(apiKey.length); } } - + return { ...provider, hasKey: !!apiKey, @@ -248,14 +205,16 @@ export async function getProviderWithKeyInfo(providerId: string): Promise<(Provi /** * Get all providers with key info (for UI display) */ -export async function getAllProvidersWithKeyInfo(): Promise> { +export async function getAllProvidersWithKeyInfo(): Promise< + Array +> { const providers = await getAllProviders(); const results: Array = []; - + for (const provider of providers) { const apiKey = await getApiKey(provider.id); let keyMasked: string | null = null; - + if (apiKey) { if (apiKey.length > 12) { keyMasked = `${apiKey.substring(0, 4)}${'*'.repeat(apiKey.length - 8)}${apiKey.substring(apiKey.length - 4)}`; @@ -263,13 +222,13 @@ export async function getAllProvidersWithKeyInfo(): Promise { + const handleAddProvider = async ( + type: ProviderType, + name: string, + apiKey: string, + options?: { baseUrl?: string; model?: string } + ) => { + // Only custom supports multiple instances. + // Built-in providers remain singleton by type. + const id = type === 'custom' ? `custom-${crypto.randomUUID()}` : type; try { - await addProvider({ - id: `${type}-${Date.now()}`, - type: type as 'anthropic' | 'openai' | 'google' | 'ollama' | 'custom', - name, - enabled: true, - }, apiKey || undefined); - + await addProvider( + { + id, + type, + name, + baseUrl: options?.baseUrl, + model: options?.model, + enabled: true, + }, + apiKey.trim() || undefined + ); + + // Auto-set as default if this is the first provider + if (providers.length === 0) { + await setDefaultProvider(id); + } + setShowAddDialog(false); toast.success('Provider added successfully'); } catch (error) { @@ -140,8 +152,12 @@ export function ProvidersSettings() { onDelete={() => handleDeleteProvider(provider.id)} onSetDefault={() => handleSetDefault(provider.id)} onToggleEnabled={() => handleToggleEnabled(provider)} - onUpdateKey={async (key) => { - await setApiKey(provider.id, key); + onSaveEdits={async (payload) => { + await updateProviderWithKey( + provider.id, + payload.updates || {}, + payload.newApiKey + ); setEditingProvider(null); }} onValidateKey={(key) => validateApiKey(provider.id, key)} @@ -153,8 +169,10 @@ export function ProvidersSettings() { {/* Add Provider Dialog */} {showAddDialog && ( p.type))} onClose={() => setShowAddDialog(false)} onAdd={handleAddProvider} + onValidateKey={(type, key) => validateApiKey(type, key)} /> )} @@ -170,7 +188,7 @@ interface ProviderCardProps { onDelete: () => void; onSetDefault: () => void; onToggleEnabled: () => void; - onUpdateKey: (key: string) => Promise; + onSaveEdits: (payload: { newApiKey?: string; updates?: Partial }) => Promise; onValidateKey: (key: string) => Promise<{ valid: boolean; error?: string }>; } @@ -198,37 +216,78 @@ function ProviderCard({ onDelete, onSetDefault, onToggleEnabled, - onUpdateKey, + onSaveEdits, onValidateKey, }: ProviderCardProps) { const [newKey, setNewKey] = useState(''); + const [baseUrl, setBaseUrl] = useState(provider.baseUrl || ''); + const [modelId, setModelId] = useState(provider.model || ''); const [showKey, setShowKey] = useState(false); const [validating, setValidating] = useState(false); const [saving, setSaving] = useState(false); - const typeInfo = providerTypes.find((t) => t.id === provider.type); - - const handleSaveKey = async () => { - if (!newKey) return; - - setValidating(true); - const result = await onValidateKey(newKey); - setValidating(false); - - if (!result.valid) { - toast.error(result.error || 'Invalid API key'); - return; + const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === provider.type); + const canEditConfig = Boolean(typeInfo?.showBaseUrl || typeInfo?.showModelId); + + useEffect(() => { + if (isEditing) { + setNewKey(''); + setShowKey(false); + setBaseUrl(provider.baseUrl || ''); + setModelId(provider.model || ''); } - + }, [isEditing, provider.baseUrl, provider.model]); + + const handleSaveEdits = async () => { setSaving(true); try { - await onUpdateKey(newKey); + const payload: { newApiKey?: string; updates?: Partial } = {}; + + if (newKey.trim()) { + setValidating(true); + const result = await onValidateKey(newKey); + setValidating(false); + if (!result.valid) { + toast.error(result.error || 'Invalid API key'); + setSaving(false); + return; + } + payload.newApiKey = newKey.trim(); + } + + if (canEditConfig) { + if (typeInfo?.showModelId && !modelId.trim()) { + toast.error('Model ID is required'); + setSaving(false); + return; + } + + const updates: Partial = {}; + if ((baseUrl.trim() || undefined) !== (provider.baseUrl || undefined)) { + updates.baseUrl = baseUrl.trim() || undefined; + } + if ((modelId.trim() || undefined) !== (provider.model || undefined)) { + updates.model = modelId.trim() || undefined; + } + if (Object.keys(updates).length > 0) { + payload.updates = updates; + } + } + + if (!payload.newApiKey && !payload.updates) { + onCancelEdit(); + setSaving(false); + return; + } + + await onSaveEdits(payload); setNewKey(''); - toast.success('API key updated'); + toast.success('Provider updated'); } catch (error) { - toast.error(`Failed to save key: ${error}`); + toast.error(`Failed to save provider: ${error}`); } finally { setSaving(false); + setValidating(false); } }; @@ -258,11 +317,37 @@ function ProviderCard({ {/* Key row */} {isEditing ? (
+ {canEditConfig && ( + <> + {typeInfo?.showBaseUrl && ( +
+ + setBaseUrl(e.target.value)} + placeholder="https://api.example.com/v1" + className="h-9 text-sm" + /> +
+ )} + {typeInfo?.showModelId && ( +
+ + setModelId(e.target.value)} + placeholder={typeInfo.modelIdPlaceholder || 'provider/model-id'} + className="h-9 text-sm" + /> +
+ )} + + )}
setNewKey(e.target.value)} className="pr-10 h-9 text-sm" @@ -278,8 +363,17 @@ function ProviderCard({
+ {validationError && ( +

{validationError}

+ )}

- Your API key will be securely encrypted and stored locally. + Your API key is stored locally on your machine.

+ + {typeInfo?.showBaseUrl && ( +
+ + setBaseUrl(e.target.value)} + /> +
+ )} + + {typeInfo?.showModelId && ( +
+ + { + setModelId(e.target.value); + setValidationError(null); + }} + /> +
+ )}
)} @@ -433,7 +617,7 @@ function AddProviderDialog({ onClose, onAdd }: AddProviderDialogProps) { )} - {!isLastStep && currentStep !== STEP.RUNTIME && currentStep !== STEP.CHANNEL && ( + {!isLastStep && safeStepIndex !== STEP.RUNTIME && safeStepIndex !== STEP.CHANNEL && ( @@ -641,11 +636,12 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) { } interface ProviderContentProps { - providers: Provider[]; + providers: ProviderTypeInfo[]; selectedProvider: string | null; onSelectProvider: (id: string | null) => void; apiKey: string; onApiKeyChange: (key: string) => void; + onConfiguredChange: (configured: boolean) => void; } function ProviderContent({ @@ -653,24 +649,39 @@ function ProviderContent({ selectedProvider, onSelectProvider, apiKey, - onApiKeyChange + onApiKeyChange, + onConfiguredChange, }: ProviderContentProps) { const [showKey, setShowKey] = useState(false); const [validating, setValidating] = useState(false); const [keyValid, setKeyValid] = useState(null); + const [baseUrl, setBaseUrl] = useState(''); + const [modelId, setModelId] = useState(''); + + // On mount, try to restore previously configured provider useEffect(() => { let cancelled = false; (async () => { try { - const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; hasKey: boolean }>; + const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>; const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null; - const preferred = (defaultId && list.find((p) => p.id === defaultId && p.hasKey)) || list.find((p) => p.hasKey); + const setupProviderTypes = new Set(providers.map((p) => p.id)); + const setupCandidates = list.filter((p) => setupProviderTypes.has(p.type)); + const preferred = + (defaultId && setupCandidates.find((p) => p.id === defaultId)) + || setupCandidates.find((p) => p.hasKey) + || setupCandidates[0]; if (preferred && !cancelled) { - onSelectProvider(preferred.id); + onSelectProvider(preferred.type); + const typeInfo = providers.find((p) => p.id === preferred.type); + const requiresKey = typeInfo?.requiresApiKey ?? false; + onConfiguredChange(!requiresKey || preferred.hasKey); const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', preferred.id) as string | null; if (storedKey) { onApiKeyChange(storedKey); } + } else if (!cancelled) { + onConfiguredChange(false); } } catch (error) { if (!cancelled) { @@ -679,15 +690,36 @@ function ProviderContent({ } })(); return () => { cancelled = true; }; - }, [onApiKeyChange, onSelectProvider]); + }, [onApiKeyChange, onConfiguredChange, onSelectProvider, providers]); + + // When provider changes, load stored key + reset base URL useEffect(() => { let cancelled = false; (async () => { if (!selectedProvider) return; try { - const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', selectedProvider) as string | null; - if (!cancelled && storedKey) { - onApiKeyChange(storedKey); + const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>; + const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null; + const sameType = list.filter((p) => p.type === selectedProvider); + const preferredInstance = + (defaultId && sameType.find((p) => p.id === defaultId)) + || sameType.find((p) => p.hasKey) + || sameType[0]; + const providerIdForLoad = preferredInstance?.id || selectedProvider; + + const savedProvider = await window.electron.ipcRenderer.invoke( + 'provider:get', + providerIdForLoad + ) as { baseUrl?: string; model?: string } | null; + const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', providerIdForLoad) as string | null; + if (!cancelled) { + if (storedKey) { + onApiKeyChange(storedKey); + } + + const info = providers.find((p) => p.id === selectedProvider); + setBaseUrl(savedProvider?.baseUrl || info?.defaultBaseUrl || ''); + setModelId(savedProvider?.model || info?.defaultModelId || ''); } } catch (error) { if (!cancelled) { @@ -696,95 +728,177 @@ function ProviderContent({ } })(); return () => { cancelled = true; }; - }, [onApiKeyChange, selectedProvider]); + }, [onApiKeyChange, selectedProvider, providers]); const selectedProviderData = providers.find((p) => p.id === selectedProvider); + const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false; + const showModelIdField = selectedProviderData?.showModelId ?? false; + const requiresKey = selectedProviderData?.requiresApiKey ?? false; - const handleValidateKey = async () => { - if (!apiKey || !selectedProvider) return; + const handleValidateAndSave = async () => { + if (!selectedProvider) return; setValidating(true); setKeyValid(null); try { - // Call real API validation - const result = await window.electron.ipcRenderer.invoke( - 'provider:validateKey', - selectedProvider, - apiKey - ) as { valid: boolean; error?: string }; - - setKeyValid(result.valid); - - if (result.valid) { - // Save the API key to both ClawX secure storage and OpenClaw auth-profiles - try { - await window.electron.ipcRenderer.invoke( - 'provider:save', - { - id: selectedProvider, - name: selectedProviderData?.name || selectedProvider, - type: selectedProvider, - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - apiKey - ); - } catch (saveErr) { - console.warn('Failed to persist API key:', saveErr); + // Validate key if the provider requires one and a key was entered + if (requiresKey && apiKey) { + const result = await window.electron.ipcRenderer.invoke( + 'provider:validateKey', + selectedProvider, + apiKey + ) as { valid: boolean; error?: string }; + + setKeyValid(result.valid); + + if (!result.valid) { + toast.error(result.error || 'Invalid API key'); + setValidating(false); + return; } - toast.success('API key validated and saved'); } else { - toast.error(result.error || 'Invalid API key'); + setKeyValid(true); } + + const effectiveModelId = + selectedProviderData?.defaultModelId || + modelId.trim() || + undefined; + + // Save provider config + API key, then set as default + const saveResult = await window.electron.ipcRenderer.invoke( + 'provider:save', + { + id: selectedProvider, + name: selectedProviderData?.name || selectedProvider, + type: selectedProvider, + baseUrl: baseUrl.trim() || undefined, + model: effectiveModelId, + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + apiKey || undefined + ) as { success: boolean; error?: string }; + + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to save provider config'); + } + + const defaultResult = await window.electron.ipcRenderer.invoke( + 'provider:setDefault', + selectedProvider + ) as { success: boolean; error?: string }; + + if (!defaultResult.success) { + throw new Error(defaultResult.error || 'Failed to set default provider'); + } + + onConfiguredChange(true); + toast.success('Provider configured'); } catch (error) { setKeyValid(false); - toast.error('Validation failed: ' + String(error)); + onConfiguredChange(false); + toast.error('Configuration failed: ' + String(error)); } finally { setValidating(false); } }; + + // Can the user submit? + const canSubmit = + selectedProvider + && (requiresKey ? apiKey.length > 0 : true) + && (showModelIdField ? modelId.trim().length > 0 : true); return (
-
-

Select AI Provider

-

- Choose your preferred AI model provider -

-
- -
- {providers.map((provider) => ( -
-
- {keyValid !== null && ( -

- {keyValid ? '✓ API key is valid' : '✗ Invalid API key'} -

- )} - - -

- Your API key will be securely stored in the system keychain. + )} + + {/* Validate & Save */} + + + {keyValid !== null && ( +

+ {keyValid ? '✓ Provider configured successfully' : '✗ Invalid API key'} +

+ )} + +

+ Your API key is stored locally on your machine.

)} diff --git a/src/stores/chat.ts b/src/stores/chat.ts index a5fef1ff2..9c6baad81 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -283,6 +283,8 @@ export const useChatStore = create((set, get) => ({ set({ error: result.error || 'Failed to send message', sending: false }); } else if (result.result?.runId) { set({ activeRunId: result.result.runId }); + } else { + // No runId from gateway; keep sending state and wait for events. } } catch (err) { set({ error: String(err), sending: false }); diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index f6e6547e1..db8c4a4b0 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -5,6 +5,8 @@ import { create } from 'zustand'; import type { GatewayStatus } from '../types/gateway'; +let gatewayInitPromise: Promise | null = null; + interface GatewayHealth { ok: boolean; error?: string; @@ -39,47 +41,79 @@ export const useGatewayStore = create((set, get) => ({ init: async () => { if (get().isInitialized) return; - - try { - // Get initial status - const status = await window.electron.ipcRenderer.invoke('gateway:status') as GatewayStatus; - set({ status, isInitialized: true }); - - // Listen for status changes - window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => { - set({ status: newStatus as GatewayStatus }); - }); - - // Listen for errors - window.electron.ipcRenderer.on('gateway:error', (error) => { - set({ lastError: String(error) }); - }); - - // Listen for notifications - window.electron.ipcRenderer.on('gateway:notification', (notification) => { - console.log('Gateway notification:', notification); - }); - - // Listen for chat events from the gateway and forward to chat store - window.electron.ipcRenderer.on('gateway:chat-message', (data) => { - try { - // Dynamic import to avoid circular dependency - import('./chat').then(({ useChatStore }) => { - const chatData = data as { message?: Record } | Record; - const event = ('message' in chatData && typeof chatData.message === 'object') - ? chatData.message as Record - : chatData as Record; - useChatStore.getState().handleChatEvent(event); - }); - } catch (err) { - console.warn('Failed to forward chat event:', err); - } - }); - - } catch (error) { - console.error('Failed to initialize Gateway:', error); - set({ lastError: String(error) }); + if (gatewayInitPromise) { + await gatewayInitPromise; + return; } + + gatewayInitPromise = (async () => { + try { + // Get initial status first + const status = await window.electron.ipcRenderer.invoke('gateway:status') as GatewayStatus; + set({ status, isInitialized: true }); + + // Listen for status changes + window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => { + set({ status: newStatus as GatewayStatus }); + }); + + // Listen for errors + window.electron.ipcRenderer.on('gateway:error', (error) => { + set({ lastError: String(error) }); + }); + + // Some Gateway builds stream chat events via generic "agent" notifications. + // Normalize and forward them to the chat store. + window.electron.ipcRenderer.on('gateway:notification', (notification) => { + const payload = notification as { method?: string; params?: Record } | undefined; + if (!payload || payload.method !== 'agent' || !payload.params || typeof payload.params !== 'object') { + return; + } + + const p = payload.params; + const data = (p.data && typeof p.data === 'object') ? (p.data as Record) : {}; + const normalizedEvent: Record = { + ...data, + runId: p.runId ?? data.runId, + sessionKey: p.sessionKey ?? data.sessionKey, + stream: p.stream ?? data.stream, + seq: p.seq ?? data.seq, + }; + + import('./chat') + .then(({ useChatStore }) => { + useChatStore.getState().handleChatEvent(normalizedEvent); + }) + .catch((err) => { + console.warn('Failed to forward gateway notification event:', err); + }); + }); + + // Listen for chat events from the gateway and forward to chat store + window.electron.ipcRenderer.on('gateway:chat-message', (data) => { + try { + // Dynamic import to avoid circular dependency + import('./chat').then(({ useChatStore }) => { + const chatData = data as { message?: Record } | Record; + const event = ('message' in chatData && typeof chatData.message === 'object') + ? chatData.message as Record + : chatData as Record; + useChatStore.getState().handleChatEvent(event); + }); + } catch (err) { + console.warn('Failed to forward chat event:', err); + } + }); + + } catch (error) { + console.error('Failed to initialize Gateway:', error); + set({ lastError: String(error) }); + } finally { + gatewayInitPromise = null; + } + })(); + + await gatewayInitPromise; }, start: async () => { diff --git a/src/stores/providers.ts b/src/stores/providers.ts index 755781256..96e64c458 100644 --- a/src/stores/providers.ts +++ b/src/stores/providers.ts @@ -3,28 +3,10 @@ * Manages AI provider configurations */ import { create } from 'zustand'; +import type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers'; -/** - * Provider configuration - */ -export interface ProviderConfig { - id: string; - name: string; - type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'ollama' | 'custom'; - baseUrl?: string; - model?: string; - enabled: boolean; - createdAt: string; - updatedAt: string; -} - -/** - * Provider with key info (for display) - */ -export interface ProviderWithKeyInfo extends ProviderConfig { - hasKey: boolean; - keyMasked: string | null; -} +// Re-export types for consumers that imported from here +export type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers'; interface ProviderState { providers: ProviderWithKeyInfo[]; @@ -38,6 +20,11 @@ interface ProviderState { updateProvider: (providerId: string, updates: Partial, apiKey?: string) => Promise; deleteProvider: (providerId: string) => Promise; setApiKey: (providerId: string, apiKey: string) => Promise; + updateProviderWithKey: ( + providerId: string, + updates: Partial, + apiKey?: string + ) => Promise; deleteApiKey: (providerId: string) => Promise; setDefaultProvider: (providerId: string) => Promise; validateApiKey: (providerId: string, apiKey: string) => Promise<{ valid: boolean; error?: string }>; @@ -95,9 +82,11 @@ export const useProviderStore = create((set, get) => ({ if (!existing) { throw new Error('Provider not found'); } + + const { hasKey: _hasKey, keyMasked: _keyMasked, ...providerConfig } = existing; const updatedConfig: ProviderConfig = { - ...existing, + ...providerConfig, ...updates, updatedAt: new Date().toISOString(), }; @@ -147,6 +136,26 @@ export const useProviderStore = create((set, get) => ({ throw error; } }, + + updateProviderWithKey: async (providerId, updates, apiKey) => { + try { + const result = await window.electron.ipcRenderer.invoke( + 'provider:updateWithKey', + providerId, + updates, + apiKey + ) as { success: boolean; error?: string }; + + if (!result.success) { + throw new Error(result.error || 'Failed to update provider'); + } + + await get().fetchProviders(); + } catch (error) { + console.error('Failed to update provider with key:', error); + throw error; + } + }, deleteApiKey: async (providerId) => { try { diff --git a/src/types/channel.ts b/src/types/channel.ts index 88001d196..71a8d2f8b 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -448,4 +448,4 @@ export function getPrimaryChannels(): ChannelType[] { */ export function getAllChannels(): ChannelType[] { return Object.keys(CHANNEL_META) as ChannelType[]; -} +} \ No newline at end of file