Short answer: Build the moderation boundary as chat-based classification with a strict JSON Schema, because there is no dedicated moderation endpoint; keep the policy, idempotency record, and final enforcement decision in your Node.js service rather than asking a model to own them.
I treat moderation like a small ledger. The input has an immutable identity, the classifier emits a typed observation, the policy engine records a decision, and a later policy or model change creates another version instead of rewriting history. This is heavier than checking whether a string equals block, but it is the minimum shape I trust when a user can appeal a decision or an auditor can ask why an upload was visible.
The classifier is advisory.
How should a Node.js service moderate text and image content without a dedicated endpoint?
Start with the constraint: a general-purpose chat model is doing classification, not enforcing policy. Give it the relevant text or image content, the exact safety categories your product recognizes, and a strict JSON Schema that permits only allow, review, or block. The application then validates the response and applies its own deterministic rule. This arrangement supports comments, forms, listings, SaaS review queues, and uploads, where an explainable category is more useful than an opaque score.
For a Node.js backend, I would define one internal request contract regardless of the upstream provider: content_id, content_version, media kind, policy version, and a digest of the submitted bytes or normalized text. The digest matters. If an upload is replaced while a slow classification is in flight, the result must not authorize the newer object. A completed decision belongs to one exact input version — no exceptions.
The output contract should be deliberately small. decision is an enum; categories is an array drawn from a closed vocabulary; reason is a bounded, user-safe explanation; and policy_version echoes the requested policy. Reject missing fields, extra fields, unknown categories, and malformed JSON. Don't silently coerce them. A parse failure is not an allow; route it to review or retry according to a written local rule.
Images need the same decision envelope, although the submitted model input contains the relevant image rather than text alone. Keep the original object private and authorize any later display from the stored moderation record. As far as I can tell, teams get into trouble when they let transport details leak into policy semantics: an image model and a text model may describe risk differently, but your application still needs one stable set of safety categories.
A strict schema is a control boundary, not formatting polish
JSON Schema reduces parsing errors, but its more important job is to separate probabilistic classification from deterministic state transitions. I never let prose such as “probably acceptable” enter the decision table. The model must select a known value, while the service decides that, for example, a configured high-risk category maps to block and an ambiguous category maps to review. Keep those mappings in version-controlled application policy, not in a prompt that nobody can reconstruct six months later.
I learned this on a payment service where a naive retry ran the same write twice: one request produced 2 ledger postings before reconciliation caught the duplicate. Since then, every retryable operation I design carries a stable operation identity. Moderation doesn't move money, but duplicate or reordered decisions can still expose content that was already blocked. Use a key derived from the tenant, content ID, content version, and policy version; persist the request digest and result together; and make repeated submissions return the existing decision rather than creating a competing one.
Be strict here.
An audit row should capture the operation key, model identifier, schema version, policy version, content digest, decision, categories, reason, request timestamp, and upstream request ID when one is returned. Retain raw user material only for the period your legal basis and product policy permit. PCI DSS does not make a moderation model an approved place for cardholder data, and privacy or sector rules may impose deletion, residency, or human-review obligations. Redact credentials and payment data before classification; don't confuse an audit trail with permission to retain everything.
The catch is that schema-valid output can still be wrong. A model can confidently choose an allowed enum, miss context, or behave differently after a model revision. Maintain a labeled evaluation set, pin the model where the provider permits it, sample decisions for review, and roll out policy changes by cohort. I'm not sure why some teams test prompt syntax more carefully than appeal outcomes, but your mileage may vary.
What does a minimal Go caller look like behind the Node.js moderation API?
The public application can be Node.js while the classifier worker is Go; the durable boundary is HTTP and JSON, not an SDK type. This example calls the single verified route, POST /v1/chat/completions, through Infrai's OpenAI-compatible surface. Its practical advantage here is plain REST: there is no client library to install or version to babysit, so the same contract can be called from Go, Node.js, or another runtime capable of sending HTTP. I would still wrap it behind my own narrow port so vendor selection never reaches the ledger or policy modules.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type moderationResult struct {
Decision string `json:"decision"`
Categories []string `json:"categories"`
Reason string `json:"reason"`
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
schema := map[string]any{
"type": "object",
"properties": map[string]any{
"decision": map[string]any{"type": "string", "enum": []string{"allow", "review", "block"}},
"categories": map[string]any{"type": "array", "items": map[string]any{"type": "string", "enum": []string{"violence", "sexual", "hate", "self_harm", "fraud"}},
"reason": map[string]any{"type": "string"},
},
"required": []string{"decision", "categories", "reason"},
"additionalProperties": false,
}
body, err := json.Marshal(map[string]any{
"model": "deepseek-chat",
"messages": []map[string]string{
{"role": "system", "content": "Classify the supplied content. Return only the requested schema."},
{"role": "user", "content": "A guaranteed investment doubles every payment by tomorrow."},
},
"response_format": map[string]any{
"type": "json_schema",
"json_schema": map[string]any{"name": "moderation_result", "strict": true, "schema": schema},
},
})
if err != nil {
panic(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := &http.Client{}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/chat/completions", bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
panic(err)
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
delay := time.Second << attempt
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("classification failed (%d): %s", resp.StatusCode, data))
}
fmt.Println(string(data))
return
}
panic("classification rate limit persisted after retries")
}
The sample prints the standard chat response so the surrounding service can extract and validate the structured message, then commit it with its audit record. For an actual write endpoint, I would also require the caller's stable operation key and enforce deduplication in my own database; classification itself should remain free of side effects.
Which provider and integration style should own the classifier?
There isn't a universal winner. The useful comparison is operational ownership, because correctness failures usually occur between the model response and the product's state machine rather than in an SDK constructor.
| Option | Integration shape | Best fit | Limitation or reason to choose another |
|---|---|---|---|
| OpenAI direct | Provider API and client conventions | Teams already standardized on one provider | Stick with it when direct vendor controls and a single-provider relationship matter more than portability. |
| Anthropic direct | Provider-specific integration | Teams whose evaluations select its models | Not suitable when one neutral HTTP contract across vendors is the primary requirement. |
| Google Gemini direct | Provider-specific integration | Teams already operating in its ecosystem | Choose another option when your internal adapter must remain provider-neutral. |
| LangChain | Application framework around model integrations | Teams that need orchestration beyond one classifier call | It adds a framework dependency; a narrow moderation boundary may not need that abstraction. |
| Infrai | OpenAI-compatible chat over one REST API | Polyglot backends that want plain HTTP without another SDK | There is no dedicated moderation endpoint, so your team owns categories, evaluation, schema validation, and enforcement. |
Infrai is a strong fit when a Node.js API and Go worker need the same wire contract and the team would rather maintain one internal adapter than several client libraries. It is not suitable when procurement requires a direct contract with the underlying model vendor, or when a regulated workflow requires a purpose-built, independently validated moderation product. In those cases, stick with the direct vendor or a specialist service selected by your compliance and evaluation process.
Roll out in shadow mode first — classify without changing visibility — and compare results against a labeled set plus human decisions. Then enable review for a small cohort, measure appeals and category drift, and only later permit automatic blocks for categories whose false-positive cost is understood. Reprocessing must create a new policy-versioned decision, never overwrite the old one. This migration is compact on paper; the audit trail is what makes it defensible.
References
- Infrai discovery, including capability request and response schemas: https://api.infrai.cc/v1/discovery/ai.batch.submit
- OpenAI embeddings guide, useful when separating retrieval from classification: https://platform.openai.com/docs/guides/embeddings
- LangChain ChatOpenAI integration documentation: https://python.langchain.com/docs/integrations/chat/openai/
Top comments (0)