@cost-monitor/sdk
v0.1.1
Published
Typed client for cost-monitor: report LLM events and read back a project's events feed and spend metrics.
Maintainers
Readme
@cost-monitor/sdk
Typed client for cost-monitor. Report
LLM events and read a project's own data back — all authenticated with a single
project API key (cm_live_…) and scoped to that key's project.
- Zero runtime dependencies.
- ESM, Node ≥ 18 (uses the global
fetchandcrypto.randomUUID). - No automatic retries — a non-ok response or transport failure throws a typed
CostMonitorError.
Install
npm install @cost-monitor/sdk
# or: pnpm add @cost-monitor/sdkUsage
cost-monitor is self-hosted, so baseUrl is required — there is no default
host. The API key is sent as Authorization: Bearer <apiKey>.
import { CostMonitorClient } from "@cost-monitor/sdk";
const client = new CostMonitorClient({
apiKey: process.env.COST_MONITOR_API_KEY!, // cm_live_…
baseUrl: "https://costs.your-company.com",
});Report an event
const result = await client.track({
provider: "openai",
model: "gpt-4o",
inputTokens: 1200,
outputTokens: 350,
status: "success",
feature: "chat",
customerId: "cust_123",
// clientEventId is optional — the SDK fills it with a UUID for idempotency.
});
// → { id, created, costUsd, warnings? }Report many events at once
Up to 100 events per call. Per-item failures are reported in the result rather than throwing.
const { results } = await client.trackBatch([
{ provider: "openai", model: "gpt-4o", inputTokens: 10, outputTokens: 5, status: "success" },
{ provider: "anthropic", model: "claude-sonnet-4-5", inputTokens: 8, outputTokens: 4, status: "success" },
]);Buffered delivery (fire-and-forget)
client.track / client.trackBatch send immediately and throw on failure — you
decide when and how to retry. For a long-lived process that would rather
"enqueue and forget", wrap the client in an EventBuffer. It batches events
(flush by size or interval) and retries transient failures (5xx, network,
429) with exponential backoff; 400 / 401 / 413 are dropped, since a retry
can't fix them. A clientEventId is assigned at enqueue and reused across
retries, so the server deduplicates and no event is double-counted.
import { CostMonitorClient, EventBuffer } from "@cost-monitor/sdk";
const client = new CostMonitorClient({ apiKey, baseUrl });
const buffer = new EventBuffer(client, {
maxBatchSize: 50, // flush when this many are queued (default 50, max 100)
flushIntervalMs: 5000, // …or this often (default 5s)
maxQueueSize: 10_000, // drop-oldest beyond this (default 10k)
maxRetries: 5, // retries after the first attempt (default 5)
onError: (event, err) => log.warn({ event, err }, "event dropped"),
onDrop: (event) => log.warn({ event }, "queue overflow"),
});
buffer.enqueue(event); // synchronous, never throwsenqueue never throws and onError / onDrop exceptions are swallowed, so the
buffer can't break your hot path. The interval timer is unref'd and won't keep
the process alive on its own. Flush remaining events before exit:
process.on("SIGTERM", async () => {
await buffer.close(); // stops the timer + final flush; idempotent
process.exit(0);
});Auto-tracking via wrappers
wrapOpenAI / wrapAnthropic return a transparent proxy of your provider client
that reports each completion to a sink for you — you keep calling the provider
SDK exactly as before. The response (and stream) you get back is untouched; the
server stays the source of truth for cost. openai and @anthropic-ai/sdk are
optional peer dependencies — install only the one you use.
import OpenAI from "openai";
import { CostMonitorClient, EventBuffer, wrapOpenAI, withCostMonitor } from "@cost-monitor/sdk";
const buffer = new EventBuffer(new CostMonitorClient({ apiKey, baseUrl }));
const openai = wrapOpenAI(new OpenAI(), { sink: buffer, defaultFeature: "chat" });
// Plain call — attribution comes from the wrap-config defaults:
await openai.chat.completions.create({ model: "gpt-4o", messages });
// Per-call attribution via withCostMonitor (stripped before the request hits OpenAI):
await openai.chat.completions.create(
withCostMonitor({ model: "gpt-4o", messages }, { customerId: "cust_123", feature: "summarize" }),
);The sink is anything with enqueue(event) — an EventBuffer is the usual
choice. Per-call feature / customerId / userId / metadata go through
withCostMonitor, which stashes them under a private Symbol that never reaches
the provider. Streaming works too (consume the stream as usual; usage is read
from the final chunk — for OpenAI stream_options.include_usage is enabled for
you). If the provider call throws, an event with status: "error" is recorded
and the original error is re-thrown unchanged. Tracking never throws into your
call path; route internal failures with onError.
Anthropic is identical via wrapAnthropic(new Anthropic(), { sink })
(intercepts messages.create). Do not wrap a client twice.
Read the events feed
Keyset-paginated, newest-first by default. Pass the returned nextCursor back
as cursor to page; nextCursor: null means the end of the feed.
let cursor: string | undefined;
do {
const page = await client.getEvents({ cursor });
for (const event of page.items) {
console.log(event.createdAt, event.model, event.costUsd);
}
cursor = page.nextCursor ?? undefined;
} while (cursor);Read spend metrics
The bundled dashboard metrics for a period ("7d" | "30d" | "90d", default
"30d"): total, top features/customers/models by spend, and a daily time series.
const metrics = await client.getMetrics({ period: "7d" });
console.log(metrics.total.costUsd, metrics.total.calls);
console.log(metrics.byModel); // [{ provider, model, costUsd, calls }, …]Errors
All failures throw a subclass of CostMonitorError:
| Class | When |
| --- | --- |
| CostMonitorValidationError | 400 — invalid request |
| CostMonitorAuthError | 401 — missing/invalid API key |
| CostMonitorRateLimitError | 429 — carries retryAfter |
| CostMonitorServerError | 413, 5xx, or a transport failure (status: 0) |
import { CostMonitorRateLimitError } from "@cost-monitor/sdk";
try {
await client.track(event);
} catch (err) {
if (err instanceof CostMonitorRateLimitError) {
// back off for err.retryAfter seconds
}
}Publishing (maintainers)
Manual, pre-flight checked. The @cost-monitor npm scope availability is TBD —
confirm before the first publish.
npm whoami— confirm you are logged in.npm org ls cost-monitor/npm access ls-packages— is the@cost-monitorscope yours?- Scope free →
npm org create cost-monitor(or publish under a personal scope). - If
@cost-monitoris taken, fall back to the unscoped namecost-monitor-sdk— changenameinpackage.jsonaccordingly.
- Scope free →
pnpm --filter @cost-monitor/sdk buildpnpm --filter @cost-monitor/sdk pack→ inspect the tarball; itsdist/must have no real@cost-monitor/sharedimports (only the inline comment inshared-types.jsmentions the name).npm publish --dry-run(frompackages/sdk) → review the files list.npm publish—publishConfig.access: "public"is already set.
License
MIT
