perf: memo GridCard, fuse save fns, fix slash tab active color
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-06-14 14:06:05 +00:00
parent a8785ed4f1
commit a623454347
120 changed files with 12301 additions and 785 deletions

View File

@@ -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) {

View File

@@ -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 });
}

View File

@@ -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) });

View 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 });
}
}

View 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
}
}