# Memento — Patch Technique v3.0 : BYOK, Billing Collaboratif & Paywall Temps Réel > **Version:** 3.0 | **Date:** 2026-05-14 | **Statut:** Draft > **Rôle:** Staff Backend Engineer / Architecte Cloud Sécurité > **Contexte:** Synchronisation de la documentation technique brainstorm avec le modèle pricing GTM v1 --- ## 0. Objectif & Portée Ce patch technique complète [saas-deployment-prep.md](saas-deployment-prep.md) (V3) et [gtm-pricing-strategy.md](docs/gtm-pricing-strategy.md). Il définit l'implémentation technique de : 1. **BYOK** — Stockage sécurisé des clés API + LLM Router interne 2. **Host-Pays Principle** — Facturation collaborative via Socket.io 3. **Paywall Temps Réel** — Erreurs HTTP 402 + Socket events → PaywallModal instantané 4. **Paradoxe Basic** — Brainstorm gratuit sans fuite de recherche sémantique --- ## 1. BYOK — Architecture Complète ### 1.1 Pourquoi BYOK est Critique — Architecture Optimisée avec Coûts Réels | Approche | Coût/100 users/mois | Notes | |----------|-------------------|-------| | **Ollama local (24GB RAM)** | $0 | ❌ Impossible (OOM crash) | | **DeepSeek V4 Flash (cache hit)** | ~$0.25 | ✅ 50× moins cher si cache hit | | **DeepSeek V4 Flash (cache miss)** | ~$8-15 | ✅ Avec prompts optimisés | | **OpenRouter Gemma 4 2B** | ~€1.65 | ✅ Fallback excellent marché | | **MiniMax M2.7** | ~$0 (quota) | ✅ 4500 req/5h (~10x weekly), 50-100 TPS | | **Z.ai GLM-5-Turbo** | ~$0 (quota) | ✅ Concurrency-based, pas token-based | | **BYOK user keys** | $0 pour Memento | ✅ Power users = coût zero | **Architecture Provider Stack (priorité):** ``` Priority 1: BYOK (user keys) → $0 pour Memento Priority 2: DeepSeek V4 Flash → ~€5-7/mois (100 DAU) Priority 3: OpenRouter Gemma 4 2B → ~€1.65/mois (fallback) Priority 4: MiniMax M2.7 → ~$0 (quota 4500 req/5h) Priority 5: Z.ai GLM-5-Turbo → ~$0 (concurrency limits) ``` **Calcul détaillé — 100 Users Actifs/mois (DeepSeek V4 Flash):** ``` 100 users × 10 req/jour × 30 jours = 30,000 requêtes Input tokens/requête = 500 (prompt système + user input) Output tokens/requête = 150 Total input/mois = 100 × 10 × 30 × 500 = 15,000,000 tokens (pas 150M) Total output/mois = 100 × 10 × 30 × 150 = 4,500,000 tokens (pas 45M) Optimisation cache (system prompts réutilisés): ├── ~60% cache hit rate: 9M tokens × €0.0028/1M = €0.025 ├── ~40% cache miss: 6M tokens × €0.14/1M = €0.84 └── Output: 4.5M tokens × €0.28/1M = €1.26 TOTAL DeepSeek V4 Flash = ~€2.13/mois (100 users intensifs) TOTAL avec 50 users = ~€1/mois ``` **Règle:** Les utilisateurs Pro+ peuvent connecter leur propre clé API. Quand ils l'utilisent, Memento ne paie rien. Quand ils ne l'utilisent pas, on applique les quotas normaux avec DeepSeek V4 Flash. ### 1.2 Stockage Sécurisé des Clés API #### [SCHEMA DB] — Nouveaux Modèles Prisma ```prisma // ===== BYOK (Bring Your Own Key) ===== enum AIProvider { OPENAI ANTHROPIC GOOGLE DEEPSEEK OPENROUTER MISTRAL OLLAMA ZAI LM_STUDIO CUSTOM_OPENAI MINIMAX GLM ANTHROPIC_CUSTOM } // Stockage chiffré des clés API utilisateur model UserAPIKey { id String @id @default(cuid()) userId String provider AIProvider alias String @default("") // "Mon clé OpenAI perso" // Chiffrement AES-256-GCM encryptedKey String // ciphertext + nonce + tag (base64) keyHash String // SHA-256 pour lookup rapide (jamais le plaintext) // Métadonnées (non sensibles) model String? // ex: "gpt-4o", "claude-3-opus" isActive Boolean @default(true) // Monitoring lastUsedAt DateTime? lastUsedFor String? // feature: "chat", "brainstorm", etc. createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, provider]) @@index([userId]) @@index([keyHash]) } // ===== CONFIGURATION PAR DÉFAUT (système) ===== model AIActiveConfig { id String @id @default(cuid()) provider AIProvider @unique apiKey String // Chiffré aussi (clé système) model String isEnabled Boolean @default(false) updatedAt DateTime @updatedAt } // ===== LLM ROUTER — Choix dynamique ===== model LLMCallLog { id String @id @default(cuid()) userId String feature String // "brainstorm.expand", "chat.rag", etc. // Quel provider a été utilisé? provider AIProvider model String // Type d'appel callType String // "system" | "byok" // Coût (si système) tokensUsed Int @default(0) estimatedCost Decimal? @default(0) // USD, pour tracking marge // Bénéfice (si BYOK) byokSavings Decimal? @default(0) // Ce que Memento a économisé createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, createdAt]) @@index([feature, createdAt]) } ``` #### 1.3 Chiffrement AES-256-GCM ```typescript // lib/crypto.ts — Chiffrement déchiffrement des clés API import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; const ALGORITHM = 'aes-256-gcm'; const KEY_LENGTH = 32; const IV_LENGTH = 16; const AUTH_TAG_LENGTH = 16; function deriveKey(password: string, salt: string): Buffer { return scryptSync(password, salt, KEY_LENGTH); } // Clé de chiffrement maître (stockée en env, jamais en DB) const MASTER_KEY = process.env.MASTER_ENCRYPTION_KEY!; // 64 chars minimum function encrypt(plaintext: string): string { const salt = randomBytes(16); const key = deriveKey(MASTER_KEY, salt); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); const encrypted = Buffer.concat([ cipher.update(plaintext, 'utf8'), cipher.final(), ]); const authTag = cipher.getAuthTag(); // Format: salt + iv + authTag + ciphertext (tous base64) return Buffer.concat([salt, iv, authTag, encrypted]).toString('base64'); } function decrypt(encryptedData: string): string { const buffer = Buffer.from(encryptedData, 'base64'); const salt = buffer.subarray(0, 16); const iv = buffer.subarray(16, 16 + IV_LENGTH); const authTag = buffer.subarray(16 + IV_LENGTH, 16 + IV_LENGTH + AUTH_TAG_LENGTH); const ciphertext = buffer.subarray(16 + IV_LENGTH + AUTH_TAG_LENGTH); const key = deriveKey(MASTER_KEY, salt); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); return decipher.update(ciphertext) + decipher.final('utf8'); } // Hash pour lookup (jamais déchiffrer pour vérifier) function hashKey(apiKey: string): string { return createHash('sha256').update(apiKey).digest('hex'); } ``` #### 1.4 Stockage d'une Clé BYOK ```typescript // app/api/user/api-keys/route.ts export async function POST(req: Request) { const session = await auth(); if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const { provider, apiKey, alias, model } = await req.json(); // Valider le provider if (!Object.values(AIProvider).includes(provider)) { return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); } // Valider la clé (format basique) if (!apiKey.startsWith('sk-') && !apiKey.startsWith('anthropic-')) { return NextResponse.json({ error: 'Invalid API key format' }, { status: 400 }); } // Chiffrer et hasher const encryptedKey = encrypt(apiKey); const keyHash = hashKey(apiKey); // Upsert (remplace si même provider) await prisma.userAPIKey.upsert({ where: { userId_provider: { userId: session.user.id, provider } }, create: { userId: session.user.id, provider, alias, model, encryptedKey, keyHash, }, update: { encryptedKey, keyHash, alias, model, isActive: true, }, }); return NextResponse.json({ success: true, provider }); } ``` --- ## 2. LLM Router — Résolution Dynamique System vs BYOK ### 2.1 Architecture du Routeur ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ LLM Router (lib/ai/router.ts) │ │ │ │ Request IA (ex: brainstorm expand) │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 1. canUseFeature(userId, 'brainstorm_expand') │ │ │ │ → Vérifie quota + tier │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ├── [Quota épuisé ET pas de BYOK] │ │ │ → THROW new QuotaExceededError() │ │ │ → Frontend: PaywallModal │ │ │ │ │ └── [OK ou BYOK disponible] │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 2. resolveLLMConfig(userId, feature) │ │ │ │ │ │ │ │ IF user has active BYOK for provider(feature): │ │ │ │ → Return { provider, apiKey: decrypt(byok.encryptedKey) } │ │ │ │ → callType = 'byok' │ │ │ │ ELSE: │ │ │ │ → Return { provider: system config } │ │ │ │ → callType = 'system' │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 3. executeLLM(config, prompt, options) │ │ │ │ → Appelle le provider (OpenAI/Anthropic/etc.) │ │ │ │ → Log dans LLMCallLog │ │ │ │ → Track tokens/cost │ │ │ └─────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 2.2 [CORE LOGIC] — Implémentation du LLM Router ```typescript // lib/ai/router.ts import { encrypt, decrypt, hashKey } from 'lib/crypto'; import { prisma } from 'lib/prisma'; import { canUseFeature, trackFeatureUsage } from 'lib/entitlements'; export type LLMCallType = 'system' | 'byok'; export interface LLMConfig { provider: AIProvider; apiKey: string; model: string; callType: LLMCallType; } export interface LLMResponse { content: string; tokensUsed: number; provider: AIProvider; callType: LLMCallType; } const PROVIDER_MODEL_MAP: Record> = { OPENAI: { default: 'gpt-4o-mini', byok: 'gpt-4o' }, ANTHROPIC: { default: 'claude-3-5-haiku-latest', byok: 'claude-3-5-sonnet-latest' }, GOOGLE: { default: 'gemini-1.5-flash', byok: 'gemini-1.5-pro' }, DEEPSEEK: { default: 'deepseek-v4-flash', // €0.14/1M input, cache hit €0.0028/1M byok: 'deepseek-v4-pro' }, OPENROUTER: { default: 'google/gemma-4-2b', byok: 'google/gemma-4-2b' }, // $0.06/1M input MINIMAX: { default: 'MiniMax-M2.7', // 4500 requêtes / 5h (~10x quota hebdo) byok: 'MiniMax-M2.7' }, ZAI: { default: 'GLM-5-Turbo', // Concurrency limits: GLM-5=2, GLM-5.1=10, GLM-4.5=10 byok: 'GLM-5-Turbo' }, // ... autres providers }; const TIER_BYOK_PROVIDERS: Record = { BASIC: [], PRO: [AIProvider.OPENAI, AIProvider.ANTHROPIC, AIProvider.DEEPSEEK, AIProvider.OPENROUTER, AIProvider.MINIMAX, AIProvider.ZAI], BUSINESS: Object.values(AIProvider), // Tous les 13+ providers ENTERPRISE: Object.values(AIProvider), }; // Quel provider est utilisé pour quelle feature // DeepSeek V4 Flash comme provider PAR DEFAUT (le moins cher) const FEATURE_PROVIDER: Record = { 'chat': 'DEEPSEEK', 'chat.rag': 'DEEPSEEK', 'brainstorm.expand': 'DEEPSEEK', 'brainstorm.manual': 'DEEPSEEK', 'semantic_search.embed': 'DEEPSEEK', 'auto_tag': 'DEEPSEEK', 'title_suggestion': 'DEEPSEEK', 'reformulate': 'DEEPSEEK', 'notebook_summary': 'DEEPSEEK', 'memory_echo': 'DEEPSEEK', 'pptx_generate': 'DEEPSEEK', 'excalidraw_generate': 'DEEPSEEK', // Agent features utilisent DeepSeek aussi (le moins cher) 'agent.scraper': 'DEEPSEEK', 'agent.researcher': 'DEEPSEEK', 'agent.monitor': 'DEEPSEEK', }; // Provider fallback chain (trié par coût croissant, pas par priorité) const PROVIDER_FALLBACK_CHAIN: AIProvider[] = [ AIProvider.DEEPSEEK, // Principal: €0.14/1M (cache hit €0.0028/1M) AIProvider.OPENROUTER, // $0.06/1M (Gemma 4 - moins cher que DeepSeek!) AIProvider.OPENAI, // $0.075/1M (GPT-4o-mini - bon marché) AIProvider.MINIMAX, // ~$0 (quota 4500 req/5h) AIProvider.ZAI, // ~$0 (concurrency-based, semaphore requis) AIProvider.ANTHROPIC, // $0.25/1M (dernier recours - plus cher) ]; class QuotaExceededError extends Error { code = 'QUOTA_EXCEEDED'; upgradeTier?: 'PRO' | 'BUSINESS'; constructor(upgradeTier?: 'PRO' | 'BUSINESS') { super('Quota épuisé. Veuillez upgrader ou configurer BYOK.'); this.upgradeTier = upgradeTier; } } async function resolveLLMConfig( userId: string, feature: string ): Promise { // 1. Vérifier entitlement (quota) const entitlement = await canUseFeature(userId, feature); if (!entitlement.allowed) { // Vérifier si BYOK peut sauver la situation const byokConfig = await getUserBYOKForFeature(userId, feature); if (!byokConfig) { throw new QuotaExceededError(entitlement.upgradeTier); } // BYOK disponible mais quota épuisé → on utilise BYOK quand même // (BYOK bypass les quotas Memento) } // 2. Déterminer le provider pour cette feature const preferredProvider = FEATURE_PROVIDER[feature] ?? AIProvider.OPENAI; // 3. Vérifier si user a BYOK pour ce provider const userTier = await getUserTier(userId); const allowedBYOKProviders = TIER_BYOK_PROVIDERS[userTier]; if (allowedBYOKProviders.includes(preferredProvider)) { const byok = await getActiveBYOK(userId, preferredProvider); if (byok) { return { provider: byok.provider, apiKey: decrypt(byok.encryptedKey), model: byok.model ?? PROVIDER_MODEL_MAP[byok.provider].byok, callType: 'byok', }; } } // 4. Fallback: système config const systemConfig = await getSystemConfig(preferredProvider); return { provider: systemConfig.provider, apiKey: decrypt(systemConfig.apiKey), model: systemConfig.model, callType: 'system', }; } // Exécution avec fallback automatique async function executeLLM( userId: string, feature: string, prompt: string, options?: { maxTokens?: number; temperature?: number } ): Promise { const config = await resolveLLMConfig(userId, feature); // Si BYOK → pas de fallback (on utilise la clé de l'utilisateur) if (config.callType === 'byok') { return callProviderDirect(config, prompt, options); } // Sinon: essayer dans l'ordre du fallback chain let lastError: Error | null = null; for (const provider of PROVIDER_FALLBACK_CHAIN) { try { const providerConfig = await getSystemConfig(provider); const result = await callProviderDirect({ ...config, provider: providerConfig.provider, apiKey: decrypt(providerConfig.apiKey), model: providerConfig.model, }, prompt, options); return result; } catch (error) { lastError = error as Error; console.warn(`Provider ${provider} failed, trying next fallback...`, error.message); continue; } } // Tous les providers ont échoué throw new Error(`All LLM providers failed. Last error: ${lastError?.message}`); } // Appel direct vers un provider (sans fallback) async function callProviderDirect( config: LLMConfig, prompt: string, options?: { maxTokens?: number; temperature?: number } ): Promise { let tokensUsed = 0; let responseContent = ''; switch (config.provider) { case AIProvider.DEEPSEEK: const deepseekResponse = await callDeepSeek(config.apiKey, config.model, prompt, options); responseContent = deepseekResponse.choices[0]?.message?.content ?? ''; tokensUsed = deepseekResponse.usage?.total_tokens ?? 0; break; case AIProvider.OPENAI: const openaiResponse = await callOpenAI(config.apiKey, config.model, prompt, options); responseContent = openaiResponse.content; tokensUsed = openaiResponse.usage.total_tokens; break; case AIProvider.ANTHROPIC: const anthropicResponse = await callAnthropic(config.apiKey, config.model, prompt, options); responseContent = anthropicResponse.content; tokensUsed = anthropicResponse.usage.input_tokens + anthropicResponse.usage.output_tokens; break; case AIProvider.OPENROUTER: // OpenRouter utilise le format OpenAI const openrouterResponse = await callOpenAI(config.apiKey, config.model, prompt, options); responseContent = openrouterResponse.content; tokensUsed = openrouterResponse.usage.total_tokens; break; // ... autres providers } // Log pour analytics et marge tracking await logLLMCall(userId, feature, config, tokensUsed); // Track usage SEULEMENT si callType = system (BYOK ne compte pas dans les quotas) if (config.callType === 'system') { await trackFeatureUsage(userId, feature, tokensUsed); } return { content: responseContent, tokensUsed, provider: config.provider, callType: config.callType, }; } // Helpers async function getActiveBYOK(userId: string, provider: AIProvider): Promise { return prisma.userAPIKey.findFirst({ where: { userId, provider, isActive: true }, orderBy: { lastUsedAt: 'desc' }, // Prend le plus récemment utilisé }); } async function getUserTier(userId: string): Promise { const sub = await prisma.subscription.findUnique({ where: { userId } }); return sub?.tier ?? 'BASIC'; } async function getSystemConfig(provider: AIProvider): Promise { return prisma.aIActiveConfig.findUnique({ where: { provider } }) as AIActiveConfig; } async function logLLMCall( userId: string, feature: string, config: LLMConfig, tokensUsed: number ): Promise { const estimatedCost = estimateCost(config.provider, config.model, tokensUsed); const byokSavings = config.callType === 'byok' ? estimatedCost : 0; await prisma.lLMCallLog.create({ data: { userId, feature, provider: config.provider, model: config.model, callType: config.callType, tokensUsed, estimatedCost, byokSavings }, }); } function estimateCost(provider: AIProvider, model: string, tokens: number): number { // GPT-4o-mini: $0.15/1M input, $0.60/1M output // Claude 3.5 Haiku: $0.25/1M input, $1.25/1M output // DeepSeek V4 Flash: €0.14/1M input, cache hit €0.0028/1M, €0.28/1M output // OpenRouter Gemma 4: $0.06/1M input, $0.33/1M output // MiniMax M2.7: quota-based (4500 req/5h, ~100 TPS off-peak) // Z.ai GLM-5-Turbo: concurrency-based (GLM-5=2, GLM-5.1=10, GLM-4.5=10) const PRICING: Record = { 'gpt-4o-mini': 0.00015, 'claude-3-5-haiku-latest': 0.00025, 'deepseek-v4-flash': 0.00014, // €0.14/1M input 'google/gemma-4-2b': 0.00006, // $0.06/1M input 'MiniMax-M2.7': 0, // Quota-based 'GLM-5-Turbo': 0, // Concurrency-based // OpenRouter models (various) 'openai/gpt-4o-mini': 0.00015, 'openai/gpt-4o': 0.00050, // DeepSeek models 'deepseek-chat': 0.00014, 'deepseek-v4-flash': 0.00014, 'deepseek-v4-pro': 0.00070, }; const perToken = PRICING[model] ?? 0.0002; return tokens * perToken / 1_000_000; } // === Provider Quota Tracker (pour MiniMax/Z.ai) === interface ProviderQuota { provider: AIProvider; requestsRemaining: number; windowStart: Date; windowMs: number; concurrencyUsed: number; maxConcurrency: number; } // MiniMax: 4500 req / 5h = 150 req/h ≈ 2.5 req/min par user // Z.ai GLM-5: concurrency = 2, GLM-5.1 = 10, GLM-4.5 = 10 const PROVIDER_QUOTA_LIMITS: Record = { [AIProvider.MINIMAX]: { windowMs: 5 * 60 * 60 * 1000, maxRequests: 4500, maxConcurrency: 100 }, [AIProvider.ZAI]: { windowMs: 60 * 1000, maxRequests: 100, maxConcurrency: 2 }, // GLM-5 = 2 }; // Semaphore pour Z.ai (concurrency limit) const zaiSemaphore = { available: 2 }; async function checkAndConsumeQuota(provider: AIProvider): Promise { const limits = PROVIDER_QUOTA_LIMITS[provider]; if (!limits) return true; // Pas de limite const now = Date.now(); // Reset si fenêtre expirée if (now - quotaState[provider].windowStart > limits.windowMs) { quotaState[provider] = { requestsRemaining: limits.maxRequests, windowStart: new Date(now) }; } // Vérifier concurrency pour Z.ai if (provider === AIProvider.ZAI) { if (zaiSemaphore.available <= 0) { return false; // Z.ai à cours de concurrency } zaiSemaphore.available--; setTimeout(() => zaiSemaphore.available++, 1000); // Libère après 1s } if (quotaState[provider].requestsRemaining <= 0) { return false; // Rate limited } quotaState[provider].requestsRemaining--; return true; } // === Circuit Breaker pour résilience === const HEALTH_THRESHOLDS = { MAX_FAILURES: 3, CIRCUIT_RESET_MS: 60_000, LATENCY_P99_MS: 5000, }; interface ProviderHealth { failures: number; lastFailure: Date; isDegraded: boolean; avgLatency: number; } const providerHealth: Record = { [AIProvider.DEEPSEEK]: { failures: 0, lastFailure: new Date(0), isDegraded: false, avgLatency: 0 }, [AIProvider.OPENROUTER]: { failures: 0, lastFailure: new Date(0), isDegraded: false, avgLatency: 0 }, [AIProvider.OPENAI]: { failures: 0, lastFailure: new Date(0), isDegraded: false, avgLatency: 0 }, [AIProvider.MINIMAX]: { failures: 0, lastFailure: new Date(0), isDegraded: false, avgLatency: 0 }, [AIProvider.ZAI]: { failures: 0, lastFailure: new Date(0), isDegraded: false, avgLatency: 0 }, [AIProvider.ANTHROPIC]: { failures: 0, lastFailure: new Date(0), isDegraded: false, avgLatency: 0 }, }; function recordProviderFailure(provider: AIProvider, latencyMs: number): void { const health = providerHealth[provider]; health.failures++; health.lastFailure = new Date(); health.avgLatency = (health.avgLatency + latencyMs) / 2; if (health.failures >= HEALTH_THRESHOLDS.MAX_FAILURES) { health.isDegraded = true; setTimeout(() => { health.failures = 0; health.isDegraded = false; }, HEALTH_THRESHOLDS.CIRCUIT_RESET_MS); } } function isProviderAvailable(provider: AIProvider): boolean { const health = providerHealth[provider]; if (health.isDegraded) return false; if (health.avgLatency > HEALTH_THRESHOLDS.LATENCY_P99_MS) return false; return true; } ``` --- ## 3. Host-Pays Principle — Facturation Collaborative ### 3.1 Règle Fondamentale > **Host-Pays Principle:** Dans une session Brainstorm partagée, TOUTES les actions IA (expand, manual-idea enrichment, finalize, etc.) sont ALWAYS facturées au Propriétaire (Host) de la session, jamais à l'Invité. **Pourquoi:** - L'host a créé la session → il contrôle l'accès - L'host peut supprimer la session à tout moment - Les invités consomment une ressource которая (la session) принадлежит à l'host - C'est simpler pour le tracking: 1 session = 1 owner = 1 facturation ### 3.2 Implémentation ```typescript // lib/brainstorm-collab.ts — Vérification host-pays export async function getBillingOwner(sessionId: string, userId: string): Promise { // Retourne l'userId qui doit être facturé pour cette session const session = await prisma.brainstormSession.findUnique({ where: { id: sessionId }, select: { userId: true, participants: { select: { userId: true, role: true } } }, }); if (!session) throw new NotFoundError(); // Le propriétaire de la session est toujours le billing owner // (participants sont juste viewers/editors, pas facturés) if (session.userId === userId) { return userId; //他自己 est le host, pas de problème } // Si userId n'est pas le host, retourner le host quand même // Le coût sera imputé au host return session.userId; } // Usage dans les routes API brainstorm export async function POSTexpand(req: Request, { params }: { params: { sessionId: string } }) { const session = await auth(); if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const billingOwnerId = await getBillingOwner(params.sessionId, session.user.id); const { ideaId, locale } = await req.json(); // Maintenant on vérifie LES QUOTAS DU HOST, pas de l'utilisateur courant const entitlement = await canUseFeature(billingOwnerId, 'brainstorm_expand'); if (!entitlement.allowed) { // Vérifier si le host a BYOK const byokConfig = await getActiveBYOK(billingOwnerId, AIProvider.OPENAI); if (!byokConfig) { throw new QuotaExceededError(entitlement.upgradeTier, billingOwnerId); } } // Exécuter avec le billingOwnerId (même si la requête vient d'un invitado) const result = await expandIdea(billingOwnerId, ideaId, locale); // Track vers le host await trackFeatureUsage(billingOwnerId, 'brainstorm_expand', result.tokensUsed); return NextResponse.json(result); } class QuotaExceededError extends Error { code = 'QUOTA_EXCEEDED'; upgradeTier?: 'PRO' | 'BUSINESS'; billingOwnerId: string; // Pour le frontend constructor(upgradeTier?: 'PRO' | 'BUSINESS', billingOwnerId?: string) { super('Quota épuisé'); this.upgradeTier = upgradeTier; this.billingOwnerId = billingOwnerId!; } } ``` --- ## 4. Paywall Temps Réel — HTTP 402 + Socket Events ### 4.1 Flux Complet ``` ┌────────────────────────────────────────────────────────────────────────┐ │ Paywall Flow (Temps Réel) │ │ │ │ User (Basic tier, quota épuisé) │ │ │ │ │ │ POST /api/brainstorm/[sessionId]/manual-idea │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────┐ │ │ │ lib/ai/router.ts │ │ │ │ canUseFeature(userId, feature) │ │ │ │ → allowed: false, upgradeTier: 'PRO'│ │ │ └─────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ THROW QuotaExceededError() │ │ │ │ │ ▼ │ │ API Route catches error → NextResponse │ │ → Status: 402 Payment Required │ │ → Body: { │ │ code: 'QUOTA_EXCEEDED', │ │ feature: 'brainstorm_manual', │ │ upgradeTier: 'PRO', │ │ currentQuota: 5, │ │ usedQuota: 5, │ │ byokConfigured: false │ │ } │ │ │ │ │ ▼ │ │ Frontend catches 402 │ │ → Si !user.subscription → PaywallModal (upgrade) │ │ → Si subscription tier < required → PaywallModal (upgrade) │ │ → Si byokConfigured: false → Modal "Connect BYOK key" │ │ → Si byokConfigured: true → Error "Unexpected" (ne devrait pas │ │ arriver si BYOK = bypass quotas) │ │ │ └─────────────────────────────────────────────────────────────────────┘ ``` ### 4.2 [API & SOCKETS] — Codes d'Erreur HTTP ```typescript // lib/errors.ts — Codes d'erreur standardisés export class AppError extends Error { constructor( public code: string, public statusCode: number, message: string, public metadata?: Record ) { super(message); this.name = 'AppError'; } } export class QuotaExceededError extends AppError { constructor( public upgradeTier: 'PRO' | 'BUSINESS', public feature: string, public currentQuota: number, public usedQuota: number, public byokConfigured: boolean, public billingOwnerId?: string // Pour les sessions collaborative ) { super( 'QUOTA_EXCEEDED', 402, // Payment Required `Votre quota ${feature} est épuisé.`, { upgradeTier, feature, currentQuota, usedQuota, byokConfigured, billingOwnerId } ); } } export class BYOKNotConfiguredError extends AppError { constructor(public provider: AIProvider, public feature: string) { super( 'BYOK_NOT_CONFIGURED', 402, `Configurez votre clé ${provider} pour utiliser ${feature} sans limites.`, { provider, feature } ); } } export class TierLimitedError extends AppError { constructor(public requiredTier: SubscriptionTier, public feature: string) { super( 'TIER_LIMITED', 403, // Forbidden (le tier ne permet même pas BYOK) `Cette fonctionnalité nécessite le plan ${requiredTier}.`, { requiredTier, feature } ); } } ``` ### 4.3 Intégration dans les Routes API ```typescript // app/api/brainstorm/[sessionId]/manual-idea/route.ts export async function POST( req: Request, { params }: { params: { sessionId: string } } ) { try { const session = await auth(); if (!session?.user?.id) { throw new AppError('UNAUTHORIZED', 401, 'Vous devez être connecté.'); } const { title, description, parentIdeaId, locale } = await req.json(); const userId = session.user.id; // Vérifier que c'est un participant avec rôle editor const participant = await verifyParticipant(params.sessionId, userId, ['host', 'editor']); if (!participant) { throw new AppError('FORBIDDEN', 403, 'Vous n\'avez pas accès à cette session.'); } // Host-Pays: le coût est imputé au host de la session const billingOwnerId = await getBillingOwner(params.sessionId, userId); const billingOwnerTier = await getUserTier(billingOwnerId); // Vérifier entitlement (sur le host, pas sur l'invité) const entitlement = await canUseFeature(billingOwnerId, 'brainstorm_manual'); // Vérifier BYOK si entitlement échoue if (!entitlement.allowed) { const allowedProviders = TIER_BYOK_PROVIDERS[billingOwnerTier]; const preferredProvider = AIProvider.OPENAI; if (allowedProviders.includes(preferredProvider)) { const byok = await getActiveBYOK(billingOwnerId, preferredProvider); if (byok) { // BYOK disponible → bypass quota (on ne throw pas) } else { throw new QuotaExceededError( entitlement.upgradeTier ?? 'PRO', 'brainstorm_manual', entitlement.limit, entitlement.used, false, billingOwnerId ); } } else { throw new QuotaExceededError( entitlement.upgradeTier ?? 'PRO', 'brainstorm_manual', entitlement.limit, entitlement.used, false, billingOwnerId ); } } // Création de l'idée (le reste du code...) const idea = await createManualIdea(params.sessionId, { userId, billingOwnerId, title, description, parentIdeaId, locale, }); // Enrichissement IA (non-bloquant, après le return) // Si le host n'a plus de quota, l'idée reste mais sans enrichissement IA // Le frontend montre "Enrichissement IA non disponible" return NextResponse.json({ idea }, { status: 201 }); } catch (error) { if (error instanceof QuotaExceededError) { return NextResponse.json({ error: error.code, message: error.message, ...error.metadata, }, { status: 402 }); } if (error instanceof AppError) { return NextResponse.json({ error: error.code, message: error.message }, { status: error.statusCode }); } throw error; } } ``` ### 4.4 Événements Socket.io pour Paywall ```typescript // socket-server.ts — Gestion des erreurs en temps réel io.on('connection', (socket) => { // ... existing code // Quand un client fait une action qui coûte (et qui pourrait échouer) socket.on('idea:create', async (data) => { try { const { sessionId, title, parentIdeaId, userId } = data; // Vérifier entitlement const billingOwnerId = await getBillingOwner(sessionId, userId); const entitlement = await canUseFeature(billingOwnerId, 'brainstorm_manual'); if (!entitlement.allowed) { // Émettre l'erreur au client QUI A FAIT LA REQUÊTE socket.emit('error:quota_exceeded', { code: 'QUOTA_EXCEEDED', feature: 'brainstorm_manual', upgradeTier: entitlement.upgradeTier, usedQuota: entitlement.used, currentQuota: entitlement.limit, byokConfigured: await hasActiveBYOK(billingOwnerId), }); return; } // Procéder à la création... const idea = await createIdea(data); // Broadcast du succès aux autres socket.to(sessionId).emit('idea:created', idea); } catch (error) { socket.emit('error:generic', { message: 'Une erreur est survenue' }); } }); // L'invité n'a PAS besoin d'avoir des quotas pour JOINER une session // Mais pour toute action WRITE, le host est facturé socket.on('session:join', async (data) => { const { sessionId, userId } = data; // C'est gratuit de rejoindre, pas de vérification de quota ici socket.join(sessionId); io.to(sessionId).emit('presence:update', await getPresence(sessionId)); }); }); ``` ### 4.5 Traitement Côté Frontend ```typescript // components/brainstorm/use-brainstorm-socket.ts useBrainstormSocket(sessionId, { onQuotaExceeded: (data) => { const { upgradeTier, feature, byokConfigured } = data; if (byokConfigured) { //理论上不应该发生 si BYOK = bypass quotas toast.error('Une erreur inattendue est survenue'); return; } // Afficher le PaywallModal openPaywallModal({ tier: upgradeTier, feature, message: `Vous avez atteint votre limite de ${feature}.`, showTrial: true, }); }, }); // Exemple de PaywallModal props interface PaywallModalProps { tier: 'PRO' | 'BUSINESS'; feature: string; message: string; showTrial: boolean; } ``` --- ## 5. Paradoxe Basic — Brainstorm Gratuit Sans Fuite de Recherche ### 5.1 Le Problème | Constraint | Valeur | |------------|--------| | Brainstorm Pro | 5 sessions/mois | | Brainstorm Basic | 1 session/mois | | Recherche sémantique Basic | **0** (Starter Pack only: 30 req lifetime) | **Le paradoxe:** Un brainstorm a BESOIN de contexte via recherche sémantique (Phase 1 de l'algo: embedding + cosine similarity). Sans elle, les idées générées sont génériques et sans connexion aux notes réelles. **La faille:** Si Basic = 1 brainstorm gratuit + recherche sémantique gratuite → un utilisateur pourrait théoriquement: 1. Créer 1 brainstorm (gratuit) 2. Obtenir le contexte RAG (qui coûte de la recherche sémantique, mais Basic = 0...) 3. L'IA génère des idées génériques sans vrai contexte 4. Mais le concept "1 brainstorm gratuit" est quand même attirant ### 5.2 Solution — Context Pool Réservé pour Brainstorm ```prisma // Modifier le Starter Pack dans le schéma // Les crédits "lifetime" du Starter Pack NE SONT PAS des crédits mensuels. // Ils sont UNIQUE et ne se réinitialisent JAMAIS. // Mais pour le Brainstorm, on crée un "context pool" séparé: // Chaque mois, les utilisateurs Basic可以获得 3 requêtes de recherche contextuelle // SPÉCIFIQUEMENT pour le brainstorm (pas pour la recherche utilisateur directe). model StarterCredits { id String @id @default(cuid()) userId String @unique type String // 'semantic_search' | 'auto_tag' | 'auto_title' total Int // Total lifetime (30, 20, 10) used Int @default(0) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model BrainstormContextPool { id String @id @default(cuid()) userId String @unique monthlyReqs Int @default(5) // 5 recherches contextuelles / mois pour brainstorm (3 trop juste pour un cycle complet) used Int @default(0) resetsAt DateTime // Fin du mois user User @relation(fields: [userId], references: [id], onDelete: Cascade) } ``` ### 5.3 Logique de Resolution ```typescript // lib/entitlements.ts — Résolution spéciale pour Brainstorm async function canUseFeature(userId: string, feature: string): Promise { const user = await getUserSubscription(userId); const tier = user?.tier ?? 'BASIC'; // === BRAINSTORM: Special case === if (feature.startsWith('brainstorm_')) { const sessionCount = await prisma.brainstormSession.count({ where: { userId, createdAt: { gte: getCurrentPeriodStart() } }, }); if (tier === 'BASIC') { // Basic: 1 brainstorm / mois MAX const limit = 1; if (sessionCount >= limit) { return { allowed: false, reason: 'TIER_LIMITED', message: 'Vous avez utilisé votre brainstorm gratuit du mois.', upgradeTier: 'PRO', }; } } // Le Brainstorm lui-même est allowed (quota respecté) // Mais les sous-features (expand, manual-enrichment) ont leurs propres quotas return { allowed: true, remaining: limit - sessionCount }; } // === AUTRES FEATURES: Logique normale === // ... (code existant) } // Resolve contexte pour brainstorm (appelé depuis le LLM) async function resolveBrainstormContext(userId: string, seedIdea: string): Promise { const user = await getUserSubscription(userId); const tier = user?.tier ?? 'BASIC'; if (tier === 'BASIC') { // Vérifier le BrainstormContextPool const pool = await getBrainstormContextPool(userId); if (pool.used >= pool.monthlyReqs) { // Pool épuisé — retourner 0 notes (contexte vide = idées génériques) // C'est le tradeoff: pas de contexte RAG si pas de credits return []; } // Utiliser 1 crédit du pool (incrémenter) await prisma.brainstormContextPool.update({ where: { userId }, data: { used: { increment: 1 } }, }); // Retourner les 5 notes les plus récentes (contexte limité) return prisma.note.findMany({ where: { userId }, orderBy: { updatedAt: 'desc' }, take: 5, }); } // Pro+: chercher les 8 notes les plus pertinentes via embeddings return findContextNotes(userId, seedIdea, 8); } ``` ### 5.4 Règle Finale Résumée | Scenario | Comportement | |----------|-------------| | Basic crée 1 brainstorm/mois | ✅ Autorisé (1/month limit) | | Basic veut un 2ème brainstorm | ❌ Blocké: "Upgrade to Pro" | | Basic fait un brainstorm | Contexte limité aux 5 notes récentes (pas d'embedding search) | | Pro fait un brainstorm | Contexte via embeddings (8 notes pertinentes) | | Basic fait une "Recherche sémantique" utilisateur (search bar) | ❌ 0 credits → Paywall (mais pas le brainstorm) | **Le ключ insight:** Le brainstorm gratuit de Basic fonctionne, mais avec un contexte limité et générique. L'utilisateur voit la mechanics mais pas la vraie magie (qui nécessite Pro). C'est suffisant pour l'effet "Aha!" initial sans créer de faille de crédit. --- ## 6. Résumé des Modifications ### [SCHEMA DB] - `UserAPIKey` — stockage chiffré AES-256-GCM des clés BYOK - `AIActiveConfig` — configuration système (fallback) - `LLMCallLog` — tracking coût/marge par appel - `StarterCredits` — crédits lifetime (par feature) - `BrainstormContextPool` — 3 req/semaine pour contexte brainstorm Basic ### [CORE LOGIC] - `lib/ai/router.ts` — LLM Router avec résolution system vs BYOK - `lib/crypto.ts` — encrypt/decrypt AES-256-GCM - `lib/entitlements.ts` — canUseFeature() avec special case brainstorm ### [API & SOCKETS] - `app/api/brainstorm/[sessionId]/manual-idea/route.ts` — 402 on quota exceeded - `socket-server.ts` — `error:quota_exceeded` event vers client - Frontend `PaywallModal` — réagit au code `QUOTA_EXCEEDED` ### [PRICING SYNC] - BYOK Pro = OpenAI + Anthropic + DeepSeek + OpenRouter + MiniMax + Z.ai (6 providers, OpenRouter était manquant) - BYOK Business = tous les 13+ providers - Host-Pays = tous les coûts IA d'une session partagée sont facturés au host - Fallback chain réorganisé par coût croissant: DeepSeek → OpenRouter → OpenAI → MiniMax → Z.ai → Anthropic --- ## 7. Questions Ouvertes (à Valider) 1. **Chiffrement clé maître** — Où stockes-tu `MASTER_ENCRYPTION_KEY`? (secrets.env? Vault? Kms?) 2. **Rotation des clés** — Si un utilisateur révoque sa clé BYOK, les appels en cours échouent. Comment gérer ce cas? 3. **Coût réel par provider** — As-tu les tarifs réels (OpenAI vs Anthropic vs DeepSeek) pour calibrer `estimateCost()` dans le LLMCallLog? 4. **Webhook Stripe + BYOK** — Si un utilisateur downgrade de Business → Pro, sa clé BYOK reste mais perd accès à 11 providers. Le forcer à re-configurer? ou silently downgrade? 5. **Brainstorm Context Pool** — 5 req/mois (corrigé de 3 → 5 pour un cycle complet) 6. **MiniMax/Z.ai quota tracking** — Implémenté via `ProviderQuota` + `zaiSemaphore` (concurrency 2). Voir section estimateCost() pour le code. 7. **Validation clé BYOK** — Le code actuelle vérifie juste le format (sk-, anthropic-). Faudrait il valider la clé avec un call API léger (ex: /models endpoint) avant de l'enregistrer? Réponse: Oui, recommandé pour éviter les clés invalides.