DEV Community

Charlie Hu
Charlie Hu

Posted on

Preflight the Free Allowance: A Weekend Spend Meter for Agent Side Projects

Preflight the Free Allowance: A Weekend Spend Meter for Agent Side Projects

Most weekend agent builds do not fail on code quality. They fail on arithmetic.

A loop that looks cheap per call keeps calling until the allowance is gone, and the build stops on Sunday night with half a checklist left. The DEV front page spent the week on arguments about what agents really are and whether AI-written code deserves the engineering label. Neither argument changes the arithmetic of a two-day project.

The fix is small and unglamorous: measure the spend before the expensive run, not after it. This build log covers one file that does that, the scope it cut, and the parts it deliberately skips.

The three numbers worth writing down

Token accounting for a side project needs exactly three quantities.

  1. Planned calls per run. A list of named steps, each with a rough token estimate.
  2. Observed tokens per call. Prompt plus completion, read from the provider's usage field after each call.
  3. Retries per call. The multiplier nobody budgets for. Three retries on a 6k-token step is an 18k-token step.

Anything beyond those three belongs in provider billing, not in a weekend script. The goal here is a preflight check, not an invoice.

The artifact: meter.py

One file, standard library only, two modes: read recorded events, or evaluate a plan worst case. Exit code 1 means over allowance, 2 means the event log is corrupt.

#!/usr/bin/env python3
"""meter.py - preflight and postflight token accounting for a weekend agent demo.

Usage:
  python meter.py --events runs/sat.jsonl --allowance 10000000
  python meter.py --plan plan.json --allowance 10000000
Exit codes: 0 under allowance, 1 over, 2 malformed event lines.
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

MALFORMED_TOLERANCE = 0  # any unparsable line fails the run


def read_events(path: Path) -> tuple[list[dict], int]:
    events: list[dict] = []
    malformed = 0
    for raw in path.read_text(encoding="utf-8").splitlines():
        raw = raw.strip()
        if not raw:
            continue
        try:
            ev = json.loads(raw)
            events.append(
                {
                    "call": str(ev["call"]),
                    "prompt_tokens": int(ev.get("prompt_tokens", 0)),
                    "completion_tokens": int(ev.get("completion_tokens", 0)),
                    "retries": int(ev.get("retries", 0)),
                }
            )
        except (json.JSONDecodeError, KeyError, TypeError, ValueError):
            malformed += 1
    return events, malformed


def spend(events: list[dict]) -> tuple[int, dict[str, int]]:
    per_call: dict[str, int] = {}
    total = 0
    for ev in events:
        n = ev["prompt_tokens"] + ev["completion_tokens"]
        per_call[ev["call"]] = per_call.get(ev["call"], 0) + n
        total += n
    return total, per_call


def worst_case(plan: dict) -> int:
    retries = int(plan.get("max_retries", 0))
    return sum(int(c["est_tokens"]) * (1 + retries) for c in plan["calls"])


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--events", type=Path)
    ap.add_argument("--plan", type=Path)
    ap.add_argument("--allowance", type=int, required=True)
    args = ap.parse_args()

    if not args.events and not args.plan:
        ap.error("pass --events, --plan, or both")

    if args.events:
        events, malformed = read_events(args.events)
        if malformed > MALFORMED_TOLERANCE:
            print(f"MALFORMED: {malformed} unparsable event lines")
            return 2
        total, per_call = spend(events)
        print(f"observed {total:,} tokens across {len(events)} calls")
        for name, n in sorted(per_call.items(), key=lambda kv: -kv[1]):
            print(f"  {name:<24} {n:>12,}")
        if total > args.allowance:
            print(f"OVER: observed spend exceeds allowance {args.allowance:,}")
            return 1

    if args.plan:
        plan = json.loads(args.plan.read_text(encoding="utf-8"))
        ceiling = worst_case(plan)
        headroom = args.allowance - ceiling
        print(f"worst case {ceiling:,} | allowance {args.allowance:,} | headroom {headroom:,}")
        if headroom < 0:
            print("OVER: cut a call or lower max_retries before the batch")
            return 1

    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

The plan file is a plain description of the intended run:

{
  "max_retries": 2,
  "calls": [
    {"name": "summarize_repo", "est_tokens": 3000},
    {"name": "draft_patch", "est_tokens": 6000},
    {"name": "self_review", "est_tokens": 4000}
  ]
}
Enter fullscreen mode Exit fullscreen mode
$ python meter.py --plan plan.json --allowance 10000000
worst case 39,000 | allowance 10,000,000 | headroom 9,961,000
Enter fullscreen mode Exit fullscreen mode

The numbers above are illustrative, not a benchmark. The point is the shape of the output: a single headroom figure that either passes or stops the batch.

Recording one event per call

Metering only works if every call writes a line. A six-line helper is enough; the field names come from the provider's usage object and differ between providers, so map them once and keep the mapping in the repo.

import json
import time


def record(path, name, usage, retries=0):
    with open(path, "a", encoding="utf-8") as fh:
        fh.write(json.dumps({
            "ts": time.time(),
            "call": name,
            "prompt_tokens": getattr(usage, "prompt_tokens", 0),
            "completion_tokens": getattr(usage, "completion_tokens", 0),
            "retries": retries,
        }) + "\n")
Enter fullscreen mode Exit fullscreen mode

Two rules keep the log trustworthy. Append after the call returns, so a crash does not record phantom spend. And log retries explicitly, because a retry count of zero is information too.

Where the batch actually runs

The meter answers how much. A separate question is where the calls go, especially for a batch that runs overnight.

MonkeyCode is one option for that second question: the operator states free model access and a free server option, and quotes a free allowance on the order of ten million tokens. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Because meter.py only consumes JSONL, the same accounting works whether the calls hit a local mock or a hosted endpoint; only the field mapping changes. Treat the allowance figure, the available models, and the server option as operator-supplied claims that can change, and re-read the current terms before planning a weekend around them.

If the spend meter is the useful part of this post, start there. Run one real batch inside the free tier only after checking today's terms, not last month's.

Decision table

Situation Local run Remote free tier Skip the call
One short call, you are watching the output Yes Unnecessary No
Batch that runs while you sleep Works, ties up your laptop Candidate - verify terms first Only if you cannot judge the output tomorrow
Retry storm already observed in the log Yes No - fix the prompt first Yes, until retries are controlled
Output you cannot evaluate tonight No No Yes
Anything closer to production than a demo Budget alerts, not this script Same Change the project

What this build skipped on purpose

A weekend scope is defined by what is not built. Three cuts made the file small enough to finish.

  • No dashboard. A printed table and an exit code are the whole interface.
  • No price conversion. Tokens are the unit; currency changes with provider and date, and static prices rot.
  • No automatic throttling. The script reports and exits. A wrapper that silently trims prompts is a second project.

Limitations and who should not use this

  • Token counts come from provider usage fields. Some providers round them, and cached-input accounting differs, so this is a spend estimate, not a billing statement.
  • The max_retries multiplier in plan.json is a guess until it is calibrated against recorded events. Two weekends of real logs beat any estimate.
  • The meter says nothing about quality. A cheap call that returns unusable output is the most expensive item in the plan.
  • Free-tier availability, quotas, model line-ups, and server options are commercial terms, not technical constants. Nothing in meter.py depends on them staying the same.
  • Teams needing billing-grade cost allocation, multi-tenant chargeback, or audit trails should use provider-side budgets and alerts. So should anyone whose calls are not token-metered at all.

Sunday check

Before the demo is called done, five things are worth confirming.

  1. python meter.py --events runs/sun.jsonl --allowance ... exits 0.
  2. Every planned call in plan.json has a matching entry in the event log.
  3. The retry multiplier in the plan matches the retries actually seen in the log.
  4. The skipped calls are written down somewhere, with the reason.
  5. The current free-tier terms were read this weekend, not remembered from an older post.

The demo proves the idea. The meter keeps the next weekend from being spent re-learning the same arithmetic.

Top comments (0)