refactor(provider): provider API validation & CN defaults (#47)
This commit is contained in:
committed by
GitHub
Unverified
parent
01f4d4800e
commit
0ced0b042c
@@ -42,6 +42,7 @@ import {
|
|||||||
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
|
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
|
||||||
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
|
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
|
||||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||||
|
import { getProviderConfig } from '../utils/provider-registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all IPC handlers
|
* Register all IPC handlers
|
||||||
@@ -900,9 +901,16 @@ function registerProviderHandlers(): void {
|
|||||||
return await getDefaultProvider();
|
return await getDefaultProvider();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate API key by making a real test request to the provider
|
// 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')
|
// providerId can be either a stored provider ID or a provider type.
|
||||||
ipcMain.handle('provider:validateKey', async (_, providerId: string, apiKey: string) => {
|
ipcMain.handle(
|
||||||
|
'provider:validateKey',
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
providerId: string,
|
||||||
|
apiKey: string,
|
||||||
|
options?: { baseUrl?: string }
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
// First try to get existing provider
|
// First try to get existing provider
|
||||||
const provider = await getProvider(providerId);
|
const provider = await getProvider(providerId);
|
||||||
@@ -910,50 +918,55 @@ function registerProviderHandlers(): void {
|
|||||||
// Use provider.type if provider exists, otherwise use providerId as the type
|
// Use provider.type if provider exists, otherwise use providerId as the type
|
||||||
// This allows validation during setup when provider hasn't been saved yet
|
// This allows validation during setup when provider hasn't been saved yet
|
||||||
const providerType = provider?.type || providerId;
|
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}`);
|
console.log(`[clawx-validate] validating provider type: ${providerType}`);
|
||||||
return await validateApiKeyWithProvider(providerType, apiKey);
|
return await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Validation error:', error);
|
console.error('Validation error:', error);
|
||||||
return { valid: false, error: String(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).
|
* 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(
|
async function validateApiKeyWithProvider(
|
||||||
providerType: string,
|
providerType: string,
|
||||||
apiKey: string
|
apiKey: string,
|
||||||
|
options?: { baseUrl?: string }
|
||||||
): Promise<{ valid: boolean; error?: string }> {
|
): Promise<{ valid: boolean; error?: string }> {
|
||||||
|
const profile = getValidationProfile(providerType);
|
||||||
|
if (profile === 'none') {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
const trimmedKey = apiKey.trim();
|
const trimmedKey = apiKey.trim();
|
||||||
if (!trimmedKey) {
|
if (!trimmedKey) {
|
||||||
return { valid: false, error: 'API key is required' };
|
return { valid: false, error: 'API key is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (providerType) {
|
switch (profile) {
|
||||||
case 'anthropic':
|
case 'openai-compatible':
|
||||||
return await validateAnthropicKey(trimmedKey);
|
return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl);
|
||||||
case 'openai':
|
case 'google-query-key':
|
||||||
return await validateOpenAIKey(trimmedKey);
|
return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl);
|
||||||
case 'google':
|
case 'anthropic-header':
|
||||||
return await validateGoogleKey(trimmedKey);
|
return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl);
|
||||||
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 };
|
|
||||||
default:
|
default:
|
||||||
// For custom providers, just check the key is not empty
|
return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` };
|
||||||
console.log(`[clawx-validate] ${providerType} uses local non-empty validation only`);
|
|
||||||
return { valid: true };
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
@@ -994,6 +1007,14 @@ function sanitizeHeaders(headers: Record<string, string>): Record<string, string
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
|
return baseUrl.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenAiModelsUrl(baseUrl: string): string {
|
||||||
|
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
|
||||||
|
}
|
||||||
|
|
||||||
function logValidationRequest(
|
function logValidationRequest(
|
||||||
provider: string,
|
provider: string,
|
||||||
method: string,
|
method: string,
|
||||||
@@ -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.
|
* Helper: classify an HTTP response as valid / invalid / error.
|
||||||
* 200 / 429 → valid (key works, possibly rate-limited).
|
* 200 / 429 → valid (key works, possibly rate-limited).
|
||||||
@@ -1025,108 +1078,48 @@ function classifyAuthResponse(
|
|||||||
return { valid: false, error: msg };
|
return { valid: false, error: msg };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function validateOpenAiCompatibleKey(
|
||||||
* Validate Anthropic API key via GET /v1/models (zero cost)
|
providerType: string,
|
||||||
*/
|
apiKey: string,
|
||||||
async function validateAnthropicKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
baseUrl?: string
|
||||||
try {
|
): Promise<{ valid: boolean; error?: string }> {
|
||||||
const url = 'https://api.anthropic.com/v1/models?limit=1';
|
const trimmedBaseUrl = baseUrl?.trim();
|
||||||
const headers = {
|
if (!trimmedBaseUrl) {
|
||||||
'x-api-key': apiKey,
|
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
|
||||||
'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)}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = buildOpenAiModelsUrl(trimmedBaseUrl);
|
||||||
|
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||||
|
return await performProviderValidationRequest(providerType, url, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function validateGoogleQueryKey(
|
||||||
* Validate OpenAI API key via GET /v1/models (zero cost)
|
providerType: string,
|
||||||
*/
|
apiKey: string,
|
||||||
async function validateOpenAIKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
baseUrl?: string
|
||||||
try {
|
): Promise<{ valid: boolean; error?: string }> {
|
||||||
const url = 'https://api.openai.com/v1/models?limit=1';
|
const trimmedBaseUrl = baseUrl?.trim();
|
||||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
if (!trimmedBaseUrl) {
|
||||||
logValidationRequest('openai', 'GET', url, headers);
|
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
|
||||||
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)}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const base = normalizeBaseUrl(trimmedBaseUrl);
|
||||||
|
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
|
||||||
|
return await performProviderValidationRequest(providerType, url, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function validateAnthropicHeaderKey(
|
||||||
* Validate Google (Gemini) API key via GET /v1beta/models (zero cost)
|
providerType: string,
|
||||||
*/
|
apiKey: string,
|
||||||
async function validateGoogleKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
baseUrl?: string
|
||||||
try {
|
): Promise<{ valid: boolean; error?: string }> {
|
||||||
const url = `https://generativelanguage.googleapis.com/v1beta/models?pageSize=1&key=${apiKey}`;
|
const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
|
||||||
logValidationRequest('google', 'GET', url, {});
|
const url = `${base}/models?limit=1`;
|
||||||
const response = await fetch(url);
|
const headers = {
|
||||||
logValidationStatus('google', response.status);
|
'x-api-key': apiKey,
|
||||||
const data = await response.json().catch(() => ({}));
|
'anthropic-version': '2023-06-01',
|
||||||
return classifyAuthResponse(response.status, data);
|
};
|
||||||
} catch (error) {
|
return await performProviderValidationRequest(providerType, url, headers);
|
||||||
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)}` };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const REGISTRY: Record<string, ProviderBackendMeta> = {
|
|||||||
envVar: 'SILICONFLOW_API_KEY',
|
envVar: 'SILICONFLOW_API_KEY',
|
||||||
defaultModel: 'siliconflow/deepseek-ai/DeepSeek-V3',
|
defaultModel: 'siliconflow/deepseek-ai/DeepSeek-V3',
|
||||||
providerConfig: {
|
providerConfig: {
|
||||||
baseUrl: 'https://api.siliconflow.com/v1',
|
baseUrl: 'https://api.siliconflow.cn/v1',
|
||||||
api: 'openai-completions',
|
api: 'openai-completions',
|
||||||
apiKeyEnv: 'SILICONFLOW_API_KEY',
|
apiKeyEnv: 'SILICONFLOW_API_KEY',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function ProvidersSettings() {
|
|||||||
);
|
);
|
||||||
setEditingProvider(null);
|
setEditingProvider(null);
|
||||||
}}
|
}}
|
||||||
onValidateKey={(key) => validateApiKey(provider.id, key)}
|
onValidateKey={(key, options) => validateApiKey(provider.id, key, options)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +172,7 @@ export function ProvidersSettings() {
|
|||||||
existingTypes={new Set(providers.map((p) => p.type))}
|
existingTypes={new Set(providers.map((p) => p.type))}
|
||||||
onClose={() => setShowAddDialog(false)}
|
onClose={() => setShowAddDialog(false)}
|
||||||
onAdd={handleAddProvider}
|
onAdd={handleAddProvider}
|
||||||
onValidateKey={(type, key) => validateApiKey(type, key)}
|
onValidateKey={(type, key, options) => validateApiKey(type, key, options)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -189,7 +189,10 @@ interface ProviderCardProps {
|
|||||||
onSetDefault: () => void;
|
onSetDefault: () => void;
|
||||||
onToggleEnabled: () => void;
|
onToggleEnabled: () => void;
|
||||||
onSaveEdits: (payload: { newApiKey?: string; updates?: Partial<ProviderConfig> }) => Promise<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 }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,7 +248,9 @@ function ProviderCard({
|
|||||||
|
|
||||||
if (newKey.trim()) {
|
if (newKey.trim()) {
|
||||||
setValidating(true);
|
setValidating(true);
|
||||||
const result = await onValidateKey(newKey);
|
const result = await onValidateKey(newKey, {
|
||||||
|
baseUrl: baseUrl.trim() || undefined,
|
||||||
|
});
|
||||||
setValidating(false);
|
setValidating(false);
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
toast.error(result.error || 'Invalid API key');
|
toast.error(result.error || 'Invalid API key');
|
||||||
@@ -426,7 +431,11 @@ interface AddProviderDialogProps {
|
|||||||
apiKey: string,
|
apiKey: string,
|
||||||
options?: { baseUrl?: string; model?: string }
|
options?: { baseUrl?: string; model?: string }
|
||||||
) => Promise<void>;
|
) => 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) {
|
function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: AddProviderDialogProps) {
|
||||||
@@ -461,7 +470,9 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (requiresKey && apiKey) {
|
if (requiresKey && apiKey) {
|
||||||
const result = await onValidateKey(selectedType, apiKey);
|
const result = await onValidateKey(selectedType, apiKey, {
|
||||||
|
baseUrl: baseUrl.trim() || undefined,
|
||||||
|
});
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
setValidationError(result.error || 'Invalid API key');
|
setValidationError(result.error || 'Invalid API key');
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
|
|||||||
{ id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...', model: 'GPT', requiresApiKey: true },
|
{ id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...', model: 'GPT', requiresApiKey: true },
|
||||||
{ id: 'google', name: 'Google', icon: '🔷', placeholder: 'AIza...', model: 'Gemini', 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: '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: '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', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.com/v1', defaultModelId: 'moonshotai/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: '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' },
|
{ id: 'custom', name: 'Custom', icon: '⚙️', placeholder: 'API key...', requiresApiKey: true, showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'your-provider/model-id' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -655,6 +655,7 @@ function ProviderContent({
|
|||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
const [keyValid, setKeyValid] = useState<boolean | null>(null);
|
const [keyValid, setKeyValid] = useState<boolean | null>(null);
|
||||||
|
const [selectedProviderConfigId, setSelectedProviderConfigId] = useState<string | null>(null);
|
||||||
const [baseUrl, setBaseUrl] = useState('');
|
const [baseUrl, setBaseUrl] = useState('');
|
||||||
const [modelId, setModelId] = useState('');
|
const [modelId, setModelId] = useState('');
|
||||||
|
|
||||||
@@ -673,6 +674,7 @@ function ProviderContent({
|
|||||||
|| setupCandidates[0];
|
|| setupCandidates[0];
|
||||||
if (preferred && !cancelled) {
|
if (preferred && !cancelled) {
|
||||||
onSelectProvider(preferred.type);
|
onSelectProvider(preferred.type);
|
||||||
|
setSelectedProviderConfigId(preferred.id);
|
||||||
const typeInfo = providers.find((p) => p.id === preferred.type);
|
const typeInfo = providers.find((p) => p.id === preferred.type);
|
||||||
const requiresKey = typeInfo?.requiresApiKey ?? false;
|
const requiresKey = typeInfo?.requiresApiKey ?? false;
|
||||||
onConfiguredChange(!requiresKey || preferred.hasKey);
|
onConfiguredChange(!requiresKey || preferred.hasKey);
|
||||||
@@ -706,6 +708,7 @@ function ProviderContent({
|
|||||||
|| sameType.find((p) => p.hasKey)
|
|| sameType.find((p) => p.hasKey)
|
||||||
|| sameType[0];
|
|| sameType[0];
|
||||||
const providerIdForLoad = preferredInstance?.id || selectedProvider;
|
const providerIdForLoad = preferredInstance?.id || selectedProvider;
|
||||||
|
setSelectedProviderConfigId(providerIdForLoad);
|
||||||
|
|
||||||
const savedProvider = await window.electron.ipcRenderer.invoke(
|
const savedProvider = await window.electron.ipcRenderer.invoke(
|
||||||
'provider:get',
|
'provider:get',
|
||||||
@@ -746,8 +749,9 @@ function ProviderContent({
|
|||||||
if (requiresKey && apiKey) {
|
if (requiresKey && apiKey) {
|
||||||
const result = await window.electron.ipcRenderer.invoke(
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
'provider:validateKey',
|
'provider:validateKey',
|
||||||
selectedProvider,
|
selectedProviderConfigId || selectedProvider,
|
||||||
apiKey
|
apiKey,
|
||||||
|
{ baseUrl: baseUrl.trim() || undefined }
|
||||||
) as { valid: boolean; error?: string };
|
) as { valid: boolean; error?: string };
|
||||||
|
|
||||||
setKeyValid(result.valid);
|
setKeyValid(result.valid);
|
||||||
@@ -766,11 +770,18 @@ function ProviderContent({
|
|||||||
modelId.trim() ||
|
modelId.trim() ||
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
|
const providerIdForSave =
|
||||||
|
selectedProvider === 'custom'
|
||||||
|
? (selectedProviderConfigId?.startsWith('custom-')
|
||||||
|
? selectedProviderConfigId
|
||||||
|
: `custom-${crypto.randomUUID()}`)
|
||||||
|
: selectedProvider;
|
||||||
|
|
||||||
// Save provider config + API key, then set as default
|
// Save provider config + API key, then set as default
|
||||||
const saveResult = await window.electron.ipcRenderer.invoke(
|
const saveResult = await window.electron.ipcRenderer.invoke(
|
||||||
'provider:save',
|
'provider:save',
|
||||||
{
|
{
|
||||||
id: selectedProvider,
|
id: providerIdForSave,
|
||||||
name: selectedProviderData?.name || selectedProvider,
|
name: selectedProviderData?.name || selectedProvider,
|
||||||
type: selectedProvider,
|
type: selectedProvider,
|
||||||
baseUrl: baseUrl.trim() || undefined,
|
baseUrl: baseUrl.trim() || undefined,
|
||||||
@@ -788,13 +799,14 @@ function ProviderContent({
|
|||||||
|
|
||||||
const defaultResult = await window.electron.ipcRenderer.invoke(
|
const defaultResult = await window.electron.ipcRenderer.invoke(
|
||||||
'provider:setDefault',
|
'provider:setDefault',
|
||||||
selectedProvider
|
providerIdForSave
|
||||||
) as { success: boolean; error?: string };
|
) as { success: boolean; error?: string };
|
||||||
|
|
||||||
if (!defaultResult.success) {
|
if (!defaultResult.success) {
|
||||||
throw new Error(defaultResult.error || 'Failed to set default provider');
|
throw new Error(defaultResult.error || 'Failed to set default provider');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSelectedProviderConfigId(providerIdForSave);
|
||||||
onConfiguredChange(true);
|
onConfiguredChange(true);
|
||||||
toast.success('Provider configured');
|
toast.success('Provider configured');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -824,6 +836,7 @@ function ProviderContent({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value || null;
|
const val = e.target.value || null;
|
||||||
onSelectProvider(val);
|
onSelectProvider(val);
|
||||||
|
setSelectedProviderConfigId(null);
|
||||||
onConfiguredChange(false);
|
onConfiguredChange(false);
|
||||||
onApiKeyChange('');
|
onApiKeyChange('');
|
||||||
setKeyValid(null);
|
setKeyValid(null);
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ interface ProviderState {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
deleteApiKey: (providerId: string) => Promise<void>;
|
deleteApiKey: (providerId: string) => Promise<void>;
|
||||||
setDefaultProvider: (providerId: string) => Promise<void>;
|
setDefaultProvider: (providerId: string) => Promise<void>;
|
||||||
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<string | null>;
|
getApiKey: (providerId: string) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +192,14 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
validateApiKey: async (providerId, apiKey) => {
|
validateApiKey: async (providerId, apiKey, options) => {
|
||||||
try {
|
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;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { valid: false, error: String(error) };
|
return { valid: false, error: String(error) };
|
||||||
|
|||||||
Reference in New Issue
Block a user