n4nAI

Building a Django chatbot with GPT-4o and Claude

Hands-on Django tutorial to build a chatbot with GPT-4o and Claude using a unified provider abstraction, conversation models, and runnable view code.

n4n Team3 min read559 words

Audio narration

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

Building a django chatbot gpt-4o claude integration doesn’t have to mean maintaining two separate API clients and request shapes. This tutorial builds a minimal but realistic Django app that exposes one chat interface and dispatches to either model behind a small abstraction. You’ll get runnable code, schema, and expected outputs at each checkpoint.

Prerequisites

  • Python 3.11 or newer
  • Django 5.0+ (pip install django)
  • openai and anthropic Python packages (pip install openai anthropic)
  • API keys for OpenAI and Anthropic (set as OPENAI_API_KEY, ANTHROPIC_API_KEY)
  • Comfort with Django’s MVT basics

If you prefer a single endpoint, an OpenAI-compatible gateway such as n4n.ai addresses 240+ models behind one base URL and handles fallback when a provider is rate-limited, but the code below uses the native SDKs so you see the raw shapes.

Scaffold the project

django-admin startproject chatproject
cd chatproject
python manage.py startapp chat

Add 'chat' to INSTALLED_APPS in chatproject/settings.py. Run python manage.py migrate to confirm the DB works. You should see a clean migration with no errors.

Expected checkpoint output:

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  ...

Project layout after the next steps:

chatproject/
  chat/
    models.py
    views.py
    llm.py
    urls.py
    templates/chat/
  chatproject/
    settings.py
    urls.py

Data model

We store conversations and messages so the chatbot has memory across HTTP requests. Edit chat/models.py:

from django.db import models

class Conversation(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    model = models.CharField(max_length=64, default="gpt-4o")

class Message(models.Model):
    ROLE_CHOICES = [("user", "user"), ("assistant", "assistant")]
    conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name="messages")
    role = models.CharField(max_length=16, choices=ROLE_CHOICES)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

Migrate:

python manage.py makemigrations chat
python manage.py migrate

Expected tail of output:

Applying chat.0001_initial... OK

Why a unified abstraction matters

GPT-4o and Claude have different request schemas: OpenAI uses messages with a system role; Anthropic uses a top-level system string and expects strictly alternating user/assistant turns. If you scatter provider calls across views, you’ll duplicate role-normalization logic and make model switching painful. A single complete() function keeps views dumb and lets you swap providers or add a third without touching templates.

Unified client abstraction

Create chat/llm.py. This wraps both providers and returns plain text.

import os
from openai import OpenAI
import anthropic

OPENAI_MODELS = {"gpt-4o", "gpt-4o-mini"}
CLAUDE_MODELS = {"claude-3-5-sonnet-20240620", "claude-3-opus-20240229"}

def complete(model: str, history: list[dict]) -> str:
    if model in OPENAI_MODELS:
        client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
        resp = client.chat.completions.create(model=model, messages=history)
        return resp.choices[0].message.content
    elif model in CLAUDE_MODELS:
        client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
        # Anthropic expects alternating user/assistant; drop system for brevity
        msgs = [{"role": m["role"], "content": m["content"]} for m in history if m["role"] != "system"]
        resp = client.messages.create(model=model, max_tokens=1024, messages=msgs)
        return resp.content[0].text
    else:
        raise ValueError(f"Unsupported model: {model}")

If you route through a unified gateway, you could replace both branches with a single OpenAI(base_url="https://api.n4n.ai/v1") call and pass model strings like openai/gpt-4o or anthropic/claude-3-5-sonnet. The gateway forwards cache-control hints and meters per token, but the native split above is clearer for learning the differences.

Views and URLs

We’ll build a view that renders a conversation and accepts POSTed user input. In chat/views.py:

from django.shortcuts import render, get_object_or_404, redirect
from django.http import HttpResponseRedirect
from .models import Conversation, Message
from .llm import complete

def index(request):
    conversations = Conversation.objects.all().order_by("-created_at")
    return render(request, "chat/index.html", {"conversations": conversations})

def conversation_detail(request, pk):
    conv = get_object_or_404(Conversation, pk=pk)
    if request.method == "POST":
        user_text = request.POST["text"]
        Message.objects.create(conversation=conv, role="user", content=user_text)
        history = [{"role": m.role, "content": m.content} for m in conv.messages.all()]
        try:
            reply = complete(conv.model, history)
        except Exception as e:
            reply = f"Error: {e}"
        Message.objects.create(conversation=conv, role="assistant", content=reply)
        return HttpResponseRedirect(request.path)
    messages = conv.messages.all()
    return render(request, "chat/detail.html", {"conv": conv, "messages": messages})

def new_conversation(request):
    if request.method == "POST":
        model = request.POST.get("model", "gpt-4o")
        conv = Conversation.objects.create(model=model)
        return redirect("conversation_detail", pk=conv.pk)
    return render(request, "chat/new.html")

Wire URLs in chat/urls.py:

from django.urls import path
from . import views

urlpatterns = [
    path("", views.index, name="index"),
    path("new/", views.new_conversation, name="new_conversation"),
    path("c/<int:pk>/", views.conversation_detail, name="conversation_detail"),
]

Include them in the root chatproject/urls.py:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("chat.urls")),
]

Templates

Create chat/templates/chat/index.html:

<h1>Conversations</h1>
<a href="{% url 'new_conversation' %}">New</a>
<ul>
{% for c in conversations %}
  <li><a href="{% url 'conversation_detail' c.pk %}">{{ c.model }} — {{ c.created_at }}</a></li>
{% endfor %}
</ul>

new.html:

<form method="post">
  {% csrf_token %}
  <select name="model">
    <option value="gpt-4o">GPT-4o</option>
    <option value="claude-3-5-sonnet-20240620">Claude 3.5 Sonnet</option>
  </select>
  <button type="submit">Create</button>
</form>

detail.html:

<h1>{{ conv.model }}</h1>
<div id="messages">
{% for m in messages %}
  <p><strong>{{ m.role }}:</strong> {{ m.content }}</p>
{% endfor %}
</div>
<form method="post">
  {% csrf_token %}
  <textarea name="text" rows="3"></textarea>
  <button type="submit">Send</button>
</form>

Run and verify

Start the dev server:

python manage.py runserver

Open http://127.0.0.1:8000/new/, pick gpt-4o, submit. You’ll be redirected to /c/1/. Type “Explain Django middleware in one sentence.” Submit.

Expected assistant message (truncated):

assistant: Django middleware is a lightweight plugin that processes requests and responses globally before they reach views or after they leave them.

Create another conversation with Claude. Same prompt yields a different phrasing but same intent. The django chatbot gpt-4o claude pair now shares one UI and one storage layer.

You can also hit the flow with curl to confirm non-browser behavior:

curl -s -X POST http://127.0.0.1:8000/c/1/ -d "text=Hello" --cookie-jar c.txt --cookie c.txt

The HTML response will contain the new <p> blocks.

Handling streaming

Non-streaming blocks the request until the full completion returns. For production, use StreamingHttpResponse. With OpenAI:

from django.http import StreamingHttpResponse

def stream_complete(model, history):
    client = OpenAI()
    stream = client.chat.completions.create(model=model, messages=history, stream=True)
    for chunk in stream:
        if chunk.choices[0].delta.content:
            yield chunk.choices[0].delta.content

Anthropic supports stream=True on messages.create with similar iteration. Wire the generator into StreamingHttpResponse so the django chatbot gpt-4o claude UI updates token by token instead of freezing for seconds.

Operational notes

  • Store API keys in environment variables or a secrets manager; never commit them.
  • Claude requires max_tokens; OpenAI has sensible defaults but set your own limits.
  • For multi-turn Claude, enforce alternating roles or the SDK raises a 400. OpenAI is lenient.
  • Move complete() calls to a Celery task if you expect concurrent users; Django’s dev server is single-threaded by default.
  • The abstraction above is intentionally thin. Add retry with backoff, token counting, and request logging before shipping.

That’s a working django chatbot gpt-4o claude baseline. From here, add authentication, per-user quotas, and a proper frontend if you need more than a demo.

Tagsdjangochatbotgpt-4oclaude

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 →