DEV Community

Charlie Zhu
Charlie Zhu

Posted on

The Cassette Workshop: Making Free Model Calls Rerunnable in Two Hours

The Cassette Workshop: Making Free Model Calls Rerunnable in Two Hours

A room of fourteen people, one shared endpoint, and a two-hour slot. By minute forty, half the room has results that disagree with the other half, nobody can say which call produced which row, and the person who owns the key is watching a dashboard instead of the projector.

The failure is not the model. It is that the workshop never separated two different activities — spending a call, and reading a result — so both became unrepeatable at the same time. This outline fixes that with one small artifact: a cassette layer that records each request/response pair to disk and replays it forever after. Students leave with a harness they can rerun on a laptop, in CI, or on a free box, without asking anyone for a key.

Every block below has a time budget, a deliverable, and a failure the instructor should expect. The code is short enough to type live.

0:00–0:12 — Put the failure on the table

Open with a deliberately bad run, not a slide. Take three prompts, call the model once each with temperature=0, save the raw JSON to live/, and print the outputs on the projector. Then run the same three prompts again and show the second folder. Two files differ, or one request times out, and the room now has a concrete reason to care about replay.

Ask nobody to explain. Just write the two timestamps on the whiteboard and move on; the discomfort does the teaching. The point students should absorb is that a model call is an event with a time, a cost, and a shape, and events need logs before they need opinions.

0:12–0:35 — Exercise one: the recorder

The first deliverable is a single module that owns every outbound call. Nothing else in the workshop is allowed to import urllib or an SDK directly. That constraint is the whole exercise; if a student bypasses it, the second exercise will fail in a way that looks mysterious.

The code assumes only that the endpoint speaks the common chat-completions request shape. Which endpoint you point it at is an environment variable, so the room can mix providers without forking the file.

# cassette.py — record once, replay forever
import hashlib, json, os, pathlib, urllib.request

CASSETTE_DIR = pathlib.Path(os.environ.get("CASSETTE_DIR", "cassettes"))
MODE = os.environ.get("MODE", "replay")            # "record" | "replay"
BASE_URL = os.environ["PROVIDER_BASE_URL"].rstrip("/")
API_KEY = os.environ.get("PROVIDER_API_KEY", "")

def _key(model, messages, params):
    blob = json.dumps([model, messages, params], sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]

def call(model, messages, params=None):
    params = params or {}
    path = CASSETTE_DIR / f"{_key(model, messages, params)}.json"

    if MODE == "replay":
        if not path.exists():
            raise LookupError(f"missing cassette {path.name} — record it first")
        record = json.loads(path.read_text())
    else:
        body = json.dumps({"model": model, "messages": messages, **params}).encode()
        headers = {"Content-Type": "application/json"}
        if API_KEY:
            headers["Authorization"] = f"Bearer {API_KEY}"
        req = urllib.request.Request(f"{BASE_URL}/chat/completions", data=body, headers=headers)
        with urllib.request.urlopen(req, timeout=60) as resp:
            record = json.loads(resp.read())
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(record, indent=2, sort_keys=True))

    return record["choices"][0]["message"]["content"]
Enter fullscreen mode Exit fullscreen mode

The key is the hash of the request, not a counter, which means a reordered prompt is a different cassette and an identical prompt is a cache hit. Students discover this when they edit one word and get a LookupError instead of a stale answer.

For a small group, the free model access offered by the MonkeyCode project is enough to record the whole exercise set once — the operator describes it as an open-source project with a free token allowance, currently stated as ten million tokens, alongside a free server option. Treat those numbers as a claim to verify on the project page on the day you teach, because allowances move. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

0:35–1:00 — Exercise two: a gate that exits non-zero

Replay is only half a harness. The second deliverable turns cassettes into a pass/fail signal, so the room can argue about a test result rather than a paragraph of prose. Give students five cases with an exact expected string and one temperature=0 parameter, and let the runner print misses without stopping at the first one.

# run_workshop.py
import json, os, sys
from cassette import call

cases = json.load(open("cases.json"))
misses = 0
for case in cases:
    out = call(os.environ["MODEL"], [{"role": "user", "content": case["prompt"]}],
               {"temperature": 0})
    if out.strip() != case["expect"].strip():
        misses += 1
        print(f"MISS {case['id']}\n  want: {case['expect'][:70]}\n  got : {out[:70]}")
print(f"{len(cases) - misses} pass / {misses} miss")
sys.exit(1 if misses else 0)
Enter fullscreen mode Exit fullscreen mode

Run it with MODE=replay. It should be instant and it should pass. Then someone changes one expected string and the room watches the exit code go to 1 — that single integer is what makes the harness usable in a pipeline later.

Add a live-call ceiling before anyone records again. A module-level counter that raises when MAX_LIVE_CALLS is exceeded costs four lines and prevents the classic accident where a student wraps the recorder in a loop over five hundred prompts. The instructor should demo the raise, not describe it.

1:00–1:22 — Exercise three: put the replayer on a free box

A replay runner needs no GPU and no key, so it belongs somewhere cheap and always on. This is where the project's free server option is genuinely relevant: the students deploy the replayer, not the recorder, which means the box never holds a provider credential.

Ship .env with MODE=replay, MODEL, and PROVIDER_BASE_URL only — no PROVIDER_API_KEY. Then a oneshot unit plus a timer gives the room a scheduled, credential-free check.

# /etc/systemd/system/workshop-replay.service
[Unit]
Description=workshop replay runner
After=network-online.target

[Service]
Type=oneshot
User=workshop
WorkingDirectory=/srv/workshop
EnvironmentFile=/srv/workshop/.env
ExecStart=/srv/workshop/.venv/bin/python run_workshop.py
Enter fullscreen mode Exit fullscreen mode
sudo systemctl daemon-reload
sudo systemctl enable --now workshop-replay.timer
journalctl -u workshop-replay.service -n 20 --no-pager
Enter fullscreen mode Exit fullscreen mode

The deliverable is a journal line that says pass, produced by a machine that cannot spend money. Students who finish early should copy the cassettes/ directory into a second folder, point CASSETTE_DIR at it, and confirm the run is byte-identical.

1:22–1:42 — Exercise four: break it on purpose

Deliberate damage teaches more than a clean demo. Ask each pair to delete one cassette and rerun in replay mode. They get a LookupError naming a sixteen-character hash, which is exactly the information they need: the missing request is identified without reading any log.

Then have them record that one missing case with MODE=record, and run git diff --stat cassettes/. One file appears. That diff is the artifact the room will actually use in review — a prompt change becomes a reviewable file change instead of an invisible behavioral drift.

The last instruction in this block matters for honesty. Have students flip one prompt in cases.json, replay, and watch the result. Replay does not prove the new prompt is good; it proves the harness notices. Saying that out loud prevents the next hour from turning into a false sense of coverage.

1:42–2:00 — Debrief, and where the method breaks

Close by naming the limits rather than defending the design. Replay hides model drift by construction: a cassette recorded in October will keep returning October's answer in December, which is a feature for grading and a hazard for evaluation. Non-zero temperature breaks exact-match assertions, so this harness rewards constrained outputs and punishes free-form ones.

Cassettes also store prompts as plaintext. Any workshop running real customer text through the recorder is writing that text to disk under a hash that is not encryption. Strip, synthesize, or relocate the data before the first record run.

Finally, the availability assumptions are exactly that. Free tiers and free boxes change shape, and a class that depends on a live endpoint the morning of the session has a single point of failure. Record the cassettes a week early, keep them in the repository, and the two hours survive any outage.

Teams running production traffic, regulated data, or anything with a latency contract should not borrow this pattern wholesale. Neither should anyone who needs a stable, versioned evaluation of model quality — that needs a pinned snapshot and many samples, not one call per case. This workshop is for making a small claim reproducible in front of other people, which is a narrower and more useful goal than it sounds.

The cassettes are the souvenir. Everything else can be regenerated.

Top comments (0)