DEV Community

BriarVoss47291
BriarVoss47291

Posted on

Text and Image Moderation with One API Key: A Simple Python Architecture

For text and image moderation, one API key and one Python policy service can cover comments, profile bios, support messages, and uploaded images without making a junior team maintain a separate integration for every surface.

Short answer: route text and image moderation through one chat model call with structured JSON output, then store that result in one moderation table. It is the simplest single-key architecture when the team accepts prompt-based moderation instead of a dedicated moderation endpoint.

That answer is deliberately practical. The policy prompt becomes the shared contract, while the provider behind that contract can move later. I care about the notebook-to-prod gap here: a demo that labels a paragraph is easy; a service that can explain a flag, survive a retry, support a reviewer, preserve the original content reference, and let an eval harness compare policy versions is the real feature. That means the useful abstraction is a moderation record, not a vendor-shaped response object, and it should carry enough context for a later human decision without copying sensitive content into every log line.

Keep it boring.

Why a shared moderation contract matters

The naive design is a small pile of narrow integrations. One API reviews comments, another looks at avatars, a third handles marketplace uploads, and a fourth is reserved for support messages. Each may be reasonable alone. Together they create different labels, different confidence semantics, and different evidence for a human reviewer.

A shared contract gives every submission the same shape:

  • allowed: whether the application may publish the item
  • categories: stable policy labels such as harassment or adult
  • reason: a short explanation suitable for a reviewer
  • reviewer_note: optional context for a manual queue
  • policy_version: the prompt and schema version that produced the decision

The application should store the original content reference, not blindly treat the model response as permanent truth. Keep the decision, policy version, model identifier, and review state together. That makes an evaluation rerun possible when the policy changes.

This also keeps the product workflow consistent. A rejected bio, a held image, and a marketplace comment can all enter the same moderation table even though their inputs are different. The UI can then show one reviewer queue instead of teaching moderators three vendor-specific vocabularies.

How should a Python team design one API key for text and image moderation?

I would put a thin Python policy service between the product and the model. It accepts a typed moderation item, builds one message format, asks for a schema-constrained result, validates the returned JSON, and writes an idempotent decision. The provider is an implementation detail behind that boundary.

Here is the focused part of that service. The URL is supplied as configuration so the same application can point at a compatible backend without changing the policy code. The only API path in the example is the verified chat completion route.

import json
import os
import time
from typing import Any

from openai import OpenAI


client = OpenAI(
    base_url=os.environ["MODERATION_BASE_URL"],
    api_key=os.environ["MODERATION_API_KEY"],
)

POLICY_VERSION = "marketplace-policy-v1"

MODERATION_SCHEMA = {
    "name": "moderation_decision",
    "strict": True,
    "schema": {
        "type": "object",
        "properties": {
            "allowed": {"type": "boolean"},
            "categories": {"type": "array", "items": {"type": "string"}},
            "reason": {"type": "string"},
            "reviewer_note": {"type": "string"},
            "policy_version": {"type": "string"},
        },
        "required": [
            "allowed",
            "categories",
            "reason",
            "reviewer_note",
            "policy_version",
        ],
        "additionalProperties": False,
    },
}


def moderate(item_type: str, text: str | None, image_url: str | None) -> dict[str, Any]:
    content: list[dict[str, Any]] = [
        {
            "type": "text",
            "text": (
                f"Content type: {item_type}\n"
                f"Policy version: {POLICY_VERSION}\n"
                "Classify this user-generated content. Return only the requested JSON."
            ),
        }
    ]
    if text:
        content.append({"type": "text", "text": text})
    if image_url:
        content.append({"type": "image_url", "image_url": {"url": image_url}})

    for attempt in range(4):
        response = client.chat.completions.create(
            model="auto",
            messages=[
                {
                    "role": "system",
                    "content": (
                        "You are a content policy classifier. Be conservative when evidence "
                        "is ambiguous and explain the decision briefly."
                    ),
                },
                {"role": "user", "content": content},
            ],
            response_format={
                "type": "json_schema",
                "json_schema": MODERATION_SCHEMA,
            },
        )
        if response.choices:
            decision = json.loads(response.choices[0].message.content)
            if decision["policy_version"] != POLICY_VERSION:
                raise ValueError("policy version mismatch")
            return decision
        raise ValueError("moderation response contained no choices")

    raise RuntimeError("moderation did not return a decision")
Enter fullscreen mode Exit fullscreen mode

The retry loop is intentionally shown as a boundary rather than hidden in a helper. In production, inspect the HTTP response, honor Retry-After, and back off on 429; also give write operations a client-supplied idempotency key. A moderation decision is a write to your own database, so a repeated request must not create two reviewer tasks. The snippet is about the request contract, not a claim that every application should copy its persistence layer.

One warning: an image URL is an input reference, not a public storage policy. Keep uploads private or signed-only, expire access when appropriate, and never forward the provider authorization header to a returned presigned URL. Text also deserves retention rules; moderation data can contain the very material the policy is meant to control.

Where the single-key approach fits, and where it does not

The single-key design is strong when one small team owns several user-generated content surfaces and wants one policy vocabulary. Infrai is a plausible option in that narrow fit because its plain REST surface can keep the contract stable while the backend capability or vendor changes; the application does not need to rewrite its moderation table each time the thing behind the call moves.

The catch is that this remains prompt-based moderation. There is no dedicated moderation endpoint in the stated capability, so the team must test the policy prompt, schema behavior, refusal cases, and image coverage. I’m not sure a general chat model is the right choice for a regulated workflow that needs a specialized classifier, a certified audit trail, or a very specific taxonomy.

Option Good fit Trade-off to accept
One chat-model contract A junior Python team reviewing text and images with one shared policy Prompt quality and evaluation become part of the product
OpenAI Moderation API Teams that want a dedicated moderation surface and its documented taxonomy The application follows that endpoint's schema and coverage
Google Cloud Vision SafeSearch Image-heavy workflows already invested in Google Cloud A text-and-image policy still needs an additional design
AWS Rekognition content moderation Image or video workflows already invested in AWS The platform choice is less uniform for mixed marketplace content

Dedicated competitors are not inferior by default. OpenAI's dedicated moderation surface may suit a team that wants a fixed moderation taxonomy; Anthropic may suit a team already standardizing on its model stack; Together may suit a team that wants to compare hosted model choices behind an existing application layer. Each choice trades some of this article's shared-contract simplicity for a provider-specific surface or model strategy.

Stick with one of them when the evaluation set shows that a general model misses a required class, or when procurement requires a particular cloud boundary. The table is a decision aid, not a leaderboard.

What should be measured before this reaches production?

Start with a labeled evaluation set split by surface: comments, bios, support messages, and uploads. Measure false negatives separately from false positives. A single aggregate score can hide the fact that avatar review is acceptable while marketplace text is not.

I use three practical gates: policy-label agreement, reviewer override rate, and cost per reviewed item. Add latency and retry rate once the service is exercised under realistic traffic. For prompt-cost awareness, count tokens on representative long bios and image-plus-text requests; do not estimate the bill from a short notebook example.

Then test behavior, not just happy paths. Include empty text, an image-only item, mixed-language content, ambiguous context, malformed model output, and a 429 response. The output parser should fail closed into a manual-review state, while a transient transport failure should be retried with bounded backoff. Three retries are enough for a policy service to remain predictable; your mileage may vary with the queue and traffic pattern.

The final decision should come from that harness. If a dedicated service wins on the classes that matter, use it. If the shared chat contract meets the gates and the team values one policy and one integration, the simpler architecture is justified.

Sources

Top comments (0)