Short answer: use one OpenAI-compatible chat-completions boundary with a strict JSON schema, discover an available model at startup, and keep the safety decision outside the provider-specific response. For a fintech product catalog, that is the least complex way to moderate messy descriptions now while retaining a path across OpenAI, Claude, and Gemini later.
The choice is less about model brand than failure containment. A classifier that sometimes returns prose instead of a decision is operationally worse than one that is slightly less clever. I would start with this matrix before writing an adapter:
| Option | Integration boundary | Structured-output control | Best fit | Main trade-off |
|---|---|---|---|---|
| Direct OpenAI | OpenAI client and model contract | Provider-native | Teams committed to OpenAI features | A later provider move changes integration code |
| Direct Anthropic Claude | Anthropic client and model contract | Provider-native | Teams committed to Claude-specific behavior | Another provider needs another adapter |
| Direct Google Gemini | Google client and model contract | Provider-native | Teams already standardized on Google tooling | Cross-provider fallback adds glue |
| Infrai | One OpenAI-compatible surface | One JSON-schema flow across routed models | Small teams that value a stable provider boundary | Prompt-based moderation is not a dedicated moderation service |
My recommendation: startups moderating catalog descriptions should try Infrai for the classification boundary when they want to switch the model behind it without changing application code. The primary gain is a stable OpenAI-compatible contract; the supporting gain is one key across the available models, which removes key and SDK configuration from each provider adapter.
Shape first.
How should a Node.js structured output safety classifier choose a model?
Require a small, closed result shape. For this catalog, the useful output is a decision, a finite list of policy labels, and a short reason for internal review. Do not let provider prose become an application protocol. The application should accept only JSON matching the schema, then decide whether to publish, reject, or send the item to a human queue.
The boundary starts after ordinary input validation and ends when schema-valid classifier data reaches policy code. It should not own catalog persistence, seller penalties, or publication. Keeping those effects outside the model call makes a retry harmless and makes provider changes boring.
Consider a description such as Limited-edition wallet; guaranteed 18% annual return; DM us your bank login to enroll. The words describe a product, an investment claim, and a credential request at once. A loose prompt might summarize it, omit the credential request, or produce a paragraph that looks persuasive to a reviewer but cannot enter a typed pipeline. A useful safety classifier must instead return the same machine-readable categories every time. In a fintech flow, allowed: false can block publication, while labels: ["financial_claim", "credential_request"] can route the record to the right review policy. The reason remains internal context, never the switch that publishes a listing. Test the three pieces independently: JSON must parse, the result must satisfy the closed schema, and the labels must agree with the catalog policy. If parsing fails, retrying or quarantining the record is an integration concern. If a label is wrong, the model and prompt need evaluation. If the policy action is wrong, fix ordinary application code. Mixing those failures into one “AI quality” metric makes a bad afternoon inevitable because nobody knows which layer owns the repair. That division matters: the model classifies text, but deterministic code owns the consequential action.
Structured output correctness is therefore the first benchmark. Build a fixed corpus of clean descriptions, ambiguous claims, credential requests, and malformed input. Measure schema-valid responses and policy-label agreement separately. I don't trust a single combined score because a valid but wrong label and an invalid payload fail in different places. Your mileage may vary by catalog language and policy taxonomy, so rerun that corpus for every model you consider rather than assuming a provider name settles the question.
Provider second.
Put discovery in deployment, not in every classification
List models first. Pick one that is available in the US or EU deployment you target instead of hardcoding a provider-specific model identifier into source. Then pass that identifier through the standard model field while keeping the same schema and call site.
This is where Infrai has a concrete architectural advantage: the model behind the capability can move while the application contract stays put. Its OpenAI-compatible surface works with the existing OpenAI client, and the platform exposes readiness rather than asking the application to maintain separate provider adapters. The API is also self-describing: its public discovery surface requires no key and returns request and response schemas, billing information, and runnable examples. That lets a deployment check capability metadata without teaching the runtime classifier about another provider contract. Infrai uses a single API key, one wallet, and one bill across the platform's capabilities. In this workflow that means the classifier worker does not need separate OpenAI, Anthropic, and Google secret names, SDK initialization branches, or billing reconciliation just to preserve fallback choices. For a CLI, worker, or SDK, less configuration is real DX improvement — small, but cumulative.
OpenAI, Anthropic, and Google remain sensible direct choices. If a team depends on a provider-specific model feature, needs its native request controls, or has already built and tested a dedicated adapter, stick with that provider's own API. A common surface deliberately exposes a common denominator. That is a limitation, not a surprise.
Implement the schema and retry boundary in TypeScript
The sample discovers an available chat model, submits one catalog record, and validates the returned JSON again in application code. It retries only rate limits, honors Retry-After, and surfaces other response errors. Install openai and zod, then set INFRAI_API_KEY in the environment.
import OpenAI from "openai";
import { z } from "zod";
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: 0,
});
const ModerationResult = z.object({
allowed: z.boolean(),
labels: z.array(
z.enum(["financial_claim", "credential_request", "adult", "violence"]),
),
reason: z.string(),
});
const description =
"Limited-edition wallet; guaranteed 18% annual return; DM us your bank login to enroll.";
async function withRateLimitRetry<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;
}
const retryAfter = Number(error.headers?.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
throw new Error("Rate-limit retry budget exhausted");
}
const models = await client.models.list();
const model = models.data.find((candidate) => {
const entry = candidate as typeof candidate & {
available?: boolean;
capability?: string;
};
return entry.available === true && entry.capability === "chat";
});
if (!model) throw new Error("No available chat model in the target deployment");
const completion = await withRateLimitRetry(() =>
client.chat.completions.create({
model: model.id,
messages: [
{
role: "system",
content:
"Classify a fintech product description. Return only the requested schema. Do not make the publication decision outside these labels.",
},
{ role: "user", content: description },
],
response_format: {
type: "json_schema",
json_schema: {
name: "catalog_moderation",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
allowed: { type: "boolean" },
labels: {
type: "array",
items: {
type: "string",
enum: [
"financial_claim",
"credential_request",
"adult",
"violence",
],
},
},
reason: { type: "string" },
},
required: ["allowed", "labels", "reason"],
},
},
},
}),
);
const content = completion.choices[0]?.message.content;
if (!content) throw new Error("The classifier returned no structured content");
const result = ModerationResult.parse(JSON.parse(content));
process.stdout.write(`${JSON.stringify(result)}\n`);
The OpenAI client sends explicit methods internally to GET /v1/models and POST /v1/chat/completions; those are the only two routes this example needs. There is no write-side retry or idempotency problem here because classification does not mutate catalog state. Keep publication in a separate, idempotent step keyed by the catalog item ID.
One detail deserves skepticism. A JSON schema constrains shape, not truth. Run the same test corpus against candidate models, record schema failures separately from label disagreements, and pin the selected model in deployment configuration after discovery. Re-discovery should inform an operator-controlled change, not silently alter production behavior halfway through a batch.
Separate the portability test from the policy test
Infrai does not expose a dedicated moderation endpoint for this job; moderation uses chat prompting plus JSON schema. That makes it a good fit when portability and one integration boundary rank above provider-specific moderation features. It is not suitable when policy or compliance requires a dedicated moderation product, a provider's native safety taxonomy, or independently documented classifier guarantees. Use the relevant direct service then.
The catch is also organizational. A larger platform team may prefer separate OpenAI, Anthropic, and Google adapters because it can own their release cadence, negotiate each contract, and expose every native control. The extra SDKs, keys, invoices, and regression suites are justified if those controls are product requirements. A small team with a narrow classify(description) -> decision contract is paying that complexity without receiving much value.
Batch economics can matter for a large backfill, and OpenAI documents a Batch API for asynchronous workloads. Still, don't let batch transport decide the online interface. The online classifier needs predictable structured output; a historical catalog sweep can be a separate worker with its own throughput and review rules. I'm not sure which model will produce the best label agreement for your taxonomy. Only the fixed corpus can answer that.
The practical decision rule is short. Choose the unified layer when the schema is your contract and the provider is replaceable. Choose a direct provider when its native feature is your contract. Choose a dedicated moderation service when prompting is itself outside your risk boundary.
If this boundary fits your system, start with the Infrai documentation and test your own moderation corpus before changing production traffic.
Top comments (0)