168 lines
6.5 KiB
TypeScript
168 lines
6.5 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { auth } from '@/auth';
|
|
import { getEffectiveTier } from '@/lib/entitlements';
|
|
import { isByokProviderAllowed } from '@/lib/byok';
|
|
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
|
|
|
|
function cleanReply(text: string): string {
|
|
// Strip <think>...</think> reasoning blocks (DeepSeek, MiniMax, etc.)
|
|
return text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/<thinking>[\s\S]*?<\/thinking>/gi, '').trim() || text.trim()
|
|
}
|
|
|
|
const PROVIDER_URLS: Record<string, string> = {
|
|
openai: 'https://api.openai.com/v1',
|
|
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.io/v1',
|
|
glm: 'https://open.bigmodel.ai/api/paas/v4',
|
|
};
|
|
|
|
/**
|
|
* GET /api/user/api-keys/test-model?provider=X&key=Y&model=Z[&baseUrl=...]
|
|
* Sends a minimal chat completion to verify the key + model work end-to-end.
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
const tier = await getEffectiveTier(session.user.id);
|
|
if (tier === 'BASIC') return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
|
|
const { searchParams } = request.nextUrl;
|
|
const provider = searchParams.get('provider') as AiGatewayProvider;
|
|
const apiKey = searchParams.get('key') ?? '';
|
|
const model = searchParams.get('model') ?? '';
|
|
const baseUrl = searchParams.get('baseUrl') ?? undefined;
|
|
|
|
if (!provider || apiKey.length < 4 || !model) {
|
|
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
|
|
}
|
|
|
|
if (!VALID_PROVIDERS.has(provider)) {
|
|
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
|
}
|
|
|
|
if (!isByokProviderAllowed(tier, provider)) {
|
|
return NextResponse.json({ error: 'Tier restricted' }, { status: 403 });
|
|
}
|
|
|
|
const start = Date.now();
|
|
|
|
try {
|
|
// Anthropic has a different API format
|
|
if (provider === 'anthropic' || provider === 'anthropic_custom' || provider === 'custom_anthropic') {
|
|
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,
|
|
max_tokens: 60,
|
|
messages: [{ role: 'user', content: 'Say: ok' }],
|
|
}),
|
|
signal: AbortSignal.timeout(20_000),
|
|
});
|
|
|
|
const latency = Date.now() - start;
|
|
if (res.status === 401 || res.status === 403) {
|
|
return NextResponse.json({ ok: false, error: 'Clé API invalide' });
|
|
}
|
|
if (res.status === 404) {
|
|
return NextResponse.json({ ok: false, error: `Modèle "${model}" introuvable` });
|
|
}
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
return NextResponse.json({ ok: false, error: body?.error?.message ?? `Erreur ${res.status}` });
|
|
}
|
|
const body = await res.json();
|
|
const raw = body?.content?.[0]?.text ?? '(réponse reçue)';
|
|
return NextResponse.json({ ok: true, latency, reply: cleanReply(raw) });
|
|
}
|
|
|
|
// Google AI
|
|
if (provider === 'google') {
|
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${apiKey}`;
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ contents: [{ parts: [{ text: 'Reply with: ok' }] }], generationConfig: { maxOutputTokens: 5 } }),
|
|
signal: AbortSignal.timeout(20_000),
|
|
});
|
|
const latency = Date.now() - start;
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
if (res.status === 400 && body?.error?.message?.includes('not found')) {
|
|
return NextResponse.json({ ok: false, error: `Modèle "${model}" introuvable` });
|
|
}
|
|
return NextResponse.json({ ok: false, error: body?.error?.message ?? `Erreur ${res.status}` });
|
|
}
|
|
const body = await res.json();
|
|
const raw = body?.candidates?.[0]?.content?.parts?.[0]?.text ?? '(réponse reçue)';
|
|
return NextResponse.json({ ok: true, latency, reply: cleanReply(raw) });
|
|
}
|
|
|
|
// OpenAI-compatible (openai, deepseek, minimax, openrouter, mistral, glm, zai, custom_openai, custom, lmstudio)
|
|
const url = (provider === 'custom' || provider === 'custom_openai')
|
|
? `${(baseUrl ?? '').replace(/\/$/, '')}/chat/completions`
|
|
: `${PROVIDER_URLS[provider] ?? ''}/chat/completions`;
|
|
|
|
if (!url || url.startsWith('/')) {
|
|
return NextResponse.json({ ok: false, error: 'URL de l\'API manquante' });
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
'content-type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
};
|
|
if (provider === 'openrouter') {
|
|
headers['HTTP-Referer'] = 'https://memento-note.com';
|
|
headers['X-Title'] = 'Memento';
|
|
}
|
|
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({
|
|
model,
|
|
max_tokens: 5,
|
|
messages: [{ role: 'user', content: 'Reply with: ok' }],
|
|
}),
|
|
signal: AbortSignal.timeout(20_000),
|
|
});
|
|
|
|
const latency = Date.now() - start;
|
|
|
|
if (res.status === 401 || res.status === 403) {
|
|
return NextResponse.json({ ok: false, error: 'Clé API invalide ou refusée' });
|
|
}
|
|
if (res.status === 404) {
|
|
return NextResponse.json({ ok: false, error: `Modèle "${model}" introuvable` });
|
|
}
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
const msg = body?.error?.message ?? body?.message ?? `Erreur ${res.status}`;
|
|
return NextResponse.json({ ok: false, error: msg });
|
|
}
|
|
|
|
const body = await res.json();
|
|
const raw = body?.choices?.[0]?.message?.content ?? '(réponse reçue)';
|
|
return NextResponse.json({ ok: true, latency, reply: cleanReply(raw) });
|
|
|
|
} catch (err: unknown) {
|
|
const latency = Date.now() - start;
|
|
if (err instanceof Error && err.name === 'TimeoutError') {
|
|
return NextResponse.json({ ok: false, error: 'Délai d\'attente dépassé (>20s)' });
|
|
}
|
|
return NextResponse.json({ ok: false, error: err instanceof Error ? err.message : 'Erreur inconnue', latency });
|
|
}
|
|
}
|