@tokensbill/node 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/index.js +206 -0
- package/package.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @tokensbill/node
|
|
2
|
+
|
|
3
|
+
**One line. Every AI token tracked.** Automatically captures Anthropic / OpenAI / Gemini token usage from your app's outgoing AI calls and streams it to your [TokensBill](https://tokensbill.com) dashboard — so you can see exactly where your AI spend goes.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @tokensbill/node
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use — one line at startup
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
require('@tokensbill/node').init('tl_live_your_project_key');
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Put it at the very top of your entry file (e.g. `server.js`), before your app starts. That's it — every AI API call your app makes is now tracked automatically.
|
|
18
|
+
|
|
19
|
+
Get your project key from **TokensBill → your project → Integration**.
|
|
20
|
+
|
|
21
|
+
## What it does
|
|
22
|
+
|
|
23
|
+
- Wraps global `fetch` (Node 18+, used by the Anthropic/OpenAI SDKs).
|
|
24
|
+
- Reads token counts and cost from the AI provider's response **without altering your call**.
|
|
25
|
+
- Sends the numbers to TokensBill in a background batch.
|
|
26
|
+
- **Silent-fail by design** — it never throws into, blocks, or slows your application. If TokensBill is unreachable, your app is completely unaffected.
|
|
27
|
+
|
|
28
|
+
## Is the key safe to commit?
|
|
29
|
+
|
|
30
|
+
The tracking key is a **label, not a password**. It cannot access your AI provider account, read your prompts, or spend money — it only lets your app report usage numbers to your dashboard. You can regenerate it any time from the dashboard.
|
|
31
|
+
|
|
32
|
+
## Options
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
require('@tokensbill/node').init('tl_live_...', {
|
|
36
|
+
environment: 'production', // tag events (production | staging | development)
|
|
37
|
+
ingestBaseUrl: 'https://tokensbill.aiappsjunction.com', // override the endpoint
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TokensBill Node SDK. One line at startup:
|
|
5
|
+
* require('@tokensbill/node').init('tl_live_abc123');
|
|
6
|
+
*
|
|
7
|
+
* Wraps global fetch (Node 18+, used by the Anthropic/OpenAI SDKs). The real call is never
|
|
8
|
+
* altered — we clone the response and read usage in the background. Silent-fail throughout:
|
|
9
|
+
* nothing the SDK does ever throws into, blocks, or slows the host application.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const AI_HOSTS = ['api.anthropic.com', 'api.openai.com', 'generativelanguage.googleapis.com'];
|
|
13
|
+
|
|
14
|
+
const state = {
|
|
15
|
+
initialized: false,
|
|
16
|
+
trackingId: '',
|
|
17
|
+
ingestBaseUrl: 'https://tokensbill.aiappsjunction.com',
|
|
18
|
+
environment: 'production',
|
|
19
|
+
batchSize: 100,
|
|
20
|
+
maxQueue: 500,
|
|
21
|
+
flushIntervalMs: 5000,
|
|
22
|
+
timeoutMs: 800,
|
|
23
|
+
captureSourceLocation: true,
|
|
24
|
+
queue: [],
|
|
25
|
+
timer: null,
|
|
26
|
+
originalFetch: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function init(trackingId, options = {}) {
|
|
30
|
+
try {
|
|
31
|
+
if (state.initialized) return;
|
|
32
|
+
state.trackingId = trackingId;
|
|
33
|
+
if (options.ingestBaseUrl) state.ingestBaseUrl = options.ingestBaseUrl;
|
|
34
|
+
if (options.environment) state.environment = options.environment;
|
|
35
|
+
if (options.batchSize) state.batchSize = options.batchSize;
|
|
36
|
+
if (options.maxQueue) state.maxQueue = options.maxQueue;
|
|
37
|
+
if (options.flushIntervalMs) state.flushIntervalMs = options.flushIntervalMs;
|
|
38
|
+
if (options.timeoutMs) state.timeoutMs = options.timeoutMs;
|
|
39
|
+
if (options.captureSourceLocation === false) state.captureSourceLocation = false;
|
|
40
|
+
|
|
41
|
+
patchFetch();
|
|
42
|
+
startFlushLoop();
|
|
43
|
+
state.initialized = true;
|
|
44
|
+
} catch {
|
|
45
|
+
/* silent — never break startup */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function patchFetch() {
|
|
50
|
+
if (typeof globalThis.fetch !== 'function') return;
|
|
51
|
+
const original = globalThis.fetch;
|
|
52
|
+
state.originalFetch = original;
|
|
53
|
+
|
|
54
|
+
globalThis.fetch = async function (input, opts) {
|
|
55
|
+
const started = Date.now();
|
|
56
|
+
const response = await original(input, opts); // real call — never wrapped
|
|
57
|
+
try {
|
|
58
|
+
handleResponse(urlOf(input), response, Date.now() - started);
|
|
59
|
+
} catch {
|
|
60
|
+
/* silent */
|
|
61
|
+
}
|
|
62
|
+
return response;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function urlOf(input) {
|
|
67
|
+
if (typeof input === 'string') return input;
|
|
68
|
+
if (input && typeof input === 'object') return input.url || String(input);
|
|
69
|
+
return String(input);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hostOf(url) {
|
|
73
|
+
try { return new URL(url).host; } catch { return ''; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isAiHost(host) {
|
|
77
|
+
return AI_HOSTS.some((h) => host === h);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Reads usage from an AI response in the background (clone → never consume the caller's body). */
|
|
81
|
+
function handleResponse(url, response, latencyMs) {
|
|
82
|
+
const host = hostOf(url);
|
|
83
|
+
if (!isAiHost(host) || !response) return;
|
|
84
|
+
|
|
85
|
+
const ct = (response.headers && response.headers.get && response.headers.get('content-type')) || '';
|
|
86
|
+
if (ct.includes('text/event-stream')) return; // don't touch streaming bodies
|
|
87
|
+
|
|
88
|
+
const clone = typeof response.clone === 'function' ? response.clone() : null;
|
|
89
|
+
if (!clone) return;
|
|
90
|
+
|
|
91
|
+
// Fire-and-forget: reading the clone never blocks or affects the caller.
|
|
92
|
+
clone.text()
|
|
93
|
+
.then((body) => ingest(host, url, body, latencyMs))
|
|
94
|
+
.catch(() => {});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ingest(host, url, body, latencyMs) {
|
|
98
|
+
try {
|
|
99
|
+
const usage = parseUsage(host, body);
|
|
100
|
+
if (!usage || (usage.inputTokens == null && usage.outputTokens == null)) return;
|
|
101
|
+
|
|
102
|
+
const loc = state.captureSourceLocation ? findCaller() : {};
|
|
103
|
+
enqueue({
|
|
104
|
+
provider: usage.provider,
|
|
105
|
+
model: usage.model,
|
|
106
|
+
source_file: loc.file || null,
|
|
107
|
+
source_method: loc.method || null,
|
|
108
|
+
http_endpoint: pathOf(url),
|
|
109
|
+
input_tokens: usage.inputTokens,
|
|
110
|
+
output_tokens: usage.outputTokens,
|
|
111
|
+
total_tokens: (usage.inputTokens || 0) + (usage.outputTokens || 0),
|
|
112
|
+
latency_ms: latencyMs,
|
|
113
|
+
finish_reason: usage.finishReason || null,
|
|
114
|
+
environment: state.environment,
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
/* silent */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function pathOf(url) {
|
|
123
|
+
try { return new URL(url).pathname; } catch { return null; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseUsage(host, body) {
|
|
127
|
+
let json;
|
|
128
|
+
try { json = JSON.parse(body); } catch { return null; }
|
|
129
|
+
|
|
130
|
+
if (host.includes('anthropic')) {
|
|
131
|
+
const u = json.usage || {};
|
|
132
|
+
return { provider: 'anthropic', model: json.model, inputTokens: num(u.input_tokens), outputTokens: num(u.output_tokens), finishReason: json.stop_reason };
|
|
133
|
+
}
|
|
134
|
+
if (host.includes('openai')) {
|
|
135
|
+
const u = json.usage || {};
|
|
136
|
+
const finish = json.choices && json.choices[0] && json.choices[0].finish_reason;
|
|
137
|
+
return { provider: 'openai', model: json.model, inputTokens: num(u.prompt_tokens), outputTokens: num(u.completion_tokens), finishReason: finish };
|
|
138
|
+
}
|
|
139
|
+
if (host.includes('googleapis')) {
|
|
140
|
+
const u = json.usageMetadata || {};
|
|
141
|
+
const finish = json.candidates && json.candidates[0] && json.candidates[0].finishReason;
|
|
142
|
+
return { provider: 'google', model: json.modelVersion, inputTokens: num(u.promptTokenCount), outputTokens: num(u.candidatesTokenCount), finishReason: finish };
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function num(v) { return typeof v === 'number' ? v : null; }
|
|
148
|
+
|
|
149
|
+
function findCaller() {
|
|
150
|
+
try {
|
|
151
|
+
const stack = (new Error().stack || '').split('\n').slice(1);
|
|
152
|
+
for (const line of stack) {
|
|
153
|
+
if (line.includes('tokenlens-node') || line.includes('node:internal') || line.includes('@tokenlens')) continue;
|
|
154
|
+
let m = line.match(/at\s+(.+?)\s+\((.*?):(\d+):(\d+)\)/);
|
|
155
|
+
if (m) return { method: m[1], file: baseName(m[2]) };
|
|
156
|
+
m = line.match(/at\s+(.*?):(\d+):(\d+)/);
|
|
157
|
+
if (m) return { method: null, file: baseName(m[1]) };
|
|
158
|
+
}
|
|
159
|
+
} catch { /* best effort */ }
|
|
160
|
+
return {};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function baseName(p) { return p.split(/[\\/]/).pop(); }
|
|
164
|
+
|
|
165
|
+
function enqueue(payload) {
|
|
166
|
+
state.queue.push(payload);
|
|
167
|
+
if (state.queue.length > state.maxQueue) state.queue.shift(); // drop oldest
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function startFlushLoop() {
|
|
171
|
+
state.timer = setInterval(() => { flush().catch(() => {}); }, state.flushIntervalMs);
|
|
172
|
+
if (state.timer.unref) state.timer.unref(); // don't keep the process alive
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function flush() {
|
|
176
|
+
if (state.queue.length === 0) return;
|
|
177
|
+
const batch = state.queue.splice(0, state.batchSize);
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
const t = setTimeout(() => controller.abort(), state.timeoutMs);
|
|
180
|
+
try {
|
|
181
|
+
const send = state.originalFetch || globalThis.fetch;
|
|
182
|
+
await send(state.ingestBaseUrl.replace(/\/$/, '') + '/api/ingest/batch', {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { 'Content-Type': 'application/json', 'X-TokenLens-Key': state.trackingId },
|
|
185
|
+
body: JSON.stringify(batch),
|
|
186
|
+
signal: controller.signal,
|
|
187
|
+
});
|
|
188
|
+
} catch {
|
|
189
|
+
/* timeout/failure → batch dropped, never retried */
|
|
190
|
+
} finally {
|
|
191
|
+
clearTimeout(t);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = {
|
|
196
|
+
init,
|
|
197
|
+
// exposed for tests only
|
|
198
|
+
_test: {
|
|
199
|
+
parseUsage,
|
|
200
|
+
enqueue,
|
|
201
|
+
flush,
|
|
202
|
+
handleResponse,
|
|
203
|
+
queue: () => state.queue,
|
|
204
|
+
state,
|
|
205
|
+
},
|
|
206
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tokensbill/node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One line. Every AI token tracked. Automatically captures Anthropic/OpenAI/Gemini token usage from outgoing fetch calls and streams it to TokensBill. Silent-fail by design.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"engines": { "node": ">=18" },
|
|
8
|
+
"files": ["index.js", "README.md"],
|
|
9
|
+
"keywords": ["ai", "tokens", "cost", "openai", "anthropic", "gemini", "observability", "tokensbill"],
|
|
10
|
+
"homepage": "https://tokensbill.com",
|
|
11
|
+
"repository": { "type": "git", "url": "https://github.com/oxtrys/tokensbill" },
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node test/sdk.test.js"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT"
|
|
16
|
+
}
|