@cockpitify/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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cockpitify
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,65 @@
1
+ # @cockpitify/node
2
+
3
+ Records each user's AI token usage to `ops.usage` in your own Supabase project. Wrap your AI client once per request; the wrapped client behaves exactly like the original.
4
+
5
+ ```ts
6
+ import OpenAI from 'npm:openai';
7
+ import { Ops } from 'npm:@cockpitify/node';
8
+
9
+ const ai = Ops.wrap(new OpenAI({ baseURL: 'https://api.deepseek.com', apiKey }), {
10
+ userId: user.id,
11
+ supabase, // service-role client; writes to ops.usage
12
+ });
13
+ ```
14
+
15
+ ## Supported clients
16
+
17
+ - **OpenAI SDK and compatibles** (DeepSeek, Groq, OpenRouter via `baseURL`)
18
+ - `chat.completions.create` (streaming or not)
19
+ - `chat.completions.stream()`
20
+ - `responses.create`
21
+ - **Anthropic SDK**
22
+ - `messages.create` (streaming or not)
23
+ - `messages.stream()`
24
+ - the same two under `beta.messages`
25
+ - **Raw HTTP:** `Ops.wrapFetch(fetch, opts)`. Handles OpenAI-compatible chat, Responses and Anthropic, both JSON and SSE.
26
+ - **Vercel AI SDK:** `wrapLanguageModel({ model, middleware: Ops.middleware(opts) })`.
27
+
28
+ ## Calls not measured in tokens
29
+
30
+ Speech-to-text, text-to-speech or image calls are recorded by hand (ops 0.17.0+):
31
+
32
+ ```ts
33
+ Ops.record({ userId, supabase, provider: 'groq', model: 'whisper-large-v3', units: 742, unit: 'seconds' });
34
+ Ops.record({ userId, supabase, provider: 'elevenlabs', model: 'eleven_multilingual_v2', units: 48000, unit: 'characters', costUsd: 0.36 });
35
+ ```
36
+
37
+ Without `costUsd` the row stays unpriced: ops shows the amount, not a guessed cost.
38
+
39
+ ## Streaming
40
+
41
+ Streaming is fully supported, and chunks reach your code unchanged and without delay.
42
+
43
+ - **OpenAI-compatible streams** only report usage when `stream_options.include_usage` is set, so it is added for you.
44
+ - The extra usage-only chunk it produces is hidden from your code, unless you asked for it yourself.
45
+ - Groq is the exception: it reports usage on its own, so its requests are left untouched.
46
+ - **If a stream is cut** (user left, `break`, abort, network error), there is no usage report from the provider. Tokens are then estimated from the prompt and the text seen so far, and the row is stored with `estimated = true`.
47
+
48
+ ## Guarantees
49
+
50
+ - **Fail-open.** A recording failure never throws into your code or slows the AI call. It goes to `onError`, which by default logs one warning.
51
+ - **Writes outlive the response.** On Supabase Edge Functions, writes run through `EdgeRuntime.waitUntil`. Elsewhere, call `await Ops.flush()` before the process exits.
52
+ - **Setup mistakes fail loudly.** A missing `userId`, or neither `supabase` nor `sink` given, throws at wrap time.
53
+
54
+ ## Options
55
+
56
+ | Option | |
57
+ |---|---|
58
+ | `userId` | Required. The end user to attribute usage to. |
59
+ | `supabase` / `sink` | Where rows go: `ops.usage` through a supabase-js client, or your own `(row) => Promise`. |
60
+ | `feature` | Optional tag, e.g. `'chat'`, `'transcribe'`. |
61
+ | `prices` | USD per 1M tokens per model (prefix match). Without it `cost_usd` is null and the database prices the row. |
62
+ | `provider` | Overrides the provider inferred from `baseURL` / URL. |
63
+ | `injectStreamUsage` | Set `false` to never touch `stream_options`. |
64
+ | `onError` | Receives recording errors. |
65
+
@@ -0,0 +1,18 @@
1
+ export declare class UsageAccumulator {
2
+ model: string;
3
+ input: number;
4
+ output: number;
5
+ cached: number;
6
+ cacheWrite: number;
7
+ /** Provider reported its final usage numbers. */
8
+ final: boolean;
9
+ /** Generated text seen so far, used to estimate output tokens when `final` never arrives. */
10
+ text: string;
11
+ feed(ev: unknown): void;
12
+ private setOpenAI;
13
+ private setAnthropic;
14
+ }
15
+ /** Rough token count (~4 characters per token). Only used when the provider never reports usage. */
16
+ export declare function estimateTokens(text: string): number;
17
+ /** The prompt part of a request body, as text, for estimating input tokens. */
18
+ export declare function promptText(params: unknown): string;
@@ -0,0 +1,128 @@
1
+ // Reads usage out of every response and stream-event shape we support, so each wrapper
2
+ // (OpenAI SDK, Anthropic SDK, raw fetch) only has to feed it objects.
3
+ //
4
+ // OpenAI-compatible chat (OpenAI, DeepSeek, OpenRouter): `usage` on the final chunk, only when
5
+ // `stream_options.include_usage` is set; that chunk has `choices: []`.
6
+ // Groq: `x_groq.usage` on the final chunk.
7
+ // OpenAI Responses: `response.completed` event carries `response.usage`.
8
+ // Anthropic: `message_start` has input usage, `message_delta` has cumulative output usage.
9
+ export class UsageAccumulator {
10
+ model = '';
11
+ input = 0;
12
+ output = 0;
13
+ cached = 0;
14
+ cacheWrite = 0;
15
+ /** Provider reported its final usage numbers. */
16
+ final = false;
17
+ /** Generated text seen so far, used to estimate output tokens when `final` never arrives. */
18
+ text = '';
19
+ feed(ev) {
20
+ if (!ev || typeof ev !== 'object')
21
+ return;
22
+ const e = ev;
23
+ if (typeof e.model === 'string' && !this.model)
24
+ this.model = e.model;
25
+ // OpenAI-compatible chat completion (whole response or stream chunk).
26
+ if (Array.isArray(e.choices)) {
27
+ for (const c of e.choices) {
28
+ const d = c?.delta ?? c?.message;
29
+ if (typeof d?.content === 'string')
30
+ this.text += d.content;
31
+ if (Array.isArray(d?.tool_calls)) {
32
+ for (const t of d.tool_calls)
33
+ if (typeof t?.function?.arguments === 'string')
34
+ this.text += t.function.arguments;
35
+ }
36
+ }
37
+ const u = e.usage ?? e.x_groq?.usage;
38
+ if (u)
39
+ this.setOpenAI(u);
40
+ return;
41
+ }
42
+ // OpenAI Responses, non-streaming.
43
+ if (e.object === 'response') {
44
+ if (e.usage)
45
+ this.setOpenAI(e.usage);
46
+ return;
47
+ }
48
+ switch (e.type) {
49
+ // Anthropic, non-streaming.
50
+ case 'message':
51
+ if (e.usage) {
52
+ this.setAnthropic(e.usage);
53
+ this.final = true;
54
+ }
55
+ break;
56
+ case 'message_start':
57
+ if (typeof e.message?.model === 'string')
58
+ this.model ||= e.message.model;
59
+ if (e.message?.usage)
60
+ this.setAnthropic(e.message.usage);
61
+ break;
62
+ case 'message_delta':
63
+ if (e.usage) {
64
+ // Cumulative totals. Newer API versions also repeat input fields here; keep earlier
65
+ // cache numbers when a field is absent.
66
+ const u = e.usage;
67
+ if (typeof u.output_tokens === 'number')
68
+ this.output = u.output_tokens;
69
+ if (typeof u.input_tokens === 'number') {
70
+ const read = typeof u.cache_read_input_tokens === 'number' ? u.cache_read_input_tokens : this.cached;
71
+ const write = typeof u.cache_creation_input_tokens === 'number' ? u.cache_creation_input_tokens : this.cacheWrite;
72
+ this.input = u.input_tokens + read + write;
73
+ this.cached = read;
74
+ this.cacheWrite = write;
75
+ }
76
+ this.final = true;
77
+ }
78
+ break;
79
+ case 'content_block_delta':
80
+ if (typeof e.delta?.text === 'string')
81
+ this.text += e.delta.text;
82
+ else if (typeof e.delta?.partial_json === 'string')
83
+ this.text += e.delta.partial_json;
84
+ break;
85
+ // OpenAI Responses, streaming.
86
+ case 'response.output_text.delta':
87
+ if (typeof e.delta === 'string')
88
+ this.text += e.delta;
89
+ break;
90
+ case 'response.completed':
91
+ case 'response.incomplete':
92
+ if (typeof e.response?.model === 'string')
93
+ this.model ||= e.response.model;
94
+ if (e.response?.usage)
95
+ this.setOpenAI(e.response.usage);
96
+ break;
97
+ }
98
+ }
99
+ setOpenAI(u) {
100
+ this.input = num(u.prompt_tokens ?? u.input_tokens);
101
+ this.output = num(u.completion_tokens ?? u.output_tokens);
102
+ this.cached = num(u.prompt_tokens_details?.cached_tokens ?? u.input_tokens_details?.cached_tokens ?? u.prompt_cache_hit_tokens);
103
+ this.final = true;
104
+ }
105
+ setAnthropic(u) {
106
+ const read = num(u.cache_read_input_tokens);
107
+ const write = num(u.cache_creation_input_tokens);
108
+ this.input = num(u.input_tokens) + read + write;
109
+ this.cached = read;
110
+ this.cacheWrite = write;
111
+ this.output = num(u.output_tokens);
112
+ }
113
+ }
114
+ function num(v) {
115
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
116
+ }
117
+ /** Rough token count (~4 characters per token). Only used when the provider never reports usage. */
118
+ export function estimateTokens(text) {
119
+ return text ? Math.ceil(text.length / 4) : 0;
120
+ }
121
+ /** The prompt part of a request body, as text, for estimating input tokens. */
122
+ export function promptText(params) {
123
+ if (!params || typeof params !== 'object')
124
+ return '';
125
+ const p = params;
126
+ const parts = [p.system, p.instructions, p.messages, p.input, p.prompt].filter((x) => x != null);
127
+ return parts.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join('\n');
128
+ }
@@ -0,0 +1,28 @@
1
+ import type { WrapOptions } from './types.js';
2
+ interface ModelLike {
3
+ modelId?: string;
4
+ provider?: string;
5
+ }
6
+ interface GenerateArgs {
7
+ doGenerate: () => Promise<any>;
8
+ params: unknown;
9
+ model?: ModelLike;
10
+ }
11
+ interface StreamArgs {
12
+ doStream: () => Promise<{
13
+ stream: ReadableStream<any>;
14
+ } & Record<string, any>>;
15
+ params: unknown;
16
+ model?: ModelLike;
17
+ }
18
+ /**
19
+ * Vercel AI SDK language model middleware:
20
+ * `wrapLanguageModel({ model, middleware: Ops.middleware({ userId, supabase }) })`.
21
+ */
22
+ export declare function opsMiddleware(opts: WrapOptions): {
23
+ wrapGenerate({ doGenerate, params, model }: GenerateArgs): Promise<any>;
24
+ wrapStream({ doStream, params, model }: StreamArgs): Promise<{
25
+ stream: ReadableStream<any>;
26
+ }>;
27
+ };
28
+ export {};
package/dist/ai-sdk.js ADDED
@@ -0,0 +1,49 @@
1
+ import { UsageAccumulator } from "./accumulator.js";
2
+ import { createRecorder } from "./recorder.js";
3
+ import { tapReadable } from "./stream.js";
4
+ /**
5
+ * Vercel AI SDK language model middleware:
6
+ * `wrapLanguageModel({ model, middleware: Ops.middleware({ userId, supabase }) })`.
7
+ */
8
+ export function opsMiddleware(opts) {
9
+ const record = createRecorder(opts);
10
+ const providerOf = (m) => opts.provider ?? (m?.provider ?? 'ai-sdk').split('.')[0];
11
+ return {
12
+ async wrapGenerate({ doGenerate, params, model }) {
13
+ const startedAt = Date.now();
14
+ const result = await doGenerate();
15
+ const acc = new UsageAccumulator();
16
+ acc.model = model?.modelId ?? '';
17
+ applyUsage(acc, result?.usage);
18
+ record({ acc, provider: providerOf(model), operation: 'ai-sdk', params, streamed: false, aborted: false, startedAt });
19
+ return result;
20
+ },
21
+ async wrapStream({ doStream, params, model }) {
22
+ const startedAt = Date.now();
23
+ const result = await doStream();
24
+ const acc = new UsageAccumulator();
25
+ acc.model = model?.modelId ?? '';
26
+ const stream = tapReadable(result.stream, {
27
+ onItem(part) {
28
+ if (part?.type === 'text-delta')
29
+ acc.text += part.delta ?? part.textDelta ?? '';
30
+ if (part?.type === 'finish')
31
+ applyUsage(acc, part.usage);
32
+ return true;
33
+ },
34
+ onDone: (aborted) => record({ acc, provider: providerOf(model), operation: 'ai-sdk', params, streamed: true, aborted, startedAt }),
35
+ });
36
+ return { ...result, stream };
37
+ },
38
+ };
39
+ }
40
+ /** AI SDK usage: numbers (`inputTokens`) or, in newer versions, objects with a `total`. */
41
+ function applyUsage(acc, u) {
42
+ if (!u)
43
+ return;
44
+ const n = (x) => (typeof x === 'number' ? x : typeof x?.total === 'number' ? x.total : 0);
45
+ acc.input = n(u.inputTokens ?? u.promptTokens);
46
+ acc.output = n(u.outputTokens ?? u.completionTokens);
47
+ acc.cached = typeof u.cachedInputTokens === 'number' ? u.cachedInputTokens : n(u.inputTokens?.cacheRead);
48
+ acc.final = true;
49
+ }
@@ -0,0 +1,2 @@
1
+ import type { WrapOptions } from './types.js';
2
+ export declare function wrapAnthropic<C extends object>(client: C, opts: WrapOptions): C;
@@ -0,0 +1,38 @@
1
+ import { UsageAccumulator } from "./accumulator.js";
2
+ import { listenHelperStream, mapApiPromise, proxyMethods } from "./proxy.js";
3
+ import { createRecorder } from "./recorder.js";
4
+ import { tapSdkStream } from "./stream.js";
5
+ export function wrapAnthropic(client, opts) {
6
+ const record = createRecorder(opts);
7
+ const provider = opts.provider ?? 'anthropic';
8
+ const create = (orig) => (params, reqOpts) => messagesCreate(orig, params, reqOpts, record, provider);
9
+ const stream = (orig) => (params, reqOpts) => {
10
+ const startedAt = Date.now();
11
+ const acc = new UsageAccumulator();
12
+ const s = orig(params, reqOpts);
13
+ listenHelperStream(s, 'streamEvent', (ev) => acc.feed(ev), (aborted) => record({ acc, provider, operation: 'messages', params, streamed: true, aborted, startedAt }));
14
+ return s;
15
+ };
16
+ return proxyMethods(client, {
17
+ 'messages.create': create,
18
+ 'messages.stream': stream,
19
+ 'beta.messages.create': create,
20
+ 'beta.messages.stream': stream,
21
+ });
22
+ }
23
+ function messagesCreate(orig, params, reqOpts, record, provider) {
24
+ const startedAt = Date.now();
25
+ const acc = new UsageAccumulator();
26
+ const done = (aborted, streamed) => record({ acc, provider, operation: 'messages', params, streamed, aborted, startedAt });
27
+ if (!params?.stream) {
28
+ return mapApiPromise(orig(params, reqOpts), (res) => {
29
+ acc.feed(res);
30
+ done(false, false);
31
+ return res;
32
+ });
33
+ }
34
+ return mapApiPromise(orig(params, reqOpts), (s) => tapSdkStream(s, {
35
+ onItem: (ev) => (acc.feed(ev), true),
36
+ onDone: (aborted) => done(aborted, true),
37
+ }));
38
+ }
@@ -0,0 +1,6 @@
1
+ import type { WrapOptions } from './types.js';
2
+ /**
3
+ * Wraps `fetch` for apps that call model APIs over raw HTTP. Understands OpenAI-compatible chat,
4
+ * OpenAI Responses and Anthropic Messages, both JSON and SSE.
5
+ */
6
+ export declare function wrapFetch(fetchImpl: typeof fetch, opts: WrapOptions): typeof fetch;
package/dist/fetch.js ADDED
@@ -0,0 +1,76 @@
1
+ import { UsageAccumulator } from "./accumulator.js";
2
+ import { inferProvider, isUsageOnlyChunk, shouldInjectUsage } from "./proxy.js";
3
+ import { background, createRecorder } from "./recorder.js";
4
+ import { tapSse } from "./stream.js";
5
+ /**
6
+ * Wraps `fetch` for apps that call model APIs over raw HTTP. Understands OpenAI-compatible chat,
7
+ * OpenAI Responses and Anthropic Messages, both JSON and SSE.
8
+ */
9
+ export function wrapFetch(fetchImpl, opts) {
10
+ const record = createRecorder(opts);
11
+ return async (input, init) => {
12
+ const startedAt = Date.now();
13
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
14
+ const provider = opts.provider ?? inferProvider(url, 'unknown');
15
+ let params;
16
+ let callerAskedUsage = true;
17
+ let sentInit = init;
18
+ try {
19
+ if (typeof init?.body === 'string') {
20
+ params = JSON.parse(init.body);
21
+ if (params?.stream === true && /\/chat\/completions/.test(url) && shouldInjectUsage(opts, provider)) {
22
+ callerAskedUsage = params.stream_options?.include_usage === true;
23
+ if (!callerAskedUsage) {
24
+ const body = { ...params, stream_options: { ...params.stream_options, include_usage: true } };
25
+ sentInit = { ...init, body: JSON.stringify(body) };
26
+ }
27
+ }
28
+ }
29
+ }
30
+ catch {
31
+ params = undefined;
32
+ sentInit = init;
33
+ }
34
+ const res = await fetchImpl(input, sentInit);
35
+ try {
36
+ if (!res.ok || !res.body)
37
+ return res;
38
+ const acc = new UsageAccumulator();
39
+ const done = (aborted, streamed) => record({ acc, provider, operation: 'fetch', params, streamed, aborted, startedAt });
40
+ const type = res.headers.get('content-type') ?? '';
41
+ if (type.includes('text/event-stream')) {
42
+ const body = tapSse(res.body, {
43
+ onItem(ev) {
44
+ if (!ev.data || ev.data === '[DONE]')
45
+ return true;
46
+ let obj;
47
+ try {
48
+ obj = JSON.parse(ev.data);
49
+ }
50
+ catch {
51
+ return true;
52
+ }
53
+ acc.feed(obj);
54
+ return callerAskedUsage || !isUsageOnlyChunk(obj);
55
+ },
56
+ onDone: (aborted) => done(aborted, true),
57
+ });
58
+ return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
59
+ }
60
+ if (type.includes('json')) {
61
+ background(res
62
+ .clone()
63
+ .json()
64
+ .then((obj) => {
65
+ acc.feed(obj);
66
+ done(false, false);
67
+ })
68
+ .catch(() => { }));
69
+ }
70
+ return res;
71
+ }
72
+ catch {
73
+ return res;
74
+ }
75
+ };
76
+ }
@@ -0,0 +1,25 @@
1
+ import { opsMiddleware } from './ai-sdk.js';
2
+ import { wrapAnthropic } from './anthropic.js';
3
+ import { wrapFetch } from './fetch.js';
4
+ import { wrapOpenAI } from './openai.js';
5
+ import { flush } from './recorder.js';
6
+ import { record } from './record.js';
7
+ import type { WrapOptions } from './types.js';
8
+ /**
9
+ * Wraps an OpenAI(-compatible) or Anthropic SDK client so every call records the user's token usage
10
+ * to `ops.usage`. The returned client behaves exactly like the original.
11
+ */
12
+ export declare function wrap<C extends object>(client: C, opts: WrapOptions): C;
13
+ export declare const Ops: {
14
+ wrap: typeof wrap;
15
+ wrapOpenAI: typeof wrapOpenAI;
16
+ wrapAnthropic: typeof wrapAnthropic;
17
+ wrapFetch: typeof wrapFetch;
18
+ middleware: typeof opsMiddleware;
19
+ record: typeof record;
20
+ flush: typeof flush;
21
+ };
22
+ export { flush, opsMiddleware, record, wrapAnthropic, wrapFetch, wrapOpenAI };
23
+ export type { RecordOptions } from './record.js';
24
+ export { cost } from './recorder.js';
25
+ export type { Prices, Sink, SupabaseLike, UsageRow, WrapOptions } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import { opsMiddleware } from "./ai-sdk.js";
2
+ import { wrapAnthropic } from "./anthropic.js";
3
+ import { wrapFetch } from "./fetch.js";
4
+ import { wrapOpenAI } from "./openai.js";
5
+ import { flush } from "./recorder.js";
6
+ import { record } from "./record.js";
7
+ /**
8
+ * Wraps an OpenAI(-compatible) or Anthropic SDK client so every call records the user's token usage
9
+ * to `ops.usage`. The returned client behaves exactly like the original.
10
+ */
11
+ export function wrap(client, opts) {
12
+ const c = client;
13
+ if (c?.chat?.completions || c?.responses)
14
+ return wrapOpenAI(client, opts);
15
+ if (c?.messages)
16
+ return wrapAnthropic(client, opts);
17
+ throw new TypeError('@cockpitify/node: unsupported client. Use Ops.wrapFetch() or Ops.middleware() instead.');
18
+ }
19
+ export const Ops = { wrap, wrapOpenAI, wrapAnthropic, wrapFetch, middleware: opsMiddleware, record, flush };
20
+ export { flush, opsMiddleware, record, wrapAnthropic, wrapFetch, wrapOpenAI };
21
+ export { cost } from "./recorder.js";
@@ -0,0 +1,3 @@
1
+ import type { WrapOptions } from './types.js';
2
+ /** Works for OpenAI and OpenAI-compatible clients (DeepSeek, Groq, OpenRouter via `baseURL`). */
3
+ export declare function wrapOpenAI<C extends object>(client: C, opts: WrapOptions): C;
package/dist/openai.js ADDED
@@ -0,0 +1,64 @@
1
+ import { UsageAccumulator } from "./accumulator.js";
2
+ import { inferProvider, isUsageOnlyChunk, listenHelperStream, mapApiPromise, proxyMethods, shouldInjectUsage, } from "./proxy.js";
3
+ import { createRecorder } from "./recorder.js";
4
+ import { tapSdkStream } from "./stream.js";
5
+ /** Works for OpenAI and OpenAI-compatible clients (DeepSeek, Groq, OpenRouter via `baseURL`). */
6
+ export function wrapOpenAI(client, opts) {
7
+ const record = createRecorder(opts);
8
+ const provider = opts.provider ?? inferProvider(client.baseURL, 'openai');
9
+ const inject = shouldInjectUsage(opts, provider);
10
+ return proxyMethods(client, {
11
+ 'chat.completions.create': (orig) => (params, reqOpts) => chatCreate(orig, params, reqOpts, record, provider, inject),
12
+ 'chat.completions.stream': (orig) => (params, reqOpts) => {
13
+ const startedAt = Date.now();
14
+ const acc = new UsageAccumulator();
15
+ const sent = inject ? withUsage(params) : params;
16
+ const s = orig(sent, reqOpts);
17
+ listenHelperStream(s, 'chunk', (c) => acc.feed(c), (aborted) => record({ acc, provider, operation: 'chat.completions', params, streamed: true, aborted, startedAt }));
18
+ return s;
19
+ },
20
+ 'responses.create': (orig) => (params, reqOpts) => {
21
+ const startedAt = Date.now();
22
+ const acc = new UsageAccumulator();
23
+ const done = (aborted, streamed) => record({ acc, provider, operation: 'responses', params, streamed, aborted, startedAt });
24
+ if (!params?.stream) {
25
+ return mapApiPromise(orig(params, reqOpts), (res) => {
26
+ acc.feed(res);
27
+ done(false, false);
28
+ return res;
29
+ });
30
+ }
31
+ return mapApiPromise(orig(params, reqOpts), (stream) => tapSdkStream(stream, {
32
+ onItem: (ev) => (acc.feed(ev), true),
33
+ onDone: (aborted) => done(aborted, true),
34
+ }));
35
+ },
36
+ });
37
+ }
38
+ function chatCreate(orig, params, reqOpts, record, provider, inject) {
39
+ const startedAt = Date.now();
40
+ const acc = new UsageAccumulator();
41
+ const done = (aborted, streamed) => record({ acc, provider, operation: 'chat.completions', params, streamed, aborted, startedAt });
42
+ if (!params?.stream) {
43
+ return mapApiPromise(orig(params, reqOpts), (res) => {
44
+ acc.feed(res);
45
+ done(false, false);
46
+ return res;
47
+ });
48
+ }
49
+ const callerAskedUsage = params.stream_options?.include_usage === true;
50
+ const sent = inject ? withUsage(params) : params;
51
+ return mapApiPromise(orig(sent, reqOpts), (stream) => tapSdkStream(stream, {
52
+ onItem(chunk) {
53
+ acc.feed(chunk);
54
+ // Hide the usage-only chunk we caused; code reading `choices[0]` would crash on it.
55
+ return callerAskedUsage || !isUsageOnlyChunk(chunk);
56
+ },
57
+ onDone: (aborted) => done(aborted, true),
58
+ }));
59
+ }
60
+ function withUsage(params) {
61
+ if (!params || params.stream_options?.include_usage === true)
62
+ return params;
63
+ return { ...params, stream_options: { ...params.stream_options, include_usage: true } };
64
+ }
@@ -0,0 +1,23 @@
1
+ import type { WrapOptions } from './types.js';
2
+ type Fn = (...args: any[]) => any;
3
+ type Route = (original: Fn) => Fn;
4
+ /**
5
+ * Returns a proxy of an SDK client where the methods at the given dotted paths
6
+ * (e.g. 'chat.completions.create') are replaced. Everything else passes through, bound to the
7
+ * real object so SDK classes with private fields keep working. The original methods stay bound to
8
+ * the real resources, so SDK helpers that call them internally are not counted twice.
9
+ */
10
+ export declare function proxyMethods<T extends object>(target: T, routes: Record<string, Route>, prefix?: string): T;
11
+ /**
12
+ * Transforms the value of an SDK request promise. OpenAI and Anthropic SDKs return an `APIPromise`
13
+ * with extras (`withResponse()`, `asResponse()`); its `_thenUnwrap` keeps those, and stays lazy so
14
+ * `asResponse()` callers still get an unread body. Plain promises fall back to `then`.
15
+ */
16
+ export declare function mapApiPromise<T, U>(p: unknown, fn: (value: T) => U): unknown;
17
+ export declare function inferProvider(url: unknown, fallback: string): string;
18
+ export declare function shouldInjectUsage(opts: WrapOptions, provider: string): boolean;
19
+ /** The extra final chunk `include_usage` adds: no choices, only usage. */
20
+ export declare function isUsageOnlyChunk(chunk: unknown): boolean;
21
+ /** Listens to an SDK helper stream (`chat.completions.stream()`, `messages.stream()`). */
22
+ export declare function listenHelperStream(s: unknown, itemEvent: string, onItem: (item: unknown) => void, onDone: (aborted: boolean) => void): void;
23
+ export {};
package/dist/proxy.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Returns a proxy of an SDK client where the methods at the given dotted paths
3
+ * (e.g. 'chat.completions.create') are replaced. Everything else passes through, bound to the
4
+ * real object so SDK classes with private fields keep working. The original methods stay bound to
5
+ * the real resources, so SDK helpers that call them internally are not counted twice.
6
+ */
7
+ export function proxyMethods(target, routes, prefix = '') {
8
+ return new Proxy(target, {
9
+ get(t, prop) {
10
+ const v = Reflect.get(t, prop, t);
11
+ if (typeof prop !== 'string')
12
+ return v;
13
+ const path = prefix ? `${prefix}.${prop}` : prop;
14
+ const route = routes[path];
15
+ if (route && typeof v === 'function')
16
+ return route(v.bind(t));
17
+ if (v && typeof v === 'object' && Object.keys(routes).some((k) => k.startsWith(`${path}.`))) {
18
+ return proxyMethods(v, routes, path);
19
+ }
20
+ return typeof v === 'function' ? v.bind(t) : v;
21
+ },
22
+ });
23
+ }
24
+ /**
25
+ * Transforms the value of an SDK request promise. OpenAI and Anthropic SDKs return an `APIPromise`
26
+ * with extras (`withResponse()`, `asResponse()`); its `_thenUnwrap` keeps those, and stays lazy so
27
+ * `asResponse()` callers still get an unread body. Plain promises fall back to `then`.
28
+ */
29
+ export function mapApiPromise(p, fn) {
30
+ const ap = p;
31
+ if (ap && typeof ap._thenUnwrap === 'function')
32
+ return ap._thenUnwrap((v) => fn(v));
33
+ return Promise.resolve(p).then(fn);
34
+ }
35
+ export function inferProvider(url, fallback) {
36
+ const s = typeof url === 'string' ? url : url instanceof URL ? url.href : '';
37
+ if (s.includes('deepseek'))
38
+ return 'deepseek';
39
+ if (s.includes('groq'))
40
+ return 'groq';
41
+ if (s.includes('openrouter'))
42
+ return 'openrouter';
43
+ if (s.includes('anthropic'))
44
+ return 'anthropic';
45
+ if (s.includes('openai'))
46
+ return 'openai';
47
+ return fallback;
48
+ }
49
+ export function shouldInjectUsage(opts, provider) {
50
+ return opts.injectStreamUsage ?? provider !== 'groq';
51
+ }
52
+ /** The extra final chunk `include_usage` adds: no choices, only usage. */
53
+ export function isUsageOnlyChunk(chunk) {
54
+ const c = chunk;
55
+ return Array.isArray(c?.choices) && c.choices.length === 0 && c.usage != null;
56
+ }
57
+ /** Listens to an SDK helper stream (`chat.completions.stream()`, `messages.stream()`). */
58
+ export function listenHelperStream(s, itemEvent, onItem, onDone) {
59
+ const em = s;
60
+ if (typeof em?.on !== 'function')
61
+ return;
62
+ let finished = false;
63
+ const fin = (aborted) => {
64
+ if (finished)
65
+ return;
66
+ finished = true;
67
+ onDone(aborted);
68
+ };
69
+ em.on(itemEvent, (item) => {
70
+ try {
71
+ onItem(item);
72
+ }
73
+ catch {
74
+ // ignore
75
+ }
76
+ });
77
+ em.on('end', () => fin(false));
78
+ em.on('abort', () => fin(true));
79
+ em.on('error', () => fin(true));
80
+ }
@@ -0,0 +1,22 @@
1
+ import type { WrapOptions } from './types.js';
2
+ export interface RecordOptions extends Pick<WrapOptions, 'userId' | 'supabase' | 'sink' | 'feature' | 'onError'> {
3
+ /** e.g. 'groq', 'elevenlabs', 'openai'. */
4
+ provider: string;
5
+ /** e.g. 'whisper-large-v3', 'eleven_multilingual_v2'. */
6
+ model: string;
7
+ /** How much was used, in `unit`: 742 seconds of audio, 48000 characters, 3 images. */
8
+ units: number;
9
+ /** Lower-case: 'seconds', 'characters', 'images', 'requests'… */
10
+ unit: string;
11
+ /** What the call cost, when you know it. Without it the row stays unpriced (ops shows it as such). */
12
+ costUsd?: number;
13
+ /** Which API was called; defaults to `unit`. */
14
+ operation?: string;
15
+ latencyMs?: number;
16
+ }
17
+ /**
18
+ * Records one call that isn't measured in tokens (speech-to-text, text-to-speech, images) to
19
+ * `ops.usage`, for the user's cost and the "who costs money" view. Like wrap(), it never throws into
20
+ * the caller and writes in the background (call flush() before a process exits).
21
+ */
22
+ export declare function record(opts: RecordOptions): void;
package/dist/record.js ADDED
@@ -0,0 +1,41 @@
1
+ import { background, createSender } from "./recorder.js";
2
+ const UNIT = /^[a-z][a-z_]{0,19}$/;
3
+ /**
4
+ * Records one call that isn't measured in tokens (speech-to-text, text-to-speech, images) to
5
+ * `ops.usage`, for the user's cost and the "who costs money" view. Like wrap(), it never throws into
6
+ * the caller and writes in the background (call flush() before a process exits).
7
+ */
8
+ export function record(opts) {
9
+ const { send, onError } = createSender(opts);
10
+ try {
11
+ if (!Number.isFinite(opts.units) || opts.units < 0)
12
+ throw new TypeError('@cockpitify/node: `units` must be 0 or more.');
13
+ if (typeof opts.unit !== 'string' || !UNIT.test(opts.unit))
14
+ throw new TypeError('@cockpitify/node: `unit` must be a lower-case word like "seconds".');
15
+ if (opts.costUsd !== undefined && (!Number.isFinite(opts.costUsd) || opts.costUsd < 0))
16
+ throw new TypeError('@cockpitify/node: `costUsd` must be 0 or more.');
17
+ const row = {
18
+ user_id: opts.userId,
19
+ provider: opts.provider,
20
+ model: opts.model,
21
+ operation: opts.operation ?? opts.unit,
22
+ input_tokens: 0,
23
+ output_tokens: 0,
24
+ cached_input_tokens: 0,
25
+ cache_write_tokens: 0,
26
+ cost_usd: opts.costUsd ?? null,
27
+ estimated: false,
28
+ streamed: false,
29
+ aborted: false,
30
+ latency_ms: Math.max(0, Math.round(opts.latencyMs ?? 0)),
31
+ feature: opts.feature ?? null,
32
+ occurred_at: new Date().toISOString(),
33
+ units: opts.units,
34
+ unit: opts.unit,
35
+ };
36
+ background(send(row).catch(onError));
37
+ }
38
+ catch (err) {
39
+ onError(err);
40
+ }
41
+ }
@@ -0,0 +1,28 @@
1
+ import { type UsageAccumulator } from './accumulator.js';
2
+ import type { Prices, UsageRow, WrapOptions } from './types.js';
3
+ export interface Measurement {
4
+ acc: UsageAccumulator;
5
+ provider: string;
6
+ operation: string;
7
+ /** Request parameters; used for the model name and for input estimates. */
8
+ params: unknown;
9
+ streamed: boolean;
10
+ aborted: boolean;
11
+ startedAt: number;
12
+ }
13
+ export type RecordFn = (m: Measurement) => void;
14
+ /**
15
+ * Keeps a write alive after the response has been sent. On Supabase Edge Functions this is
16
+ * `EdgeRuntime.waitUntil`; elsewhere the promise is tracked until `flush()`.
17
+ */
18
+ export declare function background(p: Promise<unknown>): void;
19
+ /** Waits for every usage write still in flight. Call before a Node process or serverless handler exits. */
20
+ export declare function flush(): Promise<void>;
21
+ /** Where rows go (the project's ops.usage, or a custom sink), checked once. Shared by wrap() and record(). */
22
+ export declare function createSender(opts: Pick<WrapOptions, 'userId' | 'sink' | 'supabase' | 'onError'>): {
23
+ send: (row: UsageRow) => Promise<unknown>;
24
+ onError: (err: unknown) => void;
25
+ };
26
+ export declare function createRecorder(opts: WrapOptions): RecordFn;
27
+ /** Longest matching price key wins, so 'gpt-4o-mini' is not priced as 'gpt-4o'. */
28
+ export declare function cost(prices: Prices | undefined, model: string, input: number, output: number, cached: number, cacheWrite: number): number | null;
@@ -0,0 +1,108 @@
1
+ import { estimateTokens, promptText } from "./accumulator.js";
2
+ const pending = new Set();
3
+ /**
4
+ * Keeps a write alive after the response has been sent. On Supabase Edge Functions this is
5
+ * `EdgeRuntime.waitUntil`; elsewhere the promise is tracked until `flush()`.
6
+ */
7
+ export function background(p) {
8
+ const rt = globalThis.EdgeRuntime;
9
+ if (typeof rt?.waitUntil === 'function') {
10
+ rt.waitUntil(p);
11
+ return;
12
+ }
13
+ pending.add(p);
14
+ void p.finally(() => pending.delete(p));
15
+ }
16
+ /** Waits for every usage write still in flight. Call before a Node process or serverless handler exits. */
17
+ export async function flush() {
18
+ await Promise.allSettled([...pending]);
19
+ }
20
+ let warned = false;
21
+ function defaultOnError(err) {
22
+ if (warned)
23
+ return;
24
+ warned = true;
25
+ console.warn('[@cockpitify/node] usage recording failed; AI calls are unaffected.', err);
26
+ }
27
+ /** Where rows go (the project's ops.usage, or a custom sink), checked once. Shared by wrap() and record(). */
28
+ export function createSender(opts) {
29
+ if (!opts || typeof opts.userId !== 'string' || !opts.userId) {
30
+ throw new TypeError('@cockpitify/node: `userId` is required.');
31
+ }
32
+ if (!opts.sink && !opts.supabase) {
33
+ throw new TypeError('@cockpitify/node: pass `supabase` (writes to ops.usage) or a custom `sink`.');
34
+ }
35
+ const onError = opts.onError ?? defaultOnError;
36
+ const send = (row) => {
37
+ if (opts.sink)
38
+ return Promise.resolve(opts.sink(row));
39
+ return Promise.resolve(opts.supabase.schema('ops').from('usage').insert(row)).then((r) => {
40
+ if (r?.error)
41
+ throw r.error;
42
+ });
43
+ };
44
+ return { send, onError };
45
+ }
46
+ export function createRecorder(opts) {
47
+ const { send, onError } = createSender(opts);
48
+ // Never throws: a failure here must not break the caller's AI request.
49
+ return (m) => {
50
+ try {
51
+ const row = toRow(m, opts);
52
+ background(send(row).catch(onError));
53
+ }
54
+ catch (err) {
55
+ onError(err);
56
+ }
57
+ };
58
+ }
59
+ function toRow(m, opts) {
60
+ const { acc } = m;
61
+ const model = acc.model || modelFromParams(m.params) || 'unknown';
62
+ let input = acc.input;
63
+ let output = acc.output;
64
+ const estimated = !acc.final;
65
+ if (estimated) {
66
+ input = input || estimateTokens(promptText(m.params));
67
+ output = Math.max(output, estimateTokens(acc.text));
68
+ }
69
+ return {
70
+ user_id: opts.userId,
71
+ provider: opts.provider ?? m.provider,
72
+ model,
73
+ operation: m.operation,
74
+ input_tokens: input,
75
+ output_tokens: output,
76
+ cached_input_tokens: acc.cached,
77
+ cache_write_tokens: acc.cacheWrite,
78
+ cost_usd: cost(opts.prices, model, input, output, acc.cached, acc.cacheWrite),
79
+ estimated,
80
+ streamed: m.streamed,
81
+ aborted: m.aborted,
82
+ latency_ms: Date.now() - m.startedAt,
83
+ feature: opts.feature ?? null,
84
+ occurred_at: new Date(m.startedAt).toISOString(),
85
+ };
86
+ }
87
+ function modelFromParams(params) {
88
+ const model = params?.model;
89
+ return typeof model === 'string' ? model : '';
90
+ }
91
+ /** Longest matching price key wins, so 'gpt-4o-mini' is not priced as 'gpt-4o'. */
92
+ export function cost(prices, model, input, output, cached, cacheWrite) {
93
+ if (!prices)
94
+ return null;
95
+ let key = '';
96
+ for (const k of Object.keys(prices))
97
+ if (model.startsWith(k) && k.length > key.length)
98
+ key = k;
99
+ if (!key)
100
+ return null;
101
+ const p = prices[key];
102
+ const fresh = Math.max(0, input - cached - cacheWrite);
103
+ const usd = fresh * p.input +
104
+ cached * (p.cachedInput ?? p.input) +
105
+ cacheWrite * (p.cacheWrite ?? p.input) +
106
+ output * p.output;
107
+ return usd / 1_000_000;
108
+ }
@@ -0,0 +1,27 @@
1
+ export interface Tap<T> {
2
+ /** Sees each item. Return false to hide it from the caller. Exceptions are swallowed. */
3
+ onItem(item: T): boolean;
4
+ /** Called exactly once: on normal end (aborted=false), or on break/cancel/error (aborted=true). */
5
+ onDone(aborted: boolean): void;
6
+ }
7
+ export declare function tapIterator<T>(it: AsyncIterator<T>, tap: Tap<T>): AsyncIterableIterator<T>;
8
+ /**
9
+ * Instruments an SDK stream object in place when possible.
10
+ *
11
+ * OpenAI and Anthropic SDK streams keep their source in an `iterator()` method that
12
+ * `[Symbol.asyncIterator]`, `tee()` and `toReadableStream()` all go through, so patching it covers
13
+ * every way the caller can consume the stream while keeping the original object (and its
14
+ * `controller`, `response`, etc.). Other async iterables get a thin proxy.
15
+ */
16
+ export declare function tapSdkStream<S extends object>(stream: S, tap: Tap<any>): S;
17
+ /** Object streams (e.g. AI SDK stream parts). Cancelling the result cancels the source. */
18
+ export declare function tapReadable<T>(src: ReadableStream<T>, tap: Tap<T>): ReadableStream<T>;
19
+ export interface SseEvent {
20
+ event: string;
21
+ data: string;
22
+ }
23
+ /**
24
+ * Byte-level Server-Sent Events pass-through. Splits on event boundaries and forwards each event's
25
+ * original text unchanged, unless `onItem` returns false for it.
26
+ */
27
+ export declare function tapSse(body: ReadableStream<Uint8Array>, tap: Tap<SseEvent>): ReadableStream<Uint8Array>;
package/dist/stream.js ADDED
@@ -0,0 +1,199 @@
1
+ // Pass-through wrappers that observe a stream without delaying or altering what the caller sees
2
+ // (except for dropping items the caller never asked for, see `keep`).
3
+ function safeKeep(tap, item) {
4
+ try {
5
+ return tap.onItem(item) !== false;
6
+ }
7
+ catch {
8
+ return true;
9
+ }
10
+ }
11
+ export function tapIterator(it, tap) {
12
+ let finished = false;
13
+ const finish = (aborted) => {
14
+ if (finished)
15
+ return;
16
+ finished = true;
17
+ try {
18
+ tap.onDone(aborted);
19
+ }
20
+ catch {
21
+ // never surfaces to the caller
22
+ }
23
+ };
24
+ const wrapped = {
25
+ async next() {
26
+ try {
27
+ for (;;) {
28
+ const r = await it.next();
29
+ if (r.done) {
30
+ finish(false);
31
+ return r;
32
+ }
33
+ if (safeKeep(tap, r.value))
34
+ return r;
35
+ }
36
+ }
37
+ catch (err) {
38
+ finish(true);
39
+ throw err;
40
+ }
41
+ },
42
+ async return(value) {
43
+ finish(true);
44
+ return it.return ? it.return(value) : { done: true, value: value };
45
+ },
46
+ async throw(err) {
47
+ finish(true);
48
+ if (it.throw)
49
+ return it.throw(err);
50
+ throw err;
51
+ },
52
+ [Symbol.asyncIterator]() {
53
+ return wrapped;
54
+ },
55
+ };
56
+ return wrapped;
57
+ }
58
+ /**
59
+ * Instruments an SDK stream object in place when possible.
60
+ *
61
+ * OpenAI and Anthropic SDK streams keep their source in an `iterator()` method that
62
+ * `[Symbol.asyncIterator]`, `tee()` and `toReadableStream()` all go through, so patching it covers
63
+ * every way the caller can consume the stream while keeping the original object (and its
64
+ * `controller`, `response`, etc.). Other async iterables get a thin proxy.
65
+ */
66
+ export function tapSdkStream(stream, tap) {
67
+ const s = stream;
68
+ if (typeof s.iterator === 'function') {
69
+ const original = s.iterator.bind(s);
70
+ s.iterator = () => tapIterator(original(), tap);
71
+ return stream;
72
+ }
73
+ if (typeof s[Symbol.asyncIterator] === 'function') {
74
+ return new Proxy(stream, {
75
+ get(target, prop) {
76
+ if (prop === Symbol.asyncIterator) {
77
+ return () => tapIterator(target[Symbol.asyncIterator](), tap);
78
+ }
79
+ const v = Reflect.get(target, prop, target);
80
+ return typeof v === 'function' ? v.bind(target) : v;
81
+ },
82
+ });
83
+ }
84
+ return stream;
85
+ }
86
+ /** Object streams (e.g. AI SDK stream parts). Cancelling the result cancels the source. */
87
+ export function tapReadable(src, tap) {
88
+ const reader = src.getReader();
89
+ let finished = false;
90
+ const finish = (aborted) => {
91
+ if (finished)
92
+ return;
93
+ finished = true;
94
+ try {
95
+ tap.onDone(aborted);
96
+ }
97
+ catch {
98
+ // ignore
99
+ }
100
+ };
101
+ return new ReadableStream({
102
+ async pull(ctrl) {
103
+ try {
104
+ for (;;) {
105
+ const { done, value } = await reader.read();
106
+ if (done) {
107
+ finish(false);
108
+ ctrl.close();
109
+ return;
110
+ }
111
+ if (safeKeep(tap, value)) {
112
+ ctrl.enqueue(value);
113
+ return;
114
+ }
115
+ }
116
+ }
117
+ catch (err) {
118
+ finish(true);
119
+ ctrl.error(err);
120
+ }
121
+ },
122
+ cancel(reason) {
123
+ finish(true);
124
+ return reader.cancel(reason);
125
+ },
126
+ });
127
+ }
128
+ /**
129
+ * Byte-level Server-Sent Events pass-through. Splits on event boundaries and forwards each event's
130
+ * original text unchanged, unless `onItem` returns false for it.
131
+ */
132
+ export function tapSse(body, tap) {
133
+ const reader = body.getReader();
134
+ const dec = new TextDecoder();
135
+ const enc = new TextEncoder();
136
+ let buf = '';
137
+ let finished = false;
138
+ const finish = (aborted) => {
139
+ if (finished)
140
+ return;
141
+ finished = true;
142
+ try {
143
+ tap.onDone(aborted);
144
+ }
145
+ catch {
146
+ // ignore
147
+ }
148
+ };
149
+ const boundary = /\r?\n\r?\n/;
150
+ return new ReadableStream({
151
+ async pull(ctrl) {
152
+ try {
153
+ for (;;) {
154
+ const { done, value } = await reader.read();
155
+ if (done) {
156
+ buf += dec.decode();
157
+ if (buf)
158
+ ctrl.enqueue(enc.encode(buf));
159
+ finish(false);
160
+ ctrl.close();
161
+ return;
162
+ }
163
+ buf += dec.decode(value, { stream: true });
164
+ let out = '';
165
+ for (let m = boundary.exec(buf); m; m = boundary.exec(buf)) {
166
+ const end = m.index + m[0].length;
167
+ const raw = buf.slice(0, end);
168
+ buf = buf.slice(end);
169
+ if (safeKeep(tap, parseSse(raw)))
170
+ out += raw;
171
+ }
172
+ if (out) {
173
+ ctrl.enqueue(enc.encode(out));
174
+ return;
175
+ }
176
+ }
177
+ }
178
+ catch (err) {
179
+ finish(true);
180
+ ctrl.error(err);
181
+ }
182
+ },
183
+ cancel(reason) {
184
+ finish(true);
185
+ return reader.cancel(reason);
186
+ },
187
+ });
188
+ }
189
+ function parseSse(raw) {
190
+ let event = '';
191
+ const data = [];
192
+ for (const line of raw.split(/\r?\n/)) {
193
+ if (line.startsWith('event:'))
194
+ event = line.slice(6).trim();
195
+ else if (line.startsWith('data:'))
196
+ data.push(line.slice(5).replace(/^ /, ''));
197
+ }
198
+ return { event, data: data.join('\n') };
199
+ }
@@ -0,0 +1,62 @@
1
+ /** One row in the customer's `ops.usage` table. */
2
+ export interface UsageRow {
3
+ user_id: string;
4
+ provider: string;
5
+ model: string;
6
+ /** Which API was called: 'chat.completions', 'responses', 'messages', 'fetch', 'ai-sdk'. */
7
+ operation: string;
8
+ /** Total input tokens, including cache reads and cache writes. */
9
+ input_tokens: number;
10
+ output_tokens: number;
11
+ cached_input_tokens: number;
12
+ cache_write_tokens: number;
13
+ /** Null when no price is known for the model; the database fills it from its price table. */
14
+ cost_usd: number | null;
15
+ /** True when the provider never reported usage (e.g. the stream was cut) and tokens were estimated. */
16
+ estimated: boolean;
17
+ streamed: boolean;
18
+ aborted: boolean;
19
+ latency_ms: number;
20
+ feature: string | null;
21
+ occurred_at: string;
22
+ /** Calls not measured in tokens (ops 0.17.0+): the amount and its unit, e.g. 742 'seconds'. */
23
+ units?: number | null;
24
+ unit?: string | null;
25
+ }
26
+ export type Sink = (row: UsageRow) => PromiseLike<unknown>;
27
+ /** The slice of a supabase-js client we use. */
28
+ export interface SupabaseLike {
29
+ schema(name: string): {
30
+ from(table: string): {
31
+ insert(row: UsageRow): PromiseLike<{
32
+ error: unknown;
33
+ }>;
34
+ };
35
+ };
36
+ }
37
+ /** USD per 1M tokens. Keys match a model id exactly or as a prefix (e.g. 'gpt-4o' matches 'gpt-4o-2024-08-06'). */
38
+ export type Prices = Record<string, {
39
+ input: number;
40
+ output: number;
41
+ cachedInput?: number;
42
+ cacheWrite?: number;
43
+ }>;
44
+ export interface WrapOptions {
45
+ userId: string;
46
+ /** Rows go to `ops.usage` through this client. Either this or `sink` is required. */
47
+ supabase?: SupabaseLike;
48
+ sink?: Sink;
49
+ /** Overrides the provider label inferred from the client or URL. */
50
+ provider?: string;
51
+ /** Optional product-level tag, e.g. 'chat' or 'transcribe'. */
52
+ feature?: string;
53
+ prices?: Prices;
54
+ /**
55
+ * OpenAI-compatible streams only report usage when `stream_options.include_usage` is set, so it is
56
+ * added to streaming requests (and the extra usage-only chunk is hidden from the caller unless
57
+ * they asked for it). Defaults to true, except for Groq, which reports usage without it.
58
+ */
59
+ injectStreamUsage?: boolean;
60
+ /** Called when recording fails. Recording never throws into the caller. Defaults to one console.warn. */
61
+ onError?: (err: unknown) => void;
62
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@cockpitify/node",
3
+ "version": "0.1.0",
4
+ "description": "Records each user's AI token usage and cost to your own Supabase project (Cockpitify).",
5
+ "license": "MIT",
6
+ "author": "Cockpitify",
7
+ "homepage": "https://cockpitify.app",
8
+ "keywords": [
9
+ "cockpitify",
10
+ "ai",
11
+ "llm",
12
+ "cost",
13
+ "openai",
14
+ "anthropic",
15
+ "supabase",
16
+ "deno"
17
+ ],
18
+ "type": "module",
19
+ "sideEffects": false,
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "devDependencies": {
38
+ "@anthropic-ai/sdk": "^0.131.0",
39
+ "openai": "^7.27.0"
40
+ },
41
+ "scripts": {
42
+ "build": "node ../build.mjs",
43
+ "test": "node --experimental-strip-types --no-warnings --test \"test/*.test.ts\"",
44
+ "typecheck": "tsc -p tsconfig.json"
45
+ },
46
+ "main": "./dist/index.js",
47
+ "types": "./dist/index.d.ts"
48
+ }