@xamukavila/pxpipe 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +312 -0
- package/bin/cli.js +7 -0
- package/dist/core/applicability.d.ts +31 -0
- package/dist/core/applicability.js +96 -0
- package/dist/core/atlas-gray.d.ts +26 -0
- package/dist/core/atlas-gray.js +64 -0
- package/dist/core/atlas.d.ts +34 -0
- package/dist/core/atlas.js +71 -0
- package/dist/core/baseline.d.ts +80 -0
- package/dist/core/baseline.js +101 -0
- package/dist/core/caveman.d.ts +38 -0
- package/dist/core/caveman.js +183 -0
- package/dist/core/export.d.ts +128 -0
- package/dist/core/export.js +390 -0
- package/dist/core/factsheet.d.ts +78 -0
- package/dist/core/factsheet.js +216 -0
- package/dist/core/gpt-model-profiles.d.ts +60 -0
- package/dist/core/gpt-model-profiles.js +161 -0
- package/dist/core/history.d.ts +141 -0
- package/dist/core/history.js +553 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.js +8 -0
- package/dist/core/library.d.ts +74 -0
- package/dist/core/library.js +133 -0
- package/dist/core/measurement.d.ts +22 -0
- package/dist/core/measurement.js +213 -0
- package/dist/core/openai-history.d.ts +124 -0
- package/dist/core/openai-history.js +494 -0
- package/dist/core/openai-savings.d.ts +44 -0
- package/dist/core/openai-savings.js +75 -0
- package/dist/core/openai.d.ts +24 -0
- package/dist/core/openai.js +839 -0
- package/dist/core/png.d.ts +11 -0
- package/dist/core/png.js +132 -0
- package/dist/core/proxy.d.ts +81 -0
- package/dist/core/proxy.js +730 -0
- package/dist/core/render.d.ts +188 -0
- package/dist/core/render.js +785 -0
- package/dist/core/schema-strip.d.ts +29 -0
- package/dist/core/schema-strip.js +160 -0
- package/dist/core/tracker.d.ts +154 -0
- package/dist/core/tracker.js +216 -0
- package/dist/core/transform.d.ts +362 -0
- package/dist/core/transform.js +1828 -0
- package/dist/core/types.d.ts +77 -0
- package/dist/core/types.js +8 -0
- package/dist/dashboard/fragments.d.ts +36 -0
- package/dist/dashboard/fragments.js +938 -0
- package/dist/dashboard/types.d.ts +154 -0
- package/dist/dashboard/types.js +3 -0
- package/dist/dashboard/vendor.d.ts +3 -0
- package/dist/dashboard/vendor.js +6 -0
- package/dist/dashboard.d.ts +245 -0
- package/dist/dashboard.js +1140 -0
- package/dist/export-collect.d.ts +36 -0
- package/dist/export-collect.js +59 -0
- package/dist/node.d.ts +9 -0
- package/dist/node.js +9038 -0
- package/dist/sessions.d.ts +172 -0
- package/dist/sessions.js +510 -0
- package/dist/stats.d.ts +74 -0
- package/dist/stats.js +248 -0
- package/dist/worker.d.ts +53 -0
- package/dist/worker.js +102 -0
- package/package.json +96 -0
|
@@ -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
|