feat(providers): implement real API key validation with OpenRouter support
- Replace mock API key validation with actual API calls to verify keys - Add validateApiKeyWithProvider() with provider-specific implementations - Support Anthropic, OpenAI, Google, and OpenRouter validation - Add OpenRouter as a new provider option in setup wizard and settings - Fix setup page to call real validation instead of mock length check - Allow validation during setup before provider is saved - Return user-friendly error messages instead of raw API errors
This commit is contained in:
87
build_process/commit_12_api_key_validation.md
Normal file
87
build_process/commit_12_api_key_validation.md
Normal file
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...' },
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user