239 lines
7.4 KiB
TypeScript
239 lines
7.4 KiB
TypeScript
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
|
|
|
type ValidationProfile =
|
|
| 'openai-compatible'
|
|
| 'google-query-key'
|
|
| 'anthropic-header'
|
|
| 'openrouter'
|
|
| 'none';
|
|
|
|
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 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): ValidationProfile {
|
|
switch (providerType) {
|
|
case 'anthropic':
|
|
return 'anthropic-header';
|
|
case 'google':
|
|
return 'google-query-key';
|
|
case 'openrouter':
|
|
return 'openrouter';
|
|
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 proxyAwareFetch(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)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
function classifyAuthResponse(
|
|
status: number,
|
|
data: unknown,
|
|
): { valid: boolean; error?: string } {
|
|
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' };
|
|
|
|
const obj = data as { error?: { message?: string }; message?: string } | null;
|
|
const msg = obj?.error?.message || obj?.message || `API error: ${status}`;
|
|
return { valid: false, error: msg };
|
|
}
|
|
|
|
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 headers = { Authorization: `Bearer ${apiKey}` };
|
|
const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl);
|
|
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
|
|
|
|
if (modelsResult.error?.includes('API error: 404')) {
|
|
console.log(
|
|
`[clawx-validate] ${providerType} /models returned 404, falling back to /chat/completions probe`,
|
|
);
|
|
const base = normalizeBaseUrl(trimmedBaseUrl);
|
|
const chatUrl = `${base}/chat/completions`;
|
|
return await performChatCompletionsProbe(providerType, chatUrl, headers);
|
|
}
|
|
|
|
return modelsResult;
|
|
}
|
|
|
|
async function performChatCompletionsProbe(
|
|
providerLabel: string,
|
|
url: string,
|
|
headers: Record<string, string>,
|
|
): Promise<{ valid: boolean; error?: string }> {
|
|
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(() => ({}));
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
return { valid: false, error: 'Invalid API key' };
|
|
}
|
|
if (
|
|
(response.status >= 200 && response.status < 300) ||
|
|
response.status === 400 ||
|
|
response.status === 429
|
|
) {
|
|
return { valid: true };
|
|
}
|
|
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 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<{ 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);
|
|
}
|
|
|
|
async function validateOpenRouterKey(
|
|
providerType: string,
|
|
apiKey: string,
|
|
): Promise<{ valid: boolean; error?: string }> {
|
|
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 },
|
|
): 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 (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);
|
|
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 };
|
|
}
|
|
}
|