@spendgraph/sdk 0.1.0 → 0.2.1
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/dist/ai.js +0 -12
- package/dist/client.d.ts +37 -0
- package/dist/client.js +36 -0
- package/dist/core/client/client.d.ts +30 -0
- package/dist/core/client/client.js +128 -0
- package/dist/core/client/errors.d.ts +25 -0
- package/dist/core/client/errors.js +17 -0
- package/dist/core/client/index.d.ts +2 -0
- package/dist/core/client/index.js +2 -0
- package/dist/core/types.d.ts +28 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +9 -124
- package/dist/index.js +5 -421
- package/dist/langchain.js +3 -17
- package/dist/resources/alerts.d.ts +18 -0
- package/dist/resources/alerts.js +9 -0
- package/dist/resources/credentials.d.ts +22 -0
- package/dist/resources/credentials.js +15 -0
- package/dist/resources/events.d.ts +41 -0
- package/dist/resources/events.js +18 -0
- package/dist/resources/index.d.ts +21 -0
- package/dist/resources/index.js +12 -0
- package/dist/resources/ingest.d.ts +42 -0
- package/dist/resources/ingest.js +27 -0
- package/dist/resources/keys.d.ts +34 -0
- package/dist/resources/keys.js +15 -0
- package/dist/resources/playground.d.ts +7 -0
- package/dist/resources/playground.js +9 -0
- package/dist/resources/pricing.d.ts +49 -0
- package/dist/resources/pricing.js +36 -0
- package/dist/resources/projects.d.ts +54 -0
- package/dist/resources/projects.js +49 -0
- package/dist/resources/prompts-admin.d.ts +23 -0
- package/dist/resources/prompts-admin.js +21 -0
- package/dist/resources/prompts.d.ts +124 -0
- package/dist/resources/prompts.js +49 -0
- package/dist/resources/stats.d.ts +65 -0
- package/dist/resources/stats.js +21 -0
- package/dist/resources/tools.d.ts +66 -0
- package/dist/resources/tools.js +21 -0
- package/dist/rollout/index.d.ts +1 -0
- package/dist/rollout/index.js +1 -0
- package/dist/rollout/rollout.d.ts +75 -0
- package/dist/rollout/rollout.js +1 -0
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.js +2 -0
- package/dist/schema/serialize/index.d.ts +1 -0
- package/dist/schema/serialize/index.js +1 -0
- package/dist/schema/serialize/serialize.d.ts +12 -0
- package/dist/schema/serialize/serialize.js +31 -0
- package/dist/schema/types/index.d.ts +1 -0
- package/dist/schema/types/index.js +1 -0
- package/dist/schema/types/types.d.ts +58 -0
- package/dist/schema/types/types.js +1 -0
- package/dist/schema/validate/index.d.ts +1 -0
- package/dist/schema/validate/index.js +1 -0
- package/dist/schema/validate/validate.d.ts +28 -0
- package/dist/schema/validate/validate.js +92 -0
- package/dist/track/index.d.ts +2 -0
- package/dist/track/index.js +1 -0
- package/dist/track/track.d.ts +209 -0
- package/dist/track/track.js +331 -0
- package/package.json +3 -2
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { Client, SpendgraphError } from "../core/client/index.js";
|
|
2
|
+
const MAX_RETRY_WAIT_MS = 2_000;
|
|
3
|
+
const liveMeters = new Set();
|
|
4
|
+
let exitHookInstalled = false;
|
|
5
|
+
function registerForExitFlush(meter) {
|
|
6
|
+
if (typeof process === "undefined" || typeof process.on !== "function")
|
|
7
|
+
return;
|
|
8
|
+
const ref = typeof WeakRef === "function"
|
|
9
|
+
? new WeakRef(meter)
|
|
10
|
+
: { deref: () => meter };
|
|
11
|
+
liveMeters.add(ref);
|
|
12
|
+
if (exitHookInstalled)
|
|
13
|
+
return;
|
|
14
|
+
exitHookInstalled = true;
|
|
15
|
+
process.on("beforeExit", () => {
|
|
16
|
+
for (const r of liveMeters) {
|
|
17
|
+
const m = r.deref();
|
|
18
|
+
if (m)
|
|
19
|
+
void m.flush();
|
|
20
|
+
else
|
|
21
|
+
liveMeters.delete(r);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export class SpendGraph {
|
|
26
|
+
opts;
|
|
27
|
+
queue = [];
|
|
28
|
+
timer = null;
|
|
29
|
+
warned = false;
|
|
30
|
+
dropped = 0;
|
|
31
|
+
unpricedSeen = new Set();
|
|
32
|
+
interval;
|
|
33
|
+
maxBatch;
|
|
34
|
+
timerAt = 0;
|
|
35
|
+
suspendWarned = false;
|
|
36
|
+
noKeyWarned = false;
|
|
37
|
+
streamUsageWarned = false;
|
|
38
|
+
inFlight = null;
|
|
39
|
+
constructor(opts) {
|
|
40
|
+
this.opts = opts;
|
|
41
|
+
this.interval = opts.flushIntervalMs ?? 5000;
|
|
42
|
+
this.maxBatch = opts.maxBatch ?? 20;
|
|
43
|
+
registerForExitFlush(this);
|
|
44
|
+
}
|
|
45
|
+
clientFor(apiKey) {
|
|
46
|
+
return new Client({ apiKey, baseUrl: this.opts.baseUrl, attempts: 1 });
|
|
47
|
+
}
|
|
48
|
+
track(event) {
|
|
49
|
+
try {
|
|
50
|
+
if (!this.opts.apiKey) {
|
|
51
|
+
this.warnNoKey();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.detectSuspendedRuntime();
|
|
55
|
+
this.queue.push(event);
|
|
56
|
+
if (this.queue.length >= this.maxBatch) {
|
|
57
|
+
void this.flush();
|
|
58
|
+
}
|
|
59
|
+
else if (!this.timer) {
|
|
60
|
+
this.timer = setTimeout(() => void this.flush(), this.interval);
|
|
61
|
+
this.timerAt = Date.now();
|
|
62
|
+
this.timer.unref?.();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
warnNoKey() {
|
|
69
|
+
if (this.noKeyWarned)
|
|
70
|
+
return;
|
|
71
|
+
this.noKeyWarned = true;
|
|
72
|
+
console.warn("spendgraph: no apiKey set, so track() is recording nothing. Pass apiKey " +
|
|
73
|
+
"(usually from SPENDGRAPH_API_KEY) to start tracking, or ignore this if " +
|
|
74
|
+
"tracking is meant to be off here.");
|
|
75
|
+
}
|
|
76
|
+
detectSuspendedRuntime() {
|
|
77
|
+
if (!this.timer)
|
|
78
|
+
return;
|
|
79
|
+
if (Date.now() - this.timerAt <= this.interval * 2)
|
|
80
|
+
return;
|
|
81
|
+
if (!this.suspendWarned) {
|
|
82
|
+
this.suspendWarned = true;
|
|
83
|
+
console.warn("spendgraph: a scheduled flush never ran — this runtime suspends between " +
|
|
84
|
+
"invocations, so buffered events are lost. Await meter.flush() before " +
|
|
85
|
+
"your handler returns.");
|
|
86
|
+
}
|
|
87
|
+
void this.flush();
|
|
88
|
+
}
|
|
89
|
+
async flush() {
|
|
90
|
+
const prev = this.inFlight;
|
|
91
|
+
const mine = this.drain();
|
|
92
|
+
const run = Promise.allSettled([prev, mine]).then(() => undefined);
|
|
93
|
+
this.inFlight = run;
|
|
94
|
+
try {
|
|
95
|
+
await run;
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
if (this.inFlight === run)
|
|
99
|
+
this.inFlight = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async drain() {
|
|
103
|
+
if (this.timer) {
|
|
104
|
+
clearTimeout(this.timer);
|
|
105
|
+
this.timer = null;
|
|
106
|
+
}
|
|
107
|
+
const apiKey = this.opts.apiKey;
|
|
108
|
+
if (this.queue.length === 0 || !apiKey)
|
|
109
|
+
return;
|
|
110
|
+
const events = this.queue.splice(0, this.queue.length);
|
|
111
|
+
for (let i = 0; i < events.length; i += 100) {
|
|
112
|
+
await this.send(events.slice(i, i + 100), apiKey);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
wrap(client) {
|
|
116
|
+
return this.proxy(client);
|
|
117
|
+
}
|
|
118
|
+
proxy(target) {
|
|
119
|
+
const cache = new Map();
|
|
120
|
+
return new Proxy(target, {
|
|
121
|
+
get: (obj, prop) => {
|
|
122
|
+
const value = Reflect.get(obj, prop);
|
|
123
|
+
if (value === null || (typeof value !== "function" && typeof value !== "object")) {
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
const hit = cache.get(prop);
|
|
127
|
+
if (hit && hit.from === value)
|
|
128
|
+
return hit.out;
|
|
129
|
+
const out = typeof value === "function"
|
|
130
|
+
? (...args) => this.observeResult(value.apply(obj, args), args)
|
|
131
|
+
: this.proxy(value);
|
|
132
|
+
cache.set(prop, { from: value, out });
|
|
133
|
+
return out;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
observeResult(result, args) {
|
|
138
|
+
try {
|
|
139
|
+
const helper = result;
|
|
140
|
+
const final = typeof helper?.finalMessage === "function"
|
|
141
|
+
? helper.finalMessage()
|
|
142
|
+
: typeof helper?.finalChatCompletion === "function"
|
|
143
|
+
? helper.finalChatCompletion()
|
|
144
|
+
: null;
|
|
145
|
+
if (final instanceof Promise) {
|
|
146
|
+
final.then((m) => this.trackFromResponse(m), () => { });
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
if (result instanceof Promise) {
|
|
150
|
+
const wantsStream = !!args[0]?.stream;
|
|
151
|
+
if (wantsStream) {
|
|
152
|
+
return result.then((v) => this.interceptStream(v));
|
|
153
|
+
}
|
|
154
|
+
result.then((v) => this.trackFromResponse(v), () => { });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
interceptStream(v) {
|
|
162
|
+
try {
|
|
163
|
+
const s = v;
|
|
164
|
+
if (s && typeof s.tee === "function" && typeof s[Symbol.asyncIterator] === "function") {
|
|
165
|
+
const [mine, theirs] = s.tee();
|
|
166
|
+
void this.consumeStream(mine);
|
|
167
|
+
return theirs;
|
|
168
|
+
}
|
|
169
|
+
this.trackFromResponse(v);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
}
|
|
173
|
+
return v;
|
|
174
|
+
}
|
|
175
|
+
async consumeStream(iter) {
|
|
176
|
+
try {
|
|
177
|
+
let model;
|
|
178
|
+
let inputTokens;
|
|
179
|
+
let outputTokens;
|
|
180
|
+
let cacheReadTokens = 0;
|
|
181
|
+
let cacheWriteTokens = 0;
|
|
182
|
+
let citationTokens;
|
|
183
|
+
let reasoningTokens;
|
|
184
|
+
let sawChunk = false;
|
|
185
|
+
for await (const raw of iter) {
|
|
186
|
+
sawChunk = true;
|
|
187
|
+
const ev = raw;
|
|
188
|
+
if (ev?.type === "message_start" && ev.message) {
|
|
189
|
+
model = ev.message.model ?? model;
|
|
190
|
+
const u = ev.message.usage ?? {};
|
|
191
|
+
inputTokens = u.input_tokens ?? inputTokens;
|
|
192
|
+
cacheReadTokens = u.cache_read_input_tokens ?? 0;
|
|
193
|
+
cacheWriteTokens = u.cache_creation_input_tokens ?? 0;
|
|
194
|
+
}
|
|
195
|
+
else if (ev?.type === "message_delta" && ev.usage) {
|
|
196
|
+
outputTokens = ev.usage.output_tokens ?? outputTokens;
|
|
197
|
+
}
|
|
198
|
+
else if (ev?.object === "chat.completion.chunk") {
|
|
199
|
+
model = ev.model ?? model;
|
|
200
|
+
if (ev.usage) {
|
|
201
|
+
const u = ev.usage;
|
|
202
|
+
const cached = u.prompt_tokens_details?.cached_tokens ?? 0;
|
|
203
|
+
inputTokens = Math.max(0, (u.prompt_tokens ?? 0) - cached);
|
|
204
|
+
cacheReadTokens = cached;
|
|
205
|
+
outputTokens = u.completion_tokens;
|
|
206
|
+
citationTokens = u.citation_tokens;
|
|
207
|
+
reasoningTokens = u.reasoning_tokens;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (model && typeof inputTokens === "number" && typeof outputTokens === "number") {
|
|
212
|
+
this.track({
|
|
213
|
+
model,
|
|
214
|
+
inputTokens,
|
|
215
|
+
outputTokens,
|
|
216
|
+
cacheReadTokens,
|
|
217
|
+
cacheWriteTokens,
|
|
218
|
+
citationTokens,
|
|
219
|
+
reasoningTokens,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
else if (sawChunk) {
|
|
223
|
+
this.warnStreamNoUsage();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
warnStreamNoUsage() {
|
|
230
|
+
if (this.streamUsageWarned)
|
|
231
|
+
return;
|
|
232
|
+
this.streamUsageWarned = true;
|
|
233
|
+
console.warn("spendgraph: a streamed response carried no token usage, so that call " +
|
|
234
|
+
"was not tracked. For OpenAI raw streams pass " +
|
|
235
|
+
"stream_options: { include_usage: true }. Further untracked streams " +
|
|
236
|
+
"are not logged.");
|
|
237
|
+
}
|
|
238
|
+
trackFromResponse(res) {
|
|
239
|
+
try {
|
|
240
|
+
if (!res || typeof res !== "object")
|
|
241
|
+
return;
|
|
242
|
+
const r = res;
|
|
243
|
+
if (!r.usage || !r.model)
|
|
244
|
+
return;
|
|
245
|
+
const u = r.usage;
|
|
246
|
+
if (typeof u.input_tokens === "number" && typeof u.output_tokens === "number") {
|
|
247
|
+
this.track({
|
|
248
|
+
model: r.model,
|
|
249
|
+
inputTokens: u.input_tokens,
|
|
250
|
+
outputTokens: u.output_tokens,
|
|
251
|
+
cacheReadTokens: u.cache_read_input_tokens ?? 0,
|
|
252
|
+
cacheWriteTokens: u.cache_creation_input_tokens ?? 0,
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (typeof u.prompt_tokens === "number" && typeof u.completion_tokens === "number") {
|
|
257
|
+
const cached = u.prompt_tokens_details?.cached_tokens ?? 0;
|
|
258
|
+
this.track({
|
|
259
|
+
model: r.model,
|
|
260
|
+
inputTokens: Math.max(0, u.prompt_tokens - cached),
|
|
261
|
+
outputTokens: u.completion_tokens,
|
|
262
|
+
cacheReadTokens: cached,
|
|
263
|
+
citationTokens: u.citation_tokens,
|
|
264
|
+
reasoningTokens: u.reasoning_tokens,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async send(events, apiKey, attempt = 0) {
|
|
272
|
+
let body;
|
|
273
|
+
try {
|
|
274
|
+
body = await this.clientFor(apiKey).post("/api/v1/ingest", { events });
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
return this.onSendFailed(err, events, apiKey, attempt);
|
|
278
|
+
}
|
|
279
|
+
this.warned = false;
|
|
280
|
+
this.dropped = 0;
|
|
281
|
+
this.reportUnpriced(body);
|
|
282
|
+
}
|
|
283
|
+
async onSendFailed(err, events, apiKey, attempt) {
|
|
284
|
+
const failure = err instanceof SpendgraphError ? err : null;
|
|
285
|
+
if (!failure || failure.status === 0) {
|
|
286
|
+
if (attempt === 0)
|
|
287
|
+
return this.send(events, apiKey, 1);
|
|
288
|
+
this.reportDropped(events.length, `unreachable (${String(err)})`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (failure.status >= 500 && attempt === 0) {
|
|
292
|
+
return this.send(events, apiKey, 1);
|
|
293
|
+
}
|
|
294
|
+
if (failure.status === 429 && attempt === 0) {
|
|
295
|
+
const waitMs = failure.retryAfterMs;
|
|
296
|
+
if (waitMs !== null && waitMs <= MAX_RETRY_WAIT_MS) {
|
|
297
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
298
|
+
return this.send(events, apiKey, 1);
|
|
299
|
+
}
|
|
300
|
+
this.reportDropped(events.length, waitMs === null
|
|
301
|
+
? "rate limited by ingest"
|
|
302
|
+
: `rate limited by ingest, clear in ${Math.ceil(waitMs / 1000)}s`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
this.reportDropped(events.length, `ingest returned ${failure.status}`);
|
|
306
|
+
}
|
|
307
|
+
reportUnpriced(body) {
|
|
308
|
+
{
|
|
309
|
+
const parsed = body;
|
|
310
|
+
if (!Array.isArray(parsed?.unpricedModels))
|
|
311
|
+
return;
|
|
312
|
+
const fresh = parsed.unpricedModels
|
|
313
|
+
.filter((m) => typeof m === "string")
|
|
314
|
+
.filter((m) => !this.unpricedSeen.has(m));
|
|
315
|
+
if (fresh.length === 0)
|
|
316
|
+
return;
|
|
317
|
+
for (const m of fresh)
|
|
318
|
+
this.unpricedSeen.add(m);
|
|
319
|
+
console.warn(`spendgraph: no price for ${fresh.map((m) => `"${m}"`).join(", ")}. ` +
|
|
320
|
+
`These events are recorded but cost $0 until the model is in the catalog.`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
reportDropped(count, reason) {
|
|
324
|
+
this.dropped += count;
|
|
325
|
+
if (this.warned)
|
|
326
|
+
return;
|
|
327
|
+
this.warned = true;
|
|
328
|
+
console.warn(`spendgraph: ${reason}; dropped ${this.dropped} event(s). ` +
|
|
329
|
+
`Further drops are counted but not logged until a flush succeeds.`);
|
|
330
|
+
}
|
|
331
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spendgraph/sdk",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Track LLM input/output tokens and cost. Three functions, zero dependencies, fail-open.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"README.md"
|
|
44
44
|
],
|
|
45
45
|
"scripts": {
|
|
46
|
-
"build": "tsc -p tsconfig.json"
|
|
46
|
+
"build": "tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments",
|
|
47
|
+
"test": "vitest run"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
50
|
"typescript": "^5"
|