margin-meter 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/README.md +61 -6
- package/dist/index.d.ts +124 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +313 -0
- package/dist/index.js.map +1 -1
- package/dist/instrument.d.ts +73 -0
- package/dist/instrument.d.ts.map +1 -0
- package/dist/instrument.js +152 -0
- package/dist/instrument.js.map +1 -0
- package/dist/providers.d.ts +158 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +397 -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/package.json +2 -2
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the measured economics of one LLM call straight off a provider response.
|
|
3
|
+
*
|
|
4
|
+
* The TS twin of `margin_meter.providers`: given the object an OpenAI /
|
|
5
|
+
* Anthropic / Gemini JS client returns, recover the `(provider, model, tokens)`
|
|
6
|
+
* a Margin call row needs — so auto-instrumentation is a few lines with no
|
|
7
|
+
* manual token bookkeeping.
|
|
8
|
+
*
|
|
9
|
+
* Dependency-free and duck-typed on purpose: it never imports the provider SDKs,
|
|
10
|
+
* reading fields by shape so it works on a real SDK object, a plain object (the
|
|
11
|
+
* shape a mocked provider returns in tests), or JSON. The three shapes, as the
|
|
12
|
+
* current JS SDKs publish them:
|
|
13
|
+
*
|
|
14
|
+
* - **OpenAI** — `model`; `usage.prompt_tokens` / `completion_tokens`; cached
|
|
15
|
+
* reads at `usage.prompt_tokens_details.cached_tokens` (Responses API:
|
|
16
|
+
* `usage.input_tokens` / `output_tokens` / `input_tokens_details.cached_tokens`).
|
|
17
|
+
* - **Anthropic** — `model`; `usage.input_tokens` / `output_tokens`; cached
|
|
18
|
+
* reads at `usage.cache_read_input_tokens`; cache WRITES at
|
|
19
|
+
* `usage.cache_creation_input_tokens` (a ~1.25x premium bucket).
|
|
20
|
+
* - **Gemini** (`@google/generative-ai`) — `response.usageMetadata` (the
|
|
21
|
+
* `generateContent` result nests it under `.response`), with
|
|
22
|
+
* `promptTokenCount` / `candidatesTokenCount` / `cachedContentTokenCount`, plus
|
|
23
|
+
* `thoughtsTokenCount` (thinking tokens, billed as output and folded in).
|
|
24
|
+
* The model does not ride the response, so the caller passes it.
|
|
25
|
+
* - **Cohere** (v2 `/chat`) — billed counts nest under `usage.billed_units`
|
|
26
|
+
* (what is charged; raw `usage.tokens` is the fallback), each with
|
|
27
|
+
* `input_tokens` / `output_tokens`. Cohere publishes no chat prompt-cache
|
|
28
|
+
* discount, so cache reads bill as ordinary input (`cacheReadTokens` stays 0);
|
|
29
|
+
* the model does not ride the response, so the caller passes it.
|
|
30
|
+
* - **AWS Bedrock** (`@aws-sdk/client-bedrock-runtime`, Converse) — usage rides
|
|
31
|
+
* `usage.inputTokens` / `outputTokens` in camelCase, with ADDITIVE cache
|
|
32
|
+
* buckets `cacheReadInputTokens` / `cacheWriteInputTokens` (AWS documents
|
|
33
|
+
* `totalTokens = inputTokens + outputTokens + cacheReadInputTokens +
|
|
34
|
+
* cacheWriteInputTokens`), so the three input-side buckets are disjoint and
|
|
35
|
+
* each reads verbatim, no subtraction. The Converse response omits the model
|
|
36
|
+
* id, so the caller passes it (`instrumentBedrock` reads it off the command's
|
|
37
|
+
* `input.modelId`); its gateway form (`us.anthropic.claude-…-v1:0`) prices
|
|
38
|
+
* through the server's `normalize_model`.
|
|
39
|
+
*
|
|
40
|
+
* Numbers are read verbatim — never inflated. A field the provider didn't report
|
|
41
|
+
* reads 0; an unrecognised shape throws `UsageExtractionError` rather than
|
|
42
|
+
* emitting a silently-zero (free-looking) call.
|
|
43
|
+
*/
|
|
44
|
+
export const OPENAI = "openai";
|
|
45
|
+
export const ANTHROPIC = "anthropic";
|
|
46
|
+
export const GOOGLE = "google";
|
|
47
|
+
export const COHERE = "cohere";
|
|
48
|
+
export const BEDROCK = "bedrock";
|
|
49
|
+
/** The response object matched no provider shape we can meter. */
|
|
50
|
+
export class UsageExtractionError extends Error {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "UsageExtractionError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Read `obj.name` from an object OR undefined — duck-typed, null-safe. */
|
|
57
|
+
function get(obj, name) {
|
|
58
|
+
if (obj === null || obj === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (typeof obj !== "object")
|
|
61
|
+
return undefined;
|
|
62
|
+
return obj[name];
|
|
63
|
+
}
|
|
64
|
+
/** Walk a chain of field names, tolerating a missing hop. */
|
|
65
|
+
function getPath(obj, ...names) {
|
|
66
|
+
let cur = obj;
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
cur = get(cur, name);
|
|
69
|
+
if (cur === null || cur === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return cur;
|
|
73
|
+
}
|
|
74
|
+
/** Coerce a reported token count to a non-negative int; missing → 0. Reads
|
|
75
|
+
* verbatim — never rounds a real count up or invents one. */
|
|
76
|
+
function asInt(value) {
|
|
77
|
+
if (value === null || value === undefined)
|
|
78
|
+
return 0;
|
|
79
|
+
const n = Math.trunc(Number(value));
|
|
80
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
81
|
+
return 0;
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
84
|
+
/** The Gemini `generateContent` result nests usage under `.response`; unwrap it. */
|
|
85
|
+
function usageMetadata(response) {
|
|
86
|
+
const direct = get(response, "usageMetadata");
|
|
87
|
+
if (direct !== undefined)
|
|
88
|
+
return direct;
|
|
89
|
+
return getPath(response, "response", "usageMetadata");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Best-effort provider inference from a response's shape. Prefers unambiguous
|
|
93
|
+
* signals: Gemini exposes `usageMetadata`; Anthropic's usage carries
|
|
94
|
+
* `input_tokens` while OpenAI's carries `prompt_tokens`. Returns undefined when
|
|
95
|
+
* nothing distinguishes it — the caller must then name the provider (the
|
|
96
|
+
* instrumenter always does).
|
|
97
|
+
*/
|
|
98
|
+
export function detectProvider(response) {
|
|
99
|
+
if (usageMetadata(response) !== undefined)
|
|
100
|
+
return GOOGLE;
|
|
101
|
+
const usage = get(response, "usage");
|
|
102
|
+
if (usage !== undefined && usage !== null) {
|
|
103
|
+
// Cohere nests billed counts under `billed_units` — a key no other provider
|
|
104
|
+
// carries, so it is an unambiguous signal. Checked before Anthropic so its
|
|
105
|
+
// NESTED input_tokens is never read as Anthropic's top-level one.
|
|
106
|
+
if (get(usage, "billed_units") !== undefined)
|
|
107
|
+
return COHERE;
|
|
108
|
+
// Bedrock's Converse usage is camelCase (`inputTokens`/`outputTokens`) — a
|
|
109
|
+
// shape every other provider spells snake_case, so it is unambiguous (like
|
|
110
|
+
// Cohere's `billed_units`). Checked before the snake_case branches so its
|
|
111
|
+
// camelCase keys are never read as a missing OpenAI/Anthropic usage.
|
|
112
|
+
if (get(usage, "inputTokens") !== undefined || get(usage, "outputTokens") !== undefined) {
|
|
113
|
+
return BEDROCK;
|
|
114
|
+
}
|
|
115
|
+
if (get(usage, "prompt_tokens") !== undefined || get(usage, "completion_tokens") !== undefined) {
|
|
116
|
+
return OPENAI;
|
|
117
|
+
}
|
|
118
|
+
if (get(usage, "input_tokens") !== undefined || get(usage, "output_tokens") !== undefined) {
|
|
119
|
+
return ANTHROPIC;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
function extractOpenAI(response, model) {
|
|
125
|
+
const usage = get(response, "usage");
|
|
126
|
+
if (usage === undefined || usage === null) {
|
|
127
|
+
throw new UsageExtractionError("openai response carries no .usage");
|
|
128
|
+
}
|
|
129
|
+
const inputRaw = get(usage, "prompt_tokens") ?? get(usage, "input_tokens");
|
|
130
|
+
const outputRaw = get(usage, "completion_tokens") ?? get(usage, "output_tokens");
|
|
131
|
+
// DeepSeek-direct is OpenAI-compatible, so instrument reaches it through the same
|
|
132
|
+
// client — but it reports cache hits in its own prompt_cache_hit_tokens field, not
|
|
133
|
+
// prompt_tokens_details.cached_tokens, and prompt_tokens is inclusive of them. A
|
|
134
|
+
// genuine OpenAI/Azure response never carries this field (reads 0), so the direct
|
|
135
|
+
// path is unchanged; reading it captures DeepSeek's cache discount. Consulted last,
|
|
136
|
+
// so a provider reporting both is never double-counted.
|
|
137
|
+
const cachedRaw = getPath(usage, "prompt_tokens_details", "cached_tokens") ??
|
|
138
|
+
getPath(usage, "input_tokens_details", "cached_tokens") ??
|
|
139
|
+
get(usage, "prompt_cache_hit_tokens");
|
|
140
|
+
const cached = asInt(cachedRaw);
|
|
141
|
+
// prompt_tokens is INCLUSIVE of cached — split so cache reads are not
|
|
142
|
+
// double-billed as fresh input (the server bills the two separately).
|
|
143
|
+
const freshInput = asInt(inputRaw) - cached;
|
|
144
|
+
// A reasoning model (o-series, gpt-5) reports the hidden-thinking share under
|
|
145
|
+
// completion_tokens_details.reasoning_tokens — a BREAKDOWN of completion_tokens,
|
|
146
|
+
// not an addition. outputTokens already counts it; carry it only for the
|
|
147
|
+
// reasoning-share detector, clamped to output so the subset never exceeds the
|
|
148
|
+
// whole. 0 on a non-reasoning response.
|
|
149
|
+
const out = asInt(outputRaw);
|
|
150
|
+
const reasoning = asInt(getPath(usage, "completion_tokens_details", "reasoning_tokens"));
|
|
151
|
+
return {
|
|
152
|
+
provider: OPENAI,
|
|
153
|
+
model: model ?? get(response, "model"),
|
|
154
|
+
inputTokens: freshInput > 0 ? freshInput : 0,
|
|
155
|
+
outputTokens: out,
|
|
156
|
+
cacheReadTokens: cached,
|
|
157
|
+
cacheCreationTokens: 0,
|
|
158
|
+
reasoningTokens: Math.min(reasoning, out),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function extractAnthropic(response, model) {
|
|
162
|
+
const usage = get(response, "usage");
|
|
163
|
+
if (usage === undefined || usage === null) {
|
|
164
|
+
throw new UsageExtractionError("anthropic response carries no .usage");
|
|
165
|
+
}
|
|
166
|
+
// Anthropic's input_tokens EXCLUDES both cache reads and cache writes (each a
|
|
167
|
+
// separate field) — read them verbatim, no subtraction. Cache creation
|
|
168
|
+
// (cache_creation_input_tokens) is the write-premium bucket AGGREGATE. When a
|
|
169
|
+
// request pins a 1-hour TTL, Anthropic breaks that down under usage.cache_creation
|
|
170
|
+
// into ephemeral_5m/ephemeral_1h — the 1-hour writes bill ~2x input (vs 5-minute
|
|
171
|
+
// ~1.25x), so split them out. The 1-hour portion goes in its own bucket and the
|
|
172
|
+
// 5-minute bucket is the remainder; no breakdown → 1h is 0 and the whole aggregate
|
|
173
|
+
// stays 5-minute, byte-identical to before.
|
|
174
|
+
const totalCreation = asInt(get(usage, "cache_creation_input_tokens"));
|
|
175
|
+
const creation1h = asInt(getPath(usage, "cache_creation", "ephemeral_1h_input_tokens"));
|
|
176
|
+
const creation5m = totalCreation - creation1h;
|
|
177
|
+
return {
|
|
178
|
+
provider: ANTHROPIC,
|
|
179
|
+
model: model ?? get(response, "model"),
|
|
180
|
+
inputTokens: asInt(get(usage, "input_tokens")),
|
|
181
|
+
outputTokens: asInt(get(usage, "output_tokens")),
|
|
182
|
+
cacheReadTokens: asInt(get(usage, "cache_read_input_tokens")),
|
|
183
|
+
// Never let a malformed breakdown push the 5-minute remainder negative.
|
|
184
|
+
cacheCreationTokens: creation5m > 0 ? creation5m : 0,
|
|
185
|
+
cacheCreation1hTokens: creation1h,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function extractGemini(response, model) {
|
|
189
|
+
const meta = usageMetadata(response);
|
|
190
|
+
if (meta === undefined || meta === null) {
|
|
191
|
+
throw new UsageExtractionError("gemini response carries no usageMetadata");
|
|
192
|
+
}
|
|
193
|
+
const cached = asInt(get(meta, "cachedContentTokenCount"));
|
|
194
|
+
const prompt = asInt(get(meta, "promptTokenCount"));
|
|
195
|
+
// promptTokenCount is INCLUSIVE of cached content — split it out.
|
|
196
|
+
const freshInput = prompt - cached;
|
|
197
|
+
// Gemini bills THINKING tokens (thoughtsTokenCount) at the output rate but
|
|
198
|
+
// reports them in a field DISJOINT from candidatesTokenCount (the visible
|
|
199
|
+
// completion). Billed output = candidates + thoughts, which also equals
|
|
200
|
+
// totalTokenCount - promptTokenCount. Take the max so a thinking-enabled call
|
|
201
|
+
// is never under-counted, whichever fields the response populates.
|
|
202
|
+
const candidates = asInt(get(meta, "candidatesTokenCount"));
|
|
203
|
+
const thoughts = asInt(get(meta, "thoughtsTokenCount"));
|
|
204
|
+
const total = asInt(get(meta, "totalTokenCount"));
|
|
205
|
+
const outputTokens = Math.max(candidates + thoughts, total ? total - prompt : 0);
|
|
206
|
+
return {
|
|
207
|
+
provider: GOOGLE,
|
|
208
|
+
model,
|
|
209
|
+
inputTokens: freshInput > 0 ? freshInput : 0,
|
|
210
|
+
outputTokens,
|
|
211
|
+
cacheReadTokens: cached,
|
|
212
|
+
cacheCreationTokens: 0,
|
|
213
|
+
// thoughts already sit inside outputTokens (billed at the output rate);
|
|
214
|
+
// carried here only for the reasoning-share detector, never above output.
|
|
215
|
+
reasoningTokens: Math.min(thoughts, outputTokens),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function extractCohere(response, model) {
|
|
219
|
+
const usage = get(response, "usage");
|
|
220
|
+
if (usage === undefined || usage === null) {
|
|
221
|
+
throw new UsageExtractionError("cohere response carries no .usage");
|
|
222
|
+
}
|
|
223
|
+
// Cohere v2 /chat reports billed counts under `billed_units` (what is charged),
|
|
224
|
+
// with a raw `tokens` mirror as the fallback. input_tokens is read VERBATIM:
|
|
225
|
+
// Cohere publishes no chat prompt-cache discount, so a cache read bills at the
|
|
226
|
+
// input rate and is already inside billed_units.input — folding it into input
|
|
227
|
+
// (cacheReadTokens 0) is cost-exact and never over-states a caching win.
|
|
228
|
+
const units = get(usage, "billed_units") ?? get(usage, "tokens");
|
|
229
|
+
if (units === undefined || units === null) {
|
|
230
|
+
throw new UsageExtractionError("cohere usage carries no billed_units/tokens");
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
provider: COHERE,
|
|
234
|
+
model: model ?? get(response, "model"),
|
|
235
|
+
inputTokens: asInt(get(units, "input_tokens")),
|
|
236
|
+
outputTokens: asInt(get(units, "output_tokens")),
|
|
237
|
+
cacheReadTokens: 0,
|
|
238
|
+
cacheCreationTokens: 0,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function extractBedrock(response, model) {
|
|
242
|
+
const usage = get(response, "usage");
|
|
243
|
+
if (usage === undefined || usage === null) {
|
|
244
|
+
throw new UsageExtractionError("bedrock response carries no .usage");
|
|
245
|
+
}
|
|
246
|
+
// Bedrock's Converse usage is camelCase and — like Anthropic native —
|
|
247
|
+
// `inputTokens` EXCLUDES the cache buckets: AWS documents `totalTokens =
|
|
248
|
+
// inputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens`,
|
|
249
|
+
// so the three input-side buckets are disjoint and each reads verbatim, no
|
|
250
|
+
// subtraction. `cacheWriteInputTokens` is the write bucket (a cache-creation
|
|
251
|
+
// premium); `cacheReadInputTokens` the discounted hit. Both absent (→ 0) on a
|
|
252
|
+
// no-cache call. The response omits the model id, so the caller supplies it.
|
|
253
|
+
return {
|
|
254
|
+
provider: BEDROCK,
|
|
255
|
+
model: model ?? get(response, "model"),
|
|
256
|
+
inputTokens: asInt(get(usage, "inputTokens")),
|
|
257
|
+
outputTokens: asInt(get(usage, "outputTokens")),
|
|
258
|
+
cacheReadTokens: asInt(get(usage, "cacheReadInputTokens")),
|
|
259
|
+
cacheCreationTokens: asInt(get(usage, "cacheWriteInputTokens")),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const EXTRACTORS = {
|
|
263
|
+
[OPENAI]: extractOpenAI,
|
|
264
|
+
[ANTHROPIC]: extractAnthropic,
|
|
265
|
+
[GOOGLE]: extractGemini,
|
|
266
|
+
[COHERE]: extractCohere,
|
|
267
|
+
[BEDROCK]: extractBedrock,
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Recover the metered `Usage` of one call from its provider response. `provider`
|
|
271
|
+
* forces the shape (the instrumenter always knows which client it patched);
|
|
272
|
+
* omit it and the provider is inferred from the response's shape. `model`
|
|
273
|
+
* overrides a response that omits its model (Gemini). Throws
|
|
274
|
+
* `UsageExtractionError` when the shape matches no known provider or carries no
|
|
275
|
+
* usage block, so a silently-free call is never emitted.
|
|
276
|
+
*/
|
|
277
|
+
export function extractUsage(response, opts = {}) {
|
|
278
|
+
const prov = (opts.provider ?? detectProvider(response));
|
|
279
|
+
const extractor = prov ? EXTRACTORS[prov] : undefined;
|
|
280
|
+
if (!extractor) {
|
|
281
|
+
throw new UsageExtractionError(`unknown or undetectable provider ${String(opts.provider)}; ` +
|
|
282
|
+
"pass provider: 'openai' | 'anthropic' | 'google' | 'cohere' | 'bedrock'");
|
|
283
|
+
}
|
|
284
|
+
return extractor(response, opts.model);
|
|
285
|
+
}
|
|
286
|
+
// --------------------------------------------------------------------------- //
|
|
287
|
+
// Streaming — reunite the usage of one STREAMED call across its chunks.
|
|
288
|
+
//
|
|
289
|
+
// A `stream: true` call resolves to an async-iterable of chunks, not a `.usage`
|
|
290
|
+
// response, so `extractUsage` on the resolved value throws and the call would be
|
|
291
|
+
// dropped. Streaming is the DEFAULT mode for interactive JS agents (the Vercel
|
|
292
|
+
// AI SDK, LangChain.js, chat UIs), so that is a whole class of spend going
|
|
293
|
+
// unmetered. These primitives are the TS twin of `margin_meter.providers`'
|
|
294
|
+
// `StreamUsageAccumulator`: read whatever usage each chunk carries, max-merge it
|
|
295
|
+
// across the stream, and finalize to one `Usage` — or `undefined` when the stream
|
|
296
|
+
// carried no usage at all (never a fabricated zero-token, free-looking row).
|
|
297
|
+
// --------------------------------------------------------------------------- //
|
|
298
|
+
/**
|
|
299
|
+
* Read whatever usage ONE streaming chunk/event carries, or `undefined`.
|
|
300
|
+
*
|
|
301
|
+
* The three streamed shapes differ in WHERE (and whether) a chunk carries usage:
|
|
302
|
+
*
|
|
303
|
+
* - **OpenAI** (needs `stream_options: { include_usage: true }`): every content
|
|
304
|
+
* chunk has `usage: null`; only the FINAL chunk carries the full `.usage`.
|
|
305
|
+
* - **Gemini**: every chunk carries a CUMULATIVE `usageMetadata` — the last is
|
|
306
|
+
* authoritative, and the running max keeps it.
|
|
307
|
+
* - **Anthropic**: no single event is complete — `message_start` carries the
|
|
308
|
+
* input + cache counts nested under `event.message.usage`, while `message_delta`
|
|
309
|
+
* carries the final `event.usage.output_tokens`. This reads BOTH carriers so the
|
|
310
|
+
* merge can reunite them.
|
|
311
|
+
*
|
|
312
|
+
* Returns `undefined` (never throws) for a chunk with no usage, so an ordinary
|
|
313
|
+
* content chunk is skipped, not treated as an error.
|
|
314
|
+
*/
|
|
315
|
+
export function usageFromStreamChunk(chunk, provider, model) {
|
|
316
|
+
// Straightforward carriers: a top-level `usage` (OpenAI final chunk, Anthropic
|
|
317
|
+
// message_delta) or `usageMetadata` (Gemini) — extractUsage reads either.
|
|
318
|
+
try {
|
|
319
|
+
return extractUsage(chunk, { provider, model });
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
/* not a complete-usage chunk — try the nested Anthropic carrier below */
|
|
323
|
+
}
|
|
324
|
+
// Anthropic's message_start nests the input/cache counts under
|
|
325
|
+
// `event.message.usage`; extractUsage on the event itself misses them, so read
|
|
326
|
+
// the nested message carrier directly (its usage IS the top-level shape).
|
|
327
|
+
const message = get(chunk, "message");
|
|
328
|
+
if (message !== undefined && message !== null && get(message, "usage") !== undefined) {
|
|
329
|
+
try {
|
|
330
|
+
return extractUsage(message, { provider: provider ?? ANTHROPIC, model });
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
/* not a readable message_start either */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Merge two per-chunk usages, taking the MAX of each token bucket.
|
|
340
|
+
*
|
|
341
|
+
* Every streamed token count is monotonic within one call — cumulative (Gemini),
|
|
342
|
+
* or populated exactly once (OpenAI's final chunk; Anthropic's input-here /
|
|
343
|
+
* output-there split) — so the running max reconstructs the final total no matter
|
|
344
|
+
* which chunk carried which field. The max never invents a token; it only keeps
|
|
345
|
+
* the largest REAL count each field reached.
|
|
346
|
+
*/
|
|
347
|
+
export function mergeStreamUsage(a, b) {
|
|
348
|
+
return {
|
|
349
|
+
provider: a.provider ?? b.provider,
|
|
350
|
+
model: a.model ?? b.model,
|
|
351
|
+
inputTokens: Math.max(a.inputTokens, b.inputTokens),
|
|
352
|
+
outputTokens: Math.max(a.outputTokens, b.outputTokens),
|
|
353
|
+
cacheReadTokens: Math.max(a.cacheReadTokens, b.cacheReadTokens),
|
|
354
|
+
cacheCreationTokens: Math.max(a.cacheCreationTokens, b.cacheCreationTokens),
|
|
355
|
+
cacheCreation1hTokens: Math.max(a.cacheCreation1hTokens ?? 0, b.cacheCreation1hTokens ?? 0),
|
|
356
|
+
reasoningTokens: Math.max(a.reasoningTokens ?? 0, b.reasoningTokens ?? 0),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Accumulates the metered usage of one STREAMING call across its chunks.
|
|
361
|
+
*
|
|
362
|
+
* Feed every chunk to `observe` as the host iterates the stream, then call
|
|
363
|
+
* `finalize` once the stream is exhausted (or closed) to get the single `Usage`
|
|
364
|
+
* for the whole call — or `undefined` when the stream never carried a usage block
|
|
365
|
+
* (e.g. an OpenAI stream opened without `include_usage`, where there is genuinely
|
|
366
|
+
* no count to meter and inventing one would be a lie). Provider-agnostic: the
|
|
367
|
+
* per-chunk read + max-merge handles OpenAI, Anthropic and Gemini streams alike.
|
|
368
|
+
*/
|
|
369
|
+
export class StreamUsageAccumulator {
|
|
370
|
+
provider;
|
|
371
|
+
model;
|
|
372
|
+
usage = undefined;
|
|
373
|
+
// Explicit assignment (not constructor parameter properties) so the source is
|
|
374
|
+
// erasable TypeScript — `node --experimental-strip-types` runs it with no build.
|
|
375
|
+
constructor(provider, model) {
|
|
376
|
+
this.provider = provider;
|
|
377
|
+
this.model = model;
|
|
378
|
+
}
|
|
379
|
+
/** Fold one chunk's usage (if any) into the running total. Never throws. */
|
|
380
|
+
observe(chunk) {
|
|
381
|
+
let u;
|
|
382
|
+
try {
|
|
383
|
+
u = usageFromStreamChunk(chunk, this.provider, this.model);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return; // a malformed chunk must never break the host's stream
|
|
387
|
+
}
|
|
388
|
+
if (u === undefined)
|
|
389
|
+
return;
|
|
390
|
+
this.usage = this.usage === undefined ? u : mergeStreamUsage(this.usage, u);
|
|
391
|
+
}
|
|
392
|
+
/** The accumulated usage, or `undefined` if the stream carried no usage. */
|
|
393
|
+
finalize() {
|
|
394
|
+
return this.usage;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
//# sourceMappingURL=providers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"providers.js","sourceRoot":"","sources":["../src/providers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SUBSTRATE a call travelled through to reach a model — the TypeScript twin
|
|
3
|
+
* of `margin_meter/substrate.py`.
|
|
4
|
+
*
|
|
5
|
+
* `provider` says who owns the model and who bills for it. `substrate` says which
|
|
6
|
+
* path the request actually took: the vendor's own API, a router like OpenRouter,
|
|
7
|
+
* a cloud reseller like Bedrock or Azure. The two are orthogonal, and comparing
|
|
8
|
+
* the same work across paths is the thing a single-substrate router structurally
|
|
9
|
+
* cannot do.
|
|
10
|
+
*
|
|
11
|
+
* Detection is EVIDENCE, not inference. A JS client carries the endpoint it will
|
|
12
|
+
* call (`baseURL` on the OpenAI and Anthropic SDKs), so an OpenAI client pointed
|
|
13
|
+
* at `openrouter.ai` is recorded as OpenRouter because that is literally where
|
|
14
|
+
* the request goes. Nothing is defaulted: a client whose endpoint cannot be read
|
|
15
|
+
* records NO path, which is the honest reading of an unmeasured dimension.
|
|
16
|
+
*
|
|
17
|
+
* Keep the host tables in step with the Python SDK and `margin/substrates.py`.
|
|
18
|
+
* Three copies of one rule is a real cost, and the alternative — a JS estate that
|
|
19
|
+
* cannot join the comparison at all — costs more.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DIRECT = "direct";
|
|
22
|
+
export declare const OPENROUTER = "openrouter";
|
|
23
|
+
export declare const LITELLM = "litellm";
|
|
24
|
+
export declare const AZURE = "azure";
|
|
25
|
+
export declare const BEDROCK = "bedrock";
|
|
26
|
+
export declare const VERTEX = "vertex";
|
|
27
|
+
export declare const GATEWAY = "gateway";
|
|
28
|
+
export type Substrate = "direct" | "openrouter" | "litellm" | "azure" | "bedrock" | "vertex" | "gateway";
|
|
29
|
+
/**
|
|
30
|
+
* Map a client's base URL to a substrate, or `undefined` if unreadable.
|
|
31
|
+
*
|
|
32
|
+
* Accepts anything stringable (the OpenAI SDK carries a string, others a `URL`).
|
|
33
|
+
* An unparseable value returns `undefined` rather than a default — no evidence,
|
|
34
|
+
* no claim. A host we cannot name reads `gateway`, which says the path was not
|
|
35
|
+
* the vendor's own without inventing a vendor for it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function fromBaseUrl(baseUrl: unknown): Substrate | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Detect the substrate from a live SDK client or one of its resources.
|
|
40
|
+
*
|
|
41
|
+
* The JS instrumenters patch a method on a specific client INSTANCE, so the
|
|
42
|
+
* endpoint is in hand at patch time — a process holding a vendor client and a
|
|
43
|
+
* router client instruments each separately and each reports correctly. Both
|
|
44
|
+
* `baseURL` (the JS SDKs' spelling) and `base_url` are read, and the resource's
|
|
45
|
+
* private `_client` is tried before the object itself so a patched
|
|
46
|
+
* `chat.completions` resolves as well as a bare client. Every lookup is
|
|
47
|
+
* defensive: a shape change in a vendor SDK must degrade to "no substrate
|
|
48
|
+
* recorded", never break the host's call.
|
|
49
|
+
*/
|
|
50
|
+
export declare function fromClient(obj: unknown): Substrate | undefined;
|
|
51
|
+
//# sourceMappingURL=substrate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"substrate.d.ts","sourceRoot":"","sources":["../src/substrate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,eAAO,MAAM,MAAM,WAAW,CAAC;AAC/B,eAAO,MAAM,UAAU,eAAe,CAAC;AACvC,eAAO,MAAM,OAAO,YAAY,CAAC;AACjC,eAAO,MAAM,KAAK,UAAU,CAAC;AAC7B,eAAO,MAAM,OAAO,YAAY,CAAC;AACjC,eAAO,MAAM,MAAM,WAAW,CAAC;AAC/B,eAAO,MAAM,OAAO,YAAY,CAAC;AAEjC,MAAM,MAAM,SAAS,GACjB,QAAQ,GACR,YAAY,GACZ,SAAS,GACT,OAAO,GACP,SAAS,GACT,QAAQ,GACR,SAAS,CAAC;AAoCd;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS,CAuBnE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS,CAgB9D"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SUBSTRATE a call travelled through to reach a model — the TypeScript twin
|
|
3
|
+
* of `margin_meter/substrate.py`.
|
|
4
|
+
*
|
|
5
|
+
* `provider` says who owns the model and who bills for it. `substrate` says which
|
|
6
|
+
* path the request actually took: the vendor's own API, a router like OpenRouter,
|
|
7
|
+
* a cloud reseller like Bedrock or Azure. The two are orthogonal, and comparing
|
|
8
|
+
* the same work across paths is the thing a single-substrate router structurally
|
|
9
|
+
* cannot do.
|
|
10
|
+
*
|
|
11
|
+
* Detection is EVIDENCE, not inference. A JS client carries the endpoint it will
|
|
12
|
+
* call (`baseURL` on the OpenAI and Anthropic SDKs), so an OpenAI client pointed
|
|
13
|
+
* at `openrouter.ai` is recorded as OpenRouter because that is literally where
|
|
14
|
+
* the request goes. Nothing is defaulted: a client whose endpoint cannot be read
|
|
15
|
+
* records NO path, which is the honest reading of an unmeasured dimension.
|
|
16
|
+
*
|
|
17
|
+
* Keep the host tables in step with the Python SDK and `margin/substrates.py`.
|
|
18
|
+
* Three copies of one rule is a real cost, and the alternative — a JS estate that
|
|
19
|
+
* cannot join the comparison at all — costs more.
|
|
20
|
+
*/
|
|
21
|
+
export const DIRECT = "direct";
|
|
22
|
+
export const OPENROUTER = "openrouter";
|
|
23
|
+
export const LITELLM = "litellm";
|
|
24
|
+
export const AZURE = "azure";
|
|
25
|
+
export const BEDROCK = "bedrock";
|
|
26
|
+
export const VERTEX = "vertex";
|
|
27
|
+
export const GATEWAY = "gateway";
|
|
28
|
+
/** The vendors' own API hosts. A base URL on one of these is the direct path. */
|
|
29
|
+
const DIRECT_HOSTS = [
|
|
30
|
+
"api.openai.com",
|
|
31
|
+
"api.anthropic.com",
|
|
32
|
+
"generativelanguage.googleapis.com",
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Host patterns that name a substrate outright, most specific first. The third
|
|
36
|
+
* element allows a REGIONAL PREFIX joined by a hyphen — Vertex serves from
|
|
37
|
+
* `us-central1-aiplatform.googleapis.com`, a sibling label rather than a
|
|
38
|
+
* subdomain. Granted per entry, and only where the vendor controls the parent
|
|
39
|
+
* domain end to end.
|
|
40
|
+
*/
|
|
41
|
+
const HOST_SUBSTRATES = [
|
|
42
|
+
["openrouter.ai", OPENROUTER, false],
|
|
43
|
+
["openai.azure.com", AZURE, false],
|
|
44
|
+
["azure.com", AZURE, false],
|
|
45
|
+
["amazonaws.com", BEDROCK, false],
|
|
46
|
+
["aiplatform.googleapis.com", VERTEX, true],
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* True when `host` IS `domain` or a subdomain of it — never a substring.
|
|
50
|
+
* `api.openrouter.ai` matches `openrouter.ai`; `openrouter.ai.evil.test` does
|
|
51
|
+
* not. Substring matching would let any host anyone can register be recorded as
|
|
52
|
+
* a trusted substrate, corrupting the exact comparison this dimension exists to
|
|
53
|
+
* make.
|
|
54
|
+
*/
|
|
55
|
+
function hostMatches(host, domain, regionalPrefix) {
|
|
56
|
+
if (host === domain || host.endsWith("." + domain))
|
|
57
|
+
return true;
|
|
58
|
+
return regionalPrefix && host.endsWith("-" + domain);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Map a client's base URL to a substrate, or `undefined` if unreadable.
|
|
62
|
+
*
|
|
63
|
+
* Accepts anything stringable (the OpenAI SDK carries a string, others a `URL`).
|
|
64
|
+
* An unparseable value returns `undefined` rather than a default — no evidence,
|
|
65
|
+
* no claim. A host we cannot name reads `gateway`, which says the path was not
|
|
66
|
+
* the vendor's own without inventing a vendor for it.
|
|
67
|
+
*/
|
|
68
|
+
export function fromBaseUrl(baseUrl) {
|
|
69
|
+
if (baseUrl == null)
|
|
70
|
+
return undefined;
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = String(baseUrl).trim();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
if (!raw)
|
|
79
|
+
return undefined;
|
|
80
|
+
let host;
|
|
81
|
+
try {
|
|
82
|
+
host = new URL(raw).hostname.toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
if (!host)
|
|
88
|
+
return undefined;
|
|
89
|
+
for (const [domain, name, regionalPrefix] of HOST_SUBSTRATES) {
|
|
90
|
+
if (hostMatches(host, domain, regionalPrefix))
|
|
91
|
+
return name;
|
|
92
|
+
}
|
|
93
|
+
for (const domain of DIRECT_HOSTS) {
|
|
94
|
+
if (hostMatches(host, domain, false))
|
|
95
|
+
return DIRECT;
|
|
96
|
+
}
|
|
97
|
+
return GATEWAY;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Detect the substrate from a live SDK client or one of its resources.
|
|
101
|
+
*
|
|
102
|
+
* The JS instrumenters patch a method on a specific client INSTANCE, so the
|
|
103
|
+
* endpoint is in hand at patch time — a process holding a vendor client and a
|
|
104
|
+
* router client instruments each separately and each reports correctly. Both
|
|
105
|
+
* `baseURL` (the JS SDKs' spelling) and `base_url` are read, and the resource's
|
|
106
|
+
* private `_client` is tried before the object itself so a patched
|
|
107
|
+
* `chat.completions` resolves as well as a bare client. Every lookup is
|
|
108
|
+
* defensive: a shape change in a vendor SDK must degrade to "no substrate
|
|
109
|
+
* recorded", never break the host's call.
|
|
110
|
+
*/
|
|
111
|
+
export function fromClient(obj) {
|
|
112
|
+
if (obj == null)
|
|
113
|
+
return undefined;
|
|
114
|
+
const o = obj;
|
|
115
|
+
for (const candidate of [o._client, o.client, o]) {
|
|
116
|
+
if (candidate == null)
|
|
117
|
+
continue;
|
|
118
|
+
let base;
|
|
119
|
+
try {
|
|
120
|
+
const c = candidate;
|
|
121
|
+
base = c.baseURL ?? c.base_url;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const found = fromBaseUrl(base);
|
|
127
|
+
if (found !== undefined)
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=substrate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"substrate.js","sourceRoot":"","sources":["../src/substrate.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "margin-meter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Tiny dependency-free client that emits LLM call + outcome economics to a Margin ingest API.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Tiny dependency-free client that auto-instruments OpenAI/Anthropic/Gemini and emits LLM call + outcome economics to a Margin ingest API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|