0xtrace 1.0.1 → 1.0.2

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/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "0xtrace",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Proxy-based LLM telemetry SDK for 0xtrace",
5
5
  "type": "module",
6
- "main": "index.js",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "require": "./dist/index.cjs"
12
+ }
13
+ },
14
+ "files": ["dist"],
7
15
  "scripts": {
8
- "test": "echo \"Error: no test specified\" && exit 1",
9
16
  "build": "tsup"
10
17
  },
11
- "keywords": [],
12
- "author": "",
13
- "license": "ISC",
14
18
  "dependencies": {
15
19
  "diff": "^9.0.0",
16
20
  "openai": "^6.38.0"
@@ -21,4 +25,4 @@
21
25
  "tsup": "^8.5.1",
22
26
  "typescript": "^6.0.3"
23
27
  }
24
- }
28
+ }
@@ -1,212 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────────────────────
2
- // packages/sdk/src/core/dispatcher.ts
3
- //
4
- // Responsibilities:
5
- // 1. Accept TracePayload objects from the Tracer via a fire-and-forget API.
6
- // 2. Accumulate payloads in an in-memory micro-batch.
7
- // 3. Flush the batch to the ingest endpoint as a single POST request.
8
- // 4. Retry failed requests with exponential back-off (max 3 attempts).
9
- // 5. NEVER block the caller's thread — every send goes onto the microtask
10
- // queue via Promise.resolve().then(...)
11
- // ─────────────────────────────────────────────────────────────────────────────
12
-
13
- import type { IDispatcher, TracePayload } from "./types";
14
-
15
- // ── Constants ────────────────────────────────────────────────────────────────
16
-
17
- const DEFAULT_BATCH_SIZE = 10; // flush after N payloads accumulate
18
- const DEFAULT_FLUSH_MS = 2_000; // flush every 2 s even if batch isn't full
19
- const DEFAULT_TIMEOUT_MS = 5_000; // per-request abort timeout
20
- const MAX_RETRY_ATTEMPTS = 3;
21
- const BASE_RETRY_DELAY_MS = 200; // doubles on each retry (200 → 400 → 800)
22
-
23
- // ── Types ────────────────────────────────────────────────────────────────────
24
-
25
- export interface DispatcherOptions {
26
- ingestUrl: string;
27
- apiKey?: string;
28
- timeoutMs?: number;
29
- batchSize?: number;
30
- flushIntervalMs?: number;
31
- onError?: (error: Error, payloads: TracePayload[]) => void;
32
- }
33
-
34
- // ── Dispatcher ───────────────────────────────────────────────────────────────
35
-
36
- export class Dispatcher implements IDispatcher {
37
- private readonly ingestUrl: string;
38
- private readonly apiKey: string | undefined;
39
- private readonly timeoutMs: number;
40
- private readonly batchSize: number;
41
- private readonly onError: (error: Error, payloads: TracePayload[]) => void;
42
-
43
- /** The in-memory buffer accumulating payloads between flushes. */
44
- private buffer: TracePayload[] = [];
45
-
46
- /** The NodeJS/browser timer handle for the periodic flush. */
47
- private flushTimer: ReturnType<typeof setInterval> | null = null;
48
-
49
- /** Tracks all in-flight fetch Promises so flush() can await them. */
50
- private inFlight = new Set<Promise<void>>();
51
-
52
- constructor(opts: DispatcherOptions) {
53
- this.ingestUrl = opts.ingestUrl;
54
- this.apiKey = opts.apiKey;
55
- this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
56
- this.batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
57
-
58
- this.onError = opts.onError ?? ((err, payloads) => {
59
- console.warn(
60
- `[PromptTracer] Failed to deliver ${payloads.length} trace(s):`,
61
- err.message
62
- );
63
- });
64
-
65
- // Start the periodic flush timer.
66
- const intervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_MS;
67
- this.flushTimer = setInterval(() => {
68
- if (this.buffer.length > 0) {
69
- this._drainBuffer();
70
- }
71
- }, intervalMs);
72
-
73
- // Prevent the timer from keeping a Node process alive indefinitely.
74
- if (typeof this.flushTimer === "object" && typeof (this.flushTimer as NodeJS.Timeout).unref === "function") {
75
- (this.flushTimer as NodeJS.Timeout).unref();
76
- }
77
- }
78
-
79
- // ── Public API ─────────────────────────────────────────────────────────────
80
-
81
- /**
82
- * Accepts a payload and schedules delivery non-blocking via the microtask
83
- * queue. The caller returns immediately; the POST happens asynchronously.
84
- */
85
- send(payload: TracePayload): void {
86
- // Schedule the actual buffer-push on the microtask queue so it never
87
- // adds synchronous overhead to the intercepted LLM call path.
88
- Promise.resolve().then(() => {
89
- this.buffer.push(payload);
90
-
91
- if (this.buffer.length >= this.batchSize) {
92
- this._drainBuffer();
93
- }
94
- });
95
- }
96
-
97
- /**
98
- * Waits for all in-flight requests and flushes any remaining buffered
99
- * payloads. Call this in tests or on process shutdown.
100
- *
101
- * @example
102
- * process.on('SIGTERM', () => tracer.flush());
103
- */
104
- async flush(): Promise<void> {
105
- // Flush whatever is sitting in the buffer right now.
106
- if (this.buffer.length > 0) {
107
- this._drainBuffer();
108
- }
109
-
110
- // Await all in-flight POSTs.
111
- if (this.inFlight.size > 0) {
112
- await Promise.allSettled([...this.inFlight]);
113
- }
114
- }
115
-
116
- /**
117
- * Stops the periodic flush timer and flushes remaining payloads.
118
- * Call when the Tracer instance is being torn down.
119
- */
120
- async destroy(): Promise<void> {
121
- if (this.flushTimer !== null) {
122
- clearInterval(this.flushTimer);
123
- this.flushTimer = null;
124
- }
125
- await this.flush();
126
- }
127
-
128
- // ── Private ────────────────────────────────────────────────────────────────
129
-
130
- /**
131
- * Atomically snapshots and clears the buffer, then initiates an async
132
- * POST. Multiple concurrent drains are safe — each works on its own slice.
133
- */
134
- private _drainBuffer(): void {
135
- const batch = this.buffer.splice(0, this.buffer.length);
136
- if (batch.length === 0) return;
137
-
138
- const promise = this._sendWithRetry(batch, 1).finally(() => {
139
- this.inFlight.delete(promise);
140
- });
141
-
142
- this.inFlight.add(promise);
143
- }
144
-
145
- /**
146
- * Attempts to POST a batch to the ingest endpoint.
147
- * Retries up to MAX_RETRY_ATTEMPTS times with exponential back-off.
148
- * Only retries on network errors or 5xx responses.
149
- */
150
- private async _sendWithRetry(
151
- batch: TracePayload[],
152
- attempt: number
153
- ): Promise<void> {
154
- try {
155
- await this._post(batch);
156
- } catch (err) {
157
- const error = err instanceof Error ? err : new Error(String(err));
158
-
159
- if (attempt < MAX_RETRY_ATTEMPTS) {
160
- const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
161
- await sleep(delay);
162
- return this._sendWithRetry(batch, attempt + 1);
163
- }
164
-
165
- // All retries exhausted — surface to the error handler.
166
- this.onError(error, batch);
167
- }
168
- }
169
-
170
- /**
171
- * Performs the raw HTTP POST with an AbortController timeout.
172
- * Throws on network failure or non-2xx status.
173
- */
174
- private async _post(batch: TracePayload[]): Promise<void> {
175
- const controller = new AbortController();
176
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
177
-
178
- const headers: Record<string, string> = {
179
- "Content-Type": "application/json",
180
- };
181
-
182
- if (this.apiKey) {
183
- headers["x-api-key"] = this.apiKey;
184
- }
185
-
186
- let response: Response;
187
- try {
188
- response = await fetch(this.ingestUrl, {
189
- method: "POST",
190
- headers,
191
- body: JSON.stringify({ traces: batch }),
192
- signal: controller.signal,
193
- });
194
- } finally {
195
- clearTimeout(timer);
196
- }
197
-
198
- if (!response.ok) {
199
- if (response.status >= 500) {
200
- throw new Error(`Ingest endpoint returned ${response.status}`);
201
- }
202
- console.warn(
203
- `[PromptTracer] Ingest rejected batch (${response.status}). ` +
204
- `Discarding ${batch.length} trace(s).`
205
- );
206
- }
207
- }
208
- // ── Helpers ──────────────────────────────────────────────────────────────────
209
- }
210
- function sleep(ms: number): Promise<void> {
211
- return new Promise((resolve) => setTimeout(resolve, ms));
212
- }
@@ -1,171 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────────────────────
2
- // packages/sdk/src/core/tracer.ts
3
- //
4
- // Responsibilities:
5
- // 1. Own one logical "session" (a single agent run).
6
- // 2. Maintain a monotonic step counter across all calls in that session.
7
- // 3. Enrich a RawCapturePayload into a full TracePayload (ids, cost, ts).
8
- // 4. Hand the enriched payload to the Dispatcher non-blocking.
9
- // 5. Expose a flush() for clean shutdown / test assertions.
10
- // ─────────────────────────────────────────────────────────────────────────────
11
-
12
- import { Dispatcher } from "./dispatcher";
13
- import { calcCostUsd } from "../utils/cost";
14
- import type {
15
- RawCapturePayload,
16
- TracePayload,
17
- TracerOptions,
18
- IDispatcher,
19
- } from "./types";
20
-
21
- // ── SDK version (keep in sync with package.json) ─────────────────────────────
22
- const SDK_VERSION = "0.1.0";
23
-
24
- // ── UUID helper ──────────────────────────────────────────────────────────────
25
- // crypto.randomUUID() is available in Node ≥ 14.17, modern browsers, and
26
- // the Edge runtime. Provide a tiny fallback for exotic environments.
27
- function uuid(): string {
28
- if (
29
- typeof crypto !== "undefined" &&
30
- typeof crypto.randomUUID === "function"
31
- ) {
32
- return crypto.randomUUID();
33
- }
34
- // Fallback: RFC-4122 v4 UUID
35
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
36
- const r = (Math.random() * 16) | 0;
37
- const v = c === "x" ? r : (r & 0x3) | 0x8;
38
- return v.toString(16);
39
- });
40
- }
41
-
42
- // ── Tracer ────────────────────────────────────────────────────────────────────
43
-
44
- export class Tracer {
45
- /** Groups all LLM calls in this agent run. */
46
- readonly sessionId: string;
47
-
48
- /** Caller-supplied arbitrary metadata attached to every payload. */
49
- private readonly metadata: Record<string, string>;
50
-
51
- /** Whether telemetry is active (can be disabled via options). */
52
- private readonly enabled: boolean;
53
-
54
- /** Delivery engine — injectable for unit-testing. */
55
- private readonly dispatcher: IDispatcher;
56
-
57
- /**
58
- * Monotonically increasing step counter.
59
- * Step 1 → first call in the session (triggers full snapshot in the DB).
60
- * Step N → subsequent calls (store diff only).
61
- */
62
- private stepCounter = 0;
63
-
64
- constructor(opts: TracerOptions, dispatcher?: IDispatcher) {
65
- this.sessionId = opts.sessionId ?? uuid();
66
- this.metadata = opts.metadata ?? {};
67
- this.enabled = opts.enabled ?? true;
68
-
69
- // Use an injected dispatcher (useful in tests) or create the real one.
70
- this.dispatcher = dispatcher ?? new Dispatcher({
71
- ingestUrl: opts.ingestUrl,
72
- apiKey: opts.apiKey,
73
- timeoutMs: opts.timeoutMs,
74
- onError: opts.onError
75
- ? (err, payloads) => payloads.forEach((p) => opts.onError!(err, p))
76
- : undefined,
77
- });
78
- }
79
-
80
- // ── Public API ──────────────────────────────────────────────────────────────
81
-
82
- /**
83
- * The method called by every SDK wrapper after intercepting an LLM call.
84
- *
85
- * Design contract:
86
- * - NEVER awaited by the wrapper; fire-and-forget on microtask queue.
87
- * - Returns void so the wrapper cannot accidentally `await` it.
88
- *
89
- * @example
90
- * // Inside wrappers/openai.ts — after receiving the result:
91
- * tracer.captureAsync({ prompt, response, model, tokensIn, tokensOut, latencyMs, isStream });
92
- */
93
- captureAsync(raw: RawCapturePayload): void {
94
- if (!this.enabled) return;
95
-
96
- // Schedule enrichment + dispatch asynchronously so it never adds
97
- // synchronous latency to the intercepted call path.
98
- Promise.resolve().then(() => {
99
- try {
100
- const payload = this._enrich(raw);
101
- this.dispatcher.send(payload);
102
- } catch (err) {
103
- // Tracer must NEVER throw into user code.
104
- console.warn("[PromptTracer] Failed to enrich payload:", err);
105
- }
106
- });
107
- }
108
-
109
- /**
110
- * Returns the step index the *next* call will be assigned.
111
- * Useful for callers who need to know if this is step 1 (full snapshot)
112
- * vs. a later step (diff only) before making the LLM call.
113
- */
114
- get nextStepIndex(): number {
115
- return this.stepCounter + 1;
116
- }
117
-
118
- /**
119
- * Waits for all buffered and in-flight payloads to be delivered.
120
- * Call before process exit or at the end of integration tests.
121
- *
122
- * @example
123
- * afterAll(async () => { await tracer.flush(); });
124
- */
125
- async flush(): Promise<void> {
126
- await this.dispatcher.flush();
127
- }
128
-
129
- // ── Private ─────────────────────────────────────────────────────────────────
130
-
131
- /**
132
- * Takes a raw capture from the wrapper and enriches it with:
133
- * - a unique callId
134
- * - the session's sessionId
135
- * - a monotonic stepIndex
136
- * - ISO-8601 timestamp
137
- * - USD cost estimate
138
- * - SDK version string
139
- * - caller metadata
140
- */
141
- private _enrich(raw: RawCapturePayload): TracePayload {
142
- this.stepCounter += 1;
143
-
144
- const estimatedCostUsd = calcCostUsd({
145
- model: raw.model,
146
- tokensIn: raw.tokensIn ?? 0,
147
- tokensOut: raw.tokensOut ?? 0,
148
- });
149
-
150
- return {
151
- // ── Core identity ───────────────────────────────────────────────────
152
- callId: uuid(),
153
- sessionId: this.sessionId,
154
- stepIndex: this.stepCounter,
155
- timestamp: new Date().toISOString(),
156
-
157
- // ── Raw capture data (passed through unchanged) ──────────────────────
158
- ...raw,
159
-
160
- // ── Enrichment ───────────────────────────────────────────────────────
161
- estimatedCostUsd,
162
- sdkVersion: SDK_VERSION,
163
-
164
- // Merge metadata into the payload so the ingest API can index on it.
165
- // We spread it flat; the ingest schema should have a metadata JSONB col.
166
- ...(Object.keys(this.metadata).length > 0
167
- ? { metadata: this.metadata }
168
- : {}),
169
- } as TracePayload;
170
- }
171
- }
package/src/core/types.ts DELETED
@@ -1,73 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────────────────────
2
- // packages/sdk/src/core/types.ts
3
- // Central type contracts for the entire SDK. No runtime code lives here.
4
- // ─────────────────────────────────────────────────────────────────────────────
5
-
6
- /** A single message in an OpenAI-compatible chat conversation. */
7
- export interface ChatMessage {
8
- role: "system" | "user" | "assistant" | "tool" | "function";
9
- content: string | null;
10
- name?: string;
11
- tool_call_id?: string;
12
- }
13
-
14
- /** Raw data captured at the intercept point, before any enrichment. */
15
- export interface RawCapturePayload {
16
- /** The messages array sent to the model. */
17
- prompt: ChatMessage[] | readonly ChatMessage[];
18
- /** The text content of the completion (reconstructed for streams). */
19
- response: string;
20
- /** Model string exactly as passed by the caller e.g. "gpt-4o". */
21
- model: string;
22
- /** Prompt tokens from usage object. Undefined for streams (not available). */
23
- tokensIn: number | undefined;
24
- /** Completion tokens. For streams this is an approximation (chunk count). */
25
- tokensOut: number | undefined;
26
- /** Wall-clock ms from request start to last byte received. */
27
- latencyMs: number;
28
- /** Whether the call used server-sent streaming. */
29
- isStream: boolean;
30
- }
31
-
32
- /** The fully enriched payload that gets pushed to the ingest queue. */
33
- export interface TracePayload extends RawCapturePayload {
34
- /** SDK-generated UUID for this individual LLM call. */
35
- callId: string;
36
- /** Session/trace ID grouping multiple calls in one agent run.
37
- * Set via TracerOptions.sessionId or auto-generated per Tracer instance. */
38
- sessionId: string;
39
- /** Step index within the session (1-based, incremented per capture). */
40
- stepIndex: number;
41
- /** ISO-8601 timestamp of the call start. */
42
- timestamp: string;
43
- /** Estimated USD cost for this call. */
44
- estimatedCostUsd: number;
45
- /** Version string of the SDK emitting this payload. */
46
- sdkVersion: string;
47
- }
48
-
49
- /** Options passed when constructing a Tracer instance. */
50
- export interface TracerOptions {
51
- /** Full URL of your Next.js ingest endpoint.
52
- * e.g. "https://your-app.vercel.app/api/ingest" */
53
- ingestUrl: string;
54
- apiKey?: string;
55
- /** Optional session ID to group multiple calls into one trace. If omitted,
56
- /** Groups multiple LLM calls into one logical agent run.
57
- * Auto-generated (UUID v4) if omitted. */
58
- sessionId?: string;
59
- /** Attach arbitrary key/value metadata to every payload (e.g. userId, env). */
60
- metadata?: Record<string, string>;
61
- /** Max ms to wait for the ingest POST before aborting. Default: 5000. */
62
- timeoutMs?: number;
63
- /** Called when the ingest POST fails. Defaults to console.warn. */
64
- onError?: (error: Error, payload: TracePayload) => void;
65
- /** Set false to completely disable telemetry (e.g. in unit tests). Default: true */
66
- enabled?: boolean;
67
- }
68
-
69
- /** Minimal interface the Dispatcher must satisfy — useful for testing. */
70
- export interface IDispatcher {
71
- send(payload: TracePayload): void; // non-blocking fire-and-forget
72
- flush(): Promise<void>; // drain all pending sends (use in tests / shutdown)
73
- }
package/src/diff.ts DELETED
@@ -1,181 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────────────────────
2
- // packages/sdk/src/utils/diff.ts
3
- //
4
- // Computes a minimal, git-style diff between two chat message arrays.
5
- // The ingest API uses this to decide what to store:
6
- // - Step 1 → store full snapshot (no previous to diff against)
7
- // - Step 2+ → store only the diff; reconstruct full array on the frontend
8
- //
9
- // Design goals:
10
- // 1. Deterministic — same inputs always produce same diff.
11
- // 2. Reversible — applyDiff(prev, computeDiff(prev, curr)) === curr
12
- // 3. Zero dependencies on the openai SDK — works with plain objects.
13
- // ─────────────────────────────────────────────────────────────────────────────
14
-
15
- import type { ChatMessage } from "./core/types";
16
-
17
- // ── Types ─────────────────────────────────────────────────────────────────────
18
-
19
- /** A single entry in the diff — describes ONE message's change. */
20
- export type DiffOperation =
21
- | { op: "add"; index: number; message: ChatMessage }
22
- | { op: "remove"; index: number }
23
- | { op: "keep"; index: number }; // kept for position bookkeeping
24
-
25
- /** The payload stored in prompt_snapshots.diff_from_previous */
26
- export interface MessageDiff {
27
- /** Only the add/remove operations (keeps are omitted to save bytes). */
28
- operations: Array<
29
- | { op: "add"; index: number; message: ChatMessage }
30
- | { op: "remove"; index: number }
31
- >;
32
- /** Net token change: positive = context grew, negative = messages pruned. */
33
- tokenDelta: number;
34
- /** How many messages were added in this step. */
35
- added: number;
36
- /** How many messages were removed in this step. */
37
- removed: number;
38
- }
39
-
40
- // ── Helpers ───────────────────────────────────────────────────────────────────
41
-
42
- /**
43
- * Stable string key for a message — used for identity comparison.
44
- * We hash role + content so order changes are detected correctly.
45
- */
46
- function messageKey(m: ChatMessage): string {
47
- return `${m.role}::${m.content ?? ""}`;
48
- }
49
-
50
- /**
51
- * Rough token estimator — 1 token ≈ 4 characters (GPT rule of thumb).
52
- * The SDK does not run a full tokenizer to stay dependency-free.
53
- * The backend can re-calculate with tiktoken if needed.
54
- */
55
- export function estimateTokens(messages: readonly ChatMessage[]): number {
56
- return messages.reduce((sum, m) => {
57
- const chars = (m.content ?? "").length;
58
- return sum + Math.ceil(chars / 4);
59
- }, 0);
60
- }
61
-
62
- // ── Core diff algorithm ───────────────────────────────────────────────────────
63
-
64
- /**
65
- * Computes the minimal diff between `prev` and `curr` message arrays.
66
- *
67
- * Algorithm: O(n) two-pointer walk.
68
- * 1. Build a Set of keys in `prev` for O(1) lookup.
69
- * 2. Walk `curr` — any message not in `prev` is an ADD.
70
- * 3. Walk `prev` — any message not in `curr` is a REMOVE.
71
- *
72
- * This is sufficient for 99% of real agent patterns where the context
73
- * array only ever has messages appended (never reordered mid-stream).
74
- * For adversarial reordering, swap to Myers diff.
75
- *
76
- * @example
77
- * const diff = computeMessageDiff(step1Messages, step2Messages);
78
- * // { operations: [{ op: "add", index: 3, message: {...} }], tokenDelta: 42, added: 1, removed: 0 }
79
- */
80
- export function computeMessageDiff(
81
- prev: readonly ChatMessage[],
82
- curr: readonly ChatMessage[]
83
- ): MessageDiff {
84
- const prevKeys = new Set(prev.map(messageKey));
85
- const currKeys = new Set(curr.map(messageKey));
86
-
87
- const operations: MessageDiff["operations"] = [];
88
-
89
- // Detect additions — messages in curr that weren't in prev
90
- curr.forEach((message, index) => {
91
- if (!prevKeys.has(messageKey(message))) {
92
- operations.push({ op: "add", index, message });
93
- }
94
- });
95
-
96
- // Detect removals — messages in prev that aren't in curr
97
- prev.forEach((message, index) => {
98
- if (!currKeys.has(messageKey(message))) {
99
- operations.push({ op: "remove", index });
100
- }
101
- });
102
-
103
- const tokenDelta =
104
- estimateTokens(curr) - estimateTokens(prev);
105
-
106
- return {
107
- operations,
108
- tokenDelta,
109
- added: operations.filter((o) => o.op === "add").length,
110
- removed: operations.filter((o) => o.op === "remove").length,
111
- };
112
- }
113
-
114
- // ── Reconstruction (used by the frontend to replay diffs) ─────────────────────
115
-
116
- /**
117
- * Applies a stored diff forward onto a base message array.
118
- * The frontend calls this to reconstruct the full message array for step N:
119
- *
120
- * const step1 = fullSnapshot; // stored in DB for step 1
121
- * const step2 = applyDiff(step1, diff); // reconstructed from diff
122
- * const step3 = applyDiff(step2, diff); // and so on...
123
- *
124
- * @throws {Error} if the diff references an out-of-bounds index.
125
- */
126
- export function applyDiff(
127
- base: readonly ChatMessage[],
128
- diff: MessageDiff
129
- ): ChatMessage[] {
130
- const result = [...base];
131
-
132
- // Process removes first (high-index first to avoid index shifting)
133
- const removes = diff.operations
134
- .filter((o): o is { op: "remove"; index: number } => o.op === "remove")
135
- .sort((a, b) => b.index - a.index);
136
-
137
- for (const op of removes) {
138
- if (op.index >= result.length) {
139
- throw new Error(
140
- `[PromptTracer] applyDiff: remove index ${op.index} out of bounds (len=${result.length})`
141
- );
142
- }
143
- result.splice(op.index, 1);
144
- }
145
-
146
- // Process adds (low-index first to preserve insertion order)
147
- const adds = diff.operations
148
- .filter(
149
- (o): o is { op: "add"; index: number; message: ChatMessage } =>
150
- o.op === "add"
151
- )
152
- .sort((a, b) => a.index - b.index);
153
-
154
- for (const op of adds) {
155
- result.splice(op.index, 0, op.message);
156
- }
157
-
158
- return result;
159
- }
160
-
161
- /**
162
- * Replays an ordered series of diffs from a base snapshot.
163
- * Use this when you need to reconstruct every step in a session at once.
164
- *
165
- * @example
166
- * const steps = replayDiffs(step1Snapshot, [diff2, diff3, diff4]);
167
- * // steps[0] === step1, steps[1] === step2, steps[2] === step3, steps[3] === step4
168
- */
169
- export function replayDiffs(
170
- baseSnapshot: readonly ChatMessage[],
171
- diffs: MessageDiff[]
172
- ): ChatMessage[][] {
173
- const results: ChatMessage[][] = [Array.from(baseSnapshot)];
174
-
175
- for (const diff of diffs) {
176
- const prev = results[results.length - 1];
177
- results.push(applyDiff(prev, diff));
178
- }
179
-
180
- return results;
181
- }
package/src/index.ts DELETED
@@ -1,24 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────────────────────
2
- // packages/sdk/src/index.ts
3
- // Public surface of the @prompt-tracer/sdk package.
4
- // ─────────────────────────────────────────────────────────────────────────────
5
-
6
- // Core
7
- export { Tracer } from "./core/tracer";
8
- export { Dispatcher } from "./core/dispatcher";
9
-
10
- // Wrappers
11
- export { wrapOpenAI } from "./wrappers/openai";
12
-
13
- // Utilities
14
- export { calcCostUsd, formatCostUsd } from "./utils/cost";
15
-
16
- // Types — consumers can import these without `import type` gymnastics
17
- export type {
18
- ChatMessage,
19
- RawCapturePayload,
20
- TracePayload,
21
- TracerOptions,
22
- IDispatcher,
23
- } from "./core/types";
24
- export type { DispatcherOptions } from "./core/dispatcher";
package/src/utils/cost.ts DELETED
@@ -1,104 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────────────────────
2
- // packages/sdk/src/utils/cost.ts
3
- //
4
- // Calculates estimated USD cost for a single LLM call.
5
- // Prices are per 1 million tokens (as published by each provider).
6
- // Update MODEL_PRICES when providers change pricing.
7
- // ─────────────────────────────────────────────────────────────────────────────
8
-
9
- interface ModelPrice {
10
- /** USD per 1M input tokens */
11
- inputPer1M: number;
12
- /** USD per 1M output tokens */
13
- outputPer1M: number;
14
- }
15
-
16
- /**
17
- * Pricing table keyed by model string.
18
- * Keys are matched with startsWith() so "gpt-4o-mini-2024-07-18" resolves
19
- * to the "gpt-4o-mini" entry automatically.
20
- */
21
- const MODEL_PRICES: Record<string, ModelPrice> = {
22
- // ── OpenAI ──────────────────────────────────────────────────────────────
23
- "gpt-4o": { inputPer1M: 2.50, outputPer1M: 10.00 },
24
- "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.60 },
25
- "gpt-4-turbo": { inputPer1M: 10.00, outputPer1M: 30.00 },
26
- "gpt-4": { inputPer1M: 30.00, outputPer1M: 60.00 },
27
- "gpt-3.5-turbo": { inputPer1M: 0.50, outputPer1M: 1.50 },
28
- "o1": { inputPer1M: 15.00, outputPer1M: 60.00 },
29
- "o1-mini": { inputPer1M: 3.00, outputPer1M: 12.00 },
30
- "o3-mini": { inputPer1M: 1.10, outputPer1M: 4.40 },
31
-
32
- // ── Anthropic ───────────────────────────────────────────────────────────
33
- "claude-opus-4": { inputPer1M: 15.00, outputPer1M: 75.00 },
34
- "claude-sonnet-4": { inputPer1M: 3.00, outputPer1M: 15.00 },
35
- "claude-haiku-4": { inputPer1M: 0.80, outputPer1M: 4.00 },
36
- "claude-3-5-sonnet": { inputPer1M: 3.00, outputPer1M: 15.00 },
37
- "claude-3-5-haiku": { inputPer1M: 0.80, outputPer1M: 4.00 },
38
- "claude-3-opus": { inputPer1M: 15.00, outputPer1M: 75.00 },
39
-
40
- // ── Google ──────────────────────────────────────────────────────────────
41
- "gemini-1.5-pro": { inputPer1M: 3.50, outputPer1M: 10.50 },
42
- "gemini-1.5-flash": { inputPer1M: 0.35, outputPer1M: 1.05 },
43
- "gemini-2.0-flash": { inputPer1M: 0.10, outputPer1M: 0.40 },
44
- };
45
-
46
- /** Fallback when the model string is unrecognised. */
47
- const UNKNOWN_PRICE: ModelPrice = { inputPer1M: 0, outputPer1M: 0 };
48
-
49
- // ── Helpers ───────────────────────────────────────────────────────────────────
50
-
51
- /**
52
- * Resolves a model string to its price entry.
53
- * Tries exact match first, then prefix match (handles dated model variants).
54
- */
55
- function resolvePrice(model: string): ModelPrice {
56
- const normalised = model.toLowerCase().trim();
57
-
58
- // 1. Exact match
59
- if (normalised in MODEL_PRICES) return MODEL_PRICES[normalised];
60
-
61
- // 2. Prefix match — e.g. "gpt-4o-2024-11-20" → "gpt-4o"
62
- for (const key of Object.keys(MODEL_PRICES)) {
63
- if (normalised.startsWith(key)) return MODEL_PRICES[key];
64
- }
65
-
66
- return UNKNOWN_PRICE;
67
- }
68
-
69
- // ── Public API ────────────────────────────────────────────────────────────────
70
-
71
- export interface CalcCostParams {
72
- model: string;
73
- tokensIn: number;
74
- tokensOut: number;
75
- }
76
-
77
- /**
78
- * Returns the estimated USD cost for a single LLM call.
79
- * Returns 0 for unrecognised models rather than throwing, so the SDK
80
- * never crashes user code due to a missing pricing entry.
81
- *
82
- * @example
83
- * calcCostUsd({ model: "gpt-4o", tokensIn: 1000, tokensOut: 500 })
84
- * // → 0.00750
85
- */
86
- export function calcCostUsd({ model, tokensIn, tokensOut }: CalcCostParams): number {
87
- const price = resolvePrice(model);
88
- const inputCost = (tokensIn / 1_000_000) * price.inputPer1M;
89
- const outputCost = (tokensOut / 1_000_000) * price.outputPer1M;
90
- // Round to 8 decimal places to avoid floating-point noise in the DB.
91
- return Math.round((inputCost + outputCost) * 1e8) / 1e8;
92
- }
93
-
94
- /**
95
- * Formats a USD cost as a human-readable string.
96
- * @example formatCost(0.0075) → "$0.0075"
97
- * @example formatCost(0.00000120) → "$0.0000012"
98
- */
99
- export function formatCostUsd(usd: number): string {
100
- if (usd === 0) return "$0.00";
101
- if (usd < 0.0001) return `$${usd.toFixed(8).replace(/0+$/, "")}`;
102
- if (usd < 0.01) return `$${usd.toFixed(6).replace(/0+$/, "")}`;
103
- return `$${usd.toFixed(4)}`;
104
- }
@@ -1,193 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────────────────────
2
- // packages/sdk/src/wrappers/openai.ts
3
- //
4
- // Wraps an OpenAI client instance in a deeply nested Proxy that intercepts
5
- // `chat.completions.create`, captures telemetry, and fires it non-blocking.
6
- //
7
- // Key guarantees:
8
- // 1. The original OpenAI types are 100% preserved — callers see no diffs.
9
- // 2. Streaming responses (`stream: true`) are fully supported via an async
10
- // generator that transparently yields every chunk unchanged.
11
- // 3. Telemetry is fired via the microtask queue — zero latency added.
12
- // 4. Nothing is monkey-patched; the original client is never mutated.
13
- // ─────────────────────────────────────────────────────────────────────────────
14
-
15
- import OpenAI from "openai";
16
- import type {
17
- ChatCompletionCreateParamsNonStreaming,
18
- ChatCompletionCreateParamsStreaming,
19
- ChatCompletion,
20
- } from "openai/resources/chat/completions";
21
- import type { Stream } from "openai/streaming";
22
- import type { ChatCompletionChunk } from "openai/resources";
23
- import type { Tracer } from "../core/tracer";
24
- import type { ChatMessage } from "../core/types";
25
-
26
- // ── Type helpers ──────────────────────────────────────────────────────────────
27
-
28
- type CreateParams =
29
- | ChatCompletionCreateParamsNonStreaming
30
- | ChatCompletionCreateParamsStreaming;
31
-
32
- // ── Main export ───────────────────────────────────────────────────────────────
33
-
34
- /**
35
- * Wraps an OpenAI client with a transparent telemetry layer.
36
- *
37
- * @param client The original `new OpenAI(...)` instance.
38
- * @param tracer A configured `Tracer` instance (owns the session + dispatch).
39
- * @returns A Proxy of the client — drop-in replacement, same types.
40
- *
41
- * @example
42
- * import OpenAI from "openai";
43
- * import { Tracer, wrapOpenAI } from "@prompt-tracer/sdk";
44
- *
45
- * const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
46
- * const tracer = new Tracer({ ingestUrl: "https://your-app.com/api/ingest" });
47
- * const ai = wrapOpenAI(openai, tracer);
48
- *
49
- * // Use exactly like the original client — streaming, tools, everything works.
50
- * const res = await ai.chat.completions.create({ model: "gpt-4o", messages });
51
- */
52
- export function wrapOpenAI(client: OpenAI, tracer: Tracer): OpenAI {
53
- return new Proxy(client, {
54
- get(target, prop, receiver) {
55
- // ── Intercept .chat ───────────────────────────────────────────────────
56
- if (prop === "chat") {
57
- return new Proxy(target.chat, {
58
- get(chatTarget, chatProp, chatReceiver) {
59
- // ── Intercept .chat.completions ─────────────────────────────────
60
- if (chatProp === "completions") {
61
- return new Proxy(chatTarget.completions, {
62
- get(compTarget, compProp, compReceiver) {
63
- // ── Intercept .chat.completions.create ────────────────────
64
- if (compProp === "create") {
65
- return _makeCreateInterceptor(compTarget, tracer);
66
- }
67
- // All other completions methods (e.g. .stream()) pass through
68
- return Reflect.get(compTarget, compProp, compReceiver);
69
- },
70
- });
71
- }
72
- return Reflect.get(chatTarget, chatProp, chatReceiver);
73
- },
74
- });
75
- }
76
- // All other top-level methods (embeddings, images, etc.) pass through
77
- return Reflect.get(target, prop, receiver);
78
- },
79
- });
80
- }
81
-
82
- // ── Interceptor factory ───────────────────────────────────────────────────────
83
-
84
- /**
85
- * Returns the replacement `create` function that wraps the original.
86
- * Extracted so the Proxy `get` handler stays readable.
87
- */
88
- function _makeCreateInterceptor(
89
- compTarget: OpenAI["chat"]["completions"],
90
- tracer: Tracer
91
- ) {
92
- // Overloaded signature mirrors the OpenAI SDK exactly so TypeScript callers
93
- // see the correct return type based on whether `stream` is true.
94
- async function create(
95
- params: ChatCompletionCreateParamsNonStreaming
96
- ): Promise<ChatCompletion>;
97
- async function create(
98
- params: ChatCompletionCreateParamsStreaming
99
- ): Promise<Stream<ChatCompletionChunk>>;
100
- async function create(params: CreateParams): Promise<unknown> {
101
- const startMs = Date.now();
102
-
103
- if (params.stream === true) {
104
- // ── Streaming path ────────────────────────────────────────────────────
105
- // We must return an async generator so the caller's `for await` loop
106
- // works identically to the original SDK.
107
- const stream = await (
108
- compTarget.create as (
109
- p: ChatCompletionCreateParamsStreaming
110
- ) => Promise<Stream<ChatCompletionChunk>>
111
- )(params as ChatCompletionCreateParamsStreaming);
112
-
113
- return _wrapStream(stream, params, startMs, tracer);
114
- }
115
-
116
- // ── Non-streaming path ────────────────────────────────────────────────────
117
- const result = await (
118
- compTarget.create as (
119
- p: ChatCompletionCreateParamsNonStreaming
120
- ) => Promise<ChatCompletion>
121
- )(params as ChatCompletionCreateParamsNonStreaming);
122
-
123
- const latencyMs = Date.now() - startMs;
124
-
125
- // Fire telemetry onto the microtask queue — never blocks the caller.
126
- tracer.captureAsync({
127
- prompt: params.messages as ChatMessage[],
128
- response: result.choices[0]?.message?.content ?? "",
129
- model: params.model,
130
- tokensIn: result.usage?.prompt_tokens,
131
- tokensOut: result.usage?.completion_tokens,
132
- latencyMs,
133
- isStream: false,
134
- });
135
-
136
- return result;
137
- }
138
-
139
- return create;
140
- }
141
-
142
- // ── Stream wrapper ────────────────────────────────────────────────────────────
143
-
144
- /**
145
- * Wraps an OpenAI streaming response in an async generator that:
146
- * 1. Yields every chunk to the caller unchanged.
147
- * 2. Reconstructs the full text and counts chunks.
148
- * 3. Fires telemetry after the last chunk via the microtask queue.
149
- *
150
- * The returned generator preserves the `Symbol.asyncIterator` contract so
151
- * `for await (const chunk of stream)` works exactly as before.
152
- */
153
- async function* _wrapStream(
154
- stream: Stream<ChatCompletionChunk>,
155
- params: CreateParams,
156
- startMs: number,
157
- tracer: Tracer
158
- ): AsyncGenerator<ChatCompletionChunk> {
159
- let fullContent = "";
160
- let chunkCount = 0;
161
- let promptTokens: number | undefined;
162
-
163
- try {
164
- for await (const chunk of stream) {
165
- // Capture prompt tokens if the first chunk carries usage data
166
- // (available when `stream_options: { include_usage: true }` is set).
167
- if (chunk.usage?.prompt_tokens !== undefined) {
168
- promptTokens = chunk.usage.prompt_tokens;
169
- }
170
-
171
- const delta = chunk.choices[0]?.delta?.content ?? "";
172
- fullContent += delta;
173
- chunkCount += 1;
174
-
175
- yield chunk; // ← caller gets every chunk unmodified, zero delay
176
- }
177
- } finally {
178
- // `finally` runs whether the caller broke out early or read everything.
179
- const latencyMs = Date.now() - startMs;
180
-
181
- // Schedule telemetry on the microtask queue so it fires after the
182
- // caller's current `for await` iteration completes.
183
- tracer.captureAsync({
184
- prompt: params.messages as ChatMessage[],
185
- response: fullContent,
186
- model: params.model,
187
- tokensIn: promptTokens, // exact if include_usage was set
188
- tokensOut: chunkCount, // approximation: 1 chunk ≈ 1 token
189
- latencyMs,
190
- isStream: true,
191
- });
192
- }
193
- }
package/tsup.config.ts DELETED
@@ -1,7 +0,0 @@
1
- import { defineConfig } from 'tsup';
2
- export default defineConfig({
3
- entry: ['src/index.ts'],
4
- format: ['cjs', 'esm'],
5
- dts: false,
6
- clean: true,
7
- });