<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: pr3tik</title>
    <description>The latest articles on DEV Community by pr3tik (@burnix).</description>
    <link>https://dev.to/burnix</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4069780%2F33a24494-b3a3-4ad3-9cf0-3aeae7cc92ed.jpg</url>
      <title>DEV Community: pr3tik</title>
      <link>https://dev.to/burnix</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/burnix"/>
    <language>en</language>
    <item>
      <title>I built a spend cap for LLM calls. It failed by 4.2x under parallel load.</title>
      <dc:creator>pr3tik</dc:creator>
      <pubDate>Sun, 09 Aug 2026 11:20:51 +0000</pubDate>
      <link>https://dev.to/burnix/i-built-a-spend-cap-for-llm-calls-it-failed-by-42x-under-parallel-load-2h0c</link>
      <guid>https://dev.to/burnix/i-built-a-spend-cap-for-llm-calls-it-failed-by-42x-under-parallel-load-2h0c</guid>
      <description>&lt;p&gt;Provider spending limits don't stop anything. They're alerts wearing a brake's clothing.&lt;/p&gt;

&lt;p&gt;The documented cases from this year are ugly. A developer set a $250 cap and received a $10,138 bill overnight. An AWS customer with anomaly detection enabled was charged $30,141 for a single Bedrock inference run — no alert fired. FinOps teams reported burning an entire annual token budget four months into the year.&lt;/p&gt;

&lt;p&gt;None of that is because models are expensive. It's structural: provider caps run off billing pipelines that lag by minutes to hours. That was an acceptable design when the worst case was a forgotten EC2 instance at $4/hour. An agent stuck in a retry loop moves faster than the billing system can observe it.&lt;/p&gt;

&lt;p&gt;So I built a local one. This is the story of getting it wrong first, because the way it failed is more interesting than the fix.&lt;/p&gt;

&lt;p&gt;Intercepting the calls&lt;/p&gt;

&lt;p&gt;The first problem is seeing the requests at all. I wanted a wrapper — no code changes for the user:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
burnix --cap 5.00 -- npm run agent&lt;/p&gt;

&lt;p&gt;The trick is NODE_OPTIONS. When you spawn a child process, you can inject a module that loads before any user code:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
const child = spawn(cmd, args, {&lt;br&gt;
  stdio: 'inherit',&lt;br&gt;
  env: {&lt;br&gt;
    ...process.env,&lt;br&gt;
    NODE_OPTIONS: &lt;code&gt;--require ${hookPath} ${process.env.NODE_OPTIONS ?? ''}&lt;/code&gt;,&lt;br&gt;
  },&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;That hook patches global fetch before the SDK ever captures a reference to it:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
const original = globalThis.fetch;&lt;/p&gt;

&lt;p&gt;globalThis.fetch = async function (input, init) {&lt;br&gt;
  const url = typeof input === 'string' ? input : input.url;&lt;br&gt;
  if (!isTrackedHost(url)) return original(input, init);&lt;/p&gt;

&lt;p&gt;const res = await original(input, init);&lt;br&gt;
  const clone = res.clone();&lt;br&gt;
  const body = await clone.json();&lt;br&gt;
  recordCost(body.usage);&lt;br&gt;
  return res;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;I verified the Anthropic SDK actually goes through globalThis.fetch before building anything else — thirty minutes that would have saved three days if the answer had been no.&lt;/p&gt;

&lt;p&gt;For streaming, res.body is a ReadableStream you can only consume once, so you have to tee() it: return one branch to the caller inside a reconstructed Response, read the other yourself, and parse the SSE for the final usage event.&lt;/p&gt;

&lt;p&gt;This all worked. Sequential test: cap of $0.05, blocks on call 3, exits non-zero. Ship it.&lt;/p&gt;

&lt;p&gt;The bug&lt;/p&gt;

&lt;p&gt;Then I ran twenty calls in parallel.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
await Promise.all(Array.from({ length: 20 }, () =&amp;gt; makeCall()));&lt;br&gt;
burnix: session 9k5qws done — $0.2100 spent across 20 call(s)&lt;/p&gt;

&lt;p&gt;Cap was $0.05. Spend was $0.21. Zero calls blocked.&lt;/p&gt;

&lt;p&gt;The cause is embarrassingly simple once you see it. Cost was added to state after each response returned. Twenty parallel requests all read spent = 0 before any of them completed, so all twenty passed the check:&lt;/p&gt;

&lt;p&gt;t=0ms    req 1..20 all read spent=0, all pass&lt;br&gt;
t=800ms  req 1..20 all return, each adds its cost&lt;br&gt;
t=801ms  spent = 0.21. Cap tripped 750ms too late.&lt;/p&gt;

&lt;p&gt;My sequential test passed. The feature looked done. And the product's single promise — that it stops — was false under precisely the workload it exists for. Agents make parallel tool calls. That is the use case.&lt;/p&gt;

&lt;p&gt;Reserve, then reconcile&lt;/p&gt;

&lt;p&gt;The fix is to charge before the call, not after.&lt;/p&gt;

&lt;p&gt;Reserve — estimate a pessimistic worst-case cost and add it to state immediately, keyed by a reservation id&lt;br&gt;
Check — if spent + reserved &amp;gt;= cap, release the reservation and refuse&lt;br&gt;
Reconcile — when the response lands, delete the reservation and add the actual cost&lt;/p&gt;

&lt;p&gt;The estimate is deliberately pessimistic:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
const inputTokens = JSON.stringify(body).length / 4;&lt;br&gt;
const outputTokens = body.max_tokens;  // the API cannot exceed this&lt;br&gt;
const worstCase = (inputTokens * inPrice + outputTokens * outPrice) / 1e6;&lt;/p&gt;

&lt;p&gt;Over-reserving makes the cap trip early, which is the safe direction. Under-reserving is the bug you're fixing.&lt;/p&gt;

&lt;p&gt;Release the reservation in a finally. A leaked reservation permanently inflates spend for the session, and you will leak one the first time a request throws.&lt;/p&gt;

&lt;p&gt;The part that actually matters&lt;/p&gt;

&lt;p&gt;Here's the detail that makes it work, and it's easy to get wrong:&lt;/p&gt;

&lt;p&gt;The reserve step must be synchronous. readFileSync, mutate, writeFileSync, with no await anywhere between the read and the write.&lt;/p&gt;

&lt;p&gt;Node is single-threaded. A synchronous read-modify-write cannot be interleaved by another pending promise, because the event loop has no opportunity to run anything else mid-block. The moment you introduce an await between reading state and writing it, you have reopened the exact race you're closing:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// Broken — await creates the interleaving window&lt;br&gt;
const state = await readState();&lt;br&gt;
state.reserved[id] = cost;      // ← other requests run here&lt;br&gt;
await writeState(state);&lt;/p&gt;

&lt;p&gt;// Correct — no yield point&lt;br&gt;
const state = JSON.parse(fs.readFileSync(path, 'utf8'));&lt;br&gt;
state.reserved[id] = cost;&lt;br&gt;
fs.writeFileSync(tmp, JSON.stringify(state));&lt;br&gt;
fs.renameSync(tmp, path);       // atomic swap&lt;/p&gt;

&lt;p&gt;Reconciliation afterward can be async. The reserve cannot.&lt;/p&gt;

&lt;p&gt;Result on the same test:&lt;/p&gt;

&lt;p&gt;burnix: session 4saeq3 done — $0.0454 spent across 6 call(s)&lt;/p&gt;

&lt;p&gt;Six succeed, fourteen blocked, under the cap. Landing at $0.0454 rather than exactly $0.05 is the pessimistic reservation doing its job — it trips slightly early, which is correct.&lt;/p&gt;

&lt;p&gt;The bug the fix created&lt;/p&gt;

&lt;p&gt;Then the display stopped making sense.&lt;/p&gt;

&lt;p&gt;The progress bar showed settled spend. So you'd watch it climb to 87%, and then:&lt;/p&gt;

&lt;p&gt;burnix  CAP REACHED  request blocked&lt;/p&gt;

&lt;p&gt;Blocked at 87%. To anyone watching, that reads as broken.&lt;/p&gt;

&lt;p&gt;It wasn't — the reservations had crossed the cap even though settled spend hadn't. But "technically correct" is worthless if the user concludes your tool is lying to them.&lt;/p&gt;

&lt;p&gt;The fix was to make reservations visible:&lt;/p&gt;

&lt;p&gt;total $0.0017 / $0.0020  ██████▒▒  87%  (+$0.0005 in flight)&lt;/p&gt;

&lt;p&gt;Solid blocks are settled spend, shaded blocks are in-flight reservations. Now you watch the combined bar reach the cap and then block, and the behavior explains itself.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: when internal state drives a user-visible decision, showing only part of that state makes correct behavior look like a bug.&lt;/p&gt;

&lt;p&gt;What it doesn't do&lt;br&gt;
Node children only. The NODE_OPTIONS hook can't reach Python or Go subprocesses. A local proxy would be language-agnostic; that's the next step.&lt;br&gt;
Cross-process races are narrowed, not eliminated. Two separate Node processes sharing a session can still interleave between their sync read and write. The window is microseconds instead of seconds.&lt;br&gt;
Reservations key off max_tokens. If yours is much larger than your typical response, it trips early.&lt;br&gt;
Try it&lt;br&gt;
bash&lt;br&gt;
npm install -g &lt;a class="mentioned-user" href="https://dev.to/burnix"&gt;@burnix&lt;/a&gt;/cli&lt;br&gt;
burnix --watch -- npm run agent    # tracks, blocks nothing&lt;br&gt;
burnix --cap 5.00 -- npm run agent # actually stops&lt;/p&gt;

&lt;p&gt;Works with Groq's free tier, so testing costs nothing. MIT: github.com/pr3tik/burnix&lt;/p&gt;

&lt;p&gt;One question I haven't found a good answer to: if you run agents on a shared team API key, how do you currently work out who burned the budget? Every answer I've found is "check the dashboard," which tells you the total and nothing else.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>backend</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
