A free model response is not evidence.
A plain JSON file stores what the client received.
It does not prove that the file still matches the original flow.
A hash chain links each response to the previous entry.
That link makes silent edits, deletions, and reordering visible.
MonkeyCode's free model access provides a suitable target for this tutorial.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
You can run the same workflow against any HTTP model endpoint.
Run it locally or on a free server option if your endpoint is remote.
Why a plain log fails
A typical test run logs one record per prompt.
You can edit one record and leave the rest untouched.
A reviewer cannot tell which record changed.
You can also delete a middle record without affecting the first and last entries.
A simple restore from backup does not prove order either.
A hash chain fixes this at low cost.
Each entry stores the hash of the previous entry.
The current entry is hashed with its own fields.
If any earlier record changes, the next prev_hash no longer matches.
That breaks the chain from that point forward.
What you will build
You will build two small Python 3 files.
One file is a mock model endpoint.
The other is a hash-chain client.
The client sends a prompt, extracts the response, and appends a linked entry.
A verify flag recomputes every hash and checks every link.
This uses only the Python standard library.
No web framework or database is required.
Step 0: Create the project
Run these commands in a terminal.
mkdir model-audit
cd model-audit
python3 -m venv .venv
source .venv/bin/activate
python --version
Expected output is Python 3.10 or later.
If the version is older, install a current Python 3 release first.
Step 1: Run a mock endpoint
Save this as mock_model.py.
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import time
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
response = {
'choices': [
{
'message': {
'content': f'mock:{time.time_ns()}'
}
}
]
}
data = json.dumps(response).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
if __name__ == '__main__':
HTTPServer(('127.0.0.1', 8080), Handler).serve_forever()
Start the server.
python mock_model.py &
Verify with curl.
curl -sS -X POST http://127.0.0.1:8080/v1/chat/completions -H 'Content-Type: application/json' -d '{"messages":[{"role":"user","content":"ping"}]}'
You should see a JSON object with a content value.
Step 2: Add the hash-chain client
Save this as audit.py.
"""Tamper-evident log for model responses."""
import argparse
import hashlib
import json
import os
import time
import urllib.request
LOG_FILE = os.environ.get('LOG_FILE', 'audit.jsonl')
GENESIS = 'GENESIS'
def call_model(url, prompt, token):
body = json.dumps({
'messages': [{'role': 'user', 'content': prompt}]
}).encode()
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = f'Bearer {token}'
request = urllib.request.Request(url, data=body, headers=headers)
with urllib.request.urlopen(request, timeout=10) as response:
return response.read().decode()
def extract_text(raw):
data = json.loads(raw)
try:
return data['choices'][0]['message']['content']
except (KeyError, IndexError, TypeError):
return raw
def load_prev():
if not os.path.exists(LOG_FILE):
return GENESIS
with open(LOG_FILE) as f:
last = None
for line in f:
last = json.loads(line)
return last['hash'] if last else GENESIS
def append_entry(prev_hash, request_id, prompt, response):
payload = {
'prev_hash': prev_hash,
'request_id': request_id,
'prompt': prompt,
'response': response,
'timestamp': time.time_ns(),
}
payload['hash'] = hashlib.sha256(
json.dumps(payload, sort_keys=True).encode()
).hexdigest()
with open(LOG_FILE, 'a') as f:
f.write(json.dumps(payload, sort_keys=True) + chr(10))
return payload
def verify_log():
prev = GENESIS
with open(LOG_FILE) as f:
for line in f:
record = json.loads(line)
if record.get('prev_hash') != prev:
return False, f"broken link at {record.get('request_id')}"
stored = record.get('hash')
copy = {k: v for k, v in record.items() if k != 'hash'}
recomputed = hashlib.sha256(
json.dumps(copy, sort_keys=True).encode()
).hexdigest()
if stored != recomputed:
return False, f"bad hash at {record.get('request_id')}"
prev = stored
return True, 'ok'
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--url', default='http://127.0.0.1:8080/v1/chat/completions')
parser.add_argument('--token', default=os.environ.get('MODEL_TOKEN', ''))
parser.add_argument('--prompt', default='ping')
parser.add_argument('--iterations', type=int, default=1)
parser.add_argument('--verify', action='store_true')
args = parser.parse_args()
if args.verify:
ok, message = verify_log()
print(json.dumps({'ok': ok, 'detail': message}))
raise SystemExit(0 if ok else 1)
for i in range(args.iterations):
raw = call_model(args.url, args.prompt, args.token)
text = extract_text(raw)
prev = load_prev()
request_id = f'{int(time.time() * 1000)}-{i}'
entry = append_entry(prev, request_id, args.prompt, text)
print(json.dumps({'request_id': entry['request_id'], 'hash': entry['hash'][:12]}))
if __name__ == '__main__':
main()
The client accepts MODEL_TOKEN only when the endpoint requires one.
Step 3: Generate linked entries
Run the client three times.
python audit.py --prompt "hello" --iterations 3
You should see three short hash values.
Inspect the log.
cat audit.jsonl
Each line contains prev_hash, prompt, response, and hash.
The first line uses GENESIS as its previous hash.
Step 4: Verify the chain
Run the verify command.
python audit.py --verify
Expected output is:
{"ok": true, "detail": "ok"}
A passing check means every link and every hash is consistent.
Step 5: Break the chain deliberately
Edit the last response without updating its hash.
python - <<'PY'
import json
path = 'audit.jsonl'
lines = open(path).read().splitlines()
record = json.loads(lines[-1])
record['response'] = 'changed after the fact'
lines[-1] = json.dumps(record, sort_keys=True)
open(path, 'w').write(chr(10).join(lines) + chr(10))
PY
Run verification again.
python audit.py --verify
Expected output reports ok: false and a bad hash detail.
That failure is the point.
The chain detects the edit without comparing to an external copy.
Limitations
A hash chain proves internal consistency.
It does not prove that the model output is correct.
It does not validate schema, truth, or safety.
It does not protect against an attacker who can rewrite every hash.
For that, use signed entries or an append-only store.
The timestamp uses the system wall clock.
Use time.monotonic_ns() if you need interval measurements.
Keep the log in a trusted directory.
If the log writer can be replaced, the chain can be rebuilt.
Who should not use this
Skip this approach if you need regulatory proof.
Skip it if multiple processes append concurrently.
Skip it if logs grow faster than your disk rotation plan.
Use a database or a signed log system in those cases.
For local tests against free model endpoints, the script is enough.
If you have free model access through MonkeyCode, start with this before you cache anything.
Top comments (0)