@spendgraph/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # @spendgraph/sdk
2
+
3
+ Track what your LLM calls cost. Three functions, zero dependencies, fail-open.
4
+
5
+ Tracking must never be the reason a request fails, so every path here is
6
+ non-blocking and swallows its own errors: `track()` returns `void`, network
7
+ failures retry once and then drop with a warning, and nothing throws into your
8
+ handler.
9
+
10
+ ```bash
11
+ npm install @spendgraph/sdk
12
+ ```
13
+
14
+ ## Wrap a client
15
+
16
+ The shortest path — no per-call code, and streaming, cache tokens and OpenAI
17
+ clients are all handled:
18
+
19
+ ```ts
20
+ import { SpendGraph } from "@spendgraph/sdk";
21
+ import Anthropic from "@anthropic-ai/sdk";
22
+
23
+ const meter = new SpendGraph({
24
+ apiKey: process.env.SPENDGRAPH_API_KEY,
25
+ baseUrl: "https://your-spendgraph.example.com",
26
+ });
27
+
28
+ const anthropic = meter.wrap(new Anthropic());
29
+ ```
30
+
31
+ ## Track a call yourself
32
+
33
+ ```ts
34
+ meter.track({
35
+ model: "claude-sonnet-4-5",
36
+ inputTokens: 3211, // fresh tokens only
37
+ outputTokens: 842,
38
+ cacheReadTokens: 18004, // from cache_read_input_tokens
39
+ cacheWriteTokens: 3211, // from cache_creation_input_tokens
40
+ metadata: { feature: "chat", env: "prod" },
41
+ });
42
+ ```
43
+
44
+ **The three token counts are disjoint.** Providers report them that way —
45
+ Anthropic's total input is `input + cache_read + cache_creation` — and spendgraph
46
+ prices them the same way. Do not fold cache tokens into `inputTokens` as well:
47
+ that bills the same tokens twice, at the wrong rate. Omitting them loses the
48
+ spend entirely.
49
+
50
+ ## Serverless
51
+
52
+ Events batch in memory and flush every 5s or 20 events. Serverless platforms
53
+ suspend a function once it returns rather than exiting it, so neither the timer
54
+ nor `beforeExit` runs — **await `flush()` before your handler returns**:
55
+
56
+ ```ts
57
+ export async function handler(event) {
58
+ const result = await callTheModel(event);
59
+ await meter.flush();
60
+ return result;
61
+ }
62
+ ```
63
+
64
+ If a flush is ever stranded that way, the SDK notices on the next invocation,
65
+ sends the stragglers, and warns once explaining the fix.
66
+
67
+ ## Other integrations
68
+
69
+ ```ts
70
+ import { spendGraphHandler } from "@spendgraph/sdk/langchain";
71
+ await graph.invoke(state, { callbacks: [spendGraphHandler(meter)] });
72
+
73
+ import { trackAiResult } from "@spendgraph/sdk/ai";
74
+ trackAiResult(meter, await generateText({ model, prompt }));
75
+ ```
76
+
77
+ ## Options
78
+
79
+ | Option | Default | |
80
+ | --- | --- | --- |
81
+ | `apiKey` | — | Without it, `track()` is a no-op |
82
+ | `baseUrl` | — | Your spendgraph deployment |
83
+ | `flushIntervalMs` | `5000` | |
84
+ | `maxBatch` | `20` | Flush once this many events are queued |
85
+
86
+ ## Unpriced models
87
+
88
+ The ingest response returns any model id it could not price in
89
+ `unpricedModels`, and the SDK logs each one once:
90
+
91
+ > `spendgraph: no price for "gpt-5-turbo". These events are recorded but cost $0
92
+ > until the model is in the catalog.`
93
+
94
+ Events are always stored — an unrecognised id is a mapping problem, not bad
95
+ data — so the tokens survive and only the cost is missing until the catalog
96
+ learns the model.
97
+
98
+ ## License
99
+
100
+ MIT
package/dist/ai.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { SpendGraph } from "./index.js";
2
+ type AiUsage = {
3
+ inputTokens?: number;
4
+ outputTokens?: number;
5
+ cachedInputTokens?: number;
6
+ promptTokens?: number;
7
+ completionTokens?: number;
8
+ };
9
+ type AiResult = {
10
+ usage?: AiUsage;
11
+ totalUsage?: AiUsage;
12
+ response?: {
13
+ modelId?: string;
14
+ model?: string;
15
+ };
16
+ modelId?: string;
17
+ };
18
+ /**
19
+ * Track a Vercel AI SDK result:
20
+ *
21
+ * const result = await generateText({ model: anthropic("claude-sonnet-5"), ... });
22
+ * trackAiResult(meter, result);
23
+ *
24
+ * streamText({ model, prompt, onFinish: (r) => trackAiResult(meter, r) });
25
+ *
26
+ * Fail-open: if the shape is unrecognized, nothing is tracked.
27
+ */
28
+ export declare function trackAiResult(meter: SpendGraph, result: AiResult, options?: {
29
+ model?: string;
30
+ metadata?: Record<string, string | number | boolean>;
31
+ }): void;
32
+ export {};
package/dist/ai.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Track a Vercel AI SDK result:
3
+ *
4
+ * const result = await generateText({ model: anthropic("claude-sonnet-5"), ... });
5
+ * trackAiResult(meter, result);
6
+ *
7
+ * streamText({ model, prompt, onFinish: (r) => trackAiResult(meter, r) });
8
+ *
9
+ * Fail-open: if the shape is unrecognized, nothing is tracked.
10
+ */
11
+ export function trackAiResult(meter, result, options = {}) {
12
+ try {
13
+ const usage = result?.totalUsage ?? result?.usage;
14
+ let inputTokens = usage?.inputTokens ?? usage?.promptTokens;
15
+ const outputTokens = usage?.outputTokens ?? usage?.completionTokens;
16
+ if (typeof inputTokens !== "number" || typeof outputTokens !== "number")
17
+ return;
18
+ // v5 inputTokens includes cached tokens — split them out
19
+ const cacheReadTokens = usage?.cachedInputTokens ?? 0;
20
+ inputTokens = Math.max(0, inputTokens - cacheReadTokens);
21
+ const model = options.model ??
22
+ result?.response?.modelId ??
23
+ result?.response?.model ??
24
+ result?.modelId ??
25
+ "unknown";
26
+ meter.track({
27
+ model,
28
+ inputTokens,
29
+ outputTokens,
30
+ cacheReadTokens,
31
+ metadata: options.metadata,
32
+ });
33
+ }
34
+ catch {
35
+ /* fail-open */
36
+ }
37
+ }
@@ -0,0 +1,124 @@
1
+ export interface SpendGraphOptions {
2
+ /** spendgraph API key (sg_…). Without it the SDK is a no-op. */
3
+ apiKey: string | undefined;
4
+ /** Base URL of your deployed spendgraph app, e.g. https://costs.yourco.com */
5
+ baseUrl: string;
6
+ /** Flush at most this often (ms). Default 5000. */
7
+ flushIntervalMs?: number;
8
+ /** Flush when the buffer reaches this size. Default 20. */
9
+ maxBatch?: number;
10
+ }
11
+ export interface TrackEvent {
12
+ model: string;
13
+ /** Uncached input tokens — cache tokens go in the fields below. */
14
+ inputTokens: number;
15
+ outputTokens: number;
16
+ /** Prompt-cache read (hit) tokens, billed at the provider's cache-read rate. */
17
+ cacheReadTokens?: number;
18
+ /** Prompt-cache write (creation) tokens. */
19
+ cacheWriteTokens?: number;
20
+ /**
21
+ * Client-side id: retried sends dedupe server-side.
22
+ * Must be 8–64 characters — shorter ids are rejected with a 422.
23
+ */
24
+ eventId?: string;
25
+ /** ISO timestamp; defaults to server receive time. */
26
+ timestamp?: string;
27
+ metadata?: Record<string, string | number | boolean>;
28
+ }
29
+ /**
30
+ * Fire-and-forget token tracking. track() never throws and never blocks;
31
+ * events batch in memory and flush every 5s or 20 events. Network failures
32
+ * retry once, then drop with one console.warn — tracking can never break
33
+ * the host app.
34
+ */
35
+ export declare class SpendGraph {
36
+ private readonly opts;
37
+ private queue;
38
+ private timer;
39
+ private warned;
40
+ /** Events lost in the current run of failures, reset by a successful flush. */
41
+ private dropped;
42
+ /** Unpriced model ids already reported, so each is named at most once. */
43
+ private readonly unpricedSeen;
44
+ private readonly interval;
45
+ private readonly maxBatch;
46
+ /** When the pending flush was scheduled, for the suspend check in track(). */
47
+ private timerAt;
48
+ private suspendWarned;
49
+ private noKeyWarned;
50
+ constructor(opts: SpendGraphOptions);
51
+ /** Record one LLM call. Synchronous, returns void, never throws. */
52
+ track(event: TrackEvent): void;
53
+ /**
54
+ * Says once that tracking is off.
55
+ *
56
+ * Running without a key stays a no-op on purpose — it keeps spendgraph out of
57
+ * tests and local runs without branching at every call site. But an unset
58
+ * SPENDGRAPH_API_KEY is indistinguishable from that choice, and it is the
59
+ * likeliest reason a fresh integration records nothing at all: the code is
60
+ * wired up correctly, the dashboard is empty, and nothing anywhere says why.
61
+ * Every other failure in this class warns once; this was the one that stayed
62
+ * quiet, which made it the hardest to find.
63
+ */
64
+ private warnNoKey;
65
+ /**
66
+ * Notices that the runtime froze with events still buffered.
67
+ *
68
+ * Serverless platforms suspend a function once it returns rather than
69
+ * exiting it, so neither the flush timer nor `beforeExit` ever runs and the
70
+ * queue is lost without a sound — the failure Langfuse documents for Lambda
71
+ * and Vercel. There is no reliable flag for "am I serverless", but there is
72
+ * direct evidence: a pending timer whose deadline passed long ago did not
73
+ * fire, which only happens if the runtime stopped executing between calls.
74
+ *
75
+ * Seeing that, send the stragglers now (they survive into this invocation)
76
+ * and say once what the fix is. Correct callers await flush(), which clears
77
+ * the timer, so this never fires for them.
78
+ */
79
+ private detectSuspendedRuntime;
80
+ /** Send everything buffered now. Call at the end of serverless handlers. */
81
+ flush(): Promise<void>;
82
+ /**
83
+ * Wrap an Anthropic or OpenAI client. Use the wrapped client exactly as
84
+ * before — token usage is read off each response and tracked automatically.
85
+ *
86
+ * Streaming is covered too:
87
+ * - helper streams (`anthropic.messages.stream()`,
88
+ * `openai.beta.chat.completions.stream()`) are tracked via their
89
+ * final-message promise — the stream you get back is untouched;
90
+ * - raw streams (`create({ stream: true })`) are tee'd: you receive one
91
+ * branch, usage is accumulated off the other. For OpenAI raw streams,
92
+ * pass `stream_options: { include_usage: true }` or there is no usage
93
+ * to read and the call goes untracked.
94
+ */
95
+ wrap<T extends object>(client: T): T;
96
+ private proxy;
97
+ private observeResult;
98
+ private interceptStream;
99
+ /** Accumulate usage off a tee'd SSE branch (Anthropic events / OpenAI chunks). */
100
+ private consumeStream;
101
+ private trackFromResponse;
102
+ private send;
103
+ /**
104
+ * Surfaces model ids the server could not price, once each.
105
+ *
106
+ * These are accepted and stored, so nothing here is an error — but they cost
107
+ * $0, and a dashboard reading $0 is indistinguishable from one reading
108
+ * "nothing happened". Naming the id in the integrator's own console is the
109
+ * cheapest possible moment to catch a typo or an unmapped model, and the
110
+ * per-id guard keeps a steady stream of the same unknown model from becoming
111
+ * log noise.
112
+ */
113
+ private reportUnpriced;
114
+ /**
115
+ * Warns once per outage, with a running count of what was lost.
116
+ *
117
+ * Warning on every flush would spam a hot loop, but warning exactly once per
118
+ * process — the previous behaviour — hid a server-side bug that failed every
119
+ * full batch: one line early in a long-lived process, then silence, while the
120
+ * dashboard quietly undercounted. The count is what makes the silence legible
121
+ * when someone does go looking.
122
+ */
123
+ private reportDropped;
124
+ }
package/dist/index.js ADDED
@@ -0,0 +1,421 @@
1
+ /** How long a flush may block waiting out a rate limit before giving up. */
2
+ const MAX_RETRY_WAIT_MS = 2_000;
3
+ /**
4
+ * `retry-after` in milliseconds, or null when the server did not say.
5
+ *
6
+ * Sent as whole seconds per the HTTP spec; spendgraph's ingest always sets it
7
+ * alongside a 429.
8
+ */
9
+ function retryAfterMs(res) {
10
+ const raw = res.headers.get("retry-after");
11
+ if (!raw)
12
+ return null;
13
+ const seconds = Number(raw);
14
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
15
+ }
16
+ /**
17
+ * Last-chance flush when the process winds down, shared by every meter.
18
+ *
19
+ * `beforeExit` is the right hook — unlike `exit` it permits async work, so the
20
+ * final send can actually complete. But it used to be registered per instance,
21
+ * so an app holding more than ten meters tripped Node's
22
+ * MaxListenersExceededWarning and kept every one of them alive forever.
23
+ *
24
+ * One listener, and meters are held weakly so a discarded one can still be
25
+ * collected. Deliberately no SIGTERM handler: installing one removes Node's
26
+ * default signal behaviour from the host application, and a telemetry library
27
+ * has no business changing how its host shuts down.
28
+ */
29
+ const liveMeters = new Set();
30
+ let exitHookInstalled = false;
31
+ function registerForExitFlush(meter) {
32
+ if (typeof process === "undefined" || typeof process.on !== "function")
33
+ return;
34
+ // WeakRef is everywhere the SDK runs, but fall back rather than assume
35
+ const ref = typeof WeakRef === "function"
36
+ ? new WeakRef(meter)
37
+ : { deref: () => meter };
38
+ liveMeters.add(ref);
39
+ if (exitHookInstalled)
40
+ return;
41
+ exitHookInstalled = true;
42
+ process.on("beforeExit", () => {
43
+ for (const r of liveMeters) {
44
+ const m = r.deref();
45
+ if (m)
46
+ void m.flush();
47
+ else
48
+ liveMeters.delete(r); // collected; stop tracking it
49
+ }
50
+ });
51
+ }
52
+ /**
53
+ * Fire-and-forget token tracking. track() never throws and never blocks;
54
+ * events batch in memory and flush every 5s or 20 events. Network failures
55
+ * retry once, then drop with one console.warn — tracking can never break
56
+ * the host app.
57
+ */
58
+ export class SpendGraph {
59
+ opts;
60
+ queue = [];
61
+ timer = null;
62
+ warned = false;
63
+ /** Events lost in the current run of failures, reset by a successful flush. */
64
+ dropped = 0;
65
+ /** Unpriced model ids already reported, so each is named at most once. */
66
+ unpricedSeen = new Set();
67
+ interval;
68
+ maxBatch;
69
+ /** When the pending flush was scheduled, for the suspend check in track(). */
70
+ timerAt = 0;
71
+ suspendWarned = false;
72
+ noKeyWarned = false;
73
+ constructor(opts) {
74
+ this.opts = opts;
75
+ this.interval = opts.flushIntervalMs ?? 5000;
76
+ this.maxBatch = opts.maxBatch ?? 20;
77
+ registerForExitFlush(this);
78
+ }
79
+ /** Record one LLM call. Synchronous, returns void, never throws. */
80
+ track(event) {
81
+ try {
82
+ if (!this.opts.apiKey) {
83
+ this.warnNoKey();
84
+ return;
85
+ }
86
+ this.detectSuspendedRuntime();
87
+ this.queue.push(event);
88
+ if (this.queue.length >= this.maxBatch) {
89
+ void this.flush();
90
+ }
91
+ else if (!this.timer) {
92
+ this.timer = setTimeout(() => void this.flush(), this.interval);
93
+ this.timerAt = Date.now();
94
+ // don't hold the process open just to flush telemetry
95
+ this.timer.unref?.();
96
+ }
97
+ }
98
+ catch {
99
+ /* fail-open */
100
+ }
101
+ }
102
+ /**
103
+ * Says once that tracking is off.
104
+ *
105
+ * Running without a key stays a no-op on purpose — it keeps spendgraph out of
106
+ * tests and local runs without branching at every call site. But an unset
107
+ * SPENDGRAPH_API_KEY is indistinguishable from that choice, and it is the
108
+ * likeliest reason a fresh integration records nothing at all: the code is
109
+ * wired up correctly, the dashboard is empty, and nothing anywhere says why.
110
+ * Every other failure in this class warns once; this was the one that stayed
111
+ * quiet, which made it the hardest to find.
112
+ */
113
+ warnNoKey() {
114
+ if (this.noKeyWarned)
115
+ return;
116
+ this.noKeyWarned = true;
117
+ console.warn("spendgraph: no apiKey set, so track() is recording nothing. Pass apiKey " +
118
+ "(usually from SPENDGRAPH_API_KEY) to start tracking, or ignore this if " +
119
+ "tracking is meant to be off here.");
120
+ }
121
+ /**
122
+ * Notices that the runtime froze with events still buffered.
123
+ *
124
+ * Serverless platforms suspend a function once it returns rather than
125
+ * exiting it, so neither the flush timer nor `beforeExit` ever runs and the
126
+ * queue is lost without a sound — the failure Langfuse documents for Lambda
127
+ * and Vercel. There is no reliable flag for "am I serverless", but there is
128
+ * direct evidence: a pending timer whose deadline passed long ago did not
129
+ * fire, which only happens if the runtime stopped executing between calls.
130
+ *
131
+ * Seeing that, send the stragglers now (they survive into this invocation)
132
+ * and say once what the fix is. Correct callers await flush(), which clears
133
+ * the timer, so this never fires for them.
134
+ */
135
+ detectSuspendedRuntime() {
136
+ if (!this.timer)
137
+ return;
138
+ // 2x the interval, so ordinary event-loop lag is never mistaken for a freeze
139
+ if (Date.now() - this.timerAt <= this.interval * 2)
140
+ return;
141
+ if (!this.suspendWarned) {
142
+ this.suspendWarned = true;
143
+ console.warn("spendgraph: a scheduled flush never ran — this runtime suspends between " +
144
+ "invocations, so buffered events are lost. Await meter.flush() before " +
145
+ "your handler returns.");
146
+ }
147
+ void this.flush();
148
+ }
149
+ /** Send everything buffered now. Call at the end of serverless handlers. */
150
+ async flush() {
151
+ if (this.timer) {
152
+ clearTimeout(this.timer);
153
+ this.timer = null;
154
+ }
155
+ if (this.queue.length === 0 || !this.opts.apiKey)
156
+ return;
157
+ const events = this.queue.splice(0, this.queue.length);
158
+ for (let i = 0; i < events.length; i += 100) {
159
+ await this.send(events.slice(i, i + 100));
160
+ }
161
+ }
162
+ /**
163
+ * Wrap an Anthropic or OpenAI client. Use the wrapped client exactly as
164
+ * before — token usage is read off each response and tracked automatically.
165
+ *
166
+ * Streaming is covered too:
167
+ * - helper streams (`anthropic.messages.stream()`,
168
+ * `openai.beta.chat.completions.stream()`) are tracked via their
169
+ * final-message promise — the stream you get back is untouched;
170
+ * - raw streams (`create({ stream: true })`) are tee'd: you receive one
171
+ * branch, usage is accumulated off the other. For OpenAI raw streams,
172
+ * pass `stream_options: { include_usage: true }` or there is no usage
173
+ * to read and the call goes untracked.
174
+ */
175
+ wrap(client) {
176
+ return this.proxy(client);
177
+ }
178
+ proxy(target) {
179
+ return new Proxy(target, {
180
+ get: (obj, prop, receiver) => {
181
+ const value = Reflect.get(obj, prop, receiver);
182
+ if (typeof value === "function") {
183
+ return (...args) => this.observeResult(value.apply(obj, args), args);
184
+ }
185
+ if (value !== null && typeof value === "object") {
186
+ return this.proxy(value);
187
+ }
188
+ return value;
189
+ },
190
+ });
191
+ }
192
+ observeResult(result, args) {
193
+ try {
194
+ // Helper streams return synchronously and expose a final-message
195
+ // promise (Anthropic MessageStream / OpenAI ChatCompletionStream).
196
+ // Awaiting it does not consume the caller's iterator.
197
+ const helper = result;
198
+ const final = typeof helper?.finalMessage === "function"
199
+ ? helper.finalMessage()
200
+ : typeof helper?.finalChatCompletion === "function"
201
+ ? helper.finalChatCompletion()
202
+ : null;
203
+ if (final instanceof Promise) {
204
+ final.then((m) => this.trackFromResponse(m), () => { });
205
+ return result;
206
+ }
207
+ if (result instanceof Promise) {
208
+ // Raw streaming (create({stream: true})) resolves to an SSE stream;
209
+ // swap in a tee'd branch so we can read usage without consuming the
210
+ // caller's. Only then do we replace the promise — non-streaming
211
+ // calls keep the SDK's original promise (withResponse() etc.).
212
+ const wantsStream = !!args[0]
213
+ ?.stream;
214
+ if (wantsStream) {
215
+ return result.then((v) => this.interceptStream(v));
216
+ }
217
+ // provider errors are the caller's to handle
218
+ result.then((v) => this.trackFromResponse(v), () => { });
219
+ }
220
+ }
221
+ catch {
222
+ /* fail-open */
223
+ }
224
+ return result;
225
+ }
226
+ interceptStream(v) {
227
+ try {
228
+ const s = v;
229
+ if (s &&
230
+ typeof s.tee === "function" &&
231
+ typeof s[Symbol.asyncIterator] === "function") {
232
+ const [mine, theirs] = s.tee();
233
+ void this.consumeStream(mine);
234
+ return theirs;
235
+ }
236
+ this.trackFromResponse(v);
237
+ }
238
+ catch {
239
+ /* fail-open */
240
+ }
241
+ return v;
242
+ }
243
+ /** Accumulate usage off a tee'd SSE branch (Anthropic events / OpenAI chunks). */
244
+ async consumeStream(iter) {
245
+ try {
246
+ let model;
247
+ let inputTokens;
248
+ let outputTokens;
249
+ let cacheReadTokens = 0;
250
+ let cacheWriteTokens = 0;
251
+ for await (const raw of iter) {
252
+ const ev = raw;
253
+ if (ev?.type === "message_start" && ev.message) {
254
+ // Anthropic: input + cache usage arrive up front
255
+ model = ev.message.model ?? model;
256
+ const u = ev.message.usage ?? {};
257
+ inputTokens = u.input_tokens ?? inputTokens;
258
+ cacheReadTokens = u.cache_read_input_tokens ?? 0;
259
+ cacheWriteTokens = u.cache_creation_input_tokens ?? 0;
260
+ }
261
+ else if (ev?.type === "message_delta" && ev.usage) {
262
+ // Anthropic: cumulative output count
263
+ outputTokens = ev.usage.output_tokens ?? outputTokens;
264
+ }
265
+ else if (ev?.object === "chat.completion.chunk") {
266
+ // OpenAI: usage only on the final chunk, and only with
267
+ // stream_options: { include_usage: true }
268
+ model = ev.model ?? model;
269
+ if (ev.usage) {
270
+ const u = ev.usage;
271
+ const cached = u.prompt_tokens_details?.cached_tokens ?? 0;
272
+ inputTokens = Math.max(0, (u.prompt_tokens ?? 0) - cached);
273
+ cacheReadTokens = cached;
274
+ outputTokens = u.completion_tokens;
275
+ }
276
+ }
277
+ }
278
+ if (model &&
279
+ typeof inputTokens === "number" &&
280
+ typeof outputTokens === "number") {
281
+ this.track({
282
+ model,
283
+ inputTokens,
284
+ outputTokens,
285
+ cacheReadTokens,
286
+ cacheWriteTokens,
287
+ });
288
+ }
289
+ }
290
+ catch {
291
+ /* fail-open — never disturb the caller's branch */
292
+ }
293
+ }
294
+ trackFromResponse(res) {
295
+ try {
296
+ if (!res || typeof res !== "object")
297
+ return;
298
+ const r = res;
299
+ if (!r.usage || !r.model)
300
+ return;
301
+ const u = r.usage;
302
+ if (typeof u.input_tokens === "number" && typeof u.output_tokens === "number") {
303
+ // Anthropic: cache tokens are separate fields already
304
+ this.track({
305
+ model: r.model,
306
+ inputTokens: u.input_tokens,
307
+ outputTokens: u.output_tokens,
308
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
309
+ cacheWriteTokens: u.cache_creation_input_tokens ?? 0,
310
+ });
311
+ return;
312
+ }
313
+ if (typeof u.prompt_tokens === "number" && typeof u.completion_tokens === "number") {
314
+ // OpenAI: cached tokens are included in prompt_tokens — split them out
315
+ const cached = u.prompt_tokens_details?.cached_tokens ?? 0;
316
+ this.track({
317
+ model: r.model,
318
+ inputTokens: Math.max(0, u.prompt_tokens - cached),
319
+ outputTokens: u.completion_tokens,
320
+ cacheReadTokens: cached,
321
+ });
322
+ }
323
+ }
324
+ catch {
325
+ /* fail-open */
326
+ }
327
+ }
328
+ async send(events, attempt = 0) {
329
+ try {
330
+ const res = await fetch(`${this.opts.baseUrl.replace(/\/$/, "")}/api/v1/ingest`, {
331
+ method: "POST",
332
+ headers: {
333
+ "content-type": "application/json",
334
+ "x-api-key": this.opts.apiKey,
335
+ },
336
+ body: JSON.stringify({ events }),
337
+ });
338
+ if (!res.ok && res.status >= 500 && attempt === 0) {
339
+ return this.send(events, 1);
340
+ }
341
+ // 429 is the one failure the server tells you how to fix: it sends
342
+ // retry-after, and the window is at most a minute wide, so a batch that
343
+ // arrived at the end of one is deliverable a second later. Dropping it
344
+ // immediately threw that away and reported the least useful thing it
345
+ // could — "ingest returned 429" — for the one status with a documented
346
+ // remedy. The wait is capped because flush() is awaited inside serverless
347
+ // handlers, where blocking for a minute is worse than losing the batch.
348
+ if (res.status === 429 && attempt === 0) {
349
+ const waitMs = retryAfterMs(res);
350
+ if (waitMs !== null && waitMs <= MAX_RETRY_WAIT_MS) {
351
+ await new Promise((r) => setTimeout(r, waitMs));
352
+ return this.send(events, 1);
353
+ }
354
+ this.reportDropped(events.length, waitMs === null
355
+ ? "rate limited by ingest"
356
+ : `rate limited by ingest, clear in ${Math.ceil(waitMs / 1000)}s`);
357
+ return;
358
+ }
359
+ if (!res.ok) {
360
+ this.reportDropped(events.length, `ingest returned ${res.status}`);
361
+ return;
362
+ }
363
+ // A run of failures is over; a later one is news again rather than more
364
+ // of the same, so the warning is allowed to fire once more.
365
+ this.warned = false;
366
+ this.dropped = 0;
367
+ await this.reportUnpriced(res);
368
+ }
369
+ catch (err) {
370
+ if (attempt === 0)
371
+ return this.send(events, 1);
372
+ this.reportDropped(events.length, `unreachable (${String(err)})`);
373
+ }
374
+ }
375
+ /**
376
+ * Surfaces model ids the server could not price, once each.
377
+ *
378
+ * These are accepted and stored, so nothing here is an error — but they cost
379
+ * $0, and a dashboard reading $0 is indistinguishable from one reading
380
+ * "nothing happened". Naming the id in the integrator's own console is the
381
+ * cheapest possible moment to catch a typo or an unmapped model, and the
382
+ * per-id guard keeps a steady stream of the same unknown model from becoming
383
+ * log noise.
384
+ */
385
+ async reportUnpriced(res) {
386
+ try {
387
+ const body = (await res.json());
388
+ if (!Array.isArray(body?.unpricedModels))
389
+ return;
390
+ const fresh = body.unpricedModels
391
+ .filter((m) => typeof m === "string")
392
+ .filter((m) => !this.unpricedSeen.has(m));
393
+ if (fresh.length === 0)
394
+ return;
395
+ for (const m of fresh)
396
+ this.unpricedSeen.add(m);
397
+ console.warn(`spendgraph: no price for ${fresh.map((m) => `"${m}"`).join(", ")}. ` +
398
+ `These events are recorded but cost $0 until the model is in the catalog.`);
399
+ }
400
+ catch {
401
+ // a body we cannot read is not worth failing a successful flush over
402
+ }
403
+ }
404
+ /**
405
+ * Warns once per outage, with a running count of what was lost.
406
+ *
407
+ * Warning on every flush would spam a hot loop, but warning exactly once per
408
+ * process — the previous behaviour — hid a server-side bug that failed every
409
+ * full batch: one line early in a long-lived process, then silence, while the
410
+ * dashboard quietly undercounted. The count is what makes the silence legible
411
+ * when someone does go looking.
412
+ */
413
+ reportDropped(count, reason) {
414
+ this.dropped += count;
415
+ if (this.warned)
416
+ return;
417
+ this.warned = true;
418
+ console.warn(`spendgraph: ${reason}; dropped ${this.dropped} event(s). ` +
419
+ `Further drops are counted but not logged until a flush succeeds.`);
420
+ }
421
+ }
@@ -0,0 +1,69 @@
1
+ import type { SpendGraph } from "./index.js";
2
+ type SerializedLlm = {
3
+ kwargs?: {
4
+ model?: string;
5
+ model_name?: string;
6
+ };
7
+ };
8
+ type ExtraParams = {
9
+ invocation_params?: {
10
+ model?: string;
11
+ model_name?: string;
12
+ modelId?: string;
13
+ };
14
+ };
15
+ type LlmResult = {
16
+ generations?: Array<Array<{
17
+ message?: {
18
+ usage_metadata?: {
19
+ input_tokens?: number;
20
+ output_tokens?: number;
21
+ input_token_details?: {
22
+ cache_read?: number;
23
+ cache_creation?: number;
24
+ };
25
+ };
26
+ response_metadata?: {
27
+ model?: string;
28
+ model_name?: string;
29
+ };
30
+ };
31
+ }>>;
32
+ llmOutput?: {
33
+ model?: string;
34
+ tokenUsage?: {
35
+ promptTokens?: number;
36
+ completionTokens?: number;
37
+ };
38
+ usage?: {
39
+ input_tokens?: number;
40
+ output_tokens?: number;
41
+ cache_read_input_tokens?: number;
42
+ cache_creation_input_tokens?: number;
43
+ };
44
+ };
45
+ };
46
+ export interface LangchainHandlerOptions {
47
+ /** Attached to every tracked event, e.g. { feature: "rag" }. */
48
+ metadata?: Record<string, string | number | boolean>;
49
+ /** Fallback when the model name can't be read from the run. */
50
+ defaultModel?: string;
51
+ }
52
+ /**
53
+ * LangChain / LangGraph callback handler.
54
+ *
55
+ * const handler = spendGraphHandler(meter);
56
+ * await chain.invoke(input, { callbacks: [handler] }); // LangChain
57
+ * await graph.invoke(input, { callbacks: [handler] }); // LangGraph
58
+ *
59
+ * Or pass it in the model constructor (`new ChatAnthropic({ callbacks: [...] })`)
60
+ * to cover every call that model makes. Fail-open like the rest of the SDK.
61
+ */
62
+ export declare function spendGraphHandler(meter: SpendGraph, options?: LangchainHandlerOptions): {
63
+ name: string;
64
+ handleLLMStart(llm: SerializedLlm, _prompts: string[], runId: string, _parentRunId?: string, extraParams?: ExtraParams): void;
65
+ handleChatModelStart(llm: SerializedLlm, _messages: unknown, runId: string, _parentRunId?: string, extraParams?: ExtraParams): void;
66
+ handleLLMEnd(output: LlmResult, runId: string): void;
67
+ handleLLMError(_err: unknown, runId: string): void;
68
+ };
69
+ export {};
@@ -0,0 +1,83 @@
1
+ /**
2
+ * LangChain / LangGraph callback handler.
3
+ *
4
+ * const handler = spendGraphHandler(meter);
5
+ * await chain.invoke(input, { callbacks: [handler] }); // LangChain
6
+ * await graph.invoke(input, { callbacks: [handler] }); // LangGraph
7
+ *
8
+ * Or pass it in the model constructor (`new ChatAnthropic({ callbacks: [...] })`)
9
+ * to cover every call that model makes. Fail-open like the rest of the SDK.
10
+ */
11
+ export function spendGraphHandler(meter, options = {}) {
12
+ const models = new Map();
13
+ const rememberModel = (llm, runId, extraParams) => {
14
+ try {
15
+ const model = extraParams?.invocation_params?.model ??
16
+ extraParams?.invocation_params?.model_name ??
17
+ extraParams?.invocation_params?.modelId ??
18
+ llm?.kwargs?.model ??
19
+ llm?.kwargs?.model_name;
20
+ if (model)
21
+ models.set(runId, model);
22
+ }
23
+ catch {
24
+ /* fail-open */
25
+ }
26
+ };
27
+ return {
28
+ name: "spendgraph",
29
+ handleLLMStart(llm, _prompts, runId, _parentRunId, extraParams) {
30
+ rememberModel(llm, runId, extraParams);
31
+ },
32
+ handleChatModelStart(llm, _messages, runId, _parentRunId, extraParams) {
33
+ rememberModel(llm, runId, extraParams);
34
+ },
35
+ handleLLMEnd(output, runId) {
36
+ try {
37
+ const msg = output.generations?.[0]?.[0]?.message;
38
+ const um = msg?.usage_metadata;
39
+ const tu = output.llmOutput?.tokenUsage;
40
+ const au = output.llmOutput?.usage;
41
+ let inputTokens = um?.input_tokens ?? au?.input_tokens ?? tu?.promptTokens;
42
+ const outputTokens = um?.output_tokens ?? au?.output_tokens ?? tu?.completionTokens;
43
+ if (typeof inputTokens !== "number" || typeof outputTokens !== "number")
44
+ return;
45
+ // Cache tokens: usage_metadata.input_tokens INCLUDES them (LangChain
46
+ // normalizes to a total) so they're split out; the raw Anthropic
47
+ // llmOutput.usage shape keeps them separate already.
48
+ let cacheReadTokens = 0;
49
+ let cacheWriteTokens = 0;
50
+ if (um?.input_token_details && typeof um.input_tokens === "number") {
51
+ cacheReadTokens = um.input_token_details.cache_read ?? 0;
52
+ cacheWriteTokens = um.input_token_details.cache_creation ?? 0;
53
+ inputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens);
54
+ }
55
+ else if (au) {
56
+ cacheReadTokens = au.cache_read_input_tokens ?? 0;
57
+ cacheWriteTokens = au.cache_creation_input_tokens ?? 0;
58
+ }
59
+ const model = msg?.response_metadata?.model ??
60
+ msg?.response_metadata?.model_name ??
61
+ output.llmOutput?.model ??
62
+ models.get(runId) ??
63
+ options.defaultModel ??
64
+ "unknown";
65
+ models.delete(runId);
66
+ meter.track({
67
+ model,
68
+ inputTokens,
69
+ outputTokens,
70
+ cacheReadTokens,
71
+ cacheWriteTokens,
72
+ metadata: options.metadata,
73
+ });
74
+ }
75
+ catch {
76
+ /* fail-open */
77
+ }
78
+ },
79
+ handleLLMError(_err, runId) {
80
+ models.delete(runId);
81
+ },
82
+ };
83
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@spendgraph/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Track LLM input/output tokens and cost. Three functions, zero dependencies, fail-open.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/fnLog0/spendgraph.git",
9
+ "directory": "packages/sdk"
10
+ },
11
+ "homepage": "https://github.com/fnLog0/spendgraph#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/fnLog0/spendgraph/issues"
14
+ },
15
+ "keywords": [
16
+ "llm",
17
+ "cost",
18
+ "tokens",
19
+ "openai",
20
+ "anthropic",
21
+ "observability",
22
+ "pricing"
23
+ ],
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ },
32
+ "./langchain": {
33
+ "types": "./dist/langchain.d.ts",
34
+ "import": "./dist/langchain.js"
35
+ },
36
+ "./ai": {
37
+ "types": "./dist/ai.d.ts",
38
+ "import": "./dist/ai.js"
39
+ }
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "README.md"
44
+ ],
45
+ "scripts": {
46
+ "build": "tsc -p tsconfig.json"
47
+ },
48
+ "devDependencies": {
49
+ "typescript": "^5"
50
+ },
51
+ "engines": {
52
+ "node": ">=18"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }