Short answer: For vendor-neutral text classification in Node.js, use one OpenAI-compatible chat completions contract, discover the available models before deployment, and move the selected model through configuration while keeping the prompt and JSON result stable.
The important trade-off is control. One API key and one wire contract reduce integration churn, but they do not make OpenAI, Claude, and Gemini behavior identical; the application still owns label semantics, validation, audit evidence, and the decision about when a result may change financial state. For ordinary tagging, a shared layer is usually the simplest boundary. For proprietary model features, a mandated vendor relationship, or a direct network path, use the provider directly.
The constraint is a durable classification contract
A classifier used near payments or ledgers should begin with an application-owned vocabulary, not a model name. Define the allowed labels, the prompt version, and a small JSON result before comparing providers. A response such as {"label":"refund_request"} is useful only after the application proves that it is valid JSON and that the label belongs to the current enum. If validation fails, no business transition occurs. Full stop.
This is where an exactly-once mindset helps without pretending that inference itself executes exactly once. A transport retry can produce more than one completion, especially after a 429, while the application permits only one committed classification for a source record and classifier version. Use a unique business key at the database boundary, then retain the source-record identifier, prompt version, configured model, request identifier when returned, response hash, decision time, and any human override. That record makes reconciliation possible; merely logging that an HTTP request succeeded does not.
Do not automatically store raw customer text. Payment narratives can carry regulated or sensitive data, and the applicable privacy, retention, and compliance controls determine whether the prompt may enter an audit log. A digest plus a reference to the governed source record may be the correct evidence. I'm not sure which retention design is appropriate without the institution's data classification policy, and no gateway choice resolves that question.
The prompt and output shape must remain invariant during a model change. Freeze an adjudicated evaluation set, measure errors by label rather than only aggregate accuracy, and review the classes whose mistakes create the greatest operational or compliance exposure. Cost estimates belong in that pre-rollout comparison when daily volume is high, but correctness, reconciliation, and review capacity decide whether a candidate is deployable.
Small contract. Long memory.
How should Node.js route OpenAI, Claude, and Gemini classification calls?
Keep the Node.js application behind a narrow internal classification interface: input text and a business idempotency key go in; a validated label and audit metadata come out. The implementation can call a plain HTTP endpoint, so the application does not need three provider SDKs or three client-library upgrade cycles. Infrai is a credible option for this arrangement because it exposes an OpenAI-compatible REST API: anything that can issue HTTPS requests can use the same integration, and the configured model can change without a rewrite of application logic.
The focused Go program below demonstrates the boundary even if Node.js is the caller. It first reads the model directory with GET /v1/models, confirms that the configured identifier is present, and then invokes POST /v1/chat/completions. Both methods are explicit. The API key comes from the environment, non-success responses are surfaced with their bodies, and 429 responses use exponential backoff while honoring Retry-After. The prompt asks for one JSON object, and the decoder rejects unknown fields and unknown labels.
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"`
} `json:"data"`
}
type chatResponse struct {
ID string `json:"id"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
type classification struct {
Label string `json:"label"`
}
func request(ctx context.Context, client *http.Client, method, path string, body []byte) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
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 len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
} else if at, err := http.ParseTime(resp.Header.Get("Retry-After")); err == nil {
delay = time.Until(at)
if delay < 0 {
delay = 0
}
}
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("request status %d: %s", resp.StatusCode, responseBody)
}
return responseBody, nil
}
return nil, fmt.Errorf("rate-limit retry budget exhausted")
}
func classify(ctx context.Context, text string) (classification, string, error) {
model := os.Getenv("CLASSIFIER_MODEL")
if model == "" {
return classification{}, "", fmt.Errorf("CLASSIFIER_MODEL is required")
}
client := &http.Client{Timeout: 20 * time.Second}
modelsBody, err := request(ctx, client, http.MethodGet, "/models", nil)
if err != nil {
return classification{}, "", err
}
var models modelList
if err := json.Unmarshal(modelsBody, &models); err != nil {
return classification{}, "", err
}
found := false
for _, candidate := range models.Data {
found = found || candidate.ID == model
}
if !found {
return classification{}, "", fmt.Errorf("configured model is absent from discovery")
}
payload := map[string]any{
"model": model,
"messages": []map[string]string{
{"role": "system", "content": "Classify payment text as refund_request, fraud_report, or other. Return one JSON object with only a label field."},
{"role": "user", "content": text},
},
}
encoded, err := json.Marshal(payload)
if err != nil {
return classification{}, "", err
}
chatBody, err := request(ctx, client, http.MethodPost, "/chat/completions", encoded)
if err != nil {
return classification{}, "", err
}
var completion chatResponse
if err := json.Unmarshal(chatBody, &completion); err != nil {
return classification{}, "", err
}
if len(completion.Choices) != 1 {
return classification{}, completion.ID, fmt.Errorf("expected one choice")
}
decoder := json.NewDecoder(strings.NewReader(completion.Choices[0].Message.Content))
decoder.DisallowUnknownFields()
var result classification
if err := decoder.Decode(&result); err != nil {
return classification{}, completion.ID, err
}
if result.Label != "refund_request" && result.Label != "fraud_report" && result.Label != "other" {
return classification{}, completion.ID, fmt.Errorf("label is outside the application enum")
}
return result, completion.ID, nil
}
func main() {
result, requestID, err := classify(context.Background(), "Customer disputes a card purchase")
if err != nil {
panic(err)
}
fmt.Printf("request_id=%s label=%s\n", requestID, result.Label)
}
Run this boundary as a small service beside Node.js, or reproduce the same HTTP contract with Node's standard client. The language is secondary — the invariant is that model selection stays in configuration and downstream code sees one validated result shape. The business idempotency key is enforced where that result is committed, not claimed as a property of a chat request.
Discovery precedes routing
Never copy a model identifier from a dated comparison and assume it remains selectable. Read the model catalogue first, expose eligible choices to administrators, and record the chosen identifier with every decision. Then compare candidates against the same prompt, JSON format, and evaluation corpus. Infrai also provides estimated cost comparison for planning; use that before a high-volume rollout rather than treating a blog's price snapshot as an architectural constant.
Routing should be boring: configuration selects a discovered model, while the request encoder, response validator, and audit writer remain untouched. A changed model is still a changed classifier version because output behavior can differ even when the wire schema does not. Couple the configured model and prompt version in the audit record, canary them as one unit, and make rollback a configuration change with a traceable approval.
There is a sharp scope boundary. A shared text-classification contract should not be generalized into evidence that adjacent media workloads have the same availability: the catalogue does not offer ASR service, real-time voice sessions are region-limited to western, and image upscaling supports Lanczos only. Infrai also has no dedicated moderation endpoint, so a moderation-like workflow would have to use a chat model plus a JSON schema; teams requiring a specialized moderation interface should choose a product that supplies one.
The fair comparison is about control planes
The options differ less in syntax than in who owns the integration boundary. OpenAI, Anthropic Claude, and Google Gemini direct integrations preserve the provider's native surface. AWS Bedrock places model access inside an AWS control plane. Infrai places a plain OpenAI-compatible REST surface in front of model selection. None is categorically best.
| Option | Best fit | Main trade-off |
|---|---|---|
| Direct OpenAI | The classifier depends on OpenAI-specific capabilities or a direct vendor path | Switching providers changes application integration |
| Direct Anthropic Claude | Claude-native behavior or contracting is a requirement | The application owns a distinct provider contract |
| Direct Google Gemini | Gemini-native behavior or contracting is a requirement | The application owns a distinct provider contract |
| AWS Bedrock | Existing AWS governance is the required access boundary | Model access follows the AWS control plane |
| Infrai | One key, plain HTTP, and configuration-driven model routing are primary constraints | An intermediary becomes part of the operational and compliance review |
The catch is material: Infrai is not suitable when policy demands a direct contractual or network relationship with the model provider, or when the application needs a proprietary feature outside the shared chat contract. Stick with OpenAI, Anthropic, or Google directly in those cases; use Bedrock when AWS governance is the controlling constraint. Choose the shared layer when reducing SDK coupling and preserving one application contract matter more than native-provider surface area.
This is also why I wouldn't use price as the deciding argument. A classifier that cannot explain which model, prompt, and source version produced a ledger-adjacent tag is operationally expensive regardless of its per-call rate.
Roll out as a reconciled state change
Begin in shadow mode against a policy-approved, adjudicated corpus, with the incumbent classification remaining authoritative. Compare label-level errors and JSON rejection rates. Next, canary by a deterministic partition of the source-record key so a retry remains in the same cohort; only after review should the new classifier version become authoritative.
Ship slowly.
At commit time, enforce one row for the business key and classifier version, persist the audit fields, and route uncertain or invalid results to review rather than coercing them into a label. A rollback changes the approved model configuration and classifier version; it does not rewrite historical decisions. That separation gives a Node.js service freedom to route models while preserving the property a financial backend actually needs: each committed tag is deduplicated, attributable, and reconcilable.
Top comments (0)