Files
nav-carte/server/utils/circuitBreaker.ts
T
Jules NenyandClaude Opus 5.5 a2b89489a6 fix(budget): une table sans aucune ligne datée n'est jamais « 0 € vérifié »
Passe /autofeedback (angle checkpoint) : si NocoDB renvoie des lignes
sans timestamp, CreatedAt ni created_at, checkBudget rendait
verified=true à 0 €, soit le fail-open silencieux d'avant sous une autre
forme. Désormais verified=false et avertissement au journal. 17/17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174RrDEQFQTtySXkTcsUKuv
2026-09-29 00:27:01 +02:00

161 lines
5.7 KiB
TypeScript

/**
* 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<string, unknown>[]
pageInfo?: { isLastPage?: boolean }
}
type FetchJson = (
url: string,
opts: { headers: Record<string, string>; query: Record<string, string | number> },
) => Promise<StatsPage>
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<string, unknown>): 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<BudgetStatus> {
const { nocodbUrl, nocodbToken, statsTableId } = config
const fetchJson: FetchJson = config.fetchJson ?? ((url, opts) => $fetch<StatsPage>(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 }
}
// Des lignes, mais aucune datée : on ne sait pas ce qui relève du mois.
// Rendre « 0 € vérifié » reproduirait le fail-open silencieux d'avant AF5.
if (lignesLues > 0 && lignesSansDate === lignesLues) {
console.warn(
`[circuitBreaker] budget NON vérifié : ${lignesLues} lignes stats_usage sans timestamp, CreatedAt ni created_at`,
)
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
}