@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,494 @@
1
+ /**
2
+ * GPT history-image compression.
3
+ *
4
+ * The static system+tool slab is small (~30k chars); the bulk of a GPT agent
5
+ * request is the conversation transcript, which OpenCode resends in full every
6
+ * turn — the Responses API is driven statelessly here (no `previous_response_id`),
7
+ * so turns 1..N-1 are re-sent as plain text on turn N. pxpipe collapses the OLD
8
+ * closed-tool-call prefix of that transcript into 1-N PNG images and keeps the
9
+ * recent tail as text.
10
+ *
11
+ * OpenAI prompt-caching is automatic and prefix-based: no `cache_control`
12
+ * breakpoints, no 1.25× write premium, cached reads at ~0.1×. The collapse
13
+ * boundary is snapped to a chunk grid so the history image stays byte-identical
14
+ * across turns and keeps hitting that automatic cache (the same flap-avoidance
15
+ * trick src/core/history.ts uses for Anthropic).
16
+ *
17
+ * This mirrors src/core/history.ts but operates on Responses `input` items and
18
+ * Chat `messages` rather than Anthropic Message blocks. The two formats differ
19
+ * enough (function_call/function_call_output vs tool_calls/tool role) that a
20
+ * shared block type isn't worth it; instead each format is lowered to a common
21
+ * HistoryTurn list and the planner/renderer are shared.
22
+ */
23
+ import { renderTextToPngs, reflow, neutralizeSentinel } from './render.js';
24
+ import { GPT_MAX_HEIGHT_PX } from './gpt-model-profiles.js';
25
+ import { countTokens as o200kCountTokens } from 'gpt-tokenizer/encoding/o200k_base';
26
+ /** Portrait-strip width for GPT history images. Mirrors GPT_STRIP_COLS in
27
+ * openai.ts (kept local to avoid a circular import): ≤768px wide so OpenAI
28
+ * doesn't downscale dense text below its OCR-legibility floor. The 384-col
29
+ * Anthropic dense profile would be scaled to fit OpenAI's 768px box and become
30
+ * illegible — that profile is Anthropic-only. */
31
+ const GPT_HISTORY_COLS = 152;
32
+ // GPT vision latency grows with physical image count/bytes, not just billed tokens.
33
+ // Long OpenCode sessions can otherwise turn old history into 80+ images: token-cheap
34
+ // but slow enough that gpt-5.5 times out before first token. When this cap trips,
35
+ // callers leave the old history as text rather than dropping or de-prioritizing it.
36
+ const GPT_HISTORY_MAX_IMAGES = 16;
37
+ export const GPT_HISTORY_DEFAULTS = {
38
+ keepTail: 6,
39
+ minCollapsePrefix: 10,
40
+ minCollapseTokens: 2000,
41
+ cols: GPT_HISTORY_COLS,
42
+ collapseChunk: 10,
43
+ freezeChunk: 10,
44
+ sectionTokens: 2000,
45
+ // GPT path: OpenAI's resize bounds (2048-bbox / 768 short side) permit the tall
46
+ // strip — do NOT re-link to render.ts MAX_HEIGHT_PX (Anthropic's 1568/1.15 MP clamp).
47
+ maxHeightPx: GPT_MAX_HEIGHT_PX,
48
+ maxImages: GPT_HISTORY_MAX_IMAGES,
49
+ reflow: true,
50
+ };
51
+ function safeJson(v) {
52
+ if (typeof v === 'string')
53
+ return v;
54
+ try {
55
+ return JSON.stringify(v) ?? '';
56
+ }
57
+ catch {
58
+ return String(v ?? '');
59
+ }
60
+ }
61
+ /** Last index i in [from, cutoffExclusive) where every opened tool-call id has a
62
+ * matching close. Returns from-1 (no collapse) if none. Stops at the first
63
+ * opaque barrier so unknown items are never swept into the image. */
64
+ function findClosedBoundary(turns, cutoffExclusive, from) {
65
+ const open = new Set();
66
+ let lastClosed = from - 1;
67
+ const limit = Math.min(cutoffExclusive, turns.length);
68
+ for (let i = from; i < limit; i++) {
69
+ const t = turns[i];
70
+ if (t.opaque)
71
+ break;
72
+ for (const id of t.openIds)
73
+ open.add(id);
74
+ for (const id of t.closeIds)
75
+ open.delete(id);
76
+ if (open.size === 0)
77
+ lastClosed = i;
78
+ }
79
+ return lastClosed;
80
+ }
81
+ /** True if [from, toExclusive) opens no tool call it doesn't also close (and hits
82
+ * no opaque barrier). Used to confirm the pinned user turn sits at a tool-closed
83
+ * boundary so force-sealing the section before it can't orphan a function call. */
84
+ function isClosedPrefix(turns, from, toExclusive) {
85
+ const open = new Set();
86
+ for (let i = from; i < toExclusive; i++) {
87
+ const t = turns[i];
88
+ if (t.opaque)
89
+ return false;
90
+ for (const id of t.openIds)
91
+ open.add(id);
92
+ for (const id of t.closeIds)
93
+ open.delete(id);
94
+ }
95
+ return open.size === 0;
96
+ }
97
+ /** Join turn texts over [from, toExclusive), skipping empties and `skip` (the
98
+ * pinned turn, which is emitted as text rather than imaged). */
99
+ function joinTurns(turns, from, toExclusive, skip) {
100
+ const parts = [];
101
+ for (let i = from; i < toExclusive; i++) {
102
+ if (i === skip)
103
+ continue;
104
+ const s = turns[i].text;
105
+ if (s && s.length > 0)
106
+ parts.push(s);
107
+ }
108
+ return parts.join('\n\n');
109
+ }
110
+ /**
111
+ * Plan + render a history collapse over pre-lowered turns. Pure w.r.t. the input
112
+ * (caller does the splice and builds the format-specific synthetic item).
113
+ */
114
+ export async function planGptCollapse(turns, protectedPrefix, isProfitable, opts = {}) {
115
+ const o = { ...GPT_HISTORY_DEFAULTS, ...opts };
116
+ const base = {
117
+ images: [],
118
+ imagesAfter: [],
119
+ text: '',
120
+ start: 0,
121
+ endExclusive: 0,
122
+ collapsedTurns: 0,
123
+ collapsedChars: 0,
124
+ droppedChars: 0,
125
+ droppedCodepoints: new Map(),
126
+ };
127
+ const pp = Math.max(0, Math.min(protectedPrefix, turns.length));
128
+ const rawCutoff = turns.length - o.keepTail;
129
+ if (rawCutoff - pp < o.minCollapsePrefix) {
130
+ return { ...base, reason: 'prefix_too_short' };
131
+ }
132
+ // Snap the cutoff down to a collapseChunk grid (relative to pp) so the image
133
+ // stays byte-stable across turns. Floor at pp + minCollapsePrefix.
134
+ const cutoff = o.collapseChunk > 0
135
+ ? Math.min(rawCutoff, Math.max(pp + o.minCollapsePrefix, pp + Math.floor((rawCutoff - pp) / o.collapseChunk) * o.collapseChunk))
136
+ : rawCutoff;
137
+ const boundary = findClosedBoundary(turns, cutoff, pp);
138
+ if (boundary < pp) {
139
+ return { ...base, reason: 'no_closed_prefix' };
140
+ }
141
+ if (boundary + 1 - pp < o.minCollapsePrefix) {
142
+ return { ...base, reason: 'prefix_too_short' };
143
+ }
144
+ const rawEnd = boundary + 1;
145
+ // Pin the LIVE request — the most-recent user turn OVERALL — as legible TEXT so it
146
+ // is never OCR-only. Older user turns stay imaged (they must NOT look like the live
147
+ // request; that's the snap-to-first-prompt guard). The history BEFORE and AFTER the
148
+ // pin both stay imaged, so compression holds.
149
+ //
150
+ // CRITICAL: pin ONLY when the latest user turn falls INSIDE the collapse range. If
151
+ // it sits in the kept tail (ordinary interactive turn) it is already native text —
152
+ // pinning an OLDER in-range user turn would make the pin migrate across collapse-
153
+ // chunk boundaries and re-image frozen history (cache churn). Restricting the pin to
154
+ // the latest user turn means its position is fixed until the next prompt, so the
155
+ // before/after section grid stays byte-stable across a long run. This covers exactly
156
+ // the two shapes that need it: the autonomous single-prompt agent (pin == pp), and a
157
+ // long current turn whose tool loop overflowed the tail (pin in the middle).
158
+ let pinIdx = -1;
159
+ for (let i = turns.length - 1; i >= pp; i--) {
160
+ if (turns[i].userText !== undefined) {
161
+ pinIdx = i;
162
+ break;
163
+ }
164
+ }
165
+ if (pinIdx >= rawEnd)
166
+ pinIdx = -1; // latest user turn is in the live tail → already text
167
+ // Only pin at a tool-closed boundary: a user turn straddled by an open tool call
168
+ // (malformed input) would orphan the call when we seal the section before it.
169
+ if (pinIdx >= 0 && !isClosedPrefix(turns, pp, pinIdx))
170
+ pinIdx = -1;
171
+ // Imaged baseline EXCLUDES the pinned turn (it is emitted as text, not rendered).
172
+ const text = joinTurns(turns, pp, rawEnd, pinIdx);
173
+ // Floor gate in o200k TOKENS, not chars: imaging bills vision tokens and the
174
+ // text baseline is o200k tokens, so the break-even is a token comparison.
175
+ // NOTE: this counts the IMAGEABLE work only (pin excluded), so a small history
176
+ // whose non-pin content is below the floor is left fully as text. That is correct,
177
+ // not a regression: the pinned request stays legible either way, and imaging a
178
+ // sub-floor amount of work would cost more vision tokens than it saves. Only long
179
+ // sessions (where the bug lived) clear the floor and collapse.
180
+ if (!text || gptCountTokens(text) < o.minCollapseTokens) {
181
+ return { ...base, reason: 'below_min_tokens', collapsedChars: text?.length ?? 0 };
182
+ }
183
+ // Reflow for RENDERING ONLY: pack soft-wrapped lines and mark hard newlines
184
+ // with the ↵ sentinel so the history image is as dense as the static slab
185
+ // (newline-heavy transcripts otherwise burn a full row per short line and
186
+ // show no ↵). `text` itself stays original — it backs the o200k baseline and
187
+ // the chunk-snapped cache byte-stability, so it must not change shape here.
188
+ const safeText = neutralizeSentinel(text);
189
+ const renderText = o.reflow ? reflow(safeText) ?? safeText : text;
190
+ if (!isProfitable(renderText, o.cols)) {
191
+ return { ...base, reason: 'not_profitable', collapsedChars: text.length };
192
+ }
193
+ // APPEND-ONLY, TOKEN-LENGTH sectioning. Cut the closed prefix [pp..rawEnd) into
194
+ // sections of ~sectionTokens o200k tokens by walking turns from pp and sealing a
195
+ // section each time its cumulative token count crosses the target. A sealed
196
+ // section's bytes are a pure function of its turn range — independent of where
197
+ // the conversation currently ends — so old sections stay byte-identical (OpenAI
198
+ // prefix-cache hit) as turns are appended; only freshly-sealed sections are new.
199
+ // Leftover tail turns that don't fill a whole section are NOT collapsed: collapse
200
+ // ends at the last SEALED boundary so every emitted image is a frozen section.
201
+ // (freezeChunk 0 = legacy whole-blob: one section spanning the whole range.)
202
+ // The pinned turn force-seals the section before it and starts a fresh section
203
+ // after it, so no image straddles the live request (history stays imaged on both
204
+ // sides). (freezeChunk 0 = legacy whole-blob, still split around the pin.)
205
+ const sections = [];
206
+ if (o.freezeChunk <= 0) {
207
+ if (pinIdx > pp)
208
+ sections.push([pp, pinIdx]);
209
+ const afterStart = pinIdx >= pp ? pinIdx + 1 : pp;
210
+ if (afterStart < rawEnd)
211
+ sections.push([afterStart, rawEnd]);
212
+ }
213
+ else {
214
+ let secStart = pp;
215
+ let acc = 0;
216
+ // Track open tool-call ids so a section is only sealed at a TOOL-CLOSED point.
217
+ // The token threshold can otherwise land between a function_call and its
218
+ // function_call_output: the call gets imaged while the output stays a live
219
+ // item, and OpenAI rejects the orphan with "No tool call found for function
220
+ // call output" (400). The overall [pp, rawEnd) boundary being closed does NOT
221
+ // protect the intermediate section cut — collapseEnd is the live boundary, so
222
+ // it (and every seal) must itself be tool-closed. Anthropic doesn't hit this
223
+ // because it collapses the whole closed prefix with no live leftover.
224
+ const open = new Set();
225
+ for (let i = pp; i < rawEnd; i++) {
226
+ if (i === pinIdx) {
227
+ // Force-seal the before-pin section (open is empty here by isClosedPrefix)
228
+ // and skip the pin so it is never imaged. If the remainder since the last
229
+ // seal is too small to be worth its own image, MERGE it into the previous
230
+ // before-section (a slightly oversized image) rather than emitting a sub-
231
+ // threshold one — imaging ~200 tokens costs more in vision tokens than it
232
+ // saves. (open is empty here, so extending the prior section can't orphan.)
233
+ if (secStart < i) {
234
+ const prev = sections[sections.length - 1];
235
+ if (acc < o.sectionTokens && prev && prev[1] === secStart) {
236
+ prev[1] = i; // extend previous before-section through the remainder
237
+ }
238
+ else {
239
+ sections.push([secStart, i]);
240
+ }
241
+ }
242
+ secStart = i + 1;
243
+ acc = 0;
244
+ continue;
245
+ }
246
+ acc += gptCountTokens(turns[i].text);
247
+ for (const id of turns[i].openIds)
248
+ open.add(id);
249
+ for (const id of turns[i].closeIds)
250
+ open.delete(id);
251
+ if (acc >= o.sectionTokens && open.size === 0) {
252
+ sections.push([secStart, i + 1]);
253
+ secStart = i + 1;
254
+ acc = 0;
255
+ }
256
+ }
257
+ // Trailing turns [secStart, rawEnd) didn't fill a section → leave as live text.
258
+ }
259
+ if (sections.length === 0) {
260
+ // Closed prefix cleared the floor but no single section sealed (only when
261
+ // sectionTokens > the whole prefix). Treat as below-min rather than emit a
262
+ // cache-unstable partial blob.
263
+ return { ...base, reason: 'below_min_tokens', collapsedChars: text.length };
264
+ }
265
+ const maxImages = Math.max(0, Math.floor(o.maxImages));
266
+ const rendered = [];
267
+ let imgCount = 0;
268
+ let collapseEnd = pp;
269
+ for (const [s, e] of sections) {
270
+ const sectionText = joinTurns(turns, s, e, -1);
271
+ if (!sectionText || sectionText.length === 0)
272
+ continue;
273
+ const safeSection = neutralizeSentinel(sectionText);
274
+ const sectionRender = o.reflow ? reflow(safeSection) ?? safeSection : sectionText;
275
+ // Readable portrait strips (≤768px wide) — legible to OpenAI vision, same as
276
+ // the static slab. renderTextToPngs caps each PNG at MAX_HEIGHT_PX so a tall
277
+ // section pages into N images, all still well under the 10,000-patch budget.
278
+ const sectionImgs = await renderTextToPngs(sectionRender, o.cols, {}, o.maxHeightPx);
279
+ if (imgCount + sectionImgs.length > maxImages) {
280
+ // TRUE cap: keep the sections already selected, leave this and every later
281
+ // section (and the pin, if not yet reached) as normal text in the remainder.
282
+ break;
283
+ }
284
+ rendered.push({ s, e, imgs: sectionImgs });
285
+ imgCount += sectionImgs.length;
286
+ collapseEnd = e;
287
+ }
288
+ // The pin is "consumed" (emitted as text inside the synthetic) only once we have
289
+ // collapsed PAST it. If the image cap stopped us before the pin, it survives as a
290
+ // native user message in the untouched remainder — still legible, no work lost.
291
+ const pinConsumed = pinIdx >= pp && collapseEnd > pinIdx;
292
+ const imagesBefore = [];
293
+ const imagesAfter = [];
294
+ for (const r of rendered) {
295
+ if (pinConsumed && r.s >= pinIdx + 1)
296
+ imagesAfter.push(...r.imgs);
297
+ else
298
+ imagesBefore.push(...r.imgs);
299
+ }
300
+ if (imagesBefore.length === 0 && imagesAfter.length === 0) {
301
+ // First section alone exceeded the cap (or cap <= 0). Fall back to text.
302
+ return { ...base, reason: 'too_many_images', collapsedChars: text.length };
303
+ }
304
+ const pinText = pinConsumed ? turns[pinIdx].userText : undefined;
305
+ // The collapsed transcript / o200k baseline reflects ONLY what we imaged — the
306
+ // pin, when consumed, is text and is excluded from the imaged baseline.
307
+ const collapsedText = joinTurns(turns, pp, collapseEnd, pinConsumed ? pinIdx : -1);
308
+ const droppedCodepoints = new Map();
309
+ let droppedChars = 0;
310
+ for (const img of [...imagesBefore, ...imagesAfter]) {
311
+ droppedChars += img.droppedChars;
312
+ for (const [cp, n] of img.droppedCodepoints) {
313
+ droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
314
+ }
315
+ }
316
+ return {
317
+ images: imagesBefore,
318
+ imagesAfter,
319
+ pinText,
320
+ text: collapsedText,
321
+ start: pp,
322
+ endExclusive: collapseEnd,
323
+ collapsedTurns: collapseEnd - pp - (pinConsumed ? 1 : 0),
324
+ collapsedChars: collapsedText.length,
325
+ droppedChars,
326
+ droppedCodepoints,
327
+ };
328
+ }
329
+ /** o200k_base token count — gpt-5 / gpt-4o / o-series share this encoding.
330
+ * Used for the history collapse floor (token-, not char-based). */
331
+ function gptCountTokens(text) {
332
+ if (!text)
333
+ return 0;
334
+ try {
335
+ return o200kCountTokens(text);
336
+ }
337
+ catch {
338
+ return 0;
339
+ }
340
+ }
341
+ // ---- Responses API lowering -------------------------------------------------
342
+ function responsesContentToText(content) {
343
+ if (typeof content === 'string')
344
+ return content;
345
+ if (!Array.isArray(content))
346
+ return '';
347
+ const parts = [];
348
+ for (const p of content) {
349
+ if (!p || typeof p !== 'object')
350
+ continue;
351
+ const t = p.type;
352
+ if (t === 'input_text' || t === 'output_text' || t === 'text' || t === 'summary_text') {
353
+ const txt = p.text;
354
+ if (typeof txt === 'string')
355
+ parts.push(txt);
356
+ }
357
+ else if (t === 'input_image' || t === 'image' || t === 'output_image') {
358
+ parts.push('[image]');
359
+ }
360
+ else if (t === 'refusal') {
361
+ const r = p.refusal;
362
+ if (typeof r === 'string')
363
+ parts.push(r);
364
+ }
365
+ }
366
+ return parts.join('\n');
367
+ }
368
+ function responsesItemToTurn(item, idx) {
369
+ const o = (item ?? {});
370
+ const type = typeof o.type === 'string' ? o.type : undefined;
371
+ if (type === 'reasoning') {
372
+ return { text: '', openIds: [], closeIds: [], opaque: false };
373
+ }
374
+ if (type === 'function_call') {
375
+ const callId = typeof o.call_id === 'string' ? o.call_id : typeof o.id === 'string' ? o.id : '';
376
+ const name = typeof o.name === 'string' ? o.name : 'tool';
377
+ const args = typeof o.arguments === 'string' ? o.arguments : safeJson(o.arguments);
378
+ return {
379
+ text: `[tool_use ${name}]\n${args}`,
380
+ openIds: callId ? [callId] : [],
381
+ closeIds: [],
382
+ opaque: false,
383
+ };
384
+ }
385
+ if (type === 'function_call_output') {
386
+ const callId = typeof o.call_id === 'string' ? o.call_id : '';
387
+ const out = typeof o.output === 'string' ? o.output : safeJson(o.output);
388
+ return {
389
+ text: `[tool_result]\n${out}`,
390
+ openIds: [],
391
+ closeIds: callId ? [callId] : [],
392
+ opaque: false,
393
+ };
394
+ }
395
+ const role = typeof o.role === 'string' ? o.role : undefined;
396
+ if (role) {
397
+ const body = responsesContentToText(o.content);
398
+ if (!body.trim())
399
+ return { text: '', openIds: [], closeIds: [], opaque: false };
400
+ const tag = role === 'assistant' ? 'assistant' : role === 'user' ? 'user' : role;
401
+ // Absolute turn index (item position) — recency anchor so the model can tell turn 1
402
+ // from turn 60 instead of resurfacing the salient opening turn. Stable per item →
403
+ // cache-safe (mirrors src/core/history.ts). Tool turns stay unindexed (not mistakable
404
+ // for a live request); the index rides the conversational role tags.
405
+ return {
406
+ text: `<${tag} t="${idx}">\n${body}\n</${tag}>`,
407
+ openIds: [],
408
+ closeIds: [],
409
+ opaque: false,
410
+ userText: role === 'user' ? body : undefined,
411
+ };
412
+ }
413
+ // Unknown item kind (e.g. item_reference) we can't safely serialize → barrier.
414
+ return { text: '', openIds: [], closeIds: [], opaque: true };
415
+ }
416
+ export function responsesItemsToTurns(items) {
417
+ return items.map((item, i) => responsesItemToTurn(item, i));
418
+ }
419
+ // ---- Chat Completions lowering ----------------------------------------------
420
+ function chatContentToText(content) {
421
+ if (typeof content === 'string')
422
+ return content;
423
+ if (!Array.isArray(content))
424
+ return '';
425
+ const parts = [];
426
+ for (const p of content) {
427
+ if (!p || typeof p !== 'object')
428
+ continue;
429
+ const t = p.type;
430
+ if (t === 'text') {
431
+ const txt = p.text;
432
+ if (typeof txt === 'string')
433
+ parts.push(txt);
434
+ }
435
+ else if (t === 'image_url' || t === 'input_image' || t === 'image') {
436
+ parts.push('[image]');
437
+ }
438
+ }
439
+ return parts.join('\n');
440
+ }
441
+ function chatMessageToTurn(msg, idx) {
442
+ const o = (msg ?? {});
443
+ const role = typeof o.role === 'string' ? o.role : '';
444
+ const body = chatContentToText(o.content);
445
+ if (role === 'tool') {
446
+ const id = typeof o.tool_call_id === 'string' ? o.tool_call_id : '';
447
+ return {
448
+ text: `[tool_result]\n${body}`,
449
+ openIds: [],
450
+ closeIds: id ? [id] : [],
451
+ opaque: false,
452
+ };
453
+ }
454
+ if (role === 'assistant') {
455
+ const openIds = [];
456
+ const parts = [];
457
+ if (body.trim())
458
+ parts.push(body);
459
+ const tc = o.tool_calls;
460
+ if (Array.isArray(tc)) {
461
+ for (const call of tc) {
462
+ const c = (call ?? {});
463
+ const id = typeof c.id === 'string' ? c.id : '';
464
+ if (id)
465
+ openIds.push(id);
466
+ const fn = c.function;
467
+ const name = fn && typeof fn.name === 'string' ? fn.name : 'tool';
468
+ const args = fn && typeof fn.arguments === 'string' ? fn.arguments : safeJson(fn?.arguments);
469
+ parts.push(`[tool_use ${name}]\n${args}`);
470
+ }
471
+ }
472
+ const text = parts.join('\n');
473
+ return {
474
+ text: text.trim() ? `<assistant t="${idx}">\n${text}\n</assistant>` : '',
475
+ openIds,
476
+ closeIds: [],
477
+ opaque: false,
478
+ };
479
+ }
480
+ if (!body.trim())
481
+ return { text: '', openIds: [], closeIds: [], opaque: false };
482
+ const tag = role === 'user' ? 'user' : role || 'user';
483
+ return {
484
+ text: `<${tag} t="${idx}">\n${body}\n</${tag}>`,
485
+ openIds: [],
486
+ closeIds: [],
487
+ opaque: false,
488
+ userText: role === 'user' ? body : undefined,
489
+ };
490
+ }
491
+ export function chatMessagesToTurns(messages) {
492
+ return messages.map((msg, i) => chatMessageToTurn(msg, i));
493
+ }
494
+ //# sourceMappingURL=openai-history.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cache-aware GPT/OpenAI savings math.
3
+ *
4
+ * This is deliberately separate from src/core/baseline.ts (Anthropic): OpenAI
5
+ * has no `count_tokens`, no explicit cache_control breakpoints, no cache-create
6
+ * premium, and images are billed by OpenAI's vision-token formula rather than
7
+ * text tokens. The transform path records two GPT-specific facts per imaged
8
+ * request:
9
+ *
10
+ * imageTokens = what the rendered images actually cost as input
11
+ * baselineImagedTokens = o200k text tokens the imaged/stripped content would
12
+ * have cost if left as plain text
13
+ *
14
+ * OpenAI usage then tells us how many prompt tokens were served from prompt
15
+ * cache (`cached_tokens`, a subset of input_tokens). For the gpt-5 family, cached
16
+ * input is billed at ~0.1× the normal input rate; there is no 1.25× write
17
+ * premium like Anthropic's ephemeral cache.
18
+ */
19
+ /** gpt-5 cached input list ratio: $0.125 / $1.25 per 1M tokens. */
20
+ export declare const OPENAI_GPT5_CACHE_READ_RATE = 0.1;
21
+ /** gpt-5 output/input list ratio: $10 / $1.25 per 1M tokens. */
22
+ export declare const OPENAI_GPT5_OUTPUT_RATE = 8;
23
+ /** Older OpenAI families use a less aggressive cached-input discount. pxpipe's
24
+ * GPT compression gate is currently gpt-5.x-only, but keep the helper explicit
25
+ * so passthrough telemetry does not accidentally get priced at Anthropic rates. */
26
+ export declare function openAICacheReadRate(model: string | undefined): number;
27
+ export declare function openAIOutputRate(model: string | undefined): number;
28
+ /** Weighted input tokens actually paid to OpenAI this turn. `cachedTokens` is a
29
+ * subset of `inputTokens`, not an additive bucket. */
30
+ export declare function computeOpenAIActualInputEff(inputTokens: number, cachedTokens: number, model?: string): number;
31
+ /** Raw token count for the unproxied GPT counterfactual: replace the rendered
32
+ * images with the o200k text they stood in for. */
33
+ export declare function computeOpenAIBaselineRawTokens(inputTokens: number, imageTokens: number, baselineImagedTokens: number): number;
34
+ /** Weighted input tokens for the unproxied GPT text counterfactual.
35
+ *
36
+ * We cannot ask OpenAI `count_tokens`, and the API does not expose per-block
37
+ * cache accounting. The only honest observable is whether this request had a
38
+ * prompt-cache hit at all (`cached_tokens > 0`). The imaged slab sits in the
39
+ * stable prefix; when OpenAI reports cached tokens, that slab would have been
40
+ * cached as text too, so the text↔image delta is discounted by the same cached
41
+ * input rate. On a cold/no-cache turn, the delta is paid at the full input rate.
42
+ */
43
+ export declare function computeOpenAIBaselineInputEff(inputTokens: number, cachedTokens: number, imageTokens: number, baselineImagedTokens: number, model?: string): number;
44
+ //# sourceMappingURL=openai-savings.d.ts.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Cache-aware GPT/OpenAI savings math.
3
+ *
4
+ * This is deliberately separate from src/core/baseline.ts (Anthropic): OpenAI
5
+ * has no `count_tokens`, no explicit cache_control breakpoints, no cache-create
6
+ * premium, and images are billed by OpenAI's vision-token formula rather than
7
+ * text tokens. The transform path records two GPT-specific facts per imaged
8
+ * request:
9
+ *
10
+ * imageTokens = what the rendered images actually cost as input
11
+ * baselineImagedTokens = o200k text tokens the imaged/stripped content would
12
+ * have cost if left as plain text
13
+ *
14
+ * OpenAI usage then tells us how many prompt tokens were served from prompt
15
+ * cache (`cached_tokens`, a subset of input_tokens). For the gpt-5 family, cached
16
+ * input is billed at ~0.1× the normal input rate; there is no 1.25× write
17
+ * premium like Anthropic's ephemeral cache.
18
+ */
19
+ /** gpt-5 cached input list ratio: $0.125 / $1.25 per 1M tokens. */
20
+ export const OPENAI_GPT5_CACHE_READ_RATE = 0.1;
21
+ /** gpt-5 output/input list ratio: $10 / $1.25 per 1M tokens. */
22
+ export const OPENAI_GPT5_OUTPUT_RATE = 8;
23
+ /** Older OpenAI families use a less aggressive cached-input discount. pxpipe's
24
+ * GPT compression gate is currently gpt-5.x-only, but keep the helper explicit
25
+ * so passthrough telemetry does not accidentally get priced at Anthropic rates. */
26
+ export function openAICacheReadRate(model) {
27
+ const m = (model ?? '').toLowerCase();
28
+ if (/^gpt-5/.test(m))
29
+ return OPENAI_GPT5_CACHE_READ_RATE;
30
+ return 0.5;
31
+ }
32
+ export function openAIOutputRate(model) {
33
+ const m = (model ?? '').toLowerCase();
34
+ if (/^gpt-5/.test(m))
35
+ return OPENAI_GPT5_OUTPUT_RATE;
36
+ // Good-enough fallback for non-compressed OpenAI rows; they normally do not
37
+ // enter the savings numerator, but the all-usage denominator should still be
38
+ // roughly dollar-weighted.
39
+ return 4;
40
+ }
41
+ /** Weighted input tokens actually paid to OpenAI this turn. `cachedTokens` is a
42
+ * subset of `inputTokens`, not an additive bucket. */
43
+ export function computeOpenAIActualInputEff(inputTokens, cachedTokens, model) {
44
+ if (inputTokens <= 0)
45
+ return 0;
46
+ const cached = Math.max(0, Math.min(cachedTokens || 0, inputTokens));
47
+ const uncached = inputTokens - cached;
48
+ return uncached + cached * openAICacheReadRate(model);
49
+ }
50
+ /** Raw token count for the unproxied GPT counterfactual: replace the rendered
51
+ * images with the o200k text they stood in for. */
52
+ export function computeOpenAIBaselineRawTokens(inputTokens, imageTokens, baselineImagedTokens) {
53
+ if (inputTokens <= 0)
54
+ return 0;
55
+ const delta = (baselineImagedTokens || 0) - (imageTokens || 0);
56
+ return Math.max(0, inputTokens + delta);
57
+ }
58
+ /** Weighted input tokens for the unproxied GPT text counterfactual.
59
+ *
60
+ * We cannot ask OpenAI `count_tokens`, and the API does not expose per-block
61
+ * cache accounting. The only honest observable is whether this request had a
62
+ * prompt-cache hit at all (`cached_tokens > 0`). The imaged slab sits in the
63
+ * stable prefix; when OpenAI reports cached tokens, that slab would have been
64
+ * cached as text too, so the text↔image delta is discounted by the same cached
65
+ * input rate. On a cold/no-cache turn, the delta is paid at the full input rate.
66
+ */
67
+ export function computeOpenAIBaselineInputEff(inputTokens, cachedTokens, imageTokens, baselineImagedTokens, model) {
68
+ const actual = computeOpenAIActualInputEff(inputTokens, cachedTokens, model);
69
+ if (inputTokens <= 0 || imageTokens <= 0 || baselineImagedTokens <= 0)
70
+ return actual;
71
+ const delta = baselineImagedTokens - imageTokens;
72
+ const deltaWeight = (cachedTokens || 0) > 0 ? openAICacheReadRate(model) : 1.0;
73
+ return actual + delta * deltaWeight;
74
+ }
75
+ //# sourceMappingURL=openai-savings.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * OpenAI Chat Completions + Responses API transformer for the GPT-5 family.
3
+ * Separate from the Anthropic path: no cache-control breakpoints,
4
+ * images as image_url/input_image parts, system/developer messages in messages[]/input[].
5
+ * OpenAI tools keep native names/descriptions/schema shape; verbose schema prose
6
+ * is also rendered into images for token savings so calls do not depend only on OCR.
7
+ */
8
+ import { type GptVisionCost } from './gpt-model-profiles.js';
9
+ import { type TransformInfo, type TransformOptions } from './transform.js';
10
+ type VisionCost = GptVisionCost;
11
+ export declare function resolveVisionCost(model: string): VisionCost;
12
+ export declare const HISTORY_TRANSCRIPT_INTRO = "[Earlier turns of THIS conversation, transcribed in the image(s) below. Each turn is wrapped in <user t=\"N\">...</user> or <assistant t=\"N\">...</assistant> tags, where N is an absolute turn index (larger N = more recent); attribute every turn strictly by its tag, and treat the highest-N turns as the most recent prior context, NOT the low-N opening turns. Earlier turns may contain questions or tasks that were already answered later in this same history; do not reopen low-N turns unless the live text after this block asks you to. This is prior context, NOT the current request.]";
13
+ export declare const HISTORY_TRANSCRIPT_OUTRO = "[End of earlier conversation. The current request is the live text that follows below.]";
14
+ export declare function openAIVisionTokens(model: string, w: number, h: number): number;
15
+ export declare function transformOpenAIChatCompletions(body: Uint8Array, opts?: TransformOptions): Promise<{
16
+ body: Uint8Array;
17
+ info: TransformInfo;
18
+ }>;
19
+ export declare function transformOpenAIResponses(body: Uint8Array, opts?: TransformOptions): Promise<{
20
+ body: Uint8Array;
21
+ info: TransformInfo;
22
+ }>;
23
+ export {};
24
+ //# sourceMappingURL=openai.d.ts.map