O problema técnico central é que a chamada ao modelo acontece em algum lugar fundo da pilha, e o identificador da conversa nasce lá em cima, no handler do webhook. Passar esse identificador como parâmetro por todas as camadas funciona e envenena o código: toda função no caminho ganha um argumento que não tem nada a ver com o que ela faz, e basta uma nova função esquecer de repassar para o custo virar não atribuído. A solução limpa em Node é o armazenamento de contexto assíncrono, que amarra um escopo à execução e o mantém disponível através de qualquer profundidade de await sem tocar em nenhuma assinatura intermediária.
Com o escopo disponível, o registro de custo deixa de ser responsabilidade de quem chama e passa a ser do cliente do provedor. Envolver a chamada num pequeno decorador que lê o uso da resposta, converte em dinheiro pela tabela de preços do modelo e credita no escopo ativo garante que nenhuma chamada nova nasça sem contabilidade. Esse é o ponto de estrangulamento certo: uma única função por onde todo o gasto passa. Se alguém adicionar uma etapa nova ao agente amanhã, ela é contabilizada de graça, porque usa o mesmo cliente. O detalhe que costuma escapar é que a conversão para dinheiro precisa da tabela de preços versionada por modelo, incluindo os preços diferentes de entrada, saída, escrita em cache e leitura de cache, que não são o mesmo número.
// cost-scope.js
// O escopo vive no contexto assincrono: nenhuma funcao intermediaria
// precisa receber conversationId como argumento para o custo ser atribuido.
import { AsyncLocalStorage } from 'node:async_hooks';
const storage = new AsyncLocalStorage();
// Preco por MILHAO de tokens. Entrada, saida, escrita e leitura de cache
// tem precos diferentes: tratar tudo como um numero so distorce o rateio.
const PRICING = {
'claude-sonnet-5': { input: 3.0, output: 15.0, cacheWrite: 3.75, cacheRead: 0.3 },
'claude-haiku-4-5': { input: 1.0, output: 5.0, cacheWrite: 1.25, cacheRead: 0.1 },
};
export function runInCostScope(attributes, fn) {
const scope = { attributes, events: [] };
return storage.run(scope, () => fn(scope));
}
export function currentScope() {
return storage.getStore() || null;
}
// Preco de UM componente isolado: usado pelo relatorio para separar
// escrita de cache e economia de leitura do custo efetivo da jornada.
export function priceComponent(model, component, tokens) {
const p = PRICING[model];
if (!p) throw new Error('modelo sem preco cadastrado: ' + model);
return (tokens * p[component]) / 1e6;
}
export function priceUsage(model, usage) {
return (
priceComponent(model, 'input', usage.inputTokens || 0) +
priceComponent(model, 'output', usage.outputTokens || 0) +
priceComponent(model, 'cacheWrite', usage.cacheWriteTokens || 0) +
priceComponent(model, 'cacheRead', usage.cacheReadTokens || 0)
);
}
// Credita um evento de uso no escopo ativo. Se nao houver escopo, o custo
// e real e precisa ir para o balde de nao atribuido, nunca ser descartado.
export function recordUsage({ model, usage, step, attempt = 1 }) {
const scope = currentScope();
const costUsd = priceUsage(model, usage);
const event = { model, usage, step, attempt, costUsd };
if (!scope) {
unattributed.push(event);
return event;
}
scope.events.push(event);
return event;
}
export const unattributed = [];
// llm-client.js
// Ponto de estrangulamento: TODA chamada ao provedor passa por aqui, entao
// toda etapa nova do agente nasce contabilizada sem ninguem lembrar disso.
import { recordUsage } from './cost-scope.js';
export function createCostAwareClient(provider) {
return {
async complete({ model, messages, step, attempt = 1, ...rest }) {
const response = await provider.messages.create({ model, messages, ...rest });
const u = response.usage || {};
recordUsage({
model,
step,
attempt,
usage: {
inputTokens: u.input_tokens || 0,
outputTokens: u.output_tokens || 0,
cacheWriteTokens: u.cache_creation_input_tokens || 0,
cacheReadTokens: u.cache_read_input_tokens || 0,
},
});
return response;
},
};
}
// No handler do webhook, o escopo abre uma vez e cobre a cadeia inteira:
//
// await runInCostScope(
// { conversationId, tenantId, journey: 'segunda-via', channel: 'whatsapp' },
// async (scope) => {
// await runAgent(message); // nenhuma assinatura mudou
// await emitCostReport(scope); // fecha e publica o total
// },
// );