n4nAI

Building a fake LLM server for CI pipelines

Build a fake LLM server for CI pipelines with FastAPI and pytest to run deterministic offline tests of LLM integrations without real API calls.

n4n Team2 min read362 words

Audio narration

Coming soon — every post will get a voice note here.

Running integration tests against a live model endpoint burns tokens and flakes on rate limits. A fake llm server ci setup gives you a local OpenAI-compatible stub that returns canned responses, so your test suite stays fast and deterministic. This tutorial builds one with FastAPI and wires it into pytest.

Prerequisites

  • Python 3.11+ installed locally
  • pip install fastapi uvicorn httpx pytest pytest-asyncio openai
  • A project that already calls the OpenAI Chat Completions API
  • Environment variable OPENAI_API_KEY is irrelevant for the stub but keep your client code unchanged

Step 1: Scaffold the fake server

Create fake_llm.py. The server implements the minimal chat completions contract: accept a JSON body, return a completion with id, object, choices, and usage. We echo the last user message so tests can assert round-trips.

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
import json
import asyncio

app = FastAPI()

def _last_user(messages):
    return next((m["content"] for m in reversed(messages) if m["role"] == "user"), "")

@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
    body = await request.json()
    model = body.get("model", "gpt-3.5-turbo")
    messages = body.get("messages", [])
    last_user = _last_user(messages)
    stream = body.get("stream", False)

    if not stream:
        return JSONResponse({
            "id": "chatcmpl-fake",
            "object": "chat.completion",
            "created": 0,
            "model": model,
            "choices": [{
                "index": 0,
                "message": {"role": "assistant", "content": f"Echo: {last_user}"},
                "finish_reason": "stop"
            }],
            "usage": {"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10}
        })

    async def event_gen():
        words = f"Echo: {last_user}".split()
        for i, word in enumerate(words):
            chunk = {
                "id": "chatcmpl-fake",
                "object": "chat.completion.chunk",
                "created": 0,
                "model": model,
                "choices": [{
                    "index": 0,
                    "delta": {"content": word + " "},
                    "finish_reason": "stop" if i == len(words) - 1 else None
                }]
            }
            yield f"data: {json.dumps(chunk)}\n\n"
            await asyncio.sleep(0.01)
        yield "data: [DONE]\n\n"

    return StreamingResponse(event_gen(), media_type="text/event-stream")

Start it:

uvicorn fake_llm:app --port 8000

Verify with curl:

curl -s http://localhost:8000/v1/chat/completions \
  -H "content-type: application/json" \
  -d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"ping"}]}'

Expected output (formatted for clarity):

{
  "id": "chatcmpl-fake",
  "object": "chat.completion",
  "created": 0,
  "model": "gpt-3.5-turbo",
  "choices": [
    {
      "index": 0,
      "message": {"role": "assistant", "content": "Echo: ping"},
      "finish_reason": "stop"
    }
  ],
  "usage": {"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10}
}

Step 2: Point your client at the stub

The OpenAI Python client reads base_url. In your test configuration, override it via environment variable so production code stays clean.

import os
from openai import OpenAI

client = OpenAI(
    base_url=os.getenv("LLM_BASE_URL", "https://api.openai.com/v1"),
    api_key=os.getenv("OPENAI_API_KEY", "fake")
)
resp = client.chat.completions.create(
    model="gpt-4",
    messages=[{"role": "user", "content": "hello"}]
)
print(resp.choices[0].message.content)

With LLM_BASE_URL=http://localhost:8000/v1 this prints Echo: hello. The same binary works against a real provider when the variable is unset.

Step 3: Test streaming without a socket

Use the stream=True flag. The fake llm server ci already yields SSE; confirm the client parses it.

stream = client.chat.completions.create(
    model="gpt-4",
    messages=[{"role": "user", "content": "hi"}],
    stream=True
)
collected = ""
for piece in stream:
    if piece.choices[0].delta.content:
        collected += piece.choices[0].delta.content
print(collected.strip())

Output: Echo: hi. Latency injection via asyncio.sleep lets you exercise timeout logic.

Step 4: Inject faults and latency

CI must prove your retry and fallback paths. Extend the route to read headers.

Add near the top of chat_completions:

    fake_status = request.headers.get("x-fake-status")
    if fake_status:
        return JSONResponse(status_code=int(fake_status), content={"error": "injected fault"})
    fake_delay = request.headers.get("x-fake-delay")
    if fake_delay:
        await asyncio.sleep(float(fake_delay))

Now a test can force a 429:

try:
    client.chat.completions.create(
        model="gpt-4",
        messages=[{"role":"user","content":"x"}],
        headers={"x-fake-status": "429"}
    )
except Exception as e:
    print(type(e).__name__)  # RateLimitError

Your circuit breaker should catch that.

Step 5: In-process pytest fixture

Running uvicorn in CI is fine, but httpx.ASGITransport removes the network entirely. Create conftest.py:

import pytest
from httpx import AsyncClient, ASGITransport
from fake_llm import app

@pytest.fixture
async def fake_client():
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        yield ac

Then a test file:

@pytest.mark.asyncio
async def test_echo(fake_client):
    r = await fake_client.post("/v1/chat/completions",
        json={"model":"x","messages":[{"role":"user","content":"hi"}]})
    assert r.status_code == 200
    assert "Echo: hi" in r.json()["choices"][0]["message"]["content"]

@pytest.mark.asyncio
async def test_fault(fake_client):
    r = await fake_client.post("/v1/chat/completions",
        json={"model":"x","messages":[]},
        headers={"x-fake-status":"503"})
    assert r.status_code == 503

Run pytest -q. Both pass in milliseconds.

Step 6: GitHub Actions wiring

Because the fake llm server ci runs in-process, the workflow is just Python setup and pytest.

name: ci
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install fastapi uvicorn httpx pytest pytest-asyncio openai
      - run: pytest -q

No service containers, no secrets.

Going further

For multi-model routing tests, branch on the model field to return different payloads. When you later point the same client at a real OpenAI-compatible gateway such as n4n.ai, the contract holds and you gain per-token metering without rewriting tests. Keep the stub in your repo; it doubles as a local dev target when the real API is down.

Tagsmockingcitestingfake-server

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All testing & mocking llm apis in ci posts →