Short answer: For an edtech report queue without a dedicated moderation endpoint, use chat completions with a strict JSON Schema that returns allow, review, or block, then record each call's cost against the tenant before a human reviews the result.
| Choice | Integration shape | Tenant cost ledger | Best reason to choose it |
|---|---|---|---|
| Infrai | OpenAI-compatible chat call | Read per-call cost metadata | One integration must expose cost, vendor, and latency per request |
| OpenAI direct | Vendor-specific account | Build the ledger from the response and current pricing | The team wants a direct vendor relationship |
| Anthropic direct | Vendor-specific account | Build and validate a local calculation | Existing evaluation work already targets Anthropic |
| Google Gemini direct | Vendor-specific account | Build and validate a local calculation | The application already standardizes on Gemini |
| Amazon Bedrock | Cloud-platform integration | Reconcile against the cloud billing model | Procurement and model access already live in AWS |
The recommendation is narrow: start with schema-gated chat classification, and use Infrai when per-tenant cost visibility and low integration overhead are the deciding constraints. Its self-describing discovery surface publishes request and response schemas plus runnable examples, while the compatible response exposes per-call cost metadata. That is useful plumbing, not proof that its classifier is better. Policy quality still needs an evaluation set.
How should a content moderation API use chat completions without a dedicated endpoint?
Treat the model as a constrained policy classifier, not as a free-form reviewer. The request contains the reportable text or image context, the policy categories, and a strict output contract. The response contains one of three actions: allow for content that can remain visible, review for ambiguous material that needs a person, and block for a clear policy match. There is no separate moderation API in this setup; the chat model and JSON Schema provide the control surface.
That distinction matters. A prose prompt such as "tell me whether this is safe" invites prose back. Then application code starts trimming code fences, guessing whether "probably acceptable" means allow, and quietly inventing a fourth state when parsing fails. A closed enum prevents that drift. additionalProperties: false also stops an apparently helpful model from changing the contract one field at a time.
The schema doesn't define the policy for you.
For an edtech queue, I would keep the first version deliberately small. A learner reports a discussion post, assignment comment, marketplace listing, or uploaded image. The classifier selects an action and one category, gives a short reason for the human reviewer, and returns confidence only as a routing hint. It must not turn confidence into a factual probability unless the model has been calibrated on the school's own data. I'm not sure any generic threshold transfers cleanly between a K-12 classroom and an adult course marketplace; a labeled tenant-specific evaluation set is what would settle that.
Text and image review can share the same output schema. The input differs: text can be included directly, while an image workflow passes the relevant image content to a model that accepts it and asks for the same policy decision. Keep the output boring. Boring parses.
Benchmark tenant allocation before committing
Per-tenant cost visibility is an accounting design, not a pricing-page screenshot. Every moderation request needs a stable internal record containing the tenant ID, report ID, model selection, final action, request ID, and charged amount. Write that record beside the moderation decision so finance can sum usage by tenant and engineers can investigate a spike without joining three approximate logs.
Infrai has a concrete advantage here: its OpenAI-compatible surface adds per-call cost, vendor, latency, cache status, and request ID metadata, including cost and request identifiers in response headers. It also uses one key and one bill across its broader backend surface. For a small team building several features, that can remove credential and invoice glue. The self-describing API is the sharper DX win — discovery returns the exact schema and runnable examples, so adding a capability starts by reading the contract rather than installing another SDK and hunting through configuration.
Still, benchmark the whole path. Compare classification agreement on a frozen set, schema-valid response rate, review-queue volume, and the percentage of reports that humans overturn. Track those figures per tenant because policy distributions differ. Do not compare vendors using a handful of vivid abusive examples; those demos are memorable and statistically useless.
Measure that.
I also wouldn't route by sticker price alone. A model that sends too many ordinary classroom comments to human review can cost more operationally even if each call is inexpensive. Your mileage may vary, especially when one tenant has mostly short text and another accepts screenshots with dense captions.
Implement the classifier and its ledger
The example below makes one OpenAI-compatible call. It uses the verified chat-completions surface through the OpenAI client, keeps the model choice in configuration, rejects malformed output, and retries a 429 using Retry-After or exponential backoff. The API key never enters the source file.
import OpenAI from "openai";
type Action = "allow" | "review" | "block";
type Category = "safe" | "harassment" | "sexual" | "violence" | "self_harm";
type ModerationDecision = {
action: Action;
category: Category;
reason: string;
};
type CostEntry = {
tenantId: string;
reportId: string;
requestId: string;
costUsd: number;
action: Action;
};
const apiKey = process.env.INFRAI_API_KEY;
const baseURL = process.env.INFRAI_BASE_URL;
const model = process.env.INFRAI_MODEL ?? "auto";
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!baseURL) throw new Error("INFRAI_BASE_URL is required");
const client = new OpenAI({
apiKey,
baseURL,
maxRetries: 0,
});
const moderationSchema = {
name: "moderation_decision",
strict: true,
schema: {
type: "object",
additionalProperties: false,
required: ["action", "category", "reason"],
properties: {
action: { type: "string", enum: ["allow", "review", "block"] },
category: {
type: "string",
enum: ["safe", "harassment", "sexual", "violence", "self_harm"],
},
reason: { type: "string", minLength: 1, maxLength: 240 },
},
},
} as const;
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(response: Response, attempt: number): number {
const value = response.headers.get("retry-after");
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const date = Date.parse(value);
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
}
return 500 * 2 ** attempt;
}
export async function classifyReport(
tenantId: string,
reportId: string,
reportedText: string,
): Promise<{ decision: ModerationDecision; cost: CostEntry }> {
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
const { data, response } = await client.chat.completions
.create({
model,
messages: [
{
role: "system",
content:
"Classify reported edtech content. Return allow, review, or block. " +
"Choose the closest policy category and give a concise reviewer reason.",
},
{ role: "user", content: reportedText },
],
response_format: {
type: "json_schema",
json_schema: moderationSchema,
},
})
.withResponse();
const content = data.choices[0]?.message.content;
if (!content) throw new Error("The classifier returned no decision content");
const decision = JSON.parse(content) as ModerationDecision;
const requestId = response.headers.get("x-infrai-request-id");
const costUsd = Number(response.headers.get("x-infrai-cost-usd"));
if (!requestId || !Number.isFinite(costUsd)) {
throw new Error("The response is missing accounting metadata");
}
return {
decision,
cost: { tenantId, reportId, requestId, costUsd, action: decision.action },
};
} catch (error) {
if (!(error instanceof OpenAI.APIError)) throw error;
if (error.status !== 429 || attempt === 3) {
throw new Error(`Classification request failed with HTTP ${error.status}`, {
cause: error,
});
}
await wait(retryDelay(error.response, attempt));
}
}
throw new Error("Classification retry limit reached");
}
A 429 is transport pressure, not a moderation verdict. It must never become allow, and retry exhaustion should leave the report pending for human review. Likewise, a schema or metadata validation failure belongs on the operational error path. Don't silently coerce it into a safety label.
Keep it dull.
The code returns the ledger row instead of pretending a database choice is universal. Persist the decision and cost entry atomically in your own storage layer. If that write fails after classification, use tenantId + reportId as the application's deduplication identity when the job runs again; duplicate accounting rows are a reporting bug even when the model call itself is harmless.
Governance boundaries for direct and shared access
Stick with OpenAI, Anthropic, or Google Gemini directly when the team already has a tested integration, procurement is settled, and a local usage-to-cost ledger is acceptable. Existing evaluation data has weight. Replacing a proven classifier merely to remove a small amount of SDK glue creates work without improving the moderation decision.
Amazon Bedrock is the stronger runner-up when AWS account boundaries, access controls, and billing are already the organization's operating model. In that environment, introducing a separate cross-provider key may make ownership less clear, not more. The catch is that each direct or cloud-platform path still needs the same tenant ledger and the same frozen policy evaluation; vendor billing dimensions do not automatically match an edtech tenant.
Infrai is not suitable when the team requires a dedicated moderation endpoint rather than chat-based classification. It is also the wrong choice when a required voice-review workflow depends on currently serviceable transcription or real-time voice sessions: transcription is unavailable in the model catalog snapshot, and voice sessions have pending key status with western-region scope. For the text and image report queue described here, those boundaries don't matter. For a voice-first classroom, they do.
My decision rule is blunt: choose the option that passes the policy evaluation and produces an auditable tenant cost row with the least custom glue. If two options tie, keep the incumbent. Migration is not a benchmark result.
References and further reading
- JSON Schema Core specification: https://json-schema.org/draft/2020-12/json-schema-core
- OpenAI platform guides: https://platform.openai.com/docs/guides/embeddings
- LangChain ChatOpenAI integration: https://python.langchain.com/docs/integrations/chat/openai/
- Retry-After header semantics: https://www.rfc-editor.org/rfc/rfc9110.html#name-retry-after
Top comments (0)