@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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bin/cli.js +7 -0
  4. package/dist/core/applicability.d.ts +31 -0
  5. package/dist/core/applicability.js +96 -0
  6. package/dist/core/atlas-gray.d.ts +26 -0
  7. package/dist/core/atlas-gray.js +64 -0
  8. package/dist/core/atlas.d.ts +34 -0
  9. package/dist/core/atlas.js +71 -0
  10. package/dist/core/baseline.d.ts +80 -0
  11. package/dist/core/baseline.js +101 -0
  12. package/dist/core/caveman.d.ts +38 -0
  13. package/dist/core/caveman.js +183 -0
  14. package/dist/core/export.d.ts +128 -0
  15. package/dist/core/export.js +390 -0
  16. package/dist/core/factsheet.d.ts +78 -0
  17. package/dist/core/factsheet.js +216 -0
  18. package/dist/core/gpt-model-profiles.d.ts +60 -0
  19. package/dist/core/gpt-model-profiles.js +161 -0
  20. package/dist/core/history.d.ts +141 -0
  21. package/dist/core/history.js +553 -0
  22. package/dist/core/index.d.ts +8 -0
  23. package/dist/core/index.js +8 -0
  24. package/dist/core/library.d.ts +74 -0
  25. package/dist/core/library.js +133 -0
  26. package/dist/core/measurement.d.ts +22 -0
  27. package/dist/core/measurement.js +213 -0
  28. package/dist/core/openai-history.d.ts +124 -0
  29. package/dist/core/openai-history.js +494 -0
  30. package/dist/core/openai-savings.d.ts +44 -0
  31. package/dist/core/openai-savings.js +75 -0
  32. package/dist/core/openai.d.ts +24 -0
  33. package/dist/core/openai.js +839 -0
  34. package/dist/core/png.d.ts +11 -0
  35. package/dist/core/png.js +132 -0
  36. package/dist/core/proxy.d.ts +81 -0
  37. package/dist/core/proxy.js +730 -0
  38. package/dist/core/render.d.ts +188 -0
  39. package/dist/core/render.js +785 -0
  40. package/dist/core/schema-strip.d.ts +29 -0
  41. package/dist/core/schema-strip.js +160 -0
  42. package/dist/core/tracker.d.ts +154 -0
  43. package/dist/core/tracker.js +216 -0
  44. package/dist/core/transform.d.ts +362 -0
  45. package/dist/core/transform.js +1828 -0
  46. package/dist/core/types.d.ts +77 -0
  47. package/dist/core/types.js +8 -0
  48. package/dist/dashboard/fragments.d.ts +36 -0
  49. package/dist/dashboard/fragments.js +938 -0
  50. package/dist/dashboard/types.d.ts +154 -0
  51. package/dist/dashboard/types.js +3 -0
  52. package/dist/dashboard/vendor.d.ts +3 -0
  53. package/dist/dashboard/vendor.js +6 -0
  54. package/dist/dashboard.d.ts +245 -0
  55. package/dist/dashboard.js +1140 -0
  56. package/dist/export-collect.d.ts +36 -0
  57. package/dist/export-collect.js +59 -0
  58. package/dist/node.d.ts +9 -0
  59. package/dist/node.js +9038 -0
  60. package/dist/sessions.d.ts +172 -0
  61. package/dist/sessions.js +510 -0
  62. package/dist/stats.d.ts +74 -0
  63. package/dist/stats.js +248 -0
  64. package/dist/worker.d.ts +53 -0
  65. package/dist/worker.js +102 -0
  66. package/package.json +96 -0
@@ -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
@@ -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
+ }