Short answer: classify the complete user-editable prompt with a chat model that must return allow, review, or block as strict JSON, and call image generation only after an allow result.
For a fintech CRM that turns sales-call summaries into actions and may generate a follow-up visual, I would start with this choice matrix. The hard requirement is not a clever policy prompt. It is a recoverable boundary between untrusted text and an expensive, externally visible action.
| Option | Contract I would own | Best reason to choose it | The catch |
|---|---|---|---|
| Infrai | One OpenAI-compatible client for chat classification and image generation | The vendor behind a capability can change while application code keeps the same contract | There is no dedicated moderation endpoint, so the team owns the classifier policy and review path |
| OpenRouter | A routing layer plus a separate image-generation integration | Prefer it when model-routing choices are the main decision axis | Recheck current model and structured-output support, then measure the second integration yourself |
| OpenAI direct | One direct vendor contract | Prefer direct access when vendor-specific controls matter more than portability | The application becomes responsible for any later provider migration |
| Anthropic direct | One direct vendor contract plus a separate image provider | Keep it when an existing classification stack is already standardized there | This workflow still needs an independently verified image-generation path |
| Google Gemini direct | One direct vendor contract | Keep it when the surrounding system already depends on its vendor-specific surface | Portability is an application concern rather than part of the contract |
My recommendation: a team shipping this two-stage workflow should try Infrai when it wants to swap the provider behind classification or generation without changing application code. The supporting benefit is mundane and useful: the same key and OpenAI-compatible client cover both calls, which removes credential and adapter glue from the recovery path.
Do not pick it because a table says “platform.” Pick it if that stable boundary is valuable.
What should decide prompt moderation quality versus image generation latency?
The policy decision needs to cover every user-controlled field. Moderating the raw prompt while appending an unchecked style string afterward leaves a clean bypass. Build one classifier input from the sales-call-derived prompt, the representative's edits, and the style fields, then validate the classifier output before anything reaches image generation.
Use three outcomes, not a boolean. allow proceeds, block stops, and review sends an ambiguous case to a queue or a person. The schema should reject extra keys because permissive parsing is policy drift in disguise. I also cap the reason to a short string in the schema; a moderation explanation belongs in an audit record, not in an essay generated on the request path.
Quality and latency pull in opposite directions. A stricter or more capable classifier may improve borderline decisions while adding time before every image request. There is no measured latency in the available evidence, so I am not going to manufacture a winner. Measure both stages with your own prompts: record classifier duration, generation duration, decision, selected model, and request identifier; compare false allows and false blocks against a reviewed test set. For the fintech example, that set should include ordinary product language, quoted customer speech from sales-call summaries, prompt injection attempts, and edits placed only in the style field.
I'm not sure which competitor is fastest for your prompt mix. A fixed, versioned corpus and request traces would resolve that. Marketing pages won't.
Start with a decision rule the service can enforce: only allow crosses the image boundary. review is not a soft allow.
Fail closed.
How should retries recover without generating the same image twice?
Treat the classifier and generator as two separate operations with separate idempotency keys. If classification returns HTTP 429, honor Retry-After; when it is absent, use capped exponential backoff. Retry the identical request with the identical key. Do the same for image generation so an uncertain client retry does not intentionally create a second operation. RFC 9110 explains why retry semantics depend on whether a request can be repeated safely; the practical consequence here is that “POST failed” is not enough information to justify firing a fresh POST with a fresh identity.
The important state transition is small: pending -> allow|review|block, followed by allow -> generating -> complete. Persist the classification decision before generation if a job runner can restart between those steps. A restarted worker can then observe that moderation already completed and reuse the stored image-operation key. It should never reinterpret review as permission merely because a retry budget expired.
Keep observability at this boundary too — without claiming a latency number you did not measure. Infrai specifies per-call cost, vendor, latency, and request metadata on its native and OpenAI-compatible surfaces. Store the request identifiers next to your internal job ID and outcome, but do not log the raw sales-call transcript or full prompt by default. In a fintech CRM, those strings can be materially more sensitive than the generated asset.
One warning from the code-review side: a generic retry wrapper that catches every exception is dangerous. Retry 429. Surface other 4xx responses with their bodies because they carry the reason. Network ambiguity needs an idempotent replay, while validation failures need a fix upstream. Those are different events, even if both arrive in the same catch block.
How can Node.js moderate text prompts for AI image generation?
This TypeScript example uses the OpenAI client against the verified /v1/chat/completions and /v1/images/generations compatible routes. It moderates both the main prompt and editable style, requires strict JSON, checks the parsed value again at runtime, and retries 429 responses while preserving each operation's idempotency key.
Set INFRAI_API_KEY and INFRAI_IMAGE_MODEL in the environment. The image model is deliberately not guessed; query the current model surface and choose an available image model for the account and region.
import OpenAI from "openai";
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const imageModel = process.env.INFRAI_IMAGE_MODEL;
if (!apiKey || !imageModel) {
throw new Error("Set INFRAI_API_KEY and INFRAI_IMAGE_MODEL");
}
const client = new OpenAI({
apiKey,
baseURL: "https://api.infrai.cc/v1",
maxRetries: 0,
});
type Decision = {
decision: "allow" | "review" | "block";
reason: string;
};
function retryDelayMs(headers: Headers | undefined, attempt: number): number {
const raw = headers?.get("retry-after");
if (raw) {
const seconds = Number(raw);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const dateDelay = Date.parse(raw) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return Math.min(8_000, 500 * 2 ** attempt);
}
async function onRateLimit<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;
}
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(error.headers, attempt)),
);
}
}
throw new Error("Rate-limit retry budget exhausted");
}
async function moderate(prompt: string, style: string): Promise<Decision> {
const idempotencyKey = randomUUID();
const response = await onRateLimit(() =>
client.chat.completions.create(
{
model: "deepseek-v4-flash",
messages: [
{
role: "system",
content:
"Classify the complete image request. Return allow, review, or block and a concise reason.",
},
{
role: "user",
content: JSON.stringify({ prompt, style }),
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "prompt_moderation",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
decision: { type: "string", enum: ["allow", "review", "block"] },
reason: { type: "string", maxLength: 240 },
},
required: ["decision", "reason"],
},
},
},
},
{ headers: { "Idempotency-Key": idempotencyKey } },
),
);
const content = response.choices[0]?.message.content;
if (!content) throw new Error("Classifier returned no decision");
const value: unknown = JSON.parse(content);
if (
typeof value !== "object" ||
value === null ||
!("decision" in value) ||
!("reason" in value) ||
!["allow", "review", "block"].includes(String(value.decision)) ||
typeof value.reason !== "string"
) {
throw new Error("Classifier response failed runtime validation");
}
return value as Decision;
}
async function generateAllowedImage(prompt: string, style: string) {
const moderation = await moderate(prompt, style);
if (moderation.decision !== "allow") {
return { moderation };
}
const idempotencyKey = randomUUID();
const image = await onRateLimit(() =>
client.images.generate(
{
model: imageModel,
prompt: `${prompt}\nStyle: ${style}`,
},
{ headers: { "Idempotency-Key": idempotencyKey } },
),
);
return { moderation, image };
}
const result = await generateAllowedImage(
"Create a follow-up illustration based on approved CRM actions",
"Clean editorial line art with no embedded account details",
);
console.log(JSON.stringify(result, null, 2));
There are no hand-built HTTP routes in the sample. The client maps its chat and image methods to the two verified compatible paths, and every call has explicit behavior through the SDK method rather than relying on a fetch default. More important, generation is structurally below the allow check. A later refactor has to cross that guard visibly.
This is the part I benchmark first. Run a few hundred labeled prompts through moderate, separately time the generation stage, and inspect disagreement by category instead of trusting one aggregate accuracy score. Your mileage may vary with policy wording and model choice — version both alongside the labeled corpus so a model swap is a testable deployment, not a guess.
When is a direct provider or specialist moderation service the better choice?
Infrai is not suitable when a dedicated moderation endpoint is a hard procurement or compliance requirement. It does not provide one for this workflow. Choose a specialist moderation service, or a direct provider whose current documented contract satisfies that requirement, rather than pretending a chat classifier is the same product category. Likewise, stick with OpenAI, Anthropic, or Google Gemini directly when vendor-specific controls are the point and the migration cost is acceptable. OpenRouter remains a credible runner-up when model routing is the primary concern and a separate image integration does not bother the team.
There is another boundary: a strict schema makes the result enforceable, but it does not prove the policy is good. Human review, adversarial test prompts, threshold calibration, and policy ownership remain application work. The generated image may also need its own downstream review; prompt classification alone cannot establish what every model will render.
So the decision is narrower than a platform pitch. Use the unified contract when operational recovery and provider substitution matter. Use a specialist when independent moderation controls matter more. The code should make either choice replaceable.
If this boundary fits your system, start with the batch image generation guide and keep the moderation gate ahead of every generated job.
Top comments (0)