@xamukavila/pxpipe 0.8.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 +21 -0
- package/README.md +312 -0
- package/bin/cli.js +7 -0
- package/dist/core/applicability.d.ts +31 -0
- package/dist/core/applicability.js +96 -0
- package/dist/core/atlas-gray.d.ts +26 -0
- package/dist/core/atlas-gray.js +64 -0
- package/dist/core/atlas.d.ts +34 -0
- package/dist/core/atlas.js +71 -0
- package/dist/core/baseline.d.ts +80 -0
- package/dist/core/baseline.js +101 -0
- package/dist/core/caveman.d.ts +38 -0
- package/dist/core/caveman.js +183 -0
- package/dist/core/export.d.ts +128 -0
- package/dist/core/export.js +390 -0
- package/dist/core/factsheet.d.ts +78 -0
- package/dist/core/factsheet.js +216 -0
- package/dist/core/gpt-model-profiles.d.ts +60 -0
- package/dist/core/gpt-model-profiles.js +161 -0
- package/dist/core/history.d.ts +141 -0
- package/dist/core/history.js +553 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.js +8 -0
- package/dist/core/library.d.ts +74 -0
- package/dist/core/library.js +133 -0
- package/dist/core/measurement.d.ts +22 -0
- package/dist/core/measurement.js +213 -0
- package/dist/core/openai-history.d.ts +124 -0
- package/dist/core/openai-history.js +494 -0
- package/dist/core/openai-savings.d.ts +44 -0
- package/dist/core/openai-savings.js +75 -0
- package/dist/core/openai.d.ts +24 -0
- package/dist/core/openai.js +839 -0
- package/dist/core/png.d.ts +11 -0
- package/dist/core/png.js +132 -0
- package/dist/core/proxy.d.ts +81 -0
- package/dist/core/proxy.js +730 -0
- package/dist/core/render.d.ts +188 -0
- package/dist/core/render.js +785 -0
- package/dist/core/schema-strip.d.ts +29 -0
- package/dist/core/schema-strip.js +160 -0
- package/dist/core/tracker.d.ts +154 -0
- package/dist/core/tracker.js +216 -0
- package/dist/core/transform.d.ts +362 -0
- package/dist/core/transform.js +1828 -0
- package/dist/core/types.d.ts +77 -0
- package/dist/core/types.js +8 -0
- package/dist/dashboard/fragments.d.ts +36 -0
- package/dist/dashboard/fragments.js +938 -0
- package/dist/dashboard/types.d.ts +154 -0
- package/dist/dashboard/types.js +3 -0
- package/dist/dashboard/vendor.d.ts +3 -0
- package/dist/dashboard/vendor.js +6 -0
- package/dist/dashboard.d.ts +245 -0
- package/dist/dashboard.js +1140 -0
- package/dist/export-collect.d.ts +36 -0
- package/dist/export-collect.js +59 -0
- package/dist/node.d.ts +9 -0
- package/dist/node.js +9038 -0
- package/dist/sessions.d.ts +172 -0
- package/dist/sessions.js +510 -0
- package/dist/stats.d.ts +74 -0
- package/dist/stats.js +248 -0
- package/dist/worker.d.ts +53 -0
- package/dist/worker.js +102 -0
- package/package.json +96 -0
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate metrics over a stream of TrackEvents. Pure data-layer module —
|
|
3
|
+
* the dashboard's `/api/stats.json` endpoint imports `aggregateEventsFile`
|
|
4
|
+
* + `summaryToJson` from here. There is no longer a CLI entrypoint; the
|
|
5
|
+
* live dashboard at http://127.0.0.1:47821/ surfaces everything this used
|
|
6
|
+
* to print.
|
|
7
|
+
*
|
|
8
|
+
* Node-only (uses node:fs). Streams the file line-by-line so a 100 MB log
|
|
9
|
+
* doesn't blow the heap. The aggregator itself (`newSummary` / `fold`) is
|
|
10
|
+
* pure — fed a sequence of TrackEvents and produces a Summary — so a
|
|
11
|
+
* Workers-side dashboard could reuse it later by extracting it into core/.
|
|
12
|
+
*/
|
|
13
|
+
import type { TrackEvent } from './core/tracker.js';
|
|
14
|
+
export interface Summary {
|
|
15
|
+
total: number;
|
|
16
|
+
ok2xx: number;
|
|
17
|
+
err4xx: number;
|
|
18
|
+
err5xx: number;
|
|
19
|
+
compressed: number;
|
|
20
|
+
passthrough: number;
|
|
21
|
+
/** Sum of orig_chars across compressed requests — the bytes we removed
|
|
22
|
+
* from the text path by rendering to PNG. */
|
|
23
|
+
origCharsTotal: number;
|
|
24
|
+
imageBytesTotal: number;
|
|
25
|
+
/** Aggregated Anthropic token usage. */
|
|
26
|
+
inputTokensTotal: number;
|
|
27
|
+
outputTokensTotal: number;
|
|
28
|
+
cacheCreateTokensTotal: number;
|
|
29
|
+
cacheReadTokensTotal: number;
|
|
30
|
+
/** Number of events whose cache_read_tokens > 0 — i.e. the prompt cache
|
|
31
|
+
* actually hit. */
|
|
32
|
+
cacheHitEvents: number;
|
|
33
|
+
/** Number of events that carried any usage data at all. Denominator for
|
|
34
|
+
* cacheHitEvents. */
|
|
35
|
+
eventsWithUsage: number;
|
|
36
|
+
durationMs: number[];
|
|
37
|
+
firstByteMs: number[];
|
|
38
|
+
skipReasons: Map<string, number>;
|
|
39
|
+
byCwd: Map<string, {
|
|
40
|
+
count: number;
|
|
41
|
+
origChars: number;
|
|
42
|
+
imageBytes: number;
|
|
43
|
+
}>;
|
|
44
|
+
/** system_sha8 → number of times seen. High repeat count = cache should
|
|
45
|
+
* be doing its job. */
|
|
46
|
+
systemShaHist: Map<string, number>;
|
|
47
|
+
unknownTags: Map<string, number>;
|
|
48
|
+
}
|
|
49
|
+
export declare function newSummary(): Summary;
|
|
50
|
+
export declare function fold(s: Summary, ev: TrackEvent): Summary;
|
|
51
|
+
export declare function renderTextReport(s: Summary): string;
|
|
52
|
+
/**
|
|
53
|
+
* Stream an events JSONL file and fold every row into a Summary. Returns the
|
|
54
|
+
* Summary plus a parsed/dropped tally so callers can detect empty/garbage
|
|
55
|
+
* inputs. The dashboard wraps this for the /api/stats.json endpoint.
|
|
56
|
+
*
|
|
57
|
+
* Note: this is a full re-read on every call. The dashboard already has a
|
|
58
|
+
* 50-event ring buffer of the *recent* slice; stats need the full history
|
|
59
|
+
* to compute cache-hit-rate over thousands of requests. ~1.5 MB JSONL
|
|
60
|
+
* streams in well under 100 ms on an SSD.
|
|
61
|
+
*/
|
|
62
|
+
export declare function aggregateEventsFile(file: string): Promise<{
|
|
63
|
+
summary: Summary;
|
|
64
|
+
parsed: number;
|
|
65
|
+
dropped: number;
|
|
66
|
+
} | undefined>;
|
|
67
|
+
/**
|
|
68
|
+
* Convert a Summary to a JSON-serializable shape for the dashboard's
|
|
69
|
+
* /api/stats.json endpoint. JSON.stringify drops Map entries silently, so
|
|
70
|
+
* we materialize the top-N entries of each map into plain [key, value]
|
|
71
|
+
* tuples. Caps each map at 20 entries to keep the response bounded.
|
|
72
|
+
*/
|
|
73
|
+
export declare function summaryToJson(s: Summary): Record<string, unknown>;
|
|
74
|
+
//# sourceMappingURL=stats.d.ts.map
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate metrics over a stream of TrackEvents. Pure data-layer module —
|
|
3
|
+
* the dashboard's `/api/stats.json` endpoint imports `aggregateEventsFile`
|
|
4
|
+
* + `summaryToJson` from here. There is no longer a CLI entrypoint; the
|
|
5
|
+
* live dashboard at http://127.0.0.1:47821/ surfaces everything this used
|
|
6
|
+
* to print.
|
|
7
|
+
*
|
|
8
|
+
* Node-only (uses node:fs). Streams the file line-by-line so a 100 MB log
|
|
9
|
+
* doesn't blow the heap. The aggregator itself (`newSummary` / `fold`) is
|
|
10
|
+
* pure — fed a sequence of TrackEvents and produces a Summary — so a
|
|
11
|
+
* Workers-side dashboard could reuse it later by extracting it into core/.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as readline from 'node:readline';
|
|
15
|
+
export function newSummary() {
|
|
16
|
+
return {
|
|
17
|
+
total: 0,
|
|
18
|
+
ok2xx: 0,
|
|
19
|
+
err4xx: 0,
|
|
20
|
+
err5xx: 0,
|
|
21
|
+
compressed: 0,
|
|
22
|
+
passthrough: 0,
|
|
23
|
+
origCharsTotal: 0,
|
|
24
|
+
imageBytesTotal: 0,
|
|
25
|
+
inputTokensTotal: 0,
|
|
26
|
+
outputTokensTotal: 0,
|
|
27
|
+
cacheCreateTokensTotal: 0,
|
|
28
|
+
cacheReadTokensTotal: 0,
|
|
29
|
+
cacheHitEvents: 0,
|
|
30
|
+
eventsWithUsage: 0,
|
|
31
|
+
durationMs: [],
|
|
32
|
+
firstByteMs: [],
|
|
33
|
+
skipReasons: new Map(),
|
|
34
|
+
byCwd: new Map(),
|
|
35
|
+
systemShaHist: new Map(),
|
|
36
|
+
unknownTags: new Map(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function fold(s, ev) {
|
|
40
|
+
s.total++;
|
|
41
|
+
if (ev.status >= 200 && ev.status < 300)
|
|
42
|
+
s.ok2xx++;
|
|
43
|
+
else if (ev.status >= 400 && ev.status < 500)
|
|
44
|
+
s.err4xx++;
|
|
45
|
+
else if (ev.status >= 500)
|
|
46
|
+
s.err5xx++;
|
|
47
|
+
if (ev.compressed === true) {
|
|
48
|
+
s.compressed++;
|
|
49
|
+
if (typeof ev.orig_chars === 'number')
|
|
50
|
+
s.origCharsTotal += ev.orig_chars;
|
|
51
|
+
if (typeof ev.image_bytes === 'number')
|
|
52
|
+
s.imageBytesTotal += ev.image_bytes;
|
|
53
|
+
}
|
|
54
|
+
else if (ev.compressed === false) {
|
|
55
|
+
s.passthrough++;
|
|
56
|
+
if (ev.reason)
|
|
57
|
+
s.skipReasons.set(ev.reason, (s.skipReasons.get(ev.reason) ?? 0) + 1);
|
|
58
|
+
}
|
|
59
|
+
if (typeof ev.duration_ms === 'number')
|
|
60
|
+
s.durationMs.push(ev.duration_ms);
|
|
61
|
+
if (typeof ev.first_byte_ms === 'number')
|
|
62
|
+
s.firstByteMs.push(ev.first_byte_ms);
|
|
63
|
+
const hasUsage = typeof ev.input_tokens === 'number' ||
|
|
64
|
+
typeof ev.cache_read_tokens === 'number' ||
|
|
65
|
+
typeof ev.cache_create_tokens === 'number' ||
|
|
66
|
+
typeof ev.output_tokens === 'number';
|
|
67
|
+
if (hasUsage) {
|
|
68
|
+
s.eventsWithUsage++;
|
|
69
|
+
s.inputTokensTotal += ev.input_tokens ?? 0;
|
|
70
|
+
s.outputTokensTotal += ev.output_tokens ?? 0;
|
|
71
|
+
s.cacheCreateTokensTotal += ev.cache_create_tokens ?? 0;
|
|
72
|
+
s.cacheReadTokensTotal += ev.cache_read_tokens ?? 0;
|
|
73
|
+
if ((ev.cache_read_tokens ?? 0) > 0)
|
|
74
|
+
s.cacheHitEvents++;
|
|
75
|
+
}
|
|
76
|
+
if (ev.cwd) {
|
|
77
|
+
const k = ev.cwd;
|
|
78
|
+
const e = s.byCwd.get(k) ?? { count: 0, origChars: 0, imageBytes: 0 };
|
|
79
|
+
e.count++;
|
|
80
|
+
e.origChars += ev.orig_chars ?? 0;
|
|
81
|
+
e.imageBytes += ev.image_bytes ?? 0;
|
|
82
|
+
s.byCwd.set(k, e);
|
|
83
|
+
}
|
|
84
|
+
if (ev.system_sha8) {
|
|
85
|
+
s.systemShaHist.set(ev.system_sha8, (s.systemShaHist.get(ev.system_sha8) ?? 0) + 1);
|
|
86
|
+
}
|
|
87
|
+
if (ev.unknown_static_tags) {
|
|
88
|
+
for (const t of ev.unknown_static_tags) {
|
|
89
|
+
s.unknownTags.set(t, (s.unknownTags.get(t) ?? 0) + 1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return s;
|
|
93
|
+
}
|
|
94
|
+
function percentile(sorted, p) {
|
|
95
|
+
if (sorted.length === 0)
|
|
96
|
+
return 0;
|
|
97
|
+
const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
|
|
98
|
+
return sorted[idx];
|
|
99
|
+
}
|
|
100
|
+
/** Format a number with thousands separators. Used for big token counts. */
|
|
101
|
+
function fmtN(n) {
|
|
102
|
+
return n.toLocaleString('en-US');
|
|
103
|
+
}
|
|
104
|
+
function fmtPct(num, denom) {
|
|
105
|
+
if (denom === 0)
|
|
106
|
+
return ' —';
|
|
107
|
+
return ((num / denom) * 100).toFixed(1).padStart(4) + '%';
|
|
108
|
+
}
|
|
109
|
+
// ---- text report ----------------------------------------------------------
|
|
110
|
+
export function renderTextReport(s) {
|
|
111
|
+
const lines = [];
|
|
112
|
+
const sortedDur = [...s.durationMs].sort((a, b) => a - b);
|
|
113
|
+
const sortedFB = [...s.firstByteMs].sort((a, b) => a - b);
|
|
114
|
+
lines.push('━━━ pxpipe stats ━━━');
|
|
115
|
+
lines.push('');
|
|
116
|
+
lines.push(`requests: ${fmtN(s.total)}`);
|
|
117
|
+
lines.push(` 2xx: ${fmtN(s.ok2xx).padStart(8)} ` +
|
|
118
|
+
`4xx: ${fmtN(s.err4xx).padStart(6)} 5xx: ${fmtN(s.err5xx).padStart(6)}`);
|
|
119
|
+
lines.push(` compressed: ${fmtN(s.compressed).padStart(8)} (${fmtPct(s.compressed, s.total)})`);
|
|
120
|
+
lines.push(` passthrough: ${fmtN(s.passthrough).padStart(8)} (${fmtPct(s.passthrough, s.total)})`);
|
|
121
|
+
lines.push('');
|
|
122
|
+
lines.push('latency (ms):');
|
|
123
|
+
lines.push(` duration p50=${percentile(sortedDur, 50)} p95=${percentile(sortedDur, 95)} p99=${percentile(sortedDur, 99)}`);
|
|
124
|
+
lines.push(` first-byte p50=${percentile(sortedFB, 50)} p95=${percentile(sortedFB, 95)} p99=${percentile(sortedFB, 99)}`);
|
|
125
|
+
lines.push('');
|
|
126
|
+
lines.push('compression:');
|
|
127
|
+
lines.push(` orig text rendered: ${fmtN(s.origCharsTotal)} chars`);
|
|
128
|
+
lines.push(` image bytes: ${fmtN(s.imageBytesTotal)} B`);
|
|
129
|
+
const ratio = s.origCharsTotal > 0 ? (s.imageBytesTotal / s.origCharsTotal).toFixed(3) : '—';
|
|
130
|
+
lines.push(` bytes/char ratio: ${ratio}`);
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push('Anthropic token usage:');
|
|
133
|
+
lines.push(` input: ${fmtN(s.inputTokensTotal).padStart(12)}`);
|
|
134
|
+
lines.push(` output: ${fmtN(s.outputTokensTotal).padStart(12)}`);
|
|
135
|
+
lines.push(` cache create: ${fmtN(s.cacheCreateTokensTotal).padStart(12)}`);
|
|
136
|
+
lines.push(` cache read: ${fmtN(s.cacheReadTokensTotal).padStart(12)}`);
|
|
137
|
+
const totalIn = s.inputTokensTotal + s.cacheCreateTokensTotal + s.cacheReadTokensTotal;
|
|
138
|
+
lines.push(` cache hit rate (by tokens): ${fmtPct(s.cacheReadTokensTotal, totalIn)}`);
|
|
139
|
+
lines.push(` cache hit rate (by events): ${fmtPct(s.cacheHitEvents, s.eventsWithUsage)}`);
|
|
140
|
+
lines.push('');
|
|
141
|
+
if (s.skipReasons.size > 0) {
|
|
142
|
+
lines.push('top skip reasons:');
|
|
143
|
+
const top = [...s.skipReasons.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
144
|
+
for (const [reason, count] of top) {
|
|
145
|
+
lines.push(` ${count.toString().padStart(6)} ${reason}`);
|
|
146
|
+
}
|
|
147
|
+
lines.push('');
|
|
148
|
+
}
|
|
149
|
+
if (s.byCwd.size > 0) {
|
|
150
|
+
lines.push('top working dirs (by request count):');
|
|
151
|
+
const top = [...s.byCwd.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, 10);
|
|
152
|
+
for (const [cwd, e] of top) {
|
|
153
|
+
const cratio = e.origChars > 0 ? (e.imageBytes / e.origChars).toFixed(2) : '—';
|
|
154
|
+
lines.push(` ${e.count.toString().padStart(6)} ratio=${cratio} ${cwd}`);
|
|
155
|
+
}
|
|
156
|
+
lines.push('');
|
|
157
|
+
}
|
|
158
|
+
if (s.systemShaHist.size > 0) {
|
|
159
|
+
lines.push('top system prompts (system_sha8, high count = cache reuse):');
|
|
160
|
+
const top = [...s.systemShaHist.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
161
|
+
for (const [sha, count] of top) {
|
|
162
|
+
lines.push(` ${count.toString().padStart(6)} ${sha}`);
|
|
163
|
+
}
|
|
164
|
+
const unique = s.systemShaHist.size;
|
|
165
|
+
const reuseRate = s.total > 0 ? (((s.total - unique) / s.total) * 100).toFixed(1) : '—';
|
|
166
|
+
lines.push(` unique prompts: ${unique} reuse rate: ${reuseRate}%`);
|
|
167
|
+
lines.push('');
|
|
168
|
+
}
|
|
169
|
+
if (s.unknownTags.size > 0) {
|
|
170
|
+
lines.push('⚠ unknown tag-shaped blocks observed in static slab:');
|
|
171
|
+
const top = [...s.unknownTags.entries()].sort((a, b) => b[1] - a[1]);
|
|
172
|
+
for (const [tag, count] of top) {
|
|
173
|
+
lines.push(` ${count.toString().padStart(6)} <${tag}>`);
|
|
174
|
+
}
|
|
175
|
+
lines.push(' → consider adding these to DYNAMIC_BLOCK_TAGS in src/core/transform.ts');
|
|
176
|
+
lines.push('');
|
|
177
|
+
}
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|
|
180
|
+
// ---- file-backed aggregation (used by the dashboard) ----------------------
|
|
181
|
+
/**
|
|
182
|
+
* Stream an events JSONL file and fold every row into a Summary. Returns the
|
|
183
|
+
* Summary plus a parsed/dropped tally so callers can detect empty/garbage
|
|
184
|
+
* inputs. The dashboard wraps this for the /api/stats.json endpoint.
|
|
185
|
+
*
|
|
186
|
+
* Note: this is a full re-read on every call. The dashboard already has a
|
|
187
|
+
* 50-event ring buffer of the *recent* slice; stats need the full history
|
|
188
|
+
* to compute cache-hit-rate over thousands of requests. ~1.5 MB JSONL
|
|
189
|
+
* streams in well under 100 ms on an SSD.
|
|
190
|
+
*/
|
|
191
|
+
export async function aggregateEventsFile(file) {
|
|
192
|
+
if (!fs.existsSync(file))
|
|
193
|
+
return undefined;
|
|
194
|
+
const stream = fs.createReadStream(file, { encoding: 'utf8' });
|
|
195
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
196
|
+
const summary = newSummary();
|
|
197
|
+
let parsed = 0;
|
|
198
|
+
let dropped = 0;
|
|
199
|
+
for await (const line of rl) {
|
|
200
|
+
if (!line.trim())
|
|
201
|
+
continue;
|
|
202
|
+
try {
|
|
203
|
+
const ev = JSON.parse(line);
|
|
204
|
+
fold(summary, ev);
|
|
205
|
+
parsed++;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
dropped++;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { summary, parsed, dropped };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Convert a Summary to a JSON-serializable shape for the dashboard's
|
|
215
|
+
* /api/stats.json endpoint. JSON.stringify drops Map entries silently, so
|
|
216
|
+
* we materialize the top-N entries of each map into plain [key, value]
|
|
217
|
+
* tuples. Caps each map at 20 entries to keep the response bounded.
|
|
218
|
+
*/
|
|
219
|
+
export function summaryToJson(s) {
|
|
220
|
+
const topN = (m, n = 20) => [...m.entries()].slice(0, n);
|
|
221
|
+
const sortedDur = [...s.durationMs].sort((a, b) => a - b);
|
|
222
|
+
const sortedFB = [...s.firstByteMs].sort((a, b) => a - b);
|
|
223
|
+
return {
|
|
224
|
+
total: s.total,
|
|
225
|
+
ok2xx: s.ok2xx,
|
|
226
|
+
err4xx: s.err4xx,
|
|
227
|
+
err5xx: s.err5xx,
|
|
228
|
+
compressed: s.compressed,
|
|
229
|
+
passthrough: s.passthrough,
|
|
230
|
+
origCharsTotal: s.origCharsTotal,
|
|
231
|
+
imageBytesTotal: s.imageBytesTotal,
|
|
232
|
+
inputTokensTotal: s.inputTokensTotal,
|
|
233
|
+
outputTokensTotal: s.outputTokensTotal,
|
|
234
|
+
cacheCreateTokensTotal: s.cacheCreateTokensTotal,
|
|
235
|
+
cacheReadTokensTotal: s.cacheReadTokensTotal,
|
|
236
|
+
cacheHitEvents: s.cacheHitEvents,
|
|
237
|
+
eventsWithUsage: s.eventsWithUsage,
|
|
238
|
+
durationP50: percentile(sortedDur, 50),
|
|
239
|
+
durationP95: percentile(sortedDur, 95),
|
|
240
|
+
firstByteP50: percentile(sortedFB, 50),
|
|
241
|
+
firstByteP95: percentile(sortedFB, 95),
|
|
242
|
+
skipReasons: topN(s.skipReasons),
|
|
243
|
+
byCwd: topN(s.byCwd),
|
|
244
|
+
systemShaHist: topN(s.systemShaHist),
|
|
245
|
+
unknownTags: topN(s.unknownTags),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
//# sourceMappingURL=stats.js.map
|
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers entrypoint. Identical proxy logic to the Node build,
|
|
3
|
+
* just wired up through the Worker `fetch` export.
|
|
4
|
+
*
|
|
5
|
+
* Deploy:
|
|
6
|
+
* npx wrangler deploy
|
|
7
|
+
*
|
|
8
|
+
* Dev:
|
|
9
|
+
* npx wrangler dev
|
|
10
|
+
*
|
|
11
|
+
* Config lives in wrangler.toml.
|
|
12
|
+
*/
|
|
13
|
+
export interface Env {
|
|
14
|
+
/** Optional single upstream base for every API family. Family-specific env vars override it. */
|
|
15
|
+
PXPIPE_UPSTREAM?: string;
|
|
16
|
+
ANTHROPIC_UPSTREAM?: string;
|
|
17
|
+
/** Optional override — if set, replaces whatever x-api-key the client sent. */
|
|
18
|
+
ANTHROPIC_API_KEY?: string;
|
|
19
|
+
OPENAI_UPSTREAM?: string;
|
|
20
|
+
/** Optional override — if set, replaces whatever Authorization the client sent. */
|
|
21
|
+
OPENAI_API_KEY?: string;
|
|
22
|
+
COMPRESS?: string;
|
|
23
|
+
COMPRESS_TOOLS?: string;
|
|
24
|
+
COMPRESS_REMINDERS?: string;
|
|
25
|
+
COMPRESS_TOOL_RESULTS?: string;
|
|
26
|
+
MIN_COMPRESS_CHARS?: string;
|
|
27
|
+
MIN_REMINDER_CHARS?: string;
|
|
28
|
+
MIN_TOOL_RESULT_CHARS?: string;
|
|
29
|
+
COLS?: string;
|
|
30
|
+
/** R2 multi-column packing — default 1 (off). 2 squeezes ~2× source rows
|
|
31
|
+
* per image; OCR-verify before flipping in production. */
|
|
32
|
+
MULTI_COL?: string;
|
|
33
|
+
/** Experiment, default off: deterministic prose compression (drop EN/PT
|
|
34
|
+
* articles/fillers) on image-bound text before rendering. Flipping it
|
|
35
|
+
* changes image bytes (= prompt-cache key); keep constant per deployment.
|
|
36
|
+
* See TransformOptions.caveman / src/core/caveman.ts. */
|
|
37
|
+
CAVEMAN?: string;
|
|
38
|
+
/** When "0" / "false", disable per-request event JSON logs. Default-on.
|
|
39
|
+
* Cloudflare ingests console.log as Workers Logs; pipe via Logpush to
|
|
40
|
+
* R2/S3 for the same JSONL shape Node writes to disk. */
|
|
41
|
+
PXPIPE_TRACK?: string;
|
|
42
|
+
/** Shared secret callers must present via the `x-pxpipe-secret` header
|
|
43
|
+
* whenever an API-key override is configured. Without this gate a
|
|
44
|
+
* discovered workers.dev URL is an open key-spender: the Worker would
|
|
45
|
+
* attach your key to any stranger's request. Set with:
|
|
46
|
+
* npx wrangler secret put PXPIPE_WORKER_SECRET */
|
|
47
|
+
PXPIPE_WORKER_SECRET?: string;
|
|
48
|
+
}
|
|
49
|
+
declare const _default: {
|
|
50
|
+
fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response>;
|
|
51
|
+
};
|
|
52
|
+
export default _default;
|
|
53
|
+
//# sourceMappingURL=worker.d.ts.map
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers entrypoint. Identical proxy logic to the Node build,
|
|
3
|
+
* just wired up through the Worker `fetch` export.
|
|
4
|
+
*
|
|
5
|
+
* Deploy:
|
|
6
|
+
* npx wrangler deploy
|
|
7
|
+
*
|
|
8
|
+
* Dev:
|
|
9
|
+
* npx wrangler dev
|
|
10
|
+
*
|
|
11
|
+
* Config lives in wrangler.toml.
|
|
12
|
+
*/
|
|
13
|
+
import { createProxy } from './core/proxy.js';
|
|
14
|
+
import { toTrackEvent, JsonLogTracker, noopTracker } from './core/tracker.js';
|
|
15
|
+
/** Compare SHA-256 digests instead of the raw strings so the comparison
|
|
16
|
+
* can't leak a prefix-match timing signal. */
|
|
17
|
+
async function secretsMatch(a, b) {
|
|
18
|
+
const enc = new TextEncoder();
|
|
19
|
+
const [da, db] = await Promise.all([
|
|
20
|
+
crypto.subtle.digest('SHA-256', enc.encode(a)),
|
|
21
|
+
crypto.subtle.digest('SHA-256', enc.encode(b)),
|
|
22
|
+
]);
|
|
23
|
+
const va = new Uint8Array(da);
|
|
24
|
+
const vb = new Uint8Array(db);
|
|
25
|
+
let diff = 0;
|
|
26
|
+
for (let i = 0; i < va.length; i++)
|
|
27
|
+
diff |= (va[i] ?? 0) ^ (vb[i] ?? 0);
|
|
28
|
+
return diff === 0;
|
|
29
|
+
}
|
|
30
|
+
const truthy = (v, fallback) => v == null ? fallback : v === '1' || v.toLowerCase() === 'true';
|
|
31
|
+
export default {
|
|
32
|
+
async fetch(req, env, _ctx) {
|
|
33
|
+
// ── Caller auth ────────────────────────────────────────────────────
|
|
34
|
+
// If this deployment injects API keys, never serve anonymous callers:
|
|
35
|
+
// workers.dev URLs are discoverable, and without this gate anyone who
|
|
36
|
+
// finds the URL spends this deployment's API credits.
|
|
37
|
+
if (env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY) {
|
|
38
|
+
if (!env.PXPIPE_WORKER_SECRET) {
|
|
39
|
+
return new Response(JSON.stringify({
|
|
40
|
+
error: 'refusing to proxy: an API key override is configured but PXPIPE_WORKER_SECRET is not, ' +
|
|
41
|
+
'which would let anyone who finds this URL spend the configured key. ' +
|
|
42
|
+
'Run `npx wrangler secret put PXPIPE_WORKER_SECRET` and send the value as the x-pxpipe-secret header.',
|
|
43
|
+
}), { status: 503, headers: { 'content-type': 'application/json' } });
|
|
44
|
+
}
|
|
45
|
+
const presented = req.headers.get('x-pxpipe-secret') ?? '';
|
|
46
|
+
if (!(await secretsMatch(presented, env.PXPIPE_WORKER_SECRET))) {
|
|
47
|
+
return new Response(JSON.stringify({ error: 'missing or invalid x-pxpipe-secret header' }), { status: 401, headers: { 'content-type': 'application/json' } });
|
|
48
|
+
}
|
|
49
|
+
// Don't forward the shared secret upstream.
|
|
50
|
+
req = new Request(req);
|
|
51
|
+
req.headers.delete('x-pxpipe-secret');
|
|
52
|
+
}
|
|
53
|
+
const transform = {
|
|
54
|
+
compress: truthy(env.COMPRESS, true),
|
|
55
|
+
compressTools: truthy(env.COMPRESS_TOOLS, true),
|
|
56
|
+
compressReminders: truthy(env.COMPRESS_REMINDERS, true),
|
|
57
|
+
compressToolResults: truthy(env.COMPRESS_TOOL_RESULTS, true),
|
|
58
|
+
minCompressChars: env.MIN_COMPRESS_CHARS ? Number(env.MIN_COMPRESS_CHARS) : 2000,
|
|
59
|
+
// 500 chars — CPU/latency floor only, not a correctness guard. The
|
|
60
|
+
// No floors — the content-aware `isCompressionProfitable()` gate
|
|
61
|
+
// decides per-block based on actual pixel cost vs text cost. Host
|
|
62
|
+
// can still set a floor via env if they want observability buckets
|
|
63
|
+
// (e.g. MIN_TOOL_RESULT_CHARS=200 to skip absurdly small dumps).
|
|
64
|
+
minReminderChars: env.MIN_REMINDER_CHARS ? Number(env.MIN_REMINDER_CHARS) : 0,
|
|
65
|
+
minToolResultChars: env.MIN_TOOL_RESULT_CHARS ? Number(env.MIN_TOOL_RESULT_CHARS) : 0,
|
|
66
|
+
cols: env.COLS ? Number(env.COLS) : 100,
|
|
67
|
+
// R2 multi-column ON (2 cols) — single-col drops below break-even on
|
|
68
|
+
// real tool-doc slabs. Override via MULTI_COL=1 if OCR misreads layout.
|
|
69
|
+
multiCol: env.MULTI_COL ? Math.max(1, Number(env.MULTI_COL) | 0) : 2,
|
|
70
|
+
caveman: truthy(env.CAVEMAN, false),
|
|
71
|
+
};
|
|
72
|
+
const trackingOn = truthy(env.PXPIPE_TRACK, true);
|
|
73
|
+
// Workers Logs ingests stdout as separate log lines. Emit one JSON line
|
|
74
|
+
// per event so downstream (Logpush → R2/S3) reads the same JSONL shape
|
|
75
|
+
// the Node host writes to disk.
|
|
76
|
+
const tracker = trackingOn ? new JsonLogTracker((s) => console.log(s)) : noopTracker;
|
|
77
|
+
const sharedUpstream = env.PXPIPE_UPSTREAM;
|
|
78
|
+
const config = {
|
|
79
|
+
upstream: env.ANTHROPIC_UPSTREAM ?? sharedUpstream ?? 'https://api.anthropic.com',
|
|
80
|
+
apiKey: env.ANTHROPIC_API_KEY,
|
|
81
|
+
openAIUpstream: env.OPENAI_UPSTREAM ?? sharedUpstream ?? 'https://api.openai.com',
|
|
82
|
+
openAIApiKey: env.OPENAI_API_KEY,
|
|
83
|
+
transform,
|
|
84
|
+
onRequest: (e) => {
|
|
85
|
+
// Terse human-readable line (separate from the JSON event below;
|
|
86
|
+
// shows up in `wrangler tail`).
|
|
87
|
+
const tag = e.info?.compressed
|
|
88
|
+
? `compressed ${e.info.origChars}ch → ${e.info.imageCount}img/${e.info.imageBytes}B`
|
|
89
|
+
: (e.info?.reason ?? '');
|
|
90
|
+
const cacheRead = e.usage?.cache_read_input_tokens ?? 0;
|
|
91
|
+
console.log(`${e.method} ${e.path} → ${e.status} (${e.durationMs}ms) ${tag} cache_read=${cacheRead}`);
|
|
92
|
+
if (e.info?.unknownStaticTags && e.info.unknownStaticTags.length > 0) {
|
|
93
|
+
console.warn(`[pxpipe warn] unknown tag(s) in static slab: ${e.info.unknownStaticTags.join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
tracker.emit(toTrackEvent(e));
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const handle = createProxy(config);
|
|
99
|
+
return handle(req);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
//# sourceMappingURL=worker.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xamukavila/pxpipe",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Token-saving proxy for Claude Code: renders bulky context (system prompt, tool docs, old history) as dense PNGs to cut input tokens. Runs on Node and Cloudflare Workers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pxpipe": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/core/index.d.ts",
|
|
12
|
+
"import": "./dist/core/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./transform": {
|
|
15
|
+
"types": "./dist/core/library.d.ts",
|
|
16
|
+
"import": "./dist/core/library.js"
|
|
17
|
+
},
|
|
18
|
+
"./measurement": {
|
|
19
|
+
"types": "./dist/core/measurement.d.ts",
|
|
20
|
+
"import": "./dist/core/measurement.js"
|
|
21
|
+
},
|
|
22
|
+
"./applicability": {
|
|
23
|
+
"types": "./dist/core/applicability.d.ts",
|
|
24
|
+
"import": "./dist/core/applicability.js"
|
|
25
|
+
},
|
|
26
|
+
"./proxy": {
|
|
27
|
+
"types": "./dist/core/proxy.d.ts",
|
|
28
|
+
"import": "./dist/core/proxy.js"
|
|
29
|
+
},
|
|
30
|
+
"./node": {
|
|
31
|
+
"types": "./dist/node.d.ts",
|
|
32
|
+
"import": "./dist/node.js"
|
|
33
|
+
},
|
|
34
|
+
"./worker": {
|
|
35
|
+
"types": "./dist/worker.d.ts",
|
|
36
|
+
"import": "./dist/worker.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"bin/",
|
|
41
|
+
"dist/",
|
|
42
|
+
"!dist/**/*.map",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"keywords": [
|
|
47
|
+
"claude",
|
|
48
|
+
"claude-code",
|
|
49
|
+
"anthropic",
|
|
50
|
+
"fable-5",
|
|
51
|
+
"proxy",
|
|
52
|
+
"token-optimization",
|
|
53
|
+
"prompt-cache",
|
|
54
|
+
"vision-tokens",
|
|
55
|
+
"cloudflare-workers"
|
|
56
|
+
],
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18"
|
|
59
|
+
},
|
|
60
|
+
"license": "MIT",
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@cloudflare/workers-types": "^4.20240512.0",
|
|
63
|
+
"@napi-rs/canvas": "^0.1.53",
|
|
64
|
+
"@types/node": "^20.12.0",
|
|
65
|
+
"esbuild": "^0.28.1",
|
|
66
|
+
"tsx": "^4.10.0",
|
|
67
|
+
"typescript": "^5.4.0",
|
|
68
|
+
"vite": "^8.0.16",
|
|
69
|
+
"vitest": "^4.1.6",
|
|
70
|
+
"wrangler": "^4.92.0"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"gpt-tokenizer": "^3.4.0"
|
|
74
|
+
},
|
|
75
|
+
"repository": {
|
|
76
|
+
"type": "git",
|
|
77
|
+
"url": "git+https://github.com/XamuAvila/pxpipe.git"
|
|
78
|
+
},
|
|
79
|
+
"homepage": "https://github.com/XamuAvila/pxpipe#readme",
|
|
80
|
+
"bugs": {
|
|
81
|
+
"url": "https://github.com/XamuAvila/pxpipe/issues"
|
|
82
|
+
},
|
|
83
|
+
"scripts": {
|
|
84
|
+
"build": "node scripts/build.mjs",
|
|
85
|
+
"build:atlas": "tsx scripts/gen-atlas.ts",
|
|
86
|
+
"dev:node": "tsx watch src/node.ts",
|
|
87
|
+
"dev:worker": "wrangler dev",
|
|
88
|
+
"deploy:worker": "wrangler deploy",
|
|
89
|
+
"restart": "bash scripts/restart.sh",
|
|
90
|
+
"typecheck": "tsc --noEmit",
|
|
91
|
+
"test": "vitest run",
|
|
92
|
+
"test:watch": "vitest",
|
|
93
|
+
"test:restart": "bash tests/restart.test.sh",
|
|
94
|
+
"vendor:ui": "node scripts/vendor-ui.mjs"
|
|
95
|
+
}
|
|
96
|
+
}
|