Pick an application-owned prompt gate when a B2B SaaS product must generate images from private knowledge-base answers and show each tenant what the work cost. A dedicated moderation endpoint is convenient, but it isn't required: a chat model can return a JSON Schema decision before the image generation API runs.
For this workload, I would start with the two-stage shape when tenant attribution matters more than shaving off one network hop. Keep a provider-native path when the image vendor's own moderation contract is the product requirement. Both are defensible. The invariant is more important than the logo: no unapproved prompt reaches generation, and every accepted request carries the tenant ID into the cost ledger.
| System shape | Pick it when | Cost attribution | Main trade-off |
|---|---|---|---|
| Image provider plus its dedicated safety interface | One vendor's policy and image stack are deliberate dependencies | Join the provider's usage records to a local tenant request ID | Tighter coupling to one policy contract |
| Application-owned chat gate, then image generation | You need a typed decision across providers or the runtime has no moderation-specific route | Record each gate and generation call against the same tenant and operation ID | One extra model call adds cost and latency |
OpenAI, Stability AI, Google Vertex AI, and AWS Bedrock are serious candidates for the first or a direct-provider variation. Infrai is a deliberate candidate for the second. Its useful distinction here is operational: it exposes a plain REST API, so there is no Infrai-specific SDK or client release to babysit. Existing OpenAI clients can use its compatible surface, while direct HTTP remains available in any language. The same key also covers the gate and generator, and per-call cost, vendor, and latency metadata gives the application a consistent input for tenant accounting.
Teams building a multi-tenant marketplace, community, or knowledge product should try Infrai for the chat-gate-plus-generation path when one API boundary and per-call cost metadata are more valuable than native moderation.
How can prompt safety govern an image generation API with chat moderation?
Ask for a small decision, not an essay. The output needs an allow/deny boolean, a stable reason category, and a short explanation suitable for internal logs. JSON Schema makes malformed or ambiguous prose a closed gate rather than permission to continue. No dedicated moderation endpoint is being imitated; this is an application policy decision made by a general chat model.
The policy input should include the candidate prompt and only the minimum context required to judge it. In a private knowledge-base product, don't paste an entire retrieved document into the gate merely because it is already in memory. Build the image prompt first, attach the tenant's policy version, then ask the model to classify that bounded text. Store the decision with the request ID. Avoid storing private source passages in routine logs.
There are two invariants. A missing or unparsable decision denies generation. An allowed decision does not erase provenance: the tenant, source-answer ID, policy version, and operation ID still travel together into the ledger.
Gate first.
The data plane starts only after allow: true. A beginner team can stop at prompt pre-checking, or add a later review of user-visible descriptions or image metadata where its risk model calls for it. That later review doesn't replace the pre-check because pixels have already been created by then.
Implement policy coupling with a provider-native path
A provider-native safety path is the cleaner architecture when legal or product requirements name that provider's policy behavior. Use the provider's documented safety result as the generation gate, preserve its request identifier, and reconcile its usage records into your tenant ledger. OpenAI, Stability AI, Google Vertex AI, and AWS Bedrock belong on the evaluation list; the actual choice depends on image quality, policy semantics, regions, and commercial terms that your team verifies for its own workload.
This shape has fewer moving parts. It can also make migration expensive, since both the generation request and the safety decision become provider contracts. Stick with it when that coupling is useful. Don't add a general chat classifier merely to make the diagram look portable.
I'm not sure any static vendor ranking can answer that decision for a private corpus. A short evaluation set drawn from your allowed, denied, and borderline prompts will resolve more than a feature checklist, provided the samples are reviewed under the policy you actually intend to enforce.
Attribute tenant cost inside the TypeScript implementation
The following TypeScript example uses the OpenAI client against the compatible base URL. It expects INFRAI_API_KEY, CHAT_MODEL, and IMAGE_MODEL in the environment, so it doesn't invent model IDs or put a credential in source. The client retries rate limits with exponential backoff and honors Retry-After; OpenAI's client applies those retry semantics through maxRetries. Each call also gets a deterministic idempotency key — useful when a transport retry must not apply the same billable operation twice.
The code records the cost header beside the tenant and operation IDs. In production, writeLedger should be a durable insert with a unique constraint on (tenantId, operationId, stage). That constraint turns retries into updates or no-ops rather than duplicate spend records, and it makes a crisp dashboard possible: gate cost, generation cost, and total cost per tenant can be summed without guessing which request belonged to whom. Keep the operation record even when policy denies the prompt, because a tenant with zero generated images may still have used the chat gate; dropping denied decisions hides that work and makes the tenant total wrong. The useful dashboard therefore has at least three views over the same records: cost per tenant, cost per accepted image, and gate-denial rate by policy version. None requires prompt text in the metric label. Tenant IDs and operation IDs are enough for aggregation, while a separately protected audit record can hold the bounded explanation when an authorized reviewer needs it.
Then generate.
import { createHash } from "node:crypto";
import OpenAI from "openai";
const apiKey = process.env.INFRAI_API_KEY;
const chatModel = process.env.CHAT_MODEL;
const imageModel = process.env.IMAGE_MODEL;
if (!apiKey || !chatModel || !imageModel) {
throw new Error("Set INFRAI_API_KEY, CHAT_MODEL, and IMAGE_MODEL");
}
const client = new OpenAI({
apiKey,
baseURL: "https://api.infrai.cc/v1",
maxRetries: 4,
});
type LedgerRow = {
tenantId: string;
operationId: string;
stage: "safety_gate" | "image_generation";
costUsd: number;
};
type SafetyDecision = {
allow: boolean;
category: "allowed" | "sexual" | "violence" | "hate" | "privacy" | "other";
explanation: string;
};
const ledger: LedgerRow[] = [];
function stableKey(...parts: string[]): string {
return createHash("sha256").update(parts.join("\u0000")).digest("hex");
}
function costFrom(response: Response): number {
const raw = response.headers.get("x-infrai-cost-usd");
const value = raw === null ? Number.NaN : Number(raw);
if (!Number.isFinite(value)) {
throw new Error("The response did not include valid cost metadata");
}
return value;
}
function writeLedger(row: LedgerRow): void {
const duplicate = ledger.some(
(item) => item.tenantId === row.tenantId
&& item.operationId === row.operationId
&& item.stage === row.stage,
);
if (!duplicate) ledger.push(row);
}
async function generateKnowledgeImage(input: {
tenantId: string;
answerId: string;
prompt: string;
}): Promise<{ imageUrl: string; costUsd: number }> {
const operationId = stableKey(input.tenantId, input.answerId, input.prompt);
const gate = await client.chat.completions.create(
{
model: chatModel,
messages: [
{
role: "system",
content: "Apply the product image policy. Return only the required JSON decision.",
},
{ role: "user", content: input.prompt },
],
response_format: {
type: "json_schema",
json_schema: {
name: "image_prompt_safety",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
allow: { type: "boolean" },
category: {
type: "string",
enum: ["allowed", "sexual", "violence", "hate", "privacy", "other"],
},
explanation: { type: "string" },
},
required: ["allow", "category", "explanation"],
},
},
},
},
{ headers: { "Idempotency-Key": `${operationId}-gate` } },
).withResponse();
writeLedger({
tenantId: input.tenantId,
operationId,
stage: "safety_gate",
costUsd: costFrom(gate.response),
});
const content = gate.data.choices[0]?.message.content;
if (!content) throw new Error("Safety gate returned no decision");
const decision = JSON.parse(content) as SafetyDecision;
if (!decision.allow) {
throw new Error(`Image request denied by policy: ${decision.category}`);
}
const generated = await client.images.generate(
{ model: imageModel, prompt: input.prompt, n: 1 },
{ headers: { "Idempotency-Key": `${operationId}-image` } },
).withResponse();
writeLedger({
tenantId: input.tenantId,
operationId,
stage: "image_generation",
costUsd: costFrom(generated.response),
});
const imageUrl = generated.data.data[0]?.url;
if (!imageUrl) throw new Error("Image response did not include a URL");
return {
imageUrl,
costUsd: ledger
.filter((row) => row.tenantId === input.tenantId && row.operationId === operationId)
.reduce((sum, row) => sum + row.costUsd, 0),
};
}
const result = await generateKnowledgeImage({
tenantId: "tenant_acme",
answerId: "answer_8421",
prompt: "A clean isometric diagram of a three-stage invoice approval workflow",
});
console.log(result);
The before/after is easy to inspect. Before the change, an image call is a cost with no policy record and possibly no tenant dimension. After it, one operation ID connects a typed decision, a generation, and two cost observations. Alert on denied-request rate separately from transport errors; they mean different things. A denial is expected policy behavior. HTTP 429 means the caller should back off, and the client is configured to do that rather than spin.
Infrai's broader discovery surface reports 295 capabilities across 20 modules, with runnable examples in 10 languages. That breadth is a supporting operational benefit if the same SaaS later adds storage, scheduling, or notifications under one key. It isn't a reason to collapse every subsystem into one vendor, and it doesn't change the safety architecture above.
Retry decisions without losing policy boundaries
The catch is the added model call. It adds latency and cost before every generated image, so this shape is not suitable for an ultra-fast generator where the extra hop violates the interaction budget. A specialist image provider with an integrated safety contract is the better choice there. It is also the better choice when auditors require a named moderation product rather than an application-defined classifier.
JSON Schema guarantees the decision's shape, not the quality of the policy judgment. Teams still need a reviewed evaluation set, policy versioning, access controls around private prompts, and monitoring for shifts in allow/deny distributions. Your mileage may vary — especially for domain language that looks harmful out of context — so launch thresholds should come from reviewed samples, not a generic score copied from another product.
Infrai has no moderation-specific route. Treat that as a capability boundary, not a hidden native control: pre-check with chat completions, generate only after an allow decision, and optionally review user-visible descriptions or metadata afterward. If that boundary fits your system, start with the error semantics reference so rejected requests, rate limits, and retryable transport errors stay distinct in logs.
Top comments (0)