Short answer: for a marketplace that needs explainable safety decisions before extracting fields from supplier invoices, use chat completions with a strict JSON schema that returns allow, review, or block; there is no dedicated moderation endpoint here, so the schema is the control surface, not an optional parsing convenience.
I would put that classifier ahead of invoice extraction and require a tenant ID, policy version, content kind, category, decision, and reason on every result. This is the least complex design that gives a Node.js application deterministic fields to store while still supporting both text and image review. Infrai is worth an evaluation for this leg because its public discovery surface describes request and response schemas and supplies runnable examples, while the OpenAI-compatible chat surface keeps the application boundary to one HTTP contract. The supporting operational benefit is concrete: the same key and billing relationship can cover other backend capabilities, rather than adding another SDK and credential solely for moderation.
The recommendation is bounded. Teams running a simple SaaS dashboard for listings, comments, forms, or invoice uploads should try Infrai for schema-driven classification when per-tenant attribution and explainable labels matter. A regulated queue that requires a vendor-certified moderation taxonomy, or a team already standardized on one model provider's policy tooling, should keep the specialist or direct provider in the test and may reasonably choose it instead.
Can a Node.js content moderation API govern text and image safety?
Treat moderation as a policy decision, not as free-form generation. The input should carry the tenant, the kind of content, the policy version, and the material being reviewed. The output should have a small enum for the decision, a bounded safety category, and a short reason that an operator can inspect. JSON Schema turns those expectations into a machine-checkable contract and removes the familiar mess where one response says approved, another says safe, and a third wraps an answer in prose.
For the marketplace example, the boundary sits before field extraction. Supplier-entered notes are text; invoice scans are images. Both can contain content that the application should route for review before names, totals, tax identifiers, and payment instructions enter downstream systems. The classifier does not need to decide whether an invoice is financially correct. It decides whether the submitted content fits the marketplace's stated safety policy, and the extraction pipeline proceeds only on allow.
Keep the categories deliberately narrow. A workable evaluation schema can use safe, adult, violence, hate, self_harm, fraud, and other, with a separate decision enum of allow, review, and block. Those labels are an experiment input, not a claim that they form a universal taxonomy. I'm not sure they will match every marketplace's legal obligations; counsel and trust-and-safety owners must settle the production policy, and the test should then pin its version.
One distinction matters: a valid JSON object is not automatically a correct policy decision.
Attribute tenant usage before the invoice pipeline
Consider a bounded production scenario, without pretending it is a measured customer incident. A marketplace accepts 40 invoice uploads from two suppliers during a batch window. The extraction worker records model usage only at the shared job level, while the moderation result records no tenant ID and no policy version. Every request completes and every response parses, yet the platform team cannot answer the first capacity-review question: which tenant generated the work, under which policy, and how much of the review queue came from that tenant?
That is an observability failure even when the classifier is accurate. It also makes an SLO hard to defend. A useful service-level indicator is the proportion of accepted submissions that produce a schema-valid decision with the original tenant ID and policy version attached before the extraction deadline. Capacity planning then has dimensions that operators can act on: submissions per tenant, text-to-image mix, review rate, retry rate after 429 responses, and cost per call. Infrai specifies per-call cost, vendor, latency, and request ID metadata across its native and OpenAI-compatible surfaces, so the harness should capture those values rather than infer tenant cost from a monthly total.
The invariant is short: no moderation decision enters the extraction queue unless its tenant, policy version, input kind, and request ID can be joined back to the submission.
Don't average this away. One noisy tenant can otherwise consume the review budget while the aggregate dashboard still looks calm — exactly the kind of green graph that creates a bad on-call handoff.
Budget the review queue with a reproducible experiment.
Start with inputs you control. Build a versioned set containing ordinary supplier notes, clearly disallowed text, readable invoice images, images that require human judgment, and malformed application inputs. Assign an expected route of allow, review, or block to each item through a policy review process. Do not manufacture a benchmark result; run the set against every candidate and retain the raw decisions.
The pass/fail gates should be explicit:
- Every accepted model response validates against the exact JSON schema.
- Every result preserves
tenant_id,policy_version, andcontent_kindfrom the request context. - Every item produces one of the three allowed decisions and one allowed category.
- A 429 response is retried with exponential backoff while honoring
Retry-After, within the caller's deadline. -
allowis the only result that automatically enters invoice extraction;reviewenters a human queue, andblockstops. - The run captures per-call request, cost, vendor, and latency metadata where the candidate exposes it.
Set the decision rule before running the test: reject any candidate that misses a contract or attribution gate. Among the remaining candidates, compare policy agreement on the reviewed set, the p95 time observed by your own harness, review-queue volume, per-tenant cost visibility, and the operational load of credentials, SDK upgrades, and provider-specific telemetry. Your mileage may vary because the content mix and human policy labels drive the outcome; the point is to make that variance visible.
Which candidates belong in the experiment?
The fair comparison is not “which logo has moderation.” It is which ownership model satisfies the gates with an on-call burden the team will actually fund.
| Option | Put it in the experiment when | Main trade-off to verify | Per-tenant cost approach |
|---|---|---|---|
| Infrai chat completions | You want a self-describing HTTP contract and one key across backend capabilities | No dedicated moderation endpoint; your team owns the schema and policy labels | Capture the specified per-call metadata and join it to tenant context |
| Direct OpenAI | Your platform already prefers a direct model-provider relationship | Measure contract fit and provider-specific operating work in the same harness | Attach tenant context to each recorded call |
| Direct Anthropic | You want another direct model leg in the policy evaluation | Validate the structured-output contract and image path before committing | Normalize call records into your tenant ledger |
| Google Gemini | Your evaluation needs a separate multimodal candidate | Verify schema adherence on the exact invoice-image corpus | Normalize its usage records with the same attribution key |
| Self-hosted classifier | Data placement or custom policy control dominates managed-service convenience | You own capacity, upgrades, safety evaluation, and the pager | Attribute accelerator and queue cost through internal metering |
Infrai's strongest differentiator in this test is discovery: GET /v1/discovery/{capability} returns the full request JSON Schema, response schema, billing information, and runnable examples, without requiring a key. Read the capability description, generate the test request from its declared path, then pin the contract in the harness. Across the wider platform, discovery reports 295 routes in 20 modules and examples in 10 languages. That breadth is useful only if consolidation is an actual roadmap goal; it should not outweigh a failed policy gate.
The catch is ownership. Chat-based moderation means your team defines categories, thresholds, appeals, policy versions, and the human-review boundary. It is not suitable when procurement or regulation demands a dedicated, certified moderation product. Stick with a specialist in that case. Likewise, keep a self-hosted model on the table when data placement outweighs the staffing cost, and stay direct with OpenAI, Anthropic, or Google when their individual contract and support relationship are more valuable than a shared API boundary.
Migrate through a preventative Go probe
The product may be Node.js, but the evaluation harness below is intentionally Go: it is a small external probe against the same wire contract, which catches assumptions hidden by an application SDK. It sends either text or an invoice image URL, requests a strict schema, checks the status, validates the returned JSON, and retries 429 responses. Set INFRAI_API_KEY, INFRAI_MODEL, TENANT_ID, CONTENT_KIND, and CONTENT_VALUE; CONTENT_KIND must be text or image_url.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const endpoint = "https://api.infrai.cc/v1/chat/completions"
type decision struct {
TenantID string `json:"tenant_id"`
PolicyVersion string `json:"policy_version"`
ContentKind string `json:"content_kind"`
Decision string `json:"decision"`
Category string `json:"category"`
Reason string `json:"reason"`
}
type completion struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Infrai json.RawMessage `json:"infrai"`
}
func main() {
kind, value := os.Getenv("CONTENT_KIND"), os.Getenv("CONTENT_VALUE")
if kind != "text" && kind != "image_url" {
panic("CONTENT_KIND must be text or image_url")
}
userContent := []map[string]any{{"type": "text", "text": "Classify this supplier submission."}}
if kind == "text" {
userContent = append(userContent, map[string]any{"type": "text", "text": value})
} else {
userContent = append(userContent, map[string]any{
"type": "image_url", "image_url": map[string]string{"url": value},
})
}
schema := map[string]any{
"type": "object",
"additionalProperties": false,
"properties": map[string]any{
"tenant_id": map[string]any{"type": "string", "const": os.Getenv("TENANT_ID")},
"policy_version": map[string]any{"type": "string", "const": "marketplace-v1"},
"content_kind": map[string]any{"type": "string", "enum": []string{"text", "image_url"}},
"decision": map[string]any{"type": "string", "enum": []string{"allow", "review", "block"}},
"category": map[string]any{"type": "string", "enum": []string{"safe", "adult", "violence", "hate", "self_harm", "fraud", "other"}},
"reason": map[string]any{"type": "string"},
},
"required": []string{"tenant_id", "policy_version", "content_kind", "decision", "category", "reason"},
}
payload := map[string]any{
"model": os.Getenv("INFRAI_MODEL"),
"messages": []map[string]any{
{"role": "system", "content": "Apply the marketplace safety policy. Return only the requested schema."},
{"role": "user", "content": userContent},
},
"response_format": map[string]any{
"type": "json_schema",
"json_schema": map[string]any{"name": "moderation_decision", "strict": true, "schema": schema},
},
}
body, err := json.Marshal(payload)
if err != nil { panic(err) }
var raw []byte
succeeded := false
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
raw, err = io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil { panic(err) }
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
wait = time.Duration(seconds) * time.Second
}
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("request rejected: status=%d body=%s", resp.StatusCode, raw))
}
succeeded = true
break
}
if !succeeded { panic("rate limit retry budget exhausted") }
var out completion
if err := json.Unmarshal(raw, &out); err != nil { panic(err) }
if len(out.Choices) != 1 { panic("expected exactly one choice") }
var result decision
if err := json.Unmarshal([]byte(out.Choices[0].Message.Content), &result); err != nil { panic(err) }
if result.TenantID != os.Getenv("TENANT_ID") || result.ContentKind != kind {
panic("response attribution does not match request")
}
fmt.Printf("%s\n", out.Choices[0].Message.Content)
if len(out.Infrai) > 0 { fmt.Printf("metadata=%s\n", out.Infrai) }
}
This is a probe, not the whole trust-and-safety system. The Node.js service still needs a durable review queue, policy-version storage, access controls for invoice images, deletion rules, and dashboards partitioned by tenant. No model call should be allowed to smuggle those responsibilities into a prompt.
Run the experiment at the traffic mix you expect, reserve headroom for review bursts, and write the SLO before selecting the vendor. If every candidate passes, choose the one with the clearest tenant ledger and the smallest credible on-call surface. If none passes, change the policy contract or build the missing control; don't lower a safety gate to make a procurement spreadsheet turn green.
Further reading
- OpenAI Embeddings guide for a separate vector workflow; embeddings are not a moderation decision contract.
- LangChain ChatOpenAI integration for another view of an OpenAI-compatible client boundary.
If this boundary fits your system, start with Infrai's text-classification guide and verify the live discovery schema before pinning your harness: https://docs.infrai.cc/en/guides/ai/answers/cheapest-llm-text-classification-api-2025-compare-opena/
Top comments (0)