DEV Community

Emery Li
Emery Li

Posted on

A Local Token Ledger for Free-Tier Agents: Surviving Crashes, Retries, and Drift

The last week's DEV front page leaned hard toward browser-side AI demos and long, open-ended discussions about AI fatigue, and both threads circle the same practical worry. Teams can now call a model from almost anywhere, but almost nobody can answer a simple operational question: which job spent the allowance, and did that job actually finish. The failure is rarely dramatic. It shows up as a nightly batch that dies halfway through with the pool already empty, while the dashboard shows only a single aggregate number that resets on someone else's schedule.

The scenario below is a composite of that failure mode rather than a report on a named customer. A small team runs a nightly agent job against a free model tier, retries twice on timeouts, and discovers on Monday that the pool is exhausted. Nobody can name the culprit, because usage lives only in a remote console, and the local logs stop at the point where the request was dispatched. The lesson is not that free tiers are unreliable. The lesson is that token accounting was treated as a vendor concern when it is really an application concern.

Why the accounting layer belongs on the local machine

The prompt is the part of an agent run that carries the most risk, because it often contains internal identifiers, file fragments, or customer text. An accounting layer positioned remotely sees that payload by construction, which quietly turns a billing helper into a data processor. A local ledger sees only counts, keys, and timestamps, so the sensitive material never leaves the machine that already holds it.

Local placement also survives the failure modes that matter here. A ledger written to a local SQLite file keeps working when the network drops, when the free server is slow to respond, and when the machine reboots mid-run. The heavy generation step can still be delegated, and often should be, but the record of what was spent must not depend on the same connection that spent it.

Step 1 — Fix the accounting unit before writing any code

Ambiguity about what counts as a token is the root cause of most reconciliation arguments. The ledger in this article treats billable tokens as provider-reported input tokens plus provider-reported output tokens, and it records retries as separate reservations with their own idempotency keys. Estimates are allowed at reservation time, but only reported numbers are allowed to close a run.

Two rules follow from that definition. First, a failed request that still consumed tokens must be committed, not silently released, because providers may bill for truncated streams. Second, a released reservation means the provider was never called or explicitly reported no usage, which is a much stricter condition than a client-side timeout.

Step 2 — Model reservations instead of running totals

A single spent counter cannot answer the question that matters during a crash: was this request paid for? Reservations solve that by giving every attempt a row with an explicit phase, so an interrupted process leaves a reserved row that is visibly unaccounted for rather than an invisible gap. The schema stays small enough to inspect by hand during an incident.

# ledger.py -- Python 3.10+, standard library only
import sqlite3
import time
import uuid

SCHEMA = """
CREATE TABLE IF NOT EXISTS reservations (
  idem_key        TEXT PRIMARY KEY,
  run_id          TEXT NOT NULL,
  phase           TEXT NOT NULL CHECK (phase IN ('reserved','committed','released')),
  reserved_tokens INTEGER NOT NULL,
  reported_tokens INTEGER,
  created_at      REAL NOT NULL,
  updated_at      REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_res_phase ON reservations(phase);
"""


class BudgetExceeded(RuntimeError):
    pass


class TokenLedger:
    def __init__(self, path: str, budget_tokens: int, floor_ratio: float = 0.15):
        self.db = sqlite3.connect(path, isolation_level=None, timeout=5.0)
        self.db.execute("PRAGMA journal_mode=WAL")
        self.db.executescript(SCHEMA)
        self.budget = budget_tokens
        self.floor_ratio = floor_ratio

    def _spent(self) -> int:
        row = self.db.execute(
            "SELECT COALESCE(SUM(COALESCE(reported_tokens, reserved_tokens)), 0) "
            "FROM reservations WHERE phase IN ('reserved','committed')"
        ).fetchone()
        return int(row[0])

    def remaining(self) -> int:
        return self.budget - self._spent()

    def reserve(self, idem_key: str, estimate_tokens: int) -> str:
        run_id = str(uuid.uuid4())
        now = time.time()
        floor = int(self.budget * self.floor_ratio)
        self.db.execute("BEGIN IMMEDIATE")
        try:
            if self.db.execute(
                "SELECT 1 FROM reservations WHERE idem_key = ?", (idem_key,)
            ).fetchone():
                self.db.execute("ROLLBACK")
                return run_id  # replayed call, nothing new reserved
            if self.remaining() - estimate_tokens < floor:
                raise BudgetExceeded(
                    f"remaining={self.remaining()} need={estimate_tokens} floor={floor}"
                )
            self.db.execute(
                "INSERT INTO reservations "
                "(idem_key, run_id, phase, reserved_tokens, created_at, updated_at) "
                "VALUES (?, ?, 'reserved', ?, ?, ?)",
                (idem_key, run_id, estimate_tokens, now, now),
            )
            self.db.execute("COMMIT")
            return run_id
        except Exception:
            self.db.execute("ROLLBACK")
            raise
Enter fullscreen mode Exit fullscreen mode

Step 3 — Close runs with reported usage, not hopes

The commit path is deliberately boring, because boring code is what gets called from an exception handler at two in the morning. It moves a row from reserved to committed exactly once, and the WHERE phase = 'reserved' clause makes double-commit a no-op rather than a double charge. The release path exists for the narrow case where the provider was never reached.

    def commit(self, idem_key: str, reported_tokens: int) -> bool:
        cur = self.db.execute(
            "UPDATE reservations SET phase = 'committed', reported_tokens = ?, updated_at = ? "
            "WHERE idem_key = ? AND phase = 'reserved'",
            (reported_tokens, time.time(), idem_key),
        )
        return cur.rowcount == 1

    def release(self, idem_key: str) -> bool:
        cur = self.db.execute(
            "UPDATE reservations SET phase = 'released', updated_at = ? "
            "WHERE idem_key = ? AND phase = 'reserved'",
            (time.time(), idem_key),
        )
        return cur.rowcount == 1
Enter fullscreen mode Exit fullscreen mode

The calling job then reads almost like a transaction script. The estimate is a guard rail, the commit uses the provider's own numbers, and an unexpected exception releases the hold so the pool does not leak capacity for a request that never happened.

ledger = TokenLedger("tokens.db", budget_tokens=10_000_000)
key = f"nightly-2026-10-19-{job_id}"

ledger.reserve(key, estimate_tokens=48_000)
try:
    result = client.generate(prompt)          # free model access, remote call
    ledger.commit(key, result.usage.input + result.usage.output)
except Exception:
    ledger.release(key)
    raise
Enter fullscreen mode Exit fullscreen mode

Step 4 — Reconcile drift and reap abandoned reservations

Two housekeeping queries turn the ledger from a log into a control. The drift check compares reserves against reported usage and lists runs whose error exceeds a tolerance, which reveals tokenizer mismatches and prompt bloat early. The reaper closes reservations left behind by killed processes, using an age threshold long enough to avoid racing a slow but live request.

    def drift(self, tolerance: float = 0.05):
        rows = self.db.execute(
            "SELECT idem_key, reserved_tokens, reported_tokens FROM reservations "
            "WHERE phase = 'committed' AND reported_tokens IS NOT NULL"
        ).fetchall()
        return [
            (k, res, rep)
            for k, res, rep in rows
            if res and abs(rep - res) / res > tolerance
        ]

    def reap(self, older_than_s: float = 3600) -> int:
        cur = self.db.execute(
            "UPDATE reservations SET phase = 'released', updated_at = ? "
            "WHERE phase = 'reserved' AND created_at < ?",
            (time.time(), time.time() - older_than_s),
        )
        return cur.rowcount
Enter fullscreen mode Exit fullscreen mode

Step 5 — Gate dispatch on the ledger, not on the console

The gate is one comparison, and it belongs before the request is built rather than after. When remaining() drops below a configured floor, the job should degrade to a cheaper path, shrink its context, or stop cleanly with a message that names the ledger file. That behaviour is what prevents the Monday-morning surprise, because the decision is made locally and instantly.

A test plan that fails in the useful direction

Scenario Injection Expected result
Crash after reserve kill -9 between reserve and commit Row stays reserved; reaper releases it after the age threshold
Duplicate dispatch Call reserve twice with the same key Second call returns the first run_id, no new row
Double commit Call commit twice Second call returns False, totals unchanged
Under-reported usage Commit fewer tokens than reserved drift flags the run, remaining rises by the difference
Over-reported usage Commit more tokens than reserved Gate trips earlier on the next run, no negative spend
Parallel workers Ten processes, one database file BEGIN IMMEDIATE serialises writes; no lost updates
Read-only filesystem Mount the directory read-only Initialisation fails loudly before any request is sent

Run the crash and duplicate cases first, since they are the two that produce silent budget loss in practice. A short shell loop that kills the worker at random offsets covers the first case cheaply, and asserting remaining() afterwards catches leaks without instrumenting the provider.

Where the free server earns its seat

Generation itself is the step worth delegating, because a weak or sleeping local host should not decide whether a batch finishes. In that split, the prompt is sent to a hosted endpoint while the ledger stays home, and the local machine only needs to survive long enough to record the result. MonkeyCode's free model access and free server option fit this shape: the remote side does the inference and the local side keeps the books. The brief for this piece also states a ten-million-token free allowance, and that figure is operator-supplied like the rest; check the current project terms before planning capacity around it.

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

Concern Keep local Delegate to free server
Secrets and prompt text Yes, or redact before send No
Token ledger and gates Yes No
Inference on a sleeping laptop Not reliable Preferred
Offline replay of past runs Yes, from the ledger Not needed
Crash recovery Local file, WAL mode Remote session may expire

Limitations and who should skip this

The ledger counts what the provider reports, so any client sharing the same account or key can still spend outside its view. It also cannot enforce a hard cap upstream; provider-side budget controls remain the only real ceiling, and this design is a guard rail rather than a guarantee. SQLite handles a handful of concurrent workers comfortably but is not a multi-tenant billing system, and tokenizer differences make estimates approximate until reconciliation.

Teams running a single interactive session should skip all of this, because a script and a spreadsheet answer their question faster than a schema does. Teams that need invoices, chargeback, or per-customer attribution should use a real metering service instead of a SQLite file on a laptop.

A closing note on scope

The useful habit is smaller than a platform: record reservations locally, close them with reported numbers, and reconcile before the pool is empty. If this workflow is useful, the project is open source and the free tier is the cheapest way to test whether the gate fires at the right moment for your own jobs.

Top comments (0)