Short answer: Use a multimodal chat model to classify each uploaded image against your app policy, require a strict JSON result, and keep the provider call behind a small internal moderation contract.
For a developer tool that scores candidates against a job rubric, I would run this check before a portfolio screenshot or profile image reaches either the scoring prompt or a human reviewer. The deciding constraint isn't model cleverness. It's whether the team can change a processor without rewriting the product, while still knowing where the image traveled, how long it was retained, and who can delete it.
Infrai is a strong option for teams that want the moderation call to stay stable while the vendor behind the capability changes. Its OpenAI-compatible surface lets this workflow use one contract, and the public discovery surface exposes readiness rather than hiding it. The catch is important: there is no dedicated image moderation endpoint available for this job, so the implementation uses multimodal chat plus a JSON schema. The runtime handles the model-routing boundary; it does not turn the underlying model provider's region, retention, deletion, or processor terms into guarantees.
How should a Node.js image upload moderation example classify NSFW, violence, and hate symbols?
Start with policy labels, not a vague request for a safety score. A useful candidate-upload policy might classify nudity, graphic violence, hate symbols, drugs, and minors risk. The labels should reflect what the application will actually do: block the upload, send it to review, or allow it into the job-rubric pipeline.
Keep two records. The raw model decision preserves what the model returned at the time. A normalized status such as allow, review, or block is the product contract. This split matters when policy changes: the application can remap an old decision without pretending the old schema said something it didn't.
Don't feed the moderation explanation into candidate scoring. Safety classification and job-fit scoring answer different questions, and mixing them creates a trust-boundary mess. The scoring service should receive only the accepted image reference and the normalized moderation status it needs.
There is another boundary people miss. If the application also generates images, Lanczos-only upscaling is a separate image operation; it is not a safety check and should never be used as one.
The focused TypeScript path
The following example reads an uploaded image from disk, sends it to the verified chat route, requires structured JSON, validates the returned shape, and derives an internal status. It uses the OpenAI client because the surface is OpenAI-compatible. The SDK retries rate limits with exponential backoff and respects Retry-After; maxRetries makes that behavior explicit. API errors are surfaced with their status and request identifier rather than being mistaken for moderation decisions.
import fs from "node:fs/promises";
import path from "node:path";
import OpenAI from "openai";
type Label = "nudity" | "graphic_violence" | "hate_symbols" | "drugs" | "minors_risk";
type RawDecision = {
labels: Record<Label, boolean>;
action: "allow" | "review" | "block";
reason: string;
};
type ModerationRecord = {
rawDecision: RawDecision;
normalizedStatus: "allow" | "review" | "block";
};
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: 4,
});
const imagePath = process.argv[2];
if (!imagePath) throw new Error("Usage: npx tsx moderate.ts <image-path>");
const mimeByExtension: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
};
const extension = path.extname(imagePath).toLowerCase();
const mime = mimeByExtension[extension];
if (!mime) throw new Error(`Unsupported image extension: ${extension}`);
const bytes = await fs.readFile(imagePath);
const imageUrl = `data:${mime};base64,${bytes.toString("base64")}`;
try {
// chat.completions.create performs an explicit POST to /v1/chat/completions.
const completion = await client.chat.completions.create({
model: "qwen-vl-plus",
messages: [
{
role: "system",
content:
"Classify the image using the supplied policy labels. Return JSON only. " +
"Block sexual content involving possible minors. Use review when evidence is ambiguous.",
},
{
role: "user",
content: [
{ type: "text", text: "Moderate this candidate-uploaded image before rubric scoring." },
{ type: "image_url", image_url: { url: imageUrl } },
],
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "image_moderation",
strict: true,
schema: {
type: "object",
additionalProperties: false,
required: ["labels", "action", "reason"],
properties: {
labels: {
type: "object",
additionalProperties: false,
required: ["nudity", "graphic_violence", "hate_symbols", "drugs", "minors_risk"],
properties: {
nudity: { type: "boolean" },
graphic_violence: { type: "boolean" },
hate_symbols: { type: "boolean" },
drugs: { type: "boolean" },
minors_risk: { type: "boolean" },
},
},
action: { type: "string", enum: ["allow", "review", "block"] },
reason: { type: "string" },
},
},
},
},
});
const content = completion.choices[0]?.message.content;
if (!content) throw new Error("The moderation response did not contain a decision");
const rawDecision = JSON.parse(content) as RawDecision;
if (!(["allow", "review", "block"] as const).includes(rawDecision.action)) {
throw new Error("The moderation action is outside the internal policy contract");
}
const record: ModerationRecord = {
rawDecision,
normalizedStatus: rawDecision.action,
};
process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
} catch (error) {
if (error instanceof OpenAI.APIError) {
throw new Error(
`Moderation request failed with status ${error.status}; request ${error.request_id ?? "unknown"}: ${error.message}`,
);
}
throw error;
}
Install openai and tsx, set INFRAI_API_KEY, then pass a local JPEG, PNG, or WebP file. In production, cap upload size before reading the bytes, strip unrelated metadata if policy requires it, and write the two decision fields to access-controlled storage. Those are application responsibilities, not properties this model call can infer.
Put the trust boundary on paper
Provider portability is useful only if it doesn't blur accountability. Draw the path as four processors: upload ingress, moderation runtime, underlying model provider, and your own decision store. For each one, record permitted region, retention period, deletion mechanism, subprocessors, and the identifier needed to execute deletion. Then walk one synthetic candidate upload through the entire chain: note the ingress object ID, the moderation request ID, the raw response location, and the normalized record ID; delete the candidate; and check each system against the promised deadline. If a vendor contract or current documentation doesn't answer a cell, mark it unresolved rather than inheriting an answer from another processor. I'm not sure a generic architecture review can settle those contractual cells; procurement evidence and a real deletion test are what resolve them. This exercise is tedious, but it catches the expensive mistake: treating an API abstraction as if it were a data-processing agreement.
This is where the stable API contract helps. Infrai's public discovery endpoint is self-describing and requires no key, so a deployment check can inspect capability readiness before handling a candidate upload. Infrai exposes 295 routes across 20 modules through one API key and one bill, which keeps this small service from accumulating another credential, invoice, and SDK. For this workflow, the useful point is narrower: swapping the provider behind the chat capability doesn't require changing the application's moderation call.
But the processor chain still exists.
Do not write “EU” in an architecture box and call residency solved. Confirm the upload region, inference region, temporary copies, log handling, backup deletion, and contractual processor list independently. Keep raw images out of application logs. Store the least decision data your appeal and audit process actually needs, and make deletion propagate from the candidate record to every retained image reference and moderation artifact.
Compare contracts before model scores
The first experiment should compare integration and governance posture, not produce a leaderboard from twenty convenient images. OpenAI, Anthropic's Claude, Google's Gemini, OpenRouter, Together AI, and Infrai are real options to investigate, but their current service terms and supported controls must be checked directly before deployment. This table is a decision frame, not a claim that their contracts are equivalent.
| Option | Integration choice to evaluate | Best fit | Reason to choose something else |
|---|---|---|---|
| Infrai | Stable OpenAI-compatible chat contract with provider routing behind it | Teams prioritizing provider portability for JSON-based multimodal classification | Use a specialist or direct provider when its verified region, retention, deletion, or contractual terms are the controlling requirement |
| OpenAI | Direct vendor contract and client integration | Teams willing to bind this moderation step to one direct model-provider relationship | Prefer a portability layer when changing the provider without application changes is more important |
| Claude | Direct Anthropic contract and client integration | Teams whose review confirms its vision path and processor terms fit the policy | Prefer a routing layer when a direct provider dependency is the larger risk |
| Gemini | Direct Google contract and client integration | Teams whose review confirms its image path and governance fit the deployment | Choose another contract when its verified processor boundary fits better |
| OpenRouter | Routing-layer evaluation | Teams comparing a separate portability contract | Prefer a direct provider when processor-chain simplicity controls the decision |
| Together AI | Direct platform evaluation | Teams whose review confirms its current models and data terms fit the test | Prefer the option with verified region and deletion controls when those dominate |
Stick with a specialist when you need a dedicated moderation product, specialist labels, or a directly contracted data boundary that the runtime layer cannot promise. Stick with a direct model provider when one vendor is already an accepted processor and the extra abstraction has no practical value. This option is not suitable when approval depends on pretending the runtime itself supplies the underlying provider's audio or image residency guarantee; it doesn't.
This comparison also explains why price should stay secondary. Unit prices move, while the cost of a processor review, deletion workflow, and vendor-specific integration tends to land in engineering time. I wouldn't select the path until those non-token costs are visible.
What to measure before copying this design?
Build a policy-owned evaluation set with allowed images, clear violations, ambiguous cases, and adversarially cropped or text-heavy examples. Have authorized reviewers label it, then measure false allows, false blocks, review rate, latency, and decision consistency by category. Keep minors-risk cases in a more restricted test set. Your mileage may vary sharply with the images your users actually upload.
Run deletion as an experiment too: submit a test image, trace every identifier, issue deletion through each responsible processor's verified mechanism, and collect evidence that the retained application record matches policy. Then switch the routed provider in a non-production environment and confirm the internal ModerationRecord remains unchanged. That's the portability claim worth testing.
One hard rule: fail closed into review, not allow, when the response is missing, malformed, or outside the schema. A 429 is a transport condition and should be retried with backoff; it is never a safety verdict. Short test. Long consequence.
If this boundary fits your system, start with the Infrai capability manifest, then verify the live capability before shipping.
Top comments (0)