A zero-cost AI feature is not a feature that happens to cost nothing. It is a feature whose code refuses to run once the day's allowance is gone. Repeat traffic rarely breaks a free tier; one long prompt inside a retry loop does. The artifact below is a ~120-line ledger, standard library only, that reserves a token estimate before every model call, settles it with the real usage afterwards, and returns a zero-token fallback instead of calling the model when the budget is exhausted.

Step 1: Size the unit of work before writing any code

Estimation error, not deduplication, is the usual failure mode on a free allowance. A single request that appends a 6 KB document and asks for a 600-token answer can consume a full day of budget in one call, and a while True retry turns that into a lockout.

Two numbers make the problem tractable: characters per request, and max output tokens. For English prose, dividing characters by four gets within roughly 20–30% of the real prompt tokens. Code, CJK text, and JSON payloads skew higher, which is why the guard below calibrates instead of trusting the constant.

Step 2: Put a reservation in front of the call

#!/usr/bin/env python3 """ledger.py - token budget guard. stdlib only.""" import argparse, math, os, sqlite3, sys, time DB = os.environ.get("LEDGER_DB", "spend.db") CAP = int(os.environ.get("DAILY_TOKEN_CAP", "100000")) SCHEMA = """ CREATE TABLE IF NOT EXISTS calls ( id INTEGER PRIMARY KEY, day TEXT NOT NULL, route TEXT NOT NULL, est_in INTEGER NOT NULL, est_out INTEGER NOT NULL, act_in INTEGER, act_out INTEGER, status TEXT NOT NULL, created REAL NOT NULL ); CREATE INDEX IF NOT EXISTS calls_day ON calls(day); """ def connect(path=DB): con = sqlite3.connect(path) con.executescript(SCHEMA) return con def estimate(text, out_tokens): return math.ceil(len(text) / 4), out_tokens def spent_today(con): sql = ("SELECT COALESCE(SUM(COALESCE(act_in, est_in) + " "COALESCE(act_out, est_out)), 0) FROM calls " "WHERE day = ? AND status IN ('reserved','settled')") return con.execute(sql, (time.strftime("%Y-%m-%d"),)).fetchone()[0] def reserve(con, route, est_in, est_out, cap): if spent_today(con) + est_in + est_out > cap: return None cur = con.execute( "INSERT INTO calls(day, route, est_in, est_out, status, created) " "VALUES(?,?,?,?,?,?)", (time.strftime("%Y-%m-%d"), route, est_in, est_out, "reserved", time.time())) con.commit() return cur.lastrowid def settle(con, call_id, act_in, act_out, ok=True): con.execute("UPDATE calls SET act_in=?, act_out=?, status=? WHERE id=?", (act_in, act_out, "settled" if ok else "failed", call_id)) con.commit() def report(con, days=7): rows = con.execute( "SELECT day, " "SUM(CASE WHEN status='settled' THEN COALESCE(act_in,est_in)" "+COALESCE(act_out,est_out) END), " "SUM(CASE WHEN status='settled' THEN est_in+est_out END), COUNT(*) " "FROM calls WHERE status='settled' GROUP BY day " "ORDER BY day DESC LIMIT ?", (days,)).fetchall() print(f"{'day':<12}{'actual':>9}{'est':>9}{'ratio':>7}{'calls':>7}") for day, act, est, n in rows: act, est = act or 0, est or 1 print(f"{day:<12}{act:>9}{est:>9}{act/est:>7.2f}{n:>7}") def main(): p = argparse.ArgumentParser() sub = p.add_subparsers(dest="cmd", required=True) c = sub.add_parser("check") c.add_argument("--route", default="chat") c.add_argument("--prompt") c.add_argument("--max-out", type=int, default=400) s = sub.add_parser("settle") s.add_argument("--id", type=int, required=True) s.add_argument("--in", dest="tin", type=int, default=0) s.add_argument("--out", dest="tout", type=int, default=0) s.add_argument("--fail", action="store_true") r = sub.add_parser("report") r.add_argument("--days", type=int, default=7) a = p.parse_args() con = connect() if a.cmd == "check": text = open(a.prompt).read() if a.prompt else sys.stdin.read() ei, eo = estimate(text, a.max_out) cid = reserve(con, a.route, ei, eo, CAP) if cid is None: print("BLOCKED", file=sys.stderr) sys.exit(2) print(cid) elif a.cmd == "settle": settle(con, a.id, a.tin, a.tout, ok=not a.fail) else: report(con, a.days) if __name__ == "__main__": main() 

The row is inserted as reserved before the network call, so a crash mid-request still consumes budget in the accounting. That is the intended pessimism: an unaccounted request is worse than a slightly over-charged one.

Step 3: Settle with real usage, and fail closed

Wrapping the call keeps the guard honest. Usage numbers come from the provider response, not from the estimator.

cid = reserve(con, "summarize", *estimate(prompt, max_out=300), cap=DAILY_TOKEN_CAP) if cid is None: return static_answer(prompt) # zero-token path, still ships with attempts(3): try: out, usage = model_call(prompt, max_tokens=300) settle(con, cid, usage.prompt_tokens, usage.completion_tokens) return out except Exception: settle(con, cid, 0, 0, ok=False) # releases the reservation  raise 

A failed call is marked failed and drops out of spent_today, which means a retry storm cannot silently consume a week of allowance. If the provider answers with a quota or rate-limit error, set the status to failed and let the fallback branch handle the request instead of retrying around the limit.

Step 4: Deploy beside the app on the free server

MonkeyCode's free model access and free server option are enough to run this loop end to end before deciding whether to pay for anything. The project advertises a free token allowance at the time of writing (operator-supplied figure: 10 million) and a free server option; both are terms that can change, which is exactly why the cap lives in DAILY_TOKEN_CAP and never in the source. Confirm the current allowance on the project page before sizing your cap, rather than trusting a number from a blog post.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

export DAILY_TOKEN_CAP=200000 export LEDGER_DB=/data/spend.db python3 ledger.py check --route summarize --prompt in.txt --max-out 300 python3 ledger.py settle --id 41 --in 812 --out 274 python3 ledger.py report --days 7 

If the free server's filesystem is ephemeral, the ledger resets on restart. That is acceptable for a guard that only needs today's numbers, but copy spend.db to durable storage if weekly ratios matter to you. A cron entry that runs report once a day is enough to notice a drift before it becomes an outage.

Step 5: Read the ratio column, then adjust one constant

After a week of real traffic, the ratio column tells you how wrong the estimator is for your own prompts.

Observed ratio (actual ÷ estimate) Action
0.9 – 1.1 Keep len(text) / 4.
1.1 – 1.5 Set the divisor to 3.0 and re-measure for three days.
> 1.5 The route is sending structured payloads; estimate per field, not per character.
< 0.7 Cap is too loose; tighten max_out so the block branch actually triggers in tests.

Test the block branch deliberately: set DAILY_TOKEN_CAP=1 and confirm the app serves the fallback instead of throwing. An untested refusal path is the same as no guard at all.

When this approach fits, and when it does not

Situation Use the ledger? Better option
Solo founder, one feature, zero budget Yes —
Prototype that must survive a demo today Yes, with a low cap —
Team with committed paid spend No Provider billing dashboards and alerts
Multi-tenant product with per-user quotas No Real per-account accounting
Workloads with heavy reasoning or cached-token pricing Partly Provider-side usage export

Limitations worth stating plainly

The estimator is a convenience, not a meter. It ignores special tokens, tool schemas, image inputs, and cached-input discounts. On non-English text the error can exceed 50%, and the ratio column is the only thing that reveals it. The guard also enforces one global cap; it cannot tell a valuable request from a wasteful one, and it will block both with equal enthusiasm.

Anyone who needs invoice-grade accuracy, contractual usage guarantees, or per-customer attribution should not build on this. A solo founder shipping a first AI feature on a free allowance, who would rather serve a static answer than wake up to a dead endpoint, is the right reader.

The ledger is small enough to write this afternoon, and that is the point: the free model access and free server option remove the infrastructure argument, so the remaining work is deciding what the code does when the budget hits zero.