Files
DeskClaw/electron/services/providers/provider-validation.ts
2026-04-06 14:35:09 +08:00

400 lines
13 KiB
TypeScript

import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { getProviderConfig } from '../../utils/provider-registry';
type ValidationProfile =
| 'openai-completions'
| 'openai-responses'
| 'google-query-key'
| 'anthropic-header'
| 'openrouter'
| 'none';
type ValidationResult = { valid: boolean; error?: string; status?: number };
type ClassifiedValidationResult = ValidationResult & { authFailure?: boolean };
const AUTH_ERROR_PATTERN = /\b(unauthorized|forbidden|access denied|invalid api key|api key invalid|incorrect api key|api key incorrect|authentication failed|auth failed|invalid credential|credential invalid|invalid signature|signature invalid|invalid access token|access token invalid|invalid bearer token|bearer token invalid|access token expired)\b|鉴权失败|認証失敗|认证失败|無效密鑰|无效密钥|密钥无效|密鑰無效|憑證無效|凭证无效/i;
const AUTH_ERROR_CODE_PATTERN = /\b(unauthorized|forbidden|access[_-]?denied|invalid[_-]?api[_-]?key|api[_-]?key[_-]?invalid|incorrect[_-]?api[_-]?key|api[_-]?key[_-]?incorrect|authentication[_-]?failed|auth[_-]?failed|invalid[_-]?credential|credential[_-]?invalid|invalid[_-]?signature|signature[_-]?invalid|invalid[_-]?access[_-]?token|access[_-]?token[_-]?invalid|invalid[_-]?bearer[_-]?token|bearer[_-]?token[_-]?invalid|access[_-]?token[_-]?expired|invalid[_-]?token|token[_-]?invalid|token[_-]?expired)\b/i;
function logValidationStatus(provider: string, status: number): void {
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
}
function maskSecret(secret: string): string {
if (!secret) return '';
if (secret.length <= 8) return `${secret.slice(0, 2)}***`;
return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
}
function sanitizeValidationUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);
const key = url.searchParams.get('key');
if (key) url.searchParams.set('key', maskSecret(key));
return url.toString();
} catch {
return rawUrl;
}
}
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
const next = { ...headers };
if (next.Authorization?.startsWith('Bearer ')) {
const token = next.Authorization.slice('Bearer '.length);
next.Authorization = `Bearer ${maskSecret(token)}`;
}
if (next['x-api-key']) {
next['x-api-key'] = maskSecret(next['x-api-key']);
}
return next;
}
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, '');
}
function buildOpenAiModelsUrl(baseUrl: string): string {
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
}
function resolveOpenAiProbeUrls(
baseUrl: string,
apiProtocol: 'openai-completions' | 'openai-responses',
): { modelsUrl: string; probeUrl: string } {
const normalizedBase = normalizeBaseUrl(baseUrl);
const endpointSuffixPattern = /(\/responses?|\/chat\/completions)$/;
const rootBase = normalizedBase.replace(endpointSuffixPattern, '');
const modelsUrl = buildOpenAiModelsUrl(rootBase);
if (apiProtocol === 'openai-responses') {
const probeUrl = /(\/responses?)$/.test(normalizedBase)
? normalizedBase
: `${rootBase}/responses`;
return { modelsUrl, probeUrl };
}
const probeUrl = /\/chat\/completions$/.test(normalizedBase)
? normalizedBase
: `${rootBase}/chat/completions`;
return { modelsUrl, probeUrl };
}
function logValidationRequest(
provider: string,
method: string,
url: string,
headers: Record<string, string>,
): void {
console.log(
`[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}`,
);
}
function getValidationProfile(
providerType: string,
options?: { apiProtocol?: string }
): ValidationProfile {
const providerApi = options?.apiProtocol || getProviderConfig(providerType)?.api;
if (providerApi === 'anthropic-messages') {
return 'anthropic-header';
}
if (providerApi === 'openai-responses') {
return 'openai-responses';
}
if (providerApi === 'openai-completions') {
return 'openai-completions';
}
switch (providerType) {
case 'anthropic':
return 'anthropic-header';
case 'google':
return 'google-query-key';
case 'openrouter':
return 'openrouter';
case 'ollama':
return 'none';
default:
return 'openai-completions';
}
}
async function performProviderValidationRequest(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<ClassifiedValidationResult> {
try {
logValidationRequest(providerLabel, 'GET', url, headers);
const response = await proxyAwareFetch(url, { headers });
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
const result = classifyAuthResponse(response.status, data);
return { ...result, status: response.status };
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
function classifyAuthResponse(
status: number,
data: unknown,
) : ClassifiedValidationResult {
const obj = data as {
error?: { message?: string; code?: string };
message?: string;
code?: string;
} | null;
const msg = obj?.error?.message || obj?.message || `API error: ${status}`;
const code = obj?.error?.code || obj?.code;
const hasAuthCode = typeof code === 'string' && AUTH_ERROR_CODE_PATTERN.test(code);
if (status >= 200 && status < 300) return { valid: true };
if (status === 429) return { valid: true };
if (status === 401 || status === 403) {
return { valid: false, error: 'Invalid API key', authFailure: true };
}
if (status === 400 && (AUTH_ERROR_PATTERN.test(msg) || hasAuthCode)) {
const error = hasAuthCode && msg === `API error: ${status}`
? `Invalid API key (${code})`
: msg || 'Invalid API key';
return { valid: false, error, authFailure: true };
}
return { valid: false, error: msg };
}
function shouldFallbackFromModelsProbe(result: ClassifiedValidationResult): boolean {
if (result.valid || result.status === undefined) return false;
if (result.status === 401 || result.status === 403) return false;
if (result.authFailure) return false;
return true;
}
function classifyProbeResponse(
status: number,
data: unknown,
): ClassifiedValidationResult {
const classified = classifyAuthResponse(status, data);
if (status >= 200 && status < 300) {
return { valid: true, status };
}
if (status === 429) {
return { valid: true, status };
}
if (status === 400 && !classified.authFailure) {
return { valid: true, status };
}
return { ...classified, status };
}
async function validateOpenAiCompatibleKey(
providerType: string,
apiKey: string,
apiProtocol: 'openai-completions' | 'openai-responses',
baseUrl?: string,
): Promise<ValidationResult> {
const trimmedBaseUrl = baseUrl?.trim();
if (!trimmedBaseUrl) {
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
}
const headers = { Authorization: `Bearer ${apiKey}` };
const { modelsUrl, probeUrl } = resolveOpenAiProbeUrls(trimmedBaseUrl, apiProtocol);
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
if (shouldFallbackFromModelsProbe(modelsResult)) {
console.log(
`[clawx-validate] ${providerType} /models returned ${modelsResult.status}, falling back to ${apiProtocol} probe`,
);
if (apiProtocol === 'openai-responses') {
return await performResponsesProbe(providerType, probeUrl, headers);
}
return await performChatCompletionsProbe(providerType, probeUrl, headers);
}
return modelsResult;
}
async function performResponsesProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
input: 'hi',
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
return classifyProbeResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function performChatCompletionsProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 1,
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
return classifyProbeResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function performAnthropicMessagesProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 1,
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
return classifyProbeResponse(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<ValidationResult> {
const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta');
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
return await performProviderValidationRequest(providerType, url, {});
}
async function validateAnthropicHeaderKey(
providerType: string,
apiKey: string,
baseUrl?: string,
): Promise<ValidationResult> {
const rawBase = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
const base = rawBase.endsWith('/v1') ? rawBase : `${rawBase}/v1`;
const url = `${base}/models?limit=1`;
const headers = {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
};
const modelsResult = await performProviderValidationRequest(providerType, url, headers);
// If the endpoint doesn't implement /models (like Minimax Anthropic compatibility), fallback to a /messages probe.
if (
modelsResult.status === 404 ||
modelsResult.status === 400 ||
modelsResult.error?.includes('API error: 404') ||
modelsResult.error?.includes('API error: 400')
) {
console.log(
`[clawx-validate] ${providerType} /models returned error, falling back to /messages probe`,
);
const messagesUrl = `${base}/messages`;
return await performAnthropicMessagesProbe(providerType, messagesUrl, headers);
}
return modelsResult;
}
async function validateOpenRouterKey(
providerType: string,
apiKey: string,
): Promise<ValidationResult> {
const url = 'https://openrouter.ai/api/v1/auth/key';
const headers = { Authorization: `Bearer ${apiKey}` };
return await performProviderValidationRequest(providerType, url, headers);
}
export async function validateApiKeyWithProvider(
providerType: string,
apiKey: string,
options?: { baseUrl?: string; apiProtocol?: string },
): Promise<ValidationResult> {
const profile = getValidationProfile(providerType, options);
const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl;
if (profile === 'none') {
return { valid: true };
}
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
return { valid: false, error: 'API key is required' };
}
try {
switch (profile) {
case 'openai-completions':
return await validateOpenAiCompatibleKey(
providerType,
trimmedKey,
'openai-completions',
resolvedBaseUrl,
);
case 'openai-responses':
return await validateOpenAiCompatibleKey(
providerType,
trimmedKey,
'openai-responses',
resolvedBaseUrl,
);
case 'google-query-key':
return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl);
case 'anthropic-header':
return await validateAnthropicHeaderKey(providerType, trimmedKey, resolvedBaseUrl);
case 'openrouter':
return await validateOpenRouterKey(providerType, trimmedKey);
default:
return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` };
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { valid: false, error: errorMessage };
}
}