A Quota Ledger: Track, Predict, and Cap Your Free Model Allowance

# python# sqlite# llm# tutorial
A Quota Ledger: Track, Predict, and Cap Your Free Model AllowanceDakota Huang

Free model quotas die mid-month. The 429 arrives on day 19. The rest of the month is silent. This...

Free model quotas die mid-month. The 429 arrives on day 19. The rest of the month is silent. This article builds a three-layer quota guard. It records usage, forecasts exhaustion, and denies calls before the wall. The whole system is one Python file with SQLite.

A quota is a budget. Budgets need ledgers. Most model callers track nothing. They discover the limit when the endpoint refuses. That is reactive. This is proactive.

The guard answers three questions. How much have we used? How fast are we burning? When will we hit zero? Each answer maps to one layer.

The three-layer design

Layer Question Mechanism
Ledger How much used? SQLite usage table
Forecast How fast burning? Daily rate from timestamps
Guard Should this call run? Hard cap + forecast check

The ledger is the source of truth. The forecast turns raw rows into a rate. The guard turns the rate into a decision.

The system works with any metered endpoint. MonkeyCode's free model access fits this pattern. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The code runs anywhere Python 3 runs.

Why a ledger beats a counter

A counter tracks one number. A ledger tracks history. History enables rates. Rates enable prediction. Prediction enables denial before failure.

Consider two scenarios. Scenario A: 50,000 tokens used in one day. Scenario B: 50,000 tokens spread over ten days. A counter sees the same number. The ledger sees different rates. The guard treats them differently.

Stage 1: The ledger

The usage table stores every call. Each row has prompt tokens, completion tokens, total, model, and timestamp.

import sqlite3
import time
import sys

DB_PATH = "quota.db"
MONTHLY_LIMIT = 1_000_000  # set to your real allowance
WINDOW_DAYS = 30
STOP_RATIO = 0.90          # stop at 90% of allowance
FORECAST_DAYS = 3          # deny if exhaustion is within 3 days

def connect():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS usage (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            prompt_tokens INTEGER NOT NULL,
            completion_tokens INTEGER NOT NULL,
            total_tokens INTEGER NOT NULL,
            model TEXT NOT NULL,
            created_at REAL NOT NULL
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS decisions (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            estimated_tokens INTEGER NOT NULL,
            allowed INTEGER NOT NULL,
            reason TEXT NOT NULL,
            usage_at_decision INTEGER NOT NULL,
            created_at REAL NOT NULL
        )
    """)
    conn.commit()
    return conn
Enter fullscreen mode Exit fullscreen mode

Two tables, one file. usage records what happened. decisions records what was allowed. The second table is the audit trail. It explains every denial.

Stage 2: The forecast

The forecast computes a daily burn rate. It divides total tokens by the span between the first and last recorded call.

def window_start():
    return time.time() - WINDOW_DAYS * 86400

def used_tokens(conn):
    row = conn.execute(
        "SELECT COALESCE(SUM(total_tokens), 0) FROM usage WHERE created_at >= ?",
        (window_start(),),
    ).fetchone()
    return row[0]

def daily_burn_rate(conn):
    rows = conn.execute(
        "SELECT created_at, total_tokens FROM usage WHERE created_at >= ? ORDER BY created_at",
        (window_start(),),
    ).fetchall()
    if not rows:
        return 0.0
    span_days = max((rows[-1][0] - rows[0][0]) / 86400.0, 1.0)
    total = sum(r[1] for r in rows)
    return total / span_days

def days_until_exhausted(conn):
    used = used_tokens(conn)
    rate = daily_burn_rate(conn)
    if rate <= 0:
        return None
    remaining = MONTHLY_LIMIT - used
    if remaining <= 0:
        return 0.0
    return remaining / rate
Enter fullscreen mode Exit fullscreen mode

The rate is a straight line. Real usage is spiky. The line is still useful. It smooths spikes into a trend. Trends drive decisions.

The span uses a minimum of one day. A single call would otherwise divide by zero. The maximum keeps the math stable.

Stage 3: The guard

The guard checks two conditions. First, the hard cap. Second, the forecast breach.

def check(conn, estimated_tokens=0):
    used = used_tokens(conn)
    if used + estimated_tokens >= MONTHLY_LIMIT * STOP_RATIO:
        log(conn, estimated_tokens, 0, "hard_cap", used)
        return False, "hard cap reached"
    days = days_until_exhausted(conn)
    if days is not None and days < FORECAST_DAYS:
        log(conn, estimated_tokens, 0, "forecast", used)
        return False, f"forecast: exhaustion in {days:.1f} days"
    log(conn, estimated_tokens, 1, "ok", used)
    return True, "ok"

def log(conn, estimated, allowed, reason, used):
    conn.execute(
        "INSERT INTO decisions (estimated_tokens, allowed, reason, usage_at_decision, created_at) VALUES (?, ?, ?, ?, ?)",
        (estimated, allowed, reason, used, time.time()),
    )
    conn.commit()

def record(conn, prompt_tokens, completion_tokens, model="default"):
    total = prompt_tokens + completion_tokens
    conn.execute(
        "INSERT INTO usage (prompt_tokens, completion_tokens, total_tokens, model, created_at) VALUES (?, ?, ?, ?, ?)",
        (prompt_tokens, completion_tokens, total, model, time.time()),
    )
    conn.commit()
    return total
Enter fullscreen mode Exit fullscreen mode

The hard cap stops at 90 percent. The remaining 10 percent is a buffer. It absorbs forecast error. It also leaves room for emergency calls.

The forecast check denies when exhaustion is near. Three days is the default. A batch job can wait. An interactive user cannot. Adjust the constant to match your tolerance.

Stage 4: The CLI

A small command-line interface exposes the three operations.

if __name__ == "__main__":
    conn = connect()
    if len(sys.argv) < 2:
        print("usage: quota_guard.py [check|record|status]")
        sys.exit(1)
    cmd = sys.argv[1]
    if cmd == "check":
        estimated = int(sys.argv[2]) if len(sys.argv) > 2 else 0
        allowed, reason = check(conn, estimated)
        print(f"{'ALLOW' if allowed else 'DENY'}: {reason}")
    elif cmd == "record":
        pt, ct = int(sys.argv[2]), int(sys.argv[3])
        total = record(conn, pt, ct)
        print(f"recorded {total} tokens")
    elif cmd == "status":
        used = used_tokens(conn)
        rate = daily_burn_rate(conn)
        days = days_until_exhausted(conn)
        print(f"used: {used} / {MONTHLY_LIMIT}")
        print(f"daily rate: {rate:.0f} tokens/day")
        print(f"exhaustion in: {days:.1f} days" if days else "no forecast")
    conn.close()
Enter fullscreen mode Exit fullscreen mode

Three commands cover the lifecycle. check before a call. record after. status anytime.

Stage 5: Integration pattern

Wrap every model call with the guard.

allowed, reason = check(conn, estimated_tokens=500)
if not allowed:
    print(f"blocked: {reason}")
    sys.exit(1)

# make the call, read token counts from the response
# then record the real numbers
total = record(conn, prompt_tokens=120, completion_tokens=380)
print(f"used {total} tokens this call")
Enter fullscreen mode Exit fullscreen mode

The estimate does not need precision. It reserves headroom. A rough estimate beats no estimate.

Read the token counts from the response. Most APIs return usage in the body. Store the real numbers. The forecast depends on accurate records.

Stage 6: Verification

Test the guard with synthetic data. Start from a clean database.

rm quota.db
python3 quota_guard.py record 1500 500
python3 quota_guard.py record 1500 500
python3 quota_guard.py record 1500 500
python3 quota_guard.py status
Enter fullscreen mode Exit fullscreen mode

Expected output:

used: 6000 / 1000000
daily rate: 6000 tokens/day
exhaustion in: 165.7 days
Enter fullscreen mode Exit fullscreen mode

Now simulate heavy use. Insert 900,000 tokens, then check.

python3 quota_guard.py record 700000 200000
python3 quota_guard.py check 1000
Enter fullscreen mode Exit fullscreen mode

Expected output:

DENY: hard cap reached
Enter fullscreen mode Exit fullscreen mode

The guard refuses before the endpoint does. That is the point.

Test the forecast path. Change MONTHLY_LIMIT to 50000 and FORECAST_DAYS to 3, then rerun.

rm quota.db
python3 quota_guard.py record 20000 5000
python3 quota_guard.py check
Enter fullscreen mode Exit fullscreen mode

Expected output:

DENY: forecast: exhaustion in 1.0 days
Enter fullscreen mode Exit fullscreen mode

Three tests, three behaviors. Hard cap denial. Forecast denial. Allow. The decisions table records each one.

Decision table

Condition Action Reason
Used < 90% and rate low Allow ok
Used + estimate >= 90% Deny hard_cap
Forecast exhaustion < 3 days Deny forecast
No history yet Allow ok (rate = 0)

The guard is conservative. It denies early. That is safer than denying late.

Limitations

The forecast assumes a linear rate. Real workloads spike. A weekend batch can distort the average. The 10 percent buffer compensates. It does not eliminate the error.

The window is a rolling 30 days. A calendar-month quota needs different math. Adjust WINDOW_DAYS to match your provider.

The guard denies based on estimates. An underestimate lets a call through. The hard cap still catches the total. The buffer absorbs the miss.

Multi-user systems need locking. SQLite serializes writes. Concurrent check and record calls can race. Add a mutex or enable WAL mode.

The ledger grows forever. Add a pruning job. Delete rows older than the window. The window is all the forecast needs.

Who should not use this

Sporadic users do not need a guard. If you call a model twice a week, the forecast is noise. Track nothing. Call directly.

Interactive chat needs a different design. A denial mid-conversation is a bad experience. Use a soft warning instead of a hard stop.

High-throughput pipelines need a faster store. SQLite handles thousands of rows. It struggles with millions. Move to Postgres at that scale.

The ledger is the product

The guard is three functions. The value is the data. Every row in usage is a fact. Every row in decisions is a judgment. Together they turn a quota from a surprise into a schedule.

If you want a hosted place to run this, MonkeyCode's free server option works. The code runs anywhere Python does.