SMBs using NetSuite need to classify transactions, detect anomalies, and enrich vendor data, but manual processes and unreliable point‑to‑point scripts cause data drift and missed insights.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial walks you through building a vLLM Reliability Suite that enriches NetSuite financial records using a self-hosted vLLM inference endpoint. You’ll create an Express server (inside a Next.js project) that accepts NetSuite webhook payloads, runs the records through three vLLM-powered steps (classification, anomaly detection, and entity extraction), and writes the enriched results back — all guarded by circuit breakers, idempotency middleware, and Redis-backed session continuity. By the end, you’ll have a pipeline that tolerates network failures, prevents duplicate writes, and traces every step to Langfuse.
This recipe is for TypeScript backend developers comfortable with Express and REST APIs. You’ll use the @reaatech/* package family and the OpenAI SDK pointed at a vLLM endpoint.
Prerequisites
Node.js >= 22
pnpm >= 10 (install with npm install -g pnpm)
A running Redis instance (local or remote)
A running vLLM inference endpoint (or any OpenAI-compatible LLM server)
A NetSuite account with REST API access (OAuth 1.0 credentials)
A Trigger.dev account for job orchestration
A Langfuse account for LLM observability (optional but recommended)
Basic familiarity with TypeScript, Express, and async/await
Step 1: Scaffold the project and install dependencies
Create a new Next.js project. The recipe is scaffolded as a Next.js app for future UI extensions, but the enrichment pipeline runs as an Express server alongside it.
terminal
npx
create-next-app@latest
vllm-reliability-suite
--typescript
--eslint
--app
--src-dir
--use-pnpm
cd vllm-reliability-suite
Next, install the core dependencies. All versions are pinned exactly.
Expected output:node_modules/ is populated and pnpm-lock.yaml exists. Your package.json lists all dependencies with exact versions (no ^ or ~ prefixes).
Step 2: Configure environment variables
Create a .env file by copying the example. Every value is a placeholder — replace with your real credentials.
terminal
cp .env.example .env
Open .env and fill in your values. The required environment variables are:
env
# Environment variables for vllm-reliability-suite-for-netsuite-smb-financial-operations# Keep placeholders only — never commit real values.# NetSuite OAuth 1.0 credentials (required for REST API authentication)NETSUITE_ACCOUNT_ID=<your-netsuite-account-id>NETSUITE_CONSUMER_KEY=<your-netsuite-consumer-key>NETSUITE_CONSUMER_SECRET=<your-netsuite-consumer-secret>NETSUITE_TOKEN_ID=<your-netsuite-token-id>NETSUITE_TOKEN_SECRET=<your-netsuite-token-secret># NetSuite bearer token (optional — use instead of OAuth 1.0 if provided)NETSUITE_BEARER_TOKEN=<your-netsuite-bearer-token># vLLM API connectionVLLM_BASE_URL=<your-vllm-base-url>VLLM_API_KEY=<your-vllm-api-key>VLLM_MODEL=<your-vllm-model-name># Redis connection string for session and job-state storageREDIS_URL=<your-redis-connection-url># Trigger.dev credentials for job orchestrationTRIGGER_API_KEY=<your-trigger-api-key>TRIGGER_PROJECT_ID=<your-trigger-project-id># Langfuse observability credentials for LLM tracingLANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_HOST=<your-langfuse-host-url># Express server port (default: 3100)EXPRESS_PORT=3100# Agent LLM configuration (used by the runbook agent)AGENT_LLM_PROVIDER=openaiAGENT_LLM_API_KEY=<your-agent-llm-api-key>AGENT_LLM_MODEL=<your-agent-llm-model-name>
Expected output: Your .env file has real values for all the fields you have. The .env.example stays untouched with placeholders — never commit secrets.
Step 3: Create the configuration schema with Zod
All environment variables are validated at startup through a Zod schema. This ensures your app fails fast with a clear message if a required variable is missing.
Expected output: Your app will throw immediately on startup with a Zod error message if any required env var is missing, rather than failing mysteriously at runtime.
Step 4: Define the NetSuite record types
Create src/types/netsuite.ts to define the typed schemas used throughout the pipeline:
Expected output: Your project now has a shared type vocabulary — every module imports from these types, so the compiler catches mismatches.
Step 5: Build the NetSuite client with a circuit breaker
The NetSuite client handles REST API authentication (OAuth 1.0 or Bearer token) and wraps every network call in a circuit breaker from @reaatech/circuit-breaker-core. If NetSuite returns 5 consecutive failures, the circuit opens and subsequent calls return null immediately without hitting the network, giving the upstream time to recover.
Create src/lib/netsuite-client.ts:
ts
import { CircuitBreaker } from "@reaatech/circuit-breaker-core";import type { ExecutionContext, CircuitEvent } from "@reaatech/circuit-breaker-core";import { createHmac, randomBytes } from "node:crypto";import type { AppConfig } from "../types/config.js";import type { NetSuiteRecord, EnrichedRecord, NetSuiteRecordType } from "../types/netsuite.js";export interface NetSuiteClient { getRecord(internalId: string, type: NetSuiteRecordType): Promise<NetSuiteRecord | null>; patchRecord( internalId: string, type:
Expected output: The CircuitBreaker wraps every NetSuite call. After 5 consecutive failures, getRecord returns null and patchRecord becomes a no-op, preventing cascading failures.
Step 6: Build the vLLM client with a separate circuit breaker
The vLLM client uses the OpenAI SDK pointed at your self-hosted vLLM endpoint. It’s guarded by its own circuit breaker (3 failures / 60s recovery) and a concurrency limiter from p-limit.
Create src/lib/vllm-client.ts:
ts
import { CircuitBreaker } from "@reaatech/circuit-breaker-core";import { nanoid } from "nanoid";import OpenAI from "openai";import pLimit from "p-limit";import type { AppConfig } from "../types/config.js";import type { AnomalyResult, NetSuiteClassification, NetSuiteRecord,} from "../types/netsuite.js";const VLLM_CONCURRENCY = 5;export interface VLLMClient { classifyRecord(record: NetSuiteRecord): Promise<NetSuiteClassification
Expected output: You have a typed vLLM client that sends prompts to your inference endpoint, parses structured JSON responses, and handles errors through the circuit breaker and concurrency limiter.
Step 7: Add idempotency middleware for PATCH operations
The idempotency middleware from @reaatech/idempotency-middleware prevents duplicate PATCH calls to NetSuite. If the same internalId is processed twice, the second call returns the cached result without touching the network.
Expected output: The idempotentEnrich wrapper uses the record’s internalId as the deduplication key. If the same record arrives twice, the second call returns the cached result without patching NetSuite again.
Step 8: Set up Redis-backed job state with session continuity
The session continuity package tracks long-running enrichment jobs across retries. It persists job state in Redis and enforces a token budget using a sliding window compression strategy.
Create src/lib/job-state.ts:
ts
import { ConcurrencyError, SessionManager, StorageError,} from "@reaatech/session-continuity";import type { IStorageAdapter, Session, Message, HealthStatus, SessionFilters, MessageQueryOptions, MessageRole, SessionId, MessageId, UpdateSessionOptions, TokenCounter,} from "@reaatech/session-continuity";import { randomUUID } from "node:crypto";import { Redis } from "ioredis";const SESSION_KEY_PREFIX = "session:";const VERSION_KEY_SUFFIX = ":version"
Expected output: Your app now connects to Redis and creates/updates sessions through the SessionManager. Each enrichment job gets a session with a token budget — if the conversation exceeds 10,000 tokens, the sliding window compression kicks in.
Step 9: Wire up observability with Langfuse
The observability module wraps Langfuse tracing so every vLLM inference call and every NetSuite PATCH is traced with input/output and token usage.
Expected output: Every Langfuse call is wrapped in try/catch — if Langfuse is unreachable, the enrichment pipeline continues without crashing. Traces are best-effort.
Step 10: Create the enrichment workflow
The core workflow orchestrates the pipeline: fetch from NetSuite, classify with vLLM, detect anomalies, extract entities, and write back with idempotency. Each vLLM step uses exponential backoff retry logic.
Create src/workflows/enrich-record.ts:
ts
import { retry, task } from "@trigger.dev/sdk/v3";import { traceWorkflow, spanGeneration, endGeneration } from "../lib/observability.js";import { idempotentEnrich } from "../lib/idempotency.js";import { NetSuiteRecordSchema } from "../types/netsuite.js";import type { NetSuiteRecord, NetSuiteRecordType, NetSuiteClassification, AnomalyResult, EnrichedRecord } from "../types/netsuite.js";import type { EnrichmentResult } from "../types/workflow.js";import type { NetSuiteClient } from "../lib/netsuite-client.js";import type { VLLMClient } from "../lib/vllm-client.js";import type { Langfuse } from "langfuse";import type { MessageRole }
Expected output: The enrichRecord function is the heart of the pipeline. It fetches, classifies, detects anomalies, extracts entities, and patches back — all with retries, tracing, idempotency, and session tracking. On failure, it runs a health check via the runbook agent before re-throwing.
Step 11: Create the Express server
The Express server exposes three endpoints:
POST /webhooks/netsuite — accepts webhook payloads and dispatches enrichment via Trigger.dev
GET /health — returns overall health status
GET /metrics — returns circuit breaker statistics
Create src/app.ts:
ts
import "dotenv/config";import express from "express";import type { Request, Response } from "express";import { createConfig } from "./types/config.js";import { CircuitBreaker } from "@reaatech/circuit-breaker-core";import { createVllmClient } from "./lib/vllm-client.js";import { createJobSessionManager } from "./lib/job-state.js";import { createRunbookAgent } from "./lib/runbook-agent.js";import { EnrichmentWebhookPayload } from "./types/workflow.js";import { nanoid } from "nanoid";import { z } from "zod";
Expected output: Your Express server starts on port 3100 (configurable via EXPRESS_PORT). Test the webhook endpoint with curl:
The test suite uses vitest with MSW for HTTP mocking. It covers the NetSuite client, vLLM client, idempotency middleware, job state, the enrichment workflow, observability, the runbook agent, config validation, and the Express API server.
terminal
pnpm test
This runs vitest run --coverage --reporter=json --outputFile=vitest-report.json. Expected output at the end:
tests/netsuite-client.test.ts — validates circuit breaker state transitions (CLOSED to OPEN on 5 failures, fallback returning null), Bearer vs OAuth auth headers, and lastModifiedDate handling
tests/vllm-client.test.ts — validates classification, anomaly detection, entity extraction responses, CircuitOpenError after 3 consecutive failures, and the traceGeneration callback with token usage estimates
tests/idempotency.test.ts — validates deduplication by internalId, cache hit on duplicate calls, error caching, and empty-key rejection
tests/job-state.test.ts — validates Redis session CRUD, message lifecycle, and concurrency version checks
tests/workflows/enrich-record.test.ts — validates the full pipeline orchestration with mocked NetSuite and vLLM, error paths (unreachable record, vLLM failure), and Langfuse trace null-safety
tests/app.test.ts — validates Express route handlers for valid/invalid webhook payloads, health endpoint, metrics endpoint, and graceful shutdown on SIGTERM/SIGINT
tests/config.test.ts — validates Zod schema for environment configuration
tests/observability.test.ts — validates Langfuse trace/spawn/end helpers with graceful null-handling
tests/runbook-agent.test.ts — validates failure mode analysis context building and recovery plan generation
To run a single test file:
terminal
npx vitest run tests/netsuite-client.test.ts
Next steps
Add a dashboard: The Next.js scaffold is ready for a React UI that shows job status, circuit breaker states, and enrichment results in real time. The Express API already serves /health and /metrics.
Swap the idempotency adapter: The current MemoryAdapter is in-process. Replace it with RedisAdapter from @reaatech/idempotency-middleware for multi-instance deployments, sharing the same REDIS_URL as session continuity.
Wire the runbook agent to a notification channel: The monitorWorkflowHealth function already detects failed and stuck sessions. Connect it to Slack or email via the @reaatech/agent-runbook-agent to get automated incident reports.
Add more vLLM inference steps: The VLLMClient pattern extends naturally — just add a new method with its own prompt template and response parser, then call it from the enrichment workflow.
Scale with Trigger.dev workers: The enrichRecordTask is already defined as a Trigger.dev task. Deploy it as a background worker for true at-least-once execution with automatic retries.
return `Classify the following NetSuite financial record into one of these categories: capital_expense, operating_expense, revenue, cost_of_goods, intercompany, uncategorized.
Record type: ${record.type}
Fields: ${JSON.stringify(record.fields, null, 2)}
Respond with a JSON object containing "classification" key with the category string.`;
}
function buildAnomalyPrompt(record: NetSuiteRecord): string {
return `Analyze the following NetSuite transaction for anomalies (fraud indicators, unusual patterns, data quality issues).
Record type: ${record.type}
Fields: ${JSON.stringify(record.fields, null, 2)}
Respond with a JSON object containing:
- "score": number between 0 and 1 indicating anomaly likelihood
- "reason": brief explanation string
- "flagged": boolean`;
}
function buildEntityExtractionPrompt(
record: NetSuiteRecord,
): string {
return `Extract structured entities from this NetSuite record.
Record type: ${record.type}
Fields: ${JSON.stringify(record.fields, null, 2)}
Respond with a JSON object where keys are entity type labels (e.g. "vendors", "customers", "amounts", "dates") and values are arrays of extracted strings.`;