/** * Circuit breaker budget IA * Spec F §6 — seuil 20€/mois * * Avant chaque appel IA (worker ou chatbot) : * const { blocked } = await checkBudget(config) * if (blocked) throw createError({ statusCode: 503, ... }) * * Paliers : * >= 15€ → email Jules (géré par le worker) * >= 18€ → flag budget_warning (bandeau site) * >= 20€ → hard stop, HTTP 503 */ export const BUDGET_MAX_EUR = 20 export const BUDGET_WARN_EUR = 18 export interface BudgetStatus { cumulEur: number blocked: boolean warning: boolean /** false si stats_usage n'a pas pu être lu : le budget n'est alors PAS vérifié (fail-open). */ verified: boolean /** Lignes lues dans stats_usage (toutes dates). */ lignesLues: number /** Lignes retenues pour le mois courant. */ lignesMois: number /** Lignes sans aucun champ date reconnu (ignorées, comme dans le worker). */ lignesSansDate: number } /** Page NocoDB v2 : seuls les champs lus ici sont typés. */ interface StatsPage { list?: Record[] pageInfo?: { isLastPage?: boolean } } type FetchJson = ( url: string, opts: { headers: Record; query: Record }, ) => Promise const PAGE_SIZE = 1000 const MAX_PAGES = 20 // 20 000 lignes : garde-fou contre une boucle, pas une limite métier /** * Date d'une ligne de stats_usage, dans l'ordre du worker (`worker/enrich.js`, * getBudgetMoisCourant) : `timestamp` écrit par les routes et le worker, sinon * la colonne système de NocoDB (`CreatedAt`, ou `created_at` selon la version). */ export function dateLigne(row: Record): Date | null { const brut = row.timestamp ?? row.CreatedAt ?? row.created_at if (brut === null || brut === undefined || brut === '') return null const d = new Date(brut as string) return Number.isNaN(d.getTime()) ? null : d } /** * Calcule le cumul de dépenses IA du mois courant depuis stats_usage NocoDB. * Retourne blocked=true si le budget est atteint. * * AF5 (29/09) : la requête ne nomme plus AUCUNE colonne (ni `where`, ni `fields`, * ni `sort`). L'ancienne version filtrait sur `timestamp` côté NocoDB et recevait * un 422 à chaque appel : budget jamais vérifié côté chatbot, en silence * (fail-open). On lit la table page par page et on filtre le mois en JS, * comme le worker, qui lit la même table sans erreur. */ export async function checkBudget(config: { nocodbUrl: string nocodbToken: string statsTableId: string /** Injecté par les tests ; `$fetch` de Nitro sinon. */ fetchJson?: FetchJson /** Injecté par les tests ; maintenant sinon. */ now?: Date }): Promise { const { nocodbUrl, nocodbToken, statsTableId } = config const fetchJson: FetchJson = config.fetchJson ?? ((url, opts) => $fetch(url, opts)) const now = config.now ?? new Date() const annee = now.getUTCFullYear() const mois = now.getUTCMonth() const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records` let cumulEur = 0 let lignesLues = 0 let lignesMois = 0 let lignesSansDate = 0 try { for (let page = 0; page < MAX_PAGES; page++) { const res = await fetchJson(url, { headers: { 'xc-token': nocodbToken }, query: { limit: PAGE_SIZE, offset: page * PAGE_SIZE }, }) const rows = res?.list ?? [] for (const row of rows) { lignesLues++ const d = dateLigne(row) if (!d) { lignesSansDate++ continue } if (d.getUTCFullYear() === annee && d.getUTCMonth() === mois) { lignesMois++ cumulEur += Number(row.cout_eur) || 0 } } if (rows.length < PAGE_SIZE || res?.pageInfo?.isLastPage) break } } catch (e) { // En cas d'erreur de lecture, on ne bloque PAS pour ne pas pénaliser les utilisateurs console.warn('[circuitBreaker] Erreur lecture stats_usage — budget NON vérifié:', (e as Error).message) return { cumulEur: 0, blocked: false, warning: false, verified: false, lignesLues, lignesMois, lignesSansDate } } const status: BudgetStatus = { cumulEur, blocked: cumulEur >= BUDGET_MAX_EUR, warning: cumulEur >= BUDGET_WARN_EUR, verified: true, lignesLues, lignesMois, lignesSansDate, } const mm = String(mois + 1).padStart(2, '0') const suffixe = status.blocked ? ' — BLOQUÉ' : status.warning ? ' — alerte' : '' const sansDate = lignesSansDate ? `, ${lignesSansDate} sans date ignorées` : '' console.info( `[circuitBreaker] budget lu : ${cumulEur.toFixed(4)} € / ${BUDGET_MAX_EUR} € ` + `(${lignesMois} lignes ${annee}-${mm} sur ${lignesLues} lues${sansDate})${suffixe}`, ) return status } /** * Calcule le coût en EUR d'un appel Mistral Small. * Prix : $0.20/M tokens_in, $0.60/M tokens_out (converti en EUR @0.93) */ export function calcCoutMistralSmall(tokensIn: number, tokensOut: number): number { const usd = (tokensIn / 1_000_000) * 0.2 + (tokensOut / 1_000_000) * 0.6 return usd * 0.93 } /** * Calcule le coût en EUR d'un appel Mistral Nemo. * Prix : $0.02/M tokens_in, $0.04/M tokens_out (converti en EUR @0.93) */ export function calcCoutMistralNemo(tokensIn: number, tokensOut: number): number { const usd = (tokensIn / 1_000_000) * 0.02 + (tokensOut / 1_000_000) * 0.04 return usd * 0.93 }