DEV Community

Avery Lin
Avery Lin

Posted on

The Second Run Should Cost Nothing

It was 2:41 in the morning. A solo founder watched the same failing test scroll past forty times.

The agent loop was not broken. It did exactly what it was told: retry, retry, retry.

By sunrise the test still failed. The only thing that had moved was the meter.

That scene explains a budget better than any pricing page. Agent spend rarely dies from one huge prompt. It dies from the same prompt sent forty times.

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

The operator states that MonkeyCode provides free model access and a free server option. Quota details change over time, so read the project page for current numbers instead of trusting a blog post. For a one-person shop, the allowance is not the interesting part. The harness you wrap around it is.

Retries Are Not a Model Problem

A retry loop is a deterministic machine. It keeps no memory of the last attempt.

So it pays twice for the same question. Then it pays again when the test still fails.

Engineers call this an idempotency problem. Sending the same request twice should be harmless. Model calls are the opposite: identical input, identical output, two invoices.

The fix is old and boring. Put a cache in front of the model and a ceiling behind it.

The Ledger: Sixty Lines and a Hash

The artifact below is a small Python module. It keys every prompt by SHA-256, stores the answer on disk, and refuses to pass a daily call ceiling.

Calls are counted locally on purpose. Token accounting from a free endpoint is inconsistent, and a number you cannot audit is a number you cannot budget.

# ledger.py - idempotent model calls with a hard daily ceiling
import hashlib, json, os, time, pathlib, urllib.request

ROOT = pathlib.Path(os.environ.get("LEDGER_DIR", ".ledger"))
ROOT.mkdir(exist_ok=True)
CACHE, CALLS = ROOT / "cache.jsonl", ROOT / "calls.log"
CEILING = int(os.environ.get("DAILY_CALL_CEILING", "200"))

def key_for(prompt: str) -> str:
    body = json.dumps({"prompt": prompt}, sort_keys=True)
    return hashlib.sha256(body.encode()).hexdigest()[:16]

def calls_today() -> int:
    if not CALLS.exists():
        return 0
    today = time.strftime("%Y-%m-%d")
    return sum(1 for l in CALLS.read_text().splitlines() if l.startswith(today))

def cached(key: str):
    if not CACHE.exists():
        return None
    for line in CACHE.read_text().splitlines():
        row = json.loads(line)
        if row["key"] == key:
            return row["text"]
    return None

def ask(prompt: str) -> str:
    key = key_for(prompt)
    hit = cached(key)
    if hit is not None:
        return hit                       # replay: zero cost
    if calls_today() >= CEILING:
        raise RuntimeError(f"ceiling {CEILING} reached; inspect, do not retry")
    req = urllib.request.Request(
        os.environ["MODEL_BASE_URL"].rstrip("/") + "/chat/completions",
        data=json.dumps({"model": os.environ["MODEL_NAME"],
                         "messages": [{"role": "user", "content": prompt}]}).encode(),
        headers={"Content-Type": "application/json",
                 "Authorization": "Bearer " + os.environ["MODEL_API_KEY"]})
    with urllib.request.urlopen(req, timeout=120) as r:
        text = json.load(r)["choices"][0]["message"]["content"]
    with CACHE.open("a") as f:
        f.write(json.dumps({"key": key, "prompt": prompt, "text": text}) + "\n")
    with CALLS.open("a") as f:
        f.write(f"{time.strftime('%Y-%m-%d')} {key}\n")
    return text
Enter fullscreen mode Exit fullscreen mode

Read the order inside ask. It checks the cache first, then the ceiling, then the network. The network is the last resort, not the first move.

The module knows nothing about agents. It knows that a question already answered should not be asked again.

Prove It With Two Commands

Never trust a cache you have not measured. Send one prompt twice and count the log lines.

export MODEL_BASE_URL="https://<endpoint-from-the-project-page>/v1"
export MODEL_NAME="<a free model listed on the project page>"
export MODEL_API_KEY="<your key>"
export DAILY_CALL_CEILING=50

python -c "import ledger; print(ledger.ask('reply with: ok')[:40])"
python -c "import ledger; print(ledger.ask('reply with: ok')[:40])"
wc -l .ledger/calls.log      # expected: 1
Enter fullscreen mode Exit fullscreen mode

One line means the second call was free. Raise the ceiling to your real workload and watch the log for a day.

If .ledger/calls.log grows faster than your commit history, the loop is the problem. Not the model.

Quarantine the Failure

A deterministic harness needs a place for tasks that already failed. Otherwise each pass reintroduces them.

while read -r task; do
  python run_task.py "$task" || echo "$task" >> .ledger/quarantine.txt
done < tasks.txt
Enter fullscreen mode Exit fullscreen mode

The quarantine file is the human inbox. A task leaves it only after someone edits the prompt or the test.

That single rule turns a runaway loop into a queue with a gate. It also keeps a solo founder honest about what is genuinely blocked.

The recent argument that most agent stacks are deterministic glue in costume is roughly correct. The useful work is deciding which glue, and where the money stops.

What Belongs on a Free Endpoint

Free access is a resource with a shape. Match the shape to the work, or the harness fights you.

Workload Free endpoint Paid endpoint
Drafts and tests you review anyway Yes Unnecessary
Same prompt replayed after a failure Yes, cached (zero calls) Wasteful
User-facing responses with a latency budget No Yes
Hundreds of calls per hour Usually no Maybe
Private customer code Only if the provider's terms allow it Case by case

The free server option matters for a different reason. The ledger and the loop can run on it overnight, while the founder sleeps. A laptop closed at midnight stops shipping.

Limits, Stated Plainly

The cache key covers the prompt only. Change the model name or the context and it misses.

Appending to one file works for one process. Two concurrent workers will interleave lines, so move to SQLite before parallelizing.

Free tiers change without warning. A ceiling that reads 200 today may need another value next month, and no blog post can guarantee it.

This approach suits a solo founder validating an idea on nights and weekends. It does not suit a team that needs a shared cache, an audit trail, or firm latency guarantees. Anyone handling regulated data should not route it through a free endpoint at all.

The Only Rule That Matters

A failed run should teach you something. It should not charge you for the lesson.

Wrap the free model in a ledger, cap the day, and quarantine what breaks. Do that before adding another agent framework.

If the weekend is quiet, deploy the loop on the free server and let it run. The bill stays at zero. The commits keep coming.

Top comments (0)