checkBudget filtrait stats_usage sur `timestamp` (where + fields) et recevait un 422 à chaque appel : fail-open silencieux, budget jamais vérifié côté chatbot. Aligné sur le worker : aucune colonne nommée dans la requête, lecture paginée, mois filtré en JS (timestamp, puis CreatedAt, puis created_at). Chaque lecture journalise le budget lu ; un échec journalise « budget NON vérifié » et renvoie verified=false. Test : scripts/test-circuit-breaker.mjs, faux NocoDB qui rejette en 422 toute colonne absente (15/15 ; 11 échecs si le filtre revient). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174RrDEQFQTtySXkTcsUKuv
152 lines
5.2 KiB
TypeScript
152 lines
5.2 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 }
|
|
}
|
|
|
|
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
|
|
}
|