@tozil/sdk 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tozil
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,102 @@
1
+ # tozil
2
+
3
+ Know what every AI user costs you. Track AI costs per user, per model, per endpoint — with 2 lines of code.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install tozil
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import tozil from "tozil";
15
+
16
+ tozil.init(); // reads TOZIL_API_KEY from env
17
+ ```
18
+
19
+ That's it. Tozil automatically patches the Anthropic and OpenAI SDKs to track every API call — tokens, latency, model, and cost.
20
+
21
+ ## Setup
22
+
23
+ 1. Sign up at [tozil.dev](https://tozil.dev) and grab your API key
24
+ 2. Set your environment variable:
25
+
26
+ ```bash
27
+ TOZIL_API_KEY=tz_your_key_here
28
+ ```
29
+
30
+ 3. Call `tozil.init()` before creating any Anthropic/OpenAI clients:
31
+
32
+ ```typescript
33
+ import tozil from "tozil";
34
+ import Anthropic from "@anthropic-ai/sdk";
35
+
36
+ tozil.init();
37
+
38
+ const client = new Anthropic();
39
+ const response = await client.messages.create({
40
+ model: "claude-sonnet-4-20250514",
41
+ max_tokens: 1024,
42
+ messages: [{ role: "user", content: "Hello" }],
43
+ });
44
+ // ^ This call is automatically tracked
45
+ ```
46
+
47
+ ## Track Users & Endpoints
48
+
49
+ Use the Express middleware to automatically tag requests with user and endpoint info:
50
+
51
+ ```typescript
52
+ import tozil from "tozil";
53
+ import { tozilMiddleware } from "tozil/express";
54
+ import express from "express";
55
+
56
+ tozil.init();
57
+
58
+ const app = express();
59
+ app.use(tozilMiddleware({
60
+ getUserId: (req) => req.headers["x-user-id"] as string,
61
+ }));
62
+ ```
63
+
64
+ Or set context manually anywhere in your code:
65
+
66
+ ```typescript
67
+ import { withContext } from "tozil";
68
+
69
+ await withContext({ userId: "user_123", endpoint: "/chat" }, async () => {
70
+ // All AI calls inside here are tagged with this user & endpoint
71
+ await client.messages.create({ ... });
72
+ });
73
+ ```
74
+
75
+ ## Options
76
+
77
+ ```typescript
78
+ tozil.init({
79
+ apiKey: "tz_...", // default: process.env.TOZIL_API_KEY
80
+ baseUrl: "https://...", // default: https://app.tozil.dev/api/v1
81
+ flushInterval: 5000, // ms between flushes (default: 5000)
82
+ maxBatchSize: 100, // events per batch (default: 100)
83
+ debug: false, // log tracking events to console
84
+ });
85
+ ```
86
+
87
+ ## How It Works
88
+
89
+ - Monkey-patches Anthropic and OpenAI SDK prototypes at init time
90
+ - Uses Node.js AsyncLocalStorage for zero-config context propagation
91
+ - Buffers events in memory, flushes every 5s (or at batch size)
92
+ - Silent failure — never throws, never blocks your critical path
93
+ - Zero runtime dependencies
94
+
95
+ ## Requirements
96
+
97
+ - Node.js >= 18
98
+ - `@anthropic-ai/sdk` and/or `openai` as peer dependencies (install whichever you use)
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,4 @@
1
+ export declare function tozilMiddleware(options?: {
2
+ getUserId?: (req: any) => string | null;
3
+ }): (req: any, _res: any, next: any) => void;
4
+ export default tozilMiddleware;
@@ -0,0 +1,17 @@
1
+ import { runWithContext, setUser, setEndpoint } from "./index.js";
2
+ export function tozilMiddleware(options) {
3
+ return (req, _res, next) => {
4
+ runWithContext(() => {
5
+ // Set endpoint from request
6
+ setEndpoint(req.path || req.url);
7
+ // Set user ID if extractor provided, or from common patterns
8
+ const userId = options?.getUserId
9
+ ? options.getUserId(req)
10
+ : req.user?.id || req.user?.email || req.body?.user_id || null;
11
+ if (userId)
12
+ setUser(userId);
13
+ next();
14
+ });
15
+ };
16
+ }
17
+ export default tozilMiddleware;
@@ -0,0 +1,42 @@
1
+ export interface TozilEvent {
2
+ timestamp: string;
3
+ user_id: string | null;
4
+ model: string;
5
+ provider: "anthropic" | "openai" | "unknown";
6
+ endpoint: string | null;
7
+ input_tokens: number;
8
+ output_tokens: number;
9
+ latency_ms: number;
10
+ metadata: Record<string, string>;
11
+ }
12
+ export declare function init(options?: {
13
+ apiKey?: string;
14
+ baseUrl?: string;
15
+ flushInterval?: number;
16
+ maxBatchSize?: number;
17
+ debug?: boolean;
18
+ clients?: {
19
+ Anthropic?: any;
20
+ OpenAI?: any;
21
+ };
22
+ }): void;
23
+ export declare function setUser(userId: string): void;
24
+ export declare function setEndpoint(endpoint: string): void;
25
+ export declare function setMetadata(key: string, value: string): void;
26
+ export declare function withContext<T>(context: {
27
+ userId?: string;
28
+ endpoint?: string;
29
+ metadata?: Record<string, string>;
30
+ }, fn: () => T): T;
31
+ export declare function runWithContext<T>(fn: () => T): T;
32
+ export declare function shutdown(): void;
33
+ declare const _default: {
34
+ init: typeof init;
35
+ setUser: typeof setUser;
36
+ setEndpoint: typeof setEndpoint;
37
+ setMetadata: typeof setMetadata;
38
+ withContext: typeof withContext;
39
+ runWithContext: typeof runWithContext;
40
+ shutdown: typeof shutdown;
41
+ };
42
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,321 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createRequire } from "node:module";
3
+ // --- State ---
4
+ const als = new AsyncLocalStorage();
5
+ let config = null;
6
+ let eventBuffer = [];
7
+ let flushTimer = null;
8
+ let initialized = false;
9
+ // --- Auto-discovery ---
10
+ // Resolve an SDK module from the host app's node_modules (not tozil's own).
11
+ // Uses createRequire anchored at cwd so it finds the host app's dependencies.
12
+ // CJS require returns the CJS entry which shares the same prototype chain as
13
+ // the ESM import when the package uses conditional exports pointing both to
14
+ // the same underlying class. For packages where CJS and ESM are separate
15
+ // builds, we fall back to async import() from the resolved file path.
16
+ function autoDiscover(modName, patchFn) {
17
+ try {
18
+ const hostRequire = createRequire(process.cwd() + "/package.json");
19
+ // First try: resolve the path, then use import() to get the ESM version
20
+ // This ensures we patch the same prototype the host app uses
21
+ const resolved = hostRequire.resolve(modName);
22
+ import(/* @vite-ignore */ "file://" + resolved)
23
+ .then((m) => patchFn(m.default || m))
24
+ .catch(() => {
25
+ // Fallback: use the CJS module directly
26
+ try {
27
+ const mod = hostRequire(modName);
28
+ patchFn(mod.default || mod);
29
+ }
30
+ catch { }
31
+ });
32
+ }
33
+ catch {
34
+ if (config?.debug)
35
+ console.log(`[tozil] ${modName} not installed, skipping`);
36
+ }
37
+ }
38
+ // --- Public API ---
39
+ export function init(options) {
40
+ if (initialized)
41
+ return;
42
+ const apiKey = options?.apiKey || process.env.TOZIL_API_KEY;
43
+ if (!apiKey) {
44
+ if (options?.debug)
45
+ console.warn("[tozil] No API key found. Set TOZIL_API_KEY or pass apiKey option.");
46
+ return;
47
+ }
48
+ config = {
49
+ apiKey,
50
+ baseUrl: options?.baseUrl || process.env.TOZIL_BASE_URL || "https://app.tozil.dev/api/v1",
51
+ flushInterval: options?.flushInterval || 5000,
52
+ maxBatchSize: options?.maxBatchSize || 100,
53
+ debug: options?.debug || false,
54
+ };
55
+ // Patch explicitly passed clients
56
+ if (options?.clients?.Anthropic)
57
+ patchAnthropic(options.clients.Anthropic);
58
+ if (options?.clients?.OpenAI)
59
+ patchOpenAI(options.clients.OpenAI);
60
+ // Auto-discover SDKs if not explicitly passed
61
+ if (!options?.clients?.Anthropic)
62
+ autoDiscover("@anthropic-ai/sdk", patchAnthropic);
63
+ if (!options?.clients?.OpenAI)
64
+ autoDiscover("openai", patchOpenAI);
65
+ flushTimer = setInterval(() => flush(), config.flushInterval);
66
+ process.on("beforeExit", () => flush());
67
+ initialized = true;
68
+ if (config.debug)
69
+ console.log("[tozil] Initialized");
70
+ }
71
+ export function setUser(userId) {
72
+ const store = als.getStore();
73
+ if (store)
74
+ store.userId = userId;
75
+ }
76
+ export function setEndpoint(endpoint) {
77
+ const store = als.getStore();
78
+ if (store)
79
+ store.endpoint = endpoint;
80
+ }
81
+ export function setMetadata(key, value) {
82
+ const store = als.getStore();
83
+ if (store)
84
+ store.metadata[key] = value;
85
+ }
86
+ export function withContext(context, fn) {
87
+ return als.run({ userId: context.userId || null, endpoint: context.endpoint || null, metadata: context.metadata || {} }, fn);
88
+ }
89
+ export function runWithContext(fn) {
90
+ return als.run({ userId: null, endpoint: null, metadata: {} }, fn);
91
+ }
92
+ export function shutdown() {
93
+ if (flushTimer)
94
+ clearInterval(flushTimer);
95
+ flush();
96
+ initialized = false;
97
+ config = null;
98
+ }
99
+ // --- Event tracking ---
100
+ function trackEvent(event) {
101
+ if (!config)
102
+ return;
103
+ eventBuffer.push(event);
104
+ if (config.debug) {
105
+ console.log(`[tozil] ${event.provider}/${event.model} | user=${event.user_id} endpoint=${event.endpoint} | in=${event.input_tokens} out=${event.output_tokens} | ${event.latency_ms}ms`);
106
+ }
107
+ if (eventBuffer.length >= config.maxBatchSize)
108
+ flush();
109
+ }
110
+ async function flush() {
111
+ if (!config || eventBuffer.length === 0)
112
+ return;
113
+ const events = eventBuffer.splice(0);
114
+ try {
115
+ const res = await fetch(`${config.baseUrl}/events`, {
116
+ method: "POST",
117
+ headers: {
118
+ "Content-Type": "application/json",
119
+ Authorization: `Bearer ${config.apiKey}`,
120
+ },
121
+ body: JSON.stringify({ events }),
122
+ });
123
+ if (!res.ok && config.debug)
124
+ console.warn(`[tozil] Flush failed: ${res.status}`);
125
+ }
126
+ catch (e) {
127
+ if (config?.debug)
128
+ console.warn(`[tozil] Flush failed (silent):`, e.message);
129
+ }
130
+ }
131
+ // --- Context helper ---
132
+ function getContext() {
133
+ const store = als.getStore();
134
+ return {
135
+ userId: store?.userId || null,
136
+ endpoint: store?.endpoint || null,
137
+ metadata: store?.metadata || {},
138
+ };
139
+ }
140
+ // --- Monkey patching ---
141
+ // Strategy: create a temp instance to find the prototype, patch it there.
142
+ // This way all future instances are patched too.
143
+ function trackFromResult(provider, params, result, latency) {
144
+ try {
145
+ const ctx = getContext();
146
+ const isAnthropic = provider === "anthropic";
147
+ trackEvent({
148
+ timestamp: new Date().toISOString(),
149
+ user_id: ctx.userId,
150
+ model: params.model || result?.model || "unknown",
151
+ provider,
152
+ endpoint: ctx.endpoint,
153
+ input_tokens: isAnthropic ? (result?.usage?.input_tokens || 0) : (result?.usage?.prompt_tokens || 0),
154
+ output_tokens: isAnthropic ? (result?.usage?.output_tokens || 0) : (result?.usage?.completion_tokens || 0),
155
+ latency_ms: latency,
156
+ metadata: { ...ctx.metadata },
157
+ });
158
+ }
159
+ catch { }
160
+ }
161
+ function wrapStream(provider, params, stream, start) {
162
+ // Anthropic streams have a finalMessage or final usage event
163
+ // OpenAI streams have a .usage property after iteration
164
+ const origSymbol = Symbol.asyncIterator in stream ? Symbol.asyncIterator : null;
165
+ if (!origSymbol)
166
+ return stream;
167
+ let inputTokens = 0;
168
+ let outputTokens = 0;
169
+ const isAnthropic = provider === "anthropic";
170
+ const origIterator = stream[Symbol.asyncIterator].bind(stream);
171
+ stream[Symbol.asyncIterator] = async function* () {
172
+ for await (const chunk of origIterator()) {
173
+ // Anthropic: message_delta event contains usage
174
+ if (isAnthropic && chunk?.type === "message_start" && chunk?.message?.usage) {
175
+ inputTokens = chunk.message.usage.input_tokens || 0;
176
+ }
177
+ if (isAnthropic && chunk?.type === "message_delta" && chunk?.usage) {
178
+ outputTokens = chunk.usage.output_tokens || 0;
179
+ }
180
+ // OpenAI: last chunk sometimes has usage
181
+ if (!isAnthropic && chunk?.usage) {
182
+ inputTokens = chunk.usage.prompt_tokens || 0;
183
+ outputTokens = chunk.usage.completion_tokens || 0;
184
+ }
185
+ yield chunk;
186
+ }
187
+ // After stream ends, track the event
188
+ const latency = Date.now() - start;
189
+ try {
190
+ const ctx = getContext();
191
+ trackEvent({
192
+ timestamp: new Date().toISOString(),
193
+ user_id: ctx.userId,
194
+ model: params.model || "unknown",
195
+ provider,
196
+ endpoint: ctx.endpoint,
197
+ input_tokens: inputTokens,
198
+ output_tokens: outputTokens,
199
+ latency_ms: latency,
200
+ metadata: { ...ctx.metadata },
201
+ });
202
+ }
203
+ catch { }
204
+ // Anthropic streams have finalMessage — also check after iteration
205
+ if (isAnthropic && typeof stream.finalMessage === "function") {
206
+ try {
207
+ const msg = await stream.finalMessage();
208
+ if (msg?.usage && inputTokens === 0) {
209
+ trackFromResult(provider, params, msg, Date.now() - start);
210
+ }
211
+ }
212
+ catch { }
213
+ }
214
+ };
215
+ return stream;
216
+ }
217
+ function patchAnthropic(Client) {
218
+ try {
219
+ const temp = new Client({ apiKey: "tozil-patch-probe" });
220
+ const messagesProto = Object.getPrototypeOf(temp.messages);
221
+ if (messagesProto && typeof messagesProto.create === "function" && !messagesProto._tozilPatched) {
222
+ const origCreate = messagesProto.create;
223
+ messagesProto.create = async function (...args) {
224
+ const params = args[0] || {};
225
+ // Streaming: when stream is true, the SDK returns a stream object
226
+ if (params.stream) {
227
+ try {
228
+ const start = Date.now();
229
+ const stream = await origCreate.apply(this, args);
230
+ return wrapStream("anthropic", params, stream, start);
231
+ }
232
+ catch (e) {
233
+ if (e?.status || e?.error)
234
+ throw e;
235
+ return origCreate.apply(this, args);
236
+ }
237
+ }
238
+ try {
239
+ const start = Date.now();
240
+ const result = await origCreate.apply(this, args);
241
+ const latency = Date.now() - start;
242
+ trackFromResult("anthropic", params, result, latency);
243
+ return result;
244
+ }
245
+ catch (e) {
246
+ if (e?.status || e?.error)
247
+ throw e;
248
+ return origCreate.apply(this, args);
249
+ }
250
+ };
251
+ // Also patch .stream() if it exists (Anthropic SDK helper)
252
+ if (typeof messagesProto.stream === "function" && !messagesProto._tozilStreamPatched) {
253
+ const origStream = messagesProto.stream;
254
+ messagesProto.stream = async function (...args) {
255
+ try {
256
+ const start = Date.now();
257
+ const stream = await origStream.apply(this, args);
258
+ return wrapStream("anthropic", args[0] || {}, stream, start);
259
+ }
260
+ catch (e) {
261
+ if (e?.status || e?.error)
262
+ throw e;
263
+ return origStream.apply(this, args);
264
+ }
265
+ };
266
+ messagesProto._tozilStreamPatched = true;
267
+ }
268
+ messagesProto._tozilPatched = true;
269
+ if (config?.debug)
270
+ console.log("[tozil] Patched Anthropic SDK");
271
+ }
272
+ }
273
+ catch (e) {
274
+ if (config?.debug)
275
+ console.log("[tozil] Anthropic SDK not found, skipping");
276
+ }
277
+ }
278
+ function patchOpenAI(Client) {
279
+ try {
280
+ const temp = new Client({ apiKey: "tozil-patch-probe" });
281
+ const completionsProto = Object.getPrototypeOf(temp.chat.completions);
282
+ if (completionsProto && typeof completionsProto.create === "function" && !completionsProto._tozilPatched) {
283
+ const origCreate = completionsProto.create;
284
+ completionsProto.create = async function (...args) {
285
+ const params = args[0] || {};
286
+ if (params.stream) {
287
+ try {
288
+ const start = Date.now();
289
+ const stream = await origCreate.apply(this, args);
290
+ return wrapStream("openai", params, stream, start);
291
+ }
292
+ catch (e) {
293
+ if (e?.status || e?.error)
294
+ throw e;
295
+ return origCreate.apply(this, args);
296
+ }
297
+ }
298
+ try {
299
+ const start = Date.now();
300
+ const result = await origCreate.apply(this, args);
301
+ const latency = Date.now() - start;
302
+ trackFromResult("openai", params, result, latency);
303
+ return result;
304
+ }
305
+ catch (e) {
306
+ if (e?.status || e?.error)
307
+ throw e;
308
+ return origCreate.apply(this, args);
309
+ }
310
+ };
311
+ completionsProto._tozilPatched = true;
312
+ if (config?.debug)
313
+ console.log("[tozil] Patched OpenAI SDK");
314
+ }
315
+ }
316
+ catch (e) {
317
+ if (config?.debug)
318
+ console.log("[tozil] OpenAI SDK not found, skipping");
319
+ }
320
+ }
321
+ export default { init, setUser, setEndpoint, setMetadata, withContext, runWithContext, shutdown };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@tozil/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Know what every AI user costs you — track AI costs per user with 2 lines of code",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./express": {
14
+ "import": "./dist/express.js",
15
+ "types": "./dist/express.d.ts"
16
+ }
17
+ },
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "dev": "tsc --watch",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "files": ["dist"],
24
+ "keywords": ["ai", "llm", "cost", "tracking", "anthropic", "openai", "claude", "gpt", "observability"],
25
+ "license": "MIT",
26
+ "homepage": "https://tozil.dev",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/tozil-dev/tozil"
30
+ },
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.7.0",
36
+ "@types/node": "^22.0.0"
37
+ }
38
+ }