DEV Community

Sam Chen
Sam Chen

Posted on

No Invoice, No Excuse: A Request Ledger for Free Model Access

Free tier means no invoice. It does not mean no budget.

You are spending something every call. You just cannot see the receipt. So write your own ledger first.

I keep watching the same failure. A side project runs fine for two weeks. Then a model endpoint starts answering slowly. Or it stops answering. Nobody knows which of the six scripts did it.

Is that a model problem? A quota problem? A retry loop you forgot about? You cannot tell, because you never recorded the calls.

This post is an anti-pattern catalog plus one small artifact. The artifact is a SQLite request ledger. It takes twenty lines and it answers those questions later.

The five anti-patterns

Each one has a symptom you recognize, a root cause you usually miss, and a replacement pattern.

1. Counting calls instead of work

Symptom: your counter says 40 calls today. Your free allowance is already gone.

Root cause: calls are not equal. A 200-character classification prompt and a 40,000-character code review are one call each. The counter is measuring the wrong unit.

Replacement: record input and output size per call. If the response carries a usage field, store the real token numbers. If it does not, store character counts and label them as a proxy. Never pretend they are tokens.

2. Logging after the retry loop, not inside it

Symptom: one logical task produces zero rows, or one row, or nine. You cannot reproduce it.

Root cause: the log line sits after the loop. Exceptions skip it. Early returns skip it. The information exists only on the attempt that failed.

Replacement: write the row in finally. Add an attempt column. A failed call is data, not noise.

3. Timestamping at write time

Symptom: the burn curve looks flat. Latency looks suspiciously small. Everything looks fine at 3am.

Root cause: you stamped the row when the insert ran. Under concurrency, that is minutes after the request opened.

Replacement: capture a UTC wall clock at request open, and a monotonic clock for duration. Store both. Only one of them is safe for subtraction.

4. Buffering rows in memory until exit

Symptom: the run that crashed is missing from the ledger. That is exactly the run you care about.

Root cause: a list appended in RAM, flushed by an atexit handler that never fired.

Replacement: append-only SQLite with WAL mode and a commit per row. Boring and crash-safe.

5. One error bucket called error

Symptom: 60% error rate. No idea what that means.

Root cause: a 429, a 500, and a malformed prompt all collapse into the same string.

Replacement: classify at write time. rate_limited, server_error, refused, timeout, ok. Five buckets change the whole conversation.

The artifact: a twenty-line ledger

This is the shape I use. It runs as written once you supply a send() function.

# ledger.py
import datetime, os, sqlite3, time, uuid

DB = os.environ.get("LEDGER_DB", "requests.db")

SCHEMA = """
CREATE TABLE IF NOT EXISTS calls (
  call_id      TEXT PRIMARY KEY,
  attempt      INTEGER NOT NULL,
  task         TEXT NOT NULL,
  model        TEXT NOT NULL,
  started_utc  TEXT NOT NULL,
  wall_ms      INTEGER NOT NULL,
  in_chars     INTEGER NOT NULL,
  out_chars    INTEGER NOT NULL,
  status       TEXT NOT NULL,
  http_status  INTEGER,
  host         TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_started ON calls(started_utc);
"""

def open_ledger(path=DB):
    con = sqlite3.connect(path, timeout=5)
    con.execute("PRAGMA journal_mode=WAL")
    con.execute("PRAGMA synchronous=NORMAL")
    con.executescript(SCHEMA)
    return con
Enter fullscreen mode Exit fullscreen mode

Now the wrapper. Note the finally block and the two clocks.

def classify(http_status):
    if http_status == 429:
        return "rate_limited"
    if http_status and 500 <= http_status < 600:
        return "server_error"
    if http_status and 400 <= http_status < 500:
        return "refused"
    return "error"

def call_model(con, send, task, payload, model="free-tier",
               host="laptop", max_attempts=3):
    for attempt in range(1, max_attempts + 1):
        started = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
        t0 = time.monotonic()
        status, code, out = "error", None, ""
        try:
            resp = send(payload)
            code = getattr(resp, "status_code", None)
            out = resp.text
            status = "ok" if code and 200 <= code < 300 else classify(code)
            return out
        except TimeoutError:
            status = "timeout"
            raise
        finally:
            con.execute("INSERT INTO calls VALUES (?,?,?,?,?,?,?,?,?,?,?)",
                (str(uuid.uuid4()), attempt, task, model, started,
                 int((time.monotonic() - t0) * 1000),
                 len(str(payload)), len(out), status, code, host))
            con.commit()
Enter fullscreen mode Exit fullscreen mode

That is the whole idea. Every attempt lands on disk. Nothing is inferred later.

Three queries that pay for the ledger

Run these after a day of real work.

-- 1. burn curve (characters are a proxy until you have real token counts)
SELECT substr(started_utc, 1, 13) AS hour,
       COUNT(*) AS calls,
       SUM(in_chars + out_chars) AS chars
FROM calls WHERE status = 'ok'
GROUP BY hour ORDER BY hour;
Enter fullscreen mode Exit fullscreen mode
-- 2. retries that never paid off
SELECT task, COUNT(*) AS attempts, SUM(status = 'ok') AS oks
FROM calls GROUP BY task
HAVING oks = 0 ORDER BY attempts DESC;
Enter fullscreen mode Exit fullscreen mode
-- 3. error mix by class
SELECT status, COUNT(*) AS n FROM calls
GROUP BY status ORDER BY n DESC;
Enter fullscreen mode Exit fullscreen mode

Query 2 is the one that changes behavior. A task with twelve attempts and zero successes is not slow. It is broken.

Where MonkeyCode fits

I use MonkeyCode's free model access to run these batteries. The free server option matters for the same reason.

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

Here is the useful part: the free tier is the ideal place to calibrate a ledger, because mistakes cost you nothing but time. Free model access and the free server option are described on the project's own site. Check that page for the current allowance and terms. Those numbers move, and I will not quote a figure that may already be stale.

Run the same battery twice. Once on your laptop. Once on the free server.

LEDGER_DB=laptop.db python run_battery.py
ssh free-server 'LEDGER_DB=server.db python run_battery.py'
Enter fullscreen mode Exit fullscreen mode

Then diff the two ledgers. That comparison is the whole point.

Reading the diff

Keep it mechanical:

  1. Server p95 much lower than laptop p95? You were network-bound. Your code was fine.
  2. Both p95s high and similar? You are queue-bound at the model. Reduce prompt size or batch less.
  3. Server shows rate_limited, laptop does not? Shared egress. Back off, do not add retries.
  4. Both show refused on one task only? That is your payload, not the platform.
  5. Identical ok counts and identical latency? You learned your workload is not latency-sensitive. Good news.

Who should not use this approach? If you already have provider-side usage analytics and structured traces, this ledger is duplicate work. Skip it. If you make ten calls a week, skip it too. The ledger earns its keep at a few hundred calls and more.

What the ledger will not tell you

Character counts are not tokens. Real token accounting needs the usage field from the response. Store both if you can.

A ledger also cannot tell you why a prompt was rejected. It only proves it was. Read the body you logged alongside the status.

And it will not survive multi-process writers on a network filesystem. That is a SQLite limitation, not a clever workaround.

No invoice does not mean no budget. Write the receipt yourself, then go measure something real on a free tier.

Top comments (0)