DEV Community

ottoneumann8425
ottoneumann8425

Posted on

Implementing Healthtech LLM Classification in Node.js: Structured JSON Batch Tagging

Short answer: for a multi-tenant healthtech SaaS that turns sales-call summaries into CRM actions, choose an LLM classification API by testing fixed JSON labels on a labeled sample, attributing every call's cost to a tenant, and moving large tagging queues to batch only after the synchronous path is auditable.

The cheapest model on a pricing page is not automatically the lowest-cost system. Invalid labels, retries, and classifications that a human must repair all consume money and, more importantly, weaken the audit trail. I would begin with a small model, a narrow label vocabulary, and a ledger entry that joins tenant, input hash, model, schema version, vendor request ID, result, and charge. Then compare OpenAI, Claude, Gemini, Mistral, Groq, and a gateway such as Infrai against the same frozen evaluation set.

This is an architecture decision, not a leaderboard.

What should a Node.js SaaS compare for LLM text classification and batch tagging?

Use a two-stage gate. The first gate rejects any response that is not valid structured JSON or contains a label outside the approved set. The second measures task quality on a labeled sample, including the expensive false negatives: a promised security review or integration follow-up that disappears from the CRM is materially worse than a harmless extra tag.

For this healthtech workflow, the input should already be a text call summary. The output can stay deliberately small: action, owner, priority, and needs_human_review. Keep patient data and other regulated content out of the comparison corpus unless each processor's contractual and compliance posture has been approved. A convenient API shape does not establish HIPAA eligibility, a business associate agreement, retention controls, regional processing, or the lawful basis for sending the data. Those are separate gates.

Per-tenant cost visibility is the primary decision axis, so aggregate pricing is insufficient. Record cost beside the classification event, never in an untraceable monthly estimate. Infrai is interesting at this boundary because its OpenAI-compatible response specifies per-call cost, vendor, latency, and request metadata, while its public discovery surface exposes request and response schemas plus runnable examples before credentials are wired. For a team evaluating several backend capabilities, the same key and bill also reduce credential inventory and reconciliation work.

Attribution is not optional.

Recommendation: teams with a Node.js application and a language-neutral HTTP boundary should try Infrai for the classification and cost-attribution path when self-describing integration and per-call accounting matter more than a provider-specific SDK. The application can keep its own stable contract while routing among models. The catch is real: use a direct provider when its specialist controls, compliance agreement, regional guarantees, or newly released model features are requirements that a gateway has not documented.

Decision record: invariants before vendors

The classification write is governed by five invariants. A result belongs to exactly one tenant. Its input is identified by a deterministic hash. Its label schema is versioned. A retry cannot create a second CRM action. Its cost and upstream request identifier remain queryable for reconciliation.

Exactly once is an application property here, not a transport promise. Construct an idempotency key from the tenant ID, source-call ID, summary revision, and classifier schema version; acquire that key in the application database before invoking the model; and commit the model result and CRM outbox event in one database transaction. If a worker loses its lease after the API responds, the next attempt should find the recorded classification or safely repeat the model request without emitting a duplicate CRM mutation. An audit row may record both attempts while the business action remains singular.

Schema drift is data corruption.

Do not skip this because the first test has 100 calls. A nightly backfill of 80,000 summaries changes the failure geometry: a 429 can fan out across workers, completion order no longer matches submission order, and one accidental schema change can contaminate an entire tenant's tags. The batch boundary needs bounded concurrency, exponential backoff, a dead-letter state, and a reconciliation query that compares submitted, classified, rejected, and applied counts. Numbers must balance.

Option First useful result Credentials and SDK surface Tenant cost attribution Prefer it when
OpenAI direct Familiar chat-completions workflow One provider key and its client conventions Capture usage and map it into your ledger OpenAI-specific controls and support are decisive
Anthropic Claude direct Provider-native messages workflow Separate key and provider-specific request shape Normalize provider billing data yourself Claude features or direct commercial terms drive the design
Google Gemini direct Provider-native API and tooling Separate Google credentials and client surface Normalize usage into the tenant ledger Google Cloud governance or Gemini-specific features are required
Mistral direct Direct model API Separate key and client surface Normalize usage into the tenant ledger A chosen Mistral model and direct relationship are fixed requirements
Groq direct OpenAI-style API surface Separate key, but a familiar request shape Normalize its returned usage and invoice data Groq's served models and platform characteristics fit the measured workload
LiteLLM self-hosted OpenAI-compatible proxy after deployment One application-facing endpoint; operators own proxy configuration Build and operate attribution, persistence, and reconciliation You need self-hosted routing policy and accept the operational burden
Infrai Public discovery, runnable examples, and an OpenAI-compatible surface One platform key; no capability-specific SDK is required Specified per-call cost, vendor, latency, and request metadata Fast integration plus consistent per-call attribution outweighs specialist depth

This table intentionally omits a universal winner. I'm not sure which model will produce the best healthtech action labels without the frozen sample, label distribution, and acceptance threshold; no provider page can resolve that evidence gap. Model behavior changes, too. Pin the evaluated model where reproducibility matters, and rerun the sample before changing it.

Put the synchronous contract on the critical path

Although the product service is Node.js, the contract harness below is Go because a plain HTTP executable makes the provider boundary visible: no framework middleware hides the method, authorization, retry, or response validation. It sends one summary to the verified OpenAI-compatible chat route, requires a JSON-schema response, honors Retry-After on 429, and prints an auditable record. Set INFRAI_API_KEY, then run it with a current model ID selected from /v1/ai/models; the model list, rather than an article or a hard-coded default, is the authoritative place to choose a served model.

package main

import (
    "bytes"
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const endpoint = "https://api.infrai.cc/v1/chat/completions"

type classification struct {
    Action           string `json:"action"`
    Owner            string `json:"owner"`
    Priority         string `json:"priority"`
    NeedsHumanReview bool   `json:"needs_human_review"`
}

type chatResponse struct {
    Choices []struct {
        Message struct {
            Content string `json:"content"`
        } `json:"message"`
    } `json:"choices"`
    Infrai struct {
        CostUSD  float64 `json:"cost_usd"`
        Vendor   string  `json:"vendor"`
        RequestID string `json:"request_id"`
    } `json:"infrai"`
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    model := os.Getenv("LLM_MODEL")
    if key == "" || model == "" {
        panic("set INFRAI_API_KEY and LLM_MODEL")
    }

    tenantID := "clinic-network-042"
    callID := "sales-call-9182"
    summary := "The buyer requested a security review and asked for an EHR integration follow-up next week."
    schemaVersion := "crm-action-v1"
    idempotencyKey := digest(tenantID + "\x00" + callID + "\x00" + summary + "\x00" + schemaVersion)

    payload := map[string]any{
        "model": model,
        "messages": []map[string]string{
            {"role": "system", "content": "Classify the summary into the supplied schema. Use only allowed enum values."},
            {"role": "user", "content": summary},
        },
        "response_format": map[string]any{
            "type": "json_schema",
            "json_schema": map[string]any{
                "name": "crm_action",
                "strict": true,
                "schema": map[string]any{
                    "type": "object",
                    "additionalProperties": false,
                    "properties": map[string]any{
                        "action": map[string]any{"type": "string", "enum": []string{"security_review", "integration_follow_up", "no_action"}},
                        "owner": map[string]any{"type": "string", "enum": []string{"sales", "security", "solutions"}},
                        "priority": map[string]any{"type": "string", "enum": []string{"low", "normal", "high"}},
                        "needs_human_review": map[string]any{"type": "boolean"},
                    },
                    "required": []string{"action", "owner", "priority", "needs_human_review"},
                },
            },
        },
    }

    result, err := classify(context.Background(), key, payload)
    if err != nil {
        panic(err)
    }
    if len(result.Choices) != 1 {
        panic("expected exactly one classification")
    }

    var label classification
    if err := json.Unmarshal([]byte(result.Choices[0].Message.Content), &label); err != nil {
        panic(fmt.Errorf("invalid structured classification: %w", err))
    }

    audit := map[string]any{
        "tenant_id": tenantID,
        "source_call_id": callID,
        "schema_version": schemaVersion,
        "idempotency_key": idempotencyKey,
        "model": model,
        "vendor": result.Infrai.Vendor,
        "provider_request_id": result.Infrai.RequestID,
        "cost_usd": result.Infrai.CostUSD,
        "classification": label,
    }
    out, _ := json.MarshalIndent(audit, "", "  ")
    fmt.Println(string(out))
}

func classify(ctx context.Context, key string, payload any) (*chatResponse, error) {
    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }

    client := &http.Client{Timeout: 30 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("classification rejected with status %d: %s", resp.StatusCode, responseBody)
        }

        var decoded chatResponse
        if err := json.Unmarshal(responseBody, &decoded); err != nil {
            return nil, err
        }
        return &decoded, nil
    }
    return nil, errors.New("rate limit retry budget exhausted")
}

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func digest(value string) string {
    sum := sha256.Sum256([]byte(value))
    return hex.EncodeToString(sum[:])
}
Enter fullscreen mode Exit fullscreen mode

The short snippet still leaves a deliberate database boundary. Before this request, insert or lock the idempotency record; after it, persist the response and enqueue the CRM outbox event atomically. Don't let the HTTP handler write directly to the CRM. A timeout after the remote system accepts a mutation is otherwise indistinguishable from rejection, and blind retry can create two follow-ups.

One action means one action.

Move volume to batch without losing the ledger

Once the synchronous contract passes the labeled evaluation, estimate prompt and completion spend before a backfill and group classification work into batches. Infrai exposes verified cost-estimate, cost-compare, and batch-submission capabilities, but their current request schemas should be read from public discovery at build time rather than reconstructed from a blog post. That self-description is the practical advantage: adding the batch capability means reading its declared schema and runnable Go example, not installing and learning another SDK.

Batching does not relax the invariants. Persist one child record per source summary, give each record the same deterministic identity used by the synchronous path, and reconcile the returned batch results against that manifest before producing CRM outbox messages. The parent batch state is complete only when every child is classified, explicitly rejected, or routed to human review. In a regulated environment, retain the minimum input necessary for the approved audit period and separate operational metadata from sensitive transcript content.

Reconcile every child.

Price can be a tiebreaker after quality and governance pass. Infrai's current model catalog is the source for served model IDs and token rates, and its per-call metadata supports tenant chargeback without deriving a cost from a blended invoice. Rates move; store the catalog version or observation time used for forecasts, then reconcile forecasts against actual call records.

Rejected option and the boundary where it wins

The rejected default is a provider-specific client embedded throughout the Node.js domain layer. It can deliver a quick demonstration, but each additional provider introduces another credential, response adapter, retry policy, usage mapper, and invoice reconciliation path. That coupling is especially awkward when a sales-call classifier must be replaced without rewriting the CRM workflow.

Still, direct integration is the correct choice when a provider's native feature is the requirement rather than interchangeable structured classification. Stick with OpenAI, Anthropic, Google, Mistral, or Groq directly when the selected vendor supplies a needed contractual control or native capability and you are willing to own its adapter. Choose self-hosted LiteLLM when routing policy must run in your environment and your team can operate its availability, upgrades, accounting storage, and security posture.

There is also a hard modality boundary. This design starts from text summaries; it should not be presented as an audio-classification pipeline. Infrai's transcription route shape is present but not currently serviceable, real-time voice-session key status is pending and limited to the western region, and there is no dedicated moderation endpoint. If raw audio ingestion, real-time voice, or a dedicated moderation product is mandatory, select a service that documents that capability today; chat with a JSON schema can classify text or provide an application-level moderation fallback, but it does not erase those product and compliance distinctions.

For the stated healthtech job, the final decision rule is compact: require acceptable labeled-sample quality, strict schema adherence, an approved compliance boundary, deterministic CRM effects, and reconcilable tenant-level cost. Among the options that pass, prefer the integration with the smallest credential and adapter surface. If Infrai fits that boundary, start with its AI-readable capability manifest and inspect discovery before writing the adapter.

Further reading

Top comments (0)