THAIPO.ai
Back to blog

Prepaid credits are a circuit breaker

10 November 2026 · ThaiPo

One night a background job spent about $3.40 on a small, cheap model, and live translation stopped for every chat on the platform. No bug in the translator, no bad deploy, every health check green. We had simply run out of money, on purpose, by design.

ThaiPo pays for its language models with prepaid credits and auto-reload switched off. We mentioned that in one paragraph of the post about our LLM bill, where it sounded tidy. This is the untidy version: what the breaker costs when it trips, and everything we had to build so it trips less often and never silently.

A household fuse box with one breaker flipped off, a small coin meter beside it reading empty, and a row of chat bubbles waiting patiently in the dark

The trade

With auto-reload on, a runaway loop produces an invoice. With it off, the same loop produces an outage: the balance reaches zero and the provider answers every request with a polite 400 saying the credit balance is too low. For a product run by one developer on a few dollars of model spend a day, an outage is survivable and a four-figure overnight bill is not. So the balance is the spend cap.

That is a design decision with a price, and the price is that the cap does not know the difference between a retry storm and a Tuesday. It cuts power either way.

The night it tripped properly

We needed to classify the whole learned-vocabulary table, and ran the job synchronously against the production key. It pushed roughly 900,000 input tokens and 500,000 output tokens through in a single hour, about 65 times our normal output rate. The balance was already low. It was one in the morning in Bangkok, which for our traffic is the middle of the peak.

The breaker did exactly what it was installed to do. It was also a production outage that we caused ourselves with a maintenance script. Both sentences are true and neither cancels the other.

Two details made it worse. The translator is a queue worker, so it stayed "healthy" the whole time: the process was up, the checks passed, and every single model call was being refused. And one run of the bulk job exited 0 having waved through 85,018 of 111,798 rows unexamined, because the credits had run out underneath it and its failure mode was fail-open. An exit code is a claim, not a result. Count the rows.

The rerun went through the batch API: 88,118 rows, under three minutes, $6.78, zero failures, half price. Bulk and backfill work goes through the batch API, off-peak, after checking the runway. It never borrows the live key's budget.

You cannot ask how much money is left

The obvious defence is a low-balance alarm, and the obvious obstacle is that there is no balance endpoint. The provider exposes what you spent and what you used, but not what you have left. That number lives only in a web console.

So our credits panel does dead reckoning. The operator types the console balance in once. From then on, remaining equals that synced balance minus priced usage since the sync, and runway is remaining divided by burn.

Which usage matters more than it looks. There are two reporting endpoints. The cost one returns real billed dollars but lags by hours, so a "remaining" built on it sits still while money leaves. The usage one returns token counts within minutes, so we price those ourselves with our own rate card, remembering that batch lines bill at half. Six hours after a sync the figure was within about four cents of the console.

The alarm that slept through the outage

The panel feeds an alert: an off-platform cron checks the runway every ten minutes and sends a LINE message to the operator's phone when it gets short.

Then the balance emptied overnight, one integration failed 99 of 101 sends for about four and a half hours, and we heard about it from a customer. The alarm had spoken exactly once, while there was still credit, and then gone quiet for the entire outage.

It had two holes, and both are instructive.

  • No band below "critical". The ladder was low, then critical, and each band spoke once a day. Critical had already had its turn. When the balance actually hit zero there was nothing worse left to say, so nothing was said.
  • "Empty" was derived from runway.Runway is remaining divided by recent burn. A long outage drags burn toward zero, the division has no answer, and "no answer" was read as "balance not synced", which cleared the alert state. The alarm went quiet because the outage had lasted.

The fix is an empty band read off the balance, not the runway, that repeats every three hours, because while the product is down silence reads as recovery. A one-token billed probe now asks the provider directly whether it is still accepting paid requests. That also untangles the confusing case: a panel reading $0 while the probe succeeds is not an outage, it is a stale sync after a top-up. That one stays a daily nag, since an alarm that shouts about bookkeeping eight times a day is one you learn to ignore.

The same family of bug turned up twice more. An unreadable usage report was treated as $0 spent, which snapped "remaining" back to the full balance exactly when it was lowest. And once a sync was a week old, the query asked for more hourly buckets than one page allows, got a 400, and read that as $0 too. Every one of these failed in the direction of "everything is fine".

A weekly average is not the cautious choice

The headline burn used to be a trailing seven-day average, because it looked calm. Then we moved a large share of traffic to a cheaper model, and for days the panel reported a burn more than four times the real one: two days of runway when the true answer was about eight.

The mirror image is the dangerous one. After a spike, a weekly average reports a fraction of the real rate, the runway reads far too long, and prepaid credit is gone before the average catches up. The headline is now the last 24 hours. The weekly figure survives as a trend line, which is all it was ever good for.

The deploy tax

One more leak, found while staring at that panel. Our vocabulary-mining cycle was throttled to run at intervals, but the "last ran at" timestamp lived in process memory. Every deploy restarted the process, forgot the timestamp, and re-ran the whole cycle thirty seconds after boot. Better still, the shutdown signal cancelled any cycle in flight, so the next boot started it again from the top.

Each run was cheap, but release cadence was quietly a line item. The timestamp now lives in Redis, written only when a cycle completes, so an interrupted one still reruns and a finished one does not.

What we would tell someone copying this

  • A hard spend cap is a real feature with a real cost. Take it knowingly, and only if your architecture tolerates the pause. Ours does: every message is already in a durable log and nothing is lost, though a translation more than an hour late is deliberately not delivered.
  • Test the alarm against the failure it exists for. Ours was tested against "getting low" and never against "already empty".
  • Unknown is not zero. A report you could not read, a division with no answer and a balance you never synced are three different states, and none of them means safe.
  • Measure burn over the last day. Averages are for charts.
  • Nothing bulk ever shares a budget with something a human is waiting on.

The breaker stays. We would rather apologise for a gap in service than explain an invoice. We just no longer expect it to tell us when it has tripped.

ThaiPo is designed, built and run by one developer, Steven Acreman.

Try it in your own chats

ThaiPo lives inside LINE and translates every message in both directions, free forever. Get started free or add @thaipo.ai as a friend on LINE and it sends your signup link right in the chat.

ThaiPo is operated by Marine Entertainment Co., Ltd. · Docs · Blog · Privacy · Terms · Contact