Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 113 additions & 120 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { getProviderConfig } from '../utils/provider-registry';

/**
* Register all IPC handlers
Expand Down Expand Up @@ -900,60 +901,72 @@ function registerProviderHandlers(): void {
return await getDefaultProvider();
});

// 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) => {
// Validate API key by making a real test request to the provider.
// providerId can be either a stored provider ID or a provider type.
ipcMain.handle(
'provider:validateKey',
async (
_,
providerId: string,
apiKey: string,
options?: { baseUrl?: string }
) => {
try {
// First try to get existing provider
const provider = await getProvider(providerId);

// Use provider.type if provider exists, otherwise use providerId as the type
// This allows validation during setup when provider hasn't been saved yet
const providerType = provider?.type || providerId;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
// Prefer caller-supplied baseUrl (live form value) over persisted config.
// This ensures Setup/Settings validation reflects unsaved edits immediately.
const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl;

console.log(`[clawx-validate] validating provider type: ${providerType}`);
return await validateApiKeyWithProvider(providerType, apiKey);
return await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl });
} catch (error) {
console.error('Validation error:', error);
return { valid: false, error: String(error) };
}
});
}
);
}

type ValidationProfile = 'openai-compatible' | 'google-query-key' | 'anthropic-header' | 'none';

/**
* Validate API key using lightweight model-listing endpoints (zero token cost).
* Falls back to accepting the key for unknown/custom provider types.
* Providers are grouped into 3 auth styles:
* - openai-compatible: Bearer auth + /models
* - google-query-key: ?key=... + /models
* - anthropic-header: x-api-key + anthropic-version + /models
*/
async function validateApiKeyWithProvider(
providerType: string,
apiKey: string
apiKey: string,
options?: { baseUrl?: string }
): Promise<{ valid: boolean; error?: string }> {
const profile = getValidationProfile(providerType);
if (profile === 'none') {
return { valid: true };
}

const trimmedKey = apiKey.trim();
if (!trimmedKey) {
return { valid: false, error: 'API key is required' };
}

try {
switch (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 'moonshot':
return await validateMoonshotKey(trimmedKey);
case 'siliconflow':
return await validateSiliconFlowKey(trimmedKey);
case 'ollama':
// Ollama doesn't require API key validation
return { valid: true };
switch (profile) {
case 'openai-compatible':
return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl);
case 'google-query-key':
return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl);
case 'anthropic-header':
return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl);
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 };
return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` };
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -994,6 +1007,14 @@ function sanitizeHeaders(headers: Record<string, string>): Record<string, string
return next;
}

function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, '');
}

function buildOpenAiModelsUrl(baseUrl: string): string {
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
}

function logValidationRequest(
provider: string,
method: string,
Expand All @@ -1005,6 +1026,38 @@ function logValidationRequest(
);
}

function getValidationProfile(providerType: string): ValidationProfile {
switch (providerType) {
case 'anthropic':
return 'anthropic-header';
case 'google':
return 'google-query-key';
case 'ollama':
return 'none';
default:
return 'openai-compatible';
}
}

async function performProviderValidationRequest(
providerLabel: string,
url: string,
headers: Record<string, string>
): Promise<{ valid: boolean; error?: string }> {
try {
logValidationRequest(providerLabel, 'GET', url, headers);
const response = await fetch(url, { headers });
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}

/**
* Helper: classify an HTTP response as valid / invalid / error.
* 200 / 429 → valid (key works, possibly rate-limited).
Expand All @@ -1025,108 +1078,48 @@ function classifyAuthResponse(
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 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(() => ({}));
return classifyAuthResponse(response.status, data);
} catch (error) {
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
}
}

/**
* Validate OpenAI API key via GET /v1/models (zero cost)
*/
async function validateOpenAIKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
try {
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(() => ({}));
return classifyAuthResponse(response.status, data);
} catch (error) {
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
async function validateOpenAiCompatibleKey(
providerType: string,
apiKey: string,
baseUrl?: string
): Promise<{ valid: boolean; error?: string }> {
const trimmedBaseUrl = baseUrl?.trim();
if (!trimmedBaseUrl) {
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
}
}

/**
* Validate Google (Gemini) API key via GET /v1beta/models (zero cost)
*/
async function validateGoogleKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
try {
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(() => ({}));
return classifyAuthResponse(response.status, data);
} catch (error) {
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
}
const url = buildOpenAiModelsUrl(trimmedBaseUrl);
const headers = { Authorization: `Bearer ${apiKey}` };
return await performProviderValidationRequest(providerType, url, headers);
}

/**
* Validate OpenRouter API key via GET /api/v1/models (zero cost)
*/
async function validateOpenRouterKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
try {
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(() => ({}));
return classifyAuthResponse(response.status, data);
} catch (error) {
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
async function validateGoogleQueryKey(
providerType: string,
apiKey: string,
baseUrl?: string
): Promise<{ valid: boolean; error?: string }> {
const trimmedBaseUrl = baseUrl?.trim();
if (!trimmedBaseUrl) {
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
}
}

/**
* 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 base = normalizeBaseUrl(trimmedBaseUrl);
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
return await performProviderValidationRequest(providerType, url, {});
}

/**
* 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)}` };
}
async function validateAnthropicHeaderKey(
providerType: string,
apiKey: string,
baseUrl?: string
): Promise<{ valid: boolean; error?: string }> {
const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
const url = `${base}/models?limit=1`;
const headers = {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
};
return await performProviderValidationRequest(providerType, url, headers);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion electron/utils/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const REGISTRY: Record<string, ProviderBackendMeta> = {
envVar: 'SILICONFLOW_API_KEY',
defaultModel: 'siliconflow/deepseek-ai/DeepSeek-V3',
providerConfig: {
baseUrl: 'https://api.siliconflow.com/v1',
baseUrl: 'https://api.siliconflow.cn/v1',
api: 'openai-completions',
apiKeyEnv: 'SILICONFLOW_API_KEY',
},
Expand Down
23 changes: 17 additions & 6 deletions src/components/settings/ProvidersSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export function ProvidersSettings() {
);
setEditingProvider(null);
}}
onValidateKey={(key) => validateApiKey(provider.id, key)}
onValidateKey={(key, options) => validateApiKey(provider.id, key, options)}
/>
))}
</div>
Expand All @@ -172,7 +172,7 @@ export function ProvidersSettings() {
existingTypes={new Set(providers.map((p) => p.type))}
onClose={() => setShowAddDialog(false)}
onAdd={handleAddProvider}
onValidateKey={(type, key) => validateApiKey(type, key)}
onValidateKey={(type, key, options) => validateApiKey(type, key, options)}
/>
)}
</div>
Expand All @@ -189,7 +189,10 @@ interface ProviderCardProps {
onSetDefault: () => void;
onToggleEnabled: () => void;
onSaveEdits: (payload: { newApiKey?: string; updates?: Partial<ProviderConfig> }) => Promise<void>;
onValidateKey: (key: string) => Promise<{ valid: boolean; error?: string }>;
onValidateKey: (
key: string,
options?: { baseUrl?: string }
) => Promise<{ valid: boolean; error?: string }>;
}

/**
Expand Down Expand Up @@ -245,7 +248,9 @@ function ProviderCard({

if (newKey.trim()) {
setValidating(true);
const result = await onValidateKey(newKey);
const result = await onValidateKey(newKey, {
baseUrl: baseUrl.trim() || undefined,
});
setValidating(false);
if (!result.valid) {
toast.error(result.error || 'Invalid API key');
Expand Down Expand Up @@ -426,7 +431,11 @@ interface AddProviderDialogProps {
apiKey: string,
options?: { baseUrl?: string; model?: string }
) => Promise<void>;
onValidateKey: (type: string, apiKey: string) => Promise<{ valid: boolean; error?: string }>;
onValidateKey: (
type: string,
apiKey: string,
options?: { baseUrl?: string }
) => Promise<{ valid: boolean; error?: string }>;
}

function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: AddProviderDialogProps) {
Expand Down Expand Up @@ -461,7 +470,9 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
return;
}
if (requiresKey && apiKey) {
const result = await onValidateKey(selectedType, apiKey);
const result = await onValidateKey(selectedType, apiKey, {
baseUrl: baseUrl.trim() || undefined,
});
if (!result.valid) {
setValidationError(result.error || 'Invalid API key');
setSaving(false);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
{ id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...', model: 'GPT', requiresApiKey: true },
{ id: 'google', name: 'Google', icon: '🔷', placeholder: 'AIza...', model: 'Gemini', requiresApiKey: true },
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true },
{ id: 'moonshot', name: 'Moonshot', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' },
{ id: 'siliconflow', name: 'SiliconFlow', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.com/v1', defaultModelId: 'moonshotai/Kimi-K2.5' },
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' },
{ id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', defaultModelId: 'Pro/moonshotai/Kimi-K2.5' },
{ id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required', requiresApiKey: false, defaultBaseUrl: 'http://localhost:11434', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'qwen3:latest' },
{ id: 'custom', name: 'Custom', icon: '⚙️', placeholder: 'API key...', requiresApiKey: true, showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'your-provider/model-id' },
];
Expand Down
Loading