Tutorial: Test the Refusal Path Before You Trust a Free Model Draft
Last Tuesday a support lead approved a refund reply she had not read. The draft looked calm, polite, and correct. The gate that let it through asked exactly one question: did a human click?
She was the decision owner. The consequence was a refund we cannot un-send. The point of reversibility closed the second she hit send.
Free models make that gate cheaper to build. They do not make it optional. So here is the honest question. Would your gate refuse, or would it just look busy?
I built this tutorial to answer that with a test suite, not an opinion.
What the free tier actually buys you
MonkeyCode is an open source project, as the operator describes it. It offers free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The promotion I was told about includes ten million free tokens. Check the live terms yourself before you plan around them, because quotas move.
That is the whole commercial story. Everything below is the part I actually care about.
We will build a gate that refuses first and drafts second. Each stage ends with a check. Skip a check and you are testing your hope.
Stage 1: Stand up a workspace and prove the endpoint answers
mkdir gate-lab && cd gate-lab
python -m venv .venv && source .venv/bin/activate
pip install httpx pytest
Put your endpoint and key in the environment, never in the file. Copy the request path straight from your dashboard instead of guessing it.
export MC_BASE="<your server URL from the dashboard>"
export MC_PATH="<the request path shown in your dashboard>"
export MC_KEY="<your key>"
Now the check. A refusal here is cheap. A refusal in production is not.
curl -sS -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $MC_KEY" \
"$MC_BASE$MC_PATH"
A 401 means your key is wrong, not your code. Fix that before you write another line.
Stage 2: Define the envelope before you call anything
The gate needs a shape it can reject. Mine has six fields. Who decides, what the action is, whether it reverses, how it recovers, what evidence exists, and where the stop conditions live.
# envelope.py
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
MAX_EVIDENCE_AGE = timedelta(hours=24)
@dataclass
class Evidence:
source: str
fetched_at: datetime
covers: str
@dataclass
class Envelope:
decision_owner: str
action: str
reversible: bool
recovery: str
evidence: list[Evidence] = field(default_factory=list)
stop_conditions: list[str] = field(default_factory=list)
def refusals(self, now=None) -> list[str]:
now = now or datetime.now(timezone.utc)
reasons = []
if not self.decision_owner:
reasons.append("no named decision owner")
if not self.reversible and not self.recovery:
reasons.append("irreversible with no recovery path")
if not self.evidence:
reasons.append("no evidence attached")
for item in self.evidence:
if now - item.fetched_at > MAX_EVIDENCE_AGE:
reasons.append(f"stale evidence: {item.source}")
if not self.stop_conditions:
reasons.append("no stop conditions declared")
return reasons
Check: open a Python shell and build one broken envelope by hand. If refusals() returns an empty list for an action with no owner, your validation is decoration.
Stage 3: Make the gate fail closed
The gate calls the model only after the envelope survives review. Order matters more than elegance here.
# gate.py
import httpx
from envelope import Envelope
class FreeModelGate:
def __init__(self, client, path):
self.client = client
self.path = path
def review(self, env: Envelope) -> dict:
reasons = env.refusals()
if reasons:
return {"status": "refused", "reasons": reasons, "text": ""}
try:
resp = self.client.post(self.path, json={
"action": env.action,
"evidence": [e.source for e in env.evidence],
"stop_conditions": env.stop_conditions,
}, timeout=20.0)
except httpx.TimeoutException:
return {"status": "blocked", "reasons": ["model timeout"], "text": ""}
return {"status": "drafted", "reasons": [], "text": resp.text}
Look at the timeout branch. It returns blocked, not drafted. A slow free server must never become an accidental approval.
Check: call review() with an empty envelope and a fake client that raises on use. If your fake client gets called, the gate is wired backwards.
Stage 4: Write the tests that prove refusal
This is the stage most tutorials skip. The interesting behaviour is not the draft. It is the no.
# test_refusal.py
from datetime import datetime, timedelta, timezone
from envelope import Envelope, Evidence
from gate import FreeModelGate
class SpyTransport:
def __init__(self):
self.calls = 0
def post(self, *a, **k):
self.calls += 1
raise AssertionError("model called on a refused envelope")
def fresh():
return Evidence("billing-api", datetime.now(timezone.utc), "refund window")
def test_missing_owner_never_calls_model():
spy = SpyTransport()
env = Envelope("", "send refund reply", True, "", [fresh()], ["amount > policy cap"])
out = FreeModelGate(spy, "/draft").review(env)
assert out["status"] == "refused"
assert spy.calls == 0
def test_irreversible_without_recovery_never_calls_model():
spy = SpyTransport()
env = Envelope("ana", "delete account", False, "", [fresh()], ["legal hold flag"])
out = FreeModelGate(spy, "/draft").review(env)
assert out["status"] == "refused"
assert spy.calls == 0
def test_stale_evidence_is_refused():
old = Evidence("billing-api", datetime.now(timezone.utc) - timedelta(days=3), "refund window")
env = Envelope("ana", "send refund reply", True, "resend correction", [old], ["amount > cap"])
out = FreeModelGate(SpyTransport(), "/draft").review(env)
assert any("stale" in r for r in out["reasons"])
$ pytest -q
3 passed
Three green tests are your first real evidence. They are also free to run, which matters on a free tier.
Stage 5: Order the review card around the stop conditions
The card is what a human reads. So put the refusal reasons first, not the model's prose. My order is: action, reversibility, stop conditions, evidence sources with ages, then the draft.
Check: read the card aloud. If you say "looks fine" before you say the stop conditions, reorder it.
Stage 6: Review the card for access, not aesthetics
Status must be a word, never a colour. Stop conditions must appear before the draft in reading order. Ages must be written as durations, not timestamps only.
Check: remove colour entirely and read again. If the decision changes, the design was relying on something a screen reader never gets.
Stage 7: Decide when this gate is the wrong tool
| Situation | Use the gate? | Why |
|---|---|---|
| Draft replies with a named reviewer | Yes | Refusal is cheap and reversible |
| Irreversible actions with audit duties | Not alone | You need a durable record, not a script |
| High-volume, sub-second paths | No | Human review is the bottleneck, not the model |
| No named decision owner yet | Not yet | The gate would only formalise avoidance |
That last row is the one I would defend hardest. A gate without an owner is a queue with better manners.
What I would do first if I were you
Run the three refusal tests before you write a single prompt. They cost nothing and they tell you the most. Then let the free server answer one boring draft and read your own card out loud.
If you want to try this on the free server, start with the tests, not the model. The tests are the part that survives the promotion.
Which missing evidence would stop your approval today? If the answer is none, you do not have a gate yet.
Top comments (0)