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:
Haze
2026-02-06 01:25:33 +08:00
Unverified
parent af76b28286
commit 4431d2ba1d
7 changed files with 422 additions and 42 deletions

View 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"

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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;

View File

@@ -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...' },
];

View File

@@ -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));
try {
// Call real API validation
const result = await window.electron.ipcRenderer.invoke(
'provider:validateKey',
selectedProvider,
apiKey
) as { valid: boolean; error?: string };
// Basic validation - just check format
const isValid = apiKey.length > 10;
setKeyValid(isValid);
setValidating(false);
setKeyValid(result.valid);
if (isValid) {
toast.success('API key validated successfully');
} else {
toast.error('Invalid API key format');
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);
}
};

View File

@@ -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;