deadpipe 1.0.1 → 2.0.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/dist/index.mjs CHANGED
@@ -1,44 +1,382 @@
1
1
  // src/index.ts
2
- var Deadpipe = class {
2
+ var VERSION = "2.0.0";
3
+ var MODEL_COSTS = {
4
+ // OpenAI
5
+ "gpt-4": { input: 0.03, output: 0.06 },
6
+ // legacy
7
+ "gpt-4-turbo": { input: 0.01, output: 0.03 },
8
+ // legacy
9
+ "gpt-4o": { input: 5e-3, output: 0.015 },
10
+ "gpt-4o-mini": { input: 15e-5, output: 6e-4 },
11
+ "gpt-4.1": { input: 2e-3, output: 8e-3 },
12
+ "gpt-3.5-turbo": { input: 5e-4, output: 15e-4 },
13
+ "gpt-5": { input: 175e-5, output: 0.014 },
14
+ "gpt-5-mini": { input: 25e-5, output: 2e-3 },
15
+ "gpt-5.2-pro": { input: 0.021, output: 0.168 },
16
+ // Anthropic
17
+ "claude-3-opus": { input: 0.015, output: 0.075 },
18
+ "claude-3-sonnet": { input: 3e-3, output: 0.015 },
19
+ "claude-3-haiku": { input: 25e-5, output: 125e-5 },
20
+ "claude-3.5-sonnet": { input: 3e-3, output: 0.015 },
21
+ "claude-opus-4": { input: 0.015, output: 0.075 },
22
+ "claude-sonnet-4": { input: 3e-3, output: 0.015 },
23
+ "claude-haiku-4": { input: 25e-5, output: 125e-5 }
24
+ };
25
+ function estimateCost(model, inputTokens, outputTokens) {
26
+ const modelLower = model.toLowerCase();
27
+ for (const [knownModel, costs] of Object.entries(MODEL_COSTS)) {
28
+ if (modelLower.includes(knownModel)) {
29
+ const inputCost = inputTokens / 1e3 * costs.input;
30
+ const outputCost = outputTokens / 1e3 * costs.output;
31
+ return Math.round((inputCost + outputCost) * 1e6) / 1e6;
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ function hashContentSync(content) {
37
+ let hash = 5381;
38
+ for (let i = 0; i < content.length; i++) {
39
+ hash = (hash << 5) + hash + content.charCodeAt(i);
40
+ }
41
+ return Math.abs(hash).toString(16).slice(0, 16).padStart(16, "0");
42
+ }
43
+ function hashMessages(messages) {
44
+ const serialized = JSON.stringify(messages);
45
+ return hashContentSync(serialized);
46
+ }
47
+ function hashTools(tools) {
48
+ if (!tools || tools.length === 0) return void 0;
49
+ const serialized = JSON.stringify(tools);
50
+ return hashContentSync(serialized);
51
+ }
52
+ var REFUSAL_PATTERNS = [
53
+ "i can't help with",
54
+ "i cannot help with",
55
+ "i'm not able to",
56
+ "i am not able to",
57
+ "i won't be able to",
58
+ "i'm unable to",
59
+ "i cannot provide",
60
+ "i can't provide",
61
+ "i must decline",
62
+ "i cannot assist with",
63
+ "i can't assist with",
64
+ "as an ai",
65
+ "as a language model",
66
+ "i don't have the ability",
67
+ "i cannot comply",
68
+ "i'm designed to",
69
+ "my purpose is to",
70
+ "violates my guidelines",
71
+ "against my guidelines",
72
+ "ethical guidelines",
73
+ "i apologize, but i cannot",
74
+ "i'm sorry, but i can't"
75
+ ];
76
+ function detectRefusal(text) {
77
+ const textLower = text.toLowerCase();
78
+ return REFUSAL_PATTERNS.some((pattern) => textLower.includes(pattern));
79
+ }
80
+ function validateEnumBounds(data, enumFields) {
81
+ if (!enumFields) return true;
82
+ for (const [fieldName, validValues] of Object.entries(enumFields)) {
83
+ if (fieldName in data && !validValues.includes(data[fieldName])) {
84
+ return false;
85
+ }
86
+ }
87
+ return true;
88
+ }
89
+ function validateNumericBounds(data, numericBounds) {
90
+ if (!numericBounds) return true;
91
+ for (const [fieldName, [minVal, maxVal]] of Object.entries(numericBounds)) {
92
+ if (fieldName in data) {
93
+ const value = data[fieldName];
94
+ if (typeof value === "number") {
95
+ if (minVal !== null && value < minVal) return false;
96
+ if (maxVal !== null && value > maxVal) return false;
97
+ }
98
+ }
99
+ }
100
+ return true;
101
+ }
102
+ function extractOpenAIResponse(response) {
103
+ const result = {
104
+ model: "",
105
+ content: "",
106
+ inputTokens: null,
107
+ outputTokens: null,
108
+ totalTokens: null,
109
+ finishReason: null,
110
+ toolCalls: [],
111
+ logprobs: null
112
+ };
113
+ if (response?.model) {
114
+ result.model = response.model;
115
+ }
116
+ if (response?.choices?.[0]) {
117
+ const choice = response.choices[0];
118
+ if (choice.message) {
119
+ result.content = choice.message.content || "";
120
+ if (choice.message.tool_calls) {
121
+ result.toolCalls = choice.message.tool_calls.map((tc) => ({
122
+ name: tc.function.name,
123
+ arguments: tc.function.arguments
124
+ }));
125
+ }
126
+ }
127
+ if (choice.finish_reason) {
128
+ result.finishReason = choice.finish_reason;
129
+ }
130
+ if (choice.logprobs) {
131
+ result.logprobs = choice.logprobs;
132
+ }
133
+ }
134
+ if (response?.output) {
135
+ result.content = response.output || "";
136
+ }
137
+ if (response?.usage) {
138
+ result.inputTokens = response.usage.prompt_tokens ?? null;
139
+ result.outputTokens = response.usage.completion_tokens ?? null;
140
+ result.totalTokens = response.usage.total_tokens ?? null;
141
+ }
142
+ return result;
143
+ }
144
+ function extractAnthropicResponse(response) {
145
+ const result = {
146
+ model: "",
147
+ content: "",
148
+ inputTokens: null,
149
+ outputTokens: null,
150
+ totalTokens: null,
151
+ finishReason: null,
152
+ toolCalls: [],
153
+ logprobs: null
154
+ };
155
+ if (response?.model) {
156
+ result.model = response.model;
157
+ }
158
+ if (response?.content && Array.isArray(response.content)) {
159
+ const textBlocks = response.content.filter((block) => block.type === "text" || block.text).map((block) => block.text);
160
+ result.content = textBlocks.join("");
161
+ const toolBlocks = response.content.filter(
162
+ (block) => block.type === "tool_use"
163
+ );
164
+ result.toolCalls = toolBlocks.map((block) => ({
165
+ name: block.name,
166
+ arguments: JSON.stringify(block.input)
167
+ }));
168
+ }
169
+ if (response?.stop_reason) {
170
+ result.finishReason = response.stop_reason;
171
+ }
172
+ if (response?.usage) {
173
+ result.inputTokens = response.usage.input_tokens ?? null;
174
+ result.outputTokens = response.usage.output_tokens ?? null;
175
+ if (result.inputTokens !== null && result.outputTokens !== null) {
176
+ result.totalTokens = result.inputTokens + result.outputTokens;
177
+ }
178
+ }
179
+ return result;
180
+ }
181
+ function calculateLogprobMean(logprobs) {
182
+ if (!logprobs?.content) return null;
183
+ try {
184
+ const probs = logprobs.content.filter((token) => typeof token.logprob === "number").map((token) => token.logprob);
185
+ if (probs.length > 0) {
186
+ return probs.reduce((a, b) => a + b, 0) / probs.length;
187
+ }
188
+ } catch {
189
+ }
190
+ return null;
191
+ }
192
+ var PromptTracker = class {
193
+ promptId;
3
194
  apiKey;
4
195
  baseUrl;
5
- timeout;
6
- /**
7
- * Create a Deadpipe client.
8
- *
9
- * @param apiKey - Your Deadpipe API key. Falls back to DEADPIPE_API_KEY env var.
10
- * @param options - Configuration options.
11
- */
12
- constructor(apiKey, options = {}) {
13
- this.apiKey = apiKey || process.env.DEADPIPE_API_KEY || "";
14
- if (!this.apiKey) {
15
- throw new Error(
16
- "API key required. Pass apiKey or set DEADPIPE_API_KEY environment variable."
17
- );
18
- }
196
+ timeoutMs;
197
+ // Identity
198
+ appId;
199
+ environment;
200
+ versionStr;
201
+ provider;
202
+ // Validation
203
+ schema;
204
+ enumFields;
205
+ numericBounds;
206
+ // Context hashes
207
+ promptHash;
208
+ toolSchemaHash;
209
+ systemPromptHash;
210
+ // Timing
211
+ startTime = null;
212
+ firstTokenTime = null;
213
+ endTime = null;
214
+ // State
215
+ telemetry;
216
+ recorded = false;
217
+ retryCount = 0;
218
+ constructor(promptId, options = {}) {
219
+ this.promptId = promptId;
220
+ this.apiKey = options.apiKey || process.env.DEADPIPE_API_KEY;
19
221
  this.baseUrl = (options.baseUrl || "https://www.deadpipe.com/api/v1").replace(/\/$/, "");
20
- this.timeout = options.timeout || 1e4;
21
- }
22
- /**
23
- * Send a heartbeat ping for a pipeline.
24
- *
25
- * @param pipelineId - Unique identifier for this pipeline.
26
- * @param options - Ping options.
27
- * @returns The heartbeat response, or null if the request failed.
28
- */
29
- async ping(pipelineId, options = {}) {
30
- const { status = "success", durationMs, recordsProcessed, appName } = options;
31
- const payload = {
32
- pipeline_id: pipelineId,
33
- status
222
+ this.timeoutMs = options.timeout || 1e4;
223
+ this.appId = options.appId || process.env.DEADPIPE_APP_ID;
224
+ this.environment = options.environment || process.env.DEADPIPE_ENVIRONMENT;
225
+ this.versionStr = options.version || process.env.DEADPIPE_VERSION || process.env.GIT_COMMIT;
226
+ this.provider = options.provider || "openai";
227
+ this.schema = options.schema;
228
+ this.enumFields = options.enumFields;
229
+ this.numericBounds = options.numericBounds;
230
+ this.promptHash = options.messages ? hashMessages(options.messages) : void 0;
231
+ this.toolSchemaHash = hashTools(options.tools);
232
+ this.systemPromptHash = options.systemPrompt ? hashContentSync(options.systemPrompt) : void 0;
233
+ this.telemetry = {
234
+ prompt_id: this.promptId,
235
+ provider: this.provider,
236
+ app_id: this.appId,
237
+ environment: this.environment,
238
+ version: this.versionStr,
239
+ prompt_hash: this.promptHash,
240
+ tool_schema_hash: this.toolSchemaHash,
241
+ system_prompt_hash: this.systemPromptHash,
242
+ status: "success"
34
243
  };
35
- if (durationMs !== void 0) payload.duration_ms = durationMs;
36
- if (recordsProcessed !== void 0) payload.records_processed = recordsProcessed;
37
- if (appName !== void 0) payload.app_name = appName;
244
+ }
245
+ start() {
246
+ this.startTime = Date.now();
247
+ this.telemetry.request_start = new Date(this.startTime).toISOString();
248
+ }
249
+ markFirstToken() {
250
+ if (this.firstTokenTime === null && this.startTime !== null) {
251
+ this.firstTokenTime = Date.now();
252
+ this.telemetry.first_token_time = this.firstTokenTime - this.startTime;
253
+ }
254
+ }
255
+ markRetry() {
256
+ this.retryCount++;
257
+ this.telemetry.retry_count = this.retryCount;
258
+ }
259
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
260
+ record(response, parsedOutput) {
261
+ this.endTime = Date.now();
262
+ this.telemetry.end_time = new Date(this.endTime).toISOString();
263
+ this.telemetry.total_latency = this.startTime ? this.endTime - this.startTime : 0;
264
+ const extracted = this.provider === "anthropic" ? extractAnthropicResponse(response) : extractOpenAIResponse(response);
265
+ this.telemetry.model = extracted.model;
266
+ this.telemetry.input_tokens = extracted.inputTokens ?? void 0;
267
+ this.telemetry.output_tokens = extracted.outputTokens ?? void 0;
268
+ this.telemetry.total_tokens = extracted.totalTokens ?? void 0;
269
+ this.telemetry.http_status = 200;
270
+ const content = extracted.content;
271
+ this.telemetry.output_length = content?.length ?? 0;
272
+ this.telemetry.empty_output = !content || content.trim().length === 0;
273
+ this.telemetry.truncated = extracted.finishReason === "length";
274
+ this.telemetry.tool_call_flag = extracted.toolCalls.length > 0;
275
+ this.telemetry.tool_calls_count = extracted.toolCalls.length;
276
+ if (content) {
277
+ this.telemetry.output_hash = hashContentSync(content);
278
+ }
279
+ if (extracted.logprobs) {
280
+ this.telemetry.top_logprob_mean = calculateLogprobMean(extracted.logprobs) ?? void 0;
281
+ }
282
+ if (this.telemetry.input_tokens && this.telemetry.output_tokens && this.telemetry.model) {
283
+ this.telemetry.estimated_cost_usd = estimateCost(
284
+ this.telemetry.model,
285
+ this.telemetry.input_tokens,
286
+ this.telemetry.output_tokens
287
+ ) ?? void 0;
288
+ }
289
+ if (content) {
290
+ this.telemetry.refusal_flag = detectRefusal(content);
291
+ if (this.telemetry.refusal_flag) {
292
+ this.telemetry.status = "refusal";
293
+ }
294
+ }
295
+ if (this.telemetry.empty_output) {
296
+ this.telemetry.status = "empty";
297
+ }
298
+ let parsedData = parsedOutput;
299
+ if (parsedData === void 0 && content) {
300
+ try {
301
+ const contentStripped = content.trim();
302
+ if (contentStripped.startsWith("{") || contentStripped.startsWith("[")) {
303
+ parsedData = JSON.parse(contentStripped);
304
+ this.telemetry.json_parse_success = true;
305
+ } else if (contentStripped.includes("```json")) {
306
+ const start = contentStripped.indexOf("```json") + 7;
307
+ const end = contentStripped.indexOf("```", start);
308
+ if (end > start) {
309
+ parsedData = JSON.parse(contentStripped.slice(start, end).trim());
310
+ this.telemetry.json_parse_success = true;
311
+ }
312
+ }
313
+ } catch {
314
+ this.telemetry.json_parse_success = false;
315
+ }
316
+ }
317
+ let validatedResult = null;
318
+ if (this.schema && parsedData !== null) {
319
+ const validation = this.schema.validate(parsedData);
320
+ this.telemetry.schema_validation_pass = validation.success;
321
+ if (!validation.success) {
322
+ this.telemetry.status = "schema_violation";
323
+ if (validation.errors) {
324
+ this.telemetry.missing_required_fields = JSON.stringify(validation.errors);
325
+ }
326
+ }
327
+ validatedResult = validation.data;
328
+ }
329
+ if (this.enumFields && parsedData !== null) {
330
+ if (!validateEnumBounds(parsedData, this.enumFields)) {
331
+ this.telemetry.enum_out_of_range = true;
332
+ this.telemetry.status = "schema_violation";
333
+ }
334
+ }
335
+ if (this.numericBounds && parsedData !== null) {
336
+ if (!validateNumericBounds(parsedData, this.numericBounds)) {
337
+ this.telemetry.numeric_out_of_bounds = true;
338
+ this.telemetry.status = "schema_violation";
339
+ }
340
+ }
341
+ this.send();
342
+ this.recorded = true;
343
+ if (this.schema) {
344
+ return validatedResult;
345
+ }
346
+ return response;
347
+ }
348
+ recordError(error) {
349
+ this.endTime = Date.now();
350
+ this.telemetry.end_time = new Date(this.endTime).toISOString();
351
+ this.telemetry.total_latency = this.startTime ? this.endTime - this.startTime : 0;
352
+ this.telemetry.status = "error";
353
+ this.telemetry.error_message = error.message;
354
+ const err = error;
355
+ if (err.status) {
356
+ this.telemetry.http_status = err.status;
357
+ }
358
+ if (err.code) {
359
+ this.telemetry.provider_error_code = String(err.code);
360
+ }
361
+ if (error.message.toLowerCase().includes("timeout")) {
362
+ this.telemetry.status = "timeout";
363
+ this.telemetry.timeout = true;
364
+ }
365
+ this.send();
366
+ this.recorded = true;
367
+ }
368
+ async send() {
369
+ if (!this.apiKey) return;
38
370
  try {
39
371
  const controller = new AbortController();
40
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
41
- const response = await fetch(`${this.baseUrl}/heartbeat`, {
372
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
373
+ const payload = {};
374
+ for (const [key, value] of Object.entries(this.telemetry)) {
375
+ if (value !== void 0) {
376
+ payload[key] = value;
377
+ }
378
+ }
379
+ await fetch(`${this.baseUrl}/prompt`, {
42
380
  method: "POST",
43
381
  headers: {
44
382
  "Content-Type": "application/json",
@@ -48,90 +386,102 @@ var Deadpipe = class {
48
386
  signal: controller.signal
49
387
  });
50
388
  clearTimeout(timeoutId);
51
- if (!response.ok) {
52
- return null;
53
- }
54
- return await response.json();
55
389
  } catch {
56
- return null;
57
- }
58
- }
59
- /**
60
- * Run a function with automatic heartbeat on completion.
61
- *
62
- * @param pipelineId - Unique identifier for this pipeline.
63
- * @param fn - The function to run.
64
- * @param options - Additional options.
65
- * @returns The result of the function.
66
- *
67
- * @example
68
- * const result = await dp.run('daily-etl', async () => {
69
- * const records = await processData();
70
- * return { recordsProcessed: records.length };
71
- * });
72
- */
73
- async run(pipelineId, fn, options = {}) {
74
- const startTime = Date.now();
75
- let status = "success";
76
- let recordsProcessed;
77
- try {
78
- const result = await fn();
79
- if (result && typeof result === "object" && "recordsProcessed" in result) {
80
- recordsProcessed = result.recordsProcessed;
81
- }
82
- return result;
83
- } catch (error) {
84
- status = "failed";
85
- throw error;
86
- } finally {
87
- const durationMs = Date.now() - startTime;
88
- await this.ping(pipelineId, {
89
- status,
90
- durationMs,
91
- recordsProcessed,
92
- appName: options.appName
93
- });
94
390
  }
95
391
  }
96
- /**
97
- * Create a wrapper function that auto-sends heartbeats.
98
- *
99
- * @param pipelineId - Unique identifier for this pipeline.
100
- * @param fn - The function to wrap.
101
- * @param options - Additional options.
102
- * @returns A wrapped function.
103
- *
104
- * @example
105
- * const myPipeline = dp.wrap('daily-etl', async () => {
106
- * await processData();
107
- * });
108
- *
109
- * // Later...
110
- * await myPipeline();
111
- */
112
- wrap(pipelineId, fn, options = {}) {
113
- return async (...args) => {
114
- return this.run(pipelineId, () => fn(...args), options);
115
- };
392
+ isRecorded() {
393
+ return this.recorded;
394
+ }
395
+ getTelemetry() {
396
+ return { ...this.telemetry };
116
397
  }
117
398
  };
118
- var defaultClient = null;
119
- function getDefaultClient() {
120
- if (!defaultClient) {
121
- defaultClient = new Deadpipe();
399
+ async function track(promptId, fn, options = {}) {
400
+ const tracker = new PromptTracker(promptId, options);
401
+ tracker.start();
402
+ try {
403
+ const result = await fn(tracker);
404
+ if (!tracker.isRecorded()) {
405
+ tracker.recordError(new Error("No response recorded"));
406
+ }
407
+ return result;
408
+ } catch (error) {
409
+ if (!tracker.isRecorded()) {
410
+ tracker.recordError(error instanceof Error ? error : new Error(String(error)));
411
+ }
412
+ throw error;
122
413
  }
123
- return defaultClient;
124
- }
125
- async function ping(pipelineId, options = {}) {
126
- return getDefaultClient().ping(pipelineId, options);
127
414
  }
128
- async function run(pipelineId, fn, options = {}) {
129
- return getDefaultClient().run(pipelineId, fn, options);
415
+ function wrapOpenAI(client, options) {
416
+ const { promptId, ...trackOptions } = options;
417
+ const wrappedClient = Object.create(client);
418
+ if (client.chat?.completions) {
419
+ wrappedClient.chat = {
420
+ ...client.chat,
421
+ completions: {
422
+ ...client.chat.completions,
423
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
424
+ create: async (params) => {
425
+ const messages = params.messages || [];
426
+ const tools = params.tools;
427
+ let systemPrompt;
428
+ for (const msg of messages) {
429
+ if (msg.role === "system") {
430
+ systemPrompt = msg.content || "";
431
+ break;
432
+ }
433
+ }
434
+ return track(
435
+ promptId,
436
+ async (t) => {
437
+ const response = await client.chat.completions.create(params);
438
+ t.record(response);
439
+ return response;
440
+ },
441
+ {
442
+ ...trackOptions,
443
+ messages,
444
+ tools,
445
+ systemPrompt
446
+ }
447
+ );
448
+ }
449
+ }
450
+ };
451
+ }
452
+ if (client.responses) {
453
+ wrappedClient.responses = {
454
+ ...client.responses,
455
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
456
+ create: async (params) => {
457
+ const inputContent = params.input || "";
458
+ const messages = typeof inputContent === "string" ? [{ role: "user", content: inputContent }] : inputContent;
459
+ return track(
460
+ promptId,
461
+ async (t) => {
462
+ const response = await client.responses.create(params);
463
+ t.record(response);
464
+ return response;
465
+ },
466
+ {
467
+ ...trackOptions,
468
+ messages
469
+ }
470
+ );
471
+ }
472
+ };
473
+ }
474
+ return wrappedClient;
130
475
  }
131
- var index_default = Deadpipe;
132
476
  export {
133
- Deadpipe,
134
- index_default as default,
135
- ping,
136
- run
477
+ PromptTracker,
478
+ VERSION,
479
+ detectRefusal,
480
+ estimateCost,
481
+ extractAnthropicResponse,
482
+ extractOpenAIResponse,
483
+ track,
484
+ validateEnumBounds,
485
+ validateNumericBounds,
486
+ wrapOpenAI
137
487
  };
package/package.json CHANGED
@@ -1,15 +1,19 @@
1
1
  {
2
2
  "name": "deadpipe",
3
- "version": "1.0.1",
4
- "description": "Dead simple pipeline monitoring. Know when your pipelines die.",
3
+ "version": "2.0.0",
4
+ "description": "LLM observability that answers: Is this prompt behaving the same as when it was last safe?",
5
5
  "keywords": [
6
- "monitoring",
7
- "pipelines",
8
- "etl",
6
+ "llm",
7
+ "openai",
8
+ "anthropic",
9
+ "gpt",
10
+ "claude",
9
11
  "observability",
10
- "heartbeat",
11
- "dead-mans-switch",
12
- "alerting"
12
+ "monitoring",
13
+ "telemetry",
14
+ "prompt-tracking",
15
+ "ai",
16
+ "machine-learning"
13
17
  ],
14
18
  "homepage": "https://deadpipe.com",
15
19
  "bugs": {
@@ -23,9 +27,9 @@
23
27
  "type": "commonjs",
24
28
  "exports": {
25
29
  ".": {
30
+ "types": "./dist/index.d.ts",
26
31
  "import": "./dist/index.mjs",
27
- "require": "./dist/index.js",
28
- "types": "./dist/index.d.ts"
32
+ "require": "./dist/index.js"
29
33
  }
30
34
  },
31
35
  "main": "dist/index.js",