Skip to content

How to gate your CI/CD on LLM output quality

· 9 min read

Your pipeline already blocks a merge when a unit test fails. The LLM feature in the same codebase has no such gate. A model that quietly got worse ships like nothing happened, and the first you hear of it is a user telling you the output looks off.

The fix is the idea you already trust for code, pointed at output quality. You score the model’s answers against a fixed standard on a schedule, and you let the pipeline read that score and act on it, the same way it reads a test result and acts on it.

The LLM in your pipeline has no test

Everything else in your build is checked. Unit tests assert the function returns what it should. The linter asserts the style holds. The type checker asserts the shapes line up. Each one fails the build the moment the code stops meeting it.

The model call has no fixed output to assert on. Ask it to turn a customer’s message into a structured record and the exact wording changes every time, so there is nothing a normal test can pin down. It sails through the pipeline unchecked, and “the model got worse” becomes a thing a user reports rather than a thing your build catches.

A benchmark is an assertion you can actually make

You cannot assert the exact output. You can assert that a quality score stays above a line you set.

That is what a benchmark gives you. You take one real task, write down what a good answer looks like, and score several models against it on a schedule. The score is a number, and a number is something your pipeline can read and compare. Here is a real one. The task is turning raw meeting notes into a structured action-item table, the kind of feature that sits in a lot of products.

The task being scored

Turn meeting notes into a table of action items, one row each: OWNER, ACTION, DUE DATE, PRIORITY. Resolve relative dates (the notes are dated Monday March 10, so “by Wednesday” becomes Mar 12). Mark something High priority only if the notes call it urgent.

Four models ran the same task, scored against the same written answer, with the pass line set at 60 out of 100.

ModelQuality /100CostClears the bar
GPT-4.1 Mini90$0.00022yes
Grok 4.563$0.00195yes
GPT-4o Mini62$0.00011yes
GPT-4.1 Nano45$0.00005no

The cheapest passing model costs a fraction of the highest scorer, and one model misses the bar outright.

GPT-4.1 Nano scored 45 and fell below the line, and the diagnostic says why. It read “by Wednesday” as March 15 instead of March 12, and dropped one action item entirely. That is a real regression you would want a build to catch. The other three clear the bar, and the gap between them is the whole reason to look past the top score, which is the next section. For how a benchmark works in full, and how to tell your own change from the provider’s, see how to monitor LLM quality over time.

Gate the build on it

With a score your pipeline can read, the gate is short. The workflow pulls the latest benchmark run over the API, takes the best model’s score, and fails the build if it is under your line.

There is no one-click product to buy here. You build the gate with the API, and the workflow below is a starting point you adapt. First, the one piece of setup.

The workflow authenticates with an LLM Prover developer key. Store it as a repository secret so the pipeline can read it and it never shows in a log.

  1. Mint a key on the Developers page in your dashboard.
  2. In your repo, open Settings, then Secrets and variables, then Actions, then New repository secret.
  3. Name it LLMPROVER_API_KEY and paste the key as the value.

The workflow reads it as ${{ secrets.LLMPROVER_API_KEY }}, which GitHub injects and masks in all output.

# .github/workflows/llm-quality-gate.yml
# Fails the build if your benchmark's top model dropped below your quality bar.
# Secrets required (Settings -> Secrets and variables -> Actions):
#   LLMPROVER_API_KEY  - a developer key from /developer/ (format: llmprover_...)
name: LLM Quality Gate
on:
  pull_request:        # gate every PR
  workflow_dispatch:   # and allow manual runs
jobs:
  quality-gate:
    runs-on: ubuntu-latest
    env:
      # The one line you change: your minimum acceptable quality score (0-100).
      QUALITY_BAR: "60"
      # Your scheduled benchmark's suite id (from the dashboard URL or the API).
      SUITE_ID: "your_suite_id_here"      # you set this
      API_BASE: "https://api.llmprover.pysolvr.com"
    steps:
      - name: Fetch the latest benchmark run and check the bar
        run: |
          set -euo pipefail
          # Pull the most recent run for this benchmark (newest first, just 1).
          run=$(curl -sS -f \
            -H "Authorization: Bearer ${{ secrets.LLMPROVER_API_KEY }}" \
            "${API_BASE}/benchmarks/${SUITE_ID}/runs?limit=1")
          # The best model's quality score for that run (winner by quality).
          # model_metrics[] carries provider/model/quality_score/cost_usd per model.
          top=$(echo "$run" | jq -r '
            .runs[0].model_metrics
            | max_by(.quality_score)
            | "\(.provider)/\(.model) \(.quality_score)"')
          score=$(echo "$top" | awk '{print $2}')
          echo "Top model this run: $top"
          echo "Quality bar: $QUALITY_BAR"
          # Fail the build if the best model is under the bar.
          # Swap 'exit 1' for 'echo' first if you want a soft (warn-only) gate.
          awk -v s="$score" -v b="$QUALITY_BAR" 'BEGIN { exit !(s+0 >= b+0) }' \
            && echo "PASS: $score >= $QUALITY_BAR" \
            || { echo "FAIL: $score < $QUALITY_BAR -- model quality regressed"; exit 1; }

The only line you touch is QUALITY_BAR. Everything below it is plumbing. The request fetches the most recent run, and the check reads the top model’s score and compares it to your line. While you are still calibrating where the bar should sit, swap the exit 1 for a plain echo so the build stays green and the warning just shows in the log. Once you trust the line, let it fail the build.

Warning: Set the bar from your own passing runs, not a round number you like. A line nobody can clear turns every build red and trains the team to ignore it.

This tests output, not your test suite

One point to head off a mix-up. This is about scoring your AI feature’s output in CI. It is not about using an AI to write your unit tests. Different job, different tools. The gate here watches what your model produces for your users, and fails the build when that quality drops.

Put a quality gate in your pipeline

Score the same task on a schedule and read the result from CI over the API. Scheduled benchmarks and developer keys start on Pro.

See Pro

Let the pipeline pick the model, not you

Look back at the table. GPT-4.1 Mini scored highest at 90, but GPT-4o Mini cleared the same bar at 62 for a fraction of the cost, and Grok 4.5 scored about the same as GPT-4o Mini while costing roughly seventeen times as much. The top scorer is rarely the one you want running every request. The one you want is the cheapest model still above your bar.

That is a decision a human usually makes. Read the result, edit the config, redeploy. Once you trust the benchmark, that step is automatable too. The server already computes the cheapest model that cleared your bar and returns it as cheapest_acceptable, so the workflow reads the answer rather than working it out.

# .github/workflows/llm-auto-switch.yml
# Reads the benchmark, picks the CHEAPEST model that still clears your bar,
# and writes it into your app config. The pipeline chooses the model, not you.
# Prerequisite: the benchmark must have been created with a quality threshold
# (thresholds.quality_min) so the API computes winner.cheapest_acceptable.
name: LLM Auto-Switch Model
on:
  schedule:
    - cron: "0 6 * * 1"   # weekly, after your benchmark's scheduled run
  workflow_dispatch:
jobs:
  auto-switch:
    runs-on: ubuntu-latest
    env:
      SUITE_ID: "your_suite_id_here"      # you set this
      API_BASE: "https://api.llmprover.pysolvr.com"
    steps:
      - uses: actions/checkout@v4
      - name: Pick the cheapest model above the bar
        id: pick
        run: |
          set -euo pipefail
          run=$(curl -sS -f \
            -H "Authorization: Bearer ${{ secrets.LLMPROVER_API_KEY }}" \
            "${API_BASE}/benchmarks/${SUITE_ID}/runs?limit=1")
          # cheapest_acceptable = cheapest model that cleared the bar you set on
          # the benchmark. It is null if no model qualified -> don't switch.
          cheapest=$(echo "$run" | jq -r '.runs[0].winner.cheapest_acceptable // empty | "\(.provider)/\(.id)"')
          if [ -z "$cheapest" ]; then
            echo "No model cleared the bar this run -- keeping current model."
            echo "switch=false" >> "$GITHUB_OUTPUT"
          else
            echo "Cheapest model above the bar: $cheapest"
            echo "switch=true"  >> "$GITHUB_OUTPUT"
            echo "model=$cheapest" >> "$GITHUB_OUTPUT"
          fi
      - name: Write the chosen model into your config
        if: steps.pick.outputs.switch == 'true'
        run: |
          MODEL="${{ steps.pick.outputs.model }}"
          echo "Would set production model to: $MODEL"
          # --- you implement this ---------------------------------------
          # Write $MODEL wherever your app reads its model id from, e.g.:
          #   - sed/yq into a config file in this repo, then open a PR
          #   - update a parameter store / env var in your deploy system
          #   - commit the change and let your normal deploy pick it up
          # Keep it a PR, not a direct prod write, until you trust it.
          # --------------------------------------------------------------

The decision is the server’s; the workflow acts on it. The one part left to you is writing the chosen model into wherever your app reads it from, because that is specific to your stack. Keep it a pull request rather than a direct write to production, so a person still sees the change before it ships. You earn your way to this. Run the gate from the first section until you trust it holds, then let it start choosing.

Warning: This needs a quality bar set on the benchmark, or cheapest_acceptable comes back empty and the workflow has nothing to pick. If no model clears the bar on a given run, it keeps the current model rather than switching to something worse.

Dashboard, API, or your agent

The same benchmark result is reachable three ways, and which one you reach for is just how you work. In the dashboard you read it yourself. Over the API, your CI reads it, which is everything above. And over MCP, an agent reads it in the moment, as a step while it gets a release ready, pulling the cheapest model above your bar on demand. Same number underneath, three front doors. That last one is what agentic optimization looks like in practice.

The gate makes the same promise your test suite already makes. Nothing ships that fails the bar. You set the task and the line once, the pipeline holds it, and when you are ready it picks the model too.

evaluation benchmarking ci-cd regression-testing api