A summarizer starts as a demo. Someone points it at a free model lane. Three weeks later it serves internal tickets. Nobody decided that. The trial simply never ended.
This post is the guard for the moment before that happens. It is a field guide to not using a free model lane, plus a small ledger that makes the decision explicit. The scenario above is illustrative, not a customer story.
The failure mode: trials without an end date
Most free-lane problems are bookkeeping problems. There is no record of who opted in, or when the trial was supposed to end.
Three symptoms show up together:
- The free lane has no owner and no expiry timestamp.
- Nobody can state tokens spent per useful output.
- The paid baseline disappeared from the config months ago.
A trial without an end date is a production dependency wearing a demo label.
Three red flags that mean stop now
1. An unattended path
If a cron job, webhook handler, or queue worker calls the lane, stop. Unattended callers turn a trial into uptime risk with no human in the loop.
2. Data you cannot describe in one sentence
If you cannot say what fields leave the process boundary, stop. Summaries of ticket text and summaries of customer records look identical in code.
3. No baseline lane to compare against
A free lane is only a deal relative to something. Without one paid or self-hosted baseline, the ledger measures nothing.
Better alternatives, per flag
- Unattended path: run the trial behind a manual trigger and a review queue first.
- Unclear data: redact at the call site, or keep the workload on infrastructure you control.
- No baseline: route 10% of traffic to a second lane and log both.
The artifact: a trial ledger with a hard expiry
The ledger does two jobs. It records every request with a task tag, and it refuses to run after a date or budget you set.
Both limits below are your trial limits. They are not vendor quotas.
#!/usr/bin/env python3
"""trial_ledger.py - log free-lane requests and enforce a trial expiry. Stdlib only."""
import argparse
import json
import sqlite3
import sys
import time
from pathlib import Path
DDL = '''
CREATE TABLE IF NOT EXISTS events (
ts REAL NOT NULL,
task TEXT NOT NULL,
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
accepted INTEGER NOT NULL,
latency_ms INTEGER NOT NULL,
lane TEXT NOT NULL
);
'''
def connect(path):
con = sqlite3.connect(path)
con.executescript(DDL)
return con
def cmd_record(args):
con = connect(args.db)
con.execute(
'INSERT INTO events VALUES (?,?,?,?,?,?,?)',
(time.time(), args.task, args.prompt_tokens, args.completion_tokens,
1 if args.accepted else 0, args.latency_ms, args.lane),
)
con.commit()
print('recorded task=' + args.task + ' accepted=' + str(args.accepted))
def p95(values):
if not values:
return 0
values = sorted(values)
return values[max(0, int(round(0.95 * (len(values) - 1))))]
def cmd_report(args):
con = connect(args.db)
rows = con.execute(
'SELECT task, COUNT(*), SUM(accepted), SUM(prompt_tokens + completion_tokens) '
'FROM events WHERE lane = ? GROUP BY task ORDER BY task',
(args.lane,),
).fetchall()
print('task'.ljust(16) + 'reqs'.rjust(6) + 'acc'.rjust(6) + 'rate'.rjust(7) + 'tok/acc'.rjust(10))
for task, reqs, acc, toks in rows:
rate = acc / reqs if reqs else 0.0
per = str(round(toks / acc)) if acc else 'n/a'
print(task.ljust(16) + str(reqs).rjust(6) + str(acc).rjust(6)
+ str(round(rate, 2)).rjust(7) + per.rjust(10))
lat = [r[0] for r in con.execute(
'SELECT latency_ms FROM events WHERE lane = ?', (args.lane,))]
print('lane=' + args.lane + ' p95_latency_ms=' + str(p95(lat)))
def cmd_check(args):
cfg = json.loads(Path(args.config).read_text())
con = connect(args.db)
total = con.execute(
'SELECT COALESCE(SUM(prompt_tokens + completion_tokens), 0) '
'FROM events WHERE lane = ?', (cfg['lane'],),
).fetchone()[0]
problems = []
if time.time() > cfg['expires_at']:
problems.append('trial expired at ' + str(cfg['expires_at']))
if total >= cfg['max_tokens']:
problems.append('trial budget spent: ' + str(total) + '/' + str(cfg['max_tokens']))
for p in problems:
print('BLOCK: ' + p, file=sys.stderr)
if problems:
return 1
print('OK: ' + str(total) + '/' + str(cfg['max_tokens']) + ' trial tokens used')
return 0
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest='cmd', required=True)
rec = sub.add_parser('record')
rec.add_argument('--db', required=True)
rec.add_argument('--lane', required=True)
rec.add_argument('--task', required=True)
rec.add_argument('--prompt-tokens', type=int, required=True)
rec.add_argument('--completion-tokens', type=int, required=True)
rec.add_argument('--latency-ms', type=int, required=True)
rec.add_argument('--accepted', action='store_true')
rec.set_defaults(func=cmd_record)
rep = sub.add_parser('report')
rep.add_argument('--db', required=True)
rep.add_argument('--lane', required=True)
rep.set_defaults(func=cmd_report)
chk = sub.add_parser('check')
chk.add_argument('--db', required=True)
chk.add_argument('--config', required=True)
chk.set_defaults(func=cmd_check)
args = ap.parse_args()
sys.exit(args.func(args) or 0)
if __name__ == '__main__':
main()
The config file that kills the trial
{"lane": "free-a", "expires_at": 1793664000, "max_tokens": 2000000}
1793664000 is 2026-11-03T00:00:00Z. Compute your own rather than trusting this one:
date -u -d "2026-11-03" +%s
Run it
python trial_ledger.py record --db trial.db --lane free-a --task summarize \
--prompt-tokens 1200 --completion-tokens 220 --latency-ms 840 --accepted
python trial_ledger.py report --db trial.db --lane free-a
python trial_ledger.py check --db trial.db --config lane.json
Wire the last command into CI, or into the wrapper that calls the lane:
- name: free lane trial guard
run: python tools/trial_ledger.py check --db trial.db --config lane.json
A non-zero exit is the point. The trial dies on schedule, not on vibes.
Reading the ledger: tokens per accepted outcome
The tok/acc column is the number that matters. It divides total tokens by outputs a human actually accepted.
A lane returning 60% junk looks cheap per token and expensive per outcome. That inversion is the whole argument.
Two caveats apply. Acceptance is human-labeled, so the labels age. And retries count as separate rows unless you tag them.
Decision table: stay, fix, or walk
Thresholds below are illustrations. Calibrate against your baseline lane.
| Signal from the ledger | Reading | Action |
|---|---|---|
| tok/acc within 20% of baseline | Free lane is competitive | Stay, keep the expiry |
| tok/acc 2x baseline, latency fine | Quality problem, not capacity | Fix prompts, or switch models on the same lane |
| tok/acc high and p95 latency growing | Queueing or rate limiting | Walk; move the task to the baseline lane |
| Fewer than 30 accepted samples | No usable signal | Extend once, then decide with numbers |
| Acceptance rate collapsing mid-trial | Model or input drift | Walk now, re-open as a new trial |
Exit criteria to commit before the trial starts
Write these down before the first request. A trial with exit criteria is an experiment; without them it is a migration.
- A fixed end date, stored in the config, not in a chat thread.
- A token budget for the trial, set by you.
- A minimum sample count before any decision.
- A named baseline lane and a comparison metric.
- A rollback path that does not require an incident.
- A reviewer who is not the person who wanted the trial.
Where a free lane legitimately fits
Short-lived trials are exactly the case for a free model lane and a free server option. Low-stakes tasks, human-reviewed output, and a hard stop date make a free tier a reasonable probe.
MonkeyCode is an open-source project that offers a free model lane and a free server option. The operator states a free token grant (10 million tokens at the time of writing) and a free server tier. Treat both as availability claims, not SLAs, and check the current terms in the repository before planning around them.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
In this workflow the free server hosts the ledger and the guard check. The free lane is the subject of the trial, not the foundation of it.
Where the free lane does not fit
- Anything with a latency SLO.
- Anything with a retention clause you cannot satisfy.
- Anything that must run when no human is watching.
- Anything that needs guaranteed capacity next quarter.
Who should not use this approach
- Teams with no baseline lane. Measure something first.
- Workloads below roughly 30 tasks per week. Statistical noise swamps the table.
- Teams that cannot tag tasks at the call site. The ledger needs a stable key.
- Regulated data flows where the boundary itself is the risk.
Limitations
The ledger measures cost per accepted outcome, not correctness. It cannot see retries unless you tag them. It trusts the accepted flag, which is a human judgment. And the expiry only holds if CI or the wrapper actually runs check. A guard nobody invokes is documentation.
If the guard logic is useful, run it against one small task on the free lane for a week. The ledger will tell you whether the lane deserves a second week.
Top comments (0)