DEV Community

Finley Zhou
Finley Zhou

Posted on

Free AI Models in Your CI Pipeline Will Fail Silently. Build the Circuit Breaker First.

A few months ago I wired a free AI coding model into a side project's CI pipeline. The job was modest: summarize each pull request diff into three bullet points for the changelog draft. It worked for eleven days. On day twelve, the model endpoint started returning empty completions with HTTP 200, and my pipeline happily committed twelve consecutive changelog entries that read, in full, "-". Nobody noticed for a week because the job was green.

That failure taught me something the demo-driven conversation around free AI models skips entirely: the problem with putting a zero-cost model into automation is not quality, it's silent degradation. A paid API with an SLA pages someone when it breaks. A free endpoint just gets weird, and your pipeline keeps shipping.

This article is the workflow I built after that incident. It's a small router with a circuit breaker and a canary quality gate, written in Python, that lets free model endpoints participate in CI automation without being able to fail silently. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I use MonkeyCode's free model access and its free server option as the concrete environment below, but the router is plain HTTP — it works against any OpenAI-compatible endpoint, which is the point.

The actual risk model

Before writing code, it helps to enumerate how free endpoints actually fail in automation, because it's rarely a clean 500:

  1. Empty or truncated completions with success status. The most common one in my experience. Token limits, load shedding, or silent model swaps produce a 200 with nothing usable inside.
  2. Latency cliffs. A free tier under load can go from 2 seconds to 45 seconds. In CI, that's not an error, it's a hung job eating your runner minutes.
  3. Behavioral drift. The endpoint stays up, latency stays fine, but the output format quietly changes because the model behind the route changed. My earlier article on snapshot testing covers detecting this for evaluation; here the concern is simpler — catching it inline before the output lands in a commit.
  4. Rate limiting that looks like randomness. Intermittent 429s that retry-after headers don't describe honestly.

Notice what's missing: "the model gives a mediocre answer." For the class of tasks I'll argue free models belong in — changelogs, commit message linting, test-name generation, log summarization — mediocre-but-parseable is fine. Unparseable-and-committed is not.

The artifact: a circuit-breaker router with a canary gate

The design has three moving parts:

  • Canary gate: before the real request, send a fixed probe prompt with a known checkable answer ("Reply with exactly the word ready."). If the probe fails, the endpoint is degraded for our purposes right now, regardless of what its status page says.
  • Circuit breaker: after N consecutive failures, stop calling the endpoint for a cooldown window and route to the fallback behavior.
  • Fallback behavior: the critical decision. For CI, the fallback should be deterministic and safe — skip the AI step and emit a placeholder — never "block the build."

Here's a working minimal version. I've run this pattern against MonkeyCode's free server endpoint; the only configuration is the base URL and model name:

import time
import httpx

class ModelRouter:
    def __init__(self, base_url, api_key, model,
                 failure_threshold=3, cooldown_seconds=300):
        self.base_url = base_url.rstrip("/")
        self.api_key = api_key
        self.model = model
        self.failure_threshold = failure_threshold
        self.cooldown_seconds = cooldown_seconds
        self.consecutive_failures = 0
        self.circuit_opened_at = None

    def _circuit_open(self):
        if self.circuit_opened_at is None:
            return False
        if time.time() - self.circuit_opened_at > self.cooldown_seconds:
            # half-open: allow one trial request
            return False
        return True

    def _record(self, ok):
        if ok:
            self.consecutive_failures = 0
            self.circuit_opened_at = None
        else:
            self.consecutive_failures += 1
            if self.consecutive_failures >= self.failure_threshold:
                self.circuit_opened_at = time.time()

    def _chat(self, messages, timeout=20):
        resp = httpx.post(
            f"{self.base_url}/v1/chat/completions",
            headers={"Authorization": f"Bearer {self.api_key}"},
            json={"model": self.model, "messages": messages,
                  "temperature": 0, "max_tokens": 512},
            timeout=timeout,
        )
        resp.raise_for_status()
        content = resp.json()["choices"][0]["message"]["content"]
        if not content or not content.strip():
            raise ValueError("empty completion with 200 status")
        return content

    def canary_ok(self):
        try:
            out = self._chat(
                [{"role": "user",
                  "content": "Reply with exactly the word: ready"}],
                timeout=10,
            )
            return out.strip().lower().rstrip(".") == "ready"
        except Exception:
            return False

    def complete(self, prompt):
        """Returns (text, source) where source is 'model' or 'fallback'."""
        if self._circuit_open():
            return None, "fallback"
        if not self.canary_ok():
            self._record(False)
            return None, "fallback"
        try:
            text = self._chat([{"role": "user", "content": prompt}])
            self._record(True)
            return text, "model"
        except Exception:
            self._record(False)
            return None, "fallback"
Enter fullscreen mode Exit fullscreen mode

And the CI-side usage, which is where the safety property lives:

router = ModelRouter(
    base_url="https://your-monkeycode-server.example",  # free server endpoint
    api_key=os.environ["MC_API_KEY"],
    model=os.environ.get("MC_MODEL", "default"),
)

text, source = router.complete(f"Summarize this diff in 3 bullets:\n{diff}")

if source == "model":
    changelog_entry = text
else:
    # deterministic, honest, unmissable
    changelog_entry = "- [auto-summary unavailable; see diff]"

write_changelog(changelog_entry)
Enter fullscreen mode Exit fullscreen mode

The two details that matter most: the canary runs per invocation, not on a schedule, because free endpoints degrade minute-to-minute; and the empty-completion check in _chat is what would have caught my original twelve-dash incident, since the endpoint never returned an error status.

Where this works and where it doesn't

This is the decision table I now apply before letting any free model touch automation:

Task Output is committed? Human review before merge? Verdict
PR diff summary for reviewer convenience No (comment only) Yes Good fit
Changelog draft entries Yes Yes (release review) OK with breaker
Test name / docstring suggestions Yes Yes OK with breaker
Log triage and alert deduplication No Sometimes Good fit
Auto-generated migration or schema code Yes No Never
Security-sensitive analysis (secret detection, auth logic) Any Any Never
Anything where a wrong answer blocks or ships production Yes No Never

The pattern: free models belong where the output is advisory or reviewed, the volume makes paid API costs annoying, and the fallback is cheap. The moment a wrong answer can reach production unreviewed, cost stops being the relevant variable.

Honest limitations

  • The canary adds latency. Every real call is now two calls. For my changelog job that added ~3 seconds; for anything interactive, batch the canary or cache its result for a short window (60 seconds is what I use, trading a small staleness risk).
  • Free tier behavior is not a contract. Endpoints can gain rate limits, change models behind the same route, or disappear. The breaker absorbs transient versions of this; it cannot absorb the endpoint going away mid-sprint. Keep the fallback path exercised — I run one CI job a week with the endpoint deliberately unreachable to prove the fallback still works. A fallback you've never seen execute is a rumor.
  • The router doesn't judge quality, only operability. The canary proves the endpoint responds in format, not that it responds well. Quality evaluation is a separate offline problem (I've written about snapshot tests and scoring loops for that); conflating the two gates makes both worse.
  • Who should skip this entirely: teams whose CI tasks are latency-critical, anyone without a human review step downstream, and anyone tempted to let the model's output trigger further automation. Each layer of automation between model output and human eyes multiplies the blast radius of silent degradation.

Closing thought

The economics of free model access are genuinely useful — MonkeyCode's free models plus a free server option meant my changelog automation costs nothing to run, and the whole experiment was cheap to try. But "free to call" and "free to trust" are different statements, and CI is where the difference shows up at 2 a.m. as a green pipeline full of dashes. Build the breaker first, keep the fallback deterministic, and let the free tier earn its place in the pipeline the same way any flaky dependency does: behind a circuit that assumes it will fail.

If you want to try this pattern, the router above runs unmodified against MonkeyCode's free server — the canary prompt and fallback design are the parts worth copying, not the endpoint.

Top comments (1)

Collapse
 
marcusykim profile image
Marcus Kim

Twelve consecutive "-" entries behind a green job is exactly the kind of failure that makes HTTP status an insufficient success signal. The fixed "ready" canary and the weekly deliberately unreachable endpoint test are practical safeguards, but there's a state-lifetime issue worth calling out: if each CI run creates a fresh ModelRouter, consecutive_failures resets and the three-failure circuit may never open across jobs. Persisting breaker state externally-or treating a failed per-run canary as enough to skip the model-would make the protection match CI's usually ephemeral execution model.