@lovieco/tokenops
v0.1.0
Published
TokenOps SDK — attribute LLM spend to your customers with one line.
Maintainers
Readme
@lovieco/tokenops
Attribute LLM spend to your customers with one line.
TokenOps records what each LLM call cost and which of your customers it was for, so you can see cost-vs-revenue per customer. This SDK sends usage events to the TokenOps ingest endpoint.
Install
npm install @lovieco/tokenopsRequires Node 18+ (the client uses the global fetch and node:crypto). No
runtime dependencies. The package is ESM-only ("type": "module"); import it
from ESM or a bundler, not via require().
Quick start — auto-instrument Anthropic
import { TokenOps, wrapAnthropic } from "@lovieco/tokenops";
import Anthropic from "@anthropic-ai/sdk";
const tokenops = new TokenOps({
companyId: "<your-company-uuid>",
secretKey: process.env.TOKENOPS_SECRET_KEY!, // sk_live_...
ingestUrl: "https://api.lovie.co",
});
// One line. Every non-streaming call is now attributed.
const anthropic = wrapAnthropic(new Anthropic(), tokenops);
await anthropic.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 1024,
messages: [{ role: "user", content: "hi" }],
customerId: "acme-inc", // <- attributes the spend; stripped before the real call
});wrapAnthropic is fire-and-forget: tracking never blocks or fails your LLM call.
Events are buffered and flushed automatically (see Batching below). Pass
{ onError } to observe enqueue-time failures. Streaming calls (stream: true)
are passed through untracked for now.
Quick start — auto-instrument OpenAI
import { TokenOps, wrapOpenAI } from "@lovieco/tokenops";
import OpenAI from "openai";
const tokenops = new TokenOps({
companyId: "<your-company-uuid>",
secretKey: process.env.TOKENOPS_SECRET_KEY!, // sk_live_...
ingestUrl: "https://api.lovie.co",
});
// One line. Every non-streaming call is now attributed.
const openai = wrapOpenAI(new OpenAI(), tokenops);
await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "hi" }],
customerId: "acme-inc", // <- attributes the spend; stripped before the real call
});responses.create is instrumented the same way when your OpenAI SDK exposes it —
older SDKs without it are left untouched. Like wrapAnthropic, wrapOpenAI is
fire-and-forget: tracking never blocks or fails your LLM call, events are buffered
and flushed automatically (see Batching below), and { onError } observes
enqueue-time failures. Streaming calls (stream: true) are passed through
untracked for now.
Track events directly
For other providers or custom flows, enqueue events yourself:
tokenops.track({
vendor: "openai",
model: "gpt-4o",
inputTokens: 1200,
outputTokens: 350,
customerId: "acme-inc",
providerObservationId: "resp_123", // idempotent de-dup on re-send
});track() buffers the event and returns immediately. If you omit
providerObservationId, the SDK sends the event's generated id as the
observation id so the ingest contract is always satisfied.
Batching, flushing, and shutdown
track() buffers events and flushes them in the background — when the buffer
reaches batchSize, and on a flushIntervalMs timer. This coalesces many calls
into few HTTP requests.
Because buffered events are sent asynchronously, a process that exits before the next flush would drop them. In short-lived or serverless code, drain explicitly:
await tokenops.flush(); // send everything buffered right now; rejects on failure
await tokenops.close(); // stop the timer and flush; call once on shutdownBackground flush failures do not throw into your app — configure onError to
observe them.
Send a batch synchronously
trackBatch() sends immediately and resolves once the request succeeds (or
rejects after retries), bypassing the buffer. Requests larger than 500 events
are split automatically.
await tokenops.trackBatch([event1, event2]); // up to 500 per requestFailed sends retry with exponential backoff on network errors, 429, and 5xx;
a 4xx (e.g. a bad key) throws a TokenOpsError immediately.
Configuration
| Option | Default | Description |
| ----------------- | ---------- | -------------------------------------------------------- |
| companyId | (required) | Your Lovie company UUID. |
| secretKey | (required) | A TokenOps secret key (sk_live_...). Keep server-side. |
| ingestUrl | (required) | Base URL of the ingest endpoint. |
| batchSize | 20 | Buffered events that trigger an automatic flush. |
| flushIntervalMs | 5000 | Background flush interval. 0 disables the timer. |
| maxRetries | 3 | Retry attempts on network / 429 / 5xx. |
| timeoutMs | 10000 | Per-request timeout. |
| onError | — | Called when a background flush fails. |
| fetch | global | Injectable fetch implementation. |
The raw contract
Under the hood each request POSTs Connect-JSON to
{ingestUrl}/lovie.tokenops.v1.IngestService/IngestSdkEvents:
{
"secretKey": "sk_live_...",
"events": [
{
"id": { "value": "<uuid>" },
"companyId": { "value": "<company-uuid>" },
"vendor": "anthropic",
"lineType": "inference",
"model": "claude-sonnet-4-5",
"inputTokens": 1200,
"outputTokens": 350,
"customerId": "acme-inc",
"startedAt": "2026-07-21T00:00:00Z",
"endedAt": "2026-07-21T00:00:01Z",
"providerObservationId": "resp_123"
}
]
}id and companyId are UUID wrappers ({ "value": "<uuid>" }), and
providerObservationId is required. You can send the same request with curl —
see your TokenOps dashboard's integration guide for a copy-paste snippet.
License
MIT
