@kalaa/node 1.0.3 → 1.0.5

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.
Files changed (2) hide show
  1. package/costmeter.js +48 -60
  2. package/package.json +1 -1
package/costmeter.js CHANGED
@@ -1,26 +1,8 @@
1
- // ═══════════════════════════════════════════════════════════════════
2
- // costmeter.js — drop-in AI cost tracking SDK
3
- //
4
- // Two-line setup, paste anywhere, order doesn't matter:
5
- //
6
- // const costmeter = require('costmeter');
7
- // costmeter.observe();
8
- //
9
- // Put COSTMETER_KEY=cm_live_xxx in your .env and you're done.
10
- //
11
- // observe() patches the OpenAI module at the class level (so any client
12
- // you create, whenever you create it, is already instrumented) and, if
13
- // Express is installed, auto-inserts route-attribution middleware. No
14
- // app.use, no "after express.json", no manual instrument call.
15
- //
16
- // It never touches a database, never computes cost, and never sits in
17
- // the path of your AI calls — if ingest is unreachable, events are
18
- // dropped silently and your app is unaffected.
19
- // ═══════════════════════════════════════════════════════════════════
20
-
21
1
  const { AsyncLocalStorage } = require('async_hooks');
22
2
  const crypto = require('crypto');
23
3
  const http = require('http');
4
+ const { Worker } = require('worker_threads');
5
+ const path = require('path');
24
6
 
25
7
  const als = new AsyncLocalStorage();
26
8
 
@@ -181,38 +163,58 @@ function getOrSetAnonId(req) {
181
163
  }
182
164
  return null;
183
165
  }
184
- // Best-effort byte size of the audio input, without reading the whole file.
185
- // Real duration from the audio container's own metadata (wav/mp3/m4a/webm all
186
- // expose this in their header — no decoding, no guessing). This is the
187
- // actual value Whisper bills against, just read client-side instead of
188
- // waiting for the API to hand it back (which it only does with verbose_json).
166
+
189
167
  async function getRealAudioDuration(file) {
190
- let mm;
191
- try { mm = require('music-metadata'); } catch { return null; } // not installed — degrade below
168
+ let input;
192
169
  try {
193
- let metadata;
194
170
  if (Buffer.isBuffer(file)) {
195
- metadata = await mm.parseBuffer(file);
171
+ input = { type: 'buffer', data: file };
196
172
  } else if (file?.path) {
197
- metadata = await mm.parseFile(file.path);
173
+ input = { type: 'path', data: file.path };
198
174
  } else if (file?.arrayBuffer) { // Blob/File
199
- const buf = Buffer.from(await file.arrayBuffer());
200
- metadata = await mm.parseBuffer(buf);
175
+ input = { type: 'buffer', data: Buffer.from(await file.arrayBuffer()) };
201
176
  } else {
202
177
  return null;
203
178
  }
204
- const seconds = metadata?.format?.duration;
205
- return typeof seconds === 'number' && seconds > 0 ? Math.round(seconds) : null;
206
179
  } catch (e) {
207
180
  log('duration read failed:', e.message);
208
181
  return null;
209
182
  }
183
+
184
+
185
+ return new Promise((resolve) => {
186
+ let settled = false;
187
+ let worker;
188
+ try {
189
+ worker = new Worker(path.join(__dirname, 'duration-worker.js'), { workerData: input });
190
+ } catch (e) {
191
+ log('duration worker spawn failed:', e.message);
192
+ return resolve(null);
193
+ }
194
+ const timer = setTimeout(() => {
195
+ if (settled) return;
196
+ settled = true;
197
+ worker.terminate();
198
+ log('duration worker timed out — likely a malformed file, degrading to null');
199
+ resolve(null);
200
+ }, 3000);
201
+ worker.once('message', (msg) => {
202
+ if (settled) return;
203
+ settled = true;
204
+ clearTimeout(timer);
205
+ worker.terminate();
206
+ resolve(msg?.seconds ?? null);
207
+ });
208
+ worker.once('error', () => {
209
+ if (settled) return;
210
+ settled = true;
211
+ clearTimeout(timer);
212
+ resolve(null);
213
+ });
214
+ });
210
215
  }
211
216
 
212
- // Rough seconds-from-bytes estimate. Whisper input is typically compressed
213
- // (mp3/m4a) around ~128kbps voice audio ≈ 16KB/sec. This is intentionally
214
- // conservative — good enough for cost visibility, not billing-grade exact.
215
- // ── USAGE NORMALIZATION (raw tokens only — backend prices) ──────────
217
+
216
218
  function normalizeUsage(provider, usage = {}) {
217
219
  if (provider === 'anthropic') {
218
220
  return {
@@ -260,9 +262,7 @@ function track({ step, provider = 'openai', model = '', usage = {}, latencyMs =
260
262
  } catch (err) { log('track error:', err.message); }
261
263
  }
262
264
 
263
- // Wraps a streaming completion so usage is captured on completion, without
264
- // altering what the caller receives — every chunk still flows through
265
- // exactly as OpenAI sent it.
265
+
266
266
  function wrapStreamForUsage(streamPromise, { _step, _user, model, t0 }) {
267
267
  return streamPromise.then(stream => {
268
268
  const origIterator = stream[Symbol.asyncIterator].bind(stream);
@@ -301,10 +301,7 @@ function wrapStreamForUsage(streamPromise, { _step, _user, model, t0 }) {
301
301
  });
302
302
  }
303
303
 
304
- // Anthropic streams usage across two events, not one: message_start carries
305
- // input_tokens, message_delta carries the final output_tokens. Same tap
306
- // pattern as OpenAI — caller's iteration is untouched, we just watch what
307
- // passes through.
304
+
308
305
  function wrapAnthropicStreamForUsage(stream, { _step, _user, model, t0 }) {
309
306
  const origIterator = stream[Symbol.asyncIterator].bind(stream);
310
307
  let inputTokens = 0, cachedTokens = 0, cacheWriteTokens = 0, outputTokens = 0;
@@ -354,20 +351,15 @@ function wrapAnthropicStreamForUsage(stream, { _step, _user, model, t0 }) {
354
351
  return stream;
355
352
  }
356
353
 
357
- // ── MODULE-LEVEL PATCHING (order-independent instrumentation) ───────
358
- // Patches the OpenAI class prototype so EVERY client — created before or
359
- // after observe() — is instrumented. No need to hand us the client.
360
354
  function patchOpenAIModule() {
361
355
  let OpenAIMod;
362
- try { OpenAIMod = require('openai'); } catch { return; } // not installed, skip
356
+ try { OpenAIMod = require('openai'); } catch (e) { log('openai require() failed:', e.message); return; }
357
+ log('openai require() succeeded, module keys:', Object.keys(OpenAIMod).slice(0, 10).join(','));
363
358
  const OpenAI = OpenAIMod.OpenAI || OpenAIMod.default || OpenAIMod;
364
- if (!OpenAI || typeof OpenAI !== 'function') return;
365
- if (OpenAI.__cm_patched) return;
359
+ if (!OpenAI || typeof OpenAI !== 'function') { log('openai class did not resolve to a function, typeof:', typeof OpenAI); return; }
360
+ if (OpenAI.__cm_patched) { log('openai already patched, skipping'); return; }
361
+
366
362
 
367
- // The create() methods live on the Completions / Transcriptions class
368
- // PROTOTYPES, not on each instance. Patch the prototype ONCE and every
369
- // client — created before or after observe() — inherits the wrapper.
370
- // We spin up a throwaway client (no network) just to reach those classes.
371
363
  let Completions, Transcriptions;
372
364
  try {
373
365
  const probe = new OpenAI({ apiKey: 'cm_probe' });
@@ -441,11 +433,7 @@ function patchAnthropicModule() {
441
433
  if (!Anthropic || typeof Anthropic !== 'function') return;
442
434
  if (Anthropic.__cm_patched) return;
443
435
 
444
- // Same strategy as OpenAI: messages.create() lives on the Messages class
445
- // PROTOTYPE. Patch it once and every client — constructed before or after
446
- // observe(), via any import style — inherits the wrapper. No constructor
447
- // swapping: the SDK's module exports are non-writable, so reassigning
448
- // Mod.Anthropic silently does nothing.
436
+
449
437
  let Messages;
450
438
  try {
451
439
  const probe = new Anthropic({ apiKey: 'cm_probe' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kalaa/node",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Drop-in AI cost tracking for Node.js and TypeScript. Auto-instruments OpenAI, Anthropic, and Gemini at the class level, with zero code at each call site.",
5
5
  "main": "costmeter.js",
6
6
  "types": "costmeter.d.ts",