Skip to content

Commit 0ced0b0

Browse files
authored
refactor(provider): provider API validation & CN defaults (#47)
1 parent 01f4d48 commit 0ced0b0

File tree

6 files changed

+162
-136
lines changed

6 files changed

+162
-136
lines changed

electron/main/ipc-handlers.ts

Lines changed: 113 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
4343
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
4444
import { whatsAppLoginManager } from '../utils/whatsapp-login';
45+
import { getProviderConfig } from '../utils/provider-registry';
4546

4647
/**
4748
* Register all IPC handlers
@@ -900,60 +901,72 @@ function registerProviderHandlers(): void {
900901
return await getDefaultProvider();
901902
});
902903

903-
// Validate API key by making a real test request to the provider
904-
// providerId can be either a stored provider ID or a provider type (e.g., 'openrouter', 'anthropic')
905-
ipcMain.handle('provider:validateKey', async (_, providerId: string, apiKey: string) => {
904+
// Validate API key by making a real test request to the provider.
905+
// providerId can be either a stored provider ID or a provider type.
906+
ipcMain.handle(
907+
'provider:validateKey',
908+
async (
909+
_,
910+
providerId: string,
911+
apiKey: string,
912+
options?: { baseUrl?: string }
913+
) => {
906914
try {
907915
// First try to get existing provider
908916
const provider = await getProvider(providerId);
909917

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

914926
console.log(`[clawx-validate] validating provider type: ${providerType}`);
915-
return await validateApiKeyWithProvider(providerType, apiKey);
927+
return await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl });
916928
} catch (error) {
917929
console.error('Validation error:', error);
918930
return { valid: false, error: String(error) };
919931
}
920-
});
932+
}
933+
);
921934
}
922935

936+
type ValidationProfile = 'openai-compatible' | 'google-query-key' | 'anthropic-header' | 'none';
937+
923938
/**
924939
* Validate API key using lightweight model-listing endpoints (zero token cost).
925-
* Falls back to accepting the key for unknown/custom provider types.
940+
* Providers are grouped into 3 auth styles:
941+
* - openai-compatible: Bearer auth + /models
942+
* - google-query-key: ?key=... + /models
943+
* - anthropic-header: x-api-key + anthropic-version + /models
926944
*/
927945
async function validateApiKeyWithProvider(
928946
providerType: string,
929-
apiKey: string
947+
apiKey: string,
948+
options?: { baseUrl?: string }
930949
): Promise<{ valid: boolean; error?: string }> {
950+
const profile = getValidationProfile(providerType);
951+
if (profile === 'none') {
952+
return { valid: true };
953+
}
954+
931955
const trimmedKey = apiKey.trim();
932956
if (!trimmedKey) {
933957
return { valid: false, error: 'API key is required' };
934958
}
935959

936960
try {
937-
switch (providerType) {
938-
case 'anthropic':
939-
return await validateAnthropicKey(trimmedKey);
940-
case 'openai':
941-
return await validateOpenAIKey(trimmedKey);
942-
case 'google':
943-
return await validateGoogleKey(trimmedKey);
944-
case 'openrouter':
945-
return await validateOpenRouterKey(trimmedKey);
946-
case 'moonshot':
947-
return await validateMoonshotKey(trimmedKey);
948-
case 'siliconflow':
949-
return await validateSiliconFlowKey(trimmedKey);
950-
case 'ollama':
951-
// Ollama doesn't require API key validation
952-
return { valid: true };
961+
switch (profile) {
962+
case 'openai-compatible':
963+
return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl);
964+
case 'google-query-key':
965+
return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl);
966+
case 'anthropic-header':
967+
return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl);
953968
default:
954-
// For custom providers, just check the key is not empty
955-
console.log(`[clawx-validate] ${providerType} uses local non-empty validation only`);
956-
return { valid: true };
969+
return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` };
957970
}
958971
} catch (error) {
959972
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -994,6 +1007,14 @@ function sanitizeHeaders(headers: Record<string, string>): Record<string, string
9941007
return next;
9951008
}
9961009

1010+
function normalizeBaseUrl(baseUrl: string): string {
1011+
return baseUrl.trim().replace(/\/+$/, '');
1012+
}
1013+
1014+
function buildOpenAiModelsUrl(baseUrl: string): string {
1015+
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
1016+
}
1017+
9971018
function logValidationRequest(
9981019
provider: string,
9991020
method: string,
@@ -1005,6 +1026,38 @@ function logValidationRequest(
10051026
);
10061027
}
10071028

1029+
function getValidationProfile(providerType: string): ValidationProfile {
1030+
switch (providerType) {
1031+
case 'anthropic':
1032+
return 'anthropic-header';
1033+
case 'google':
1034+
return 'google-query-key';
1035+
case 'ollama':
1036+
return 'none';
1037+
default:
1038+
return 'openai-compatible';
1039+
}
1040+
}
1041+
1042+
async function performProviderValidationRequest(
1043+
providerLabel: string,
1044+
url: string,
1045+
headers: Record<string, string>
1046+
): Promise<{ valid: boolean; error?: string }> {
1047+
try {
1048+
logValidationRequest(providerLabel, 'GET', url, headers);
1049+
const response = await fetch(url, { headers });
1050+
logValidationStatus(providerLabel, response.status);
1051+
const data = await response.json().catch(() => ({}));
1052+
return classifyAuthResponse(response.status, data);
1053+
} catch (error) {
1054+
return {
1055+
valid: false,
1056+
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
1057+
};
1058+
}
1059+
}
1060+
10081061
/**
10091062
* Helper: classify an HTTP response as valid / invalid / error.
10101063
* 200 / 429 → valid (key works, possibly rate-limited).
@@ -1025,108 +1078,48 @@ function classifyAuthResponse(
10251078
return { valid: false, error: msg };
10261079
}
10271080

1028-
/**
1029-
* Validate Anthropic API key via GET /v1/models (zero cost)
1030-
*/
1031-
async function validateAnthropicKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
1032-
try {
1033-
const url = 'https://api.anthropic.com/v1/models?limit=1';
1034-
const headers = {
1035-
'x-api-key': apiKey,
1036-
'anthropic-version': '2023-06-01',
1037-
};
1038-
logValidationRequest('anthropic', 'GET', url, headers);
1039-
const response = await fetch(url, { headers });
1040-
logValidationStatus('anthropic', response.status);
1041-
const data = await response.json().catch(() => ({}));
1042-
return classifyAuthResponse(response.status, data);
1043-
} catch (error) {
1044-
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
1045-
}
1046-
}
1047-
1048-
/**
1049-
* Validate OpenAI API key via GET /v1/models (zero cost)
1050-
*/
1051-
async function validateOpenAIKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
1052-
try {
1053-
const url = 'https://api.openai.com/v1/models?limit=1';
1054-
const headers = { Authorization: `Bearer ${apiKey}` };
1055-
logValidationRequest('openai', 'GET', url, headers);
1056-
const response = await fetch(url, { headers });
1057-
logValidationStatus('openai', response.status);
1058-
const data = await response.json().catch(() => ({}));
1059-
return classifyAuthResponse(response.status, data);
1060-
} catch (error) {
1061-
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
1081+
async function validateOpenAiCompatibleKey(
1082+
providerType: string,
1083+
apiKey: string,
1084+
baseUrl?: string
1085+
): Promise<{ valid: boolean; error?: string }> {
1086+
const trimmedBaseUrl = baseUrl?.trim();
1087+
if (!trimmedBaseUrl) {
1088+
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
10621089
}
1063-
}
10641090

1065-
/**
1066-
* Validate Google (Gemini) API key via GET /v1beta/models (zero cost)
1067-
*/
1068-
async function validateGoogleKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
1069-
try {
1070-
const url = `https://generativelanguage.googleapis.com/v1beta/models?pageSize=1&key=${apiKey}`;
1071-
logValidationRequest('google', 'GET', url, {});
1072-
const response = await fetch(url);
1073-
logValidationStatus('google', response.status);
1074-
const data = await response.json().catch(() => ({}));
1075-
return classifyAuthResponse(response.status, data);
1076-
} catch (error) {
1077-
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
1078-
}
1091+
const url = buildOpenAiModelsUrl(trimmedBaseUrl);
1092+
const headers = { Authorization: `Bearer ${apiKey}` };
1093+
return await performProviderValidationRequest(providerType, url, headers);
10791094
}
10801095

1081-
/**
1082-
* Validate OpenRouter API key via GET /api/v1/models (zero cost)
1083-
*/
1084-
async function validateOpenRouterKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
1085-
try {
1086-
const url = 'https://openrouter.ai/api/v1/models';
1087-
const headers = { Authorization: `Bearer ${apiKey}` };
1088-
logValidationRequest('openrouter', 'GET', url, headers);
1089-
const response = await fetch(url, { headers });
1090-
logValidationStatus('openrouter', response.status);
1091-
const data = await response.json().catch(() => ({}));
1092-
return classifyAuthResponse(response.status, data);
1093-
} catch (error) {
1094-
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
1096+
async function validateGoogleQueryKey(
1097+
providerType: string,
1098+
apiKey: string,
1099+
baseUrl?: string
1100+
): Promise<{ valid: boolean; error?: string }> {
1101+
const trimmedBaseUrl = baseUrl?.trim();
1102+
if (!trimmedBaseUrl) {
1103+
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
10951104
}
1096-
}
10971105

1098-
/**
1099-
* Validate Moonshot API key via GET /v1/models (zero cost)
1100-
*/
1101-
async function validateMoonshotKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
1102-
try {
1103-
const url = 'https://api.moonshot.cn/v1/models';
1104-
const headers = { Authorization: `Bearer ${apiKey}` };
1105-
logValidationRequest('moonshot', 'GET', url, headers);
1106-
const response = await fetch(url, { headers });
1107-
logValidationStatus('moonshot', response.status);
1108-
const data = await response.json().catch(() => ({}));
1109-
return classifyAuthResponse(response.status, data);
1110-
} catch (error) {
1111-
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
1112-
}
1106+
const base = normalizeBaseUrl(trimmedBaseUrl);
1107+
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
1108+
return await performProviderValidationRequest(providerType, url, {});
11131109
}
11141110

1115-
/**
1116-
* Validate SiliconFlow API key via GET /v1/models (zero cost)
1117-
*/
1118-
async function validateSiliconFlowKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
1119-
try {
1120-
const url = 'https://api.siliconflow.com/v1/models';
1121-
const headers = { Authorization: `Bearer ${apiKey}` };
1122-
logValidationRequest('siliconflow', 'GET', url, headers);
1123-
const response = await fetch(url, { headers });
1124-
logValidationStatus('siliconflow', response.status);
1125-
const data = await response.json().catch(() => ({}));
1126-
return classifyAuthResponse(response.status, data);
1127-
} catch (error) {
1128-
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
1129-
}
1111+
async function validateAnthropicHeaderKey(
1112+
providerType: string,
1113+
apiKey: string,
1114+
baseUrl?: string
1115+
): Promise<{ valid: boolean; error?: string }> {
1116+
const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
1117+
const url = `${base}/models?limit=1`;
1118+
const headers = {
1119+
'x-api-key': apiKey,
1120+
'anthropic-version': '2023-06-01',
1121+
};
1122+
return await performProviderValidationRequest(providerType, url, headers);
11301123
}
11311124

11321125
/**

electron/utils/provider-registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ const REGISTRY: Record<string, ProviderBackendMeta> = {
9292
envVar: 'SILICONFLOW_API_KEY',
9393
defaultModel: 'siliconflow/deepseek-ai/DeepSeek-V3',
9494
providerConfig: {
95-
baseUrl: 'https://api.siliconflow.com/v1',
95+
baseUrl: 'https://api.siliconflow.cn/v1',
9696
api: 'openai-completions',
9797
apiKeyEnv: 'SILICONFLOW_API_KEY',
9898
},

src/components/settings/ProvidersSettings.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export function ProvidersSettings() {
160160
);
161161
setEditingProvider(null);
162162
}}
163-
onValidateKey={(key) => validateApiKey(provider.id, key)}
163+
onValidateKey={(key, options) => validateApiKey(provider.id, key, options)}
164164
/>
165165
))}
166166
</div>
@@ -172,7 +172,7 @@ export function ProvidersSettings() {
172172
existingTypes={new Set(providers.map((p) => p.type))}
173173
onClose={() => setShowAddDialog(false)}
174174
onAdd={handleAddProvider}
175-
onValidateKey={(type, key) => validateApiKey(type, key)}
175+
onValidateKey={(type, key, options) => validateApiKey(type, key, options)}
176176
/>
177177
)}
178178
</div>
@@ -189,7 +189,10 @@ interface ProviderCardProps {
189189
onSetDefault: () => void;
190190
onToggleEnabled: () => void;
191191
onSaveEdits: (payload: { newApiKey?: string; updates?: Partial<ProviderConfig> }) => Promise<void>;
192-
onValidateKey: (key: string) => Promise<{ valid: boolean; error?: string }>;
192+
onValidateKey: (
193+
key: string,
194+
options?: { baseUrl?: string }
195+
) => Promise<{ valid: boolean; error?: string }>;
193196
}
194197

195198
/**
@@ -245,7 +248,9 @@ function ProviderCard({
245248

246249
if (newKey.trim()) {
247250
setValidating(true);
248-
const result = await onValidateKey(newKey);
251+
const result = await onValidateKey(newKey, {
252+
baseUrl: baseUrl.trim() || undefined,
253+
});
249254
setValidating(false);
250255
if (!result.valid) {
251256
toast.error(result.error || 'Invalid API key');
@@ -426,7 +431,11 @@ interface AddProviderDialogProps {
426431
apiKey: string,
427432
options?: { baseUrl?: string; model?: string }
428433
) => Promise<void>;
429-
onValidateKey: (type: string, apiKey: string) => Promise<{ valid: boolean; error?: string }>;
434+
onValidateKey: (
435+
type: string,
436+
apiKey: string,
437+
options?: { baseUrl?: string }
438+
) => Promise<{ valid: boolean; error?: string }>;
430439
}
431440

432441
function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: AddProviderDialogProps) {
@@ -461,7 +470,9 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
461470
return;
462471
}
463472
if (requiresKey && apiKey) {
464-
const result = await onValidateKey(selectedType, apiKey);
473+
const result = await onValidateKey(selectedType, apiKey, {
474+
baseUrl: baseUrl.trim() || undefined,
475+
});
465476
if (!result.valid) {
466477
setValidationError(result.error || 'Invalid API key');
467478
setSaving(false);

src/lib/providers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
5959
{ id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...', model: 'GPT', requiresApiKey: true },
6060
{ id: 'google', name: 'Google', icon: '🔷', placeholder: 'AIza...', model: 'Gemini', requiresApiKey: true },
6161
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true },
62-
{ id: 'moonshot', name: 'Moonshot', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' },
63-
{ id: 'siliconflow', name: 'SiliconFlow', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.com/v1', defaultModelId: 'moonshotai/Kimi-K2.5' },
62+
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' },
63+
{ 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' },
6464
{ id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required', requiresApiKey: false, defaultBaseUrl: 'http://localhost:11434', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'qwen3:latest' },
6565
{ id: 'custom', name: 'Custom', icon: '⚙️', placeholder: 'API key...', requiresApiKey: true, showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'your-provider/model-id' },
6666
];

0 commit comments

Comments
 (0)