Last week I published a small JavaScript class that stops AI agents from burning money on tool calls. A reader left a comment that was better than the article: use the real token counts from the API response, make the price a per-model map, and cap each tool separately. While answering, I reread my own code and found two bugs. This post is the corrected version.
The two bugs
-
budget.costwas never initialised, sobudget.cost += costproducedNaNon the first call. - The class stored an
EventEmitterinthis.emitterbut my example calledguard.on(...), which did not exist.
If you copied the first version, fix both before trusting any number it prints.
Fix 1: estimate before, measure after
A length / 4 guess is fine for deciding whether to start a call. It can be off by 2x for code or non-English text, so it must not be what you record. Every major chat API returns the real count in the response, for example the usage object described in the OpenAI API reference and the usage field in the Anthropic Messages API.
Fix 2: a price per model
const PRICE_PER_1M = {
"cheap-model": { in: 0.15, out: 0.60 },
"expensive-model": { in: 3.00, out: 15.00 },
};
function costOf(model, usage) {
const p = PRICE_PER_1M[model];
if (!p) throw new Error(`no price for ${model}`);
return (usage.input_tokens * p.in + usage.output_tokens * p.out) / 1e6;
}
Throwing on an unknown model is deliberate: a silent default price is how budgets drift. The numbers above are placeholders, so copy current prices from your provider's pricing page.
Fix 3: budgets keyed by agent AND tool
import { EventEmitter } from "node:events";
export class BudgetGuard extends EventEmitter {
constructor({ perToolUsd = 2, perAgentUsd = 10 } = {}) {
super();
this.perToolUsd = perToolUsd;
this.perAgentUsd = perAgentUsd;
this.spent = new Map(); // "agent|tool" -> usd
}
_agentTotal(agent) {
let sum = 0;
for (const [k, v] of this.spent) if (k.startsWith(agent + "|")) sum += v;
return sum;
}
canCall(agent, tool, estimateUsd = 0) {
const key = `${agent}|${tool}`;
const tool$ = (this.spent.get(key) ?? 0) + estimateUsd;
const agent$ = this._agentTotal(agent) + estimateUsd;
if (tool$ > this.perToolUsd) { this.emit("blocked", { agent, tool, why: "tool" }); return false; }
if (agent$ > this.perAgentUsd) { this.emit("blocked", { agent, tool, why: "agent" }); return false; }
return true;
}
record(agent, tool, usd) {
const key = `${agent}|${tool}`;
this.spent.set(key, (this.spent.get(key) ?? 0) + usd); // starts at 0, no NaN
}
}
Usage: call canCall with the cheap estimate, make the request, then call record with costOf(model, response.usage). Because the class now extends EventEmitter, guard.on("blocked", ...) works.
Why per tool matters
One looping search or retrieval tool is usually where the money goes. An agent-level cap stops the damage eventually, but a tool-level cap stops the one misbehaving tool while the rest of the agent keeps working.
Open question
How do you set the per-tool limit: a fixed dollar number, or a percentage of the agent's total budget? I have used a fixed number so far and I am curious whether a share works better in practice.
This article was written with AI assistance and checked by the author.
Top comments (0)