NullSpend Docs

javascript

JavaScript SDK

TypeScript/JavaScript client for the NullSpend API.

Installation

npm install @nullspend/sdk

Quick Start

import { NullSpend } from "@nullspend/sdk";

const ns = new NullSpend({
  baseUrl: "https://nullspend.dev",
  apiKey: "ns_live_sk_...",
});

// Report a cost event
await ns.reportCost({
  provider: "openai",
  model: "gpt-4o",
  inputTokens: 500,
  outputTokens: 150,
  costMicrodollars: 4625,
});

Configuration

The NullSpend constructor accepts a NullSpendConfig object:

OptionTypeDefaultDescription
baseUrlstringrequiredNullSpend dashboard URL (e.g. https://nullspend.dev)
apiKeystringrequiredAPI key (ns_live_sk_...)
apiVersionstring"2026-04-01"API version sent via NullSpend-Version header
fetchtypeof fetchglobalThis.fetchCustom fetch implementation
requestTimeoutMsnumber30000Per-request timeout in ms. Set to 0 to disable
maxRetriesnumber2Max retries on transient failures. Clamped to [0, 10]
retryBaseDelayMsnumber500Base delay between retries in ms
maxRetryTimeMsnumber0Total wall-time cap for all retry attempts. 0 = no cap
onRetry(info: RetryInfo) => void | booleanCalled before each retry. Return false to abort
costReportingCostReportingConfigEnable client-side cost event batching (see below)

Actions (Human-in-the-Loop)

The SDK provides methods for the full HITL approval workflow.

createAction(input)

Create a new action for human approval.

const { id, status, expiresAt } = await ns.createAction({
  agentId: "support-agent",
  actionType: "send_email",
  payload: { to: "user@example.com", subject: "Refund" },
  metadata: { ticketId: "T-1234" },
  expiresInSeconds: 1800,
});

getAction(id)

Fetch the current state of an action.

const action = await ns.getAction("ns_act_550e8400-...");
console.log(action.status); // "pending" | "approved" | "rejected" | ...

markResult(id, input)

Report execution status back to NullSpend.

// Start executing
await ns.markResult(id, { status: "executing" });

// Report success
await ns.markResult(id, {
  status: "executed",
  result: { rowsDeleted: 42 },
});

// Or report failure
await ns.markResult(id, {
  status: "failed",
  errorMessage: "Connection timeout",
});

waitForDecision(id, options?)

Poll until the action leaves pending status or the timeout elapses.

const decision = await ns.waitForDecision(id, {
  pollIntervalMs: 2000,   // default: 2000 (2s)
  timeoutMs: 300000,       // default: 300000 (5 min)
  onPoll: (action) => console.log(action.status),
});

Throws TimeoutError if the timeout elapses while still pending.

proposeAndWait<T>(options)

High-level orchestrator that combines create → poll → execute → report:

const result = await ns.proposeAndWait({
  agentId: "data-agent",
  actionType: "db_write",
  payload: { query: "DELETE FROM logs WHERE age > 90" },
  expiresInSeconds: 3600,

  execute: async ({ actionId }) => {
    // Runs only after human approval.
    // actionId can be sent as X-NullSpend-Action-Id to correlate costs.
    return await deleteOldLogs();
  },

  pollIntervalMs: 2000,  // default: 2000
  timeoutMs: 300000,      // default: 300000 (5 min)
  onPoll: (action) => {},
});
  • On approval: marks executing, calls execute(), marks executed with result
  • On rejection/expiry: throws RejectedError
  • On execute failure: marks failed, re-throws the original error
  • Handles 409 conflicts from concurrent writes gracefully

Cost Reporting

Three approaches for reporting cost events.

reportCost(event) — Single Event

const { id, createdAt } = await ns.reportCost({
  provider: "anthropic",
  model: "claude-sonnet-4-20250514",
  inputTokens: 1000,
  outputTokens: 500,
  costMicrodollars: 6750,
  // Optional fields:
  cachedInputTokens: 200,
  reasoningTokens: 0,
  durationMs: 1200,
  sessionId: "session-123",
  traceId: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
  eventType: "llm",        // "llm" | "tool" | "custom"
  tags: { team: "backend" },
});

reportCostBatch(events) — Batch

const { inserted, ids } = await ns.reportCostBatch([
  { provider: "openai", model: "gpt-4o", inputTokens: 500, outputTokens: 150, costMicrodollars: 4625 },
  { provider: "openai", model: "gpt-4o-mini", inputTokens: 1000, outputTokens: 300, costMicrodollars: 225 },
]);

Client-Side Batching

Enable automatic batching by passing costReporting in the constructor:

const ns = new NullSpend({
  baseUrl: "https://nullspend.dev",
  apiKey: "ns_live_sk_...",
  costReporting: {
    batchSize: 10,          // default: 10 (clamped [1, 100])
    flushIntervalMs: 5000,  // default: 5000 (min 100)
    maxQueueSize: 1000,     // default: 1000 (min 1)
    onDropped: (count) => console.warn(`Dropped ${count} events`),
    onFlushError: (error, events) => console.error("Flush failed", error),
  },
});

// Queue events — they flush automatically
ns.queueCost({ provider: "openai", model: "gpt-4o", inputTokens: 500, outputTokens: 150, costMicrodollars: 4625 });

// Force an immediate flush
await ns.flush();

// Flush remaining events and stop the timer
await ns.shutdown();

When the queue overflows maxQueueSize, the oldest events are dropped and onDropped is called.

Budget Status

const status = await ns.checkBudget();

for (const entity of status.entities) {
  console.log(
    `${entity.entityType}/${entity.entityId}: ` +
    `$${entity.spendMicrodollars / 1_000_000} / $${entity.limitMicrodollars / 1_000_000}`
  );
}

Returns a BudgetStatus with an entities array. Each BudgetEntity contains:

FieldTypeDescription
entityTypestringBudget entity type (e.g. "user", "key", "tag")
entityIdstringEntity identifier
limitMicrodollarsnumberBudget ceiling
spendMicrodollarsnumberCurrent spend
remainingMicrodollarsnumberRemaining budget
policystringEnforcement policy
resetIntervalstring | nullReset period (e.g. "daily", "monthly")
currentPeriodStartstring | nullISO 8601 timestamp of current period start

Cost Awareness (Read APIs)

Query your spend data programmatically — useful for cost-aware agents and dashboards.

listBudgets()

Fetch all budgets for the authenticated org.

const { data: budgets } = await ns.listBudgets();

for (const budget of budgets) {
  const spent = budget.spendMicrodollars / 1_000_000;
  const limit = budget.maxBudgetMicrodollars / 1_000_000;
  console.log(`${budget.entityType}/${budget.entityId}: $${spent} / $${limit}`);
}

Each BudgetRecord contains:

FieldTypeDescription
idstringBudget ID
entityTypestring"user", "api_key", or "tag"
entityIdstringEntity identifier
maxBudgetMicrodollarsnumberBudget ceiling
spendMicrodollarsnumberCurrent spend
policystring"strict_block" or "warn"
resetIntervalstring | null"daily", "monthly", etc.
thresholdPercentagesnumber[]Webhook alert thresholds
velocityLimitMicrodollarsnumber | nullPer-window spend limit
sessionLimitMicrodollarsnumber | nullPer-session spend limit

getCostSummary(period?)

Get aggregated spend data for a time period.

const summary = await ns.getCostSummary("30d"); // "7d" | "30d" | "90d"

console.log(`Total spend: $${summary.totals.totalCostMicrodollars / 1_000_000}`);
console.log(`Total requests: ${summary.totals.totalRequests}`);

// Spend by model
for (const [model, cost] of Object.entries(summary.models)) {
  console.log(`  ${model}: $${cost / 1_000_000}`);
}

// Daily trend
for (const day of summary.daily) {
  console.log(`  ${day.date}: $${day.totalCostMicrodollars / 1_000_000}`);
}

listCostEvents(options?)

Fetch recent cost events with pagination.

// Get the last 10 cost events
const { data: events, cursor } = await ns.listCostEvents({ limit: 10 });

for (const event of events) {
  console.log(`${event.model}: ${event.inputTokens} in / ${event.outputTokens} out — $${event.costMicrodollars / 1_000_000}`);
}

// Paginate with cursor
if (cursor) {
  const nextPage = await ns.listCostEvents({ limit: 10, cursor: `${cursor.createdAt},${cursor.id}` });
}

Retry Behavior

The SDK automatically retries on transient failures:

Retryable: 429, 500, 502, 503, 504, network errors (TypeError), timeout errors (AbortSignal.timeout)

Not retryable: user-initiated abort (AbortError), 4xx errors other than 429

Backoff: Full-jitter exponential — floor(random() * min(base * 2^attempt, 5000ms)). The Retry-After header is respected when present (used once, then back to exponential).

Idempotency: Mutating requests (POST) include an Idempotency-Key header generated once and reused across retries.

The onRetry callback receives a RetryInfo object:

const ns = new NullSpend({
  baseUrl: "https://nullspend.dev",
  apiKey: "ns_live_sk_...",
  maxRetries: 3,
  onRetry: ({ attempt, delayMs, error, method, path }) => {
    console.log(`Retry ${attempt} for ${method} ${path} in ${delayMs}ms: ${error.message}`);
    // Return false to abort retrying
  },
});

Tracked Fetch (Provider Wrappers)

Wrap your LLM provider's fetch to automatically track costs and enforce policies client-side.

Basic Setup

const ns = new NullSpend({
  baseUrl: "https://app.nullspend.dev",
  apiKey: "ns_live_sk_...",
  costReporting: {},  // required for createTrackedFetch
});

const openai = new OpenAI({ fetch: ns.createTrackedFetch("openai") });
const anthropic = new Anthropic({ fetch: ns.createTrackedFetch("anthropic") });

Cost events are calculated locally using the built-in pricing engine and reported asynchronously in batches. Your requests go directly to the provider — no proxy required.

Enforcement Mode

Enable enforcement: true to check budgets, model mandates, and session limits before each request:

const openai = new OpenAI({
  fetch: ns.createTrackedFetch("openai", {
    enforcement: true,
    sessionId: "task-042",
    sessionLimitMicrodollars: 5_000_000, // $5 per session
    tags: { team: "backend", customer: "acme" },
    onDenied: (reason) => {
      if (reason.type === "budget") console.log(`Budget: ${reason.remaining} remaining`);
      if (reason.type === "mandate") console.log(`Mandate: ${reason.mandate} blocks ${reason.requested}`);
      if (reason.type === "session_limit") console.log(`Session: ${reason.sessionSpend} of ${reason.sessionLimit}`);
    },
    onCostError: (err) => console.warn("Cost tracking error:", err.message),
  }),
});

TrackedFetchOptions

OptionTypeDefaultDescription
enforcementbooleanfalseEnable budget, mandate, and session limit checks
sessionIdstringSession identifier for cost correlation and session limits
sessionLimitMicrodollarsnumberManual per-session spend cap (takes precedence over policy)
tagsRecord<string, string>Tags attached to every cost event
traceIdstringDistributed trace ID
actionIdstringHITL action ID for cost correlation
onDenied(reason: DenialReason) => voidCalled before throwing enforcement errors
onCostError(error: Error) => voidconsole.warnCalled on non-fatal cost tracking errors

Enforcement Flow

When enforcement: true, each request goes through:

  1. Mandate check — is this model/provider allowed by key policy?
  2. Budget check — does estimated cost fit within remaining budget?
  3. Session limit check — does sessionSpend + estimate exceed the session limit?

If any check fails, the SDK throws the corresponding error before calling the provider. If the policy endpoint is unreachable, the SDK falls open (requests proceed) — except for manual session limits, which are always enforced.

Session Limit Enforcement

Session limits track cumulative spend per createTrackedFetch() instance:

  • Each instance starts at 0 spend
  • Actual cost from each successful response is accumulated
  • Before each request, the SDK checks sessionSpend + estimate > sessionLimit
  • The limit comes from sessionLimitMicrodollars (manual) or the policy endpoint (from budget config), with manual taking precedence
  • Streaming cost is accumulated asynchronously — a concurrent request may slip through before the first stream's cost is counted
  • Failed responses (4xx/5xx) don't count toward session spend

Note: SDK session limits are cooperative — each createTrackedFetch() instance tracks independently. For fleet-wide authoritative enforcement, use the proxy.

Error Handling

Five error classes, all extending Error:

NullSpendError

Base error for all SDK errors. Properties:

PropertyTypeDescription
statusCodenumber | undefinedHTTP status code (if from an API response)
codestring | undefinedMachine-readable error code from the API
try {
  await ns.createAction({ ... });
} catch (err) {
  if (err instanceof NullSpendError) {
    console.log(err.statusCode); // 409
    console.log(err.code);       // "invalid_action_transition"
  }
}

TimeoutError

Thrown by waitForDecision when the timeout elapses. Extends NullSpendError.

RejectedError

Thrown by proposeAndWait when the action is rejected or expired. Extends NullSpendError.

PropertyTypeDescription
actionIdstringThe action that was rejected
actionStatusstringThe terminal status ("rejected" or "expired")
try {
  await ns.proposeAndWait({ ... });
} catch (err) {
  if (err instanceof RejectedError) {
    console.log(`${err.actionId} was ${err.actionStatus}`);
  }
}

BudgetExceededError

Thrown by createTrackedFetch when enforcement is enabled and the estimated cost exceeds remaining budget.

PropertyTypeDescription
remainingMicrodollarsnumberBudget remaining when denial occurred

MandateViolationError

Thrown when the requested model or provider is not allowed by key policy.

PropertyTypeDescription
mandatestringWhich mandate was violated ("allowed_models" or "allowed_providers")
requestedstringThe model or provider that was denied
allowedstring[]The allowed values

SessionLimitExceededError

Thrown when session spend plus estimated cost exceeds the session limit.

PropertyTypeDescription
sessionSpendMicrodollarsnumberAccumulated session spend at denial time
sessionLimitMicrodollarsnumberConfigured session limit
import {
  BudgetExceededError,
  MandateViolationError,
  SessionLimitExceededError,
} from "@nullspend/sdk";

try {
  await openai.chat.completions.create({ model: "gpt-4o", messages: [{ role: "user", content: "Hi" }] });
} catch (err) {
  if (err instanceof SessionLimitExceededError) {
    console.log(`Session spent $${err.sessionSpendMicrodollars / 1_000_000} of $${err.sessionLimitMicrodollars / 1_000_000} limit`);
  } else if (err instanceof BudgetExceededError) {
    console.log(`Budget exhausted: $${err.remainingMicrodollars / 1_000_000} remaining`);
  } else if (err instanceof MandateViolationError) {
    console.log(`${err.mandate} blocks "${err.requested}". Allowed: ${err.allowed.join(", ")}`);
  }
}

Types

All types are exported from the package:

import type {
  // Configuration
  NullSpendConfig,
  CostReportingConfig,
  RetryInfo,

  // Actions
  CreateActionInput,
  CreateActionResponse,
  ActionRecord,
  MarkResultInput,
  MutateActionResponse,
  ProposeAndWaitOptions,
  ExecuteContext,
  WaitForDecisionOptions,

  // Cost reporting
  CostEventInput,
  ReportCostResponse,
  ReportCostBatchResponse,

  // Tracked fetch
  TrackedFetchOptions,
  TrackedProvider,
  DenialReason,

  // Budgets
  BudgetStatus,
  BudgetEntity,
  BudgetRecord,
  ListBudgetsResponse,

  // Cost awareness (read)
  CostEventRecord,
  ListCostEventsResponse,
  ListCostEventsOptions,
  CostSummaryResponse,
  CostSummaryPeriod,

  // Enums
  ActionType,
  ActionStatus,
} from "@nullspend/sdk";

Constants:

import {
  ACTION_TYPES,       // readonly tuple of valid action types
  ACTION_STATUSES,    // readonly tuple of all statuses
  TERMINAL_STATUSES,  // ReadonlySet of terminal statuses
} from "@nullspend/sdk";

Utilities:

import {
  waitWithAbort,       // waitForDecision with AbortSignal support
  interruptibleSleep,  // sleep that can be cancelled via AbortSignal
} from "@nullspend/sdk";

On this page