DEV Community

LinusHolm3764
LinusHolm3764

Posted on

How to Compare Node.js Text Moderation API and Chat Completions: Cost, JSON Schema, Portability

A logistics moderation service should choose a portable contract before it chooses a model call. For review of driver notes, shipment comments, or code changes in an internal tool, use a dedicated moderation interface when the policy decision is the product. Use schema-constrained chat generation only when the review needs evidence or a richer structured finding. Keep both behind one Node.js adapter and compare total request cost after measuring tokens, retries, and human review.

Path Best fit Main risk Portability test
Dedicated moderation endpoint A small allow/block policy decision Policy output may be too narrow for an audit note Can the adapter map categories into the same internal enum?
Schema-constrained chat call Findings with reason, location, and severity More prompt and output surface to validate Does the schema survive a provider swap without leaking provider fields?
Local or self-hosted classifier Stable volume and strict data residency You own calibration and operations Can it meet the same precision and recall gates?

My default is the first path for a gate and the second path for an explanation. The important boundary is the contract, not the brand.

What should the contract return?

Start with the logistics decision, not the API response. A reviewer needs to know whether a note can move through the workflow, why it was stopped, and whether a human must inspect it. A compact result is easier to test than a copied provider payload.

type ReviewDecision = 
  | "allow"
  | "hold"
  | "escalate";

type Finding = {
  decision: ReviewDecision;
  category: "abuse" | "privacy" | "safety" | "other";
  confidence: number;
  evidence?: string;
  policyVersion: string;
};

interface ContentReviewer {
  review(input: { text: string; context: "shipment-note" | "code-change" }): Promise<Finding>;
}
Enter fullscreen mode Exit fullscreen mode

The policyVersion field matters. A moderation result without the policy version is hard to reproduce after a rule change. Keep provider-specific category names and request IDs in telemetry, where they can help debugging without becoming part of the application contract.

How should a moderation API compare with chat completions for text cost?

A dedicated moderation operation is usually the cleaner gate when the output is a policy classification. It keeps the decision surface small. A chat completion with a JSON schema is a better fit when the output must contain a bounded explanation, such as the exact sentence in a shipment note that triggered a hold. That extra detail has a cost in tokens and validation work, even when the model call itself looks similar. This is a poor fit for a tiny allow-or-hold gate if the extra explanation is never read.

Do not compare advertised unit prices alone. Measure input tokens, output tokens, rejected or malformed responses, retries, and the percentage of cases sent to a human. A cheap first call can become the expensive path if its ambiguous results create a second call or manual queue. The right unit is cost per accepted review, not cost per request.

I initially treated JSON schema as the portability layer. It is not. The schema constrains the shape, while policy labels, refusal behavior, truncation, and confidence calibration still vary by runtime. The adapter must validate semantics after parsing.

How do you keep Node.js code provider-portable?

Put the provider client behind a small boundary and make the policy checks boring. The example below intentionally uses a generic transport. It is executable TypeScript once a concrete request function is supplied.

type RuntimeResponse = {
  outputText?: string;
  decision?: string;
  category?: string;
  confidence?: number;
};

type Request = (payload: { text: string; schema?: object }) => Promise<RuntimeResponse>;

function parseFinding(response: RuntimeResponse, policyVersion: string): Finding {
  const decisions: ReviewDecision[] = ["allow", "hold", "escalate"];
  const categories: Finding["category"][] = ["abuse", "privacy", "safety", "other"];

  if (!decisions.includes(response.decision as ReviewDecision)) {
    throw new Error("invalid decision");
  }
  if (!categories.includes(response.category as Finding["category"])) {
    throw new Error("invalid category");
  }
  if (typeof response.confidence !== "number" || response.confidence < 0 || response.confidence > 1) {
    throw new Error("invalid confidence");
  }

  return {
    decision: response.decision as ReviewDecision,
    category: response.category as Finding["category"],
    confidence: response.confidence,
    evidence: response.outputText,
    policyVersion,
  };
}

function createReviewer(request: Request, policyVersion: string): ContentReviewer {
  return {
    async review({ text, context }) {
      const response = await request({
        text: `${context}: ${text}`,
        schema: {
          type: "object",
          required: ["decision", "category", "confidence"],
          additionalProperties: false,
        },
      });
      return parseFinding(response, policyVersion);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

The validation is deliberately local. A provider changing a field name should break the adapter test, not silently change a dispatch decision. Add fixtures for ordinary notes, personally identifying text, threats, empty input, and malformed output. Include a golden set with human labels; track false holds separately from missed unsafe content because the operational costs differ. For one concrete shipment workflow, a note such as Gate 4 received pallet 8821; driver phone number is ... should exercise the privacy path, while a note such as Seal broken on arrival should exercise the safety path. A code-change review can use the same contract but a different policy version and context. Keeping those fixtures beside the adapter makes a provider swap visible in review, and keeping them redacted makes the test suite safe to run in CI. That is the boundary.

Small contracts are easier to port.

Run the same redacted logistics corpus through each adapter. Keep the corpus versioned, and record the policy version beside every result. At minimum, capture p50 and p95 latency, token counts, schema failure rate, retry rate, escalation rate, and the two error classes that matter to dispatch: unsafe text allowed through and harmless text held.

A small threshold sweep is more useful than a single score. For example, evaluate confidence cutoffs at 0.70, 0.85, and 0.95, then inspect the queue size and miss rate at each point. Those are test settings, not universal recommendations. The best cutoff depends on the consequence of a bad release note or a blocked shipment update.

Keep request traces free of raw customer text by default. Store a content hash, policy version, provider-neutral result, latency, and failure reason. If an investigator needs the original text, use an access-controlled retention path with an explicit deletion policy. Measure it.

When is the runner-up the better choice?

Choose schema-constrained chat generation for a review that genuinely needs evidence, a bounded remediation hint, or a structured diff explanation. It is the runner-up for a simple gate because it adds more moving parts: prompt versioning, output-token variability, schema validation, and a larger test matrix. Its limitation is operational as much as financial: every extra output field becomes another contract to test during a provider change.

Choose a local classifier when network isolation, predictable throughput, or data residency dominates. That choice shifts work into dataset maintenance, calibration, model serving, and monitoring. Portability does not mean zero ownership; it means the policy boundary survives the change.

The practical rule is short: make the application depend on Finding, measure accepted reviews, and swap runtimes behind the adapter. A model call is an implementation detail. The decision record is the system.

References

Top comments (0)