llm-budget-cap
Un tope de gasto atómico para APIs de LLM — un contador en Redis que decide antes de que el dinero se gaste, para que un bug o un abuso no te sangren la factura mientras duermes.
Montas un asistente sobre Gemini u OpenAI, todo se ve bien en pruebas, y un día un ciclo infinito, un scraper o un cliente distribuido con mil IPs te dispara la factura mientras duermes. llm-budget-cap es el paquete de npm que publiqué para que eso no pase: un tope de gasto atómico en Redis que toma la decisión antes de que el dinero se gaste.
No es un rate limiter de propósito general — para eso ya existen
@nestjs/throttler o rate-limiter-flexible. Es una red de seguridad barata y
dura contra la factura sorpresa.
Qué hace
Es un contador con ventana fija (24 h por defecto, configurable en milisegundos) que acumula gasto y corta cuando se pasa del límite. Una sola clase y una API chica, con dos formas de usarla según cuándo conoces el monto:
checkAndIncrement— cuando el monto se sabe antes de la llamada: topar cuántas llamadas se hacen, o un costo fijo por llamada.reserve→settle— cuando el costo real solo se sabe después (tokens). Reservas un estimado antes de la llamada pagada —esa reserva es lo que topa— haces la llamada, y luego liquidas con el costo real: se devuelve lo que sobró o se cobra el excedente, sin extender la ventana.
El contador puede ser uno global (gemini:daily) o uno por clave (por
tenant, por usuario), y esa subclave tiene que ser un identificador normalizado
del lado del servidor. Si el cliente puede elegir su propia subclave, puede
rotarla y estrenar contador en cada request — que es lo mismo que no tener tope.
Bajo el capó
Dos partes sutiles dan la garantía, y las dos son fáciles de hacer mal.
Uno: el incremento y el TTL tienen que correr juntos. El acercamiento ingenuo
—GET, comparar, SET— tiene una carrera: con el límite en 500 y el contador en
499, dos requests que llegan casi al mismo instante leen ambas 499, ambas
deciden que caben, y ambas pasan. INCR de Redis sí es atómico, pero el problema
real es coordinarlo con el PEXPIRE que arma la ventana. La solución es meter
todo en un único script Lua, que Redis ejecuta de principio a fin sin
intercalar otros comandos. Los scripts se exportan como constantes (
ATOMIC_BUDGET_CAP_LUA, SETTLE_BUDGET_CAP_LUA) para que cualquiera los audite
en vez de creerme.
Dos: la decisión tiene que ocurrir antes de la llamada pagada. Si cuentas después, para cuando te enteras el dinero ya se fue. De ahí el patrón reserve→settle.
El otro detalle de producción es el modo degradado real: cada llamada a Redis
lleva un timeoutMs duro (5 s por defecto), así que un Redis colgado no cuelga
tu request — se decide dentro del presupuesto de tiempo. Por defecto falla
abierto (preferimos que una función pagada quede brevemente sin medir a tirar
la función entera), y hay un callback onDegraded justo para eso: un tope
degradado en silencio es un tope que no está. Con failOpen: false se lanza un
error tipado y decides tú.
Y lo que el README dice de frente, porque es parte del diseño: el contador vive
solo en Redis, así que es best-effort. Hay que correrlo con
maxmemory-policy noeviction (bajo allkeys-lru la clave del presupuesto es tan
desalojable como cualquier caché y el tope se reinicia sin avisar), no sobrevive a
un FLUSHALL ni a un failover de réplica asíncrona. Es el techo barato enfrente
de tu contabilidad, no la contabilidad.
El stack
- TypeScript strict, una sola clase, cero dependencias de terceros — solo
el cliente
ioredisque ya traes, como peer dependency. - Redis con la lógica en scripts Lua; funciona en Redis < 7 (usa
PTTL/PEXPIREen vez dePEXPIRE ... NX). - Sin dependencia de framework: Express, Fastify, NestJS o lo que sea.
- tsup para empaquetar a ESM + CJS + tipos; Vitest en un solo worker, con las pruebas de atomicidad corriendo contra un Redis real.
- Publicado en npm bajo licencia MIT — ver el paquete.
Vale la pena mencionar
Este paquete nació de una necesidad de producción, no de un ejercicio: había que topar el gasto de un chatbot sin que un bug lo dejara sangrando dinero.
Lo que más me gusta contar es el error. La versión 0.1.0 documentaba el patrón
de contar después de la llamada — y ese patrón no topaba nada. Medido: con
el límite en 1,000 y 50 llamadas concurrentes de 400 tokens, se gastaron 20,000
tokens, 20× el presupuesto. La 0.2.0 lo arregla con reserve/settle, y el
README no lo esconde: tiene una sección de migración que empieza diciendo que el
patrón viejo estaba mal y por qué. Publicar la corrección con el número que la
delata me pareció más útil que borrar la historia.
¿Un café y platicamos?
¿Te gustó lo que leíste? Construyo productos así de punta a punta — y siempre estoy para una buena plática. Hablemos del tuyo, o nomás intercambiamos ideas con un café.