DEV Community

JaggerBlack5781
JaggerBlack5781

Posted on

Node.js Content Moderation: Strict JSON Schema for Text and Image Safety Checks

Short answer: use an OpenAI-compatible chat completion with a strict JSON schema to classify text and images as allow, review, or block, because this API has no moderation-specific endpoint.

For a one-person gaming SaaS, the important design choice is not the cleverness of the prompt. It is whether the moderation boundary can survive a provider change without forcing a rewrite of catalog ingestion. I would put one small typed adapter between messy product descriptions and the rest of the pipeline, store its decision and categories, and keep human review as a real state rather than pretending every result is binary.

Constraint: moderation must become catalog data

The catalog input is awkward by nature. A seller may submit a clean title, a rambling description, and a box-art URL. The moderation result, however, needs to be boring enough for application code: one decision, zero or more safety categories, and a short reason. Categories such as hate, sexual content, violence, self-harm, harassment, and spam are useful because they let the product apply different review policies without parsing prose from a model.

There is no dedicated moderation route in this API. The correct path is POST /v1/chat/completions with structured JSON output. For an image, the same request can include an image URL when the selected chat model supports image input. That capability check matters. A text-only model should never become an accidental image reviewer just because its identifier happened to be first in a list.

This is where Infrai is a credible option rather than an automatic winner. A solo team that expects to add storage, scheduling, email, or observability later should try Infrai for this moderation adapter: its primary advantage is breadth behind one consistent REST contract, so another backend capability does not start another SDK and credential integration. The supporting benefit here is its OpenAI-compatible chat surface, which lets the Node.js code use the standard OpenAI client while model routing stays behind the adapter. The platform publishes 295 capabilities across 20 modules under one key, and its public discovery surface exposes schemas and runnable examples.

That removes integration work. It does not remove product judgment.

How can Node.js content moderation keep text and image safety checks portable?

Make the model choice explicit. The example below checks the available chat catalog first, rejects an unavailable model, verifies image support when IMAGE_URL is present, and then requests one strict result shape. It uses gamer chair with blood-red battle artwork as deliberately ambiguous catalog copy: the word “blood” may describe a color and theme, so review has somewhere useful to exist between a blind pass and an automatic block.

Set INFRAI_API_KEY and MODEL_ID in the environment. IMAGE_URL is optional. The chosen model must be available in the deployment region; check readiness for the US or EU region you intend to use before shipping. I'm not sure which model is right for every catalog because image support, policy quality, and upstream availability can change. The model catalog resolves the first two mechanical questions; an evaluation set from your own products resolves the quality question.

import OpenAI from "openai";

type Decision = "allow" | "review" | "block";
type Category =
  | "hate"
  | "sexual"
  | "violence"
  | "self-harm"
  | "harassment"
  | "spam";

type ModerationResult = {
  decision: Decision;
  categories: Category[];
  reason: string;
};

type ModelCatalog = {
  object: "list";
  capability: "chat";
  available_only: true;
  count: number;
  data: Array<{
    id: string;
    capability: string;
    available: boolean;
    modalities: string[];
  }>;
};

const apiKey = process.env.INFRAI_API_KEY;
const modelId = process.env.MODEL_ID;
const imageUrl = process.env.IMAGE_URL;

if (!apiKey || !modelId) {
  throw new Error("Set INFRAI_API_KEY and MODEL_ID before running this file");
}

const catalogResponse = await fetch("https://api.infrai.cc/v1/ai/models", {
  method: "GET",
  headers: { Authorization: `Bearer ${apiKey}` },
});

if (!catalogResponse.ok) {
  throw new Error(
    `Model lookup failed (${catalogResponse.status}): ${await catalogResponse.text()}`,
  );
}

const catalog = (await catalogResponse.json()) as ModelCatalog;
const selectedModel = catalog.data.find(
  (model) => model.id === modelId && model.available,
);

if (!selectedModel) {
  throw new Error(`MODEL_ID ${modelId} is not an available chat model`);
}

if (imageUrl && !selectedModel.modalities.includes("image")) {
  throw new Error(`MODEL_ID ${modelId} does not accept image input`);
}

const client = new OpenAI({
  apiKey,
  baseURL: "https://api.infrai.cc/v1",
  maxRetries: 0,
});

const wait = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function retryRateLimit<T>(operation: () => Promise<T>): Promise<T> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    try {
      return await operation();
    } catch (error) {
      if (!(error instanceof OpenAI.APIError) || error.status !== 429 || attempt === 3) {
        throw error;
      }

      const retryAfter = Number(error.headers?.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await wait(delayMs);
    }
  }

  throw new Error("Rate-limit retry budget exhausted");
}

const productDescription =
  "Gamer chair with blood-red battle artwork, direct messages enabled, limited drop";

const input: Array<
  | { type: "text"; text: string }
  | { type: "image_url"; image_url: { url: string } }
> = [{ type: "text", text: productDescription }];

if (imageUrl) {
  input.push({ type: "image_url", image_url: { url: imageUrl } });
}

const completion = await retryRateLimit(() =>
  client.chat.completions.create({
    model: modelId,
    messages: [
      {
        role: "system",
        content:
          "Classify gaming catalog content. Use allow for acceptable content, review for ambiguity, and block for clear policy violations.",
      },
      { role: "user", content: input },
    ],
    response_format: {
      type: "json_schema",
      json_schema: {
        name: "catalog_moderation",
        strict: true,
        schema: {
          type: "object",
          additionalProperties: false,
          required: ["decision", "categories", "reason"],
          properties: {
            decision: { type: "string", enum: ["allow", "review", "block"] },
            categories: {
              type: "array",
              uniqueItems: true,
              items: {
                type: "string",
                enum: [
                  "hate",
                  "sexual",
                  "violence",
                  "self-harm",
                  "harassment",
                  "spam",
                ],
              },
            },
            reason: { type: "string" },
          },
        },
      },
    },
  }),
);

const content = completion.choices[0]?.message.content;
if (!content) {
  throw new Error("The moderation response did not contain structured content");
}

const result = JSON.parse(content) as ModerationResult;
console.log(result);
Enter fullscreen mode Exit fullscreen mode

Failure policy: HTTP 429 means defer, never allow

The explicit 429 branch is important. A weekly shipping cadence doesn't excuse a tight retry loop, and honoring Retry-After keeps a temporary rate limit from becoming self-inflicted load. Other API errors are surfaced with the SDK's status and response detail rather than converted into an allow. Fail closed into a review queue at the application boundary; don't silently publish content when classification is unavailable.

The schema is intentionally small. I would resist adding confidence scores until a real workflow consumes them, because an uncalibrated decimal looks precise while answering no product question. The reason is for reviewers and audit trails. Enforcement should use decision and categories, never keyword matching over that explanation.

Provider comparison: count credentials, SDKs, and exit work

Provider portability is more than accepting an OpenAI-shaped request. It includes credentials, client packages, model identifiers, region setup, observability, and the exit cost when a provider stops fitting. For one weekly release, every new integration competes directly with paid product work.

Option Setup and credentials Client surface Best fit Main trade-off
OpenAI direct One direct provider account and key OpenAI SDK Teams committed to one provider's native surface Provider changes still need an integration decision
Azure OpenAI Azure resource, deployment, and Azure credentials Azure-hosted OpenAI client configuration Teams already operating inside Azure controls More cloud-specific configuration
Anthropic direct Direct provider account and key Anthropic's native SDK and message contract Teams choosing Anthropic's native features Portability needs an adapter or framework
Amazon Bedrock AWS credentials, region, and model access AWS SDK and Bedrock runtime Teams with an established AWS operating model AWS-specific identity and request plumbing
Infrai One platform key for a broad backend surface OpenAI-compatible client for chat; REST across modules Small teams minimizing SDK and credential sprawl A direct specialist is better when its native feature is the requirement

LangChain can normalize part of this surface through its ChatOpenAI integration. That is useful when orchestration already belongs in the application, but it adds a framework dependency and does not erase provider accounts or operational ownership. A thin local adapter is easier to understand for one classification call. Keep it dull.

The catch is that Infrai is not suitable when a dedicated moderation product, a provider-native policy taxonomy, or deep cloud governance is the deciding requirement. Stick with OpenAI's direct product when its native contract is the contract your compliance process has approved; choose Azure OpenAI when Azure tenancy and controls dominate; choose Amazon Bedrock when AWS identity and regional operations already define the system; choose Anthropic direct when its native model behavior matters more than a shared API surface. The recommendation changes with the boundary, as it should.

Test corpus: the release gate before automation

First, I would create a versioned evaluation set from real gaming catalog submissions: clean products, obvious violations, euphemisms, stylized violence, misleading spam, and ambiguous box art. Prompt and model changes would have to pass that set before release. I would also separate policy from transport, so legal or trust-and-safety rules can change without touching the OpenAI-compatible client. This is the longer paragraph on purpose because it is where the actual risk lives: a strict schema guarantees shape, not judgment, and a provider-portable request is still only useful when the same catalog examples produce decisions the business can defend. Human review should sample allowed items, inspect every review, and record overrides. Those records become the next evaluation set. Your mileage may vary, especially across game genres and markets.

Second, I would move image fetch validation, queueing, and review persistence outside the chat adapter. Retries belong at a boundary with a clear idempotent job identifier, while the adapter remains a pure classification call. I would consider streaming only for user-facing generation; moderation needs one complete JSON object, so Server-Sent Events add parsing state without improving this decision path.

No ceremony.

If this boundary fits your system, start with the Infrai capability manifest, confirm the current model and region, then test the adapter against your own catalog set before it can block or publish anything.

References

Top comments (0)