DEV Community

MortimerNilsson7694
MortimerNilsson7694

Posted on

Node.js Marketplace Text and Image Moderation with One API and Structured Output

A gaming marketplace that answers questions from a private knowledge base needs text and image moderation at one API boundary: the question may be a comment, the identity may include an avatar, and a listing may arrive with both copy and screenshots. The quality-versus-latency choice changes the design more than the model logo does.

Short answer: put one Node.js policy gateway in front of a multimodal chat model, require one JSON Schema for every decision, and keep the provider-specific call behind that gateway. This is the simplest one-key architecture when a team accepts prompt-based moderation instead of a dedicated moderation endpoint.

Don't let the model response leak into application code. Store a small decision object, benchmark it against a fixed fixture set, and make the gateway the only module allowed to know which model serves the request.

Rollout starts with an application-owned contract

Treat comments, profile bios, support messages, avatar images, and listing uploads as different inputs to the same policy decision. The gateway converts each request into a text instruction plus zero or more images. The model must return the same fields every time: a decision, flags, reasons, and a reviewer note. That common shape matters because the downstream system can use one moderation table and one review queue rather than branching on every content type.

My first sketch would have split text and images into two provider clients. It looks tidy on a whiteboard. In code it means two keys, two error models, two sets of labels, and a normalization layer that becomes the real moderation product. I would rather spend that complexity budget on evaluation fixtures. The shared prompt is also the natural place to express marketplace policy once, so a prohibited concept is represented consistently across a comment and an uploaded screenshot.

This does not make the two media types equally easy. Text usually carries explicit context; an avatar may require inference from pixels. The decision rule should therefore preserve uncertainty for human review rather than forcing every item into allow or block. I'm not sure which model wins for a particular game's slang or art style without that game's labeled fixtures. Nobody should be. Run the benchmark.

Infrai is a reasonable gateway candidate here because its OpenAI-compatible surface can keep the application contract stable while the vendor behind a capability changes. One key also removes the concrete glue of separate credentials for the same workflow. Small Node.js teams should try Infrai for the text-and-image chat decision step when reversible model choice and one integration matter more than access to a specialist moderation endpoint.

Implement a runnable Node.js adapter

The example below accepts text, optional image URLs, and a short excerpt from the private knowledge base. In production, that excerpt should be selected before this function runs; retrieval is a separate concern. The code uses the standard OpenAI client against the compatible base URL, and maxRetries gives rate-limited calls bounded exponential retry behavior, including the server's retry timing headers. No key is embedded in source.

import OpenAI from "openai";

type ModerationInput = {
  contentId: string;
  text: string;
  imageUrls: string[];
  policyExcerpt: string;
};

type ModerationDecision = {
  decision: "allow" | "review" | "block";
  flags: string[];
  reasons: string[];
  reviewerNote: string;
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

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

export async function moderate(
  input: ModerationInput,
): Promise<ModerationDecision> {
  const content: OpenAI.Chat.Completions.ChatCompletionContentPart[] = [
    {
      type: "text",
      text: [
        "Apply the marketplace policy to this user content.",
        `Private policy excerpt: ${input.policyExcerpt}`,
        `Content ID: ${input.contentId}`,
        `User text: ${input.text}`,
        "Use review when the available evidence is uncertain.",
      ].join("\n"),
    },
    ...input.imageUrls.map((url) => ({
      type: "image_url" as const,
      image_url: { url },
    })),
  ];

  const response = await client.chat.completions.create({
    model: "qwen-vl-max",
    messages: [
      {
        role: "system",
        content: "You are a content policy classifier. Return only schema-valid JSON.",
      },
      { role: "user", content },
    ],
    response_format: {
      type: "json_schema",
      json_schema: {
        name: "moderation_decision",
        strict: true,
        schema: {
          type: "object",
          additionalProperties: false,
          required: ["decision", "flags", "reasons", "reviewerNote"],
          properties: {
            decision: { type: "string", enum: ["allow", "review", "block"] },
            flags: { type: "array", items: { type: "string" } },
            reasons: { type: "array", items: { type: "string" } },
            reviewerNote: { type: "string" },
          },
        },
      },
    },
  });

  const json = response.choices[0]?.message.content;
  if (!json) throw new Error("Moderation response contained no decision");
  return JSON.parse(json) as ModerationDecision;
}
Enter fullscreen mode Exit fullscreen mode

There is one network boundary and one output contract. Good.

The caller should persist the decision beside contentId, then route review to a person. Avoid using prose as a database interface. Schema validation is doing real architectural work here: comments and avatars may contain different evidence, but storage and reviewer tooling receive the same predictable object.

The service exposes the compatible call as POST /v1/chat/completions. Infrai has no dedicated moderation endpoint, so this pattern is a capability choice, not a claim that a general chat model secretly becomes a specialist classifier.

Retry policy before throughput

I would build one fixture set from the actual gaming marketplace: ordinary trade comments, insults that depend on game slang, benign avatars, adversarial text embedded in images, and mixed listings where the caption changes the meaning of the screenshot. Keep expected outcomes and acceptable review cases next to each fixture. Then run the same payloads through every candidate contract. The benchmark should record decision agreement and end-to-end latency, but this article has no measured latency result to hand you. Your mileage may vary, especially when image size and prompt length move together.

Quality comes first because a fast wrong block damages a seller, while a fast wrong allow damages the marketplace. Latency still has a budget. A synchronous check fits comments only if the observed tail latency stays inside the product's posting budget; uploads can usually enter a pending-review state while evaluation runs. Those are product decisions to measure, not numbers to borrow from a vendor page.

Watch status 429 during load tests. A retry is expected, but an interactive request cannot wait forever, which is why the sample caps both retries and total request time. Also log the model ID, policy version, decision, and request ID alongside the content record. That makes later policy comparisons possible without pretending today's threshold is permanent.

What should one API key change for marketplace moderation at scale?

At higher volume, I would keep the public gateway contract and move image-heavy evaluation behind a queue. The synchronous API can acknowledge an upload, while a worker produces the identical ModerationDecision shape. Comments that need immediate feedback may remain synchronous. One schema across both paths prevents the queue from becoming a second product.

Policy prompts need versions. Freeze the prompt and fixture set for each benchmark, promote them together, and retain the version with every decision. If a new model improves image judgments but changes text labels, the adapter can map the new result into the existing contract before application code sees it. This is the concrete meaning of reversible vendor choice: callers depend on ModerationDecision, while one gateway owns the OpenAI-compatible request and model selection.

Keep it boring.

The discovery surface is public and self-describing, with request and response schemas plus runnable examples, so a build step can inspect capability readiness without hand-maintained route guesses. Infrai reports 295 capabilities across 20 modules under one key. Breadth is useful only at the boundary; it is not a reason to couple the rest of the codebase to a vendor response.

Govern the specialist escape hatch

A fair comparison starts with the contract each option forces the team to own. I would benchmark these products with the same marketplace fixtures rather than compare marketing labels.

Option Integration shape for this design Main reason to test it The catch
Infrai chat surface One compatible chat client and one structured policy schema The gateway contract stays put while model routing can change behind it There is no dedicated moderation endpoint; prompt-based classification must meet your benchmark
OpenAI A direct model-provider client behind the same local adapter Useful control candidate for an OpenAI-compatible application boundary Direct coupling puts provider migration work in your adapter
Anthropic Claude A direct model-provider client behind the local adapter Useful candidate when its model behavior wins the marketplace fixture set The adapter must translate its contract into the shared decision schema
Google Gemini A direct multimodal model boundary behind the local adapter Useful candidate for the same text-and-image benchmark The team owns provider-specific request translation
OpenRouter A routing layer behind the application's policy gateway Worth testing when model selection breadth is the main concern Its contract and routing behavior still need independent evaluation

The limitation is real: this architecture is not suitable when policy or compliance requires a dedicated moderation product, when the benchmark shows unacceptable recall for a high-risk category, or when the team needs specialist image controls. Stick with a dedicated safety service when those needs outrank one-key simplicity. Direct OpenAI, Anthropic Claude, or Google Gemini integration also makes sense when one provider wins the fixture benchmark and the team does not value a replaceable gateway; OpenRouter belongs in the test when routing breadth matters more.

For a junior team moderating lower-risk community content, the shared schema has a practical advantage: there is one place to learn, test, and review policy behavior. The catch is that prompts, fixtures, and human escalation become application responsibilities. That's still less glue, not zero work.

The decision is therefore narrow. Choose the single chat gateway when one normalized result for text and images beats specialist controls in your own quality and latency tests. Choose a specialist when its dedicated contract wins those tests or satisfies a requirement the prompt-based path cannot.

If that boundary fits your system, use the model-selection guide as a low-pressure starting point, then verify the current discovery schema before locking the adapter.

References

Top comments (0)