The Fare Gate Workshop: A Token Budget That Fails Loudly
The room goes quiet at the same moment every time. A scripted client loops through forty prompts, the provider stops answering somewhere around the thirty-first, and the traceback on the projector says nothing useful, because the real cause lives in an account nobody in the room can inspect. The failure is not a bug in the loop. It is a missing gate.
This workshop runs ninety minutes, uses one repository, and needs no paid key. Students write the component that refuses to spend before the refusal becomes real, and they leave with a ledger file they can hand to the next cohort. The framing comes from a complaint that keeps resurfacing in developer discussions: AI demos are easy to stage and hard to keep honest once quota and money enter the picture.
Think of a subway turnstile. Nobody expects the gate to explain the fare table to the crowd behind it. They expect it to close cleanly at the right moment and to leave a record of every entry. A token budget behaves the same way, and the record is the part students actually keep.
What the class builds
Three files, standard library only, no network required by default. ledger.py holds the spend record and the projection check. gate.py chooses a route and refuses when the projection crosses the budget. test_gate.py pins the behaviour so the exercises cannot be faked by accident. The ledger is append-only JSONL, which matters more than it sounds: a student who kills the process mid-batch and reruns the script should see the earlier spend still there.
# ledger.py — token ledger and preflight gate. Standard library only.
from __future__ import annotations
import json, pathlib, time
LEDGER_PATH = pathlib.Path("runs/ledger.jsonl")
def estimate_tokens(text: str) -> int:
"""Deliberately conservative: 4 chars per token, rounded up, +8 per message."""
return max(1, -(-len(text) // 4)) + 8
class BudgetExceeded(RuntimeError):
def __init__(self, spent: int, projected: int, budget: int):
super().__init__(f"spent {spent} + projected {projected} > budget {budget}")
self.spent, self.projected, self.budget = spent, projected, budget
class Ledger:
def __init__(self, budget: int, path: pathlib.Path = LEDGER_PATH):
self.budget, self.path = budget, path
self.path.parent.mkdir(parents=True, exist_ok=True)
def spent(self) -> int:
if not self.path.exists():
return 0
rows = (json.loads(line) for line in self.path.read_text().splitlines() if line.strip())
return sum(row["tokens"] for row in rows)
def preflight(self, prompt: str, max_output: int) -> int:
projected = estimate_tokens(prompt) + max_output
if self.spent() + projected > self.budget:
raise BudgetExceeded(self.spent(), projected, self.budget)
return projected
def record(self, route: str, tokens: int, note: str = "") -> None:
row = {"ts": round(time.time(), 3), "route": route, "tokens": tokens, "note": note}
with self.path.open("a") as fh:
fh.write(json.dumps(row, sort_keys=True) + "\n")
def report(self) -> str:
spent = self.spent()
pct = 100 * spent / self.budget if self.budget else 0.0
return f"spent={spent} budget={self.budget} ({pct:.1f}%)"
The -(-len(text) // 4) trick is ceiling division, and the constant four is a guess that the calibration exercise later punishes. That is intentional. Students should feel the estimate drift before they trust it.
The gate, and the route table
Two routes exist: a local stub that never touches the network, and a hosted route that students wire to a real client themselves. The stub is what makes the class reproducible on a train with no signal, and it is also the honest default in argparse.
# gate.py — route selection; dry-run friendly by design.
import argparse
from ledger import Ledger, estimate_tokens, BudgetExceeded
def stub(prompt: str) -> tuple[str, int]:
return f"[stub] {prompt[:40]}", estimate_tokens(prompt)
def hosted(prompt: str) -> tuple[str, int]:
raise SystemExit(
"Wire the provider client here. Read the key from the environment, "
"never from source, and return (text, tokens_reported_by_provider)."
)
ROUTES = {"stub": stub, "hosted": hosted}
def run(route: str, prompts: list[str], budget: int, max_output: int) -> int:
led, refused = Ledger(budget=budget), 0
for i, prompt in enumerate(prompts, 1):
try:
led.preflight(prompt, max_output)
except BudgetExceeded as exc:
print(f"{i:02d} refused: {exc}")
refused += 1
continue
text, tokens = ROUTES[route](prompt)
led.record(route, tokens, note=f"prompt={i}")
print(f"{i:02d} {route} {tokens}t :: {text}")
print(led.report(), f"refused={refused}")
return refused
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--route", choices=sorted(ROUTES), default="stub")
ap.add_argument("--budget", type=int, default=600)
ap.add_argument("--max-output", type=int, default=64)
ap.add_argument("--prompts", type=int, default=40)
args = ap.parse_args()
batch = [f"Explain checkpointing, variation {i}." for i in range(1, args.prompts + 1)]
run(args.route, batch, args.budget, args.max_output)
A class-sized demo looks like this, and the important line is the refusal, not the success.
python gate.py --route stub --budget 600 --max-output 64 --prompts 40
# 31 stub 15t :: [stub] Explain checkpointing, variation 31.
# 32 refused: spent 592 + projected 20 > budget 600
# spent=592 budget=600 (98.7%) refused=9
python -c "from ledger import Ledger; print(Ledger(600).report())"
# spent=592 budget=600 (98.7%) <- survives a fresh process
The tests students must not skip
The two assertions below cover the only two failures that matter in production: the gate that never closes, and the ledger that forgets.
# test_gate.py — run with: pytest -q
import pytest
from ledger import Ledger, BudgetExceeded
def test_gate_refuses_when_projection_exceeds_budget(tmp_path):
led = Ledger(budget=50, path=tmp_path / "l.jsonl")
led.record("hosted", 40)
with pytest.raises(BudgetExceeded):
led.preflight("x" * 400, max_output=64)
def test_ledger_survives_process_restart(tmp_path):
path = tmp_path / "l.jsonl"
Ledger(budget=1000, path=path).record("stub", 12)
assert Ledger(budget=1000, path=path).spent() == 12
The ninety minutes, block by block
The first ten minutes are the staged incident and nothing else; the class watches the loop die, then writes down three things the traceback failed to say. From ten to twenty-five, students copy ledger.py, run pytest -q, and watch both tests pass, which buys credibility for a file that looks trivial. The middle half hour is the gate itself: wire the refusal path, force BudgetExceeded on purpose with --budget 200, then open runs/ledger.jsonl and read the rows aloud. Students who finish early delete the ledger file and rerun, and the failure of that run is the lesson about persistence.
At the forty-five minute mark the class switches to the hosted route for roughly twelve minutes. The goal is not volume; it is the calibration step. Each student records estimate_tokens(prompt) + max_output before the call, compares it against the token count the provider reports, and writes the drift into a shared column on the board. Drift of twenty percent is common in a chatty batch, which is why the last fifteen minutes belong to the post-mortem: which of the three numbers would you trust in an alerting rule, and what would you set the budget to if the free allowance disappeared tomorrow. The final five minutes exist for one question only, and it is not about the code.
The hosted route and a free tier
Exercise four needs a real provider response, and not every student arrives with a key. For that block the workshop points the hosted route at MonkeyCode, whose free tier the operator describes as including ten million free tokens and a free server option; the ledger does not care who pays for the tokens, so the exercise survives either way. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Treat the free allowance as a teaching budget, not a production plan. Nothing here depends on specific model names, throughput, or hardware, because the class never needs them — the gate, the JSONL rows, and the drift column are provider-agnostic, and swapping the hosted client for a different vendor is a five-line change in gate.py. A reader who wants to run exercise four next week can claim the free tier and point the hosted route at it, then keep the ledger as the artifact.
Where this breaks, and who should skip it
The char-divided-by-four estimate is a heuristic, not a tokenizer. On code-heavy prompts it can be off by a wide margin, and the calibration block is the only honest defence against that. The gate is also per-process: two students running the same ledger file concurrently will interleave writes, and the file has no locking, so a team doing parallel batch work needs a real counter in a database instead. Refusals are coarse. The gate stops a whole prompt; it does not shrink the output budget or degrade gracefully to the stub, and adding that fallback is a legitimate second workshop.
Skip this entirely if the workload is interactive and single-shot, where a gate costs more attention than it saves. Skip it if quota exhaustion is already handled by platform-side limits you do not control. And skip it if the goal is throughput measurement, because a ledger records spend, not speed.
What remains after the room empties is a directory with one JSONL file in it, a few hundred lines of number, and a refusal message that reads like a sentence instead of a stack trace. That file is the part worth keeping. It is also the cheapest possible answer to the question every AI demo eventually raises, which is who is paying, and how much, and what happens when the answer runs out.
MonkeyCode provides free models that can run this workflow.
Top comments (0)