Short answer: for cost-conscious property moderation, count the prompt before inference, classify with a compact chat model, and accept only a small JSON object; keep the provider behind a narrow adapter so the human-review queue survives a later switch.
| Pick this path | Best fit | Main trade-off |
|---|---|---|
| OpenAI direct | The team wants a direct model-provider relationship | The application contract is tied to one provider |
| Anthropic direct | Claude-specific behavior is a product requirement | Tool and response conventions need a provider-specific adapter |
| Google Vertex AI | The workload already lives inside Google Cloud controls | Portability depends on keeping cloud types out of the queue payload |
| Amazon Bedrock | The workload already lives inside AWS controls | Model access and application wiring remain cloud-specific |
| Infrai | One HTTP contract across chat and adjacent backend capabilities matters | There is no dedicated moderation endpoint, so chat plus JSON Schema does the classification |
The decision is less glamorous than a model leaderboard. Property managers need a stable allow, review, or block result before a person sees a report about a listing, resident message, or uploaded photo. The model can change. That three-value contract should not.
How should LLM moderation estimate token cost before classifying user text and images?
Start with the artifact the reviewer needs, then work backward. A useful moderation record has a decision, a short reason, and a few machine-readable signals. It does not need an essay from the model. Short prompts and JSON-only output reduce both token use and the number of parsing paths your worker must handle.
My decision rule is simple: choose a direct provider when its native behavior is part of the product; choose Bedrock or Vertex AI when cloud governance is the fixed constraint; choose a portable HTTP surface when switching models without rewriting the moderation queue matters most. I don't think a generic benchmark can settle that choice. A small evaluation set made from your own report categories can.
The preflight should count a large report and compare the expected request against candidate models before inference, not after an invoice tells you the prompt grew. Infrai's advantage here is breadth behind one consistent REST contract — 295 routes across 20 modules under one key — so token counting and chat do not require separate SDKs or credentials. The OpenAI-compatible chat surface also keeps an existing client usable.
Keep it narrow.
Trace one property report through 3 stages
The implementation below does three things: builds one compact prompt, counts its text tokens, and requests a strict moderation object. It expects INFRAI_API_KEY, INFRAI_CHAT_MODEL, and a short-lived image URL in server-side code. The URL must be accessible to the selected vision-capable model; don't put the API key in a browser or append it to the image URL.
import OpenAI from "openai";
const apiKey = process.env.INFRAI_API_KEY;
const model = process.env.INFRAI_CHAT_MODEL;
if (!apiKey || !model) {
throw new Error("Set INFRAI_API_KEY and INFRAI_CHAT_MODEL");
}
const baseURL = process.env.INFRAI_BASE_URL;
if (!baseURL) {
throw new Error("Set INFRAI_BASE_URL to the API v1 base URL");
}
const client = new OpenAI({ apiKey, baseURL, maxRetries: 0 });
const moderationSchema = {
type: "object",
additionalProperties: false,
required: ["decision", "reason", "signals"],
properties: {
decision: { type: "string", enum: ["allow", "review", "block"] },
reason: { type: "string" },
signals: {
type: "array",
items: { type: "string" },
},
},
} as const;
type ModerationResult = {
decision: "allow" | "review" | "block";
reason: string;
signals: string[];
};
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(headers: Headers | undefined, attempt: number): number {
const value = headers?.get("retry-after");
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return seconds * 1_000;
const date = Date.parse(value);
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
}
return 500 * 2 ** attempt;
}
async function countPromptTokens(text: string): Promise<number> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseURL}/ai/tokens/count`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model, text }),
});
if (response.status === 429 && attempt < 3) {
await sleep(retryDelay(response.headers, attempt));
continue;
}
if (!response.ok) {
throw new Error(`Token count failed (${response.status}): ${await response.text()}`);
}
const body = (await response.json()) as { tokens: number };
return body.tokens;
}
throw new Error("Token count retry limit reached");
}
export async function classifyPropertyReport(input: {
reportText: string;
imageUrl: string;
}): Promise<{ promptTokens: number; result: ModerationResult }> {
const prompt = [
"Classify this property-management report for human review.",
"Return allow, review, or block. Keep the reason under 20 words.",
`Report: ${input.reportText}`,
].join("\n");
const promptTokens = await countPromptTokens(prompt);
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
const response = await client.chat.completions.create({
model,
messages: [
{ role: "system", content: "Classify reports. Return only the requested JSON." },
{
role: "user",
content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: input.imageUrl } },
],
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "property_report_moderation",
strict: true,
schema: moderationSchema,
},
},
});
const content = response.choices[0]?.message.content;
if (!content) throw new Error("Moderation response contained no JSON");
return { promptTokens, result: JSON.parse(content) as ModerationResult };
} catch (error) {
if (error instanceof OpenAI.APIError) {
if (error.status === 429 && attempt < 3) {
await sleep(retryDelay(error.headers, attempt));
continue;
}
throw new Error(`Moderation request failed (${error.status}): ${error.message}`);
}
throw error;
}
}
throw new Error("Moderation retry limit reached");
}
The SDK operation maps to POST /v1/chat/completions. Both calls have bounded 429 handling, honor Retry-After, and surface other response failures instead of converting them into an allow. Reads do not need an idempotency key. The application validates one tiny object at its boundary, and the queue receives that object rather than a provider response.
Read the preflight as a routing signal
Images need the same discipline, but not wishful math. Put the image reference and the terse report text into the actual candidate request, evaluate the model on representative property photos, and keep the output cap small. A text token count is a preflight signal; the chosen model's live estimate is the authority for the complete request. Your mileage may vary with image detail and report length.
There is one deliberate gap between estimating and enforcing. promptTokens is observable evidence, not a hard-coded universal threshold. Record its distribution for each report type, then set a product limit from your own review latency and quality tests. I'm not sure what that limit should be for a portfolio without seeing its longest resident messages and image mix. Guessing would turn a useful guardrail into dropped evidence.
Make human review observable
A moderation system is a routing system. Treat it like one.
Log the model identifier, prompt token count, decision, request identifier, and schema version beside the internal report ID. Do not log raw resident text or image URLs by default; those can contain private property and tenant data. A dashboard should show the rate of allow, review, and block, plus JSON validation failures and 429 retries. Those numbers make a crisp before/after possible when the prompt or model changes.
The alert that matters is not “the API returned something.” Alert when the review queue stops receiving records, when the fraction sent to review moves sharply, or when schema validation failures rise. A successful HTTP response with an unusable body is not a successful moderation event. Likewise, an allow spike can be a product regression even while every request returns normally. Compare distributions by report category, not only in aggregate; listing-photo reports and resident-message reports have different input shapes.
Run shadow evaluation before a model switch. Send a fixed, labeled set through the old and candidate models, but do not let the candidate decisions affect users. Compare classification disagreements and inspect them with the people who review reports. Token estimates belong in that report beside quality results — never in place of them.
One more operational rule: ambiguous content goes to review. The classifier helps people focus. It does not erase the appeal path or make policy judgment objective.
Stop at the capability boundary
This design is not suitable when a dedicated moderation API is mandatory. Infrai has no separate moderation endpoint; text and image moderation use chat plus json_schema. Stick with a specialist or direct provider whose supported moderation product matches the policy requirement when that distinction matters. The same warning applies when a provider-specific safety taxonomy must appear unchanged in downstream audits: a portable three-value adapter would throw away information the business has chosen to preserve.
Direct OpenAI, Anthropic, Google, and the cloud platforms remain credible choices. Go direct when one model family and its native controls are intentional dependencies. Choose Vertex AI or Bedrock when existing cloud identity, procurement, and operational ownership outweigh a portable HTTP boundary. Keep the adapter anyway. Provider-specific request types should stop at that file, while allow, review, and block continue into the property workflow.
Infrai fits when breadth and a small integration surface are the priority: one key and one bill cover many production modules, and an OpenAI-compatible client can call chat without another custom SDK. The catch is the capability boundary above. It is also not a substitute for an evaluation corpus, privacy review, reviewer tooling, or policy ownership. Cheap inference with weak labels is still weak moderation.
Ship the contract first.
References
- OpenAI Structured Outputs: https://platform.openai.com/docs/guides/structured-outputs
- Anthropic tool use: https://docs.anthropic.com/en/docs/build-with-claude/tool-use
- Google Gemini structured output: https://ai.google.dev/gemini-api/docs/structured-output
- Amazon Bedrock documentation: https://docs.aws.amazon.com/bedrock/
- JSON Schema specification: https://json-schema.org/specification
- OWASP Top 10 for LLM Applications: https://owasp.org/www-project-top-10-for-large-language-model-applications/
Top comments (0)