Every team that wires tests to a live model eventually watches CI rot into a 40-minute slog of rate-limit errors and nondeterministic failures. Keeping CI fast with LLM API test calls means treating the model as a slow, expensive I/O boundary that you stub, record, or shrink—not a service you trust in every run.
Step 1: Classify tests by real model dependency
Before changing tooling, map your suite. A prompt-builder unit test has no business hitting an API. An integration test that validates JSON parsing against a real response does. A weekly eval that checks output quality needs a live model but not in every commit.
Keeping CI fast with LLM API test calls starts with refusing to pay for model latency where you aren’t testing the model. Split your pytest targets into a clear tree:
tests/
unit/ # no network, fake client
integration/ # recorded or cached responses
eval/ # live small model, nightly only
Tag them so you can run fast subsets in pre-commit:
# tests/integration/test_parse.py
import pytest
@pytest.mark.integration
def test_extract_json(llm_replay):
out = llm_replay.complete("Return JSON: {name: 'x'}")
assert '"name"' in out
Run only unit in the primary CI stage: pytest tests/unit -q. The goal is to make the commit gate independent of third-party uptime.
Step 2: Mock the client at the boundary
Define a narrow interface and inject a fake in tests. This removes all network calls from logic tests and makes them deterministic.
# llm/client.py
from abc import ABC, abstractmethod
class LLMClient(ABC):
@abstractmethod
def complete(self, prompt: str) -> str: ...
class FakeLLMClient(LLMClient):
def complete(self, prompt: str) -> str:
# Canned text shaped like real output
return '{"name": "x"}'
# conftest.py
import pytest
from llm.client import FakeLLMClient
@pytest.fixture
def llm_client():
return FakeLLMClient()
Any test using llm_client runs in microseconds. This is the single highest-leverage change for keeping CI fast with LLM API test calls, because it deletes the variable you can’t control.
Step 3: Record and replay real responses for integration tests
For tests that must exercise the real request shape, call the API once locally, save the response, and replay it in CI. Use a hash of the normalized prompt as the key.
# tests/integration/replay.py
import json, hashlib, os
from llm.client import LLMClient
class ReplayClient(LLMClient):
def __init__(self, mode="replay", record_dir="tests/fixtures"):
self.mode = mode
self.record_dir = record_dir
def complete(self, prompt: str) -> str:
key = hashlib.sha256(prompt.strip().encode()).hexdigest()
path = os.path.join(self.record_dir, f"{key}.json")
if self.mode == "replay":
with open(path) as f:
return json.load(f)["response"]
# record mode: hit real API, persist, return
real = real_client.complete(prompt)
with open(path, "w") as f:
json.dump({"response": real}, f)
return real
Generate fixtures with mode="record" on a developer machine, then commit the JSON. CI never leaves the repo. Normalize prompts (strip whitespace, sort JSON keys) before hashing to avoid cache misses from insignificant changes.
Verify replay works
Block network and run integration offline:
pytest tests/integration --disable-socket
They should pass using only fixtures.
Step 4: Use a tiny model for the few live smoke tests
Some paths—auth headers, streaming parsing, tool-call shape—need a live call. Use the smallest, cheapest model your provider offers and keep the prompt trivial.
# CI env
export LLM_MODEL=text-babbage-002
export LLM_BASE_URL=https://api.openai.com/v1
import os
from openai import OpenAI
def live_client():
return OpenAI(api_key=os.environ["LLM_API_KEY"]).chat.completions
def test_smoke():
resp = live_client().create(
model=os.environ["LLM_MODEL"],
messages=[{"role": "user", "content": "say OK"}],
timeout=5,
)
assert resp.choices[0].message.content.strip().upper() == "OK"
Run these in a separate stage with a concurrency limit of 1. They are a canary, not a test of intelligence.
Step 5: Put a caching proxy in front of the API
If you must call live models in CI, cache identical prompts. An OpenAI-compatible gateway such as n4n.ai addresses 240+ models behind one endpoint, honors client cache-control hints, and provides automatic fallback when a provider is degraded—wire it as your base URL and repeated prompts cost zero extra latency after the first hit. It also forwards routing directives, so you can pin a specific provider per test type.
import os
from openai import OpenAI
client = OpenAI(
base_url=os.getenv("LLM_BASE_URL", "https://api.n4n.ai/v1"),
api_key=os.getenv("LLM_API_KEY"),
timeout=5,
max_retries=1,
)
def cached_complete(prompt: str) -> str:
resp = client.chat.completions.create(
model=os.getenv("LLM_MODEL", "gpt-3.5-turbo"),
messages=[{"role": "user", "content": prompt}],
extra_headers={"cache-control": "max-age=86400"},
)
return resp.choices[0].message.content
This step alone can cut repeated eval suites from minutes to seconds, and the fallback hides transient provider 429s.
Step 6: Enforce timeout and retry budgets
LLM endpoints fail weirdly. Set aggressive timeouts and disable SDK auto-retry storms.
client = OpenAI(timeout=5, max_retries=1)
In pytest, fail the test if it exceeds a budget:
import time
def test_within_budget():
start = time.time()
cached_complete("hi")
assert time.time() - start < 5
A test that hangs on a model is a CI resource leak; kill it fast.
Step 7: Limit parallelism to avoid rate limits
pytest-xdist helps, but unbounded concurrency triggers 429s that look like bugs. Cap workers and separate stages in your pipeline config.
# .github/workflows/ci.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Unit
run: pytest tests/unit -n 2
- name: Integration (replay)
run: pytest tests/integration --disable-socket
- name: Live smoke
if: github.ref == 'refs/heads/main'
run: pytest tests/eval -n 1
If you use the gateway from Step 5, its fallback masks transient provider limits, but you still pay latency, so keep live concurrency low.
Step 8: Meter token usage to catch regressions
Log usage on every live call. A sudden jump in prompt tokens means a test fixture or prompt template ballooned.
resp = client.chat.completions.create(...)
print(f"tokens={resp.usage.total_tokens}")
If you route through a gateway with per-token usage metering, export those metrics to your CI dashboard. Keeping CI fast with LLM API test calls also means keeping it cheap—unbounded eval loops will quietly drain budget.
Step 9: Verify success
After applying Steps 1–8, measure the primary gate:
pytest tests/unit tests/integration --durations=10
Success criteria:
- Unit + integration stage completes under 2 minutes with no network egress.
- Live smoke stage runs <30s and only on main branch or nightly.
- Token spend in CI drops >90% versus the unmodified suite (compare gateway metering reports).
If those hold, you’ve solved the problem. If integration tests still hit the wire, revisit Step 3’s replay hash—prompt normalization prevents cache misses. Treat the model like a database: seed it, fake it, or cache it. Never let it dictate your commit cycle.