DEV Community

Haley
Haley

Posted on

Tutorial: Test the Refusal Path Before You Trust a Free Model Draft

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
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode
$ pytest -q
3 passed
Enter fullscreen mode Exit fullscreen mode

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)