@kalaa/node 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Francis Ssennoga
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # kalaa
2
+
3
+ Drop-in AI cost tracking for Node.js and TypeScript. Patches the OpenAI,
4
+ Anthropic, and Gemini client libraries at the class level, so every client
5
+ you create — before or after `observe()`, anywhere in your app — is already
6
+ instrumented. No wrapping, no manual logging per call.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @kalaa/node
12
+ ```
13
+
14
+ ## Setup
15
+
16
+ ```js
17
+ const kalaa = require('@kalaa/node');
18
+ kalaa.observe({ key: process.env.KALAA_KEY });
19
+ ```
20
+
21
+ Or in TypeScript / ESM:
22
+
23
+ ```ts
24
+ import kalaa from '@kalaa/node';
25
+ kalaa.observe({ key: process.env.KALAA_KEY });
26
+ ```
27
+
28
+ That's it. Put `KALAA_KEY=cm_live_xxx` in your `.env` and every OpenAI,
29
+ Anthropic, and Gemini call your app makes from here on is tracked
30
+ automatically — including the user who made the request, with zero extra
31
+ code, if you're running Express (route + user attribution wire themselves
32
+ in automatically).
33
+
34
+ ## Tagging a user manually
35
+
36
+ Automatic detection covers most setups (reads `x-user-id`, a decoded JWT,
37
+ or a client-side anonymous id). If yours doesn't fit that shape, tag it
38
+ explicitly:
39
+
40
+ ```js
41
+ kalaa.setUser(currentUser.id);
42
+ ```
43
+
44
+ ## Background jobs / cron
45
+
46
+ No request to hang a route off of, so give it a label instead:
47
+
48
+ ```js
49
+ kalaa.withContext('nightly-digest', () => {
50
+ runTheJob();
51
+ });
52
+ ```
53
+
54
+ ## What's tracked automatically
55
+
56
+ - OpenAI: chat completions, including streaming (`stream_options.include_usage`)
57
+ - Anthropic: messages, including streaming
58
+ - Gemini: `generateContent`
59
+ - Whisper/audio transcription duration (if `music-metadata` is installed)
60
+
61
+ ## Configuration
62
+
63
+ | Option | Env var | Default |
64
+ |---|---|---|
65
+ | `key` | `KALAA_KEY` | — |
66
+ | `endpoint` | `KALAA_ENDPOINT` | `https://api.kalaa.cc/api/ingest` |
67
+ | `env` | `KALAA_ENV` | `NODE_ENV` |
68
+ | `debug` | `KALAA_DEBUG=1` | `false` |
69
+
70
+ ## Failure behavior
71
+
72
+ This package never touches a database, never computes cost itself (your
73
+ Kalaa project prices every event server-side), and never sits in the path
74
+ of your AI calls. If the ingest endpoint is unreachable, events are
75
+ buffered and retried on the next flush cycle — your app is never blocked
76
+ or crashed by a tracking failure.
77
+
78
+ ## License
79
+
80
+ MIT
package/costmeter.d.ts ADDED
@@ -0,0 +1,77 @@
1
+ // Type declarations for kalaa — hand-written against the actual exported
2
+ // API surface (see costmeter.js), not generated.
3
+
4
+ export interface ObserveOptions {
5
+ /** Your project's ingest key. Defaults to process.env.KALAA_KEY. */
6
+ key?: string;
7
+ /** Ingest endpoint. Defaults to https://api.kalaa.cc/api/ingest. */
8
+ endpoint?: string;
9
+ /** How often buffered events flush, in ms. Default 3000. */
10
+ flushMs?: number;
11
+ /** Max events per batch before an early flush. Default 50. */
12
+ maxBatch?: number;
13
+ /** Environment label attached to every event. Defaults to NODE_ENV. */
14
+ env?: string;
15
+ /** Verbose console logging. Default false. */
16
+ debug?: boolean;
17
+ }
18
+
19
+ export interface TrackOptions {
20
+ step?: string;
21
+ provider?: 'openai' | 'anthropic' | 'gemini' | string;
22
+ model?: string;
23
+ usage?: Record<string, unknown>;
24
+ latencyMs?: number;
25
+ status?: 'ok' | 'error' | string;
26
+ meta?: Record<string, unknown> | null;
27
+ endUser?: string | null;
28
+ }
29
+
30
+ /**
31
+ * Start observing. Patches OpenAI/Anthropic/Gemini clients at the class
32
+ * level and, if Express is on the require path, auto-wires route
33
+ * attribution — no app.use() needed. Safe to call more than once; only
34
+ * the first call takes effect.
35
+ */
36
+ export function observe(opts?: ObserveOptions): typeof api;
37
+
38
+ /** Alias for observe(). */
39
+ export function init(opts?: ObserveOptions): typeof api;
40
+
41
+ /** Manually report a call — for raw-fetch usage (e.g. Gemini) or anything observe() didn't catch. */
42
+ export function track(opts: TrackOptions): void;
43
+
44
+ /** Tag the current request's AI calls with a real user id. Call this once you know who's asking. */
45
+ export function setUser(id: string | null): void;
46
+
47
+ /** Tag the current request's AI calls with a feature label. */
48
+ export function setFeature(feature: string | null): void;
49
+
50
+ /**
51
+ * Context manager for background jobs / cron — no request to hang a
52
+ * route off of, so you supply a label instead.
53
+ */
54
+ export function withContext<T>(label: string, fn: () => T): T;
55
+
56
+ /** Force an immediate flush of any buffered events. */
57
+ export function flush(): void;
58
+
59
+ /** Express middleware for manual route-attribution wiring, if you need it explicitly. */
60
+ export function middleware(req: unknown, res: unknown, next: () => void): void;
61
+
62
+ /** Manually instrument a specific OpenAI client instance. Rarely needed — observe() already patches the module. */
63
+ export function instrumentOpenAI(client: unknown): void;
64
+
65
+ declare const api: {
66
+ observe: typeof observe;
67
+ init: typeof init;
68
+ track: typeof track;
69
+ setUser: typeof setUser;
70
+ setFeature: typeof setFeature;
71
+ withContext: typeof withContext;
72
+ flush: typeof flush;
73
+ middleware: typeof middleware;
74
+ instrumentOpenAI: typeof instrumentOpenAI;
75
+ };
76
+
77
+ export default api;
package/costmeter.js ADDED
@@ -0,0 +1,587 @@
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
+ const { AsyncLocalStorage } = require('async_hooks');
22
+ const crypto = require('crypto');
23
+ const http = require('http');
24
+
25
+ const als = new AsyncLocalStorage();
26
+
27
+ // ── CONFIG ───────────────────────────────────────────────────────────
28
+ const config = {
29
+ key: process.env.KALAA_KEY || process.env.COSTMETER_KEY || null,
30
+ endpoint: process.env.KALAA_ENDPOINT || process.env.COSTMETER_ENDPOINT || 'https://api.kalaa.cc/api/ingest',
31
+ flushMs: 3000,
32
+ maxBatch: 50,
33
+ env: process.env.KALAA_ENV || process.env.COSTMETER_ENV || process.env.NODE_ENV || 'production',
34
+ debug: process.env.KALAA_DEBUG === '1' || process.env.COSTMETER_DEBUG === '1',
35
+ };
36
+
37
+ let buffer = [];
38
+ let timer = null;
39
+ let started = false;
40
+
41
+ function log(...a) { if (config.debug) console.log('[kalaa]', ...a); }
42
+
43
+ // ═══════════════════════════════════════════════════════════════════
44
+ // observe() — the one call that wires everything.
45
+ // ═══════════════════════════════════════════════════════════════════
46
+ function observe(opts = {}) {
47
+ Object.assign(config, opts);
48
+ if (started) return api;
49
+ started = true;
50
+
51
+ if (!config.key) {
52
+ console.warn('[kalaa] no KALAA_KEY set — tracking disabled. Add KALAA_KEY to your .env');
53
+ return api;
54
+ }
55
+
56
+ // 1. flush timer
57
+ if (!timer) {
58
+ timer = setInterval(flush, config.flushMs);
59
+ if (timer.unref) timer.unref();
60
+ }
61
+ process.once('beforeExit', flush);
62
+
63
+ // 2. auto-context: wrap the HTTP server so every request gets a run context
64
+ // with route attribution — no app.use needed.
65
+ patchHttpServer();
66
+
67
+ // 3. auto-instrument AI SDKs at the MODULE level (order-independent)
68
+ patchOpenAIModule();
69
+ patchAnthropicModule();
70
+ patchGeminiModule();
71
+
72
+ log('observing →', config.endpoint);
73
+ return api;
74
+ }
75
+
76
+ // ── OUTBOUND: batch + fire-and-forget POST ──────────────────────────
77
+ async function flush() {
78
+ if (!config.key || buffer.length === 0) return;
79
+ const batch = buffer;
80
+ buffer = [];
81
+ try {
82
+ const fetchFn = global.fetch || require('node-fetch');
83
+ await fetchFn(config.endpoint, {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/json', 'x-ingest-key': config.key },
86
+ body: JSON.stringify({ events: batch }),
87
+ });
88
+ log(`flushed ${batch.length} events`);
89
+ } catch (err) {
90
+ log('flush failed, dropping', batch.length, 'events:', err.message);
91
+ }
92
+ }
93
+
94
+ // ── CONTEXT ──────────────────────────────────────────────────────────
95
+ function ctx() {
96
+ return als.getStore() || { runId: null, route: 'background', endUser: null, feature: null };
97
+ }
98
+ const aliasedThisProcess = new Set(); // avoid re-sending the same anon→real merge repeatedly
99
+ function setUser(id) {
100
+ const s = als.getStore();
101
+ if (!s) return;
102
+ const prevAnon = s.endUser;
103
+ s.endUser = id;
104
+ // Upgrading a still-anonymous session to a real id — tell the server to
105
+ // merge that anon id's history into this real one. Once per pair per
106
+ // process; the merge itself is idempotent so duplicate sends are harmless.
107
+ if (id && prevAnon && String(prevAnon).startsWith('anon_') && !aliasedThisProcess.has(prevAnon)) {
108
+ aliasedThisProcess.add(prevAnon);
109
+ buffer.push({ type: 'alias', anon_id: prevAnon, real_id: String(id), ts: new Date().toISOString() });
110
+ }
111
+ }
112
+ function setFeature(f) { const s = als.getStore(); if (s) s.feature = f; }
113
+ function withContext(label, fn) {
114
+ return als.run({ runId: crypto.randomUUID(), route: label, endUser: null, feature: null }, fn);
115
+ }
116
+
117
+ // Reads a user id from common auth signals, no code change required on the
118
+ // integrator's side if they're already sending these — an X-User-Id header,
119
+ // or a JWT's sub/user_id/uid claim. We only decode the payload, never verify
120
+ // the signature — we're reading an identity hint, not authenticating anyone.
121
+ function detectUserFromHeaders(req) {
122
+ try {
123
+ const hdr = req.headers['x-user-id'] || req.headers['x-customer-id'];
124
+ if (hdr) return String(hdr);
125
+ const auth = req.headers['authorization'] || '';
126
+ const m = auth.match(/^Bearer\s+(.+)$/i);
127
+ if (m) {
128
+ const parts = m[1].split('.');
129
+ if (parts.length === 3) {
130
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8'));
131
+ const uid = payload.sub || payload.user_id || payload.uid || payload.id;
132
+ if (uid) return String(uid);
133
+ }
134
+ }
135
+ } catch (e) { /* not a JWT, or malformed — fall through to anonymous */ }
136
+ return null;
137
+ }
138
+ // Auto route-attribution: wrap http.Server request handling so each
139
+ // incoming request runs inside an ALS context tagged with its route.
140
+ function patchHttpServer() {
141
+ const origEmit = http.Server.prototype.emit;
142
+ if (origEmit.__cm_patched) return;
143
+ const patched = function (event, req, res) {
144
+ if (event !== 'request') return origEmit.apply(this, arguments);
145
+ const store = {
146
+ runId: crypto.randomUUID(),
147
+ route: `${req.method} ${(req.url || '').split('?')[0]}`,
148
+ endUser: detectUserFromHeaders(req) || getOrSetAnonId(req), // JWT/header id if present, else localStorage-backed client id, else untagged — setUser() still overrides any of these
149
+ feature: null,
150
+ };
151
+ // Re-enter the store on every event req/res emit later. Body chunks for
152
+ // large/multipart requests arrive in packets AFTER this synchronous chain
153
+ // returns — without this re-binding, body parsers (multer, express.json on
154
+ // big payloads) finish OUTSIDE the context, the route handler runs with no
155
+ // store, and every AI call in it lands untagged as route 'background'.
156
+ for (const target of [req, res]) {
157
+ const origTargetEmit = target.emit;
158
+ target.emit = function (...args) { return als.run(store, () => origTargetEmit.apply(this, args)); };
159
+ }
160
+ return als.run(store, () => origEmit.apply(this, arguments));
161
+ };
162
+ patched.__cm_patched = true;
163
+ http.Server.prototype.emit = patched;
164
+ log('http server patched for route attribution');
165
+ }
166
+
167
+ // Anonymous identity comes ONLY from a client-supplied id — set by
168
+ // costmeter-client.js, backed by localStorage on the customer's frontend,
169
+ // sent as a plain header (not a cookie, so no SameSite/third-party
170
+ // blocking applies). If it's missing — no frontend script yet, or a pure
171
+ // server-to-server call with no browser involved — we deliberately return
172
+ // null (untagged) rather than mint a disposable id. A random id that shows
173
+ // up once and never returns is worse than untagged: it's noise in the
174
+ // customer list instead of the real signal, which is "add the client
175
+ // script or call setUser() here."
176
+ const CM_ANON_HEADER = 'x-cm-anon-id';
177
+ function getOrSetAnonId(req) {
178
+ const clientId = req.headers[CM_ANON_HEADER];
179
+ if (clientId && /^[a-zA-Z0-9_-]{8,64}$/.test(String(clientId))) {
180
+ return `anon_${clientId}`;
181
+ }
182
+ return null;
183
+ }
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).
189
+ async function getRealAudioDuration(file) {
190
+ let mm;
191
+ try { mm = require('music-metadata'); } catch { return null; } // not installed — degrade below
192
+ try {
193
+ let metadata;
194
+ if (Buffer.isBuffer(file)) {
195
+ metadata = await mm.parseBuffer(file);
196
+ } else if (file?.path) {
197
+ metadata = await mm.parseFile(file.path);
198
+ } else if (file?.arrayBuffer) { // Blob/File
199
+ const buf = Buffer.from(await file.arrayBuffer());
200
+ metadata = await mm.parseBuffer(buf);
201
+ } else {
202
+ return null;
203
+ }
204
+ const seconds = metadata?.format?.duration;
205
+ return typeof seconds === 'number' && seconds > 0 ? Math.round(seconds) : null;
206
+ } catch (e) {
207
+ log('duration read failed:', e.message);
208
+ return null;
209
+ }
210
+ }
211
+
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) ──────────
216
+ function normalizeUsage(provider, usage = {}) {
217
+ if (provider === 'anthropic') {
218
+ return {
219
+ input: usage.input_tokens || 0,
220
+ cached: usage.cache_read_input_tokens || 0,
221
+ cacheWrite: usage.cache_creation_input_tokens || 0,
222
+ output: usage.output_tokens || 0,
223
+ seconds: 0,
224
+ };
225
+ }
226
+ if (provider === 'gemini') {
227
+ const cached = usage.cachedContentTokenCount || 0;
228
+ return {
229
+ input: Math.max((usage.promptTokenCount || 0) - cached, 0),
230
+ cached, cacheWrite: 0,
231
+ output: usage.candidatesTokenCount || 0,
232
+ seconds: 0,
233
+ };
234
+ }
235
+ const cached = usage.prompt_tokens_details?.cached_tokens || 0;
236
+ return {
237
+ input: Math.max((usage.prompt_tokens || usage.input_tokens || 0) - cached, 0),
238
+ cached, cacheWrite: 0,
239
+ output: usage.completion_tokens || usage.output_tokens || 0,
240
+ seconds: usage.seconds || 0,
241
+ };
242
+ }
243
+
244
+ function track({ step, provider = 'openai', model = '', usage = {}, latencyMs = 0, status = 'ok', meta = null, endUser = null }) {
245
+ try {
246
+ const c = ctx();
247
+ const u = normalizeUsage(provider, usage);
248
+ buffer.push({
249
+ ts: new Date().toISOString(),
250
+ run_id: c.runId, route: c.route, step: step || model,
251
+ provider, model,
252
+ input_tokens: u.input, cached_tokens: u.cached, cache_write_tokens: u.cacheWrite,
253
+ output_tokens: u.output, audio_seconds: u.seconds,
254
+ latency_ms: Math.round(latencyMs), status,
255
+ end_user: endUser || c.endUser || null, feature: c.feature || null,
256
+ environment: config.env, meta: meta || null,
257
+ });
258
+ log(`${c.route} → ${step || model} | ${u.input}f/${u.cached}c/${u.output}o`);
259
+ if (buffer.length >= config.maxBatch) flush();
260
+ } catch (err) { log('track error:', err.message); }
261
+ }
262
+
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.
266
+ function wrapStreamForUsage(streamPromise, { _step, _user, model, t0 }) {
267
+ return streamPromise.then(stream => {
268
+ const origIterator = stream[Symbol.asyncIterator].bind(stream);
269
+ let capturedUsage = null;
270
+ let lastModel = model;
271
+
272
+ stream[Symbol.asyncIterator] = function () {
273
+ const it = origIterator();
274
+ return {
275
+ async next() {
276
+ const result = await it.next();
277
+ if (!result.done && result.value) {
278
+ const chunk = result.value;
279
+ if (chunk.usage) capturedUsage = chunk.usage;
280
+ if (chunk.model) lastModel = chunk.model;
281
+ }
282
+ if (result.done) {
283
+ track({
284
+ step: _step, endUser: _user, provider: 'openai',
285
+ model: lastModel, usage: capturedUsage || {},
286
+ latencyMs: Date.now() - t0,
287
+ status: capturedUsage ? 'ok' : 'no_usage_streamed',
288
+ });
289
+ }
290
+ return result;
291
+ },
292
+ return(value) { return it.return ? it.return(value) : Promise.resolve({ value, done: true }); },
293
+ throw(err) { return it.throw ? it.throw(err) : Promise.reject(err); },
294
+ };
295
+ };
296
+
297
+ return stream;
298
+ }, err => {
299
+ track({ step: _step, endUser: _user, provider: 'openai', model, usage: {}, latencyMs: Date.now() - t0, status: 'error', meta: { error: String(err.message).slice(0, 200) } });
300
+ throw err;
301
+ });
302
+ }
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.
308
+ function wrapAnthropicStreamForUsage(stream, { _step, _user, model, t0 }) {
309
+ const origIterator = stream[Symbol.asyncIterator].bind(stream);
310
+ let inputTokens = 0, cachedTokens = 0, cacheWriteTokens = 0, outputTokens = 0;
311
+ let sawUsage = false;
312
+ let lastModel = model;
313
+
314
+ stream[Symbol.asyncIterator] = function () {
315
+ const it = origIterator();
316
+ return {
317
+ async next() {
318
+ const result = await it.next();
319
+ if (!result.done && result.value) {
320
+ const ev = result.value;
321
+ if (ev.type === 'message_start' && ev.message?.usage) {
322
+ sawUsage = true;
323
+ inputTokens = ev.message.usage.input_tokens || 0;
324
+ cachedTokens = ev.message.usage.cache_read_input_tokens || 0;
325
+ cacheWriteTokens = ev.message.usage.cache_creation_input_tokens || 0;
326
+ if (ev.message.model) lastModel = ev.message.model;
327
+ }
328
+ if (ev.type === 'message_delta' && ev.usage) {
329
+ sawUsage = true;
330
+ outputTokens = ev.usage.output_tokens || 0;
331
+ }
332
+ }
333
+ if (result.done) {
334
+ track({
335
+ step: _step, endUser: _user, provider: 'anthropic',
336
+ model: lastModel,
337
+ usage: {
338
+ input_tokens: inputTokens,
339
+ cache_read_input_tokens: cachedTokens,
340
+ cache_creation_input_tokens: cacheWriteTokens,
341
+ output_tokens: outputTokens,
342
+ },
343
+ latencyMs: Date.now() - t0,
344
+ status: sawUsage ? 'ok' : 'no_usage_streamed',
345
+ });
346
+ }
347
+ return result;
348
+ },
349
+ return(value) { return it.return ? it.return(value) : Promise.resolve({ value, done: true }); },
350
+ throw(err) { return it.throw ? it.throw(err) : Promise.reject(err); },
351
+ };
352
+ };
353
+
354
+ return stream;
355
+ }
356
+
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
+ function patchOpenAIModule() {
361
+ let OpenAIMod;
362
+ try { OpenAIMod = require('openai'); } catch { return; } // not installed, skip
363
+ const OpenAI = OpenAIMod.OpenAI || OpenAIMod.default || OpenAIMod;
364
+ if (!OpenAI || typeof OpenAI !== 'function') return;
365
+ if (OpenAI.__cm_patched) return;
366
+
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
+ let Completions, Transcriptions;
372
+ try {
373
+ const probe = new OpenAI({ apiKey: 'cm_probe' });
374
+ Completions = Object.getPrototypeOf(probe.chat.completions).constructor;
375
+ Transcriptions = Object.getPrototypeOf(probe.audio.transcriptions).constructor;
376
+ } catch (e) {
377
+ log('openai probe failed:', e.message);
378
+ return;
379
+ }
380
+
381
+ if (Completions?.prototype?.create && !Completions.prototype.create.__cm) {
382
+ const orig = Completions.prototype.create;
383
+ const wrapped = function (params, ...rest) {
384
+ const { _step, _user, ...clean } = params || {};
385
+ const t0 = Date.now();
386
+
387
+ if (clean.stream) {
388
+ // Ask for usage in the final chunk — safe even if caller already set stream_options.
389
+ const streamParams = {
390
+ ...clean,
391
+ stream_options: { ...(clean.stream_options || {}), include_usage: true },
392
+ };
393
+ const streamPromise = orig.call(this, streamParams, ...rest);
394
+ return wrapStreamForUsage(streamPromise, { _step, _user, model: clean.model, t0 });
395
+ }
396
+
397
+ return orig.call(this, clean, ...rest).then(resp => {
398
+ track({ step: _step, endUser: _user, provider: 'openai', model: resp.model || clean.model, usage: resp.usage || {}, latencyMs: Date.now() - t0 });
399
+ return resp;
400
+ }, err => {
401
+ track({ step: _step, endUser: _user, provider: 'openai', model: clean.model, usage: {}, latencyMs: Date.now() - t0, status: 'error', meta: { error: String(err.message).slice(0, 200) } });
402
+ throw err;
403
+ });
404
+ };
405
+ wrapped.__cm = true;
406
+ Completions.prototype.create = wrapped;
407
+ }
408
+
409
+ if (Transcriptions?.prototype?.create && !Transcriptions.prototype.create.__cm) {
410
+ const orig = Transcriptions.prototype.create;
411
+ const wrapped = async function (params, ...rest) {
412
+ const { _step, ...clean } = params || {};
413
+ const t0 = Date.now();
414
+ // Read real duration from the file header BEFORE the call — cheap,
415
+ // and doesn't depend on the caller's response_format choice.
416
+ const measuredSeconds = await getRealAudioDuration(clean.file);
417
+ const resp = await orig.call(this, clean, ...rest);
418
+ const seconds = resp.duration || measuredSeconds || 0;
419
+ const source = resp.duration ? 'api' : (measuredSeconds ? 'file_metadata' : 'none');
420
+ track({
421
+ step: _step || 'transcription', provider: 'openai',
422
+ model: clean.model || 'whisper-1', usage: { seconds },
423
+ latencyMs: Date.now() - t0,
424
+ status: seconds ? 'ok' : 'no_duration',
425
+ meta: { duration_source: source },
426
+ });
427
+ return resp;
428
+ };
429
+ wrapped.__cm = true;
430
+ Transcriptions.prototype.create = wrapped;
431
+ }
432
+
433
+ OpenAI.__cm_patched = true;
434
+ log('openai module patched (prototype-level)');
435
+ }
436
+
437
+ function patchAnthropicModule() {
438
+ let Mod;
439
+ try { Mod = require('@anthropic-ai/sdk'); } catch { return; }
440
+ const Anthropic = Mod.Anthropic || Mod.default || Mod;
441
+ if (!Anthropic || typeof Anthropic !== 'function') return;
442
+ if (Anthropic.__cm_patched) return;
443
+
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.
449
+ let Messages;
450
+ try {
451
+ const probe = new Anthropic({ apiKey: 'cm_probe' });
452
+ Messages = Object.getPrototypeOf(probe.messages).constructor;
453
+ } catch (e) {
454
+ log('anthropic probe failed:', e.message);
455
+ return;
456
+ }
457
+
458
+ if (Messages?.prototype?.create && !Messages.prototype.create.__cm) {
459
+ const orig = Messages.prototype.create;
460
+ const wrapped = async function (params, ...rest) {
461
+ const { _step, _user, ...clean } = params || {};
462
+ const t0 = Date.now();
463
+
464
+ if (clean.stream) {
465
+ let stream;
466
+ try {
467
+ stream = await orig.call(this, clean, ...rest);
468
+ } catch (err) {
469
+ track({ step: _step, endUser: _user, provider: 'anthropic', model: clean.model, usage: {}, latencyMs: Date.now() - t0, status: 'error', meta: { error: String(err.message).slice(0, 200) } });
470
+ throw err;
471
+ }
472
+ return wrapAnthropicStreamForUsage(stream, { _step, _user, model: clean.model, t0 });
473
+ }
474
+
475
+ try {
476
+ const resp = await orig.call(this, clean, ...rest);
477
+ track({ step: _step, endUser: _user, provider: 'anthropic', model: resp.model || clean.model, usage: resp.usage || {}, latencyMs: Date.now() - t0 });
478
+ return resp;
479
+ } catch (err) {
480
+ track({ step: _step, endUser: _user, provider: 'anthropic', model: clean.model, usage: {}, latencyMs: Date.now() - t0, status: 'error', meta: { error: String(err.message).slice(0, 200) } });
481
+ throw err;
482
+ }
483
+ };
484
+ wrapped.__cm = true;
485
+ Messages.prototype.create = wrapped;
486
+ }
487
+
488
+ Anthropic.__cm_patched = true;
489
+ log('anthropic module patched (prototype-level)');
490
+ }
491
+
492
+ function patchGeminiModule() {
493
+ let Mod;
494
+ try { Mod = require('@google/generative-ai'); } catch { return; } // not installed, skip
495
+ const GoogleGenerativeAI = Mod.GoogleGenerativeAI;
496
+ if (!GoogleGenerativeAI || GoogleGenerativeAI.__cm_patched) return;
497
+
498
+ // Same trick as OpenAI: probe an instance to reach the GenerativeModel
499
+ // class prototype, patch it once, every model instance inherits it.
500
+ let GenerativeModel;
501
+ try {
502
+ const probe = new GoogleGenerativeAI('cm_probe');
503
+ const model = probe.getGenerativeModel({ model: 'gemini-1.5-flash' });
504
+ GenerativeModel = Object.getPrototypeOf(model).constructor;
505
+ } catch (e) {
506
+ log('gemini probe failed:', e.message);
507
+ return;
508
+ }
509
+
510
+ if (GenerativeModel?.prototype?.generateContent && !GenerativeModel.prototype.generateContent.__cm) {
511
+ const orig = GenerativeModel.prototype.generateContent;
512
+ const wrapped = async function (params, ...rest) {
513
+ const t0 = Date.now();
514
+ const modelName = (this.model || 'gemini').replace(/^models\//, '');
515
+ // Gemini params can be a plain object, a string, or an array — only
516
+ // strip _user when it's actually a plain request object carrying one.
517
+ let _user, clean = params;
518
+ if (params && typeof params === 'object' && !Array.isArray(params) && '_user' in params) {
519
+ ({ _user, ...clean } = params);
520
+ }
521
+ try {
522
+ const resp = await orig.call(this, clean, ...rest);
523
+ const usage = resp?.response?.usageMetadata || {};
524
+ track({ provider: 'gemini', model: modelName, usage, latencyMs: Date.now() - t0, endUser: _user });
525
+ return resp;
526
+ } catch (err) {
527
+ track({ provider: 'gemini', model: modelName, usage: {}, latencyMs: Date.now() - t0, status: 'error', meta: { error: String(err.message).slice(0, 200) }, endUser: _user });
528
+ throw err;
529
+ }
530
+ };
531
+ wrapped.__cm = true;
532
+ GenerativeModel.prototype.generateContent = wrapped;
533
+ }
534
+
535
+ // generateContentStream: usage arrives only after the stream is fully
536
+ // consumed, via response.usageMetadata on the final resolved response
537
+ // object the SDK exposes — same tap pattern as OpenAI/Anthropic streaming.
538
+ if (GenerativeModel?.prototype?.generateContentStream && !GenerativeModel.prototype.generateContentStream.__cm) {
539
+ const orig = GenerativeModel.prototype.generateContentStream;
540
+ const wrapped = async function (params, ...rest) {
541
+ const t0 = Date.now();
542
+ const modelName = (this.model || 'gemini').replace(/^models\//, '');
543
+ let _user, clean = params;
544
+ if (params && typeof params === 'object' && !Array.isArray(params) && '_user' in params) {
545
+ ({ _user, ...clean } = params);
546
+ }
547
+ try {
548
+ const result = await orig.call(this, clean, ...rest);
549
+ result.response.then(finalResp => {
550
+ const usage = finalResp?.usageMetadata || {};
551
+ track({ provider: 'gemini', model: modelName, usage, latencyMs: Date.now() - t0, status: Object.keys(usage).length ? 'ok' : 'no_usage_streamed', endUser: _user });
552
+ }).catch(() => {});
553
+ return result;
554
+ } catch (err) {
555
+ track({ provider: 'gemini', model: modelName, usage: {}, latencyMs: Date.now() - t0, status: 'error', meta: { error: String(err.message).slice(0, 200) }, endUser: _user });
556
+ throw err;
557
+ }
558
+ };
559
+ wrapped.__cm = true;
560
+ GenerativeModel.prototype.generateContentStream = wrapped;
561
+ }
562
+
563
+ GoogleGenerativeAI.__cm_patched = true;
564
+ log('gemini module patched (prototype-level)');
565
+ }
566
+
567
+ // ── PUBLIC API ───────────────────────────────────────────────────────
568
+ // observe() is the headline. The older explicit helpers stay available
569
+ // for advanced users who want manual control (e.g. Gemini via raw fetch).
570
+ const api = {
571
+ observe,
572
+ track, // manual: for raw-fetch calls (Gemini, custom)
573
+ setUser, setFeature, // per-request tagging
574
+ withContext, // background jobs / cron
575
+ flush,
576
+ // legacy explicit helpers (still work; observe() makes them unnecessary)
577
+ init: observe,
578
+ middleware: (req, res, next) => {
579
+ als.run({ runId: crypto.randomUUID(), route: `${req.method} ${req.path}`, endUser: null, feature: null }, next);
580
+ },
581
+ instrumentOpenAI: (client) => {
582
+ // manual path — wrap a specific instance
583
+ patchOpenAIModule();
584
+ },
585
+ };
586
+
587
+ module.exports = api;
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@kalaa/node",
3
+ "version": "1.0.0",
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
+ "main": "costmeter.js",
6
+ "types": "costmeter.d.ts",
7
+ "files": [
8
+ "costmeter.js",
9
+ "costmeter.d.ts",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "engines": {
14
+ "node": ">=16"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "llm",
19
+ "cost-tracking",
20
+ "openai",
21
+ "anthropic",
22
+ "gemini",
23
+ "observability",
24
+ "monitoring",
25
+ "express",
26
+ "middleware"
27
+ ],
28
+ "author": "",
29
+ "license": "MIT",
30
+ "homepage": "https://kalaa.cc",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/YOUR_ORG/kalaa-node.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/YOUR_ORG/kalaa-node/issues"
37
+ },
38
+ "dependencies": {
39
+ "music-metadata": "^7.0.0",
40
+ "node-fetch": "^2.7.0"
41
+ },
42
+ "peerDependencies": {
43
+ "openai": ">=4",
44
+ "@anthropic-ai/sdk": ">=0.20",
45
+ "@google/generative-ai": ">=0.10"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "openai": { "optional": true },
49
+ "@anthropic-ai/sdk": { "optional": true },
50
+ "@google/generative-ai": { "optional": true }
51
+ }
52
+ }