@spendgraph/sdk 0.1.0 → 0.2.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/dist/client.d.ts +37 -0
- package/dist/client.js +48 -0
- package/dist/core/client/client.d.ts +30 -0
- package/dist/core/client/client.js +143 -0
- package/dist/core/client/errors.d.ts +25 -0
- package/dist/core/client/errors.js +31 -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 +8 -2
- package/dist/resources/alerts.d.ts +18 -0
- package/dist/resources/alerts.js +10 -0
- package/dist/resources/credentials.d.ts +22 -0
- package/dist/resources/credentials.js +22 -0
- package/dist/resources/events.d.ts +41 -0
- package/dist/resources/events.js +20 -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 +30 -0
- package/dist/resources/keys.d.ts +34 -0
- package/dist/resources/keys.js +20 -0
- package/dist/resources/playground.d.ts +7 -0
- package/dist/resources/playground.js +10 -0
- package/dist/resources/pricing.d.ts +49 -0
- package/dist/resources/pricing.js +42 -0
- package/dist/resources/projects.d.ts +54 -0
- package/dist/resources/projects.js +51 -0
- package/dist/resources/prompts-admin.d.ts +23 -0
- package/dist/resources/prompts-admin.js +26 -0
- package/dist/resources/prompts.d.ts +124 -0
- package/dist/resources/prompts.js +67 -0
- package/dist/resources/stats.d.ts +65 -0
- package/dist/resources/stats.js +29 -0
- package/dist/resources/tools.d.ts +66 -0
- package/dist/resources/tools.js +30 -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 +42 -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 +108 -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 +513 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,421 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
}
|
|
1
|
+
export { Spendgraph } from "./client.js";
|
|
2
|
+
export { Client, queryString, SpendgraphError } from "./core/client/index.js";
|
|
3
|
+
export * from "./resources/index.js";
|
|
4
|
+
export { FieldValidationError, serializeFields, validateFields, } from "./schema/index.js";
|
|
5
|
+
export { SpendGraph } from "./track/index.js";
|
package/dist/langchain.js
CHANGED
|
@@ -33,6 +33,13 @@ export function spendGraphHandler(meter, options = {}) {
|
|
|
33
33
|
rememberModel(llm, runId, extraParams);
|
|
34
34
|
},
|
|
35
35
|
handleLLMEnd(output, runId) {
|
|
36
|
+
// Forget the run before anything else can bail out. The usage checks
|
|
37
|
+
// below give up on any response whose token counts cannot be read, and
|
|
38
|
+
// the delete used to sit after them — so each such call left its entry
|
|
39
|
+
// behind for good. This handler is meant to be attached to a long-lived
|
|
40
|
+
// model instance, which turns that into a map that only ever grows.
|
|
41
|
+
const startedAs = models.get(runId);
|
|
42
|
+
models.delete(runId);
|
|
36
43
|
try {
|
|
37
44
|
const msg = output.generations?.[0]?.[0]?.message;
|
|
38
45
|
const um = msg?.usage_metadata;
|
|
@@ -59,10 +66,9 @@ export function spendGraphHandler(meter, options = {}) {
|
|
|
59
66
|
const model = msg?.response_metadata?.model ??
|
|
60
67
|
msg?.response_metadata?.model_name ??
|
|
61
68
|
output.llmOutput?.model ??
|
|
62
|
-
|
|
69
|
+
startedAs ??
|
|
63
70
|
options.defaultModel ??
|
|
64
71
|
"unknown";
|
|
65
|
-
models.delete(runId);
|
|
66
72
|
meter.track({
|
|
67
73
|
model,
|
|
68
74
|
inputTokens,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Client } from "../core/client/index.js";
|
|
2
|
+
export interface AlertRow {
|
|
3
|
+
id: string;
|
|
4
|
+
projectId: string;
|
|
5
|
+
kind: string;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
}
|
|
8
|
+
/** Budget alerts the server has raised. */
|
|
9
|
+
export declare class Alerts {
|
|
10
|
+
private readonly client;
|
|
11
|
+
constructor(client: Client);
|
|
12
|
+
list(query?: {
|
|
13
|
+
project?: string;
|
|
14
|
+
limit?: number;
|
|
15
|
+
}): Promise<{
|
|
16
|
+
alerts: AlertRow[];
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Client } from "../core/client/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Provider API keys held by the server so it can run a prompt for you.
|
|
4
|
+
*
|
|
5
|
+
* Dashboard session only, and it stays that way: these are the secrets an
|
|
6
|
+
* `sg_` key must never be able to read.
|
|
7
|
+
*/
|
|
8
|
+
export declare class Credentials {
|
|
9
|
+
private readonly client;
|
|
10
|
+
constructor(client: Client);
|
|
11
|
+
list(): Promise<{
|
|
12
|
+
providers: unknown[];
|
|
13
|
+
}>;
|
|
14
|
+
/** Write-only from the caller's side — the server never reads one back. */
|
|
15
|
+
set(body: {
|
|
16
|
+
provider: string;
|
|
17
|
+
apiKey: string;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
provider: string;
|
|
20
|
+
}>;
|
|
21
|
+
remove(provider: string): Promise<Record<string, unknown>>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider API keys held by the server so it can run a prompt for you.
|
|
3
|
+
*
|
|
4
|
+
* Dashboard session only, and it stays that way: these are the secrets an
|
|
5
|
+
* `sg_` key must never be able to read.
|
|
6
|
+
*/
|
|
7
|
+
export class Credentials {
|
|
8
|
+
client;
|
|
9
|
+
constructor(client) {
|
|
10
|
+
this.client = client;
|
|
11
|
+
}
|
|
12
|
+
list() {
|
|
13
|
+
return this.client.get("/api/v1/credentials");
|
|
14
|
+
}
|
|
15
|
+
/** Write-only from the caller's side — the server never reads one back. */
|
|
16
|
+
set(body) {
|
|
17
|
+
return this.client.put("/api/v1/credentials", body);
|
|
18
|
+
}
|
|
19
|
+
remove(provider) {
|
|
20
|
+
return this.client.delete("/api/v1/credentials", { provider });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Client } from "../core/client/index.js";
|
|
2
|
+
export interface EventsQuery {
|
|
3
|
+
from?: string;
|
|
4
|
+
to?: string;
|
|
5
|
+
project?: string;
|
|
6
|
+
model?: string;
|
|
7
|
+
key?: string;
|
|
8
|
+
/** Free-text search. */
|
|
9
|
+
q?: string;
|
|
10
|
+
sort?: string;
|
|
11
|
+
limit?: number;
|
|
12
|
+
/** From the previous page's `nextCursor`. */
|
|
13
|
+
cursor?: string;
|
|
14
|
+
format?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface UsageEventRow {
|
|
17
|
+
id: string;
|
|
18
|
+
model: string;
|
|
19
|
+
inputTokens: number;
|
|
20
|
+
outputTokens: number;
|
|
21
|
+
cacheReadTokens: number;
|
|
22
|
+
cacheWriteTokens: number;
|
|
23
|
+
citationTokens: number;
|
|
24
|
+
reasoningTokens: number;
|
|
25
|
+
costMicros: number;
|
|
26
|
+
metadata: string | null;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
}
|
|
29
|
+
export interface EventsPage {
|
|
30
|
+
events: UsageEventRow[];
|
|
31
|
+
/** Absent on the last page. */
|
|
32
|
+
nextCursor?: string | null;
|
|
33
|
+
}
|
|
34
|
+
/** The raw call records behind the totals. */
|
|
35
|
+
export declare class Events {
|
|
36
|
+
private readonly client;
|
|
37
|
+
constructor(client: Client);
|
|
38
|
+
list(query?: EventsQuery): Promise<EventsPage>;
|
|
39
|
+
/** Walks every page, so a caller does not have to hold the cursor. */
|
|
40
|
+
all(query?: EventsQuery): AsyncGenerator<UsageEventRow>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The raw call records behind the totals. */
|
|
2
|
+
export class Events {
|
|
3
|
+
client;
|
|
4
|
+
constructor(client) {
|
|
5
|
+
this.client = client;
|
|
6
|
+
}
|
|
7
|
+
list(query = {}) {
|
|
8
|
+
return this.client.get("/api/v1/events", { ...query });
|
|
9
|
+
}
|
|
10
|
+
/** Walks every page, so a caller does not have to hold the cursor. */
|
|
11
|
+
async *all(query = {}) {
|
|
12
|
+
let cursor = query.cursor;
|
|
13
|
+
do {
|
|
14
|
+
const page = await this.list({ ...query, cursor });
|
|
15
|
+
for (const event of page.events)
|
|
16
|
+
yield event;
|
|
17
|
+
cursor = page.nextCursor ?? undefined;
|
|
18
|
+
} while (cursor);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type { AlertRow } from "./alerts.js";
|
|
2
|
+
export { Alerts } from "./alerts.js";
|
|
3
|
+
export { Credentials } from "./credentials.js";
|
|
4
|
+
export type { EventsPage, EventsQuery, UsageEventRow } from "./events.js";
|
|
5
|
+
export { Events } from "./events.js";
|
|
6
|
+
export type { IngestResult, Sender, UsageEvent } from "./ingest.js";
|
|
7
|
+
export { Ingest, MAX_EVENTS } from "./ingest.js";
|
|
8
|
+
export type { ApiKeyRow } from "./keys.js";
|
|
9
|
+
export { Keys } from "./keys.js";
|
|
10
|
+
export { Playground } from "./playground.js";
|
|
11
|
+
export type { PricingRow } from "./pricing.js";
|
|
12
|
+
export { Models, Pricing } from "./pricing.js";
|
|
13
|
+
export type { Budget, ProjectRow } from "./projects.js";
|
|
14
|
+
export { Invites, Projects } from "./projects.js";
|
|
15
|
+
export type { DatasetCase, PromptBlock, RenderedMessage, ReportRolloutInput, Role, RunPromptInput, SavePromptInput, Scoped, } from "./prompts.js";
|
|
16
|
+
export { Prompts } from "./prompts.js";
|
|
17
|
+
export { PromptsAdmin } from "./prompts-admin.js";
|
|
18
|
+
export type { SaveToolInput, ToolEffect, ToolRow } from "./tools.js";
|
|
19
|
+
export { Tools } from "./tools.js";
|
|
20
|
+
export type { ModelRow, RangeQuery, Summary, Timeseries, TimeseriesPoint, Totals, } from "./stats.js";
|
|
21
|
+
export { Stats } from "./stats.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { Alerts } from "./alerts.js";
|
|
2
|
+
export { Credentials } from "./credentials.js";
|
|
3
|
+
export { Events } from "./events.js";
|
|
4
|
+
export { Ingest, MAX_EVENTS } from "./ingest.js";
|
|
5
|
+
export { Keys } from "./keys.js";
|
|
6
|
+
export { Playground } from "./playground.js";
|
|
7
|
+
export { Models, Pricing } from "./pricing.js";
|
|
8
|
+
export { Invites, Projects } from "./projects.js";
|
|
9
|
+
export { Prompts } from "./prompts.js";
|
|
10
|
+
export { PromptsAdmin } from "./prompts-admin.js";
|
|
11
|
+
export { Tools } from "./tools.js";
|
|
12
|
+
export { Stats } from "./stats.js";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Client } from "../core/client/index.js";
|
|
2
|
+
import type { Tag } from "../core/types.js";
|
|
3
|
+
/** One call's consumption. The server prices it from the model id. */
|
|
4
|
+
export interface UsageEvent {
|
|
5
|
+
eventId?: string;
|
|
6
|
+
model: string;
|
|
7
|
+
inputTokens: number;
|
|
8
|
+
outputTokens: number;
|
|
9
|
+
cacheReadTokens?: number;
|
|
10
|
+
cacheWriteTokens?: number;
|
|
11
|
+
/** Billed on their own by search-grounded providers, not folded into output. */
|
|
12
|
+
citationTokens?: number;
|
|
13
|
+
reasoningTokens?: number;
|
|
14
|
+
timestamp?: string;
|
|
15
|
+
metadata?: Record<string, Tag>;
|
|
16
|
+
}
|
|
17
|
+
export interface IngestResult {
|
|
18
|
+
accepted: number;
|
|
19
|
+
rejected: number;
|
|
20
|
+
/** Ids nothing could price. Stored at $0 and counted, never rejected. */
|
|
21
|
+
unpricedModels: string[];
|
|
22
|
+
}
|
|
23
|
+
/** The route takes up to this many events per request. */
|
|
24
|
+
export declare const MAX_EVENTS = 100;
|
|
25
|
+
/**
|
|
26
|
+
* Anything that can deliver usage — this sender, a queue, a test double.
|
|
27
|
+
*
|
|
28
|
+
* `@spendgraph/llms` accepts this shape, so an `Ingest` from here can be handed
|
|
29
|
+
* straight to it without either package importing the other's transport.
|
|
30
|
+
*/
|
|
31
|
+
export interface Sender {
|
|
32
|
+
send(events: UsageEvent[]): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/** Posts usage straight through, chunked to what the route accepts. */
|
|
35
|
+
export declare class Ingest implements Sender {
|
|
36
|
+
private readonly client;
|
|
37
|
+
private readonly project?;
|
|
38
|
+
constructor(client: Client, project?: string | undefined);
|
|
39
|
+
send(events: UsageEvent[]): Promise<void>;
|
|
40
|
+
/** The same write, with the server's answer — which ids went unpriced. */
|
|
41
|
+
report(events: UsageEvent[]): Promise<IngestResult>;
|
|
42
|
+
}
|