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.
@@ -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.1.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",