@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,730 @@
1
+ /**
2
+ * pxpipe proxy as a single Web-standard fetch handler.
3
+ * Adapted by src/node.ts and src/worker.ts; uses only Request/Response/URL/fetch.
4
+ */
5
+ import { transformRequest } from './transform.js';
6
+ import { transformOpenAIChatCompletions, transformOpenAIResponses } from './openai.js';
7
+ import { isAnthropicMessagesPath, isPxpipeSupportedGptModel, isPxpipeSupportedModel } from './applicability.js';
8
+ import { buildBaselineCountTokensBody, buildCacheablePrefixCountTokensBody, } from './measurement.js';
9
+ /** Max chars of 4xx error body captured on ProxyEvent — enough for Anthropic's full error JSON. */
10
+ const ERROR_BODY_MAX = 2048;
11
+ /** Read the top-level `model` field from a /v1/messages body without parsing the full JSON.
12
+ * Returns null when not found — callers treat null as outside supported scope (fail-closed). */
13
+ function readModelField(body) {
14
+ try {
15
+ const head = new TextDecoder().decode(body.subarray(0, 8192));
16
+ const m = /"model"\s*:\s*"([^"]{1,80})"/.exec(head);
17
+ return m ? m[1] : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ /** Gzip via CompressionStream — available in Node 18+ and Cloudflare Workers. */
24
+ async function gzipBytes(body) {
25
+ // Cast: TS doesn't model Response(Uint8Array) even though it works in both runtimes.
26
+ const stream = new Response(body).body.pipeThrough(new CompressionStream('gzip'));
27
+ const buf = await new Response(stream).arrayBuffer();
28
+ return new Uint8Array(buf);
29
+ }
30
+ /** sha256[0..8] hex of a byte buffer. */
31
+ async function sha8Bytes(body) {
32
+ // Cast: Web Crypto accepts Uint8Array at runtime despite the BufferSource type.
33
+ const digest = await crypto.subtle.digest('SHA-256', body);
34
+ const bytes = new Uint8Array(digest);
35
+ let hex = '';
36
+ for (let i = 0; i < 4; i++)
37
+ hex += bytes[i].toString(16).padStart(2, '0');
38
+ return hex;
39
+ }
40
+ /** Parse one SSE block into the running usage + measurement accumulators. Silent on malformed input. */
41
+ function processSseEvent(block, m, state) {
42
+ // Parse `event:` + `data:` lines; continuation data: lines concatenate per SSE spec.
43
+ let event = '';
44
+ let data = '';
45
+ for (const line of block.split('\n')) {
46
+ if (line.startsWith('event:'))
47
+ event = line.slice(6).trim();
48
+ else if (line.startsWith('data:'))
49
+ data += line.slice(5).replace(/^\s/, '');
50
+ }
51
+ if (!data)
52
+ return;
53
+ let j;
54
+ try {
55
+ j = JSON.parse(data);
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ const obj = j;
61
+ // OpenAI chunks have no `event:` line; usage only present when stream_options.include_usage is set.
62
+ const openAIUsage = normalizeUsage(obj.usage);
63
+ if (openAIUsage)
64
+ state.usage = openAIUsage;
65
+ // OpenAI Responses API streams usage nested under `response` on the terminal
66
+ // `response.completed` (or `.incomplete`) event — not at the top level.
67
+ if (event === 'response.completed' || event === 'response.incomplete') {
68
+ const resp = obj.response;
69
+ const respUsage = normalizeUsage(resp?.usage);
70
+ if (respUsage)
71
+ state.usage = respUsage;
72
+ // Responses API has no stop_reason; normalize the terminal status/reason instead.
73
+ const reason = resp?.incomplete_details?.reason;
74
+ state.stopReason = typeof reason === 'string' ? reason
75
+ : event === 'response.incomplete' ? 'incomplete' : 'stop';
76
+ }
77
+ measureOpenAIChoices(obj, m);
78
+ // OpenAI chat chunks: the final chunk carries choices[].finish_reason (earlier chunks ship null).
79
+ const choices = obj.choices;
80
+ if (Array.isArray(choices)) {
81
+ for (const c of choices) {
82
+ const fr = c?.finish_reason;
83
+ if (typeof fr === 'string')
84
+ state.stopReason = fr;
85
+ }
86
+ }
87
+ if (event === 'message_start') {
88
+ const msg = obj.message;
89
+ const usage = normalizeUsage(msg?.usage);
90
+ if (usage)
91
+ state.usage = usage;
92
+ }
93
+ else if (event === 'content_block_start') {
94
+ const cb = obj.content_block;
95
+ if (cb?.type === 'redacted_thinking')
96
+ m.redactedBlockCount += 1;
97
+ }
98
+ else if (event === 'content_block_delta') {
99
+ const d = obj.delta;
100
+ if (d?.type === 'text_delta' && typeof d.text === 'string') {
101
+ m.textChars += d.text.length;
102
+ }
103
+ else if (d?.type === 'thinking_delta' && typeof d.thinking === 'string') {
104
+ m.thinkingChars += d.thinking.length;
105
+ }
106
+ else if (d?.type === 'input_json_delta' && typeof d.partial_json === 'string') {
107
+ m.toolUseChars += d.partial_json.length;
108
+ }
109
+ }
110
+ else if (event === 'message_delta') {
111
+ // Anthropic ships the final stop_reason here ("end_turn", "refusal", …).
112
+ const d = obj.delta;
113
+ if (typeof d?.stop_reason === 'string')
114
+ state.stopReason = d.stop_reason;
115
+ // Authoritative final output_tokens; merge over message_start (which ships output_tokens: 1).
116
+ const u = obj.usage;
117
+ if (u) {
118
+ if (!state.usage)
119
+ state.usage = {};
120
+ const cur = state.usage;
121
+ if (typeof u.output_tokens === 'number')
122
+ cur.output_tokens = u.output_tokens;
123
+ if (typeof u.input_tokens === 'number' && cur.input_tokens === undefined) {
124
+ cur.input_tokens = u.input_tokens;
125
+ }
126
+ if (typeof u.cache_creation_input_tokens === 'number') {
127
+ cur.cache_creation_input_tokens = u.cache_creation_input_tokens;
128
+ }
129
+ if (typeof u.cache_read_input_tokens === 'number') {
130
+ cur.cache_read_input_tokens = u.cache_read_input_tokens;
131
+ }
132
+ }
133
+ }
134
+ }
135
+ function normalizeUsage(raw) {
136
+ if (!raw || typeof raw !== 'object')
137
+ return undefined;
138
+ const u = raw;
139
+ const out = {};
140
+ if (typeof u.input_tokens === 'number')
141
+ out.input_tokens = u.input_tokens;
142
+ if (typeof u.output_tokens === 'number')
143
+ out.output_tokens = u.output_tokens;
144
+ if (typeof u.cache_creation_input_tokens === 'number') {
145
+ out.cache_creation_input_tokens = u.cache_creation_input_tokens;
146
+ }
147
+ if (typeof u.cache_read_input_tokens === 'number') {
148
+ out.cache_read_input_tokens = u.cache_read_input_tokens;
149
+ }
150
+ if (typeof u.cache_creation === 'object' && u.cache_creation !== null) {
151
+ out.cache_creation = u.cache_creation;
152
+ }
153
+ if (typeof u.server_tool_use === 'object' && u.server_tool_use !== null) {
154
+ out.server_tool_use = u.server_tool_use;
155
+ }
156
+ // OpenAI field aliases.
157
+ if (typeof u.prompt_tokens === 'number')
158
+ out.input_tokens = u.prompt_tokens;
159
+ if (typeof u.completion_tokens === 'number')
160
+ out.output_tokens = u.completion_tokens;
161
+ // OpenAI prompt-cache hits live in a details sub-object: Responses uses
162
+ // `input_tokens_details.cached_tokens`, Chat uses `prompt_tokens_details`.
163
+ const details = u.input_tokens_details ??
164
+ u.prompt_tokens_details;
165
+ if (details && typeof details.cached_tokens === 'number') {
166
+ out.cached_tokens = details.cached_tokens;
167
+ }
168
+ return Object.keys(out).length > 0 ? out : undefined;
169
+ }
170
+ function measureOpenAIChoices(obj, m) {
171
+ const choices = obj.choices;
172
+ if (!Array.isArray(choices))
173
+ return;
174
+ for (const choice of choices) {
175
+ if (!choice || typeof choice !== 'object')
176
+ continue;
177
+ const c = choice;
178
+ const payload = (c.delta ?? c.message);
179
+ if (!payload || typeof payload !== 'object')
180
+ continue;
181
+ if (typeof payload.content === 'string')
182
+ m.textChars += payload.content.length;
183
+ const toolCalls = payload.tool_calls;
184
+ if (Array.isArray(toolCalls)) {
185
+ for (const tc of toolCalls) {
186
+ const fn = tc?.function;
187
+ const args = fn?.arguments;
188
+ if (typeof args === 'string')
189
+ m.toolUseChars += args.length;
190
+ }
191
+ }
192
+ }
193
+ }
194
+ /** Measure non-streaming messages.content[] — same OutputMeasurement shape as the SSE accumulator. */
195
+ function measureFromMessageJson(j) {
196
+ const m = { textChars: 0, thinkingChars: 0, toolUseChars: 0, redactedBlockCount: 0 };
197
+ if (j && typeof j === 'object')
198
+ measureOpenAIChoices(j, m);
199
+ const content = j?.content;
200
+ if (!Array.isArray(content))
201
+ return m;
202
+ for (const block of content) {
203
+ const b = block;
204
+ if (b?.type === 'text' && typeof b.text === 'string') {
205
+ m.textChars += b.text.length;
206
+ }
207
+ else if (b?.type === 'thinking' && typeof b.thinking === 'string') {
208
+ m.thinkingChars += b.thinking.length;
209
+ }
210
+ else if (b?.type === 'redacted_thinking') {
211
+ m.redactedBlockCount += 1;
212
+ }
213
+ else if (b?.type === 'tool_use') {
214
+ try {
215
+ m.toolUseChars += JSON.stringify(b.input ?? {}).length;
216
+ }
217
+ catch {
218
+ /* circular / unserialisable input — leave the counter as-is */
219
+ }
220
+ }
221
+ }
222
+ return m;
223
+ }
224
+ /** Stop reason from a non-streaming response JSON: Anthropic `stop_reason`,
225
+ * OpenAI chat `choices[].finish_reason`, Responses `incomplete_details.reason`. */
226
+ function readStopReasonFromJson(j) {
227
+ if (!j || typeof j !== 'object')
228
+ return undefined;
229
+ const obj = j;
230
+ if (typeof obj.stop_reason === 'string')
231
+ return obj.stop_reason;
232
+ if (Array.isArray(obj.choices)) {
233
+ for (const c of obj.choices) {
234
+ const fr = c?.finish_reason;
235
+ if (typeof fr === 'string')
236
+ return fr;
237
+ }
238
+ }
239
+ if (obj.status === 'incomplete') {
240
+ const reason = obj.incomplete_details?.reason;
241
+ return typeof reason === 'string' ? reason : 'incomplete';
242
+ }
243
+ return undefined;
244
+ }
245
+ /**
246
+ * Tee the response body to extract usage + output measurement without blocking the client.
247
+ * Streams are scanned to EOF (final output_tokens is in message_delta; redacted_thinking
248
+ * blocks can appear anywhere). 4xx bodies are capped at ERROR_BODY_MAX. 5xx is skipped.
249
+ */
250
+ function teeForUsage(res) {
251
+ // No body at all: nothing to extract on either path.
252
+ if (!res.body) {
253
+ return {
254
+ response: res,
255
+ usagePromise: Promise.resolve(undefined),
256
+ errorBodyPromise: Promise.resolve(undefined),
257
+ measurementPromise: Promise.resolve(undefined),
258
+ stopReasonPromise: Promise.resolve(undefined),
259
+ };
260
+ }
261
+ // 4xx: tee for the error body but skip usage scanning entirely.
262
+ if (res.status >= 400 && res.status < 500) {
263
+ const [forClient, forUs] = res.body.tee();
264
+ const errorBodyPromise = (async () => {
265
+ const reader = forUs.getReader();
266
+ const decoder = new TextDecoder();
267
+ let out = '';
268
+ try {
269
+ while (out.length < ERROR_BODY_MAX) {
270
+ const { done, value } = await reader.read();
271
+ if (done)
272
+ break;
273
+ out += decoder.decode(value, { stream: true });
274
+ }
275
+ out += decoder.decode();
276
+ // Drain the rest so the tee buffer doesn't hold the stream open.
277
+ while (true) {
278
+ const { done } = await reader.read();
279
+ if (done)
280
+ break;
281
+ }
282
+ }
283
+ catch {
284
+ /* client may have aborted */
285
+ }
286
+ return out.length > ERROR_BODY_MAX ? out.slice(0, ERROR_BODY_MAX) : out;
287
+ })();
288
+ return {
289
+ response: new Response(forClient, {
290
+ status: res.status,
291
+ statusText: res.statusText,
292
+ headers: res.headers,
293
+ }),
294
+ usagePromise: Promise.resolve(undefined),
295
+ errorBodyPromise,
296
+ measurementPromise: Promise.resolve(undefined),
297
+ stopReasonPromise: Promise.resolve(undefined),
298
+ };
299
+ }
300
+ // 5xx: skip both (the host already synthesizes an error message).
301
+ if (res.status >= 500) {
302
+ return {
303
+ response: res,
304
+ usagePromise: Promise.resolve(undefined),
305
+ errorBodyPromise: Promise.resolve(undefined),
306
+ measurementPromise: Promise.resolve(undefined),
307
+ stopReasonPromise: Promise.resolve(undefined),
308
+ };
309
+ }
310
+ const ct = (res.headers.get('content-type') ?? '').toLowerCase();
311
+ const [forClient, forUs] = res.body.tee();
312
+ // Single read loop resolves all three; exposed as separate promises for call-site readability.
313
+ const scanResult = (async () => {
314
+ const reader = forUs.getReader();
315
+ const decoder = new TextDecoder();
316
+ let buf = '';
317
+ try {
318
+ if (ct.includes('text/event-stream')) {
319
+ // Walk every SSE event to EOF — message_delta (final output_tokens) is last.
320
+ const m = {
321
+ textChars: 0,
322
+ thinkingChars: 0,
323
+ toolUseChars: 0,
324
+ redactedBlockCount: 0,
325
+ };
326
+ const state = {
327
+ usage: undefined,
328
+ stopReason: undefined,
329
+ };
330
+ while (true) {
331
+ const { done, value } = await reader.read();
332
+ if (done)
333
+ break;
334
+ buf += decoder.decode(value, { stream: true });
335
+ // SSE events are terminated by a blank line.
336
+ let evEnd;
337
+ while ((evEnd = buf.indexOf('\n\n')) >= 0) {
338
+ const block = buf.slice(0, evEnd);
339
+ buf = buf.slice(evEnd + 2);
340
+ processSseEvent(block, m, state);
341
+ }
342
+ }
343
+ buf += decoder.decode();
344
+ if (buf.trim().length > 0)
345
+ processSseEvent(buf, m, state); // trailing partial event
346
+ return { usage: state.usage, measurement: m, stopReason: state.stopReason };
347
+ }
348
+ if (ct.includes('application/json')) {
349
+ // Buffer fully, capped at 4 MiB.
350
+ const MAX = 4 * 1024 * 1024;
351
+ while (buf.length < MAX) {
352
+ const { done, value } = await reader.read();
353
+ if (done)
354
+ break;
355
+ buf += decoder.decode(value, { stream: true });
356
+ }
357
+ try {
358
+ const j = JSON.parse(buf);
359
+ return {
360
+ usage: normalizeUsage(j?.usage),
361
+ measurement: measureFromMessageJson(j),
362
+ stopReason: readStopReasonFromJson(j),
363
+ };
364
+ }
365
+ catch {
366
+ return { usage: undefined, measurement: undefined, stopReason: undefined };
367
+ }
368
+ }
369
+ }
370
+ catch {
371
+ /* tee released early (client abort) */
372
+ }
373
+ // Unknown content-type: drain to release the tee buffer.
374
+ try {
375
+ while (true) {
376
+ const { done } = await reader.read();
377
+ if (done)
378
+ break;
379
+ }
380
+ }
381
+ catch {
382
+ /* ignore */
383
+ }
384
+ return { usage: undefined, measurement: undefined, stopReason: undefined };
385
+ })();
386
+ return {
387
+ response: new Response(forClient, {
388
+ status: res.status,
389
+ statusText: res.statusText,
390
+ headers: res.headers,
391
+ }),
392
+ usagePromise: scanResult.then((s) => s.usage),
393
+ errorBodyPromise: Promise.resolve(undefined),
394
+ measurementPromise: scanResult.then((s) => s.measurement),
395
+ stopReasonPromise: scanResult.then((s) => s.stopReason),
396
+ };
397
+ }
398
+ const DEFAULT_UPSTREAM = 'https://api.anthropic.com';
399
+ const DEFAULT_OPENAI_UPSTREAM = 'https://api.openai.com';
400
+ /** Headers we strip on the way out — they're hop-by-hop or proxy-injected. */
401
+ const STRIP_REQ_HEADERS = new Set([
402
+ 'host',
403
+ 'connection',
404
+ 'keep-alive',
405
+ 'proxy-connection',
406
+ 'transfer-encoding',
407
+ 'upgrade',
408
+ 'content-length', // we recompute
409
+ 'expect',
410
+ 'accept-encoding', // let upstream choose
411
+ ]);
412
+ const STRIP_RES_HEADERS = new Set([
413
+ 'connection',
414
+ 'keep-alive',
415
+ 'transfer-encoding',
416
+ 'content-encoding', // we don't re-encode
417
+ 'content-length', // body may differ after streaming
418
+ ]);
419
+ function filterHeaders(src, strip) {
420
+ const out = new Headers();
421
+ src.forEach((v, k) => {
422
+ if (!strip.has(k.toLowerCase()))
423
+ out.append(k, v);
424
+ });
425
+ return out;
426
+ }
427
+ const PASSTHROUGH_PREFIXES = [
428
+ '/anthropic/',
429
+ '/openai/',
430
+ '/google-ai-studio/',
431
+ '/compat/',
432
+ ];
433
+ function isProviderPrefixedPath(pathname) {
434
+ return PASSTHROUGH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
435
+ }
436
+ function isOpenAIChatPath(pathname) {
437
+ return pathname === '/v1/chat/completions' || pathname === '/openai/v1/chat/completions';
438
+ }
439
+ function isOpenAIResponsesPath(pathname) {
440
+ return pathname === '/v1/responses'
441
+ || pathname === '/openai/v1/responses'
442
+ || pathname === '/openai/responses';
443
+ }
444
+ function isCanonicalOpenAIPath(pathname, headers, hasOpenAIKey) {
445
+ const isModelsPath = pathname === '/v1/models' || pathname.startsWith('/v1/models/');
446
+ const looksOpenAIAuth = hasOpenAIKey || (headers.has('authorization') && !headers.has('x-api-key'));
447
+ return pathname === '/v1/chat/completions'
448
+ || pathname === '/v1/responses'
449
+ || pathname.startsWith('/v1/responses/')
450
+ || (isModelsPath && looksOpenAIAuth);
451
+ }
452
+ /** POST /v1/messages/count_tokens with the given body. Returns the upstream's
453
+ * `input_tokens` number or null on any failure. count_tokens is documented
454
+ * as a free endpoint (no input-token billing) — we use it once per request
455
+ * on the PRE-COMPRESSION body to get the ground-truth baseline. Actual
456
+ * post-compression tokens already come back free in the /v1/messages usage
457
+ * block (input_tokens + cache_create + cache_read), so no second probe. */
458
+ async function countTokensUpstream(countTokensUrl, body, headers) {
459
+ try {
460
+ const res = await fetch(countTokensUrl, {
461
+ method: 'POST',
462
+ headers,
463
+ body: body,
464
+ });
465
+ if (!res.ok)
466
+ return null;
467
+ const json = (await res.json());
468
+ return typeof json.input_tokens === 'number' ? json.input_tokens : null;
469
+ }
470
+ catch {
471
+ return null;
472
+ }
473
+ }
474
+ /** Resolve upstream URLs from config. Pure — unit-testable. */
475
+ export function resolveUpstreams(config) {
476
+ if (config.provider === 'cloudflare-ai-gateway') {
477
+ const base = (config.gatewayBaseUrl ?? '').replace(/\/+$/, '');
478
+ if (!base) {
479
+ throw new Error("provider 'cloudflare-ai-gateway' requires gatewayBaseUrl (PXPIPE_GATEWAY_BASE_URL)");
480
+ }
481
+ return { anthropic: `${base}/anthropic`, openai: `${base}/openai`, stripOpenAIV1: true };
482
+ }
483
+ return {
484
+ anthropic: (config.upstream ?? DEFAULT_UPSTREAM).replace(/\/+$/, ''),
485
+ openai: (config.openAIUpstream ?? DEFAULT_OPENAI_UPSTREAM).replace(/\/+$/, ''),
486
+ stripOpenAIV1: false,
487
+ };
488
+ }
489
+ /** Parse PXPIPE_GATEWAY_HEADERS — JSON object or `k=v;k2=v2`. */
490
+ export function parseGatewayHeaders(spec) {
491
+ if (!spec)
492
+ return {};
493
+ const trimmed = spec.trim();
494
+ if (trimmed.startsWith('{')) {
495
+ const obj = JSON.parse(trimmed);
496
+ const out = {};
497
+ for (const [k, v] of Object.entries(obj))
498
+ out[k] = String(v);
499
+ return out;
500
+ }
501
+ const out = {};
502
+ for (const pair of trimmed.split(';')) {
503
+ const i = pair.indexOf('=');
504
+ if (i <= 0)
505
+ continue;
506
+ out[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
507
+ }
508
+ return out;
509
+ }
510
+ /** Build the proxy fetch handler. */
511
+ export function createProxy(config = {}) {
512
+ const routes = resolveUpstreams(config);
513
+ const upstream = routes.anthropic;
514
+ const openAIUpstream = routes.openai;
515
+ const passthroughUpstream = config.provider === 'cloudflare-ai-gateway'
516
+ ? (config.gatewayBaseUrl ?? '').replace(/\/+$/, '')
517
+ : upstream;
518
+ const gatewayHeaders = config.gatewayHeaders ?? {};
519
+ const applyGatewayHeaders = (h) => {
520
+ for (const [k, v] of Object.entries(gatewayHeaders))
521
+ h.set(k, v);
522
+ return h;
523
+ };
524
+ return async function handle(req) {
525
+ const t0 = Date.now();
526
+ const url = new URL(req.url);
527
+ const path = url.pathname + url.search;
528
+ // reqBodyBytes: kept for lazy gzip on 4xx. reqBodySha8: computed eagerly for correlation.
529
+ let reqBodyBytes;
530
+ let reqBodySha8;
531
+ const fire = (status, info, error, firstByteMs, usage, errorBody, measurement, stopReason) => {
532
+ const is4xx = status >= 400 && status < 500;
533
+ // Gzip body lazily (only on 4xx). Async IIFE keeps fire() synchronous.
534
+ const finalize = async () => {
535
+ let reqBodyGz;
536
+ if (is4xx && reqBodyBytes && reqBodyBytes.byteLength > 0) {
537
+ try {
538
+ reqBodyGz = await gzipBytes(reqBodyBytes);
539
+ }
540
+ catch {
541
+ // Non-fatal — drop body sample.
542
+ }
543
+ }
544
+ // Await both count_tokens probes so baseline numbers land on the same event row.
545
+ // Each probe is independent; null leaves the field absent and dashboard math degrades cleanly.
546
+ if (info && baselineStatusApplies) {
547
+ // Track both halves so the dashboard can gate on probe completeness (partial vs ok).
548
+ // A missing cacheable-prefix probe must NOT be treated as cacheable=0 — that fabricates savings.
549
+ let baselineResolved = null;
550
+ let cacheableExpected = false;
551
+ let cacheableResolved = null;
552
+ if (baselinePromise) {
553
+ try {
554
+ baselineResolved = await baselinePromise;
555
+ if (baselineResolved !== null)
556
+ info.baselineTokens = baselineResolved;
557
+ }
558
+ catch {
559
+ /* probe threw — drop */
560
+ }
561
+ }
562
+ if (baselineCacheablePromise) {
563
+ cacheableExpected = true;
564
+ try {
565
+ cacheableResolved = await baselineCacheablePromise;
566
+ if (cacheableResolved !== null)
567
+ info.baselineCacheableTokens = cacheableResolved;
568
+ }
569
+ catch {
570
+ /* probe threw */
571
+ }
572
+ }
573
+ if (baselineResolved === null) {
574
+ info.baselineProbeStatus = 'failed';
575
+ }
576
+ else if (cacheableExpected && cacheableResolved === null) {
577
+ info.baselineProbeStatus = 'partial'; // dashboard excludes row; must not treat as cacheable=0
578
+ }
579
+ else {
580
+ info.baselineProbeStatus = 'ok';
581
+ }
582
+ }
583
+ await config.onRequest?.({
584
+ method: req.method,
585
+ path: url.pathname,
586
+ model: requestModel,
587
+ status,
588
+ durationMs: Date.now() - t0,
589
+ firstByteMs,
590
+ info,
591
+ usage,
592
+ error,
593
+ errorBody,
594
+ reqBodySha8,
595
+ reqBodyGz,
596
+ measurement,
597
+ stopReason,
598
+ });
599
+ };
600
+ void finalize();
601
+ };
602
+ // Transform only known shapes; everything else passes through.
603
+ const providerPrefixed = isProviderPrefixedPath(url.pathname);
604
+ const isMessages = req.method === 'POST' && isAnthropicMessagesPath(url.pathname);
605
+ const isOpenAIChat = req.method === 'POST' && isOpenAIChatPath(url.pathname);
606
+ const isOpenAIResponses = req.method === 'POST' && isOpenAIResponsesPath(url.pathname);
607
+ const isOpenAIPath = isCanonicalOpenAIPath(url.pathname, req.headers, config.openAIApiKey !== undefined);
608
+ const upstreamBase = providerPrefixed ? passthroughUpstream : isOpenAIPath ? openAIUpstream : upstream;
609
+ let bodyOut = null;
610
+ let info;
611
+ let requestModel;
612
+ // Two count_tokens probes on the pre-compression body (see docs/HISTORY_CACHE_MODEL.md):
613
+ // baselinePromise → full-body input_tokens
614
+ // baselineCacheablePromise → input_tokens truncated at last cache_control marker
615
+ // Dashboard combines them for cache-aware baseline. Both run in parallel with the main forward.
616
+ let baselinePromise;
617
+ let baselineCacheablePromise;
618
+ let baselineStatusApplies = false;
619
+ if (isMessages || isOpenAIChat || isOpenAIResponses) {
620
+ const bodyIn = new Uint8Array(await req.arrayBuffer());
621
+ try {
622
+ const transformOpts = typeof config.transform === 'function' ? config.transform() : config.transform;
623
+ // Fail-closed: unreadable model → no compression, not a risky guess.
624
+ const model = readModelField(bodyIn);
625
+ requestModel = model ?? undefined;
626
+ const modelOk = isMessages
627
+ ? isPxpipeSupportedModel(model)
628
+ : isPxpipeSupportedGptModel(model);
629
+ // Unsupported model → a true passthrough: no break-even compression
630
+ // (a text-only model may not accept injected image blocks at all).
631
+ const effectiveOpts = modelOk
632
+ ? transformOpts
633
+ : { ...transformOpts, compress: false };
634
+ const r = isMessages
635
+ ? await transformRequest(bodyIn, effectiveOpts)
636
+ : isOpenAIChat
637
+ ? await transformOpenAIChatCompletions(bodyIn, effectiveOpts)
638
+ : await transformOpenAIResponses(bodyIn, effectiveOpts);
639
+ if (!modelOk)
640
+ r.info.reason = 'unsupported_model';
641
+ bodyOut = r.body; // TS narrows Uint8Array away from BodyInit
642
+ info = r.info;
643
+ reqBodyBytes = r.body;
644
+ if (r.body.byteLength > 0) {
645
+ reqBodySha8 = await sha8Bytes(r.body);
646
+ }
647
+ if (isMessages) {
648
+ baselineStatusApplies = true;
649
+ // Probes fire on the ORIGINAL body before the main forward so all three overlap.
650
+ // count_tokens is not billed; ~30-80ms latency is hidden by the main forward.
651
+ const ctBody = buildBaselineCountTokensBody(bodyIn);
652
+ if (ctBody) {
653
+ const ctHeaders = applyGatewayHeaders(filterHeaders(req.headers, STRIP_REQ_HEADERS));
654
+ ctHeaders.set('content-type', 'application/json');
655
+ if (config.apiKey)
656
+ ctHeaders.set('x-api-key', config.apiKey);
657
+ // Mirror the actual outbound request base+path: count_tokens lives at
658
+ // `<messages-path>/count_tokens`, so provider-prefixed routes like
659
+ // `/anthropic/messages` probe `/anthropic/messages/count_tokens`.
660
+ const ctBase = providerPrefixed ? passthroughUpstream : upstream;
661
+ const ctUrl = ctBase + url.pathname + '/count_tokens';
662
+ baselinePromise = countTokensUpstream(ctUrl, ctBody, ctHeaders);
663
+ // Null = no markers → cacheable=0 by definition, no probe needed.
664
+ const ctCacheableBody = buildCacheablePrefixCountTokensBody(bodyIn);
665
+ if (ctCacheableBody) {
666
+ baselineCacheablePromise = countTokensUpstream(ctUrl, ctCacheableBody, new Headers(ctHeaders));
667
+ }
668
+ }
669
+ }
670
+ }
671
+ catch (e) {
672
+ fire(502, undefined, `transform_error: ${e.message}`);
673
+ return new Response(JSON.stringify({ error: 'pxpipe transform failed' }), {
674
+ status: 502,
675
+ headers: { 'content-type': 'application/json' },
676
+ });
677
+ }
678
+ }
679
+ else {
680
+ bodyOut = req.body; // pass through unchanged
681
+ }
682
+ const outHeaders = filterHeaders(req.headers, STRIP_REQ_HEADERS);
683
+ if (isOpenAIPath) {
684
+ if (config.openAIApiKey)
685
+ outHeaders.set('authorization', `Bearer ${config.openAIApiKey}`);
686
+ }
687
+ else if (config.apiKey && (!providerPrefixed || url.pathname.startsWith('/anthropic/'))) {
688
+ outHeaders.set('x-api-key', config.apiKey);
689
+ }
690
+ applyGatewayHeaders(outHeaders);
691
+ // Gateway OpenAI routes drop the `/v1` prefix; provider-prefixed passthrough
692
+ // routes keep their full path so ocproxy-style upstreams see `/openai/*`,
693
+ // `/google-ai-studio/*`, etc. exactly as the client sent them.
694
+ const outPath = isOpenAIPath && routes.stripOpenAIV1 ? path.replace(/^\/v1(?=\/)/, '') : path;
695
+ const upstreamUrl = upstreamBase + outPath;
696
+ let upstreamRes;
697
+ try {
698
+ upstreamRes = await fetch(upstreamUrl, {
699
+ method: req.method,
700
+ headers: outHeaders,
701
+ body: bodyOut,
702
+ // duplex is required by spec when sending a stream as body
703
+ ...(bodyOut instanceof ReadableStream ? { duplex: 'half' } : {}),
704
+ });
705
+ }
706
+ catch (e) {
707
+ fire(502, info, `upstream_error: ${e.message}`);
708
+ return new Response(JSON.stringify({ error: 'pxpipe upstream unreachable' }), {
709
+ status: 502,
710
+ headers: { 'content-type': 'application/json' },
711
+ });
712
+ }
713
+ const firstByteMs = Date.now() - t0;
714
+ // Tee: client gets one side; scanner reads the other for usage/measurement/error body.
715
+ const { response: teed, usagePromise, errorBodyPromise, measurementPromise, stopReasonPromise } = teeForUsage(upstreamRes);
716
+ // Fire event in background once all four resolve (all share the same stream read).
717
+ void Promise.all([
718
+ usagePromise.catch(() => undefined),
719
+ errorBodyPromise.catch(() => undefined),
720
+ measurementPromise.catch(() => undefined),
721
+ stopReasonPromise.catch(() => undefined),
722
+ ]).then(([usage, errorBody, measurement, stopReason]) => fire(upstreamRes.status, info, undefined, firstByteMs, usage, errorBody, measurement, stopReason));
723
+ return new Response(teed.body, {
724
+ status: upstreamRes.status,
725
+ statusText: upstreamRes.statusText,
726
+ headers: filterHeaders(upstreamRes.headers, STRIP_RES_HEADERS),
727
+ });
728
+ };
729
+ }
730
+ //# sourceMappingURL=proxy.js.map