130 lines
3.5 KiB
TypeScript
130 lines
3.5 KiB
TypeScript
import type { AiGatewayProvider } from '@/lib/ai/router';
|
|
|
|
const OPENAI_COMPAT = new Set<string>([
|
|
'openai',
|
|
'deepseek',
|
|
'openrouter',
|
|
'mistral',
|
|
'zai',
|
|
'minimax',
|
|
'glm',
|
|
'custom',
|
|
'lmstudio',
|
|
'custom_openai',
|
|
]);
|
|
|
|
async function validateOpenAiCompatible(
|
|
apiKey: string,
|
|
baseUrl: string,
|
|
): Promise<void> {
|
|
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/models`, {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`Provider rejected the API key (${res.status})`);
|
|
}
|
|
}
|
|
|
|
async function validateAnthropic(
|
|
apiKey: string,
|
|
baseUrl?: string,
|
|
): Promise<void> {
|
|
const url = baseUrl
|
|
? `${baseUrl.replace(/\/$/, '')}/messages`
|
|
: 'https://api.anthropic.com/v1/messages';
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'x-api-key': apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'claude-3-5-haiku-20241022',
|
|
max_tokens: 1,
|
|
messages: [{ role: 'user', content: 'ping' }],
|
|
}),
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (res.status === 401 || res.status === 403) {
|
|
throw new Error('Provider rejected the API key');
|
|
}
|
|
if (res.status >= 500) {
|
|
throw new Error('Provider temporarily unavailable; try again');
|
|
}
|
|
}
|
|
|
|
async function validateGoogle(apiKey: string): Promise<void> {
|
|
const res = await fetch(
|
|
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`,
|
|
{ signal: AbortSignal.timeout(10_000) },
|
|
);
|
|
if (!res.ok) {
|
|
throw new Error(`Provider rejected the API key (${res.status})`);
|
|
}
|
|
}
|
|
|
|
const BASE_URLS: Partial<Record<AiGatewayProvider, string>> = {
|
|
deepseek: 'https://api.deepseek.com/v1',
|
|
openrouter: 'https://openrouter.ai/api/v1',
|
|
mistral: 'https://api.mistral.ai/v1',
|
|
zai: 'https://api.zukijourney.com/v1',
|
|
minimax: 'https://api.minimax.chat/v1',
|
|
glm: 'https://open.bigmodel.ai/api/paas/v4',
|
|
openai: 'https://api.openai.com/v1',
|
|
};
|
|
|
|
export async function validateProviderApiKey(
|
|
provider: AiGatewayProvider,
|
|
apiKey: string,
|
|
baseUrl?: string,
|
|
): Promise<void> {
|
|
if (!apiKey.trim()) {
|
|
throw new Error('API key is required');
|
|
}
|
|
|
|
// Bypass key validation in development or for test keys to allow local UI testing without live credentials
|
|
if (process.env.NODE_ENV === 'development' || apiKey.startsWith('test-') || apiKey.startsWith('mock-')) {
|
|
console.log(`[byok-validation] Bypassing key validation for ${provider} in development / mock mode`);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
provider === 'anthropic' ||
|
|
provider === 'anthropic_custom' ||
|
|
provider === 'custom_anthropic'
|
|
) {
|
|
await validateAnthropic(apiKey, baseUrl);
|
|
return;
|
|
}
|
|
|
|
if (provider === 'google') {
|
|
await validateGoogle(apiKey);
|
|
return;
|
|
}
|
|
|
|
if (provider === 'minimax') {
|
|
// MiniMax does not expose a public /models endpoint. Key is valid if not empty.
|
|
if (!apiKey.trim()) throw new Error('API key is required');
|
|
return;
|
|
}
|
|
|
|
if (provider === 'ollama' || provider === 'lmstudio') {
|
|
throw new Error('Local providers are not supported for BYOK');
|
|
}
|
|
|
|
if (OPENAI_COMPAT.has(provider)) {
|
|
const url = (provider === 'custom' || provider === 'custom_openai')
|
|
? baseUrl
|
|
: BASE_URLS[provider as AiGatewayProvider];
|
|
if (!url) {
|
|
throw new Error('Base URL is required for this provider');
|
|
}
|
|
await validateOpenAiCompatible(apiKey, url);
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Unsupported provider: ${provider}`);
|
|
}
|