Skip to main content
Back to Blog
AI Testing
2026-08-07

LLM Eval Cost Budget CI Gate Without Sacrificing Quality

Build an LLM eval cost budget CI gate that measures candidate and judge usage, blocks runaway spend, and preserves quality with auditable thresholds.

LLM Eval Cost Budget CI Gate Without Sacrificing Quality

An LLM eval cost budget CI gate should fail a change when its measured or conservatively estimated evaluation cost exceeds an approved ceiling, while also failing when quality drops below required thresholds. Capture input and output tokens for candidate calls, evaluator calls, retries, and repeated samples; price them through a versioned catalog; aggregate them by scenario and suite; and publish the calculation as a CI artifact.

Cost cannot be the only gate. A prompt that returns empty strings is cheap, and a suite that silently skips difficult cases costs less. Pair the spending ceiling with dataset completeness, validity, and quality assertions. For pull requests, compare the candidate against a pinned baseline under equivalent conditions, then apply both absolute limits and regression limits.

This guide gives QA and test-automation engineers a concrete implementation pattern that works around promptfoo, DeepEval, Ragas, or a custom evaluator. Tool adapters may differ, but the gate's evidence remains the same: planned cases, executed calls, usage records, pricing provenance, quality results, and one reproducible decision.

Start with a budget boundary the team can explain

“Keep evals cheap” is not a testable requirement. Define which execution the budget covers and what unit is constrained. A pull-request smoke suite, nightly regression, release candidate, and experiment may have different ceilings because they answer different questions.

Gate scopePrimary purposeUseful cost boundaryQuality boundary
Pull requestFast regression feedbackPer run and per caseCritical-case pass plus aggregate floor
Nightly suiteBroader model and prompt coverageDaily suite ceilingSegment floors and trend comparison
Release candidateProduction readinessApproved release evaluation envelopeNo critical failures and strict quality floor
Prompt experimentCompare variantsPer variant and total experimentStatistical or practical improvement rule
Monitoring sampleDetect deployed driftHourly or daily evaluation spendAlert threshold, not necessarily merge block

Express budgets in at least two units. Currency maps to finance, while tokens or model calls help engineering diagnose the source of a change. A provider price change can alter currency without changing workload. A prompt expansion can alter tokens even if a temporary discount hides the financial impact.

An example policy could say:

  • The PR suite must execute all 80 planned cases.
  • Every critical safety and tool-selection case must pass.
  • Aggregate task quality must not fall below the approved floor.
  • Candidate-model input and output tokens each have a ceiling.
  • Judge-model tokens and total calculated cost have separate ceilings.
  • Missing usage for a billable call fails closed.
  • The candidate must not exceed the baseline's cost by more than the reviewed tolerance.

The numbers belong in repository configuration reviewed by the owning team, not buried in a script. The agentic AI testing guide explains how broader agent behaviors fit together; the cost gate here controls one evaluation run without reducing the agent to a single score.

Inventory every call that belongs in the calculation

LLM evaluation pipelines often make more calls than the number of dataset rows. One case can invoke the candidate multiple times, trigger tools, retry transport failures, and call one or more judge models. Ragas-style or custom RAG metrics may use evaluator models and embeddings. A self-correction loop can add calls that are part of candidate behavior rather than test infrastructure.

Build a call taxonomy:

Call classInclude in spend?Report separately?Common source of surprise
Candidate generationYesYesMulti-turn steps and long system prompt
Candidate retryYes if billableYesTimeout policy hides repeated calls
LLM judgeYesYesSeveral criteria each call a judge
Reference-answer generationYes when generated during runYesGold data is not actually precomputed
Embedding for evaluationYes when billableYesPer-document or per-context expansion
Tool API unrelated to model billingTrack by its own policyYesSearch or browser services have separate quotas
Cached model resultZero incremental provider cost, if truly reusedYesCache accounting mistaken for fresh usage
Local deterministic assertionNo model costCountCheap checks accidentally omitted from completeness

The gate should record attempts, not only successful final answers. If a provider accepts and bills a request but the client loses the response, exact usage may be unavailable. Policy must say whether to use provider-reported usage later, a conservative estimate, or a maximum reservation. Treating it as zero rewards unreliable behavior and understates budget.

For agent evaluations, include tool-induced model turns. A task that calls an MCP tool, reads the result, and synthesizes an answer may involve several inference calls. The MCP servers test automation guide is useful for testing the tool boundary itself. In this gate, attribute all candidate turns required to complete the eval case.

Define a normalized usage ledger

Different providers and frameworks expose usage in different result shapes. Normalize them into an append-only ledger before calculating the gate. Preserve raw provider usage in a protected artifact when policy allows, but make the decision from one reviewed schema.

export type UsageRecord = {
  runId: string;
  caseId: string;
  sample: number;
  role: 'candidate' | 'judge' | 'embedding' | 'reference';
  provider: string;
  model: string;
  attempt: number;
  inputTokens: number;
  outputTokens: number;
  cachedInputTokens?: number;
  status: 'completed' | 'failed' | 'estimated';
  source: 'provider' | 'framework' | 'estimate';
};

export type QualityRecord = {
  caseId: string;
  sample: number;
  metric: string;
  score: number;
  passed: boolean;
  critical: boolean;
};

The schema separates role and source. role explains why the call happened. source explains how trustworthy the usage number is. Keep model and provider because prices can differ even for similarly named models.

Do not silently coerce missing values to zero. Validate each record before aggregation:

export function validateUsage(record: UsageRecord): string[] {
  const errors: string[] = [];
  if (!record.caseId) errors.push('caseId is required');
  if (!record.model) errors.push('model is required');
  if (!Number.isInteger(record.inputTokens) || record.inputTokens < 0) {
    errors.push('inputTokens must be a non-negative integer');
  }
  if (!Number.isInteger(record.outputTokens) || record.outputTokens < 0) {
    errors.push('outputTokens must be a non-negative integer');
  }
  if (record.status === 'completed' && record.source === 'estimate') {
    errors.push('completed usage cannot be labeled only as an estimate');
  }
  return errors;
}

The final rule about estimates is an example governance choice. Some providers may not return usage in a synchronous response, so your organization might permit reconciled estimates. If so, represent that policy explicitly rather than pretending the provider supplied exact data.

Store dataset version, prompt or agent revision, evaluator configuration hash, and pricing-catalog revision at run level. A ledger without provenance can calculate a number but cannot make a later comparison fair.

Price usage through a pinned catalog

Never scatter model prices across test code. Maintain a small catalog with effective date, currency, units, and source URL or internal approval reference. Update it in a dedicated review. Prices change, providers may distinguish input, output, cached input, or other categories, and some hosting arrangements use negotiated rates.

{
  "catalogRevision": "2026-08-07-reviewed",
  "currency": "USD",
  "models": {
    "provider-a/model-candidate": {
      "inputPerMillion": 1.25,
      "outputPerMillion": 5.0,
      "cachedInputPerMillion": 0.25,
      "source": "approved-finance-reference"
    },
    "provider-b/model-judge": {
      "inputPerMillion": 2.0,
      "outputPerMillion": 8.0,
      "source": "approved-finance-reference"
    }
  }
}

Those names and rates are intentionally fictional examples, not current provider facts. Populate the real catalog from official pricing documentation or the organization's contract on the day it is reviewed. Do not copy sample values into a production gate.

Calculate with integer-friendly care. Floating-point arithmetic is usually adequate for a CI estimate when rounding happens only for display, but keep unrounded values for comparison and state the rounding rule.

type Price = {
  inputPerMillion: number;
  outputPerMillion: number;
  cachedInputPerMillion?: number;
};

export function costOf(record: UsageRecord, price: Price): number {
  const cached = record.cachedInputTokens ?? 0;
  const uncachedInput = Math.max(0, record.inputTokens - cached);
  const cachedRate = price.cachedInputPerMillion ?? price.inputPerMillion;

  return (
    (uncachedInput * price.inputPerMillion) / 1_000_000 +
    (cached * cachedRate) / 1_000_000 +
    (record.outputTokens * price.outputPerMillion) / 1_000_000
  );
}

Confirm whether a provider's reported input total already includes cached tokens before subtracting. Normalization adapters should encode that documented meaning and carry adapter tests with representative provider fixtures. Getting cached-token semantics wrong can double count or undercount a large prompt.

Gate tokens, calls, and currency independently

A single dollar ceiling loses diagnostic power. Use multiple related limits so the failure points to its source. Candidate output growth suggests termination or verbosity issues. Judge growth suggests evaluator changes. Call-count growth suggests retries, extra samples, or agent loops. Currency growth with flat tokens suggests price or model-routing changes.

schemaVersion: 1
suite: pr-agent-smoke
completeness:
  plannedCases: 80
  requiredCriticalCases: 12
quality:
  minimumAggregateScore: 0.82
  allowCriticalFailures: 0
cost:
  maximumTotalUsd: 4.50
  maximumCandidateInputTokens: 1200000
  maximumCandidateOutputTokens: 180000
  maximumJudgeTokens: 500000
  maximumBillableCalls: 500
regression:
  maximumCostIncreaseRatio: 0.15

This YAML is an illustrative project policy, not configuration for promptfoo, DeepEval, Ragas, GitHub Actions, or another external tool. Parse it in your gate script and validate it with a schema. Choose real values from measured runs and business constraints.

Absolute and relative limits answer different questions. An absolute ceiling prevents unacceptable total spend. A regression ratio catches a smaller suite suddenly becoming less efficient. Use both, but handle tiny baselines carefully because a few cents can create a huge percentage. A minimum absolute delta before ratio enforcement can make the rule more meaningful if it is documented.

LimitDetectsCan miss
Maximum total currencyFinance envelope breachWhich stage caused it
Candidate input tokensPrompt/context expansionProvider price change
Candidate output tokensRunaway generationExpensive fixed input
Judge tokensEvaluator overheadCandidate inefficiency
Billable callsRetry or loop explosionOne enormous call
Cost increase from baselineRegression relative to known stateBoth baseline and candidate being too costly

Report all limits even if the first one fails. Engineers should see that a change exceeded output tokens and judge cost, not fix one only to discover the next on a second CI run.

Pair cost ceilings with quality and completeness floors

Cost optimization creates a dangerous incentive if quality is outside the gate. The decision should fail when any critical quality rule fails, aggregate performance drops too far, or planned cases do not execute.

A straightforward evaluator produces a list of violations:

type GateInput = {
  plannedCaseIds: string[];
  usage: UsageRecord[];
  quality: QualityRecord[];
  totalUsd: number;
  baselineUsd: number;
};

export function evaluateGate(input: GateInput, policy: Policy): string[] {
  const failures: string[] = [];
  const executed = new Set(input.quality.map((item) => item.caseId));
  const missing = input.plannedCaseIds.filter((id) => !executed.has(id));

  if (missing.length > 0) failures.push(`Missing eval cases: ${missing.join(', ')}`);
  if (input.quality.some((item) => item.critical && !item.passed)) {
    failures.push('At least one critical quality assertion failed');
  }
  if (input.totalUsd > policy.cost.maximumTotalUsd) {
    failures.push('Total calculated cost exceeded the absolute budget');
  }

  const increase = (input.totalUsd - input.baselineUsd) / input.baselineUsd;
  if (increase > policy.regression.maximumCostIncreaseRatio) {
    failures.push('Calculated cost regressed beyond the allowed ratio');
  }
  return failures;
}

Notice the escaped interpolation in the template-literal source above. In your implementation, also guard against a zero baseline and validate that every case has all required metrics, not just any quality record.

Completeness needs segment coverage. If the suite planned 80 rows but accidentally executed only the 60 easy FAQ cases, an aggregate score might improve and cost might fall. Compare executed case IDs and required metric names against the versioned manifest.

Quality aggregation should not let a large easy segment overwhelm a small critical one. Use per-segment floors for tool selection, refusal, retrieval, calculation, or whatever capabilities matter. Preserve case-level failures for diagnosis.

Compare candidate and baseline under equivalent conditions

A relative gate is fair only when candidate and baseline are evaluated on the same dataset, sample count, model routing, tools, judge configuration, and caching policy. If the baseline artifact came from last month with different prices or a different judge, reprice its usage through the current catalog and clearly identify which dimensions remain comparable.

There are two common baseline strategies:

StrategyAdvantageRiskControl
Run baseline and candidate in the same CI jobStrong environmental parityRoughly doubles evaluation spendUse on high-risk changes or sampled cases
Compare candidate to approved historical ledgerLower incremental costDrift in dataset, judge, or providerRequire matching provenance and reprice usage
Hybrid periodic refreshBalances spend and confidenceBaseline can age between refreshesSet maximum baseline age and refresh triggers

Keep baseline results immutable. A pull request should not overwrite the reference it is judged against. Promotion to baseline is a separate reviewed action after quality and cost approval.

Model nondeterminism complicates comparisons. Multiple samples can estimate variance but increase spend. Choose sample count from observed instability and risk, then include every sample in both completeness and cost. Do not keep the best answer but charge only one sample, or keep one answer while ignoring the others in quality aggregation.

For high-variance metrics, define a review band rather than repeatedly rerunning until green. Automatic reruns consume more money and bias the result toward a lucky pass. A result within the band can require human review while hard quality or cost breaches remain blocking.

Integrate framework output through adapters, not assumptions

promptfoo, DeepEval, and Ragas have different execution models and result objects. Use their documented outputs and extension points, then translate them into the normalized ledger and quality records. Do not make the gate scrape colorized terminal text when structured results or programmatic hooks are available in the version your repository pins.

A framework adapter has four responsibilities:

  1. Map a framework case to the versioned dataset case ID.
  2. Extract candidate, judge, embedding, and retry usage without double counting.
  3. Convert framework metrics into named quality records with preserved raw evidence.
  4. Mark absent or estimated usage explicitly.

Contract-test the adapter with checked-in fixtures that contain completed calls, failed calls, cached calls, missing usage, multiple judge metrics, and multiple samples.

describe('evaluation result adapter', () => {
  it('keeps candidate and judge usage separate', () => {
    const result = loadFixture('candidate-with-two-judges.json');
    const normalized = adaptEvaluationResult(result);

    expect(normalized.usage.filter((item) => item.role === 'candidate'))
      .toHaveLength(1);
    expect(normalized.usage.filter((item) => item.role === 'judge'))
      .toHaveLength(2);
    expect(normalized.quality.map((item) => item.metric)).toEqual(
      expect.arrayContaining(['task_success', 'groundedness']),
    );
  });

  it('rejects a billable result with missing usage', () => {
    const result = loadFixture('completed-without-usage.json');
    expect(() => adaptEvaluationResult(result)).toThrow(
      'Missing usage for completed billable call',
    );
  });
});

Pinning a framework version does not justify undocumented assumptions. When upgrading, regenerate fixture examples from documented output, review the adapter diff, and run a shadow calculation before allowing the new adapter to block merges.

Make the GitHub Actions job auditable

A CI job should separate evaluation from gate calculation. The evaluator writes raw or normalized results. The gate validates policy, calculates totals, prints a concise summary, uploads protected artifacts, and exits nonzero on failure. This makes a cost bug diagnosable without rerunning expensive model calls.

name: llm-evaluation

on:
  pull_request:

jobs:
  evaluate:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci
      - name: Run the repository evaluation adapter
        run: npm run eval:pr
        env:
          MODEL_API_KEY: ${{ secrets.MODEL_API_KEY }}
      - name: Calculate quality and cost gate
        run: npm run eval:gate -- --policy eval/policy/pr.yaml
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: llm-eval-report
          path: artifacts/llm-eval/
          retention-days: 14

The npm scripts belong to the repository, so their arguments are under your control. The action names and core inputs shown are standard GitHub Actions usage, but pinning policy may require immutable commit references in security-sensitive repositories.

Artifacts should include the normalized ledger, policy revision, price revision, case results, adapter version, and gate summary. Sanitize model inputs and outputs according to data policy. A full prompt may contain proprietary or personal data and should not automatically become a broadly downloadable CI artifact.

Use least-privilege workflow permissions and prevent untrusted pull-request code from receiving production model secrets. The exact workflow design depends on repository trust and GitHub's event security model. Cost control is irrelevant if a workflow exposes credentials.

Produce a failure report that identifies the lever

The gate output should let a reviewer decide whether to shorten context, cap output, reduce judge calls, fix retries, change sampling, or approve a new budget. A bare “cost exceeded” message creates churn.

Include this summary:

Report fieldWhy it matters
Planned, executed, skipped, and failed casesDetects cheap incomplete runs
Candidate input and output tokensSeparates context from generation growth
Judge and embedding usageExposes evaluation overhead
Calls and attempts by roleReveals loops and transport retries
Total using catalog revisionMakes currency calculation reproducible
Baseline delta in units and percentQuantifies regression
Top cases by cost and cost increasePoints to actionable scenarios
Quality by critical segmentPrevents savings from hiding regressions
Missing or estimated usage countCommunicates accounting confidence

Print more decimal precision for calculation than display. If the display rounds both 4.499 and 4.501 to 4.50, the pass/fail line still needs to explain which side of the exact threshold the run landed on.

Keep case-level token deltas. A prompt template change may add 500 tokens to all cases, while a retrieval defect may add 50,000 tokens to one case. Both can breach the same total but require different fixes.

Diagnose a sudden cost spike before raising the budget

A realistic failure looks like this: quality remains stable, but the PR gate reports 38 percent more cost. Reviewers are tempted to approve a larger ceiling because the prompt changed intentionally. First decompose the increase.

Use this sequence:

  1. Confirm the planned and executed case counts match the baseline.
  2. Compare candidate call counts and attempts.
  3. Compare input tokens per case, then output tokens per case.
  4. Separate candidate usage from judge and embedding usage.
  5. Confirm candidate, judge, and routing model identifiers are unchanged.
  6. Recalculate both runs with the same price catalog.
  7. Inspect cache hits and misses under the declared cache policy.
  8. Check whether retries or tool loops concentrated in a few cases.

If input tokens rose uniformly, inspect system prompt, tool schemas, retrieved context, conversation history, and repeated boilerplate. If output rose, inspect stop conditions, output schema enforcement, and agent step budget. If judge cost rose, a new metric may have introduced another LLM call per case.

If calls rose only on failures, the transport layer may be retrying requests that actually completed. Track attempt status and provider request identifiers where safe. Do not automatically remove retries, but make their spend and reliability impact visible.

What people get wrong is treating a currency delta as a prompt-only regression. Routing changes, updated prices, cache behavior, judge configuration, and repeated samples can all move it. The ledger exists to separate these causes.

Treat cache policy as part of test reproducibility

Caching can reduce CI spend substantially, but it changes the evidence. A cached candidate response proves deterministic assertions against an old output, not that the current provider and model produce it now. Decide which suites allow cache and record cache status for every call.

Use caches for evaluator-development loops, deterministic re-scoring, or unchanged cases when the goal permits it. Require fresh calls for release confidence, model-drift detection, or provider integration checks. Key the cache with every input that affects output: model identity, relevant generation settings, system and user content, tool schemas, retrieved context, and framework transformation.

Never count a cache hit as fresh provider spend. Also never discard it from completeness without explanation. Report “executed from cache” as its own category and enforce a maximum cache age or allowed suite purpose.

Judge caching needs the same rigor. If only the scoring prompt changed, reusing an old judge result would invalidate the experiment. If candidate output is cached but judge output is fresh, the ledger should show zero incremental candidate cost and current judge cost while retaining historical usage metadata separately.

Add controls for runaway agents before post-run accounting

A post-run ceiling tells you spending exceeded the budget after calls have already happened. Add preflight and runtime controls to limit exposure. Calculate an upper-bound plan from case count, sample count, configured step limits, and maximum token settings where those are documented. Refuse obviously oversized runs before sending requests.

Runtime tracking can stop scheduling new cases when the conservative remaining budget is exhausted. Preserve already-started calls and produce an incomplete-run failure, not a misleading cheap pass. For concurrent execution, reserve expected or worst-case cost before dispatch so many workers cannot all cross the ceiling simultaneously.

Control stageMechanismResult when triggered
PreflightEstimate maximum planned calls and tokensFail before provider traffic
DispatchReserve budget for each new caseStop scheduling further cases
Agent executionEnforce documented step and output limitsEnd runaway case with explicit failure
Post-runReconcile actual or estimated usageProduce final gate decision
Later billing reconciliationCompare provider records where availableAlert on accounting discrepancy

Do not invent a universal token ceiling for every case. Retrieval-heavy analysis and short classification have different legitimate profiles. Use per-segment or per-case allowances when one global limit would either waste money or reject valid work.

Evolve the budget through measured review

Budgets are engineering controls, not immutable laws. Raise a ceiling when added coverage or higher quality creates reviewed value, and lower it when optimization proves the suite can sustain a tighter bound. Require the policy change to show recent distributions, candidate-baseline comparison, and the cases driving the new envelope.

Track median and upper-percentile cost per segment over time, but do not use an aggregate trend as a substitute for hard critical checks. Annotate model changes, prompt migrations, dataset expansions, judge changes, and price-catalog revisions so charts remain interpretable.

When a price changes, update the catalog and reprice stored usage ledgers to see pure financial impact. This separates workload regression from market pricing. When a model changes tokenization or stops returning comparable usage fields, version the adapter and run non-blocking shadow reports before switching the gate.

The mature outcome is not the cheapest possible suite. It is a suite whose cost, coverage, and decision quality are understood well enough that a pull request cannot quietly multiply spend or buy savings by deleting evidence.

Frequently Asked Questions

Should the CI gate use estimated cost or provider billing data?

Use provider-reported usage when it is available and well defined, normalized through tested adapters and a reviewed price catalog. A CI gate usually cannot wait for a final invoice, so it calculates a near-real-time amount. For calls with missing usage, fail closed or apply a documented conservative estimate. Later reconciliation against provider records can detect systematic differences. Always label the source and catalog revision so reviewers know whether a number is measured, derived, or estimated.

How do I budget LLM judges separately from candidate models?

Tag every usage record with a role such as candidate, judge, embedding, or reference. Apply model-specific prices, then aggregate judge input, output, calls, and currency independently. Set a judge ceiling as well as a total ceiling because adding one scoring metric can multiply evaluator calls without changing candidate usage. Preserve metric names in quality records so reviewers can see which judge criteria produced value and which added cost without improving the release decision.

Can cached eval results count toward a pull-request gate?

They can when the gate's purpose and policy permit it, but report them as cached rather than fresh. A cache hit can prove that deterministic assertions or a new scoring function behave against a known output. It cannot prove current model behavior or provider availability. Include all output-affecting inputs in the cache key, enforce suitable age limits, and require fresh execution for model-drift, release, and provider-integration suites. Charge zero incremental provider cost only when reuse truly avoided a billable call.

What should happen when the budget stops a suite halfway?

Mark the run incomplete and fail the gate. Report which cases finished, which were never scheduled, the spend consumed, and the reservation rule that stopped dispatch. Do not calculate a passing aggregate from the cheaper partial dataset. Preserve completed quality and usage records for diagnosis, then reduce workload, fix the runaway segment, or approve a reviewed policy change. With concurrent workers, reserve budget before dispatch so already-started calls cannot collectively overshoot far beyond the intended ceiling.