perf: memo GridCard, fuse save fns, fix slash tab active color
This commit is contained in:
@@ -5,7 +5,17 @@ import {
|
||||
type AiGatewayProvider,
|
||||
} from '@/lib/ai/router';
|
||||
import { getProviderConfigKeys } from '@/lib/ai/factory';
|
||||
import type { SubscriptionTier } from '@/lib/entitlements';
|
||||
import { getUserInfo, type SubscriptionTier } from '@/lib/entitlements';
|
||||
import { redis } from '@/lib/redis';
|
||||
|
||||
/** Thrown when user has active BYOK configured but it can't be loaded (decryption failure, etc.). */
|
||||
export class ByokUnavailableError extends Error {
|
||||
readonly code = 'BYOK_UNAVAILABLE';
|
||||
constructor(msg = 'Votre clé API est configurée mais n\'a pas pu être chargée.') {
|
||||
super(msg);
|
||||
this.name = 'ByokUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const PRO_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
|
||||
'openai',
|
||||
@@ -14,6 +24,8 @@ const PRO_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
|
||||
'openrouter',
|
||||
'minimax',
|
||||
'zai',
|
||||
'custom_openai', // Custom OpenAI-compatible API
|
||||
'custom_anthropic', // Custom Anthropic-compatible API
|
||||
];
|
||||
|
||||
const BUSINESS_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
|
||||
@@ -42,33 +54,125 @@ export async function hasAnyActiveByok(userId: string): Promise<boolean> {
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
export async function getActiveByokKey(userId: string, provider: string) {
|
||||
return prisma.userAPIKey.findFirst({
|
||||
/**
|
||||
* Get active BYOK key for a user and provider.
|
||||
* Optionally pass tier to avoid an extra query. If not provided, tier will be fetched.
|
||||
*/
|
||||
export async function getActiveByokKey(userId: string, provider: string, tier?: SubscriptionTier) {
|
||||
const key = await prisma.userAPIKey.findFirst({
|
||||
where: { userId, provider, isActive: true },
|
||||
});
|
||||
|
||||
// Safety check: if key exists but provider is no longer allowed for user's tier, deactivate it
|
||||
if (key) {
|
||||
const effectiveTier = tier ?? (await getUserInfo(userId)).tier;
|
||||
if (!isByokProviderAllowed(effectiveTier, provider)) {
|
||||
await prisma.userAPIKey.update({
|
||||
where: { id: key.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
console.warn(`[byok] Deactivated key for ${provider} (user ${userId}) - tier ${effectiveTier} does not allow this provider`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate all API keys that are no longer allowed for the user's current tier.
|
||||
* Call this when a user's subscription tier changes.
|
||||
*/
|
||||
export async function deactivateUnauthorizedKeys(userId: string): Promise<number> {
|
||||
const { tier } = await getUserInfo(userId);
|
||||
const allowedProviders = new Set(getAllowedByokProviders(tier));
|
||||
|
||||
// Find all active keys
|
||||
const allKeys = await prisma.userAPIKey.findMany({
|
||||
where: { userId, isActive: true },
|
||||
select: { id: true, provider: true },
|
||||
});
|
||||
|
||||
// Filter keys that are no longer allowed
|
||||
const toDeactivate = allKeys.filter((k) => !allowedProviders.has(k.provider as AiGatewayProvider));
|
||||
|
||||
if (toDeactivate.length === 0) return 0;
|
||||
|
||||
// Batch deactivate
|
||||
await prisma.userAPIKey.updateMany({
|
||||
where: {
|
||||
id: { in: toDeactivate.map((k) => k.id) },
|
||||
},
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
console.log(`[byok] Deactivated ${toDeactivate.length} keys for user ${userId} after tier change to ${tier}`);
|
||||
return toDeactivate.length;
|
||||
}
|
||||
|
||||
export async function resolveByokApiKey(
|
||||
userId: string,
|
||||
providerType: string,
|
||||
): Promise<{ plaintext: string; provider: string; model: string | null } | null> {
|
||||
feature?: string,
|
||||
): Promise<{ plaintext: string; provider: string; model: string | null; baseUrl: string | null } | null> {
|
||||
const row = await getActiveByokKey(userId, providerType);
|
||||
if (!row) return null;
|
||||
try {
|
||||
const plaintext = await decryptApiKey(row.encryptedKey);
|
||||
return { plaintext, provider: row.provider, model: row.model };
|
||||
prisma.userAPIKey.update({
|
||||
where: { id: row.id },
|
||||
data: { lastUsedAt: new Date(), lastUsedFor: feature || null },
|
||||
}).catch((err) => { console.error('[byok] Failed to update lastUsedAt/lastUsedFor:', err); });
|
||||
return { plaintext, provider: row.provider, model: row.model, baseUrl: row.baseUrl ?? null };
|
||||
} catch (err) {
|
||||
console.error('[byok] Failed to decrypt key for provider', providerType, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns any active BYOK key for the user.
|
||||
* Prefers a key matching preferredProvider, falls back to any active key.
|
||||
* This allows BYOK to work regardless of which provider the admin configured.
|
||||
*/
|
||||
export async function getAnyActiveByokForUser(
|
||||
userId: string,
|
||||
preferredProvider?: string,
|
||||
feature?: string,
|
||||
): Promise<{ plaintext: string; provider: string; model: string | null; baseUrl: string | null } | null> {
|
||||
// 1. Try exact match first
|
||||
if (preferredProvider) {
|
||||
const exact = await resolveByokApiKey(userId, preferredProvider, feature);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
// 2. Fall back to any active key
|
||||
const anyRow = await prisma.userAPIKey.findFirst({
|
||||
where: { userId, isActive: true },
|
||||
orderBy: { lastUsedAt: 'desc' },
|
||||
});
|
||||
if (!anyRow) return null;
|
||||
|
||||
try {
|
||||
const plaintext = await decryptApiKey(anyRow.encryptedKey);
|
||||
prisma.userAPIKey.update({
|
||||
where: { id: anyRow.id },
|
||||
data: { lastUsedAt: new Date(), lastUsedFor: feature || null },
|
||||
}).catch(() => {});
|
||||
return { plaintext, provider: anyRow.provider, model: anyRow.model, baseUrl: anyRow.baseUrl ?? null };
|
||||
} catch (err) {
|
||||
console.error('[byok] Failed to decrypt any active key:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyByokToConfig(
|
||||
billingUserId: string,
|
||||
providerType: string,
|
||||
config: Record<string, string>,
|
||||
feature?: string,
|
||||
): Promise<{ config: Record<string, string>; usedByok: boolean; model: string | null }> {
|
||||
const byok = await resolveByokApiKey(billingUserId, providerType);
|
||||
const byok = await resolveByokApiKey(billingUserId, providerType, feature);
|
||||
if (!byok) return { config, usedByok: false, model: null };
|
||||
|
||||
const { apiKeyConfigKey } = getProviderConfigKeys(providerType);
|
||||
@@ -87,6 +191,7 @@ export async function upsertUserApiKey(params: {
|
||||
plaintext: string;
|
||||
alias?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
}) {
|
||||
const encryptedKey = await encryptApiKey(params.plaintext);
|
||||
const keyHash = hashApiKey(params.plaintext);
|
||||
@@ -105,6 +210,7 @@ export async function upsertUserApiKey(params: {
|
||||
encryptedKey,
|
||||
keyHash,
|
||||
model: params.model ?? null,
|
||||
baseUrl: params.baseUrl ?? null,
|
||||
isActive: true,
|
||||
},
|
||||
update: {
|
||||
@@ -112,15 +218,37 @@ export async function upsertUserApiKey(params: {
|
||||
encryptedKey,
|
||||
keyHash,
|
||||
model: params.model ?? null,
|
||||
baseUrl: params.baseUrl ?? null,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this API key hash is already used by another provider for this user.
|
||||
* Returns the existing provider if found, null otherwise.
|
||||
*/
|
||||
export async function findDuplicateApiKeyHash(
|
||||
userId: string,
|
||||
keyHash: string,
|
||||
excludeProvider?: string,
|
||||
): Promise<string | null> {
|
||||
const existing = await prisma.userAPIKey.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
keyHash,
|
||||
...(excludeProvider ? { provider: { not: excludeProvider } } : {}),
|
||||
},
|
||||
select: { provider: true },
|
||||
});
|
||||
return existing?.provider ?? null;
|
||||
}
|
||||
|
||||
export function toPublicApiKey(row: {
|
||||
provider: string;
|
||||
alias: string;
|
||||
model: string | null;
|
||||
baseUrl: string | null;
|
||||
isActive: boolean;
|
||||
lastUsedAt: Date | null;
|
||||
createdAt: Date;
|
||||
@@ -130,9 +258,77 @@ export function toPublicApiKey(row: {
|
||||
provider: row.provider,
|
||||
alias: row.alias,
|
||||
model: row.model,
|
||||
baseUrl: row.baseUrl,
|
||||
isActive: row.isActive,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit for API key creation: max 5 keys per hour per user.
|
||||
* Uses atomic Lua script to prevent race conditions.
|
||||
* Returns true if limit is not exceeded, false if rate limited.
|
||||
*/
|
||||
const RATE_LIMIT_LUA = `
|
||||
local key = KEYS[1]
|
||||
local limit = tonumber(ARGV[1])
|
||||
local window = tonumber(ARGV[2])
|
||||
local current = tonumber(redis.call('GET', key) or '0')
|
||||
if current >= limit then
|
||||
local ttl = redis.call('TTL', key)
|
||||
return {-1, ttl}
|
||||
end
|
||||
local newCount = redis.call('INCR', key)
|
||||
if newCount == 1 then
|
||||
redis.call('EXPIRE', key, window)
|
||||
end
|
||||
return {newCount, limit - newCount}
|
||||
`;
|
||||
|
||||
// Simple in-memory cache for rate limit results (30s TTL)
|
||||
const rateLimitCache = new Map<string, { result: { allowed: boolean; remaining: number; resetAt: Date | null }; expiresAt: number }>();
|
||||
const CACHE_TTL = 30_000; // 30 seconds
|
||||
|
||||
export async function checkApiKeyCreationRateLimit(userId: string): Promise<{ allowed: boolean; remaining: number; resetAt: Date | null }> {
|
||||
const key = `byok:ratelimit:create:${userId}`;
|
||||
const limit = 5; // max 5 creations per hour
|
||||
const window = 60 * 60; // 1 hour in seconds
|
||||
|
||||
// Check cache first
|
||||
const cached = rateLimitCache.get(key);
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await redis.eval(RATE_LIMIT_LUA, 1, key, String(limit), String(window)) as number[];
|
||||
|
||||
if (!Array.isArray(result)) {
|
||||
// Fallback for non-array results (shouldn't happen with correct Lua)
|
||||
return { allowed: true, remaining: limit, resetAt: null };
|
||||
}
|
||||
|
||||
const [value, ttlOrRemaining] = result;
|
||||
|
||||
// Rate limited
|
||||
if (value === -1) {
|
||||
const ttl = ttlOrRemaining as number;
|
||||
const resetAt = ttl > 0 ? new Date(Date.now() + ttl * 1000) : null;
|
||||
const rateLimitResult = { allowed: false, remaining: 0, resetAt };
|
||||
// Cache rate limited results for 60s
|
||||
rateLimitCache.set(key, { result: rateLimitResult, expiresAt: Date.now() + 60_000 });
|
||||
return rateLimitResult;
|
||||
}
|
||||
|
||||
// Allowed
|
||||
const remaining = ttlOrRemaining as number;
|
||||
const rateLimitResult = { allowed: true, remaining, resetAt: null };
|
||||
// Don't cache allowed results too aggressively (let users create keys)
|
||||
return rateLimitResult;
|
||||
} catch (err) {
|
||||
console.error('[byok] Rate limit check failed, allowing request:', err);
|
||||
return { allowed: true, remaining: limit, resetAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user