0xtrace 1.0.0 → 1.0.1
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/dist/index.cjs +458 -0
- package/dist/index.js +430 -1
- package/package.json +15 -10
- package/src/core/dispatcher.ts +212 -0
- package/src/core/tracer.ts +171 -0
- package/src/core/types.ts +73 -0
- package/src/diff.ts +181 -0
- package/src/index.ts +20 -33
- package/src/utils/cost.ts +104 -0
- package/src/wrappers/openai.ts +193 -0
- package/tsup.config.ts +5 -10
- package/dist/index.d.mts +0 -14
- package/dist/index.d.ts +0 -14
- package/dist/index.mjs +0 -1
- package/tsconfig.json +0 -23
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
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
CHANGED
|
@@ -1,37 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
messages: any[];
|
|
6
|
-
latencyMs: number;
|
|
7
|
-
tokensIn: number;
|
|
8
|
-
tokensOut: number;
|
|
9
|
-
estimatedCostUsd: number;
|
|
10
|
-
isStream?: boolean;
|
|
11
|
-
}
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// packages/sdk/src/index.ts
|
|
3
|
+
// Public surface of the @prompt-tracer/sdk package.
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
5
|
|
|
6
|
+
// Core
|
|
7
|
+
export { Tracer } from "./core/tracer";
|
|
8
|
+
export { Dispatcher } from "./core/dispatcher";
|
|
13
9
|
|
|
14
|
-
//
|
|
10
|
+
// Wrappers
|
|
11
|
+
export { wrapOpenAI } from "./wrappers/openai";
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
13
|
+
// Utilities
|
|
14
|
+
export { calcCostUsd, formatCostUsd } from "./utils/cost";
|
|
19
15
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
body: JSON.stringify({
|
|
30
|
-
...payload,
|
|
31
|
-
timestamp: new Date().toISOString(),
|
|
32
|
-
}),
|
|
33
|
-
}).catch((e) => console.error(e));
|
|
34
|
-
} catch (error) {
|
|
35
|
-
console.error(error);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
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";
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
}
|