Free model servers feel stable. Then one Tuesday, they aren't.
The endpoint still returns 200. The text still looks plausible. The output is subtly wrong.
I've spent the last month building evaluation harnesses for free model servers. The scariest failure isn't a timeout. It's silent drift.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Drift is worse than downtime
Downtime wakes you up. Drift doesn't.
A timeout triggers an alert. A wrong answer gets merged into your app. Users notice later, and they blame you, not the model.
Free servers make this worse. They change routing, load, and versions without telling you. You need a gate that watches the output, not just the status code.
This is not a ceiling test. It's a stability test.
What the gate checks
I run a fixed set of probes against the same endpoint on a schedule. Each probe has a small, machine-checkable expectation.
The gate checks:
- JSON schema validity
- Required substrings
- Minimum response length
- p95 latency
- Error rate
That's it. No embeddings. No semantic similarity. Just things a script can verify without another model.
The artifact: drift_gate.py
The script uses only the Python standard library. Point it at any chat-completions-style endpoint.
#!/usr/bin/env python3
"""drift_gate.py - regression gate for free model servers."""
import argparse, json, statistics, sys, time, urllib.request
from datetime import datetime, timezone
def call_endpoint(cfg, prompt):
body = {
"model": cfg["model"],
"messages": [{"role": "user", "content": prompt}],
"temperature": 0,
"max_tokens": 300,
}
req = urllib.request.Request(
cfg["endpoint"],
data=json.dumps(body).encode(),
headers={
"Content-Type": "application/json",
"Authorization": "Bearer " + cfg["api_key"],
},
)
start = time.monotonic()
with urllib.request.urlopen(req, timeout=cfg.get("timeout", 30)) as resp:
payload = json.loads(resp.read().decode())
elapsed = time.monotonic() - start
return payload["choices"][0]["message"]["content"], elapsed
def check_output(text, expected):
failures = []
if expected.get("json_schema"):
try:
json.loads(text)
except json.JSONDecodeError:
failures.append("json_invalid")
for required in expected.get("required_substrings", []):
if required not in text:
failures.append("missing:" + required)
if len(text.strip()) < expected.get("min_chars", 10):
failures.append("too_short")
return failures
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", required=True)
ap.add_argument("--history", default="drift_history.jsonl")
ap.add_argument("--threshold", type=float, default=0.3)
args = ap.parse_args()
cfg = json.load(open(args.config))
rows = []
for probe in cfg["probes"]:
text, elapsed = call_endpoint(cfg, probe["prompt"])
failures = check_output(text, probe["expected"])
rows.append({
"probe": probe["name"],
"latency": round(elapsed, 2),
"failures": failures,
"ok": not failures,
})
error_rate = 1 - (sum(r["ok"] for r in rows) / len(rows))
latencies = sorted(r["latency"] for r in rows)
p95 = latencies[int(len(latencies) * 0.95) - 1]
score = error_rate + (p95 / cfg.get("latency_budget", 20)) * 0.5
record = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"score": round(score, 3),
"p95": p95,
"rows": rows,
}
with open(args.history, "a") as f:
f.write(json.dumps(record) + "\n")
print(json.dumps(record, indent=2))
if score > args.threshold:
sys.exit(1)
if __name__ == "__main__":
main()
The score is a weighted blend of error rate and p95 latency. It is not a quality score. It is a drift signal.
A minimal config
Save this as config.json. Replace the endpoint, key, and model with your provider's current values.
{
"endpoint": "https://api.example.com/v1/chat/completions",
"api_key": "${FREEMODEL_KEY}",
"model": "free-model-name",
"latency_budget": 20,
"probes": [
{
"name": "json_extract",
"prompt": "Return JSON with keys name and age.",
"expected": {"json_schema": true}
},
{
"name": "short_answer",
"prompt": "What is 2+2?",
"expected": {"required_substrings": ["4"]}
},
{
"name": "empty_guard",
"prompt": "Say nothing.",
"expected": {"min_chars": 1}
}
]
}
The empty_guard probe catches a common failure mode. Some free servers return an empty string under load. A human would never notice. The gate does.
Sample output
This is a synthetic example. Your numbers will differ.
{
"timestamp": "2026-08-21T09:00:00Z",
"score": 0.79,
"p95": 18.3,
"rows": [
{"probe": "json_extract", "latency": 2.1, "failures": [], "ok": true},
{"probe": "short_answer", "latency": 18.3, "failures": ["missing:4"], "ok": false},
{"probe": "empty_guard", "latency": 1.2, "failures": [], "ok": true}
]
}
A score above 0.3 fails the gate. In this example, the model stopped answering 2+2 correctly. The gate exits with code 1.
Run it in GitLab CI
Add a scheduled pipeline or a manual job. Store the key as a CI/CD variable.
drift-gate:
image: python:3.12-slim
variables:
FREEMODEL_KEY: $FREEMODEL_KEY
script:
- python drift_gate.py --config config.json
artifacts:
paths:
- drift_history.jsonl
when: always
The artifact gives you a history. Trends matter more than single runs.
Where this approach breaks
This gate checks stability, not truth. A confident wrong answer passes.
It needs fixed expected outputs. If your prompts change, the baseline changes. Rebuild the probe set first.
Free servers rate-limit. Add backoff and retries before running this in production. The script above skips them for clarity.
The p95 from three probes is noisy. Use at least 20 probes per run for real signal.
Do not use this to compare models. Use it to watch one endpoint over time.
Who should not use this
Teams without a fixed prompt set should skip it. So should teams where a wrong answer is dangerous.
If you need factual accuracy, add a human review step. This gate is a tripwire, not a judge.
Try it
Point this at any free model server you already use. Run it for a week. The first failing probe will tell you more than the score.
MonkeyCode's free model access and free server option are relevant here. They give you an endpoint to point this at. Confirm the request shape in the current docs, then run the gate.
The gate won't stop drift. It will stop silent drift.
Top comments (0)