CI is a bad place to repeat model calls. A pipeline can re-run the same job on every push, every retry, and every scheduled trigger. If the job includes a direct model request, an unchanged prompt can hit the same free route again and again. The pipeline still looks green, but the free quota is gone.
I moved that call out of CI. The verdict now lives behind a small sidecar hosted on MonkeyCode's free server option. CI calls the sidecar; the sidecar calls the free model routes only when it has to.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The real failure was placement, not the model
The model route was not broken. The problem was the caller.
A CI job is a bad network client. It repeats, retries, and runs under time pressure. When the job calls a free route directly, every rerun is a fresh upstream request even when the prompt did not change.
That creates three costs:
- quota burn without new information
- flaky latency inside an otherwise deterministic job
- a wider attack and failure surface in the pipeline
The sidecar fixes the placement. It takes the repeated part away from CI and keeps the model route behind one local contract.
The sidecar has three jobs
- Normalize: turn the provider response into a single
ok/textshape. - Cache: reuse the last good verdict for the same prompt within a short window.
- Fail closed: return
502when the upstream response is empty, malformed, or missing.
CI does not need to understand provider JSON, retry logic, or model names. It only needs one local endpoint.
The Go code
This uses only the Go standard library. That matters on a free server slot because there are no extra build dependencies to install.
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"sync"
"time"
)
type UpstreamRequest struct {
Prompt string `json:"prompt"`
}
type UpstreamResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
type Verdict struct {
OK bool `json:"ok"`
Text string `json:"text"`
Source string `json:"source"`
Cached bool `json:"cached"`
ElapsedMS int64 `json:"elapsed_ms"`
}
type cacheEntry struct {
Verdict Verdict
ExpiresAt time.Time
}
var (
upstreamURL = os.Getenv("UPSTREAM_URL")
apiKey = os.Getenv("UPSTREAM_API_KEY")
modelName = os.Getenv("UPSTREAM_MODEL")
cacheTTL = 10 * time.Minute
cacheMu sync.Mutex
cache = make(map[string]cacheEntry)
)
func keyFor(prompt string) string {
sum := sha256.Sum256([]byte(prompt))
return hex.EncodeToString(sum[:])
}
func cachedVerdict(key string) (Verdict, bool) {
cacheMu.Lock()
defer cacheMu.Unlock()
entry, ok := cache[key]
if !ok || time.Now().After(entry.ExpiresAt) {
return Verdict{}, false
}
return entry.Verdict, true
}
func storeVerdict(key string, verdict Verdict) {
cacheMu.Lock()
defer cacheMu.Unlock()
cache[key] = cacheEntry{Verdict: verdict, ExpiresAt: time.Now().Add(cacheTTL)}
}
func callUpstream(prompt string) (Verdict, error) {
payload := map[string]any{
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
}
if modelName != "" {
payload["model"] = modelName
}
body, err := json.Marshal(payload)
if err != nil {
return Verdict{}, err
}
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest(http.MethodPost, upstreamURL, bytes.NewReader(body))
if err != nil {
return Verdict{}, err
}
req.Header.Set("Content-Type", "application/json")
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
started := time.Now()
resp, err := client.Do(req)
if err != nil {
return Verdict{}, err
}
defer resp.Body.Close()
elapsed := time.Since(started).Milliseconds()
if resp.StatusCode != http.StatusOK {
return Verdict{OK: false, ElapsedMS: elapsed}, nil
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return Verdict{}, err
}
var parsed UpstreamResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return Verdict{OK: false, ElapsedMS: elapsed}, nil
}
text := ""
if len(parsed.Choices) > 0 {
text = parsed.Choices[0].Message.Content
}
if text == "" {
return Verdict{OK: false, ElapsedMS: elapsed}, nil
}
return Verdict{OK: true, Text: text, Source: "upstream", ElapsedMS: elapsed}, nil
}
func handleVerdict(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var request UpstreamRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Prompt == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
key := keyFor(request.Prompt)
if verdict, ok := cachedVerdict(key); ok {
verdict.Cached = true
writeJSON(w, http.StatusOK, verdict)
return
}
verdict, err := callUpstream(request.Prompt)
if err != nil {
http.Error(w, "upstream unavailable", http.StatusBadGateway)
return
}
if verdict.OK {
storeVerdict(key, verdict)
}
if !verdict.OK {
writeJSON(w, http.StatusBadGateway, verdict)
return
}
writeJSON(w, http.StatusOK, verdict)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func main() {
if upstreamURL == "" {
log.Fatal("UPSTREAM_URL is required")
}
http.HandleFunc("/v1/verdict", handleVerdict)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
})
addr := os.Getenv("ADDR")
if addr == "" {
addr = ":8080"
}
log.Printf("verdict sidecar listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
Run it with:
go run verdict_sidecar.go
Then call it:
curl -X POST http://localhost:8080/v1/verdict -H 'Content-Type: application/json' -d '{"prompt":"Is this test failure likely environmental?"}'
Expected first response:
{"ok":true,"text":"...","source":"upstream","cached":false,"elapsed_ms":812}
The second identical call should return the same text with cached:true and a much lower elapsed_ms.
What the GitLab job sees
model-verdict:
image: curlimages/curl:latest
variables:
SIDECAR_URL: "http://your-sidecar-host:8080/v1/verdict"
script:
- curl --fail-with-body -X POST "$SIDECAR_URL" -H 'Content-Type: application/json' -d '{"prompt":"Does this error look like a flake?"}' -o model_verdict.json
- cat model_verdict.json
artifacts:
paths:
- model_verdict.json
when: always
The CI job never sees the provider. It only sees the sidecar's normalized verdict.
The cache is a safety valve, not a truth source
I cache by SHA-256 of the prompt. Same prompt, same key.
The cache prevents identical reruns from burning quota. But it also means an outage upstream can look healthy if the sidecar returns the last good answer.
For my use, that is acceptable. For a fail-closed freshness requirement, set cacheTTL to a very low value or skip the cache. The trade-off is explicit.
Failure rules
| Sidecar outcome | HTTP status | What it means |
|---|---|---|
| Correct JSON, non-empty text, upstream | 200 |
Fresh verdict |
| Same prompt within TTL |
200 with cached:true
|
Reused verdict |
| Non-200 from provider, empty text, or bad JSON | 502 |
Upstream unusable |
| Upstream timeout or unreachable | 502 |
No verdict |
| Empty prompt or non-POST | 400 |
Caller error |
This table is the whole contract. CI only needs to check the curl exit code.
What this sidecar does not do
- It does not judge answer quality.
- It does not pick the best model.
- It does not protect sensitive input.
- It does not authenticate the caller in the sample above.
- It does not make a slow free route fast.
It only stops CI from repeating raw model calls through a poorly shaped client.
Who should skip this pattern
- Teams with strict freshness: a short cache can hide provider drift.
- Sensitive prompts: do not send those through an unauthenticated local endpoint.
- Production decision gates: this is a deterministic smoke check, not a model evaluation.
- Multi-region setup: a single sidecar adds a single point of failure.
For those cases, keep the model call in a proper eval harness with auth, privacy controls, and explicit freshness.
The local contract is the point
The sidecar is not sophisticated. It is a narrow contract boundary between a repeatable CI job and a non-deterministic free route.
If you already have a free server slot, deploy the sidecar next to your route and let CI call /v1/verdict instead of the provider. The model stays behind one door. CI stays small.
Top comments (0)