margin-meter 0.1.0 → 0.3.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 +353 -8
- package/dist/batching.d.ts +94 -0
- package/dist/batching.d.ts.map +1 -0
- package/dist/batching.js +181 -0
- package/dist/batching.js.map +1 -0
- package/dist/braintrust.d.ts +153 -0
- package/dist/braintrust.d.ts.map +1 -0
- package/dist/braintrust.js +424 -0
- package/dist/braintrust.js.map +1 -0
- package/dist/index.d.ts +428 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +927 -9
- package/dist/index.js.map +1 -1
- package/dist/instrument.d.ts +74 -0
- package/dist/instrument.d.ts.map +1 -0
- package/dist/instrument.js +153 -0
- package/dist/instrument.js.map +1 -0
- package/dist/langsmith.d.ts +150 -0
- package/dist/langsmith.d.ts.map +1 -0
- package/dist/langsmith.js +415 -0
- package/dist/langsmith.js.map +1 -0
- package/dist/promptfoo.d.ts +161 -0
- package/dist/promptfoo.d.ts.map +1 -0
- package/dist/promptfoo.js +446 -0
- package/dist/promptfoo.js.map +1 -0
- package/dist/providers.d.ts +190 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +611 -0
- package/dist/providers.js.map +1 -0
- package/dist/substrate.d.ts +51 -0
- package/dist/substrate.d.ts.map +1 -0
- package/dist/substrate.js +132 -0
- package/dist/substrate.js.map +1 -0
- package/dist/vitest.d.ts +232 -0
- package/dist/vitest.d.ts.map +1 -0
- package/dist/vitest.js +502 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +25 -7
package/dist/index.js
CHANGED
|
@@ -21,11 +21,158 @@
|
|
|
21
21
|
* written `source` is forced server-side to the key's project slug — the
|
|
22
22
|
* client cannot spoof another project's economics.
|
|
23
23
|
*/
|
|
24
|
+
import { extractUsage, StreamUsageAccumulator, } from "./providers.js";
|
|
25
|
+
import { BATCH_PATH, Batcher, SHUTDOWN_TIMEOUT_MS, } from "./batching.js";
|
|
26
|
+
// The Node analogue of Python's `contextvars`: it carries a value across the
|
|
27
|
+
// async/await chain of the callback it runs, so a call metered several awaits
|
|
28
|
+
// deep inside `stage(...)` still sees the enclosing step. `node:async_hooks` is
|
|
29
|
+
// a Node builtin (stable since 16; this package requires node>=18) and is
|
|
30
|
+
// present in Bun and the edge runtimes too. It is the ONE thing `stage`/`span`
|
|
31
|
+
// need that a browser bundle would not have — and those primitives are the whole
|
|
32
|
+
// reason to reach for it — so a caller instrumenting server-side agent code (the
|
|
33
|
+
// only place an LLM key belongs) always has it.
|
|
34
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
35
|
+
export * from "./providers.js";
|
|
36
|
+
export * from "./instrument.js";
|
|
37
|
+
export * from "./batching.js";
|
|
38
|
+
// Namespaced, not flattened: `BEDROCK` means two different things here — the
|
|
39
|
+
// provider that bills for the model and the path the request took — and merging
|
|
40
|
+
// them into one barrel would collide the names and blur the distinction the
|
|
41
|
+
// substrate dimension exists to draw. `substrate.BEDROCK` reads as the path.
|
|
42
|
+
export * as substrate from "./substrate.js";
|
|
43
|
+
// Namespaced: an eval adapter reuses common verb names (`recordResults`,
|
|
44
|
+
// `outcomeInput`), so `promptfoo.recordResults(...)` keeps the framework it
|
|
45
|
+
// bridges legible at the call site — the Python SDK's `margin_meter.promptfoo`
|
|
46
|
+
// twin (`docs/INTEGRATION_LADDER.md` rung 2).
|
|
47
|
+
export * as promptfoo from "./promptfoo.js";
|
|
48
|
+
// The second eval adapter (rung 2), same namespaced shape — `braintrust.recordResults(...)`.
|
|
49
|
+
export * as braintrust from "./braintrust.js";
|
|
50
|
+
// The third eval adapter (rung 2), same namespaced shape — `langsmith.recordResults(...)`.
|
|
51
|
+
export * as langsmith from "./langsmith.js";
|
|
52
|
+
// The test-runner adapter (rung 2/6): one vitest/jest JSON file, every test an
|
|
53
|
+
// outcome — `vitest.recordResults(...)`. The pytest plugin's TS twin.
|
|
54
|
+
export * as vitest from "./vitest.js";
|
|
24
55
|
export const CALLS_PATH = "/api/ingest/calls";
|
|
25
56
|
export const OUTCOMES_PATH = "/api/ingest/outcomes";
|
|
26
57
|
export const KEY_HEADER = "X-Margin-Key";
|
|
27
|
-
|
|
58
|
+
// ⛔ No default ingest URL, on purpose. It used to be `http://127.0.0.1:8000`, so a
|
|
59
|
+
// meter given a key and no URL POSTed to the caller's own laptop and dropped every row
|
|
60
|
+
// (a real authorized run on 2026-09-15 spent money and landed nothing that way). An
|
|
61
|
+
// unset URL is refused as `no_url` at once, like `no_key`, never guessed.
|
|
28
62
|
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
63
|
+
/** One batched request's byte ceiling, and the retry policy behind it. Full
|
|
64
|
+
* reasoning in `docs/reference/SDK_BATCHING.md`; the short version:
|
|
65
|
+
*
|
|
66
|
+
* - 1 MB is ~7x headroom over a worst-case 1500-byte row at the default 100
|
|
67
|
+
* records, and far under any gateway limit. Vercel accepts 100 MB bodies;
|
|
68
|
+
* that is a ceiling, not a target.
|
|
69
|
+
* - 3 attempts with FULL jitter rather than plain exponential, because every
|
|
70
|
+
* buffered client backs off on the same schedule and un-jittered retries
|
|
71
|
+
* re-synchronise into a herd aimed at the endpoint that just failed.
|
|
72
|
+
* - Duplicates from an at-least-once retry are free: every record carries an
|
|
73
|
+
* `event_id` and the server is idempotent on `(source, event_id)`.
|
|
74
|
+
*/
|
|
75
|
+
export const MAX_BATCH_BYTES = 1_000_000;
|
|
76
|
+
export const RETRY_ATTEMPTS = 3;
|
|
77
|
+
export const RETRY_BASE_MS = 1_000;
|
|
78
|
+
export const RETRY_CAP_MS = 30_000;
|
|
79
|
+
/** Statuses worth resending. Everything else (401, 422, 400) fails identically
|
|
80
|
+
* next time, so retrying is a hot loop that never drains — and against a bad key
|
|
81
|
+
* it would hammer us forever. */
|
|
82
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
83
|
+
/** A fresh idempotency key per emit. Uses the platform crypto UUID (Node 16.7+,
|
|
84
|
+
* modern browsers); falls back to a random-hex id where crypto is absent, which
|
|
85
|
+
* is still unique enough to key a single call's dedup. */
|
|
86
|
+
function newEventId() {
|
|
87
|
+
const c = globalThis.crypto;
|
|
88
|
+
if (c?.randomUUID)
|
|
89
|
+
return c.randomUUID();
|
|
90
|
+
return (Date.now().toString(16) +
|
|
91
|
+
Math.random().toString(16).slice(2) +
|
|
92
|
+
Math.random().toString(16).slice(2));
|
|
93
|
+
}
|
|
94
|
+
// --------------------------------------------------------------------------- //
|
|
95
|
+
// The chain — `stage()` and `span()` (MAR-899, the TS twin of the Python SDK).
|
|
96
|
+
// --------------------------------------------------------------------------- //
|
|
97
|
+
// The wire fields (`spanId` / `parentSpanId` / `taskClass`) have always been on
|
|
98
|
+
// `RecordCallInput`, but a TS user had to thread the ids through their own code by
|
|
99
|
+
// hand — so `margin_stage_plan`, the per-step model/effort advice, worked out of
|
|
100
|
+
// the box for a Python customer and needed manual plumbing for a TS one. That is a
|
|
101
|
+
// real asymmetry on the integration path we describe to design partners as two
|
|
102
|
+
// plugs. These two blocks close it: wrap a step and every call it makes, however
|
|
103
|
+
// many awaits deep, records under one node with the right label — no ids by hand.
|
|
104
|
+
//
|
|
105
|
+
// Honesty, identical to the Python side: `stage`/`span` RE-LABEL and LINK only.
|
|
106
|
+
// Tokens, cost and the outcome are never touched, so a call inside one prices
|
|
107
|
+
// byte-for-byte the same as one before this existed.
|
|
108
|
+
/** A 32-hex span id, the shape of Python's `uuid4().hex` (no dashes). Uses the
|
|
109
|
+
* platform crypto UUID where present, a random-hex id otherwise — either is
|
|
110
|
+
* unique enough to key one run's tree. */
|
|
111
|
+
function newSpanId() {
|
|
112
|
+
const c = globalThis.crypto;
|
|
113
|
+
if (c?.randomUUID)
|
|
114
|
+
return c.randomUUID().replace(/-/g, "");
|
|
115
|
+
return (Math.random().toString(16).slice(2) +
|
|
116
|
+
Math.random().toString(16).slice(2) +
|
|
117
|
+
Date.now().toString(16))
|
|
118
|
+
.replace(/[^0-9a-f]/g, "")
|
|
119
|
+
.slice(0, 32)
|
|
120
|
+
.padEnd(32, "0");
|
|
121
|
+
}
|
|
122
|
+
const _spanStore = new AsyncLocalStorage();
|
|
123
|
+
const _stageStore = new AsyncLocalStorage();
|
|
124
|
+
export function stage(operation, optsOrFn, maybeFn) {
|
|
125
|
+
const opts = typeof optsOrFn === "function" ? {} : optsOrFn;
|
|
126
|
+
const fn = (typeof optsOrFn === "function" ? optsOrFn : maybeFn);
|
|
127
|
+
const parent = _spanStore.getStore();
|
|
128
|
+
const ctx = {
|
|
129
|
+
spanId: newSpanId(),
|
|
130
|
+
parentSpanId: opts.parentSpanId ?? parent?.spanId,
|
|
131
|
+
};
|
|
132
|
+
const override = {
|
|
133
|
+
operation,
|
|
134
|
+
sessionId: opts.sessionId,
|
|
135
|
+
taskClass: opts.taskClass,
|
|
136
|
+
};
|
|
137
|
+
return _stageStore.run(override, () => _spanStore.run(ctx, () => fn(ctx.spanId)));
|
|
138
|
+
}
|
|
139
|
+
/** The active `(operation, sessionId, taskClass)` step override, or `undefined`
|
|
140
|
+
* outside any `stage`. */
|
|
141
|
+
export function currentStage() {
|
|
142
|
+
return _stageStore.getStore();
|
|
143
|
+
}
|
|
144
|
+
/** Mark every metered call inside `fn` as one node of an agent tree — the tree the
|
|
145
|
+
* Money Map decomposes to ask what a BRANCH cost, which a flat list of calls
|
|
146
|
+
* sharing a `sessionId` cannot answer. Nesting is what builds the tree; returns
|
|
147
|
+
* whatever `fn` returns.
|
|
148
|
+
*
|
|
149
|
+
* ```ts
|
|
150
|
+
* await span({ pattern: "panel", branchWidth: 3, stoppingRule: "majority of 3" }, () =>
|
|
151
|
+
* Promise.all(candidates.map((c) => judge(c))), // each judge nests its own node
|
|
152
|
+
* );
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* `stage()` is the primitive to reach for on a plain pipeline step; `span()` adds
|
|
156
|
+
* the orchestration shape when a block fans out. This is the TS twin of
|
|
157
|
+
* `margin_meter.span`. */
|
|
158
|
+
export function span(opts, fn) {
|
|
159
|
+
const parent = _spanStore.getStore();
|
|
160
|
+
const ctx = {
|
|
161
|
+
spanId: opts.spanId ?? newSpanId(),
|
|
162
|
+
parentSpanId: opts.parentSpanId ?? parent?.spanId,
|
|
163
|
+
pattern: opts.pattern,
|
|
164
|
+
branchWidth: opts.branchWidth,
|
|
165
|
+
stoppingRule: opts.stoppingRule,
|
|
166
|
+
};
|
|
167
|
+
return _spanStore.run(ctx, () => fn(ctx.spanId));
|
|
168
|
+
}
|
|
169
|
+
/** The active `(spanId, parentSpanId)`, or `undefined` outside any span/stage. */
|
|
170
|
+
export function currentSpan() {
|
|
171
|
+
const ctx = _spanStore.getStore();
|
|
172
|
+
return ctx === undefined
|
|
173
|
+
? undefined
|
|
174
|
+
: { spanId: ctx.spanId, parentSpanId: ctx.parentSpanId };
|
|
175
|
+
}
|
|
29
176
|
/** Raised (only in strict mode) when a row is rejected by the ingest API. */
|
|
30
177
|
export class MarginIngestError extends Error {
|
|
31
178
|
statusCode;
|
|
@@ -67,47 +214,406 @@ export class MarginMeter {
|
|
|
67
214
|
apiKey;
|
|
68
215
|
raiseOnError;
|
|
69
216
|
transport;
|
|
217
|
+
attempted = 0;
|
|
218
|
+
delivered = 0;
|
|
219
|
+
droppedByReason = {};
|
|
220
|
+
attemptedByPath = {};
|
|
221
|
+
droppedByPath = {};
|
|
222
|
+
warnedOnDrop = false;
|
|
223
|
+
noUrl;
|
|
224
|
+
batcher;
|
|
225
|
+
exitFlush;
|
|
226
|
+
exitFlushArmed = false;
|
|
70
227
|
constructor(options = {}) {
|
|
71
228
|
const env = (typeof process !== "undefined" && process.env) || {};
|
|
72
|
-
this.ingestUrl = options.ingestUrl || env.MARGIN_INGEST_URL ||
|
|
229
|
+
this.ingestUrl = options.ingestUrl || env.MARGIN_INGEST_URL || "";
|
|
230
|
+
// An injected transport already knows where it posts; only our own fetch needs a URL.
|
|
231
|
+
this.noUrl = !this.ingestUrl && !options.transport;
|
|
73
232
|
this.apiKey = options.apiKey || env.MARGIN_INGEST_KEY;
|
|
74
233
|
this.raiseOnError = options.raiseOnError ?? false;
|
|
75
234
|
this.transport =
|
|
76
235
|
options.transport ||
|
|
77
236
|
new FetchTransport(this.ingestUrl, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
237
|
+
this.batcher = new Batcher((batch) => this.sendBatch(batch));
|
|
238
|
+
// ⭐ The shutdown flush. `beforeExit`, deliberately NOT a signal handler:
|
|
239
|
+
// installing SIGTERM/SIGINT from a library steals signal handling from a host
|
|
240
|
+
// that usually has its own graceful shutdown. It fires when the event loop
|
|
241
|
+
// empties — which the batcher's `unref`'d timer is careful not to prevent —
|
|
242
|
+
// and it is async-capable, unlike `exit`.
|
|
243
|
+
//
|
|
244
|
+
// ⛔ FLUSH, not close, and the difference is not cosmetic. `beforeExit` fires
|
|
245
|
+
// whenever the loop drains, which in a long-lived host happens between bursts
|
|
246
|
+
// of work rather than only at the end — closing there would wedge the meter
|
|
247
|
+
// for the rest of the process's life, and every later call would be dropped.
|
|
248
|
+
// Caught exactly that way: a test run went quiet for a tick and two subsequent
|
|
249
|
+
// rows were refused by a batcher that had "shut down" mid-suite. Flushing is
|
|
250
|
+
// idempotent and safe to fire any number of times; closing is an explicit,
|
|
251
|
+
// one-way act the host asks for with `await meter.close()`.
|
|
252
|
+
//
|
|
253
|
+
// ⚠️ A hard kill loses the buffer, bounded by the flush interval. Said plainly
|
|
254
|
+
// rather than glossed: no in-memory buffer survives SIGKILL, and pretending
|
|
255
|
+
// otherwise would be a fabricated guarantee.
|
|
256
|
+
//
|
|
257
|
+
// ⭐ BOUNDED, and registered LAZILY on the first buffered row (see `enqueue`).
|
|
258
|
+
// Registering in the constructor put one listener per meter on `process` for
|
|
259
|
+
// the process's lifetime, each pinning its meter and up to `maxQueue` records
|
|
260
|
+
// — Node warns at eleven, and the meter that never emits pays for a handler it
|
|
261
|
+
// will never need. `close()` removes it again.
|
|
262
|
+
this.exitFlush = () => {
|
|
263
|
+
void Promise.race([this.batcher.flush(), sleep(SHUTDOWN_TIMEOUT_MS)]);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/** Attach the process-exit flush, once, on the first row that will need it. */
|
|
267
|
+
armExitFlush() {
|
|
268
|
+
if (this.exitFlushArmed)
|
|
269
|
+
return;
|
|
270
|
+
if (typeof process === "undefined" || typeof process.on !== "function")
|
|
271
|
+
return;
|
|
272
|
+
this.exitFlushArmed = true;
|
|
273
|
+
process.on("beforeExit", this.exitFlush);
|
|
274
|
+
}
|
|
275
|
+
/** Record one emit attempt. Called at every exit point that knows its own
|
|
276
|
+
* outcome, BEFORE any `raiseOnError` throw, so a strict-mode failure is
|
|
277
|
+
* counted exactly like the fail-safe one it would otherwise have been. */
|
|
278
|
+
count(path, ok, reason) {
|
|
279
|
+
this.attempted += 1;
|
|
280
|
+
this.attemptedByPath[path] = (this.attemptedByPath[path] ?? 0) + 1;
|
|
281
|
+
if (ok) {
|
|
282
|
+
this.delivered += 1;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const bucket = reason ?? "unknown";
|
|
286
|
+
this.droppedByReason[bucket] = (this.droppedByReason[bucket] ?? 0) + 1;
|
|
287
|
+
this.droppedByPath[path] = (this.droppedByPath[path] ?? 0) + 1;
|
|
288
|
+
if (!this.warnedOnDrop) {
|
|
289
|
+
this.warnedOnDrop = true;
|
|
290
|
+
// Once per meter, on the FIRST drop only, so a silent drop stops being
|
|
291
|
+
// silent without letting a flapping network flood the host's own logs.
|
|
292
|
+
// The RATE lives in deliveryStats(); this line exists to send someone there.
|
|
293
|
+
console.warn(`margin-meter: an emit was dropped (${bucket}) — this row is NOT in the ` +
|
|
294
|
+
`ledger. Metering is fail-safe, so the host call was unaffected. ` +
|
|
295
|
+
`Call meter.deliveryStats() for the running count.`);
|
|
296
|
+
}
|
|
78
297
|
}
|
|
298
|
+
/** A snapshot of what this meter delivered against what it tried to.
|
|
299
|
+
* See {@link DeliveryStats}. */
|
|
300
|
+
deliveryStats() {
|
|
301
|
+
const dropped = this.attempted - this.delivered;
|
|
302
|
+
return {
|
|
303
|
+
attempted: this.attempted,
|
|
304
|
+
delivered: this.delivered,
|
|
305
|
+
dropped,
|
|
306
|
+
dropRate: this.attempted ? dropped / this.attempted : 0,
|
|
307
|
+
droppedByReason: { ...this.droppedByReason },
|
|
308
|
+
attemptedByPath: { ...this.attemptedByPath },
|
|
309
|
+
droppedByPath: { ...this.droppedByPath },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Route one built payload to the wire or the buffer.
|
|
314
|
+
*
|
|
315
|
+
* Buffering is the default and the whole point (`docs/reference/SDK_BATCHING.md`).
|
|
316
|
+
* Two cases deliberately stay immediate, and neither is a knob:
|
|
317
|
+
*
|
|
318
|
+
* - **Strict mode** (`raiseOnError`) means "throw at MY call site when this row
|
|
319
|
+
* is rejected", and a buffered emit has no call site left to throw at by the
|
|
320
|
+
* time the POST happens. The two are incompatible, so strict posts now.
|
|
321
|
+
* - **No key**, because buffering rows that can never be sent would fill the
|
|
322
|
+
* queue and then report `queue_full` — a diagnosis that sends the reader
|
|
323
|
+
* hunting for a network problem when the answer is a missing variable.
|
|
324
|
+
* A missing URL is the same case and is named `no_url`.
|
|
325
|
+
*/
|
|
79
326
|
async emit(path, payload) {
|
|
80
327
|
if (!this.apiKey) {
|
|
81
328
|
const error = "no ingest key (set MARGIN_INGEST_KEY or pass apiKey)";
|
|
329
|
+
this.count(path, false, "no_key");
|
|
330
|
+
if (this.raiseOnError)
|
|
331
|
+
throw new MarginConfigError(error);
|
|
332
|
+
return { ok: false, statusCode: 0, error, reason: "no_key" };
|
|
333
|
+
}
|
|
334
|
+
if (this.noUrl) {
|
|
335
|
+
const error = "no ingest URL (set MARGIN_INGEST_URL=https://trymargin.io or pass ingestUrl)";
|
|
336
|
+
this.count(path, false, "no_url");
|
|
82
337
|
if (this.raiseOnError)
|
|
83
338
|
throw new MarginConfigError(error);
|
|
84
|
-
return { ok: false, statusCode: 0, error };
|
|
339
|
+
return { ok: false, statusCode: 0, error, reason: "no_url" };
|
|
340
|
+
}
|
|
341
|
+
if (!this.raiseOnError)
|
|
342
|
+
return this.enqueue(path, payload);
|
|
343
|
+
return await this.postNow(path, payload);
|
|
344
|
+
}
|
|
345
|
+
/** Hand one record to the buffer. Never blocks, never throws.
|
|
346
|
+
*
|
|
347
|
+
* `ok: true` here means ACCEPTED INTO THE BUFFER, not written to the ledger —
|
|
348
|
+
* the body says `queued` so nothing mistakes the two. What actually landed is
|
|
349
|
+
* `deliveryStats()`, which exists for exactly this question. */
|
|
350
|
+
enqueue(path, payload) {
|
|
351
|
+
if (this.batcher.submit({ path, payload })) {
|
|
352
|
+
this.armExitFlush();
|
|
353
|
+
return { ok: true, statusCode: 0, body: { queued: true } };
|
|
354
|
+
}
|
|
355
|
+
// Refused. Either our endpoint is unreachable and the buffer filled, or the
|
|
356
|
+
// meter was explicitly closed and is still being written to — opposite fixes,
|
|
357
|
+
// so they are counted apart rather than under one bucket. Dropping is correct
|
|
358
|
+
// in both cases (an unbounded buffer would OOM the customer's agent) but it is
|
|
359
|
+
// COUNTED, because a meter that silently stops metering is the one failure
|
|
360
|
+
// this product cannot have.
|
|
361
|
+
const reason = this.batcher.closed ? "meter_closed" : "queue_full";
|
|
362
|
+
const error = this.batcher.closed
|
|
363
|
+
? "meter is closed — this row is NOT in the ledger"
|
|
364
|
+
: `meter buffer full (${this.batcher.maxQueue} records) — this row is NOT in the ledger`;
|
|
365
|
+
this.count(path, false, reason);
|
|
366
|
+
return { ok: false, statusCode: 0, error, reason };
|
|
367
|
+
}
|
|
368
|
+
/** Resolve once every buffered row has been delivered or counted as dropped.
|
|
369
|
+
*
|
|
370
|
+
* Call it before reading `deliveryStats()` if you need the counts to cover rows
|
|
371
|
+
* you have only just emitted — buffering separates an emit from its delivery
|
|
372
|
+
* in time by design. */
|
|
373
|
+
async flush() {
|
|
374
|
+
await this.batcher.flush();
|
|
375
|
+
}
|
|
376
|
+
/** Drain the buffer and stop the timer. Registered on `beforeExit`.
|
|
377
|
+
*
|
|
378
|
+
* Anything the timeout strands is COUNTED as dropped rather than abandoned: the
|
|
379
|
+
* records are handed back by `Batcher.close` precisely so the number is
|
|
380
|
+
* knowable. */
|
|
381
|
+
async close(timeoutMs = SHUTDOWN_TIMEOUT_MS) {
|
|
382
|
+
// Detached first so a meter the host closed explicitly does not also hold a
|
|
383
|
+
// process-exit listener (and a reference to itself) alive for the rest of the
|
|
384
|
+
// process.
|
|
385
|
+
if (this.exitFlushArmed && typeof process !== "undefined") {
|
|
386
|
+
process.off("beforeExit", this.exitFlush);
|
|
387
|
+
this.exitFlushArmed = false;
|
|
388
|
+
}
|
|
389
|
+
for (const record of await this.batcher.close(timeoutMs)) {
|
|
390
|
+
this.count(record.path, false, "shutdown_timeout");
|
|
85
391
|
}
|
|
392
|
+
}
|
|
393
|
+
async postNow(path, payload) {
|
|
86
394
|
let resp;
|
|
87
395
|
try {
|
|
88
396
|
resp = await this.transport.post(path, {
|
|
89
397
|
json: payload,
|
|
90
|
-
headers: { [KEY_HEADER]: this.apiKey },
|
|
398
|
+
headers: { [KEY_HEADER]: this.apiKey ?? "" },
|
|
91
399
|
});
|
|
92
400
|
}
|
|
93
401
|
catch (e) {
|
|
94
402
|
const error = `transport error: ${e.message}`;
|
|
403
|
+
this.count(path, false, "transport");
|
|
95
404
|
if (this.raiseOnError)
|
|
96
405
|
throw new MarginIngestError(0, e.message);
|
|
97
|
-
return { ok: false, statusCode: 0, error };
|
|
406
|
+
return { ok: false, statusCode: 0, error, reason: "transport" };
|
|
98
407
|
}
|
|
99
408
|
if (resp.status === 200) {
|
|
100
409
|
const body = (await resp.json());
|
|
410
|
+
this.count(path, true);
|
|
101
411
|
return { ok: true, statusCode: 200, body };
|
|
102
412
|
}
|
|
413
|
+
const reason = `http_${resp.status}`;
|
|
414
|
+
this.count(path, false, reason);
|
|
103
415
|
const detail = await extractDetail(resp);
|
|
104
416
|
if (this.raiseOnError)
|
|
105
417
|
throw new MarginIngestError(resp.status, detail);
|
|
106
|
-
return { ok: false, statusCode: resp.status, error: detail };
|
|
418
|
+
return { ok: false, statusCode: resp.status, error: detail, reason };
|
|
419
|
+
}
|
|
420
|
+
// --------------------------------------------------------------------- //
|
|
421
|
+
// Delivery — what happens off the caller's path
|
|
422
|
+
// --------------------------------------------------------------------- //
|
|
423
|
+
/** POST one buffered batch. Never rejects: it owns its own accounting.
|
|
424
|
+
*
|
|
425
|
+
* ⛔ The blanket `catch` is load-bearing, not defensive habit. `Batcher.flush`
|
|
426
|
+
* swallows a rejection to keep the drain going, so anything escaping here would
|
|
427
|
+
* vanish with the counters reading a clean sheet — an uncounted loss, which
|
|
428
|
+
* makes measured spend read CHEAPER than the bill. Scoped per chunk so records
|
|
429
|
+
* already accounted for in an earlier chunk are not counted twice. */
|
|
430
|
+
async sendBatch(records) {
|
|
431
|
+
for (const chunk of this.chunkByBytes(records)) {
|
|
432
|
+
try {
|
|
433
|
+
await this.deliverChunk(chunk);
|
|
434
|
+
}
|
|
435
|
+
catch (e) {
|
|
436
|
+
console.warn(`margin-meter: batch send failed unexpectedly: ${e.message}`);
|
|
437
|
+
for (const record of chunk)
|
|
438
|
+
this.count(record.path, false, "send_error");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/** Split a batch so no request exceeds {@link MAX_BATCH_BYTES}.
|
|
443
|
+
*
|
|
444
|
+
* ⛔ A single record over the cap on its own is DROPPED and counted as
|
|
445
|
+
* `oversize`. No split can send it, and keeping it would wedge the queue behind
|
|
446
|
+
* one poisoned row forever. It is a bug in the caller — every open-vocabulary
|
|
447
|
+
* field is already bounded server-side, so an honest row cannot come near a
|
|
448
|
+
* megabyte.
|
|
449
|
+
*
|
|
450
|
+
* ⛔ A payload that will not serialise is dropped and counted too, and that case
|
|
451
|
+
* is not exotic — a `BigInt` anywhere in the payload makes `JSON.stringify`
|
|
452
|
+
* throw. Unguarded, the throw escaped into the flush and the row vanished with
|
|
453
|
+
* the counters reading a clean sheet. */
|
|
454
|
+
chunkByBytes(records) {
|
|
455
|
+
const chunks = [];
|
|
456
|
+
let current = [];
|
|
457
|
+
let size = 0;
|
|
458
|
+
for (const record of records) {
|
|
459
|
+
let weight;
|
|
460
|
+
try {
|
|
461
|
+
weight = JSON.stringify(record.payload).length + 2;
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
this.count(record.path, false, "unserializable");
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (weight > MAX_BATCH_BYTES) {
|
|
468
|
+
this.count(record.path, false, "oversize");
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (current.length > 0 && size + weight > MAX_BATCH_BYTES) {
|
|
472
|
+
chunks.push(current);
|
|
473
|
+
current = [];
|
|
474
|
+
size = 0;
|
|
475
|
+
}
|
|
476
|
+
current.push(record);
|
|
477
|
+
size += weight;
|
|
478
|
+
}
|
|
479
|
+
if (current.length > 0)
|
|
480
|
+
chunks.push(current);
|
|
481
|
+
return chunks;
|
|
482
|
+
}
|
|
483
|
+
async deliverChunk(records) {
|
|
484
|
+
const calls = records.filter((r) => r.path === CALLS_PATH);
|
|
485
|
+
const outcomes = records.filter((r) => r.path === OUTCOMES_PATH);
|
|
486
|
+
const body = {};
|
|
487
|
+
if (calls.length > 0)
|
|
488
|
+
body.calls = calls.map((r) => r.payload);
|
|
489
|
+
if (outcomes.length > 0)
|
|
490
|
+
body.outcomes = outcomes.map((r) => r.payload);
|
|
491
|
+
if (calls.length === 0 && outcomes.length === 0)
|
|
492
|
+
return;
|
|
493
|
+
const { parsed, reason } = await this.postBatchWithRetry(body);
|
|
494
|
+
if (!parsed) {
|
|
495
|
+
// The request never succeeded. Every record in it is a loss, counted under
|
|
496
|
+
// the reason that caused it — and NOT re-queued: an unbounded retry queue is
|
|
497
|
+
// the OOM this design bounds, arriving by another door.
|
|
498
|
+
for (const record of records)
|
|
499
|
+
this.count(record.path, false, reason);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
for (const [key, group] of [
|
|
503
|
+
["calls", calls],
|
|
504
|
+
["outcomes", outcomes],
|
|
505
|
+
]) {
|
|
506
|
+
const raw = parsed[key];
|
|
507
|
+
const results = Array.isArray(raw) ? raw : [];
|
|
508
|
+
group.forEach((record, index) => {
|
|
509
|
+
const outcome = results[index];
|
|
510
|
+
// ⚠️ Positional alignment is the contract, so a short or missing results
|
|
511
|
+
// array means we cannot say what happened to this row. Counted as a drop
|
|
512
|
+
// under its own reason rather than assumed delivered — assuming success is
|
|
513
|
+
// how an invisible loss starts.
|
|
514
|
+
if (!outcome || typeof outcome !== "object") {
|
|
515
|
+
this.count(record.path, false, "missing_result");
|
|
516
|
+
}
|
|
517
|
+
else if (outcome.ok) {
|
|
518
|
+
this.count(record.path, true);
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
// ⭐ Split by the server's own `retryable` flag. A validation failure is
|
|
522
|
+
// the caller's bug (fix the payload) and a rate-cap refusal is our
|
|
523
|
+
// ceiling (the project is over its daily rows) — opposite fixes, and a
|
|
524
|
+
// reader who cannot tell them apart looks in the wrong place.
|
|
525
|
+
//
|
|
526
|
+
// ⛔ Neither is re-queued, including the retryable one: the cap is a
|
|
527
|
+
// 24-HOUR rolling window, so the server's honest `retry_after_seconds`
|
|
528
|
+
// can be hours. Holding the queue that long would stall every later row
|
|
529
|
+
// behind a wait the buffer cannot survive. The drop is made VISIBLE
|
|
530
|
+
// instead — `rate_limited` in deliveryStats() tells a customer their cap
|
|
531
|
+
// is the reason, and nothing else could.
|
|
532
|
+
this.count(record.path, false, outcome.retryable ? "rate_limited" : "rejected");
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/** POST one batch, retrying the failures that clear with time. */
|
|
538
|
+
async postBatchWithRetry(body) {
|
|
539
|
+
let reason = "transport";
|
|
540
|
+
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt += 1) {
|
|
541
|
+
let retryAfterMs;
|
|
542
|
+
try {
|
|
543
|
+
const resp = await this.transport.post(BATCH_PATH, {
|
|
544
|
+
json: body,
|
|
545
|
+
headers: { [KEY_HEADER]: this.apiKey ?? "" },
|
|
546
|
+
});
|
|
547
|
+
if (resp.status === 200) {
|
|
548
|
+
try {
|
|
549
|
+
return { parsed: (await resp.json()), reason: "" };
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// A 200 whose body we cannot read tells us nothing about which rows
|
|
553
|
+
// landed. Retrying is safe (event_id dedup) and beats guessing.
|
|
554
|
+
reason = "unparseable_response";
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
else if (RETRYABLE_STATUS.has(resp.status)) {
|
|
558
|
+
reason = `http_${resp.status}`;
|
|
559
|
+
retryAfterMs = retryAfterMs_(resp);
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
// 401/422/400: identical next time. Stop rather than loop.
|
|
563
|
+
return { reason: `http_${resp.status}` };
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
reason = "transport";
|
|
568
|
+
}
|
|
569
|
+
if (attempt + 1 >= RETRY_ATTEMPTS)
|
|
570
|
+
break;
|
|
571
|
+
await sleep(backoffMs(attempt, retryAfterMs));
|
|
572
|
+
}
|
|
573
|
+
return { reason };
|
|
107
574
|
}
|
|
108
575
|
/** Emit one measured LLM call. Cost is computed server-side when `costUsd`
|
|
109
576
|
* is omitted. Never throws unless `raiseOnError`. */
|
|
110
577
|
async recordCall(input) {
|
|
578
|
+
// Resolve the ambient step/tree BEFORE the payload, with the Python SDK's
|
|
579
|
+
// exact precedence — the two rules differ on purpose:
|
|
580
|
+
// • a `stage`'s operation/sessionId OVERRIDE what the call passed (that is
|
|
581
|
+
// how one run decomposes into named per-stage operations), while
|
|
582
|
+
// • its taskClass and every span field FILL only what the call left unset
|
|
583
|
+
// (an explicit `parentSpanId` is usually a branch reattached across a
|
|
584
|
+
// worker boundary — the ambient span must not overwrite it).
|
|
585
|
+
// Labels and links only: no cost/token/outcome field is touched here.
|
|
586
|
+
const stageOv = _stageStore.getStore();
|
|
587
|
+
const spanCtx = _spanStore.getStore();
|
|
588
|
+
let operation = input.operation;
|
|
589
|
+
let sessionId = input.sessionId;
|
|
590
|
+
let taskClass = input.taskClass;
|
|
591
|
+
if (stageOv !== undefined) {
|
|
592
|
+
if (stageOv.operation !== undefined)
|
|
593
|
+
operation = stageOv.operation;
|
|
594
|
+
if (stageOv.sessionId !== undefined)
|
|
595
|
+
sessionId = stageOv.sessionId;
|
|
596
|
+
if (stageOv.taskClass !== undefined && taskClass === undefined) {
|
|
597
|
+
taskClass = stageOv.taskClass;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
let spanId = input.spanId;
|
|
601
|
+
let parentSpanId = input.parentSpanId;
|
|
602
|
+
let orchestrationPattern = input.orchestrationPattern;
|
|
603
|
+
let branchWidth = input.branchWidth;
|
|
604
|
+
let stoppingRule = input.stoppingRule;
|
|
605
|
+
if (spanCtx !== undefined) {
|
|
606
|
+
if (spanId === undefined)
|
|
607
|
+
spanId = spanCtx.spanId;
|
|
608
|
+
if (parentSpanId === undefined)
|
|
609
|
+
parentSpanId = spanCtx.parentSpanId;
|
|
610
|
+
if (orchestrationPattern === undefined)
|
|
611
|
+
orchestrationPattern = spanCtx.pattern;
|
|
612
|
+
if (branchWidth === undefined)
|
|
613
|
+
branchWidth = spanCtx.branchWidth;
|
|
614
|
+
if (stoppingRule === undefined)
|
|
615
|
+
stoppingRule = spanCtx.stoppingRule;
|
|
616
|
+
}
|
|
111
617
|
const payload = {
|
|
112
618
|
workflow_id: input.workflowId,
|
|
113
619
|
provider: input.provider,
|
|
@@ -115,17 +621,84 @@ export class MarginMeter {
|
|
|
115
621
|
input_tokens: input.inputTokens ?? 0,
|
|
116
622
|
output_tokens: input.outputTokens ?? 0,
|
|
117
623
|
cache_read_tokens: input.cacheReadTokens ?? 0,
|
|
118
|
-
|
|
624
|
+
cache_creation_tokens: input.cacheCreationTokens ?? 0,
|
|
625
|
+
// ⛔ `?? null`, NOT `?? 0` (MAR-982). A caller who did not time the call must not
|
|
626
|
+
// have a zero invented for them — the server stores null as NULL, and nothing
|
|
627
|
+
// downstream can tell a substituted 0 from a real one.
|
|
628
|
+
latency_ms: input.latencyMs ?? null,
|
|
119
629
|
status: input.status ?? "ok",
|
|
120
630
|
is_retry: input.isRetry ?? false,
|
|
121
631
|
is_simulated: input.isSimulated ?? false,
|
|
632
|
+
event_id: input.eventId ?? newEventId(),
|
|
122
633
|
};
|
|
123
|
-
if (
|
|
124
|
-
payload.session_id =
|
|
634
|
+
if (sessionId !== undefined)
|
|
635
|
+
payload.session_id = sessionId;
|
|
125
636
|
if (input.promptId !== undefined)
|
|
126
637
|
payload.prompt_id = input.promptId;
|
|
638
|
+
if (operation !== undefined)
|
|
639
|
+
payload.operation = operation;
|
|
640
|
+
// ⛔ CLAMPED to the server's column widths, matching the Python SDK and the in-repo meter.
|
|
641
|
+
// `ingest._require_str` RAISES rather than truncating, so an over-long label is a 422 for the
|
|
642
|
+
// whole row — and this client defaults to `raiseOnError=false`, so a caller setting e.g.
|
|
643
|
+
// environment="production-us-east-1-blue-green" (37 chars) would silently drop 100% of that
|
|
644
|
+
// service's metered spend. Truncating mislabels a row; raising loses it.
|
|
645
|
+
if (input.environment !== undefined)
|
|
646
|
+
payload.environment = input.environment.slice(0, 32);
|
|
647
|
+
if (input.team !== undefined)
|
|
648
|
+
payload.team = input.team.slice(0, 64);
|
|
127
649
|
if (input.costUsd !== undefined)
|
|
128
650
|
payload.cost_usd = input.costUsd;
|
|
651
|
+
// Only send a non-default tier: a synchronous call omits it (the server
|
|
652
|
+
// defaults "standard"), keeping the wire minimal and an older server that
|
|
653
|
+
// predates the field unaffected.
|
|
654
|
+
if (input.billingTier !== undefined && input.billingTier !== "standard") {
|
|
655
|
+
payload.billing_tier = input.billingTier;
|
|
656
|
+
}
|
|
657
|
+
// Only send the 1-hour cache-write bucket when a call used one: the common
|
|
658
|
+
// single-TTL call omits it (the server reads 0 and prices its whole cache
|
|
659
|
+
// creation at the 5-minute rate, unchanged), keeping the wire minimal and an
|
|
660
|
+
// older server that predates the field unaffected.
|
|
661
|
+
if (input.cacheCreation1hTokens) {
|
|
662
|
+
payload.cache_creation_1h_tokens = input.cacheCreation1hTokens;
|
|
663
|
+
}
|
|
664
|
+
// Only send the reasoning subset when a call spent thinking tokens: a
|
|
665
|
+
// non-reasoning call omits it (server reads 0). It never changes cost — it is a
|
|
666
|
+
// share of output already priced at the output rate.
|
|
667
|
+
if (input.reasoningTokens) {
|
|
668
|
+
payload.reasoning_tokens = input.reasoningTokens;
|
|
669
|
+
}
|
|
670
|
+
// The agent-tree fields ride only when a call belongs to one, so a
|
|
671
|
+
// single-agent call's wire is unchanged and an older server that predates them
|
|
672
|
+
// is unaffected. They carry structure, never money — no cost branch reads them.
|
|
673
|
+
if (spanId !== undefined)
|
|
674
|
+
payload.span_id = spanId;
|
|
675
|
+
if (parentSpanId !== undefined)
|
|
676
|
+
payload.parent_span_id = parentSpanId;
|
|
677
|
+
if (orchestrationPattern !== undefined) {
|
|
678
|
+
payload.orchestration_pattern = orchestrationPattern;
|
|
679
|
+
}
|
|
680
|
+
if (branchWidth !== undefined)
|
|
681
|
+
payload.branch_width = branchWidth;
|
|
682
|
+
if (stoppingRule !== undefined)
|
|
683
|
+
payload.stopping_rule = stoppingRule;
|
|
684
|
+
// The delivery path, sent only when it was actually determined.
|
|
685
|
+
if (input.substrate !== undefined)
|
|
686
|
+
payload.substrate = input.substrate;
|
|
687
|
+
// The unit of work this call served, sent only when the caller named it.
|
|
688
|
+
if (input.taskKey !== undefined)
|
|
689
|
+
payload.task_key = input.taskKey;
|
|
690
|
+
if (taskClass !== undefined)
|
|
691
|
+
payload.task_class = taskClass;
|
|
692
|
+
// WHICH AGENT SCAFFOLD drove the call (MAR-371). Clamped to the server's
|
|
693
|
+
// String(64) so an over-long harness costs the tail of the label, never the row
|
|
694
|
+
// (ingest._require_str raises on over-long, which would 422 the whole call).
|
|
695
|
+
if (input.harness !== undefined)
|
|
696
|
+
payload.harness = input.harness.slice(0, 64);
|
|
697
|
+
// WHY the generation ended, straight off the provider — a signal, never a
|
|
698
|
+
// billing verdict (MAR-209). Rides only when the provider reported one; absent
|
|
699
|
+
// it reads UNKNOWN server-side rather than a fabricated value. Never priced.
|
|
700
|
+
if (input.finishReason !== undefined)
|
|
701
|
+
payload.finish_reason = input.finishReason;
|
|
129
702
|
return await this.emit(CALLS_PATH, payload);
|
|
130
703
|
}
|
|
131
704
|
/** Emit one outcome (a unit of productivity). `qualityMethod` records HOW the
|
|
@@ -135,13 +708,24 @@ export class MarginMeter {
|
|
|
135
708
|
workflow_id: input.workflowId,
|
|
136
709
|
passed: input.passed,
|
|
137
710
|
is_simulated: input.isSimulated ?? false,
|
|
711
|
+
event_id: input.eventId ?? newEventId(),
|
|
138
712
|
};
|
|
139
713
|
if (input.qualityScore !== undefined)
|
|
140
714
|
payload.quality_score = input.qualityScore;
|
|
141
715
|
if (input.qualityMethod !== undefined)
|
|
142
716
|
payload.quality_method = input.qualityMethod;
|
|
717
|
+
if (input.graderKind !== undefined)
|
|
718
|
+
payload.grader_kind = input.graderKind;
|
|
719
|
+
if (input.judgeModelId !== undefined)
|
|
720
|
+
payload.judge_model_id = input.judgeModelId;
|
|
721
|
+
if (input.graderConfidence !== undefined)
|
|
722
|
+
payload.grader_confidence = input.graderConfidence;
|
|
143
723
|
if (input.link !== undefined)
|
|
144
724
|
payload.link = input.link;
|
|
725
|
+
// Rides only when a value was actually supplied — absent is the first-class
|
|
726
|
+
// normal case (cost-per-outcome), never a defaulted 0 the customer did not send.
|
|
727
|
+
if (input.valueUsd !== undefined)
|
|
728
|
+
payload.value_usd = input.valueUsd;
|
|
145
729
|
return await this.emit(OUTCOMES_PATH, payload);
|
|
146
730
|
}
|
|
147
731
|
/**
|
|
@@ -169,22 +753,321 @@ export class MarginMeter {
|
|
|
169
753
|
inputTokens: m.inputTokens,
|
|
170
754
|
outputTokens: m.outputTokens,
|
|
171
755
|
cacheReadTokens: m.cacheReadTokens,
|
|
756
|
+
cacheCreationTokens: m.cacheCreationTokens,
|
|
757
|
+
cacheCreation1hTokens: m.cacheCreation1hTokens,
|
|
172
758
|
latencyMs,
|
|
173
759
|
status: threw ? "error" : "ok",
|
|
174
760
|
});
|
|
175
761
|
}
|
|
176
762
|
}
|
|
763
|
+
// --------------------------------------------------------------------- //
|
|
764
|
+
// Auto-instrumentation — meter straight off a provider response, no manual
|
|
765
|
+
// setTokens(). The "few lines to instrument a repo" path.
|
|
766
|
+
// --------------------------------------------------------------------- //
|
|
767
|
+
/**
|
|
768
|
+
* Meter one call by reading tokens straight off its provider `response`
|
|
769
|
+
* (OpenAI / Anthropic / Gemini result). The no-bookkeeping counterpart to
|
|
770
|
+
* `recordCall`: the `(provider, model, tokens)` are recovered by
|
|
771
|
+
* `extractUsage`. `provider` is optional when the shape is unambiguous but the
|
|
772
|
+
* instrumenter always passes it; `model` overrides a response that omits it
|
|
773
|
+
* (Gemini). Numbers are read verbatim. Never throws unless `raiseOnError`.
|
|
774
|
+
*/
|
|
775
|
+
async recordResponse(input) {
|
|
776
|
+
const usage = extractUsage(input.response, {
|
|
777
|
+
provider: input.provider,
|
|
778
|
+
model: input.model,
|
|
779
|
+
});
|
|
780
|
+
return await this.recordCall({
|
|
781
|
+
workflowId: input.workflowId,
|
|
782
|
+
provider: usage.provider,
|
|
783
|
+
model: usage.model ?? "",
|
|
784
|
+
inputTokens: usage.inputTokens,
|
|
785
|
+
outputTokens: usage.outputTokens,
|
|
786
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
787
|
+
cacheCreationTokens: usage.cacheCreationTokens,
|
|
788
|
+
cacheCreation1hTokens: usage.cacheCreation1hTokens,
|
|
789
|
+
reasoningTokens: usage.reasoningTokens,
|
|
790
|
+
costUsd: usage.billedCostUsd,
|
|
791
|
+
latencyMs: input.latencyMs,
|
|
792
|
+
status: input.status ?? "ok",
|
|
793
|
+
isRetry: input.isRetry,
|
|
794
|
+
sessionId: input.sessionId,
|
|
795
|
+
promptId: input.promptId,
|
|
796
|
+
operation: input.operation,
|
|
797
|
+
isSimulated: input.isSimulated,
|
|
798
|
+
substrate: input.substrate,
|
|
799
|
+
taskKey: input.taskKey,
|
|
800
|
+
// The provider's stop/finish reason, read verbatim off the response and
|
|
801
|
+
// forwarded as finish_reason (MAR-209). A signal, never a billing verdict.
|
|
802
|
+
finishReason: usage.stopReason,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Return `fn` wrapped so every call is timed and auto-metered. The core
|
|
807
|
+
* auto-instrumentation primitive: give it any async LLM call
|
|
808
|
+
* (`client.chat.completions.create`, `client.messages.create`,
|
|
809
|
+
* `model.generateContent` …) and it returns a drop-in replacement that times
|
|
810
|
+
* the call, emits its economics from the returned response, and hands the
|
|
811
|
+
* ORIGINAL response back untouched. On a throw the call is metered with
|
|
812
|
+
* `status="error"` and the error re-thrown (never swallowed); a metering
|
|
813
|
+
* failure is fail-safe and never breaks the wrapped call. `provider` is
|
|
814
|
+
* required for a shape the extractor can't disambiguate; `model` supplies a
|
|
815
|
+
* model the response omits (Gemini); `resolveModel(args)` derives it per call
|
|
816
|
+
* (e.g. off a bound Gemini model). Signature-agnostic (`...args`).
|
|
817
|
+
*/
|
|
818
|
+
wrap(fn, opts) {
|
|
819
|
+
const meter = this;
|
|
820
|
+
return async function (...args) {
|
|
821
|
+
let callModel = opts.model;
|
|
822
|
+
if (opts.resolveModel) {
|
|
823
|
+
try {
|
|
824
|
+
const resolved = opts.resolveModel(args);
|
|
825
|
+
if (resolved)
|
|
826
|
+
callModel = resolved;
|
|
827
|
+
}
|
|
828
|
+
catch {
|
|
829
|
+
/* a resolver hiccup never breaks the wrapped call */
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
// The unit of work, resolved off the host's ambient context per call — never a
|
|
833
|
+
// constant, which would collapse a run into one task class.
|
|
834
|
+
let callTaskKey;
|
|
835
|
+
if (opts.resolveTaskKey) {
|
|
836
|
+
try {
|
|
837
|
+
callTaskKey = opts.resolveTaskKey();
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
/* a resolver hiccup reads as UNKNOWN, never breaks the wrapped call */
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
// Ask OpenAI to include usage on a streamed call so the final chunk carries
|
|
844
|
+
// the count the accumulator needs (only when this is an OpenAI chat patch).
|
|
845
|
+
maybeInjectIncludeUsage(args, opts);
|
|
846
|
+
const start = now();
|
|
847
|
+
let response;
|
|
848
|
+
try {
|
|
849
|
+
response = (await fn(...args));
|
|
850
|
+
}
|
|
851
|
+
catch (e) {
|
|
852
|
+
const latencyMs = Math.round(now() - start);
|
|
853
|
+
try {
|
|
854
|
+
await meter.recordCall({
|
|
855
|
+
workflowId: opts.workflowId,
|
|
856
|
+
provider: opts.provider ?? "unknown",
|
|
857
|
+
model: callModel ?? "",
|
|
858
|
+
latencyMs,
|
|
859
|
+
status: "error",
|
|
860
|
+
isRetry: opts.isRetry,
|
|
861
|
+
sessionId: opts.sessionId,
|
|
862
|
+
operation: opts.operation,
|
|
863
|
+
isSimulated: opts.isSimulated,
|
|
864
|
+
substrate: opts.substrate,
|
|
865
|
+
taskKey: callTaskKey,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
/* metering must never mask the host's real exception */
|
|
870
|
+
}
|
|
871
|
+
throw e;
|
|
872
|
+
}
|
|
873
|
+
// A streamed call resolves to an async-iterable of chunks, not a `.usage`
|
|
874
|
+
// response. Reading it here would throw and drop the call; instead hand back
|
|
875
|
+
// a transparent proxy that meters ONCE as the host consumes the stream.
|
|
876
|
+
if (isAsyncIterable(response)) {
|
|
877
|
+
return meteredStream(response, meter, opts, callModel, callTaskKey, start);
|
|
878
|
+
}
|
|
879
|
+
// Bedrock's streaming commands resolve to a plain object whose event stream rides
|
|
880
|
+
// under `.stream` (ConverseStream) or `.body` (InvokeModelWithResponseStream), with
|
|
881
|
+
// the usage in its final event. Swap that field for the metered proxy (MAR-1518).
|
|
882
|
+
const nestedKey = opts.provider === "bedrock" ? bedrockStreamKey(response) : undefined;
|
|
883
|
+
if (nestedKey !== undefined) {
|
|
884
|
+
const r = response;
|
|
885
|
+
r[nestedKey] = meteredStream(r[nestedKey], meter, opts, callModel, callTaskKey, start);
|
|
886
|
+
return response;
|
|
887
|
+
}
|
|
888
|
+
const latencyMs = Math.round(now() - start);
|
|
889
|
+
try {
|
|
890
|
+
await meter.recordResponse({
|
|
891
|
+
workflowId: opts.workflowId,
|
|
892
|
+
response,
|
|
893
|
+
provider: opts.provider,
|
|
894
|
+
model: callModel,
|
|
895
|
+
latencyMs,
|
|
896
|
+
isRetry: opts.isRetry,
|
|
897
|
+
sessionId: opts.sessionId,
|
|
898
|
+
operation: opts.operation,
|
|
899
|
+
isSimulated: opts.isSimulated,
|
|
900
|
+
substrate: opts.substrate,
|
|
901
|
+
taskKey: callTaskKey,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
/* fail-safe: an unreadable response must not break the host call */
|
|
906
|
+
}
|
|
907
|
+
return response;
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/** True when `x` is an async-iterable (a provider stream), never a plain response
|
|
912
|
+
* object, string, or array. The clean discriminator between a streamed call and
|
|
913
|
+
* a normal one after the wrapped fn resolves. */
|
|
914
|
+
function isAsyncIterable(x) {
|
|
915
|
+
return (x != null &&
|
|
916
|
+
(typeof x === "object" || typeof x === "function") &&
|
|
917
|
+
typeof x[Symbol.asyncIterator] === "function");
|
|
918
|
+
}
|
|
919
|
+
/** The field a Bedrock streaming response carries its event stream under, if any.
|
|
920
|
+
* A non-streaming `InvokeModel` body is a Uint8Array, not async-iterable, so it is
|
|
921
|
+
* never mistaken for one. */
|
|
922
|
+
function bedrockStreamKey(response) {
|
|
923
|
+
if (response == null || typeof response !== "object")
|
|
924
|
+
return undefined;
|
|
925
|
+
const r = response;
|
|
926
|
+
if (isAsyncIterable(r.stream))
|
|
927
|
+
return "stream";
|
|
928
|
+
if (isAsyncIterable(r.body))
|
|
929
|
+
return "body";
|
|
930
|
+
return undefined;
|
|
931
|
+
}
|
|
932
|
+
/** When this wrapper is an OpenAI chat patch (`streamIncludeUsage`) AND the call
|
|
933
|
+
* requests `stream: true`, add `stream_options.include_usage` so OpenAI emits its
|
|
934
|
+
* usage on the final chunk. Clones the params object (never mutates the caller's),
|
|
935
|
+
* and fills only when absent so an explicit caller choice is respected. */
|
|
936
|
+
function maybeInjectIncludeUsage(args, opts) {
|
|
937
|
+
if (!opts.streamIncludeUsage)
|
|
938
|
+
return;
|
|
939
|
+
const params = args[0];
|
|
940
|
+
if (params == null || typeof params !== "object")
|
|
941
|
+
return;
|
|
942
|
+
const p = params;
|
|
943
|
+
if (p.stream !== true)
|
|
944
|
+
return;
|
|
945
|
+
const existing = p.stream_options;
|
|
946
|
+
if (existing == null) {
|
|
947
|
+
args[0] = { ...p, stream_options: { include_usage: true } };
|
|
948
|
+
}
|
|
949
|
+
else if (typeof existing === "object" &&
|
|
950
|
+
!("include_usage" in existing)) {
|
|
951
|
+
args[0] = {
|
|
952
|
+
...p,
|
|
953
|
+
stream_options: { ...existing, include_usage: true },
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* A transparent proxy over a provider stream that meters on completion.
|
|
959
|
+
*
|
|
960
|
+
* The TS twin of `margin_meter`'s `_MeteredStream`. It hands every chunk to the
|
|
961
|
+
* host UNCHANGED while feeding it to a `StreamUsageAccumulator`; when the stream
|
|
962
|
+
* is exhausted, the host breaks early (its iterator's `return()`), or a chunk
|
|
963
|
+
* throws, it meters exactly once with the accumulated usage — or nothing when the
|
|
964
|
+
* stream carried no usage at all (an OpenAI stream without `include_usage`), since
|
|
965
|
+
* a zero-token row would misreport a real call as free. Every other property/method
|
|
966
|
+
* delegates to the wrapped stream, so `for await (const c of stream)`, `.tee()`,
|
|
967
|
+
* and the rest work unchanged. Fail-safe: a metering hiccup never breaks iteration.
|
|
968
|
+
*/
|
|
969
|
+
function meteredStream(stream, meter, opts, callModel, callTaskKey, start) {
|
|
970
|
+
const acc = new StreamUsageAccumulator(opts.provider, callModel);
|
|
971
|
+
let finished = false;
|
|
972
|
+
const finish = async () => {
|
|
973
|
+
if (finished)
|
|
974
|
+
return;
|
|
975
|
+
finished = true;
|
|
976
|
+
const usage = acc.finalize();
|
|
977
|
+
// Honest: a stream that carried no usage is NOT recorded — there is no real
|
|
978
|
+
// token count and a zero-token row would report the call as free.
|
|
979
|
+
if (usage === undefined)
|
|
980
|
+
return;
|
|
981
|
+
const latencyMs = Math.round(now() - start);
|
|
982
|
+
try {
|
|
983
|
+
await meter.recordCall({
|
|
984
|
+
workflowId: opts.workflowId,
|
|
985
|
+
provider: usage.provider ?? opts.provider ?? "unknown",
|
|
986
|
+
model: usage.model ?? callModel ?? "",
|
|
987
|
+
inputTokens: usage.inputTokens,
|
|
988
|
+
outputTokens: usage.outputTokens,
|
|
989
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
990
|
+
cacheCreationTokens: usage.cacheCreationTokens,
|
|
991
|
+
cacheCreation1hTokens: usage.cacheCreation1hTokens,
|
|
992
|
+
reasoningTokens: usage.reasoningTokens,
|
|
993
|
+
costUsd: usage.billedCostUsd,
|
|
994
|
+
latencyMs,
|
|
995
|
+
status: "ok",
|
|
996
|
+
isRetry: opts.isRetry,
|
|
997
|
+
sessionId: opts.sessionId,
|
|
998
|
+
operation: opts.operation,
|
|
999
|
+
isSimulated: opts.isSimulated,
|
|
1000
|
+
substrate: opts.substrate,
|
|
1001
|
+
taskKey: callTaskKey,
|
|
1002
|
+
// A streaming Anthropic message_delta carries stop_reason too (the
|
|
1003
|
+
// accumulator merges it) — forward it so a streamed Fable fallback records
|
|
1004
|
+
// its reason like a non-streamed one.
|
|
1005
|
+
finishReason: usage.stopReason,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
catch {
|
|
1009
|
+
/* metering must never mask the host's real result */
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
return new Proxy(stream, {
|
|
1013
|
+
get(target, prop, receiver) {
|
|
1014
|
+
if (prop === Symbol.asyncIterator) {
|
|
1015
|
+
return function meteredAsyncIterator() {
|
|
1016
|
+
const inner = target[Symbol.asyncIterator]();
|
|
1017
|
+
return {
|
|
1018
|
+
async next() {
|
|
1019
|
+
let r;
|
|
1020
|
+
try {
|
|
1021
|
+
r = await inner.next();
|
|
1022
|
+
}
|
|
1023
|
+
catch (e) {
|
|
1024
|
+
await finish(); // a mid-stream error still banks the partial usage seen
|
|
1025
|
+
throw e;
|
|
1026
|
+
}
|
|
1027
|
+
if (r.done) {
|
|
1028
|
+
await finish(); // metered once, cleanly, at the end of the stream
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
try {
|
|
1032
|
+
acc.observe(r.value);
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
/* a malformed chunk never breaks the host's read */
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return r;
|
|
1039
|
+
},
|
|
1040
|
+
async return(value) {
|
|
1041
|
+
await finish(); // the host broke early — meter what was seen
|
|
1042
|
+
if (typeof inner.return === "function") {
|
|
1043
|
+
return inner.return(value);
|
|
1044
|
+
}
|
|
1045
|
+
return { done: true, value };
|
|
1046
|
+
},
|
|
1047
|
+
};
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
const val = Reflect.get(target, prop, receiver);
|
|
1051
|
+
return typeof val === "function"
|
|
1052
|
+
? val.bind(target)
|
|
1053
|
+
: val;
|
|
1054
|
+
},
|
|
1055
|
+
});
|
|
177
1056
|
}
|
|
178
1057
|
/** Token accumulator passed into `measure`'s callback. */
|
|
179
1058
|
export class Measurement {
|
|
180
1059
|
inputTokens = 0;
|
|
181
1060
|
outputTokens = 0;
|
|
182
1061
|
cacheReadTokens = 0;
|
|
1062
|
+
cacheCreationTokens = 0;
|
|
1063
|
+
cacheCreation1hTokens = 0;
|
|
183
1064
|
result;
|
|
184
1065
|
setTokens(tokens) {
|
|
185
1066
|
this.inputTokens = tokens.inputTokens ?? 0;
|
|
186
1067
|
this.outputTokens = tokens.outputTokens ?? 0;
|
|
187
1068
|
this.cacheReadTokens = tokens.cacheReadTokens ?? 0;
|
|
1069
|
+
this.cacheCreationTokens = tokens.cacheCreationTokens ?? 0;
|
|
1070
|
+
this.cacheCreation1hTokens = tokens.cacheCreation1hTokens ?? 0;
|
|
188
1071
|
}
|
|
189
1072
|
}
|
|
190
1073
|
async function extractDetail(resp) {
|
|
@@ -202,4 +1085,39 @@ async function extractDetail(resp) {
|
|
|
202
1085
|
function now() {
|
|
203
1086
|
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
204
1087
|
}
|
|
1088
|
+
function sleep(ms) {
|
|
1089
|
+
return new Promise((resolve) => {
|
|
1090
|
+
const timer = setTimeout(resolve, ms);
|
|
1091
|
+
// Unref'd for the same reason the flush timer is: a backoff must never be the
|
|
1092
|
+
// thing keeping a short-lived script alive.
|
|
1093
|
+
if (typeof timer.unref === "function") {
|
|
1094
|
+
timer.unref();
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
/** The server's own `Retry-After`, in ms, when it sent one.
|
|
1099
|
+
*
|
|
1100
|
+
* The ingest API computes the real wait from its rolling window rather than
|
|
1101
|
+
* sending a round number, so honouring it beats any backoff we could calculate
|
|
1102
|
+
* blind — a client that guesses either hammers or overshoots, and overshooting
|
|
1103
|
+
* drops exactly the calls the SDK was installed to record. */
|
|
1104
|
+
export function retryAfterMs_(resp) {
|
|
1105
|
+
const headers = resp.headers;
|
|
1106
|
+
const raw = headers?.get?.("Retry-After") ?? undefined;
|
|
1107
|
+
if (raw === undefined || raw === null)
|
|
1108
|
+
return undefined;
|
|
1109
|
+
const seconds = Number(raw);
|
|
1110
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : undefined;
|
|
1111
|
+
}
|
|
1112
|
+
/** FULL jitter — `random(0, base * 2**attempt)`, capped, or the server's wait.
|
|
1113
|
+
*
|
|
1114
|
+
* Jittered rather than plain exponential because every buffered client backs off
|
|
1115
|
+
* on the same schedule: un-jittered retries re-synchronise into a thundering herd
|
|
1116
|
+
* aimed at the endpoint that just failed, which is how a brief blip becomes an
|
|
1117
|
+
* outage. */
|
|
1118
|
+
export function backoffMs(attempt, retryAfter) {
|
|
1119
|
+
if (retryAfter !== undefined)
|
|
1120
|
+
return Math.min(RETRY_CAP_MS, retryAfter);
|
|
1121
|
+
return Math.random() * Math.min(RETRY_CAP_MS, RETRY_BASE_MS * 2 ** attempt);
|
|
1122
|
+
}
|
|
205
1123
|
//# sourceMappingURL=index.js.map
|