perf: memo GridCard, fuse save fns, fix slash tab active color
This commit is contained in:
@@ -11,6 +11,20 @@ const requestSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||
})
|
||||
|
||||
/** Supprime les balises HTML pour extraire le texte brut */
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication and user setting
|
||||
@@ -42,10 +56,13 @@ export async function POST(req: NextRequest) {
|
||||
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err);
|
||||
}
|
||||
const body = await req.json()
|
||||
const { content } = requestSchema.parse(body)
|
||||
const { content: rawContent } = requestSchema.parse(body)
|
||||
|
||||
// Nettoyer le HTML (l'éditeur TipTap envoie du HTML)
|
||||
const content = stripHtml(rawContent)
|
||||
|
||||
// Vérifier qu'il y a au moins 10 mots
|
||||
const wordCount = content.split(/\s+/).length
|
||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||
|
||||
if (wordCount < 10) {
|
||||
return NextResponse.json(
|
||||
@@ -57,7 +74,6 @@ export async function POST(req: NextRequest) {
|
||||
const config = await getSystemConfig()
|
||||
|
||||
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
|
||||
const hasNonLatinChars = /[\u0400-\u04FF\u0600-\u06FF\u4E00-\u9FFF\u0E00-\u0E7F]/.test(content)
|
||||
const isPersian = /[\u0600-\u06FF]/.test(content)
|
||||
const isChinese = /[\u4E00-\u9FFF]/.test(content)
|
||||
const isRussian = /[\u0400-\u04FF]/.test(content)
|
||||
@@ -99,7 +115,7 @@ IMPORTANT INSTRUCTIONS:
|
||||
- Focus on the main topics and themes in THIS SPECIFIC content
|
||||
- Be specific to what is actually discussed
|
||||
|
||||
CONTENT_START: ${content.substring(0, 500)} CONTENT_END
|
||||
CONTENT_START: ${content.substring(0, 3000)} CONTENT_END
|
||||
|
||||
Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"title": "title2", "confidence": 0.85}, {"title": "title3", "confidence": 0.75}]`
|
||||
: `Tu es un générateur de titres. Génère 3 titres concis et descriptifs pour le contenu suivant en ${responseLanguage}.
|
||||
@@ -110,7 +126,7 @@ INSTRUCTIONS IMPORTANTES :
|
||||
- Concentre-toi sur les sujets principaux et thèmes de CE CONTENU SPÉCIFIQUE
|
||||
- Sois spécifique à ce qui est réellement discuté
|
||||
|
||||
CONTENT_START: ${content.substring(0, 500)} CONTENT_END
|
||||
CONTENT_START: ${content.substring(0, 3000)} CONTENT_END
|
||||
|
||||
Réponds SEULEMENT avec un tableau JSON: [{"title": "titre1", "confidence": 0.95}, {"title": "titre2", "confidence": 0.85}, {"title": "titre3", "confidence": 0.75}]`
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export async function POST(req: NextRequest) {
|
||||
metadata: { userId, tier },
|
||||
subscription_data: { metadata: { userId, tier } },
|
||||
customer_update: { address: 'auto' },
|
||||
allow_promotion_codes: true,
|
||||
};
|
||||
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth';
|
||||
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import type Stripe from 'stripe';
|
||||
import { priceIdToTier } from '@/lib/billing/stripe-prices';
|
||||
import { priceIdToTier, getDynamicPrices } from '@/lib/billing/stripe-prices';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest) {
|
||||
? checkoutSession.subscription
|
||||
: (checkoutSession.subscription as any).id;
|
||||
|
||||
const sub = await stripe.subscriptions.retrieve(subId);
|
||||
const sub = await stripe.subscriptions.retrieve(subId) as any;
|
||||
const priceId = sub.items.data[0].price.id;
|
||||
const tier = priceIdToTier(priceId) || (checkoutSession.metadata?.tier as any) || 'PRO';
|
||||
|
||||
@@ -70,11 +70,11 @@ export async function GET(req: NextRequest) {
|
||||
console.error('[billing/status] Failed to sync Stripe session:', err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
|
||||
const effectiveTier = await getEffectiveTier(userId);
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
const prices = await getDynamicPrices();
|
||||
|
||||
return NextResponse.json({
|
||||
tier,
|
||||
@@ -84,6 +84,7 @@ export async function GET(req: NextRequest) {
|
||||
currentPeriodStart: subscription?.currentPeriodStart ?? null,
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
||||
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
||||
prices,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[billing/status]', error);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||
import { toolRegistry } from '@/lib/ai/tools'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||
import { ByokUnavailableError } from '@/lib/byok'
|
||||
import { trackFeatureUsage } from '@/lib/usage-tracker'
|
||||
import { readFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
@@ -70,6 +71,12 @@ export async function POST(req: Request) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return Response.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
if (err instanceof ByokUnavailableError) {
|
||||
return Response.json(
|
||||
{ error: 'byok_unavailable', message: 'Votre clé API BYOK est configurée mais n\'a pas pu être chargée. Vérifiez vos paramètres BYOK.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
console.error('[chat] Quota check error (fail-open):', err)
|
||||
}
|
||||
|
||||
@@ -410,40 +417,49 @@ Focus ONLY on this note unless asked otherwise.`
|
||||
]
|
||||
const wantsChart = chartKeywords.some(k => lastMessage.includes(k))
|
||||
|
||||
const { result, usedByok } = await runLaneWithBillingUser(
|
||||
'chat',
|
||||
sysConfig,
|
||||
userId,
|
||||
async (provider) =>
|
||||
streamText({
|
||||
model: provider.getModel(),
|
||||
system: systemPrompt,
|
||||
messages: incomingMessages,
|
||||
tools: chatTools,
|
||||
toolChoice: wantsChart && chatTools.insert_chart ? { type: 'tool', toolName: 'insert_chart' } : undefined,
|
||||
stopWhen: stepCountIs(5),
|
||||
onFinish: async (final) => {
|
||||
const userContent = incomingMessages[incomingMessages.length - 1].content
|
||||
await prisma.chatMessage.create({
|
||||
data: { conversationId: conversation.id, role: 'user', content: userContent },
|
||||
})
|
||||
await prisma.chatMessage.create({
|
||||
data: { conversationId: conversation.id, role: 'assistant', content: final.text },
|
||||
})
|
||||
if (!usedByok) {
|
||||
trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0)
|
||||
incrementUsageAsync(userId, 'chat')
|
||||
}
|
||||
logAuditEvent({
|
||||
userId,
|
||||
action: 'AI_REQUEST',
|
||||
resource: 'chat',
|
||||
metadata: { tokens: final.usage?.totalTokens, byok: usedByok },
|
||||
ip: getClientIp(req),
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return result.toUIMessageStreamResponse()
|
||||
try {
|
||||
const { result, usedByok } = await runLaneWithBillingUser(
|
||||
'chat',
|
||||
sysConfig,
|
||||
userId,
|
||||
async (provider) =>
|
||||
streamText({
|
||||
model: provider.getModel(),
|
||||
system: systemPrompt,
|
||||
messages: incomingMessages,
|
||||
tools: chatTools,
|
||||
toolChoice: wantsChart && chatTools.insert_chart ? { type: 'tool', toolName: 'insert_chart' } : undefined,
|
||||
stopWhen: stepCountIs(5),
|
||||
onFinish: async (final) => {
|
||||
const userContent = incomingMessages[incomingMessages.length - 1].content
|
||||
await prisma.chatMessage.create({
|
||||
data: { conversationId: conversation.id, role: 'user', content: userContent },
|
||||
})
|
||||
await prisma.chatMessage.create({
|
||||
data: { conversationId: conversation.id, role: 'assistant', content: final.text },
|
||||
})
|
||||
if (!usedByok) {
|
||||
trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0)
|
||||
incrementUsageAsync(userId, 'chat')
|
||||
}
|
||||
logAuditEvent({
|
||||
userId,
|
||||
action: 'AI_REQUEST',
|
||||
resource: 'chat',
|
||||
metadata: { tokens: final.usage?.totalTokens, byok: usedByok },
|
||||
ip: getClientIp(req),
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
return result.toUIMessageStreamResponse()
|
||||
} catch (err) {
|
||||
if (err instanceof ByokUnavailableError) {
|
||||
return Response.json(
|
||||
{ error: 'byok_unavailable', message: 'Votre clé API BYOK est configurée mais n\'a pas pu être chargée. Vérifiez vos paramètres dans Réglages > Clés API.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
39
memento-note/app/api/user/ai-status/route.ts
Normal file
39
memento-note/app/api/user/ai-status/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { getAnyActiveByokForUser } from '@/lib/byok';
|
||||
import { resolveAiRoute } from '@/lib/ai/router';
|
||||
|
||||
/**
|
||||
* GET /api/user/ai-status
|
||||
* Returns the effective AI provider and model for the current user.
|
||||
* Used by the UI to show which model is active (BYOK vs admin).
|
||||
*/
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getSystemConfig();
|
||||
const adminRoute = resolveAiRoute('chat', config);
|
||||
|
||||
const byok = await getAnyActiveByokForUser(session.user.id, adminRoute.providerType);
|
||||
|
||||
if (byok) {
|
||||
const model = (byok.model && byok.model.trim()) ? byok.model : adminRoute.modelName;
|
||||
return NextResponse.json({
|
||||
usedByok: true,
|
||||
provider: byok.provider,
|
||||
model,
|
||||
source: 'byok',
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
usedByok: false,
|
||||
provider: adminRoute.providerType,
|
||||
model: adminRoute.modelName,
|
||||
source: 'admin',
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { encryptApiKey, hashApiKey } from '@/lib/crypto';
|
||||
import { VALID_PROVIDERS } from '@/lib/ai/router';
|
||||
|
||||
type RouteContext = { params: Promise<{ provider: string }> };
|
||||
@@ -16,14 +17,34 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Unknown provider' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { isActive?: boolean };
|
||||
if (typeof body.isActive !== 'boolean') {
|
||||
return NextResponse.json({ error: 'isActive boolean required' }, { status: 400 });
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
isActive?: boolean;
|
||||
model?: string;
|
||||
alias?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string; // optional — only when rotating the key
|
||||
};
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (typeof body.isActive === 'boolean') data.isActive = body.isActive;
|
||||
if (body.model !== undefined) data.model = body.model || null;
|
||||
if (body.alias !== undefined) data.alias = body.alias || '';
|
||||
if (body.baseUrl !== undefined) data.baseUrl = body.baseUrl || null;
|
||||
|
||||
// Key rotation: only when new key explicitly provided
|
||||
if (body.apiKey && body.apiKey.length >= 8) {
|
||||
data.encryptedKey = await encryptApiKey(body.apiKey);
|
||||
data.keyHash = hashApiKey(body.apiKey);
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.userAPIKey.updateMany({
|
||||
where: { userId: session.user.id, provider },
|
||||
data: { isActive: body.isActive },
|
||||
data,
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getEffectiveTier } from '@/lib/entitlements';
|
||||
import { getAllowedByokProviders, isByokProviderAllowed } from '@/lib/byok';
|
||||
import { fetchLiveModelsForProvider } from '@/lib/ai/models-list';
|
||||
import { isByokProviderAllowed } from '@/lib/byok';
|
||||
import { fetchLiveModelsForProvider, PROVIDER_MODEL_SUGGESTIONS, type FetchModelsResult } from '@/lib/ai/models-list';
|
||||
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
|
||||
|
||||
// Providers that return static suggestions regardless of key
|
||||
const STATIC_PROVIDERS = new Set(['anthropic', 'anthropic_custom', 'custom_anthropic', 'google', 'minimax']);
|
||||
|
||||
/**
|
||||
* GET /api/user/api-keys/live-models?provider=<provider>&key=<api_key>&baseUrl=<optional_custom_url>
|
||||
* GET /api/user/api-keys/live-models?provider=<provider>[&key=<api_key>][&baseUrl=<url>]
|
||||
*
|
||||
* Dynamically queries the third-party provider's API with the user's key to fetch
|
||||
* actual available models dynamically.
|
||||
* - Static providers (minimax, anthropic, google): returns suggestions immediately, no key needed.
|
||||
* - Live providers (openai, deepseek…): requires key to fetch live from provider.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
@@ -24,11 +27,11 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const provider = searchParams.get('provider') as AiGatewayProvider;
|
||||
const apiKey = searchParams.get('key');
|
||||
const apiKey = searchParams.get('key') ?? '';
|
||||
const baseUrl = searchParams.get('baseUrl') ?? undefined;
|
||||
|
||||
if (!provider || !apiKey) {
|
||||
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
|
||||
if (!provider) {
|
||||
return NextResponse.json({ error: 'Missing provider' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_PROVIDERS.has(provider)) {
|
||||
@@ -39,7 +42,18 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Tier restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
const models = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);
|
||||
// Static suggestion providers: return immediately without a key
|
||||
if (STATIC_PROVIDERS.has(provider)) {
|
||||
const base = provider === 'anthropic_custom' || provider === 'custom_anthropic' ? 'anthropic' : provider;
|
||||
const models = PROVIDER_MODEL_SUGGESTIONS[base] ?? [];
|
||||
return NextResponse.json({ success: true, models, fromApi: false });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, models });
|
||||
// Live providers need a key
|
||||
if (!apiKey || apiKey.length < 4) {
|
||||
return NextResponse.json({ success: true, models: PROVIDER_MODEL_SUGGESTIONS[provider] ?? [], fromApi: false });
|
||||
}
|
||||
|
||||
const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);
|
||||
return NextResponse.json({ success: true, models: result.models, fromApi: result.fromApi });
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const createSchema = z.object({
|
||||
apiKey: z.string().min(8),
|
||||
alias: z.string().max(120).optional(),
|
||||
model: z.string().max(120).optional(),
|
||||
baseUrl: z.string().url().optional(),
|
||||
baseUrl: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list';
|
||||
@@ -75,8 +75,9 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveBaseUrl = provider === 'custom' ? body.baseUrl : undefined;
|
||||
if (provider !== 'custom' && body.baseUrl) {
|
||||
const effectiveBaseUrl = (provider === 'custom' || provider === 'custom_openai' || provider === 'custom_anthropic') ? body.baseUrl : undefined;
|
||||
const allowsBaseUrl = provider === 'custom' || provider === 'custom_openai' || provider === 'custom_anthropic';
|
||||
if (!allowsBaseUrl && body.baseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'INVALID_REQUEST', message: 'baseUrl is only allowed for custom providers' },
|
||||
{ status: 400 },
|
||||
@@ -96,6 +97,7 @@ export async function POST(req: NextRequest) {
|
||||
plaintext: body.apiKey,
|
||||
alias: body.alias,
|
||||
model: body.model,
|
||||
baseUrl: effectiveBaseUrl,
|
||||
});
|
||||
|
||||
return NextResponse.json({ key: toPublicApiKey(row) });
|
||||
|
||||
167
memento-note/app/api/user/api-keys/test-model/route.ts
Normal file
167
memento-note/app/api/user/api-keys/test-model/route.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
82
memento-note/app/api/user/api-keys/verify/route.ts
Normal file
82
memento-note/app/api/user/api-keys/verify/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getEffectiveTier } from '@/lib/entitlements';
|
||||
import { isByokProviderAllowed } from '@/lib/byok';
|
||||
import { fetchLiveModelsForProvider, type FetchModelsResult } from '@/lib/ai/models-list';
|
||||
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
|
||||
|
||||
/**
|
||||
* GET /api/user/api-keys/verify?provider=<provider>&key=<api_key>&baseUrl=<optional_custom_url>
|
||||
*
|
||||
* Verifies that the user's API key is valid by attempting to fetch models.
|
||||
* Returns validity status and available models if successful.
|
||||
*
|
||||
* Key is only considered VALID if we can fetch models from the actual provider API.
|
||||
* Fallback to hardcoded suggestions is NOT considered valid verification.
|
||||
*/
|
||||
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 baseUrl = searchParams.get('baseUrl') ?? undefined;
|
||||
|
||||
if (!provider || !apiKey) {
|
||||
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 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);
|
||||
|
||||
console.log(`[verify] ${provider}: fromApi=${result.fromApi}, models=${result.models.length}`, result.models);
|
||||
|
||||
// Only consider key valid if we got models from the REAL API (not fallbacks)
|
||||
// Exception: Anthropic/Google/custom don't guarantee public /models endpoints, so we accept fallbacks for them
|
||||
const acceptsFallbacks =
|
||||
provider === 'anthropic' ||
|
||||
provider === 'anthropic_custom' ||
|
||||
provider === 'custom_anthropic' ||
|
||||
provider === 'google' ||
|
||||
provider === 'minimax' ||
|
||||
provider === 'custom' ||
|
||||
provider === 'custom_openai';
|
||||
const isValid = result.models.length > 0 && (result.fromApi || acceptsFallbacks);
|
||||
|
||||
return NextResponse.json({
|
||||
valid: isValid,
|
||||
models: result.models,
|
||||
fromApi: result.fromApi,
|
||||
message: isValid
|
||||
? result.fromApi
|
||||
? `${result.models.length} modèle(s) trouvé(s) via l'API ${provider}`
|
||||
: `${result.models.length} modèle(s) suggéré(s) pour ${provider}`
|
||||
: 'Aucun modèle trouvé - vérifiez votre clé API'
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Clé API invalide';
|
||||
console.error(`[verify] ${provider} failed:`, error);
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
models: [],
|
||||
fromApi: false,
|
||||
message
|
||||
}, { status: 200 }); // Return 200 with valid:false for UI handling
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user