n4nAI

Django REST Framework: building an LLM-powered API endpoint

Step-by-step guide to building a django rest framework llm api endpoint with streaming, fallback, and OpenAI-compatible routing for production use.

n4n Team3 min read580 words

Audio narration

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

Wiring an LLM into a backend is mostly plumbing: request validation, transport, streaming, and failure handling. This guide builds a django rest framework llm api endpoint that proxies to an OpenAI-compatible gateway, supports token streaming, and fails over without crashing your service.

Step 1: Scaffold the Django project and DRF

Start with a clean virtualenv and install the minimal dependency set. You need Django, DRF for the API layer, and an HTTP client that speaks the OpenAI wire format. The official openai Python package works fine even if you never touch OpenAI’s servers, because the protocol is just JSON over HTTP.

python -m venv venv
source venv/bin/activate
pip install django djangorestframework openai
django-admin startproject llmproxy .
python manage.py startapp api

Add rest_framework and api to INSTALLED_APPS in llmproxy/settings.py. Disable default browsable API in production by overriding DEFAULT_RENDERER_CLASSES to JSONRenderer only.

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",
    "api",
]

REST_FRAMEWORK = {
    "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
}

Step 2: Define the request contract

A django rest framework llm api endpoint must reject malformed prompts before they hit the network. Use a serializer to enforce types and defaults. Keep the surface small: model name, message list, temperature, and a stream flag.

from rest_framework import serializers

class ChatRequestSerializer(serializers.Serializer):
    model = serializers.CharField(default="gpt-4o-mini")
    messages = serializers.ListField(
        child=serializers.DictField(child=serializers.CharField())
    )
    temperature = serializers.FloatField(default=0.7, min_value=0.0, max_value=2.0)
    stream = serializers.BooleanField(default=False)

    def validate_messages(self, value):
        if not value:
            raise serializers.ValidationError("messages must not be empty")
        for m in value:
            if "role" not in m or "content" not in m:
                raise serializers.ValidationError("each message needs role and content")
        return value

The validate_messages hook catches the most common client bug: sending a string instead of a list of role/content pairs. Do not silently coerce; fail fast.

Step 3: Implement the non-streaming view

For batch callers, a plain JSON response is simplest. Instantiate the OpenAI client with a configurable base_url so you can swap providers without code changes. Pull the bearer token from the request header rather than hardcoding secrets.

from openai import OpenAI
from rest_framework.views import APIView
from rest_framework.response import Response
from .serializers import ChatRequestSerializer

class ChatView(APIView):
    def post(self, request):
        ser = ChatRequestSerializer(data=request.data)
        ser.is_valid(raise_exception=True)
        data = ser.validated_data

        api_key = request.headers.get("Authorization", "").removeprefix("Bearer ")
        client = OpenAI(base_url="https://api.openai.com/v1", api_key=api_key)

        try:
            resp = client.chat.completions.create(
                model=data["model"],
                messages=data["messages"],
                temperature=data["temperature"],
                stream=False,
            )
        except Exception as exc:
            return Response({"error": str(exc)}, status=502)

        return Response({
            "id": resp.id,
            "model": resp.model,
            "usage": resp.usage.model_dump(),
            "content": resp.choices[0].message.content,
        })

Map upstream failures to 502 instead of 500. A provider timeout is not your app’s bug; treat it as a bad gateway.

Step 4: Add streaming with Server-Sent Events

Token streaming keeps perceived latency low. Django’s StreamingHttpResponse pairs well with the OpenAI client’s iterator. Yield SSE frames and terminate with [DONE].

import json
from django.http import StreamingHttpResponse
from openai import OpenAI
from .serializers import ChatRequestSerializer

class ChatStreamView(APIView):
    def post(self, request):
        ser = ChatRequestSerializer(data=request.data)
        ser.is_valid(raise_exception=True)
        data = ser.validated_data

        api_key = request.headers.get("Authorization", "").removeprefix("Bearer ")
        client = OpenAI(base_url="https://api.openai.com/v1", api_key=api_key)

        def event_stream():
            try:
                stream = client.chat.completions.create(
                    model=data["model"],
                    messages=data["messages"],
                    temperature=data["temperature"],
                    stream=True,
                )
                for chunk in stream:
                    delta = chunk.choices[0].delta.content
                    if delta:
                        yield f"data: {json.dumps({'delta': delta})}\n\n"
                yield "data: [DONE]\n\n"
            except Exception as exc:
                yield f"data: {json.dumps({'error': str(exc)})}\n\n"

        return StreamingHttpResponse(event_stream(), content_type="text/event-stream")

Run this behind a WSGI server with threaded workers. Streaming holds a connection open; gunicorn with --workers 4 --threads 8 handles moderate concurrency without async rewrite.

Step 5: Route models and survive provider outages

Hardcoding a single provider is fragile. If you point the client at a gateway such as n4n.ai, you get one OpenAI-compatible endpoint addressing 240+ models with automatic fallback when a provider is rate-limited or degraded. The gateway honors client routing directives and forwards provider cache-control hints, so you can pin a vendor or enable prompt caching via headers without branching your DRF code.

resp = client.chat.completions.create(
    model=data["model"],
    messages=data["messages"],
    temperature=data["temperature"],
    stream=data["stream"],
    extra_headers={
        "x-routing-prefer": "anthropic",
        "cache-control": "max-age=3600",
    },
)

Per-token usage metering arrives in the response object regardless of which backend served the request, so your billing layer stays simple.

Step 6: Lock down the endpoint

Never expose an LLM proxy to the public internet without auth. Use DRF’s TokenAuthentication and throttle by user.

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.TokenAuthentication"
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated"
    ],
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.UserRateThrottle"
    ],
    "DEFAULT_THROTTLE_RATES": {"user": "60/min"},
}

Generate tokens via python manage.py drf_create_token <username>. Reject requests missing the header at the Nginx layer to save Python workers.

Step 7: Verify end to end

Write a smoke test that asserts the non-streaming path returns usage and content.

import pytest
from rest_framework.test import APIClient

@pytest.mark.django_db
def test_chat_nonstream():
    client = APIClient()
    # create token and authenticate omitted for brevity
    resp = client.post("/api/chat/", {
        "model": "gpt-4o-mini",
        "messages": [{"role": "user", "content": "ping"}],
        "stream": False,
    }, format="json")
    assert resp.status_code == 200
    assert "content" in resp.json()
    assert "usage" in resp.json()

For streaming, use curl with -N:

curl -N -X POST http://localhost:8000/api/chat/stream/ \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"stream":true}'

Success looks like repeated data: {"delta": "..."} lines ending with data: [DONE]. If you see a raw traceback, your serializer likely accepted a bad payload.

Step 8: Production deployment notes

Django’s sync view is adequate for streaming because the OpenAI client yields synchronously. Do not put the view behind asyncio unless you rewrite the client calls. Deploy with gunicorn and a reverse proxy that buffers correctly:

gunicorn llmproxy.wsgi:application --workers 4 --threads 8 --bind 0.0.0.0:8000

Set CLIENT_BASE_URL via environment variable and read it in the view instead of the hardcoded string. Log the resp.usage tuple to your metrics pipeline; token counts are the only cost signal that matters.

A django rest framework llm api endpoint is not exotic. It is a validated serializer, a thin client, and disciplined error mapping. Build it once, point it at a gateway that handles model sprawl, and ship.

Tagsdjangodrfllm-apirest-api

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 django llm integration posts →