@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 +21 -0
- package/README.md +80 -0
- package/costmeter.d.ts +77 -0
- package/costmeter.js +587 -0
- package/package.json +52 -0
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
|
+
}
|