llm-diff 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +189 -0
- package/README.md +186 -0
- package/bin/llm-diff.js +8 -0
- package/package.json +56 -0
- package/src/cli.js +192 -0
- package/src/diff.js +148 -0
- package/src/index.js +10 -0
- package/src/providers.js +356 -0
- package/src/render.js +350 -0
package/src/render.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal renderer — pretty-prints diff results.
|
|
3
|
+
*
|
|
4
|
+
* Supports two modes:
|
|
5
|
+
* • human – colored terminal output (default)
|
|
6
|
+
* • json – machine-readable JSON
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ANSI codes
|
|
10
|
+
const c = {
|
|
11
|
+
reset: '\x1b[0m',
|
|
12
|
+
bold: '\x1b[1m',
|
|
13
|
+
dim: '\x1b[2m',
|
|
14
|
+
red: '\x1b[31m',
|
|
15
|
+
green: '\x1b[32m',
|
|
16
|
+
yellow: '\x1b[33m',
|
|
17
|
+
cyan: '\x1b[36m',
|
|
18
|
+
bgRed: '\x1b[41m',
|
|
19
|
+
bgGreen:'\x1b[42m',
|
|
20
|
+
gray: '\x1b[90m',
|
|
21
|
+
white: '\x1b[37m',
|
|
22
|
+
strikethrough: '\x1b[9m',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Render the diff result to stdout.
|
|
27
|
+
*/
|
|
28
|
+
export function render(result, opts = {}) {
|
|
29
|
+
if (opts.json) {
|
|
30
|
+
return renderJson(result);
|
|
31
|
+
}
|
|
32
|
+
return renderHuman(result, opts);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// JSON output
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function renderJson(result) {
|
|
40
|
+
const { a, b, delta, model, runs } = result;
|
|
41
|
+
const output = {
|
|
42
|
+
model: model.id,
|
|
43
|
+
provider: model.provider,
|
|
44
|
+
runs,
|
|
45
|
+
a: {
|
|
46
|
+
inputTokens: a.inputTokens,
|
|
47
|
+
outputTokens: a.outputTokens,
|
|
48
|
+
totalTokens: a.inputTokens + a.outputTokens,
|
|
49
|
+
cost: round(a.cost, 6),
|
|
50
|
+
latencyMs: round(a.latencyMs, 0),
|
|
51
|
+
text: a.text,
|
|
52
|
+
},
|
|
53
|
+
b: {
|
|
54
|
+
inputTokens: b.inputTokens,
|
|
55
|
+
outputTokens: b.outputTokens,
|
|
56
|
+
totalTokens: b.inputTokens + b.outputTokens,
|
|
57
|
+
cost: round(b.cost, 6),
|
|
58
|
+
latencyMs: round(b.latencyMs, 0),
|
|
59
|
+
text: b.text,
|
|
60
|
+
},
|
|
61
|
+
delta: {
|
|
62
|
+
totalTokens: delta.totalTokens,
|
|
63
|
+
totalTokensPct: round(delta.totalTokensPct, 1),
|
|
64
|
+
cost: round(delta.cost, 6),
|
|
65
|
+
costPct: round(delta.costPct, 1),
|
|
66
|
+
latencyMs: round(delta.latencyMs, 0),
|
|
67
|
+
latencyPct: round(delta.latencyPct, 1),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
console.log(JSON.stringify(output, null, 2));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Human-readable output
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function renderHuman(result, opts) {
|
|
78
|
+
const { a, b, delta, wordDiff, model, runs } = result;
|
|
79
|
+
|
|
80
|
+
const lines = [];
|
|
81
|
+
|
|
82
|
+
// Header
|
|
83
|
+
lines.push('');
|
|
84
|
+
lines.push(`${c.bold}${c.cyan}llm-diff${c.reset} ${c.dim}${model.provider}/${model.id}${c.reset}`);
|
|
85
|
+
if (runs > 1) {
|
|
86
|
+
lines.push(`${c.dim}averaged over ${runs} runs${c.reset}`);
|
|
87
|
+
}
|
|
88
|
+
lines.push('');
|
|
89
|
+
|
|
90
|
+
// Stats table
|
|
91
|
+
const totalA = a.inputTokens + a.outputTokens;
|
|
92
|
+
const totalB = b.inputTokens + b.outputTokens;
|
|
93
|
+
|
|
94
|
+
lines.push(statsRow(
|
|
95
|
+
'tokens',
|
|
96
|
+
`${totalA}`,
|
|
97
|
+
`${totalB}`,
|
|
98
|
+
delta.totalTokens,
|
|
99
|
+
delta.totalTokensPct,
|
|
100
|
+
''
|
|
101
|
+
));
|
|
102
|
+
|
|
103
|
+
lines.push(statsRow(
|
|
104
|
+
' input',
|
|
105
|
+
`${a.inputTokens}`,
|
|
106
|
+
`${b.inputTokens}`,
|
|
107
|
+
delta.inputTokens,
|
|
108
|
+
null,
|
|
109
|
+
'',
|
|
110
|
+
true
|
|
111
|
+
));
|
|
112
|
+
|
|
113
|
+
lines.push(statsRow(
|
|
114
|
+
' output',
|
|
115
|
+
`${a.outputTokens}`,
|
|
116
|
+
`${b.outputTokens}`,
|
|
117
|
+
delta.outputTokens,
|
|
118
|
+
null,
|
|
119
|
+
'',
|
|
120
|
+
true
|
|
121
|
+
));
|
|
122
|
+
|
|
123
|
+
lines.push(statsRow(
|
|
124
|
+
'cost',
|
|
125
|
+
`$${formatCost(a.cost)}`,
|
|
126
|
+
`$${formatCost(b.cost)}`,
|
|
127
|
+
delta.cost,
|
|
128
|
+
delta.costPct,
|
|
129
|
+
'$'
|
|
130
|
+
));
|
|
131
|
+
|
|
132
|
+
lines.push(statsRow(
|
|
133
|
+
'latency',
|
|
134
|
+
`${round(a.latencyMs, 0)}ms`,
|
|
135
|
+
`${round(b.latencyMs, 0)}ms`,
|
|
136
|
+
delta.latencyMs,
|
|
137
|
+
delta.latencyPct,
|
|
138
|
+
'ms'
|
|
139
|
+
));
|
|
140
|
+
|
|
141
|
+
lines.push('');
|
|
142
|
+
|
|
143
|
+
// Response diff
|
|
144
|
+
lines.push(`${c.bold}--- prompt A${c.reset}`);
|
|
145
|
+
lines.push(`${c.bold}+++ prompt B${c.reset}`);
|
|
146
|
+
lines.push('');
|
|
147
|
+
|
|
148
|
+
if (opts.full) {
|
|
149
|
+
// Full inline diff
|
|
150
|
+
for (const part of wordDiff) {
|
|
151
|
+
if (part.added) {
|
|
152
|
+
process.stdout.write(`${c.bgGreen}${c.white}${part.value}${c.reset}`);
|
|
153
|
+
} else if (part.removed) {
|
|
154
|
+
process.stdout.write(`${c.bgRed}${c.white}${c.strikethrough}${part.value}${c.reset}`);
|
|
155
|
+
} else {
|
|
156
|
+
process.stdout.write(part.value);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
process.stdout.write('\n');
|
|
160
|
+
} else {
|
|
161
|
+
// Compact: only changed lines
|
|
162
|
+
renderCompactDiff(wordDiff);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
lines.push('');
|
|
166
|
+
|
|
167
|
+
console.log(lines.join('\n'));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Compact diff view — shows only lines with changes, with surrounding context.
|
|
172
|
+
*/
|
|
173
|
+
function renderCompactDiff(wordDiff) {
|
|
174
|
+
// Build the full A and B texts, then do a line diff
|
|
175
|
+
let aText = '';
|
|
176
|
+
let bText = '';
|
|
177
|
+
for (const part of wordDiff) {
|
|
178
|
+
if (!part.added) aText += part.value;
|
|
179
|
+
if (!part.removed) bText += part.value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const aLines = aText.split('\n');
|
|
183
|
+
const bLines = bText.split('\n');
|
|
184
|
+
|
|
185
|
+
// Simple LCS-based line diff
|
|
186
|
+
const lineDiff = diffLines(aLines, bLines);
|
|
187
|
+
|
|
188
|
+
const contextLines = 2;
|
|
189
|
+
const output = [];
|
|
190
|
+
let lastPrinted = -1;
|
|
191
|
+
|
|
192
|
+
// Find which lines have changes
|
|
193
|
+
const changedIndices = new Set();
|
|
194
|
+
lineDiff.forEach((entry, i) => {
|
|
195
|
+
if (entry.type !== 'equal') {
|
|
196
|
+
// Mark surrounding context too
|
|
197
|
+
for (let j = Math.max(0, i - contextLines); j <= Math.min(lineDiff.length - 1, i + contextLines); j++) {
|
|
198
|
+
changedIndices.add(j);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
lineDiff.forEach((entry, i) => {
|
|
204
|
+
if (!changedIndices.has(i)) return;
|
|
205
|
+
|
|
206
|
+
if (lastPrinted >= 0 && i > lastPrinted + 1) {
|
|
207
|
+
output.push(`${c.dim} ...${c.reset}`);
|
|
208
|
+
}
|
|
209
|
+
lastPrinted = i;
|
|
210
|
+
|
|
211
|
+
switch (entry.type) {
|
|
212
|
+
case 'removed':
|
|
213
|
+
output.push(`${c.red}- ${entry.value}${c.reset}`);
|
|
214
|
+
break;
|
|
215
|
+
case 'added':
|
|
216
|
+
output.push(`${c.green}+ ${entry.value}${c.reset}`);
|
|
217
|
+
break;
|
|
218
|
+
case 'equal':
|
|
219
|
+
output.push(`${c.dim} ${entry.value}${c.reset}`);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
console.log(output.join('\n'));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Minimal line differ (no external dep needed for this simple case).
|
|
229
|
+
*/
|
|
230
|
+
function diffLines(aLines, bLines) {
|
|
231
|
+
const result = [];
|
|
232
|
+
let ai = 0;
|
|
233
|
+
let bi = 0;
|
|
234
|
+
|
|
235
|
+
// Simple greedy match — not optimal LCS but good enough for readable diffs
|
|
236
|
+
while (ai < aLines.length && bi < bLines.length) {
|
|
237
|
+
if (aLines[ai] === bLines[bi]) {
|
|
238
|
+
result.push({ type: 'equal', value: aLines[ai] });
|
|
239
|
+
ai++;
|
|
240
|
+
bi++;
|
|
241
|
+
} else {
|
|
242
|
+
// Look ahead for a match
|
|
243
|
+
const aMatch = findNext(bLines, aLines[ai], bi, 5);
|
|
244
|
+
const bMatch = findNext(aLines, bLines[bi], ai, 5);
|
|
245
|
+
|
|
246
|
+
if (aMatch === -1 && bMatch === -1) {
|
|
247
|
+
// Both lines changed
|
|
248
|
+
result.push({ type: 'removed', value: aLines[ai] });
|
|
249
|
+
result.push({ type: 'added', value: bLines[bi] });
|
|
250
|
+
ai++;
|
|
251
|
+
bi++;
|
|
252
|
+
} else if (bMatch !== -1 && (aMatch === -1 || bMatch - ai <= aMatch - bi)) {
|
|
253
|
+
// Lines were removed from A
|
|
254
|
+
while (ai < bMatch) {
|
|
255
|
+
result.push({ type: 'removed', value: aLines[ai] });
|
|
256
|
+
ai++;
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
// Lines were added in B
|
|
260
|
+
while (bi < aMatch) {
|
|
261
|
+
result.push({ type: 'added', value: bLines[bi] });
|
|
262
|
+
bi++;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
while (ai < aLines.length) {
|
|
269
|
+
result.push({ type: 'removed', value: aLines[ai++] });
|
|
270
|
+
}
|
|
271
|
+
while (bi < bLines.length) {
|
|
272
|
+
result.push({ type: 'added', value: bLines[bi++] });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function findNext(arr, value, from, maxLook) {
|
|
279
|
+
const limit = Math.min(arr.length, from + maxLook);
|
|
280
|
+
for (let i = from; i < limit; i++) {
|
|
281
|
+
if (arr[i] === value) return i;
|
|
282
|
+
}
|
|
283
|
+
return -1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Stats formatting helpers
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
function statsRow(label, valA, valB, delta, deltaPct, unit, subdued = false) {
|
|
291
|
+
const labelStr = subdued
|
|
292
|
+
? `${c.dim}${label.padEnd(12)}${c.reset}`
|
|
293
|
+
: `${c.bold}${label.padEnd(12)}${c.reset}`;
|
|
294
|
+
|
|
295
|
+
const arrow = `${c.dim}→${c.reset}`;
|
|
296
|
+
const valAStr = valA.padStart(10);
|
|
297
|
+
const valBStr = valB.padStart(10);
|
|
298
|
+
|
|
299
|
+
let deltaStr;
|
|
300
|
+
if (typeof delta === 'number') {
|
|
301
|
+
const sign = delta > 0 ? '+' : '';
|
|
302
|
+
const color = delta < 0 ? c.green : delta > 0 ? c.red : c.dim;
|
|
303
|
+
|
|
304
|
+
if (unit === '$') {
|
|
305
|
+
deltaStr = `${color}${sign}$${formatCost(Math.abs(delta))}${c.reset}`;
|
|
306
|
+
} else if (unit === 'ms') {
|
|
307
|
+
deltaStr = `${color}${sign}${round(delta, 0)}ms${c.reset}`;
|
|
308
|
+
} else {
|
|
309
|
+
deltaStr = `${color}${sign}${delta}${c.reset}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (deltaPct != null && deltaPct !== 0) {
|
|
313
|
+
const pctColor = deltaPct < 0 ? c.green : deltaPct > 0 ? c.red : c.dim;
|
|
314
|
+
deltaStr += ` ${pctColor}(${deltaPct > 0 ? '+' : ''}${round(deltaPct, 1)}%)${c.reset}`;
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
deltaStr = '';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return ` ${labelStr}${valAStr} ${arrow} ${valBStr} ${deltaStr}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function formatCost(n) {
|
|
324
|
+
if (n < 0.001) return n.toFixed(6);
|
|
325
|
+
if (n < 0.01) return n.toFixed(5);
|
|
326
|
+
if (n < 1) return n.toFixed(4);
|
|
327
|
+
return n.toFixed(2);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function round(n, decimals) {
|
|
331
|
+
const f = Math.pow(10, decimals);
|
|
332
|
+
return Math.round(n * f) / f;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Model list printer
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
export function renderModelList(grouped) {
|
|
340
|
+
console.log(`\n${c.bold}${c.cyan}Supported models${c.reset}\n`);
|
|
341
|
+
for (const [provider, models] of Object.entries(grouped)) {
|
|
342
|
+
console.log(` ${c.bold}${provider}${c.reset}`);
|
|
343
|
+
for (const m of models) {
|
|
344
|
+
const input = `$${m.inputCostPer1k.toFixed(5)}/1k in`;
|
|
345
|
+
const output = `$${m.outputCostPer1k.toFixed(5)}/1k out`;
|
|
346
|
+
console.log(` ${m.alias.padEnd(24)} ${c.dim}${input} ${output}${c.reset}`);
|
|
347
|
+
}
|
|
348
|
+
console.log('');
|
|
349
|
+
}
|
|
350
|
+
}
|