whatbroke-cli 0.2.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.
@@ -0,0 +1,1028 @@
1
+ // src/diff.ts
2
+ function stats(run) {
3
+ const models = [...new Set(run.llmCalls.map((c) => c.model))];
4
+ let inputTokens = 0;
5
+ let outputTokens = 0;
6
+ let costUsd = 0;
7
+ let latencyMs = 0;
8
+ for (const c of run.llmCalls) {
9
+ inputTokens += c.tokens?.input ?? 0;
10
+ outputTokens += c.tokens?.output ?? 0;
11
+ costUsd += c.cost_usd ?? 0;
12
+ latencyMs += c.latency_ms ?? 0;
13
+ }
14
+ for (const t of run.toolCalls) {
15
+ latencyMs += t.latency_ms ?? 0;
16
+ }
17
+ return {
18
+ llmCalls: run.llmCalls.length,
19
+ toolCalls: run.toolCalls.length,
20
+ inputTokens,
21
+ outputTokens,
22
+ costUsd,
23
+ latencyMs,
24
+ models
25
+ };
26
+ }
27
+ function alignTools(a, b) {
28
+ const n = a.length;
29
+ const m = b.length;
30
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
31
+ for (let i2 = n - 1; i2 >= 0; i2--) {
32
+ for (let j2 = m - 1; j2 >= 0; j2--) {
33
+ dp[i2][j2] = a[i2].name === b[j2].name ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
34
+ }
35
+ }
36
+ const pairs = [];
37
+ let i = 0;
38
+ let j = 0;
39
+ while (i < n && j < m) {
40
+ if (a[i].name === b[j].name) {
41
+ pairs.push([i, j]);
42
+ i++;
43
+ j++;
44
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
45
+ i++;
46
+ } else {
47
+ j++;
48
+ }
49
+ }
50
+ return pairs;
51
+ }
52
+ function changedKeys(before = {}, after = {}) {
53
+ const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
54
+ const changed = [];
55
+ for (const key of keys) {
56
+ if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) changed.push(key);
57
+ }
58
+ return changed;
59
+ }
60
+ function diffRun(runId, before, after, opts) {
61
+ const findings = [];
62
+ const add = (f) => findings.push(f);
63
+ if (before.status === "ok" && after.status === "error") {
64
+ add({
65
+ severity: "breaking",
66
+ kind: "status_changed",
67
+ subject: "status",
68
+ run: runId,
69
+ message: `run now fails${after.error ? `: ${after.error}` : ""}`
70
+ });
71
+ } else if (before.status === "error" && after.status === "ok") {
72
+ add({
73
+ severity: "info",
74
+ kind: "status_changed",
75
+ subject: "status",
76
+ run: runId,
77
+ message: "run now succeeds (was failing)"
78
+ });
79
+ }
80
+ const sBefore = stats(before);
81
+ const sAfter = stats(after);
82
+ if (sBefore.models.length && sAfter.models.length && sBefore.models.join(",") !== sAfter.models.join(",")) {
83
+ add({
84
+ severity: "info",
85
+ kind: "model_changed",
86
+ subject: "model",
87
+ run: runId,
88
+ message: `model: ${sBefore.models.join(", ")} -> ${sAfter.models.join(", ")}`
89
+ });
90
+ }
91
+ const beforeErrors = new Set(before.toolCalls.filter((t) => t.error).map((t) => t.name));
92
+ for (const t of after.toolCalls) {
93
+ if (t.error && !beforeErrors.has(t.name)) {
94
+ add({
95
+ severity: "breaking",
96
+ kind: "tool_error",
97
+ subject: t.name,
98
+ run: runId,
99
+ message: `tool ${t.name} now errors: ${t.error}`
100
+ });
101
+ }
102
+ }
103
+ const orderBefore = before.toolCalls.map((t) => t.name).join(" > ");
104
+ const orderAfter = after.toolCalls.map((t) => t.name).join(" > ");
105
+ const sameToolSet = [...before.toolCalls.map((t) => t.name)].sort().join("\n") === [...after.toolCalls.map((t) => t.name)].sort().join("\n");
106
+ let pairs;
107
+ if (sameToolSet) {
108
+ if (orderBefore !== orderAfter) {
109
+ add({
110
+ severity: "warning",
111
+ kind: "tool_reordered",
112
+ subject: "tool-order",
113
+ run: runId,
114
+ message: `tool order changed: ${orderBefore} -> ${orderAfter}`
115
+ });
116
+ }
117
+ pairs = [];
118
+ const seen = /* @__PURE__ */ new Map();
119
+ for (let i = 0; i < before.toolCalls.length; i++) {
120
+ const name = before.toolCalls[i].name;
121
+ const nth = seen.get(name) ?? 0;
122
+ seen.set(name, nth + 1);
123
+ let count = 0;
124
+ for (let j = 0; j < after.toolCalls.length; j++) {
125
+ if (after.toolCalls[j].name === name && count++ === nth) {
126
+ pairs.push([i, j]);
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ } else {
132
+ pairs = alignTools(before.toolCalls, after.toolCalls);
133
+ const matchedBefore = new Set(pairs.map(([i]) => i));
134
+ const matchedAfter = new Set(pairs.map(([, j]) => j));
135
+ for (let i = 0; i < before.toolCalls.length; i++) {
136
+ if (!matchedBefore.has(i)) {
137
+ add({
138
+ severity: "breaking",
139
+ kind: "tool_removed",
140
+ subject: before.toolCalls[i].name,
141
+ run: runId,
142
+ message: `tool call dropped: ${before.toolCalls[i].name}`,
143
+ detail: { args: before.toolCalls[i].args }
144
+ });
145
+ }
146
+ }
147
+ for (let j = 0; j < after.toolCalls.length; j++) {
148
+ if (!matchedAfter.has(j)) {
149
+ add({
150
+ severity: "warning",
151
+ kind: "tool_added",
152
+ subject: after.toolCalls[j].name,
153
+ run: runId,
154
+ message: `new tool call: ${after.toolCalls[j].name}`,
155
+ detail: { args: after.toolCalls[j].args }
156
+ });
157
+ }
158
+ }
159
+ }
160
+ for (const [i, j] of pairs) {
161
+ const keys = changedKeys(before.toolCalls[i].args, after.toolCalls[j].args);
162
+ if (keys.length) {
163
+ add({
164
+ severity: "warning",
165
+ kind: "tool_args_changed",
166
+ subject: before.toolCalls[i].name,
167
+ run: runId,
168
+ message: `${before.toolCalls[i].name} called with different args (${keys.join(", ")})`,
169
+ detail: {
170
+ tool: before.toolCalls[i].name,
171
+ before: pick(before.toolCalls[i].args, keys),
172
+ after: pick(after.toolCalls[j].args, keys)
173
+ }
174
+ });
175
+ }
176
+ }
177
+ if (opts.compareOutputs) {
178
+ const outBefore = before.outputs.map((o) => o.content).join("\n");
179
+ const outAfter = after.outputs.map((o) => o.content).join("\n");
180
+ if (outBefore && !outAfter) {
181
+ add({
182
+ severity: "breaking",
183
+ kind: "output_missing",
184
+ subject: "output",
185
+ run: runId,
186
+ message: "run no longer produces an output"
187
+ });
188
+ } else if (outBefore !== outAfter) {
189
+ add({
190
+ severity: "warning",
191
+ kind: "output_changed",
192
+ subject: "output",
193
+ run: runId,
194
+ message: "final output changed",
195
+ detail: { before: truncate(outBefore), after: truncate(outAfter) }
196
+ });
197
+ }
198
+ }
199
+ if (sBefore.latencyMs > 0 && sAfter.latencyMs > sBefore.latencyMs * opts.latencyThreshold) {
200
+ const pct = Math.round((sAfter.latencyMs / sBefore.latencyMs - 1) * 100);
201
+ add({
202
+ severity: "warning",
203
+ kind: "latency_regression",
204
+ subject: "latency",
205
+ run: runId,
206
+ message: `latency up ${pct}% (${fmtMs(sBefore.latencyMs)} -> ${fmtMs(sAfter.latencyMs)})`
207
+ });
208
+ }
209
+ if (sBefore.costUsd > 0 && sAfter.costUsd > sBefore.costUsd * opts.costThreshold) {
210
+ const pct = Math.round((sAfter.costUsd / sBefore.costUsd - 1) * 100);
211
+ add({
212
+ severity: "warning",
213
+ kind: "cost_increase",
214
+ subject: "cost",
215
+ run: runId,
216
+ message: `cost up ${pct}% ($${sBefore.costUsd.toFixed(4)} -> $${sAfter.costUsd.toFixed(4)})`
217
+ });
218
+ }
219
+ const tokBefore = sBefore.inputTokens + sBefore.outputTokens;
220
+ const tokAfter = sAfter.inputTokens + sAfter.outputTokens;
221
+ if (tokBefore > 0 && (tokAfter > tokBefore * 2 || tokAfter < tokBefore * 0.5)) {
222
+ add({
223
+ severity: "info",
224
+ kind: "token_change",
225
+ subject: "tokens",
226
+ run: runId,
227
+ message: `total tokens ${tokBefore} -> ${tokAfter}`
228
+ });
229
+ }
230
+ return { run: runId, findings, before: sBefore, after: sAfter };
231
+ }
232
+ function pick(obj = {}, keys) {
233
+ const out = {};
234
+ for (const k of keys) if (k in obj) out[k] = obj[k];
235
+ return out;
236
+ }
237
+ function truncate(s, max = 300) {
238
+ return s.length > max ? s.slice(0, max) + "\u2026" : s;
239
+ }
240
+ function fmtMs(ms) {
241
+ return ms >= 1e3 ? `${(ms / 1e3).toFixed(1)}s` : `${Math.round(ms)}ms`;
242
+ }
243
+ function diffTraces(before, after, options = {}) {
244
+ const opts = {
245
+ latencyThreshold: options.latencyThreshold ?? 1.5,
246
+ costThreshold: options.costThreshold ?? 1.25,
247
+ compareOutputs: options.compareOutputs ?? true
248
+ };
249
+ const runDiffs = [];
250
+ const allRuns = /* @__PURE__ */ new Set([...before.keys(), ...after.keys()]);
251
+ for (const id of allRuns) {
252
+ const a = before.get(id);
253
+ const b = after.get(id);
254
+ if (a && !b) {
255
+ runDiffs.push({
256
+ run: id,
257
+ findings: [
258
+ {
259
+ severity: "breaking",
260
+ kind: "run_missing",
261
+ subject: "run",
262
+ run: id,
263
+ message: "run missing from the new trace"
264
+ }
265
+ ],
266
+ before: stats(a)
267
+ });
268
+ } else if (!a && b) {
269
+ runDiffs.push({
270
+ run: id,
271
+ findings: [
272
+ {
273
+ severity: "info",
274
+ kind: "run_added",
275
+ subject: "run",
276
+ run: id,
277
+ message: "new run, nothing to compare against"
278
+ }
279
+ ],
280
+ after: stats(b)
281
+ });
282
+ } else if (a && b) {
283
+ runDiffs.push(diffRun(id, a, b, opts));
284
+ }
285
+ }
286
+ const findings = runDiffs.flatMap((r) => r.findings);
287
+ return {
288
+ runs: runDiffs,
289
+ findings,
290
+ breaking: findings.filter((f) => f.severity === "breaking").length,
291
+ warnings: findings.filter((f) => f.severity === "warning").length,
292
+ info: findings.filter((f) => f.severity === "info").length
293
+ };
294
+ }
295
+
296
+ // src/samples.ts
297
+ var SAMPLE_RE = /^(.+)#(\d+)$/;
298
+ function hasSamples(runs) {
299
+ for (const id of runs.keys()) if (SAMPLE_RE.test(id)) return true;
300
+ return false;
301
+ }
302
+ function groupSamples(runs) {
303
+ const groups = /* @__PURE__ */ new Map();
304
+ for (const [id, run] of runs) {
305
+ const match = SAMPLE_RE.exec(id);
306
+ const base = match ? match[1] : id;
307
+ const list = groups.get(base) ?? [];
308
+ list.push(run);
309
+ groups.set(base, list);
310
+ }
311
+ return groups;
312
+ }
313
+ function findingKey(f) {
314
+ return `${f.kind}:${f.subject ?? f.message}`;
315
+ }
316
+ function pairFindings(a, b, base, options) {
317
+ const result = diffTraces(/* @__PURE__ */ new Map([[base, a]]), /* @__PURE__ */ new Map([[base, b]]), options);
318
+ return result.findings;
319
+ }
320
+ function meanStats(samples) {
321
+ if (!samples.length) return void 0;
322
+ const n = samples.length;
323
+ const sum = (fn) => samples.reduce((t, s) => t + fn(s), 0);
324
+ return {
325
+ llmCalls: Math.round(sum((s) => s.llmCalls) / n),
326
+ toolCalls: Math.round(sum((s) => s.toolCalls) / n),
327
+ inputTokens: Math.round(sum((s) => s.inputTokens) / n),
328
+ outputTokens: Math.round(sum((s) => s.outputTokens) / n),
329
+ costUsd: sum((s) => s.costUsd) / n,
330
+ latencyMs: sum((s) => s.latencyMs) / n,
331
+ models: [...new Set(samples.flatMap((s) => s.models))]
332
+ };
333
+ }
334
+ function statsOf(run, options) {
335
+ const result = diffTraces(/* @__PURE__ */ new Map([["x", run]]), /* @__PURE__ */ new Map([["x", run]]), options);
336
+ return result.runs[0].before;
337
+ }
338
+ function diffSampledRun(base, beforeSamples, afterSamples, options) {
339
+ const baselineKeys = /* @__PURE__ */ new Set();
340
+ for (let i = 0; i < beforeSamples.length; i++) {
341
+ for (let j = i + 1; j < beforeSamples.length; j++) {
342
+ for (const f of pairFindings(beforeSamples[i], beforeSamples[j], base, options)) {
343
+ baselineKeys.add(findingKey(f));
344
+ }
345
+ }
346
+ }
347
+ const totalPairs = beforeSamples.length * afterSamples.length;
348
+ const counts = /* @__PURE__ */ new Map();
349
+ for (const a of beforeSamples) {
350
+ for (const b of afterSamples) {
351
+ const seen = /* @__PURE__ */ new Set();
352
+ for (const f of pairFindings(a, b, base, options)) {
353
+ const key = findingKey(f);
354
+ if (seen.has(key)) continue;
355
+ seen.add(key);
356
+ const entry = counts.get(key);
357
+ if (entry) entry.count++;
358
+ else counts.set(key, { finding: f, count: 1 });
359
+ }
360
+ }
361
+ }
362
+ const findings = [];
363
+ for (const [key, { finding, count }] of counts) {
364
+ const f = { ...finding, rate: `${count}/${totalPairs}` };
365
+ if (baselineKeys.has(key)) {
366
+ f.flaky = true;
367
+ if (f.severity !== "info") f.severity = "info";
368
+ } else if (count / totalPairs < 0.5 && f.severity === "breaking") {
369
+ f.severity = "warning";
370
+ }
371
+ findings.push(f);
372
+ }
373
+ const order = { breaking: 0, warning: 1, info: 2 };
374
+ findings.sort((a, b) => order[a.severity] - order[b.severity]);
375
+ return {
376
+ run: base,
377
+ findings,
378
+ before: meanStats(beforeSamples.map((r) => statsOf(r, options))),
379
+ after: meanStats(afterSamples.map((r) => statsOf(r, options)))
380
+ };
381
+ }
382
+ function diffTracesSampled(before, after, options = {}) {
383
+ const beforeGroups = groupSamples(before);
384
+ const afterGroups = groupSamples(after);
385
+ const runDiffs = [];
386
+ const bases = /* @__PURE__ */ new Set([...beforeGroups.keys(), ...afterGroups.keys()]);
387
+ for (const base of bases) {
388
+ const beforeSamples = beforeGroups.get(base) ?? [];
389
+ const afterSamples = afterGroups.get(base) ?? [];
390
+ if (beforeSamples.length && !afterSamples.length) {
391
+ runDiffs.push({
392
+ run: base,
393
+ findings: [
394
+ {
395
+ severity: "breaking",
396
+ kind: "run_missing",
397
+ subject: "run",
398
+ run: base,
399
+ message: "run missing from the new trace"
400
+ }
401
+ ],
402
+ before: meanStats(beforeSamples.map((r) => statsOf(r, options)))
403
+ });
404
+ } else if (!beforeSamples.length && afterSamples.length) {
405
+ runDiffs.push({
406
+ run: base,
407
+ findings: [
408
+ {
409
+ severity: "info",
410
+ kind: "run_added",
411
+ subject: "run",
412
+ run: base,
413
+ message: "new run, nothing to compare against"
414
+ }
415
+ ],
416
+ after: meanStats(afterSamples.map((r) => statsOf(r, options)))
417
+ });
418
+ } else {
419
+ runDiffs.push(diffSampledRun(base, beforeSamples, afterSamples, options));
420
+ }
421
+ }
422
+ const findings = runDiffs.flatMap((r) => r.findings);
423
+ return {
424
+ runs: runDiffs,
425
+ findings,
426
+ breaking: findings.filter((f) => f.severity === "breaking").length,
427
+ warnings: findings.filter((f) => f.severity === "warning").length,
428
+ info: findings.filter((f) => f.severity === "info").length
429
+ };
430
+ }
431
+
432
+ // src/parse.ts
433
+ import { readFileSync } from "fs";
434
+ function parseTrace(text) {
435
+ const runs = /* @__PURE__ */ new Map();
436
+ const get = (id) => {
437
+ let run = runs.get(id);
438
+ if (!run) {
439
+ run = {
440
+ id,
441
+ meta: {},
442
+ llmCalls: [],
443
+ toolCalls: [],
444
+ outputs: [],
445
+ status: "unknown"
446
+ };
447
+ runs.set(id, run);
448
+ }
449
+ return run;
450
+ };
451
+ const lines = text.split("\n");
452
+ for (let i = 0; i < lines.length; i++) {
453
+ const line = lines[i].trim();
454
+ if (!line) continue;
455
+ let event;
456
+ try {
457
+ event = JSON.parse(line);
458
+ } catch {
459
+ throw new Error(`invalid JSON on line ${i + 1}`);
460
+ }
461
+ if (!event || typeof event !== "object" || !("type" in event)) {
462
+ throw new Error(`line ${i + 1} is not a trace event (missing "type")`);
463
+ }
464
+ const runId = "run" in event && event.run ? String(event.run) : "default";
465
+ const run = get(runId);
466
+ switch (event.type) {
467
+ case "run_start":
468
+ run.meta = { ...run.meta, ...event.meta };
469
+ break;
470
+ case "llm_call":
471
+ run.llmCalls.push(event);
472
+ break;
473
+ case "tool_call":
474
+ run.toolCalls.push(event);
475
+ break;
476
+ case "output":
477
+ run.outputs.push(event);
478
+ break;
479
+ case "run_end":
480
+ run.status = event.status;
481
+ if (event.error) run.error = event.error;
482
+ break;
483
+ default:
484
+ break;
485
+ }
486
+ }
487
+ for (const run of runs.values()) {
488
+ if (run.status === "unknown" && (run.llmCalls.length || run.toolCalls.length || run.outputs.length)) {
489
+ run.status = "ok";
490
+ }
491
+ }
492
+ return runs;
493
+ }
494
+ function loadTrace(path) {
495
+ let text;
496
+ try {
497
+ text = readFileSync(path, "utf8");
498
+ } catch {
499
+ throw new Error(`could not read trace file: ${path}`);
500
+ }
501
+ try {
502
+ return parseTrace(text);
503
+ } catch (err) {
504
+ throw new Error(`${path}: ${err instanceof Error ? err.message : String(err)}`);
505
+ }
506
+ }
507
+
508
+ // src/record.ts
509
+ import { appendFileSync, mkdirSync } from "fs";
510
+ import { dirname } from "path";
511
+ var Recorder = class {
512
+ file;
513
+ run;
514
+ constructor(options) {
515
+ this.file = options.file;
516
+ this.run = options.run ?? "default";
517
+ mkdirSync(dirname(this.file) || ".", { recursive: true });
518
+ this.write({ type: "run_start", run: this.run, ts: Date.now(), meta: options.meta });
519
+ }
520
+ write(event) {
521
+ appendFileSync(this.file, JSON.stringify(event) + "\n");
522
+ }
523
+ llmCall(data) {
524
+ this.write({
525
+ type: "llm_call",
526
+ run: this.run,
527
+ ts: Date.now(),
528
+ model: data.model,
529
+ latency_ms: data.latencyMs,
530
+ tokens: { input: data.inputTokens, output: data.outputTokens },
531
+ cost_usd: data.costUsd,
532
+ stop_reason: data.stopReason,
533
+ error: data.error
534
+ });
535
+ }
536
+ toolCall(name, args, data) {
537
+ this.write({
538
+ type: "tool_call",
539
+ run: this.run,
540
+ ts: Date.now(),
541
+ name,
542
+ args,
543
+ latency_ms: data?.latencyMs,
544
+ error: data?.error
545
+ });
546
+ }
547
+ output(content) {
548
+ this.write({ type: "output", run: this.run, ts: Date.now(), content });
549
+ }
550
+ end(status = "ok", error) {
551
+ this.write({ type: "run_end", run: this.run, ts: Date.now(), status, error });
552
+ }
553
+ /**
554
+ * Wraps an OpenAI client (openai npm package) so every
555
+ * chat.completions.create call is recorded, including tool calls the
556
+ * model requested. Returns the same client.
557
+ */
558
+ wrapOpenAI(client) {
559
+ const anyClient = client;
560
+ const completions = anyClient?.chat?.completions;
561
+ if (!completions?.create) {
562
+ throw new Error("wrapOpenAI expects an OpenAI client with chat.completions.create");
563
+ }
564
+ const original = completions.create.bind(completions);
565
+ const recorder = this;
566
+ completions.create = async function(params, ...rest) {
567
+ const started = Date.now();
568
+ try {
569
+ const response = await original(params, ...rest);
570
+ recorder.llmCall({
571
+ model: response?.model ?? params?.model ?? "unknown",
572
+ latencyMs: Date.now() - started,
573
+ inputTokens: response?.usage?.prompt_tokens,
574
+ outputTokens: response?.usage?.completion_tokens,
575
+ stopReason: response?.choices?.[0]?.finish_reason
576
+ });
577
+ const toolCalls = response?.choices?.[0]?.message?.tool_calls ?? [];
578
+ for (const call of toolCalls) {
579
+ recorder.toolCall(call?.function?.name ?? "unknown", safeParse(call?.function?.arguments));
580
+ }
581
+ return response;
582
+ } catch (err) {
583
+ recorder.llmCall({
584
+ model: params?.model ?? "unknown",
585
+ latencyMs: Date.now() - started,
586
+ error: err instanceof Error ? err.message : String(err)
587
+ });
588
+ throw err;
589
+ }
590
+ };
591
+ return client;
592
+ }
593
+ /**
594
+ * Wraps an Anthropic client (@anthropic-ai/sdk) so every messages.create
595
+ * call is recorded, including tool_use blocks. Returns the same client.
596
+ */
597
+ wrapAnthropic(client) {
598
+ const anyClient = client;
599
+ const messages = anyClient?.messages;
600
+ if (!messages?.create) {
601
+ throw new Error("wrapAnthropic expects an Anthropic client with messages.create");
602
+ }
603
+ const original = messages.create.bind(messages);
604
+ const recorder = this;
605
+ messages.create = async function(params, ...rest) {
606
+ const started = Date.now();
607
+ try {
608
+ const response = await original(params, ...rest);
609
+ recorder.llmCall({
610
+ model: response?.model ?? params?.model ?? "unknown",
611
+ latencyMs: Date.now() - started,
612
+ inputTokens: response?.usage?.input_tokens,
613
+ outputTokens: response?.usage?.output_tokens,
614
+ stopReason: response?.stop_reason
615
+ });
616
+ const blocks = Array.isArray(response?.content) ? response.content : [];
617
+ for (const block of blocks) {
618
+ if (block?.type === "tool_use") {
619
+ recorder.toolCall(block.name ?? "unknown", block.input ?? {});
620
+ }
621
+ }
622
+ return response;
623
+ } catch (err) {
624
+ recorder.llmCall({
625
+ model: params?.model ?? "unknown",
626
+ latencyMs: Date.now() - started,
627
+ error: err instanceof Error ? err.message : String(err)
628
+ });
629
+ throw err;
630
+ }
631
+ };
632
+ return client;
633
+ }
634
+ };
635
+ function safeParse(json) {
636
+ if (typeof json !== "string") return void 0;
637
+ try {
638
+ const parsed = JSON.parse(json);
639
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
640
+ } catch {
641
+ return void 0;
642
+ }
643
+ }
644
+
645
+ // src/proxy.ts
646
+ import http from "http";
647
+ var STRIP_REQUEST_HEADERS = /* @__PURE__ */ new Set([
648
+ "host",
649
+ "connection",
650
+ "content-length",
651
+ "accept-encoding",
652
+ "x-whatbroke-run"
653
+ ]);
654
+ var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
655
+ "content-length",
656
+ "content-encoding",
657
+ "transfer-encoding",
658
+ "connection"
659
+ ]);
660
+ function startProxy(options) {
661
+ const recorders = /* @__PURE__ */ new Map();
662
+ const recorderFor = (run) => {
663
+ let rec = recorders.get(run);
664
+ if (!rec) {
665
+ rec = new Recorder({ file: options.file, run });
666
+ recorders.set(run, rec);
667
+ }
668
+ return rec;
669
+ };
670
+ const server = http.createServer(async (req, res) => {
671
+ const chunks = [];
672
+ for await (const chunk of req) chunks.push(chunk);
673
+ const body = Buffer.concat(chunks);
674
+ const path = req.url ?? "/";
675
+ const anthropic = path.startsWith("/v1/messages");
676
+ const origin = options.target ?? (anthropic ? "https://api.anthropic.com" : "https://api.openai.com");
677
+ const headers = {};
678
+ for (const [key, value] of Object.entries(req.headers)) {
679
+ if (typeof value === "string" && !STRIP_REQUEST_HEADERS.has(key.toLowerCase())) {
680
+ headers[key] = value;
681
+ }
682
+ }
683
+ headers["accept-encoding"] = "identity";
684
+ const runHeader = req.headers["x-whatbroke-run"];
685
+ const run = typeof runHeader === "string" && runHeader || options.run || "default";
686
+ const started = Date.now();
687
+ let upstream;
688
+ try {
689
+ upstream = await fetch(origin + path, {
690
+ method: req.method,
691
+ headers,
692
+ body: body.length ? body : void 0
693
+ });
694
+ } catch (err) {
695
+ const message = err instanceof Error ? err.message : String(err);
696
+ recorderFor(run).llmCall({ model: "unknown", latencyMs: Date.now() - started, error: message });
697
+ res.writeHead(502, { "content-type": "application/json" });
698
+ res.end(JSON.stringify({ error: { message: `whatbroke proxy: upstream unreachable: ${message}` } }));
699
+ return;
700
+ }
701
+ const responseHeaders = {};
702
+ upstream.headers.forEach((value, key) => {
703
+ if (!STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) responseHeaders[key] = value;
704
+ });
705
+ const contentType = upstream.headers.get("content-type") ?? "";
706
+ const streaming = contentType.includes("text/event-stream");
707
+ let text = "";
708
+ if (streaming && upstream.body) {
709
+ res.writeHead(upstream.status, responseHeaders);
710
+ const decoder = new TextDecoder();
711
+ for await (const chunk of upstream.body) {
712
+ res.write(chunk);
713
+ text += decoder.decode(chunk, { stream: true });
714
+ }
715
+ text += decoder.decode();
716
+ res.end();
717
+ } else {
718
+ text = await upstream.text();
719
+ res.writeHead(upstream.status, responseHeaders);
720
+ res.end(text);
721
+ }
722
+ if (upstream.status >= 400) {
723
+ recorderFor(run).llmCall({
724
+ model: "unknown",
725
+ latencyMs: Date.now() - started,
726
+ error: `upstream ${upstream.status}: ${truncate2(text)}`
727
+ });
728
+ return;
729
+ }
730
+ let summary = null;
731
+ try {
732
+ if (streaming) {
733
+ const events = parseSse(text);
734
+ summary = anthropic ? summarizeAnthropicStream(events) : summarizeOpenAIStream(events);
735
+ } else {
736
+ const parsed = JSON.parse(text);
737
+ summary = anthropic ? summarizeAnthropic(parsed) : summarizeOpenAI(parsed);
738
+ }
739
+ } catch {
740
+ }
741
+ if (!summary) return;
742
+ const rec = recorderFor(run);
743
+ rec.llmCall({
744
+ model: summary.model,
745
+ latencyMs: Date.now() - started,
746
+ inputTokens: summary.inputTokens,
747
+ outputTokens: summary.outputTokens,
748
+ stopReason: summary.stopReason
749
+ });
750
+ for (const call of summary.toolCalls) rec.toolCall(call.name, call.args);
751
+ if (!summary.toolCalls.length && summary.text.trim()) rec.output(summary.text);
752
+ options.onRecord?.(run, summary.model, summary.toolCalls.map((c) => c.name));
753
+ });
754
+ let closing;
755
+ return new Promise((resolve, reject) => {
756
+ server.once("error", reject);
757
+ server.listen(options.port ?? 4141, "127.0.0.1", () => {
758
+ const port = server.address().port;
759
+ resolve({
760
+ port,
761
+ url: `http://127.0.0.1:${port}`,
762
+ close: () => {
763
+ closing ??= new Promise((done, fail) => {
764
+ for (const rec of recorders.values()) rec.end("ok");
765
+ server.close((err) => err ? fail(err) : done());
766
+ });
767
+ return closing;
768
+ }
769
+ });
770
+ });
771
+ });
772
+ }
773
+ function parseSse(text) {
774
+ const events = [];
775
+ for (const line of text.split(/\r?\n/)) {
776
+ if (!line.startsWith("data:")) continue;
777
+ const data = line.slice(5).trim();
778
+ if (!data || data === "[DONE]") continue;
779
+ try {
780
+ events.push(JSON.parse(data));
781
+ } catch {
782
+ }
783
+ }
784
+ return events;
785
+ }
786
+ function summarizeOpenAI(body) {
787
+ const choice = body?.choices?.[0];
788
+ if (!choice) return null;
789
+ const message = choice.message ?? {};
790
+ const toolCalls = (message.tool_calls ?? []).map((c) => ({
791
+ name: c?.function?.name ?? "unknown",
792
+ args: safeParse2(c?.function?.arguments)
793
+ }));
794
+ return {
795
+ model: body.model ?? "unknown",
796
+ inputTokens: body.usage?.prompt_tokens,
797
+ outputTokens: body.usage?.completion_tokens,
798
+ stopReason: choice.finish_reason ?? void 0,
799
+ toolCalls,
800
+ text: typeof message.content === "string" ? message.content : ""
801
+ };
802
+ }
803
+ function summarizeOpenAIStream(events) {
804
+ if (!events.length) return null;
805
+ let model = "unknown";
806
+ let text = "";
807
+ let stopReason;
808
+ let inputTokens;
809
+ let outputTokens;
810
+ const tools = /* @__PURE__ */ new Map();
811
+ for (const event of events) {
812
+ if (event?.model) model = event.model;
813
+ if (event?.usage) {
814
+ inputTokens = event.usage.prompt_tokens ?? inputTokens;
815
+ outputTokens = event.usage.completion_tokens ?? outputTokens;
816
+ }
817
+ const choice = event?.choices?.[0];
818
+ if (!choice) continue;
819
+ if (choice.finish_reason) stopReason = choice.finish_reason;
820
+ const delta = choice.delta ?? {};
821
+ if (typeof delta.content === "string") text += delta.content;
822
+ for (const call of delta.tool_calls ?? []) {
823
+ const index = call?.index ?? 0;
824
+ const entry = tools.get(index) ?? { name: "unknown", args: "" };
825
+ if (call?.function?.name) entry.name = call.function.name;
826
+ if (call?.function?.arguments) entry.args += call.function.arguments;
827
+ tools.set(index, entry);
828
+ }
829
+ }
830
+ return {
831
+ model,
832
+ inputTokens,
833
+ outputTokens,
834
+ stopReason,
835
+ toolCalls: [...tools.values()].map((t) => ({ name: t.name, args: safeParse2(t.args) })),
836
+ text
837
+ };
838
+ }
839
+ function summarizeAnthropic(body) {
840
+ if (body?.type !== "message") return null;
841
+ const blocks = Array.isArray(body.content) ? body.content : [];
842
+ const toolCalls = blocks.filter((b) => b?.type === "tool_use").map((b) => ({ name: b.name ?? "unknown", args: b.input ?? {} }));
843
+ const text = blocks.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("");
844
+ return {
845
+ model: body.model ?? "unknown",
846
+ inputTokens: body.usage?.input_tokens,
847
+ outputTokens: body.usage?.output_tokens,
848
+ stopReason: body.stop_reason ?? void 0,
849
+ toolCalls,
850
+ text
851
+ };
852
+ }
853
+ function summarizeAnthropicStream(events) {
854
+ if (!events.length) return null;
855
+ let model = "unknown";
856
+ let text = "";
857
+ let stopReason;
858
+ let inputTokens;
859
+ let outputTokens;
860
+ const tools = /* @__PURE__ */ new Map();
861
+ for (const event of events) {
862
+ switch (event?.type) {
863
+ case "message_start":
864
+ model = event.message?.model ?? model;
865
+ inputTokens = event.message?.usage?.input_tokens ?? inputTokens;
866
+ break;
867
+ case "content_block_start":
868
+ if (event.content_block?.type === "tool_use") {
869
+ tools.set(event.index ?? 0, { name: event.content_block.name ?? "unknown", json: "" });
870
+ }
871
+ break;
872
+ case "content_block_delta":
873
+ if (event.delta?.type === "text_delta") text += event.delta.text ?? "";
874
+ if (event.delta?.type === "input_json_delta") {
875
+ const entry = tools.get(event.index ?? 0);
876
+ if (entry) entry.json += event.delta.partial_json ?? "";
877
+ }
878
+ break;
879
+ case "message_delta":
880
+ stopReason = event.delta?.stop_reason ?? stopReason;
881
+ outputTokens = event.usage?.output_tokens ?? outputTokens;
882
+ break;
883
+ }
884
+ }
885
+ return {
886
+ model,
887
+ inputTokens,
888
+ outputTokens,
889
+ stopReason,
890
+ toolCalls: [...tools.values()].map((t) => ({ name: t.name, args: safeParse2(t.json) ?? {} })),
891
+ text
892
+ };
893
+ }
894
+ function safeParse2(json) {
895
+ if (typeof json !== "string" || !json) return void 0;
896
+ try {
897
+ const parsed = JSON.parse(json);
898
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
899
+ } catch {
900
+ return void 0;
901
+ }
902
+ }
903
+ function truncate2(s, max = 200) {
904
+ return s.length > max ? s.slice(0, max) + "\u2026" : s;
905
+ }
906
+
907
+ // src/report.ts
908
+ import pc from "picocolors";
909
+ var ICONS = {
910
+ breaking: "x",
911
+ warning: "!",
912
+ info: "i"
913
+ };
914
+ function color(severity, text) {
915
+ if (severity === "breaking") return pc.red(text);
916
+ if (severity === "warning") return pc.yellow(text);
917
+ return pc.dim(text);
918
+ }
919
+ function statLine(diff) {
920
+ const b = diff.before;
921
+ const a = diff.after;
922
+ if (!b || !a) return null;
923
+ const parts = [];
924
+ parts.push(`${b.toolCalls} -> ${a.toolCalls} tool calls`);
925
+ parts.push(`${b.llmCalls} -> ${a.llmCalls} llm calls`);
926
+ if (b.latencyMs || a.latencyMs) parts.push(`${fmtMs(b.latencyMs)} -> ${fmtMs(a.latencyMs)}`);
927
+ if (b.costUsd || a.costUsd)
928
+ parts.push(`$${b.costUsd.toFixed(4)} -> $${a.costUsd.toFixed(4)}`);
929
+ return parts.join(" \xB7 ");
930
+ }
931
+ function renderTerminal(result) {
932
+ const lines = [];
933
+ lines.push("");
934
+ for (const run of result.runs) {
935
+ const worst = worstSeverity(run.findings);
936
+ const badge = worst === "breaking" ? pc.bgRed(pc.white(" BROKE ")) : worst === "warning" ? pc.bgYellow(pc.black(" CHANGED ")) : run.findings.length ? pc.bgBlue(pc.white(" INFO ")) : pc.bgGreen(pc.black(" OK "));
937
+ lines.push(`${badge} ${pc.bold(run.run)}`);
938
+ const stat = statLine(run);
939
+ if (stat) lines.push(pc.dim(` ${stat}`));
940
+ for (const f of run.findings) {
941
+ lines.push(color(f.severity, ` ${ICONS[f.severity]} ${f.message}${annotation(f)}`));
942
+ if (f.detail?.before !== void 0 || f.detail?.after !== void 0) {
943
+ if (f.detail.before !== void 0)
944
+ lines.push(pc.red(` - ${compact(f.detail.before)}`));
945
+ if (f.detail.after !== void 0)
946
+ lines.push(pc.green(` + ${compact(f.detail.after)}`));
947
+ }
948
+ }
949
+ lines.push("");
950
+ }
951
+ lines.push(summaryLine(result));
952
+ lines.push("");
953
+ return lines.join("\n");
954
+ }
955
+ function renderMarkdown(result) {
956
+ const lines = [];
957
+ lines.push("## whatbroke report");
958
+ lines.push("");
959
+ lines.push(summaryPlain(result));
960
+ lines.push("");
961
+ for (const run of result.runs) {
962
+ if (!run.findings.length) continue;
963
+ lines.push(`### ${run.run}`);
964
+ lines.push("");
965
+ for (const f of run.findings) {
966
+ const tag = f.severity === "breaking" ? "**BROKE**" : f.severity === "warning" ? "changed" : "info";
967
+ lines.push(`- ${tag}: ${f.message}${annotationPlain(f)}`);
968
+ if (f.detail?.before !== void 0) lines.push(` - before: \`${compact(f.detail.before)}\``);
969
+ if (f.detail?.after !== void 0) lines.push(` - after: \`${compact(f.detail.after)}\``);
970
+ }
971
+ lines.push("");
972
+ }
973
+ if (result.runs.every((r) => !r.findings.length)) {
974
+ lines.push("No behavioral changes detected.");
975
+ lines.push("");
976
+ }
977
+ return lines.join("\n");
978
+ }
979
+ function annotationPlain(f) {
980
+ if (!f.rate) return "";
981
+ return f.flaky ? ` (${f.rate} run pairs, also flaps in the baseline)` : ` (${f.rate} run pairs)`;
982
+ }
983
+ function annotation(f) {
984
+ const plain = annotationPlain(f);
985
+ return plain ? pc.dim(plain) : "";
986
+ }
987
+ function worstSeverity(findings) {
988
+ if (findings.some((f) => f.severity === "breaking")) return "breaking";
989
+ if (findings.some((f) => f.severity === "warning")) return "warning";
990
+ if (findings.length) return "info";
991
+ return null;
992
+ }
993
+ function compact(value) {
994
+ const s = typeof value === "string" ? value : JSON.stringify(value);
995
+ return s.length > 120 ? s.slice(0, 120) + "\u2026" : s;
996
+ }
997
+ function summaryPlain(result) {
998
+ if (!result.breaking && !result.warnings && !result.info) {
999
+ return "No behavioral changes detected.";
1000
+ }
1001
+ const parts = [];
1002
+ if (result.breaking) parts.push(`${result.breaking} breaking`);
1003
+ if (result.warnings) parts.push(`${result.warnings} changed`);
1004
+ if (result.info) parts.push(`${result.info} info`);
1005
+ return parts.join(", ");
1006
+ }
1007
+ function summaryLine(result) {
1008
+ if (!result.breaking && !result.warnings && !result.info) {
1009
+ return pc.green("nothing broke.");
1010
+ }
1011
+ const parts = [];
1012
+ if (result.breaking) parts.push(pc.red(`${result.breaking} breaking`));
1013
+ if (result.warnings) parts.push(pc.yellow(`${result.warnings} changed`));
1014
+ if (result.info) parts.push(pc.dim(`${result.info} info`));
1015
+ return parts.join(pc.dim(" \xB7 "));
1016
+ }
1017
+
1018
+ export {
1019
+ diffTraces,
1020
+ hasSamples,
1021
+ diffTracesSampled,
1022
+ parseTrace,
1023
+ loadTrace,
1024
+ Recorder,
1025
+ startProxy,
1026
+ renderTerminal,
1027
+ renderMarkdown
1028
+ };