Files
Momento/memento-note/docs/byok-billing-patch-v3.md
Antigravity 1fcea6ed7d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export
- Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG)
- Add document Q&A overlay with streaming chat and PDF preview
- Add note attachments UI with status polling, grid layout, and auto-scroll
- Add task extraction AI tool and agent executor improvements
- Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings
- Fix brainstorm 'Create Note' button: add success toast and redirect to created note
- Fix memory echo notification infinite polling
- Fix chat route to always include document_search tool
- Add brainstorm i18n keys across all 14 locales
- Add socket server for real-time brainstorm collaboration
- Add hierarchical notebook selector and organize notebook dialog improvements
- Add sidebar brainstorm section with session management
- Update prisma schema with brainstorm tables, attachments, and document chunks
2026-05-14 17:43:21 +00:00

45 KiB
Raw Permalink Blame History

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 (V3) et 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

// ===== 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

// 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

// 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

// 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<AIProvider, Record<string, string>> = {
  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<SubscriptionTier, AIProvider[]> = {
  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<string, AIProvider> = {
  '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<LLMConfig> {
  // 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<LLMResponse> {
  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<LLMResponse> {
  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<UserAPIKey | null> {
  return prisma.userAPIKey.findFirst({
    where: { userId, provider, isActive: true },
    orderBy: { lastUsedAt: 'desc' }, // Prend le plus récemment utilisé
  });
}

async function getUserTier(userId: string): Promise<SubscriptionTier> {
  const sub = await prisma.subscription.findUnique({ where: { userId } });
  return sub?.tier ?? 'BASIC';
}

async function getSystemConfig(provider: AIProvider): Promise<AIActiveConfig> {
  return prisma.aIActiveConfig.findUnique({ where: { provider } }) as AIActiveConfig;
}

async function logLLMCall(
  userId: string,
  feature: string,
  config: LLMConfig,
  tokensUsed: number
): Promise<void> {
  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<string, number> = {
    '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, { windowMs: number; maxRequests: number; maxConcurrency: number }> = {
  [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<boolean> {
  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, ProviderHealth> = {
  [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

// lib/brainstorm-collab.ts — Vérification host-pays

export async function getBillingOwner(sessionId: string, userId: string): Promise<string> {
  // 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

// 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<string, any>
  ) {
    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

// 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

// 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

// 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

// 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

// lib/entitlements.ts — Résolution spéciale pour Brainstorm

async function canUseFeature(userId: string, feature: string): Promise<EntitlementResult> {
  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<Note[]> {
  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.tserror: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.