Last reviewed: November 7, 2026 (Asia/Shanghai); primary references fetched November 6 UTC.

Direct answer

Build a Django form that calls CometAPI by validating a bound forms.Form inside an authenticated server-side view. Call the generation adapter only after form.is_valid() succeeds, retain Django’s CSRF middleware and template token, and render the accepted reply as escaped text. Keep the API key, model, and provider URL in server-owned configuration.

The Django forms guide documents the central sequence: bind request.POST, validate the form, then use cleaned_data. The CometAPI chat reference documents the separate outbound operation, POST /v1/chat/completions, with bearer authentication, a model, and messages. A valid browser submission authorizes this example to make one such request.

This guide assembles the complete browser-to-view flow. It uses a synchronous view for a small development integration; each generation occupies the handling worker while it waits. Decide whether that execution model fits your application’s traffic and request budget before exposing the feature.

Who this is for

Use this with an existing Django 5.2 application that already has working login, sessions, templates, and the standard authentication and CSRF middleware. The examples use an app named assistant, Django’s standard user model in tests, and HTTPX. Install the latest patched Django 5.2 release and a compatible HTTPX version in your isolated environment, then record exact versions in your lockfile.

Add assistant to INSTALLED_APPS, retain SessionMiddleware, AuthenticationMiddleware, and CsrfViewMiddleware, and keep your existing login URL configured. The Django authentication guide documents that login_required redirects unauthenticated users to LOGIN_URL. Authentication identifies a user; apply your application’s feature permission and per-user spending allowance before enabling this route for a wider audience.

No authenticated CometAPI request or deployment was performed for this article. The code and test cases below are an implementation and acceptance procedure, not recorded integration-test results.

Key takeaways

  • Bind POST data explicitly, including an empty POST, so missing input cannot look like an untouched form.
  • Use only cleaned_data after validation; browser attributes alone do not authorize generation.
  • Keep Django’s built-in CSRF protection and submit the token to the application URL.
  • Construct the upstream payload from trusted settings and the one validated prompt.
  • Display model text through ordinary template escaping.
  • Mock the generation boundary and require zero calls for rejected requests.

Define the form and server settings

Save this as assistant/forms.py:

from django import forms


class ChatForm(forms.Form):
    prompt = forms.CharField(
        label="Prompt",
        max_length=2000,
        strip=True,
        widget=forms.Textarea(attrs={"rows": 6}),
    )

The field rejects missing, blank, and overlong input on the server. Django also renders relevant browser validation attributes. The 2,000-character limit is an illustrative application choice, independent of model token limits. Keep a separate request-body limit at your application ingress: a form field limit does not bound the complete incoming HTTP body before parsing.

In your existing settings module, load the two provider values from the process environment or equivalent secret configuration:

import os

COMETAPI_KEY = os.environ.get("COMETAPI_KEY", "")
COMETAPI_MODEL = os.environ.get("COMETAPI_MODEL", "")

Set the model to an exact current identifier that you have verified for text Chat Completions and your account. This example adds no model-specific optional parameters. The browser submits neither a credential nor an authoritative model choice. Extra submitted fields are ignored by this form; it does not promise to reject unknown fields.

Provide one narrow generation function

Save this small synchronous adapter as assistant/gateway.py. If your application already has a reviewed adapter, keep its transport policy and expose the same complete_text(prompt) interface instead. The site’s Python HTTPX client guide covers a reusable asynchronous implementation in more depth.

import json

import httpx
from django.conf import settings


class ChatUnavailable(Exception):
    pass


def complete_text(prompt):
    key = settings.COMETAPI_KEY
    model = settings.COMETAPI_MODEL
    if not key.strip() or not model.strip():
        raise ChatUnavailable("missing_configuration")

    try:
        with httpx.stream(
            "POST",
            "https://api.cometapi.com/v1/chat/completions",
            headers={
                "Authorization": f"Bearer {key}",
                "Accept": "application/json",
            },
            json={
                "model": model,
                "messages": [{"role": "user", "content": prompt}],
                "stream": False,
            },
            timeout=20.0,
            follow_redirects=False,
        ) as response:
            media = response.headers.get("content-type", "")
            media = media.split(";", 1)[0].strip().lower()
            if response.status_code != 200 or media != "application/json":
                raise ChatUnavailable("unexpected_http_response")
            body = bytearray()
            for chunk in response.iter_bytes():
                if len(body) + len(chunk) > 131072:
                    raise ChatUnavailable("response_too_large")
                body.extend(chunk)
        data = json.loads(body)
    except (httpx.RequestError, ValueError, UnicodeError) as exc:
        raise ChatUnavailable("upstream_unavailable") from exc

    if not isinstance(data, dict) or data.get("object") != "chat.completion":
        raise ChatUnavailable("unexpected_completion")
    choices = data.get("choices")
    if (not isinstance(choices, list) or len(choices) != 1
            or not isinstance(choices[0], dict)):
        raise ChatUnavailable("unexpected_completion")
    choice = choices[0]
    message = choice.get("message")
    if (choice.get("finish_reason") != "stop"
            or not isinstance(message, dict)
            or message.get("role") != "assistant"
            or message.get("refusal") is not None
            or message.get("tool_calls") is not None):
        raise ChatUnavailable("unaccepted_completion")
    text = message.get("content")
    if not isinstance(text, str) or not text.strip():
        raise ChatUnavailable("empty_completion")
    return text

The HTTPX quickstart documents JSON request bodies, headers, response streaming, redirects, timeouts, and request exceptions. HTTPX’s stream() here controls local reading of a single JSON response; the provider request still contains stream: false. The loop limits accumulated decoded response bytes before JSON parsing. It is not a token budget or a guarantee about all transient transport allocations.

The 128 KiB ceiling and 20-second network inactivity timeout are local choices. That timeout is not a 20-second total wall-clock deadline: a response arriving in small chunks can take longer. Review the complete web-worker deadline and concurrency budget before rollout. This compact helper also creates its transport for each invocation; an application-owned shared client needs explicit startup, shutdown, and pooling decisions.

The completion checks accept one finished assistant text choice. Refusals, tool calls, truncation, incompatible JSON, and empty content produce an application failure. These acceptance rules are stricter than merely receiving HTTP 200. The function contains no retry loop.

Connect the view, URL, and template

Save this as assistant/views.py:

from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_http_methods

from .forms import ChatForm
from .gateway import ChatUnavailable, complete_text


@never_cache
@login_required
@require_http_methods(["GET", "POST"])
def chat(request):
    reply = None
    status = 200
    if request.method == "POST":
        form = ChatForm(request.POST)
        if form.is_valid():
            try:
                reply = complete_text(form.cleaned_data["prompt"])
            except ChatUnavailable:
                form.add_error(
                    None,
                    "A completed reply is unavailable. "
                    "Do not resubmit automatically.",
                )
                status = 502
        else:
            status = 400
    else:
        form = ChatForm()
    return render(
        request, "assistant/chat.html",
        {"form": form, "reply": reply}, status=status,
    )

The explicit POST branch matters. ChatForm(request.POST or None) would turn an empty submission into an unbound form, losing the expected required-field errors. This view re-renders the bound invalid form with HTTP 400 and its editable input. A gateway failure adds a fixed non-field error with HTTP 502; no raw exception or upstream body enters the template.

In assistant/urls.py, register the route:

from django.urls import path
from . import views

urlpatterns = [path("chat/", views.chat, name="comet-chat")]

Include it in the project’s existing URL configuration with path("assistant/", include("assistant.urls")), importing include from django.urls. The resulting application route is /assistant/chat/. Keep that URL distinct from the outbound provider endpoint.

Save the template at assistant/templates/assistant/chat.html:

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Ask a text question</title>
<main>
  <h1>Ask a text question</h1>
  <form method="post" action="{% url 'comet-chat' %}">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Generate reply</button>
  </form>
  {% if reply %}
    <section aria-label="Reply">
      <h2>Reply</h2>
      <pre style="white-space: pre-wrap">{{ reply }}</pre>
    </section>
  {% endif %}
</main>
</html>

The Django CSRF guide requires a token inside internal POST forms and explains that render() supplies the appropriate request context. Keep the form action on the application origin. Do not point it at CometAPI or remove protection to make a failing POST succeed.

The Django template language reference documents automatic HTML escaping and the effect of disabling it. Leave escaping enabled and omit safe or mark_safe for the model reply. Text resembling an HTML element should display as text. This template makes no Markdown-rendering promise.

For the underlying browser spending boundary, see Protect CometAPI Chat Routes Against CSRF . Django supplies the maintained framework mechanism here.

Contract details to verify

StageAccepted behaviorRejected behavior
Browser identityExisting authenticated application sessionLogin redirect before view execution
Request integrityInternal POST with a valid Django CSRF tokenCSRF failure before generation
InputOne nonblank prompt within the form limitBound form errors and zero generation calls
Provider selectionFixed endpoint and configured model/keySubmitted extra fields cannot replace those settings
CompletionOne accepted finished assistant text responseFixed form error for upstream or completion failure
DisplayEscaped text in a non-cacheable pageNo interpretation of generated HTML

Happy-path operator workflow

First run the form flow with a synthetic generation function. Save these focused tests in assistant/tests.py. They assume the route and template above and Django’s standard user model. Adapt the user factory if your application uses a custom identity model.

from unittest.mock import patch

from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse

from .gateway import ChatUnavailable


class ChatViewTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = get_user_model().objects.create_user(username="reader")

    def setUp(self):
        self.browser = Client(enforce_csrf_checks=True)
        self.browser.force_login(self.user)
        self.url = reverse("comet-chat")

    def token(self):
        page = self.browser.get(self.url)
        self.assertEqual(page.status_code, 200)
        return str(page.context["csrf_token"])

    def test_valid_form_dispatches_once_and_escapes_reply(self):
        with patch("assistant.views.complete_text",
                   return_value="<script>alert(1)</script>") as generate:
            token = self.token()
            generate.assert_not_called()
            response = self.browser.post(self.url, {
                "prompt": " Say hello. ",
                "model": "untrusted-browser-choice",
                "csrfmiddlewaretoken": token,
            })
        self.assertContains(response, "&lt;script&gt;alert(1)&lt;/script&gt;")
        self.assertNotContains(response, "<script>")
        generate.assert_called_once_with("Say hello.")

    def test_invalid_forms_never_dispatch(self):
        with patch("assistant.views.complete_text") as generate:
            token = self.token()
            for prompt in ("", "   ", "x" * 2001):
                response = self.browser.post(self.url, {
                    "prompt": prompt, "csrfmiddlewaretoken": token,
                })
                self.assertEqual(response.status_code, 400)
            generate.assert_not_called()

    def test_missing_csrf_never_dispatches(self):
        with patch("assistant.views.complete_text") as generate:
            self.token()
            response = self.browser.post(self.url, {"prompt": "Hello"})
        self.assertEqual(response.status_code, 403)
        generate.assert_not_called()

    def test_anonymous_submission_never_dispatches(self):
        with patch("assistant.views.complete_text") as generate:
            response = Client().post(self.url, {"prompt": "Hello"})
        self.assertEqual(response.status_code, 302)
        generate.assert_not_called()

    def test_gateway_failure_has_a_fixed_message(self):
        with patch("assistant.views.complete_text",
                   side_effect=ChatUnavailable("private-fixture-detail")):
            response = self.browser.post(self.url, {
                "prompt": "Hello", "csrfmiddlewaretoken": self.token(),
            })
        self.assertContains(response, "Do not resubmit automatically.",
                            status_code=502)
        self.assertNotContains(response, "private-fixture-detail",
                               status_code=502)

Run python manage.py test assistant. The Django testing-tools reference describes the client, authentication helpers, and response assertions. Patch assistant.views.complete_text, where the view resolves the imported name. Patching only the gateway module’s original name would leave the view’s reference intact.

The accepted-form test verifies dispatch count, stripped input, and escaped output. The extra browser model value is ignored; the gateway still owns actual provider selection. GET rendering must leave the mocked generation function untouched.

After those checks pass in your installation, supply the credential privately and make one harmless staging submission using the selected model. Require a completed reply and confirm that the browser receives no provider credential or raw provider envelope. Retain the software versions, time, model identifier, status, and completion verdict without storing the prompt or reply in routine diagnostics.

Error-path operator workflow

Django’s ordinary test client relaxes CSRF checks. The authenticated form tests deliberately use Client(enforce_csrf_checks=True); otherwise a missing-token POST could reach the view and conceal a broken browser contract. The anonymous test uses the ordinary client specifically to isolate the login decorator, while the separate enforced-CSRF test covers request integrity.

Also submit a syntactically valid but incorrect token through the enforced client and require HTTP 403 with zero generation calls. Add a non-GET/POST method check and confirm HTTP 405 without dispatch for an authenticated request that passes applicable middleware checks. Repeat the missing-token and valid-form cases through staging ingress, since a local test client does not exercise your browser or reverse proxy.

Keep transport tests separate from these view tests. Stub HTTPX to exercise a rejected status, wrong media type, malformed JSON, an over-limit body, and a truncated completion. The view fixtures replace the entire gateway and therefore cannot establish its real request path, timeout behavior, parser correctness, account access, or remote outcome.

This example renders the result directly after POST and stores no durable result identity. A refresh, double click, or manual resubmission can create another generation. For a product requiring safe refreshes or replay handling, add durable operation identity and a separate result GET before release. A redirect alone cannot recover an answer lost after the provider finished. A timeout or worker failure may leave remote execution uncertain; the form must not retry automatically.

Failure modes

An empty POST has no visible field error. Bind request.POST in an explicit POST branch. Do not use truthiness to choose whether the form is bound.

A CSRF error disappears after an exemption is added. Restore middleware protection and check that the internal form contains its token. Require the rejection test to leave the generation counter at zero.

The generated reply changes the page markup. Check template inheritance, escaping settings, and filters. Keep the reply in a text context with escaping enabled; never mark model output safe merely to preserve formatting.

A logged-in user can generate without the intended product permission. Add your feature authorization and spending admission before complete_text. The login decorator establishes authentication, not every application entitlement.

The form stalls beyond the configured HTTPX value. Distinguish network inactivity from total elapsed time and inspect worker occupancy. Establish the application’s complete execution budget rather than treating this timeout as a service-level guarantee.

A gateway error encourages repeated submissions. Keep the fixed failure message, preserve whatever operational evidence is available, and inspect the uncertain attempt before authorizing another call.

FAQ

Does the browser need the CometAPI key?

The browser posts to the Django application. Only the server adapter reads the key and sends it to the fixed provider endpoint. Keep it out of templates, browser data, logs, and shared exceptions.

Can a visitor change the model by adding a form field?

The view passes only the validated prompt to the gateway, which obtains its model from server settings. The form ignores extra fields. Add an explicit allowlisted selection field if the product later permits users to choose among models.

Why keep a synchronous view?

It makes this small form integration straightforward to inspect. It also occupies a worker during inference. An async view needs an appropriate async transport and lifecycle, while longer durable work needs an owned background workflow. Reassess execution ownership when the workload changes.

Does an escaped reply need to be trusted?

Escaping protects this HTML text insertion. It does not establish that the answer is factually correct or safe to execute as a command, query, or tool instruction. Those uses need their own validation and authorization.

Reader next step

Add the form, view, internal URL, and escaped template to a development branch. Run the mocked view checks, verify the real gateway separately, then complete one authorized staging submission and its rejection cases. Decide how repeated submissions and longer inference will be owned before opening the feature to regular users.

If you still need account setup for that staging check, Start with CometAPI , configure the credential on the server, and return to the same narrow acceptance procedure.

Sources checked

Seven primary public references returned HTTP 200 on November 6, 2026 UTC:

These maintained technical references support an evergreen implementation task. The Django pages were fetched at their versioned 5.2 URLs to match the example baseline. Local limits, completion acceptance, error text, and the verification sequence are application recommendations derived from those contracts. This article makes no new-release, measured performance, or successful deployment claim.