diff --git a/build_process/commit_12_api_key_validation.md b/build_process/commit_12_api_key_validation.md new file mode 100644 index 000000000..65d1b7fc3 --- /dev/null +++ b/build_process/commit_12_api_key_validation.md @@ -0,0 +1,87 @@ +# Commit 12: Real API Key Validation & OpenRouter Support + +## Overview +Implemented real API key validation by making actual API calls to each provider, replacing the mock validation. Also added OpenRouter as a new provider option. + +## Changes + +### 1. Real API Key Validation (`electron/main/ipc-handlers.ts`) + +**Before**: Mock validation that only checked key format (e.g., `apiKey.startsWith('sk-ant-')`) + +**After**: Real API validation that sends a minimal chat completion request to verify the key works + +#### New Functions Added: +- `validateApiKeyWithProvider(providerType, apiKey)` - Routes to provider-specific validation +- `validateAnthropicKey(apiKey)` - Calls Anthropic `/v1/messages` endpoint +- `validateOpenAIKey(apiKey)` - Calls OpenAI `/v1/chat/completions` endpoint +- `validateGoogleKey(apiKey)` - Calls Google Gemini `generateContent` endpoint +- `validateOpenRouterKey(apiKey)` - Calls OpenRouter `/api/v1/chat/completions` endpoint +- `parseApiError(data)` - Extracts user-friendly error messages from API responses + +#### Validation Logic: +- Sends minimal request with `max_tokens: 1` and message "hi" +- HTTP 200: Key is valid +- HTTP 401/403: Invalid API key +- HTTP 429: Rate limited but key is valid +- HTTP 402 (OpenRouter): No credits but key is valid +- HTTP 400/404: Check error message for auth vs model issues + +#### Error Handling: +- Returns user-friendly "Invalid API key" instead of raw API errors like "User not found." + +### 2. Setup Page Real Validation (`src/pages/Setup/index.tsx`) + +**Before**: +```typescript +// Mock validation +await new Promise((resolve) => setTimeout(resolve, 1500)); +const isValid = apiKey.length > 10; +``` + +**After**: +```typescript +// Real API validation via IPC +const result = await window.electron.ipcRenderer.invoke( + 'provider:validateKey', + selectedProvider, + apiKey +); +``` + +### 3. OpenRouter Provider Support + +Added OpenRouter to: +- `src/pages/Setup/index.tsx` - Provider selection in setup wizard +- `src/components/settings/ProvidersSettings.tsx` - Provider settings panel +- `electron/utils/secure-storage.ts` - ProviderConfig type +- `src/stores/providers.ts` - ProviderConfig type + +### 4. IPC Handler Improvement + +Modified `provider:validateKey` handler to accept provider type directly: +- During setup, provider may not exist in storage yet +- Falls back to using `providerId` as the provider type +- Enables validation before provider is saved + +## Files Changed +- `electron/main/ipc-handlers.ts` - Real API validation implementation (+300 lines) +- `src/pages/Setup/index.tsx` - Real validation call, OpenRouter option +- `src/components/settings/ProvidersSettings.tsx` - OpenRouter option +- `electron/utils/secure-storage.ts` - OpenRouter type +- `src/stores/providers.ts` - OpenRouter type + +## API Endpoints Used +| Provider | Endpoint | Model | +|----------|----------|-------| +| Anthropic | `https://api.anthropic.com/v1/messages` | claude-3-haiku-20240307 | +| OpenAI | `https://api.openai.com/v1/chat/completions` | gpt-4o-mini | +| Google | `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent` | gemini-2.0-flash | +| OpenRouter | `https://openrouter.ai/api/v1/chat/completions` | meta-llama/llama-3.2-3b-instruct:free | + +## Testing +1. Select OpenRouter in setup wizard +2. Enter an invalid API key (e.g., "asdasfdsadf") +3. Click Validate - should show "Invalid API key" +4. Enter a valid API key +5. Click Validate - should show "API key validated successfully" diff --git a/build_process/process.md b/build_process/process.md index 7da31783c..b60ff3092 100644 --- a/build_process/process.md +++ b/build_process/process.md @@ -17,6 +17,7 @@ * [commit_9] Skills browser - Bundles, categories, detail dialog * [commit_10] Cron tasks - Create/edit dialog, schedule presets, improved UI * [commit_11] OpenClaw submodule fix - GitHub URL, auto-generated token, WebSocket auth +* [commit_12] Real API key validation - OpenRouter support, actual API calls to verify keys ### Plan: 1. ~~Initialize project structure~~ ✅ @@ -44,6 +45,7 @@ All core features have been implemented: - Skills browser with bundles - Cron tasks management for scheduled automation - OpenClaw submodule from official GitHub (v2026.2.3) with auto-token auth +- Real API key validation via actual API calls (Anthropic, OpenAI, Google, OpenRouter) ## Version Milestones diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index eb93e5f25..60273958b 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -266,45 +266,326 @@ function registerProviderHandlers(): void { return await getDefaultProvider(); }); - // Validate API key by making a test request (simulated for now) + // Validate API key by making a real test request to the provider + // providerId can be either a stored provider ID or a provider type (e.g., 'openrouter', 'anthropic') ipcMain.handle('provider:validateKey', async (_, providerId: string, apiKey: string) => { - // In a real implementation, this would make a test API call to the provider - // For now, we'll just do basic format validation try { - // Basic validation based on provider type + // First try to get existing provider const provider = await getProvider(providerId); - if (!provider) { - return { valid: false, error: 'Provider not found' }; - } - switch (provider.type) { - case 'anthropic': - if (!apiKey.startsWith('sk-ant-')) { - return { valid: false, error: 'Anthropic keys should start with sk-ant-' }; - } - break; - case 'openai': - if (!apiKey.startsWith('sk-')) { - return { valid: false, error: 'OpenAI keys should start with sk-' }; - } - break; - case 'google': - if (apiKey.length < 20) { - return { valid: false, error: 'Google API key seems too short' }; - } - break; - } + // Use provider.type if provider exists, otherwise use providerId as the type + // This allows validation during setup when provider hasn't been saved yet + const providerType = provider?.type || providerId; - // Simulate API validation delay - await new Promise((resolve) => setTimeout(resolve, 1000)); - - return { valid: true }; + console.log(`Validating API key for provider type: ${providerType}`); + return await validateApiKeyWithProvider(providerType, apiKey); } catch (error) { + console.error('Validation error:', error); return { valid: false, error: String(error) }; } }); } +/** + * 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 + */ +async function validateApiKeyWithProvider( + providerType: string, + apiKey: string +): Promise<{ valid: boolean; error?: string }> { + const trimmedKey = apiKey.trim(); + if (!trimmedKey) { + return { valid: false, error: 'API key is required' }; + } + + try { + switch (providerType) { + case 'anthropic': + return await validateAnthropicKey(trimmedKey); + case 'openai': + return await validateOpenAIKey(trimmedKey); + case 'google': + return await validateGoogleKey(trimmedKey); + case 'openrouter': + return await validateOpenRouterKey(trimmedKey); + case 'ollama': + // Ollama doesn't require API key validation + return { valid: true }; + default: + // For custom providers, just check the key is not empty + return { valid: true }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { valid: false, error: errorMessage }; + } +} + +/** + * Parse error message from API response + */ +function parseApiError(data: unknown): string { + if (!data || typeof data !== 'object') return 'Unknown error'; + + // Anthropic format: { error: { message: "..." } } + // OpenAI format: { error: { message: "..." } } + // Google format: { error: { message: "..." } } + const obj = data as { error?: { message?: string; type?: string }; message?: string }; + + if (obj.error?.message) return obj.error.message; + if (obj.message) return obj.message; + + return 'Unknown error'; +} + +/** + * Validate Anthropic API key by making a minimal chat completion request + */ +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 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}` }; + } 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 + */ +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 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}` }; + } 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 + */ +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 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}` }; + } 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 + */ +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 data = await response.json().catch(() => ({})); + console.log('OpenRouter validation response:', response.status, JSON.stringify(data)); + + // 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; + + 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}` }; + } catch (error) { + return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` }; + } +} + /** * Shell-related IPC handlers */ diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index fbb861d30..140140728 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -40,7 +40,7 @@ async function getProviderStore() { export interface ProviderConfig { id: string; name: string; - type: 'anthropic' | 'openai' | 'google' | 'ollama' | 'custom'; + type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'ollama' | 'custom'; baseUrl?: string; model?: string; enabled: boolean; diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index f452fc32d..298225030 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -31,6 +31,7 @@ const providerTypes = [ { id: 'anthropic', name: 'Anthropic', icon: '🤖', placeholder: 'sk-ant-api03-...' }, { id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...' }, { id: 'google', name: 'Google', icon: '🔷', placeholder: 'AIza...' }, + { id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...' }, { id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required' }, { id: 'custom', name: 'Custom', icon: '⚙️', placeholder: 'API key...' }, ]; diff --git a/src/pages/Setup/index.tsx b/src/pages/Setup/index.tsx index 9cdfafdeb..8b5764b7a 100644 --- a/src/pages/Setup/index.tsx +++ b/src/pages/Setup/index.tsx @@ -77,6 +77,7 @@ const providers: Provider[] = [ { id: 'anthropic', name: 'Anthropic', model: 'Claude', icon: '🤖', placeholder: 'sk-ant-...' }, { id: 'openai', name: 'OpenAI', model: 'GPT-4', icon: '💚', placeholder: 'sk-...' }, { id: 'google', name: 'Google', model: 'Gemini', icon: '🔷', placeholder: 'AI...' }, + { id: 'openrouter', name: 'OpenRouter', model: 'Multi-Model', icon: '🌐', placeholder: 'sk-or-...' }, ]; // Channel types @@ -593,18 +594,26 @@ function ProviderContent({ setValidating(true); setKeyValid(null); - // Simulate API key validation - await new Promise((resolve) => setTimeout(resolve, 1500)); - - // Basic validation - just check format - const isValid = apiKey.length > 10; - setKeyValid(isValid); - setValidating(false); - - if (isValid) { - toast.success('API key validated successfully'); - } else { - toast.error('Invalid API key format'); + 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) { + toast.success('API key validated successfully'); + } else { + toast.error(result.error || 'Invalid API key'); + } + } catch (error) { + setKeyValid(false); + toast.error('Validation failed: ' + String(error)); + } finally { + setValidating(false); } }; diff --git a/src/stores/providers.ts b/src/stores/providers.ts index 69bb4edb2..755781256 100644 --- a/src/stores/providers.ts +++ b/src/stores/providers.ts @@ -10,7 +10,7 @@ import { create } from 'zustand'; export interface ProviderConfig { id: string; name: string; - type: 'anthropic' | 'openai' | 'google' | 'ollama' | 'custom'; + type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'ollama' | 'custom'; baseUrl?: string; model?: string; enabled: boolean;