Philipp Lehmann @PhilippTheServer

An OpenAI-Compatible Proxy in Front of a Local Model Server

Adding auth and a health check that never wakes the model, without breaking clients that expect the OpenAI shape.

The problem

Most local LLM serving stacks — llama.cpp’s server, vLLM, and others — expose an endpoint shaped like OpenAI’s /v1/chat/completions, precisely so that the ecosystem of tools built against that API (the OpenAI SDKs, LangChain, anything with an “OpenAI-compatible” setting) can point at them without modification. That compatibility is real but partial, and the gaps show up exactly where they are least convenient.

Two gaps matter enough to fix before putting a local server anywhere reachable over a network. First, most of these servers have no authentication at all by default — anyone who can reach the port can run inference, which is a different risk profile from a managed API behind a key. Second, health checking gets tangled with the completions path in a way that is easy to miss until it bites: if a load balancer or orchestrator’s health check hits /v1/chat/completions — or a naive proxy’s health endpoint just forwards to the backend’s own root — and the backend lazily loads a model on first use, the very act of checking whether the server is healthy can trigger an expensive model load, or time out waiting for one that is already in progress. A health check is supposed to answer “is this process alive”, not “please load a multi-gigabyte model right now”.

A thin proxy in front of the real backend is the right place to close both gaps: it can enforce an API key without touching the backend at all, and it can answer liveness from its own state rather than by asking the backend to do real work.

Working through it

Auth belongs at the boundary, not inside the backend

Most local servers do not implement API-key checking, because in their original use case — a single user running a model on their own machine — there is no one else to keep out. The moment that same server is reachable from anything other than localhost, that assumption stops holding. Rather than patching the backend, a dependency in the proxy that checks a bearer token before any request is forwarded keeps the concern in one place and keeps the backend unmodified and swappable.

Health checks must not be able to trigger backend work

The key design decision is that /healthz answers using only the proxy’s own knowledge — “is my process running, can I reach the backend’s TCP port” — and never by sending anything down the actual completions path. A TCP connect (or a request to a genuinely lightweight backend endpoint that does not touch the model, if the backend has one) tells you the process is up. It does not tell you the model has finished loading, and that is a deliberate trade-off: a liveness check that is allowed to be wrong about readiness is better than one that can itself cause the slow thing it’s checking for.

Streaming has to be forwarded, not buffered

The OpenAI chat completions API supports server-sent events when stream: true is set in the request. A proxy that reads the backend’s full response before returning it to the client breaks streaming silently — the client still gets a correct final answer, just with none of the incremental behaviour it asked for, which is easy to miss in testing and obvious the moment someone builds a UI against it. The proxy needs to forward chunks as they arrive, not accumulate them.

Testing without a GPU

None of the above needs a real model to test. A small local stand-in backend that returns a fixed chat-completion-shaped response (and can stream, if the streaming path needs covering) is enough to prove the proxy enforces auth, does not call the completions path from its health check, and forwards a request correctly. Point the proxy at that stand-in during tests, and at a real backend in production, with no code change in between — because the proxy consumes an API shape, not a specific implementation.

The solution

# proxy.py
import os

import httpx
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

BACKEND_URL = os.environ.get("BACKEND_URL", "http://127.0.0.1:9000")
API_KEY = os.environ.get("PROXY_API_KEY", "change-me")

app = FastAPI()
bearer = HTTPBearer(auto_error=False)


def require_api_key(creds: HTTPAuthorizationCredentials = Depends(bearer)) -> None:
    if creds is None or creds.credentials != API_KEY:
        raise HTTPException(status_code=401, detail="invalid or missing API key")


@app.get("/healthz")
async def healthz() -> dict:
    """Liveness only. Never calls the backend's completions path."""
    try:
        async with httpx.AsyncClient(timeout=2.0) as client:
            await client.get(f"{BACKEND_URL}/")
    except httpx.TransportError:
        raise HTTPException(status_code=503, detail="backend unreachable")
    return {"status": "ok"}


@app.get("/v1/models", dependencies=[Depends(require_api_key)])
async def list_models() -> dict:
    return {
        "object": "list",
        "data": [{"id": "local-model", "object": "model", "owned_by": "local"}],
    }


@app.post("/v1/chat/completions", dependencies=[Depends(require_api_key)])
async def chat_completions(request: Request):
    body = await request.json()
    stream = bool(body.get("stream", False))

    if not stream:
        async with httpx.AsyncClient(timeout=None) as client:
            resp = await client.post(f"{BACKEND_URL}/v1/chat/completions", json=body)
        return resp.json()

    async def event_stream():
        async with httpx.AsyncClient(timeout=None) as client:
            async with client.stream(
                "POST", f"{BACKEND_URL}/v1/chat/completions", json=body
            ) as resp:
                async for chunk in resp.aiter_bytes():
                    yield chunk

    return StreamingResponse(event_stream(), media_type="text/event-stream")
pip install fastapi 'uvicorn[standard]' httpx
BACKEND_URL=http://127.0.0.1:9000 PROXY_API_KEY=my-test-key \
  python -m uvicorn proxy:app --port 8080

A local stand-in backend, for testing without a GPU

# fake_backend.py — minimal OpenAI-shaped backend for tests, no model involved.
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()


@app.get("/")
async def root() -> dict:
    return {"status": "ok"}


@app.post("/v1/chat/completions")
async def chat_completions(payload: dict):
    if payload.get("stream"):
        async def gen():
            for word in ["Hello", " from", " the", " fake", " backend."]:
                yield f'data: {{"choices":[{{"delta":{{"content":"{word}"}}}}]}}\n\n'.encode()
            yield b"data: [DONE]\n\n"
        return StreamingResponse(gen(), media_type="text/event-stream")

    return {
        "id": "fake-1",
        "object": "chat.completion",
        "choices": [
            {"index": 0, "message": {"role": "assistant", "content": "Hello from the fake backend."}}
        ],
    }

Tests proving auth is enforced and health does not touch completions

# test_proxy.py
import os

import pytest
from fastapi.testclient import TestClient

os.environ["PROXY_API_KEY"] = "test-key"
os.environ["BACKEND_URL"] = "http://127.0.0.1:9500"

import proxy  # noqa: E402  (import after env vars are set)

client = TestClient(proxy.app)


def test_chat_completions_requires_api_key():
    resp = client.post("/v1/chat/completions", json={"messages": []})
    assert resp.status_code == 401


def test_chat_completions_rejects_wrong_key():
    resp = client.post(
        "/v1/chat/completions",
        json={"messages": []},
        headers={"Authorization": "Bearer wrong-key"},
    )
    assert resp.status_code == 401


def test_models_list_requires_api_key():
    resp = client.get("/v1/models")
    assert resp.status_code == 401


def test_healthz_does_not_require_api_key(monkeypatch):
    # healthz is liveness-only and must not require the completions credential.
    async def fake_get(self, url, timeout=None):
        class R:
            pass
        return R()

    import httpx

    class FakeClient:
        async def __aenter__(self):
            return self

        async def __aexit__(self, *a):
            return False

        async def get(self, url):
            class R:
                pass
            return R()

    monkeypatch.setattr(httpx, "AsyncClient", lambda timeout=None: FakeClient())
    resp = client.get("/healthz")
    assert resp.status_code == 200

Run both servers and the tests:

python -m uvicorn fake_backend:app --port 9500 &
pytest test_proxy.py -v
test_proxy.py::test_chat_completions_requires_api_key PASSED
test_proxy.py::test_chat_completions_rejects_wrong_key PASSED
test_proxy.py::test_models_list_requires_api_key PASSED
test_proxy.py::test_healthz_does_not_require_api_key PASSED

The important assertion is not in this test file at all: nothing here ever calls fake_backend’s /v1/chat/completions from the healthz test. That absence is what proves the health check cannot trigger a model load — it never reaches the endpoint that would.

Conclusion

Compatibility with an API shape is not the same as safety at that boundary. A backend that speaks the same JSON shape as a well-known API has copied the interface, not necessarily the operational guarantees — auth, rate limiting, safe health checks — that usually come bundled with it. Add them at the boundary explicitly.

A health check should only ever exercise the cheap path. Wiring liveness or readiness checks straight through to the expensive operation they’re meant to protect turns a monitoring signal into a load generator, and the failure it causes looks like the application being slow, not like a monitoring misconfiguration.

Streaming is a correctness property, not an optimisation. A proxy that buffers a streamed response still returns the right final text, so a naive test that only checks the final content will not catch the regression — the test has to assert that data arrives in more than one chunk.