DEV Community

Dakota Liu
Dakota Liu

Posted on

Nightly Drift Checks: Catch a Free Model's Behavior Change Before Your Users Do

Here's the conclusion up front: a free LLM endpoint is a moving target. You can't see the changes, but they're happening — model updates, quantization tweaks, server-side prompt rewrites. And your app will feel them, usually as a slow, invisible quality dip.

I've spent weeks on this account probing free LLM servers, caching tokens, and building evaluation harnesses. The pattern I keep seeing: teams pick a free tier, wire it in, and then never look at it again. They treat it like a static API. It isn't.

The fix is a nightly drift check. A small script that runs your most important prompts against the endpoint, compares the outputs to a baseline, and tells you when something changed. Not a benchmark. Not a one-time eval. A recurring alarm.

This post walks through a 90-line harness you can run tonight. I'll use MonkeyCode's free server as the reference endpoint — it's an open-source project with free model access, a free server option, and, as advertised at the time of writing, a 10M token grant. The exact numbers may move, so check the repo's README before you depend on them.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Why drift is the silent killer of free-tier apps

Let's be honest: free endpoints don't come with changelogs. The provider can swap the underlying model, adjust the temperature default, or add a safety filter without telling you. Your tests still pass. Your error rate stays flat. But the responses get a little shorter, a little more evasive, a little less useful.

Users notice before you do. They don't file bugs for 'the bot got dumber.' They just stop using it.

A drift check turns 'the bot got dumber' into a concrete signal: 'the pass rate on 12 core prompts dropped from 92% to 74% overnight.' That's something you can act on.

Step 1: Define your core prompts

Don't test everything. Pick 10-20 prompts that represent the actual workload your app handles. For each prompt, define what 'good' looks like.

Prompt Expected behavior
'Summarize this article in 3 bullets' Output contains at least 3 bullet-like lines
'Extract the email address from this text' Output contains a regex-matchable email
'Explain recursion to a 10-year-old' Output contains the word 'function' or 'calls itself'
'Classify this review as positive or negative' Output contains 'positive' or 'negative'

The key is that expected behavior must be checkable without an LLM. Keywords, regexes, length limits, or simple heuristics. If you need another model to judge the output, you're adding a second drift source.

Step 2: Build the harness

Here's the core script. It's designed to run in a cron job or GitHub Action, and it has two modes: --baseline to record current behavior, and --check to compare against the baseline.

#!/usr/bin/env python3
'''drift_harness.py — nightly drift detection for free LLM endpoints.'''

import argparse
import json
import re
import sys
import urllib.request
from datetime import date
from pathlib import Path

# Each case: prompt, and a list of (name, function) checks.
# A case passes if all checks pass.
CASES = [
    {
        'name': 'summary_bullets',
        'prompt': 'Summarize this article in 3 bullet points:' + chr(10) + chr(10) +
                  'The new update adds dark mode, faster startup, and offline sync.',
        'checks': [
            ('has_3_lines', lambda out: len([l for l in out.split(chr(10)) if l.strip().startswith('-')]) >= 3),
        ],
    },
    {
        'name': 'extract_email',
        'prompt': 'Extract the email address from this text:' + chr(10) + chr(10) +
                  'Contact Jane at jane.doe@example.com for more info.',
        'checks': [
            ('has_email', lambda out: re.search(r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9.]+', out) is not None),
        ],
    },
    {
        'name': 'recursion_explainer',
        'prompt': 'Explain recursion to a 10-year-old in 2 sentences.',
        'checks': [
            ('mentions_function', lambda out: 'function' in out.lower() or 'calls itself' in out.lower()),
        ],
    },
    {
        'name': 'sentiment_classifier',
        'prompt': 'Classify this review as positive or negative: ' +
                  "'The app crashes constantly but the design is pretty.'",
        'checks': [
            ('has_sentiment', lambda out: 'positive' in out.lower() or 'negative' in out.lower()),
        ],
    },
]


def call_endpoint(prompt, endpoint, api_key=None):
    '''Call an OpenAI-compatible chat completions endpoint.'''
    payload = {
        'model': 'default',
        'messages': [{'role': 'user', 'content': prompt}],
        'temperature': 0.2,
    }
    headers = {'Content-Type': 'application/json'}
    if api_key:
        headers['Authorization'] = 'Bearer ' + api_key
    req = urllib.request.Request(
        endpoint, data=json.dumps(payload).encode(), headers=headers
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        data = json.loads(resp.read().decode())
    return data['choices'][0]['message']['content']


def run_case(case, endpoint, api_key=None):
    '''Run a single case, return (passed, output, details).'''
    try:
        output = call_endpoint(case['prompt'], endpoint, api_key)
    except Exception as e:
        return False, '', 'endpoint error: ' + str(e)
    failures = []
    for name, check in case['checks']:
        if not check(output):
            failures.append(name)
    if failures:
        return False, output, 'failed checks: ' + str(failures)
    return True, output, 'ok'


def main():
    parser = argparse.ArgumentParser(description='Nightly LLM drift checker')
    parser.add_argument('--endpoint', required=True, help='OpenAI-compatible endpoint URL')
    parser.add_argument('--api-key', default=None, help='Optional API key')
    parser.add_argument('--baseline', action='store_true', help='Record baseline results')
    parser.add_argument('--threshold', type=float, default=0.8, help='Min pass rate before alert')
    args = parser.parse_args()

    results = []
    for case in CASES:
        passed, output, details = run_case(case, args.endpoint, args.api_key)
        results.append({'name': case['name'], 'passed': passed, 'details': details})
        status = 'PASS' if passed else 'FAIL'
        print('  ' + status + '  ' + case['name'] + ': ' + details)

    pass_rate = sum(1 for r in results if r['passed']) / len(results)
    print('Pass rate: {:.0%}'.format(pass_rate))

    if args.baseline:
        baseline_path = Path('baseline_' + date.today().isoformat() + '.json')
        baseline_path.write_text(json.dumps(results, indent=2))
        print('Baseline written to ' + str(baseline_path))
        return 0

    # Compare against most recent baseline
    baseline_files = sorted(Path('.').glob('baseline_*.json'))
    if not baseline_files:
        print('No baseline found. Run with --baseline first.')
        return 2
    baseline = json.loads(baseline_files[-1].read_text())
    baseline_pass = sum(1 for r in baseline if r['passed']) / len(baseline)

    print('Baseline pass rate: {:.0%}'.format(baseline_pass))
    if pass_rate < args.threshold:
        print('ALERT: pass rate dropped below {:.0%}!'.format(args.threshold))
        return 1
    if pass_rate < baseline_pass - 0.1:
        print('ALERT: pass rate dropped {:.0%} vs baseline!'.format(baseline_pass - pass_rate))
        return 1
    print('OK: no significant drift detected.')
    return 0


if __name__ == '__main__':
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Step 3: Schedule it

Add a cron job:

0 2 * * * cd /path/to/project && python3 drift_harness.py --endpoint "$MONKEYCODE_ENDPOINT" --api-key "$MONKEYCODE_KEY" --threshold 0.8
Enter fullscreen mode Exit fullscreen mode

Or a GitHub Action that runs nightly and opens an issue on failure.

Step 4: When it fires

The alert means something changed. Now what?

  1. Check the failed cases. Which prompts broke?
  2. Look at the actual outputs. Did the model get more terse? More verbose? Refuse to answer?
  3. Compare with the baseline outputs. What's different?
  4. Decide: is this a temporary blip or a permanent change? Re-run the harness a few times.
  5. If it's permanent, update your prompt or your fallback. If the model is now refusing certain requests, add a retry with different wording. If it's producing shorter outputs, adjust your parsing.

The point isn't to prevent drift. It's to know about it on day one, not day thirty.

Limitations

This harness checks surface-level behavior, not semantic quality. A model can pass all your keyword checks while getting subtly worse at reasoning. That's fine — this is an early-warning system, not a full evaluation.

Your test cases will go stale. As your app evolves, your prompts change. Update the CASES list regularly, and re-baseline after intentional changes.

Free endpoints can be flaky. A single timeout will count as a failure. Run the harness a few times before trusting a single alert. Or add retries to call_endpoint.

Who should not use this

Skip the drift check if:

  • Your app uses a paid API with a stable version. You're paying for the changelog.
  • You're building a throwaway prototype. Don't engineer alarms for something disposable.
  • You don't have 10 minutes to maintain the test cases. A stale drift check is worse than none — it gives false confidence.

The takeaway

Free LLM tiers are a trade: cost for control. You don't get to see the model updates, the config changes, the silent tweaks. But you can build your own early-warning system.

Set up a nightly drift check this weekend. Ten prompts, one script, one cron job. When the model changes — and it will — you'll be the first to know, not the last.

And if you're looking for a free endpoint to practice on, MonkeyCode's free server is a reasonable place to start. The 10M token grant and free server are what the project advertises today. Check the README for current numbers, then point this harness at it and see what you learn.

Top comments (0)