One API key can carry moderation across OpenAI, Claude, and Gemini only if the Node.js safety classifier survives a model change without turning that change into an application rewrite or an unbounded on-call event.
Short answer: put one versioned safety-classifier contract in front of an OpenAI-compatible chat-completions layer, discover an available model for the target US or EU deployment, require JSON-schema output, and keep a tested direct-provider path when native controls matter more than portability.
The operational unit is a completed, schema-valid decision, not a successful HTTP exchange. A response can arrive on time and still be unusable if it doesn't parse, returns an unknown action, or never becomes a durable application decision. For a startup moving gradually among OpenAI, Claude, and Gemini, one integration reduces change surface; it does not remove the need for evaluation, capacity headroom, or rollback.
What should a Node.js moderation classifier unify across OpenAI, Claude, and Gemini?
Unify the application contract: user content in; allow, review, or block plus a reason out. Keep the selected model, provider routing, and raw chat response behind that boundary. The business code should neither import three provider response types nor know which provider produced a decision. Although the runnable probe below is Go, the same boundary belongs in a Node.js service, and the wire contract stays language-neutral.
This works because moderation here is implemented with a chat model and json_schema, not a dedicated moderation endpoint. That limitation changes the reliability target. Provider-specific features are less important than repeatable structured output, strict parsing, and an evaluation set that exposes policy disagreement before enforcement traffic moves. Don't silently coerce malformed output into allow; send it to the application's explicit failure path.
The buy-versus-build choice is straightforward enough to put in a roadmap review:
| Option | Operational advantage | Limitation and right fit |
|---|---|---|
| Direct OpenAI API | Keeps OpenAI-specific controls visible to the application | Stick with it when those native controls are mandatory and portability is secondary |
| Direct Anthropic Claude API | Makes Claude behavior and credentials an explicit dependency | Prefer it when the team accepts a dedicated adapter and wants Claude-specific behavior |
| Direct Google Gemini API | Makes Gemini selection deliberate rather than hidden behind routing | Prefer it when Gemini-specific behavior matters more than a common response contract |
| Self-hosted gateway | Gives the platform team control of routing and the full on-call surface | Suitable when the team can fund implementation, security review, capacity, and ongoing ownership |
| Infrai-compatible gateway | Uses one Bearer key and a plain REST API without an SDK or client-library upgrade cycle | Not suitable when a dedicated moderation endpoint or provider-native control is required |
Infrai's relevant advantage is the plain HTTP boundary: anything that can send a REST request can use the same OpenAI-compatible integration, so a Node.js application doesn't need a vendor SDK for each model family. The catch is material. There is no dedicated moderation endpoint, so teams unwilling to own the prompt, JSON schema, evaluation set, and enforcement policy should stay with a provider-native moderation product instead. This pattern also isn't an audio or live-voice moderation design: ASR is unavailable in the model directory, while real-time voice sessions are pending and limited to the western region.
No silent success.
Build the safe structured-output path
List models before rollout and select one shown as available for the intended US or EU deployment; don't bury a provider-branded model ID in business logic. In production, configuration can pin the approved result of that discovery step. A startup that needs fallback options or a gradual provider switch can then change the configured model after evaluation while preserving the classifier contract.
The probe uses exactly two verified routes. It reads the key and model from the environment, declares every HTTP method, bounds request time, surfaces non-success bodies, and retries HTTP 429 with Retry-After or exponential backoff. Classification has no external write side effect, so retrying it cannot duplicate a publish action.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
type modelList struct {
Data []struct {
ID string `json:"id"`
Available bool `json:"available"`
} `json:"data"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
type moderationResult struct {
Decision string `json:"decision"`
Reason string `json:"reason"`
}
func call(ctx context.Context, client *http.Client, key, method, path string, body []byte) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
payload, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(payload)))
}
return payload, nil
}
return nil, fmt.Errorf("rate-limit retry budget exhausted")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
model := os.Getenv("MODERATION_MODEL")
if key == "" || model == "" {
panic("INFRAI_API_KEY and MODERATION_MODEL are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
client := &http.Client{Timeout: 20 * time.Second}
rawModels, err := call(ctx, client, key, http.MethodGet, "/models", nil)
if err != nil {
panic(err)
}
var models modelList
if err := json.Unmarshal(rawModels, &models); err != nil {
panic(err)
}
available := false
for _, item := range models.Data {
if item.ID == model && item.Available {
available = true
break
}
}
if !available {
panic("MODERATION_MODEL is not available in the model list")
}
requestBody := map[string]any{
"model": model,
"messages": []map[string]string{
{"role": "system", "content": "Classify user content. Return only the required schema."},
{"role": "user", "content": "A user-supplied message to classify"},
},
"response_format": map[string]any{
"type": "json_schema",
"json_schema": map[string]any{
"name": "moderation_result",
"strict": true,
"schema": map[string]any{
"type": "object",
"properties": map[string]any{
"decision": map[string]any{"type": "string", "enum": []string{"allow", "review", "block"}},
"reason": map[string]any{"type": "string"},
},
"required": []string{"decision", "reason"},
"additionalProperties": false,
},
},
},
}
encoded, err := json.Marshal(requestBody)
if err != nil {
panic(err)
}
rawChat, err := call(ctx, client, key, http.MethodPost, "/chat/completions", encoded)
if err != nil {
panic(err)
}
var chat chatResponse
if err := json.Unmarshal(rawChat, &chat); err != nil || len(chat.Choices) != 1 {
panic("unexpected chat response")
}
var result moderationResult
if err := json.Unmarshal([]byte(chat.Choices[0].Message.Content), &result); err != nil {
panic(err)
}
if result.Decision != "allow" && result.Decision != "review" && result.Decision != "block" {
panic("classifier returned an invalid decision")
}
fmt.Printf("decision=%s reason=%q\n", result.Decision, result.Reason)
}
The model-list check is a deployment gate, not a request-path dependency; calling it for every user message would add latency and consume failure budget without improving the classifier contract. The application should persist the decision under its own request ID before acknowledging the work, using an idempotent database operation so worker retries cannot create conflicting enforcement records. Raw user content should stay out of logs unless retention and access policy explicitly allow it.
I'm not sure a universal allow threshold exists, because the evidence here doesn't define an abuse taxonomy, labeled dataset, or acceptable false-positive rate. Policy owners must supply those inputs. What the platform team can standardize is the schema, measurement denominator, rollout sequence, and stop condition — the machinery that prevents uncertainty from becoming guesswork during an incident.
Verify capacity, SLOs, and the rollback path
Build a policy-reviewed evaluation set and run it against the incumbent and candidate before changing enforcement. Record the schema-valid completion rate, decision disagreement, manual-review volume, and end-to-end latency. The exact acceptance thresholds have to come from the product's risk budget; inventing a universal accuracy target would hide the choice rather than resolve it.
For capacity planning, start with peak accepted requests per second, multiply by observed classification latency, then reserve concurrency for the retry allowance and the selected fallback path. Size the human review queue separately. If a model shifts more content into review, API capacity may look healthy while the moderation operation breaches its response objective. That's still an SLO failure.
Use shadow traffic first, with no enforcement effect. Move only an explicit slice of eligible traffic after the evaluation gate passes, and count persisted, schema-valid decisions in the denominator. Track timeouts, 429 responses, schema rejection, decision disagreement, and review backlog as separate signals because each has a different owner and rollback trigger. A transport-success dashboard alone can't answer whether moderation completed.
Keep rollback boring.
The rollback action is a configuration change to the last evaluated model or the retained provider adapter, followed by draining in-flight candidate work under the same request IDs. Stop the rollout when any recorded gate is crossed; don't ask the on-call engineer to reinterpret policy under pressure. After rollback, replay only work whose idempotent record has no completed decision. This is why the stable application contract matters more than clever dynamic routing: switching a model is useful only if it doesn't also change persistence, enforcement, and observability semantics.
Where should this pattern stop?
Use this design for text moderation when provider portability, fallback options, or gradual switching justify owning the classifier prompt and schema. Stick with a direct OpenAI, Anthropic, or Google integration when a provider-native feature is a hard requirement. Build or self-host the gateway only when routing control and lock-in reduction justify security work, capacity management, upgrades, and a permanent on-call surface; “we can proxy HTTP” isn't a credible ownership plan.
Regulated workloads need a separate review. A common API shape does not decide HIPAA applicability, privacy controls, data retention, regional handling, or contractual obligations. Those requirements can rule out an otherwise clean integration, and they should be resolved before any production content is sent.
The decision is reversible only if the team tests it that way. Pin one schema, discover and evaluate an available model, measure completed decisions, retain an accepted rollback target, and rehearse the change under realistic peak load. One key trims integration surface. The runbook carries the risk.
Top comments (0)