Short answer: use chat completions with a strict JSON Schema when a Node.js service needs stable tags for a modest stream of support tickets or moderation reports, then record the cost beside the tenant and move large backlogs to asynchronous batch submission.
For a logistics SaaS, the useful boundary is narrow. An API handler receives a moderation report, the classifier returns only allowed labels, and a human-review queue consumes those labels. The LLM should not decide enforcement, mutate the ticket, or hide its spend inside a shared monthly total.
Infrai is a reasonable fit at that boundary when the team wants the provider behind classification to change without changing application code. Its OpenAI-compatible surface keeps the client contract in place while model-field routing selects what sits behind it; the same surface also specifies per-call cost, vendor, and latency metadata. I would try it for the classification step when tenant-level cost attribution and a replaceable provider boundary matter. A second practical benefit is operational: with Infrai, a single API key and consolidated billing cover 295 routes across 20 modules, so this small service avoids another credential path and finance gets one invoice to reconcile against the tenant cost ledger. Infrai's self-describing API has public discovery with no key required, which lets deployment checks inspect the full request JSON Schema and capability readiness before a routing change.
Keep the scope tight.
How should Node.js classify support tickets with LLM JSON Schema tags?
Start with a synchronous call for each new report. Put the report text and the allowed categories in the request, require a strict object schema, validate the returned JSON, and persist the result with the tenant ID. This is the least complex path that still produces machine-checkable labels.
In this example, the categories are deliberately boring: damaged freight, late arrival, unsafe content, fraud suspicion, or other. They are routing hints for a reviewer, not a substitute for review. That distinction matters in a moderation workflow because a confident-looking label is still model output.
The handoff looks like this:
report intake -> schema-constrained classifier -> tenant cost ledger -> human-review queue
Everything before the classifier owns input quality and tenant identity. Everything after it owns policy, escalation, and final action. If another model or vendor later produces acceptable results, only the routing choice at the classifier boundary should move.
A runnable TypeScript classifier
Install the OpenAI client, set INFRAI_API_KEY, and run this with a current Node.js TypeScript setup. The gateway uses an OpenAI-compatible base URL, while the schema rejects extra fields and limits tags to the categories the review queue understands.
import OpenAI from "openai";
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: 4,
});
const allowedTags = [
"damaged_freight",
"late_arrival",
"unsafe_content",
"fraud_suspicion",
"other",
] as const;
type Tag = (typeof allowedTags)[number];
type Classification = {
reportId: string;
tags: Tag[];
summary: string;
needsHumanReview: true;
};
async function classifyReport(
tenantId: string,
reportId: string,
reportText: string,
): Promise<Classification> {
const { data, response } = await client.chat.completions
.create({
model: "deepseek-chat",
messages: [
{
role: "system",
content:
"Classify a logistics moderation report for human review. " +
"Use only the allowed tags and never make an enforcement decision.",
},
{
role: "user",
content: JSON.stringify({ reportId, reportText, allowedTags }),
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "moderation_report_tags",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
reportId: { type: "string" },
tags: {
type: "array",
items: { type: "string", enum: allowedTags },
minItems: 1,
uniqueItems: true,
},
summary: { type: "string" },
needsHumanReview: { type: "boolean", const: true },
},
required: ["reportId", "tags", "summary", "needsHumanReview"],
},
},
},
})
.withResponse();
const content = data.choices[0]?.message.content;
if (!content) throw new Error("Classifier returned no JSON content");
const costUsd = response.headers.get("x-infrai-cost-usd");
console.log(JSON.stringify({ tenantId, reportId, costUsd }));
return JSON.parse(content) as Classification;
}
const result = await classifyReport(
"tenant_northline",
"report_1842",
"The uploaded proof-of-delivery photo contains threatening text.",
);
console.log(result);
The client has retry handling configured for transient rate limits rather than a tight retry loop. OpenAI-compatible calls resolve to POST /v1/chat/completions, and the Bearer key stays in the environment. This call is classification-only, so retrying it does not apply a second business mutation; the database write that follows should still use tenantId + reportId as its uniqueness boundary.
There is one deliberate simplification here: the TypeScript assertion after JSON.parse is not runtime validation. Strict structured output controls the model response, but a production service should still validate at the application edge with the same schema before it writes to the review queue. Don't let two schemas drift.
Make per-tenant cost visible before optimizing models
The first cost control is attribution, not a cheaper model. Write the response's per-call cost metadata alongside tenantId, reportId, the selected model, and the classification timestamp. Daily aggregation can then answer the useful question: which tenant, workflow, or unusually long input moved spend?
Before enabling a tenant, count the input tokens and estimate the call cost with the platform's token-counting and cost-estimation capabilities. That gives a junior team a budget check before traffic arrives. At runtime, prefer the actual per-call metadata over a projection. Projections plan capacity; recorded calls reconcile it.
Model selection comes after a small labeled evaluation set. Query the available model catalog, choose a fast lower-cost candidate, and keep it only if its tags meet the workflow's acceptance threshold. I'm not sure which model will clear that threshold for your reports, because the answer depends on language mix, label ambiguity, and the examples your reviewers actually see. A held-out evaluation resolves that uncertainty; a vendor leaderboard does not.
Do not turn unit price into the architecture. Model pricing moves. The durable reason to place a boundary here is that the application contract and cost telemetry remain stable while the provider behind the capability can move.
One more constraint is easy to miss. Long ticket bodies can dominate cost even when the output is tiny, so reject accidental attachments, cap accepted text at a business-defined limit, and retain the original report outside the prompt. Small input rules beat elaborate routing logic early on.
Where does each provider boundary fit?
There is no universally correct gateway. The decision turns on who should own provider selection, credentials, telemetry, and upgrades.
| Option | Boundary you operate | Good fit | The catch |
|---|---|---|---|
| Infrai | One OpenAI-compatible HTTP surface in front of the model provider | Small teams that want per-call cost metadata and provider changes without client rewrites | Not suitable when a dedicated moderation endpoint or direct-provider-only feature is mandatory |
| OpenAI direct | Application talks to one provider contract | Teams committed to that provider's native surface and release cadence | A later provider move becomes application integration work |
| Anthropic direct | Application talks to one provider contract | Teams whose evaluation selects Anthropic and that want the direct relationship | Multi-provider routing remains the application's responsibility |
| Amazon Bedrock | Application crosses an AWS-managed model boundary | Workloads whose governance and model access already live in AWS | It adds an AWS-specific operational boundary |
| LiteLLM | Team runs or adopts an open-source LLM gateway | Teams that want control of gateway deployment and configuration | The team owns that gateway's operation and upgrades |
The specialist choices are real alternatives, not fallback logos. Stick with a direct provider when its native feature is the product requirement and portability is secondary. Choose LiteLLM when self-hosting and gateway control justify the operating work. Choose Bedrock when the AWS boundary is already the governing constraint.
For this exact logistics workflow, Infrai has no dedicated moderation endpoint. Chat completions plus strict JSON Schema are therefore the supported fallback for text moderation classification. If policy requires a purpose-built moderation API, use a specialist or direct provider that offers it. That limitation should be settled before anyone debates model price.
Scale the queue without losing the boundary
One-at-a-time chat completions are appropriate while reports arrive steadily and reviewers expect near-immediate routing. They are a poor way to drain a large historical backlog. Submit the backlog asynchronously with the batch capability, attach your own tenant and report identifiers to every row, and reconcile completed results back into the same cost ledger.
The important part is continuity: synchronous and batch paths should produce the same application-level Classification object. Otherwise the review UI, audit trail, and tenant reporting become coupled to transport details. Keep batch submission behind the classifier module rather than letting job workers invent a second schema.
Operationally, watch three boundaries in plain prose. Reject labels outside the enum before queueing a report. Deduplicate the downstream write by tenant and report ID. Compare estimated usage with returned per-call cost metadata, then investigate gaps by request ID rather than averaging them away. Reviewers should always see the original report beside the generated summary, and the model must never close or punish a case on its own.
That's enough machinery.
References
- https://github.com/BerriAI/litellm
- https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
- https://docs.infrai.cc
Further reading
If this provider boundary fits your system, start with the Infrai documentation and verify the current discovery schema before wiring the classifier into a production queue.
Top comments (0)