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 ... reasoning blocks (DeepSeek, MiniMax, etc.) return text.replace(/[\s\S]*?<\/think>/gi, '').replace(/[\s\S]*?<\/thinking>/gi, '').trim() || text.trim() } const PROVIDER_URLS: Record = { 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 = { '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 }); } }