O ponto que quebra a implementação ingênua é temporal: você precisa decidir se autoriza a chamada antes de saber quanto ela vai custar. O custo exato só aparece na resposta do provedor, com a contagem real de tokens de entrada, de saída e de cache, e a essa altura o dinheiro já foi gasto. Debitar apenas depois da resposta cria uma janela em que dez requisições paralelas do mesmo cliente passam todas pela verificação enquanto o saldo ainda está intacto, e o estouro só aparece quando as dez retornam. Em atendimento com resposta longa, essa janela dura segundos, tempo suficiente para um script agressivo furar um teto por uma margem grande.
A solução é o mesmo padrão de reserva usado em pagamento com cartão: antes da chamada você estima um teto de custo e reserva esse valor no orçamento do cliente de forma atômica; depois da resposta, substitui a reserva pelo custo real e devolve a diferença. A estimativa não precisa ser exata, precisa ser conservadora: tokens de entrada você já conhece exatamente, porque o prompt está montado na sua mão, e a saída você limita com max tokens, que é justamente o teto que torna a estimativa possível. Se a chamada falhar ou for cancelada, a reserva expira e volta ao saldo. O efeito é que dez requisições paralelas competem pelo mesmo saldo reservado e a décima é recusada antes de gastar, não depois.
// spend-budget.js
// Orcamento de gasto por cliente com reserva atomica antes da chamada
// e acerto pelo custo real depois. Estado no Redis: um hash por cliente
// e ciclo, com o consumido confirmado e o total reservado em aberto.
import { randomUUID } from 'node:crypto';
// Estimativa conservadora: entrada e exata, saida usa o teto de maxTokens.
export const estimateCostCents = ({ model, inputTokens, maxTokens, prices }) => {
const price = prices[model];
if (!price) throw new Error(`Sem preco cadastrado para o modelo ${model}`);
const input = (inputTokens / 1_000_000) * price.inputPerMillionCents;
const output = (maxTokens / 1_000_000) * price.outputPerMillionCents;
return Math.ceil(input + output);
};
// Reserva e acerto em Lua para que a leitura do saldo e a escrita da
// reserva sejam um passo so: sem isso, requisicoes concorrentes leem o
// mesmo saldo livre e todas se autorizam.
const RESERVE_SCRIPT = `
local key = KEYS[1]
local reservationsKey = KEYS[2]
local limit = tonumber(ARGV[1])
local amount = tonumber(ARGV[2])
local reservationId = ARGV[3]
local ttl = tonumber(ARGV[4])
local spent = tonumber(redis.call('HGET', key, 'spent') or '0')
local reserved = tonumber(redis.call('HGET', key, 'reserved') or '0')
if spent + reserved + amount > limit then
return {0, spent, reserved}
end
redis.call('HINCRBY', key, 'reserved', amount)
redis.call('HSET', reservationsKey, reservationId, amount)
redis.call('EXPIRE', reservationsKey, ttl)
return {1, spent, reserved + amount}
`;
// O acerto e sempre relativo a reserva registrada: se a reserva ja tiver
// expirado, o custo real ainda precisa entrar no consumido, senao o
// gasto some da contabilidade justamente nas chamadas mais lentas.
const SETTLE_SCRIPT = `
local key = KEYS[1]
local reservationsKey = KEYS[2]
local reservationId = ARGV[1]
local actual = tonumber(ARGV[2])
local reserved = tonumber(redis.call('HGET', reservationsKey, reservationId) or '0')
if reserved > 0 then
redis.call('HINCRBY', key, 'reserved', -reserved)
redis.call('HDEL', reservationsKey, reservationId)
end
redis.call('HINCRBY', key, 'spent', actual)
return redis.call('HGET', key, 'spent')
`;
export class SpendBudget {
constructor(redis, { reservationTtlSeconds = 300 } = {}) {
this.redis = redis;
this.reservationTtlSeconds = reservationTtlSeconds;
}
#keys(tenantId, cycle) {
return [`budget:${tenantId}:${cycle}`, `budget:${tenantId}:${cycle}:reservations`];
}
async reserve({ tenantId, cycle, limitCents, amountCents }) {
const reservationId = randomUUID();
const [ok, spent, reserved] = await this.redis.eval(
RESERVE_SCRIPT,
2,
...this.#keys(tenantId, cycle),
String(limitCents),
String(amountCents),
reservationId,
String(this.reservationTtlSeconds),
);
if (ok !== 1) {
return { granted: false, spentCents: spent, reservedCents: reserved };
}
return { granted: true, reservationId, spentCents: spent, reservedCents: reserved };
}
async settle({ tenantId, cycle, reservationId, actualCents }) {
const spent = await this.redis.eval(
SETTLE_SCRIPT,
2,
...this.#keys(tenantId, cycle),
reservationId,
String(actualCents),
);
return Number(spent);
}
// Chamada abortada ou falha antes de consumir token: devolve a reserva
// sem debitar nada. Sem isso, timeout do provedor come o orcamento do
// cliente por ate reservationTtlSeconds.
async release({ tenantId, cycle, reservationId }) {
return this.settle({ tenantId, cycle, reservationId, actualCents: 0 });
}
}
Dois detalhes desse código costumam ser descobertos em produção da forma cara. O primeiro é o TTL da reserva: ele precisa ser maior que o timeout máximo da chamada ao modelo, senão a reserva expira enquanto a resposta ainda está em streaming e o acerto tenta descontar algo que já sumiu, inflando o consumido em dobro. O segundo é o acerto quando a reserva já expirou: o custo real precisa entrar no consumido de qualquer forma, porque o dinheiro foi gasto, e o erro comum é tratar reserva ausente como chamada não realizada e simplesmente ignorar o débito, o que faz o gasto desaparecer exatamente nas chamadas mais longas, que são as mais caras.