@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,133 @@
|
|
|
1
|
+
import { isPxpipeSupportedModel } from './applicability.js';
|
|
2
|
+
import { countCacheControlMarkers } from './measurement.js';
|
|
3
|
+
import { renderTextToPngsWithCharLimit, renderTextToPngsMultiCol, measureContentCols, maxFittingCols, reflow, DENSE_CONTENT_COLS, DENSE_CONTENT_CHARS_PER_IMAGE, DENSE_RENDER_STYLE, MAX_HEIGHT_PX, } from './render.js';
|
|
4
|
+
import { transformRequest, } from './transform.js';
|
|
5
|
+
function toUint8Array(bytes) {
|
|
6
|
+
if (bytes instanceof Uint8Array)
|
|
7
|
+
return bytes;
|
|
8
|
+
if (bytes instanceof ArrayBuffer)
|
|
9
|
+
return new Uint8Array(bytes);
|
|
10
|
+
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
11
|
+
}
|
|
12
|
+
function emptyInfo(reason) {
|
|
13
|
+
return {
|
|
14
|
+
compressed: false,
|
|
15
|
+
reason,
|
|
16
|
+
origChars: 0,
|
|
17
|
+
compressedChars: 0,
|
|
18
|
+
imageCount: 0,
|
|
19
|
+
imageBytes: 0,
|
|
20
|
+
staticChars: 0,
|
|
21
|
+
dynamicChars: 0,
|
|
22
|
+
dynamicBlockCount: 0,
|
|
23
|
+
droppedChars: 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function classifyReason(info) {
|
|
27
|
+
if (info.compressed)
|
|
28
|
+
return 'applied';
|
|
29
|
+
const r = info.reason ?? '';
|
|
30
|
+
if (r.startsWith('parse_error'))
|
|
31
|
+
return 'parse_error';
|
|
32
|
+
if (r.startsWith('compress=false'))
|
|
33
|
+
return 'compress_disabled';
|
|
34
|
+
if (r.startsWith('below_min_chars'))
|
|
35
|
+
return 'below_min_chars';
|
|
36
|
+
if (r.startsWith('below_min_tokens'))
|
|
37
|
+
return 'below_min_tokens';
|
|
38
|
+
if (r.startsWith('not_profitable'))
|
|
39
|
+
return 'not_profitable';
|
|
40
|
+
if (r.includes('image') && r.includes('limit'))
|
|
41
|
+
return 'image_limit';
|
|
42
|
+
return 'passthrough';
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Library wrapper for the Anthropic Messages transformer: model gate, machine-readable
|
|
46
|
+
* reasons, and cache_control ownership flag (prevents hosts stacking a second injector).
|
|
47
|
+
*/
|
|
48
|
+
export async function transformAnthropicMessages(input) {
|
|
49
|
+
const original = toUint8Array(input.body);
|
|
50
|
+
if (!isPxpipeSupportedModel(input.model)) {
|
|
51
|
+
return {
|
|
52
|
+
body: original,
|
|
53
|
+
applied: false,
|
|
54
|
+
reason: 'unsupported_model',
|
|
55
|
+
detail: input.model ?? undefined,
|
|
56
|
+
info: emptyInfo('unsupported_model'),
|
|
57
|
+
cache: { ownsCacheControl: false, markerCount: countCacheControlMarkers(original) },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const { body, info } = await transformRequest(original, input.options);
|
|
62
|
+
const reason = classifyReason(info);
|
|
63
|
+
const markerCount = countCacheControlMarkers(body);
|
|
64
|
+
return {
|
|
65
|
+
body,
|
|
66
|
+
applied: info.compressed,
|
|
67
|
+
reason,
|
|
68
|
+
detail: info.reason,
|
|
69
|
+
info,
|
|
70
|
+
cache: {
|
|
71
|
+
ownsCacheControl: info.compressed && markerCount > 0,
|
|
72
|
+
markerCount,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
return {
|
|
78
|
+
body: original,
|
|
79
|
+
applied: false,
|
|
80
|
+
reason: 'transform_error',
|
|
81
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
82
|
+
info: emptyInfo(`transform_error: ${e instanceof Error ? e.message : String(e)}`),
|
|
83
|
+
cache: { ownsCacheControl: false, markerCount: countCacheControlMarkers(original) },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Render arbitrary text to dense PNG pages — the public, documented entry for the
|
|
89
|
+
* renderer the proxy uses internally. Sizes a narrow canvas to the content (`shrink`)
|
|
90
|
+
* and packs multiple columns (`multiCol`) so short-line content isn't priced at full
|
|
91
|
+
* width. Returns raw PNG bytes + pixel dimensions, ready to write to disk or wrap in
|
|
92
|
+
* image blocks. This is the surface SDK consumers should use instead of reaching into
|
|
93
|
+
* the internal leaf renderers in `render.ts`.
|
|
94
|
+
*/
|
|
95
|
+
export async function renderTextToImages(text, opts = {}) {
|
|
96
|
+
const maxCols = Math.max(1, (opts.cols ?? DENSE_CONTENT_COLS) | 0);
|
|
97
|
+
const style = opts.style ?? DENSE_RENDER_STYLE;
|
|
98
|
+
const maxHeightPx = opts.maxHeightPx ?? MAX_HEIGHT_PX;
|
|
99
|
+
const maxChars = opts.maxCharsPerImage ?? DENSE_CONTENT_CHARS_PER_IMAGE;
|
|
100
|
+
// Reflow (the proxy's dense default; opt-in here): minify trailing whitespace + collapse
|
|
101
|
+
// blank-line runs, then join hard newlines with the ↵ sentinel so short lines PACK into
|
|
102
|
+
// full-width rows instead of one-line-per-row with a ragged right margin. Indentation is
|
|
103
|
+
// preserved (minifyForRender only touches trailing ws), so code stays readable and the ↵
|
|
104
|
+
// marks every real newline so the text is fully reconstructable. This is exactly what the
|
|
105
|
+
// proxy's history path does before rendering — without it, a 384-col canvas holding ~25-col
|
|
106
|
+
// code lines wastes ~75% of every row, which is why raw exports looked sparse. reflow()
|
|
107
|
+
// bails (→ raw text) only if the source already contains ↵, which is vanishingly rare.
|
|
108
|
+
const source = opts.reflow ? reflow(text) ?? text : text;
|
|
109
|
+
// Width/columns: measure the content, then pack as many side-by-side columns as fit the
|
|
110
|
+
// width cap (auto) or the caller's explicit count. Reflowed source is one ↵-joined full-
|
|
111
|
+
// width line, so this collapses to a single dense 384-col column — byte-identical to the
|
|
112
|
+
// proxy's history render (renderTextToPngsWithCharLimit at DENSE_CONTENT_COLS).
|
|
113
|
+
const cols = opts.shrink === false ? maxCols : measureContentCols(source, maxCols);
|
|
114
|
+
const requestedCols = opts.multiCol === undefined || opts.multiCol === 'auto'
|
|
115
|
+
? Math.max(1, maxFittingCols(cols))
|
|
116
|
+
: Math.max(1, opts.multiCol | 0);
|
|
117
|
+
const numCols = cols < maxCols ? 1 : requestedCols;
|
|
118
|
+
const imgs = numCols > 1
|
|
119
|
+
? await renderTextToPngsMultiCol(source, cols, numCols)
|
|
120
|
+
: await renderTextToPngsWithCharLimit(source, cols, maxChars, style, maxHeightPx);
|
|
121
|
+
let droppedChars = 0;
|
|
122
|
+
let pixels = 0;
|
|
123
|
+
for (const im of imgs) {
|
|
124
|
+
droppedChars += im.droppedChars;
|
|
125
|
+
pixels += im.width * im.height;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
pages: imgs.map((im) => ({ png: im.png, width: im.width, height: im.height })),
|
|
129
|
+
droppedChars,
|
|
130
|
+
pixels,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=library.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure body-shaping utilities for the uncompressed count_tokens counterfactual.
|
|
3
|
+
* No fetch, auth, or Node APIs — hosts supply their own transport.
|
|
4
|
+
*/
|
|
5
|
+
export interface CountTokensBodies {
|
|
6
|
+
/** Full original body, filtered to count_tokens-accepted fields. */
|
|
7
|
+
readonly fullBody: Uint8Array | null;
|
|
8
|
+
/** Original body truncated at the latest cache_control marker; null when none exists. */
|
|
9
|
+
readonly cacheablePrefixBody: Uint8Array | null;
|
|
10
|
+
}
|
|
11
|
+
type BytesLike = Uint8Array | ArrayBuffer | ArrayBufferView;
|
|
12
|
+
export declare function buildCountTokensBodies(bytes: BytesLike): CountTokensBodies;
|
|
13
|
+
export declare function buildBaselineCountTokensBody(bytes: BytesLike): Uint8Array | null;
|
|
14
|
+
/** Build a body containing only the longest cacheable prefix (everything up to and including the last
|
|
15
|
+
* cache_control marker). count_tokens on this body gives cacheable_prefix_tokens.
|
|
16
|
+
* Walk order (latest-first in cache order): messages → system → tools.
|
|
17
|
+
* Returns null when no markers exist (cacheable_prefix_tokens = 0). */
|
|
18
|
+
export declare function buildCacheablePrefixCountTokensBody(bytes: BytesLike): Uint8Array | null;
|
|
19
|
+
/** Count cache_control markers anywhere in an Anthropic Messages body. */
|
|
20
|
+
export declare function countCacheControlMarkers(bytes: BytesLike): number;
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=measurement.d.ts.map
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure body-shaping utilities for the uncompressed count_tokens counterfactual.
|
|
3
|
+
* No fetch, auth, or Node APIs — hosts supply their own transport.
|
|
4
|
+
*/
|
|
5
|
+
/** Fields accepted by /v1/messages/count_tokens. Any other field returns 400 "Unknown parameter". */
|
|
6
|
+
const COUNT_TOKENS_FIELDS = new Set([
|
|
7
|
+
'model',
|
|
8
|
+
'messages',
|
|
9
|
+
'system',
|
|
10
|
+
'tools',
|
|
11
|
+
'tool_choice',
|
|
12
|
+
'thinking',
|
|
13
|
+
'mcp_servers',
|
|
14
|
+
]);
|
|
15
|
+
function toUint8Array(bytes) {
|
|
16
|
+
if (bytes instanceof Uint8Array)
|
|
17
|
+
return bytes;
|
|
18
|
+
if (bytes instanceof ArrayBuffer)
|
|
19
|
+
return new Uint8Array(bytes);
|
|
20
|
+
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
21
|
+
}
|
|
22
|
+
export function buildCountTokensBodies(bytes) {
|
|
23
|
+
const b = toUint8Array(bytes);
|
|
24
|
+
return {
|
|
25
|
+
fullBody: buildBaselineCountTokensBody(b),
|
|
26
|
+
cacheablePrefixBody: buildCacheablePrefixCountTokensBody(b),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function buildBaselineCountTokensBody(bytes) {
|
|
30
|
+
const b = toUint8Array(bytes);
|
|
31
|
+
try {
|
|
32
|
+
const obj = JSON.parse(new TextDecoder().decode(b));
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const k of Object.keys(obj)) {
|
|
35
|
+
if (COUNT_TOKENS_FIELDS.has(k))
|
|
36
|
+
out[k] = obj[k];
|
|
37
|
+
}
|
|
38
|
+
if (typeof out.model !== 'string' || !Array.isArray(out.messages))
|
|
39
|
+
return null;
|
|
40
|
+
return new TextEncoder().encode(JSON.stringify(out));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** True when an object carries a cache_control key (presence only; value ignored). */
|
|
47
|
+
function hasCacheControl(x) {
|
|
48
|
+
return (typeof x === 'object'
|
|
49
|
+
&& x !== null
|
|
50
|
+
&& x.cache_control != null);
|
|
51
|
+
}
|
|
52
|
+
/** Return tool_use ids with no matching tool_result. count_tokens rejects orphans;
|
|
53
|
+
* truncating at a cache_control marker commonly creates them (result is in the dropped tail). */
|
|
54
|
+
function findOrphanToolUseIds(messages) {
|
|
55
|
+
const uses = [];
|
|
56
|
+
const results = new Set();
|
|
57
|
+
for (const msg of messages) {
|
|
58
|
+
if (!msg || typeof msg !== 'object')
|
|
59
|
+
continue;
|
|
60
|
+
const content = msg.content;
|
|
61
|
+
if (!Array.isArray(content))
|
|
62
|
+
continue;
|
|
63
|
+
for (const blk of content) {
|
|
64
|
+
if (!blk || typeof blk !== 'object')
|
|
65
|
+
continue;
|
|
66
|
+
const t = blk.type;
|
|
67
|
+
if (t === 'tool_use') {
|
|
68
|
+
const id = blk.id;
|
|
69
|
+
if (typeof id === 'string')
|
|
70
|
+
uses.push(id);
|
|
71
|
+
}
|
|
72
|
+
else if (t === 'tool_result') {
|
|
73
|
+
const id = blk.tool_use_id;
|
|
74
|
+
if (typeof id === 'string')
|
|
75
|
+
results.add(id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return uses.filter((id) => !results.has(id));
|
|
80
|
+
}
|
|
81
|
+
/** Append minimal synthetic tool_results for orphan tool_use ids so count_tokens won't reject the body.
|
|
82
|
+
* Adds only a handful of tokens; keeps estimate within ~1% of truth. */
|
|
83
|
+
function appendSyntheticToolResults(truncated) {
|
|
84
|
+
const messages = truncated.messages;
|
|
85
|
+
if (!Array.isArray(messages))
|
|
86
|
+
return truncated;
|
|
87
|
+
const orphanIds = findOrphanToolUseIds(messages);
|
|
88
|
+
if (orphanIds.length === 0)
|
|
89
|
+
return truncated;
|
|
90
|
+
const syntheticUserMsg = {
|
|
91
|
+
role: 'user',
|
|
92
|
+
content: orphanIds.map((id) => ({
|
|
93
|
+
type: 'tool_result',
|
|
94
|
+
tool_use_id: id,
|
|
95
|
+
content: 'ok',
|
|
96
|
+
})),
|
|
97
|
+
};
|
|
98
|
+
return { ...truncated, messages: [...messages, syntheticUserMsg] };
|
|
99
|
+
}
|
|
100
|
+
/** Build a body containing only the longest cacheable prefix (everything up to and including the last
|
|
101
|
+
* cache_control marker). count_tokens on this body gives cacheable_prefix_tokens.
|
|
102
|
+
* Walk order (latest-first in cache order): messages → system → tools.
|
|
103
|
+
* Returns null when no markers exist (cacheable_prefix_tokens = 0). */
|
|
104
|
+
export function buildCacheablePrefixCountTokensBody(bytes) {
|
|
105
|
+
const b = toUint8Array(bytes);
|
|
106
|
+
let obj;
|
|
107
|
+
try {
|
|
108
|
+
obj = JSON.parse(new TextDecoder().decode(b));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (typeof obj.model !== 'string')
|
|
114
|
+
return null;
|
|
115
|
+
const system = obj.system;
|
|
116
|
+
const messages = obj.messages;
|
|
117
|
+
const tools = obj.tools;
|
|
118
|
+
let truncated = null;
|
|
119
|
+
if (Array.isArray(messages)) {
|
|
120
|
+
for (let mi = messages.length - 1; mi >= 0 && truncated == null; mi--) {
|
|
121
|
+
const msg = messages[mi];
|
|
122
|
+
const content = msg?.content;
|
|
123
|
+
if (Array.isArray(content)) {
|
|
124
|
+
for (let bi = content.length - 1; bi >= 0; bi--) {
|
|
125
|
+
if (hasCacheControl(content[bi])) {
|
|
126
|
+
const truncatedMsg = { ...msg, content: content.slice(0, bi + 1) };
|
|
127
|
+
const truncatedMessages = messages.slice(0, mi).concat([truncatedMsg]);
|
|
128
|
+
truncated = {
|
|
129
|
+
model: obj.model,
|
|
130
|
+
messages: truncatedMessages,
|
|
131
|
+
};
|
|
132
|
+
if (system !== undefined)
|
|
133
|
+
truncated.system = system;
|
|
134
|
+
if (tools !== undefined)
|
|
135
|
+
truncated.tools = tools;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (hasCacheControl(msg)) {
|
|
141
|
+
truncated = {
|
|
142
|
+
model: obj.model,
|
|
143
|
+
messages: messages.slice(0, mi + 1),
|
|
144
|
+
};
|
|
145
|
+
if (system !== undefined)
|
|
146
|
+
truncated.system = system;
|
|
147
|
+
if (tools !== undefined)
|
|
148
|
+
truncated.tools = tools;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (truncated == null && Array.isArray(system)) {
|
|
153
|
+
for (let si = system.length - 1; si >= 0; si--) {
|
|
154
|
+
if (hasCacheControl(system[si])) {
|
|
155
|
+
truncated = {
|
|
156
|
+
model: obj.model,
|
|
157
|
+
system: system.slice(0, si + 1),
|
|
158
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
159
|
+
};
|
|
160
|
+
if (tools !== undefined)
|
|
161
|
+
truncated.tools = tools;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (truncated == null && Array.isArray(tools)) {
|
|
167
|
+
for (let ti = tools.length - 1; ti >= 0; ti--) {
|
|
168
|
+
if (hasCacheControl(tools[ti])) {
|
|
169
|
+
truncated = {
|
|
170
|
+
model: obj.model,
|
|
171
|
+
tools: tools.slice(0, ti + 1),
|
|
172
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
173
|
+
};
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (truncated == null)
|
|
179
|
+
return null;
|
|
180
|
+
truncated = appendSyntheticToolResults(truncated);
|
|
181
|
+
const out = {};
|
|
182
|
+
for (const k of Object.keys(truncated)) {
|
|
183
|
+
if (COUNT_TOKENS_FIELDS.has(k))
|
|
184
|
+
out[k] = truncated[k];
|
|
185
|
+
}
|
|
186
|
+
return new TextEncoder().encode(JSON.stringify(out));
|
|
187
|
+
}
|
|
188
|
+
/** Count cache_control markers anywhere in an Anthropic Messages body. */
|
|
189
|
+
export function countCacheControlMarkers(bytes) {
|
|
190
|
+
const b = toUint8Array(bytes);
|
|
191
|
+
try {
|
|
192
|
+
return countCacheControlValue(JSON.parse(new TextDecoder().decode(b)));
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function countCacheControlValue(value) {
|
|
199
|
+
if (!value || typeof value !== 'object')
|
|
200
|
+
return 0;
|
|
201
|
+
let n = hasCacheControl(value) ? 1 : 0;
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
for (const item of value)
|
|
204
|
+
n += countCacheControlValue(item);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
for (const item of Object.values(value)) {
|
|
208
|
+
n += countCacheControlValue(item);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return n;
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=measurement.js.map
|
|
@@ -0,0 +1,124 @@
|
|
|
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 { type RenderedImage } from './render.js';
|
|
24
|
+
/** Break-even gate predicate, injected to avoid a circular import with openai.ts.
|
|
25
|
+
* Receives the full string (not length) so the renderer's row-aware image-count
|
|
26
|
+
* estimate sees real newlines — history text is newline-heavy. */
|
|
27
|
+
export type GptProfitableFn = (text: string, cols: number) => boolean;
|
|
28
|
+
export interface GptHistoryOptions {
|
|
29
|
+
/** Trailing items kept as live text (never collapsed). */
|
|
30
|
+
keepTail: number;
|
|
31
|
+
/** Minimum collapsible items in [protectedPrefix..boundary]; below this the
|
|
32
|
+
* cache-amortization math doesn't pay (imaging a tiny prefix is net cost). */
|
|
33
|
+
minCollapsePrefix: number;
|
|
34
|
+
/** Minimum collapsed-text size in o200k TOKENS (not chars). OpenAI caches the
|
|
35
|
+
* text transcript at ~0.1× already and bills images by vision tokens, so the
|
|
36
|
+
* break-even is a token comparison — 8000 chars of dense JSON tokenizes very
|
|
37
|
+
* differently from 8000 chars of prose. Below this, imaging a tiny prefix is
|
|
38
|
+
* net cost. */
|
|
39
|
+
minCollapseTokens: number;
|
|
40
|
+
/** Soft-wrap columns for the dense renderer. */
|
|
41
|
+
cols: number;
|
|
42
|
+
/** Advance the collapse boundary in steps of this many items so the rendered
|
|
43
|
+
* PNG stays byte-identical across turns and keeps hitting the prompt cache.
|
|
44
|
+
* 0 = per-item moving boundary (cache-hostile; tests only). */
|
|
45
|
+
collapseChunk: number;
|
|
46
|
+
/** Render the collapse range as independent image chunks of this many turns on
|
|
47
|
+
* an ABSOLUTE grid anchored at protectedPrefix. A completed chunk's bytes are
|
|
48
|
+
* fixed by its turn range alone, so old chunks stay byte-identical (cache_read
|
|
49
|
+
* forever) as the conversation grows — only the newest partial chunk
|
|
50
|
+
* re-renders. 0 = render the whole range as one blob (legacy, non-append-only). */
|
|
51
|
+
freezeChunk: number;
|
|
52
|
+
/** Target size of one frozen image SECTION, in o200k tokens. The collapse range
|
|
53
|
+
* is cut into sections by walking turns from protectedPrefix and sealing a
|
|
54
|
+
* section each time its cumulative token count crosses this target. A sealed
|
|
55
|
+
* section's bytes are a pure function of its turn range (independent of where
|
|
56
|
+
* the conversation currently ends), so it stays byte-identical — and OpenAI
|
|
57
|
+
* prefix-cache-hits — as the conversation grows. Leftover tail turns that don't
|
|
58
|
+
* fill a whole section are left UNCOLLAPSED (live text) until they do. Chosen so
|
|
59
|
+
* each section renders to roughly one ≤6000px image, well under gpt-5.x's
|
|
60
|
+
* 10,000-patch `detail:original` budget. Turn size, not turn count, drives this. */
|
|
61
|
+
sectionTokens: number;
|
|
62
|
+
/** Max rendered image height in px (per-model; from the GPT profile). Threaded
|
|
63
|
+
* into renderTextToPngs so history pages split at the same height the gate prices. */
|
|
64
|
+
maxHeightPx: number;
|
|
65
|
+
/** Hard cap on GPT history image count. This is a TRUE cap, not a threshold:
|
|
66
|
+
* collapse the oldest completed sections until the next section would exceed
|
|
67
|
+
* the cap, then leave the remaining history as ordinary text. Prevents 80+
|
|
68
|
+
* image gpt-5.5 requests without dropping context or live tool state. */
|
|
69
|
+
maxImages: number;
|
|
70
|
+
/** Reflow the transcript before rendering: pack soft-wrapped lines and mark
|
|
71
|
+
* every hard newline with the ↵ sentinel — same treatment as the static
|
|
72
|
+
* slab. History text is newline-heavy (role headers, JSON args), so without
|
|
73
|
+
* this each short line wastes a full render row and no ↵ marker appears.
|
|
74
|
+
* The returned `text` (o200k baseline + cache byte-stability) stays the
|
|
75
|
+
* ORIGINAL, un-reflowed transcript. */
|
|
76
|
+
reflow: boolean;
|
|
77
|
+
}
|
|
78
|
+
export declare const GPT_HISTORY_DEFAULTS: GptHistoryOptions;
|
|
79
|
+
/** One conversation item lowered to a renderable unit. */
|
|
80
|
+
export interface HistoryTurn {
|
|
81
|
+
/** Serialized text (with role header / tool markers). Empty = skip (e.g. reasoning). */
|
|
82
|
+
text: string;
|
|
83
|
+
/** Tool-call ids this item opens (function_call / assistant tool_calls). */
|
|
84
|
+
openIds: string[];
|
|
85
|
+
/** Tool-call ids this item closes (function_call_output / tool message). */
|
|
86
|
+
closeIds: string[];
|
|
87
|
+
/** Item we can't safely serialize (unknown kind, item_reference) — a hard
|
|
88
|
+
* barrier: never collapse across it, since dropping it could lose state. */
|
|
89
|
+
opaque: boolean;
|
|
90
|
+
/** Raw body when this item is a real USER request (role==='user', not a tool
|
|
91
|
+
* result). The planner pins the MOST RECENT such turn as legible text instead
|
|
92
|
+
* of imaging it, so the live ask is never OCR-only. undefined = not a user turn. */
|
|
93
|
+
userText?: string;
|
|
94
|
+
}
|
|
95
|
+
export interface GptCollapsePlan {
|
|
96
|
+
/** Rendered history images BEFORE the pinned user turn (or ALL images when no
|
|
97
|
+
* turn was pinned). Empty when no collapse happened. */
|
|
98
|
+
images: RenderedImage[];
|
|
99
|
+
/** Rendered history images AFTER the pinned user turn. Empty unless a pin split
|
|
100
|
+
* the range. Total imaged = images ∪ imagesAfter. */
|
|
101
|
+
imagesAfter: RenderedImage[];
|
|
102
|
+
/** Raw text of the most-recent user request, kept legible (NOT imaged) and
|
|
103
|
+
* spliced between `images` and `imagesAfter`. undefined = nothing pinned. */
|
|
104
|
+
pinText?: string;
|
|
105
|
+
/** The collapsed transcript text that was rendered (for o200k token counting). */
|
|
106
|
+
text: string;
|
|
107
|
+
/** Inclusive start index into the original item array. */
|
|
108
|
+
start: number;
|
|
109
|
+
/** Exclusive end index. Caller splices [start, endExclusive) → one synthetic item. */
|
|
110
|
+
endExclusive: number;
|
|
111
|
+
collapsedTurns: number;
|
|
112
|
+
collapsedChars: number;
|
|
113
|
+
reason?: 'prefix_too_short' | 'no_closed_prefix' | 'below_min_tokens' | 'not_profitable' | 'too_many_images' | 'render_empty';
|
|
114
|
+
droppedChars: number;
|
|
115
|
+
droppedCodepoints: Map<number, number>;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Plan + render a history collapse over pre-lowered turns. Pure w.r.t. the input
|
|
119
|
+
* (caller does the splice and builds the format-specific synthetic item).
|
|
120
|
+
*/
|
|
121
|
+
export declare function planGptCollapse(turns: HistoryTurn[], protectedPrefix: number, isProfitable: GptProfitableFn, opts?: Partial<GptHistoryOptions>): Promise<GptCollapsePlan>;
|
|
122
|
+
export declare function responsesItemsToTurns(items: unknown[]): HistoryTurn[];
|
|
123
|
+
export declare function chatMessagesToTurns(messages: unknown[]): HistoryTurn[];
|
|
124
|
+
//# sourceMappingURL=openai-history.d.ts.map
|