From 0ced0b042c37064848d817f328d39ede712978e2 Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:48:15 -0800 Subject: [PATCH] refactor(provider): provider API validation & CN defaults (#47) --- electron/main/ipc-handlers.ts | 237 +++++++++--------- electron/utils/provider-registry.ts | 2 +- src/components/settings/ProvidersSettings.tsx | 23 +- src/lib/providers.ts | 4 +- src/pages/Setup/index.tsx | 21 +- src/stores/providers.ts | 15 +- 6 files changed, 164 insertions(+), 138 deletions(-) diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index c96cd37ad..6dd6bd11f 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -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 @@ -900,9 +901,16 @@ 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); @@ -910,50 +918,55 @@ function registerProviderHandlers(): void { // 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); @@ -994,6 +1007,14 @@ function sanitizeHeaders(headers: Record): Record +): 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). @@ -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)}` }; +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` }; } + + const url = buildOpenAiModelsUrl(trimmedBaseUrl); + const headers = { Authorization: `Bearer ${apiKey}` }; + return await performProviderValidationRequest(providerType, url, headers); } -/** - * 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 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` }; } + + const base = normalizeBaseUrl(trimmedBaseUrl); + const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`; + return await performProviderValidationRequest(providerType, url, {}); } -/** - * 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)}` }; - } -} - -/** - * 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)}` }; - } -} - -/** - * 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)}` }; - } -} - -/** - * 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); } /** diff --git a/electron/utils/provider-registry.ts b/electron/utils/provider-registry.ts index 0d218b4f7..78d7f6cc7 100644 --- a/electron/utils/provider-registry.ts +++ b/electron/utils/provider-registry.ts @@ -92,7 +92,7 @@ const REGISTRY: Record = { 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', }, diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index 0f21b8fd3..e8a94bcea 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -160,7 +160,7 @@ export function ProvidersSettings() { ); setEditingProvider(null); }} - onValidateKey={(key) => validateApiKey(provider.id, key)} + onValidateKey={(key, options) => validateApiKey(provider.id, key, options)} /> ))} @@ -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)} /> )} @@ -189,7 +189,10 @@ interface ProviderCardProps { onSetDefault: () => void; onToggleEnabled: () => void; onSaveEdits: (payload: { newApiKey?: string; updates?: Partial }) => Promise; - onValidateKey: (key: string) => Promise<{ valid: boolean; error?: string }>; + onValidateKey: ( + key: string, + options?: { baseUrl?: string } + ) => Promise<{ valid: boolean; error?: string }>; } /** @@ -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'); @@ -426,7 +431,11 @@ interface AddProviderDialogProps { apiKey: string, options?: { baseUrl?: string; model?: string } ) => Promise; - 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) { @@ -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); diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 5494c99d1..a581196f5 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -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' }, ]; diff --git a/src/pages/Setup/index.tsx b/src/pages/Setup/index.tsx index aa0a87f4f..042bc7130 100644 --- a/src/pages/Setup/index.tsx +++ b/src/pages/Setup/index.tsx @@ -655,6 +655,7 @@ function ProviderContent({ const [showKey, setShowKey] = useState(false); const [validating, setValidating] = useState(false); const [keyValid, setKeyValid] = useState(null); + const [selectedProviderConfigId, setSelectedProviderConfigId] = useState(null); const [baseUrl, setBaseUrl] = useState(''); const [modelId, setModelId] = useState(''); @@ -673,6 +674,7 @@ function ProviderContent({ || setupCandidates[0]; if (preferred && !cancelled) { onSelectProvider(preferred.type); + setSelectedProviderConfigId(preferred.id); const typeInfo = providers.find((p) => p.id === preferred.type); const requiresKey = typeInfo?.requiresApiKey ?? false; onConfiguredChange(!requiresKey || preferred.hasKey); @@ -706,6 +708,7 @@ function ProviderContent({ || sameType.find((p) => p.hasKey) || sameType[0]; const providerIdForLoad = preferredInstance?.id || selectedProvider; + setSelectedProviderConfigId(providerIdForLoad); const savedProvider = await window.electron.ipcRenderer.invoke( 'provider:get', @@ -746,8 +749,9 @@ function ProviderContent({ if (requiresKey && apiKey) { const result = await window.electron.ipcRenderer.invoke( 'provider:validateKey', - selectedProvider, - apiKey + selectedProviderConfigId || selectedProvider, + apiKey, + { baseUrl: baseUrl.trim() || undefined } ) as { valid: boolean; error?: string }; setKeyValid(result.valid); @@ -766,11 +770,18 @@ function ProviderContent({ modelId.trim() || undefined; + const providerIdForSave = + selectedProvider === 'custom' + ? (selectedProviderConfigId?.startsWith('custom-') + ? selectedProviderConfigId + : `custom-${crypto.randomUUID()}`) + : selectedProvider; + // Save provider config + API key, then set as default const saveResult = await window.electron.ipcRenderer.invoke( 'provider:save', { - id: selectedProvider, + id: providerIdForSave, name: selectedProviderData?.name || selectedProvider, type: selectedProvider, baseUrl: baseUrl.trim() || undefined, @@ -788,13 +799,14 @@ function ProviderContent({ const defaultResult = await window.electron.ipcRenderer.invoke( 'provider:setDefault', - selectedProvider + providerIdForSave ) as { success: boolean; error?: string }; if (!defaultResult.success) { throw new Error(defaultResult.error || 'Failed to set default provider'); } + setSelectedProviderConfigId(providerIdForSave); onConfiguredChange(true); toast.success('Provider configured'); } catch (error) { @@ -824,6 +836,7 @@ function ProviderContent({ onChange={(e) => { const val = e.target.value || null; onSelectProvider(val); + setSelectedProviderConfigId(null); onConfiguredChange(false); onApiKeyChange(''); setKeyValid(null); diff --git a/src/stores/providers.ts b/src/stores/providers.ts index 96e64c458..cd5fd6ba9 100644 --- a/src/stores/providers.ts +++ b/src/stores/providers.ts @@ -27,7 +27,11 @@ interface ProviderState { ) => Promise; deleteApiKey: (providerId: string) => Promise; setDefaultProvider: (providerId: string) => Promise; - validateApiKey: (providerId: string, apiKey: string) => Promise<{ valid: boolean; error?: string }>; + validateApiKey: ( + providerId: string, + apiKey: string, + options?: { baseUrl?: string } + ) => Promise<{ valid: boolean; error?: string }>; getApiKey: (providerId: string) => Promise; } @@ -188,9 +192,14 @@ export const useProviderStore = create((set, get) => ({ } }, - validateApiKey: async (providerId, apiKey) => { + validateApiKey: async (providerId, apiKey, options) => { try { - const result = await window.electron.ipcRenderer.invoke('provider:validateKey', providerId, apiKey) as { valid: boolean; error?: string }; + const result = await window.electron.ipcRenderer.invoke( + 'provider:validateKey', + providerId, + apiKey, + options + ) as { valid: boolean; error?: string }; return result; } catch (error) { return { valid: false, error: String(error) };