mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
fix(gpt): cap history images and honest cache math
This commit is contained in:
@@ -31,6 +31,12 @@ import { countTokens as o200kCountTokens } from 'gpt-tokenizer/encoding/o200k_ba
|
||||
* illegible — that profile is Anthropic-only. */
|
||||
const GPT_HISTORY_COLS = 152;
|
||||
|
||||
// GPT vision latency grows with physical image count/bytes, not just billed tokens.
|
||||
// Long OpenCode sessions can otherwise turn old history into 80+ images: token-cheap
|
||||
// but slow enough that gpt-5.5 times out before first token. When this cap trips,
|
||||
// callers leave the old history as text rather than dropping or de-prioritizing it.
|
||||
const GPT_HISTORY_MAX_IMAGES = 16;
|
||||
|
||||
/** Break-even gate predicate, injected to avoid a circular import with openai.ts.
|
||||
* Receives the full string (not length) so the renderer's row-aware image-count
|
||||
* estimate sees real newlines — history text is newline-heavy. */
|
||||
@@ -73,6 +79,11 @@ export interface GptHistoryOptions {
|
||||
/** Max rendered image height in px (per-model; from the GPT profile). Threaded
|
||||
* into renderTextToPngs so history pages split at the same height the gate prices. */
|
||||
maxHeightPx: number;
|
||||
/** Hard cap on GPT history image count. This is a TRUE cap, not a threshold:
|
||||
* collapse the oldest completed sections until the next section would exceed
|
||||
* the cap, then leave the remaining history as ordinary text. Prevents 80+
|
||||
* image gpt-5.5 requests without dropping context or live tool state. */
|
||||
maxImages: number;
|
||||
/** Reflow the transcript before rendering: pack soft-wrapped lines and mark
|
||||
* every hard newline with the ↵ sentinel — same treatment as the static
|
||||
* slab. History text is newline-heavy (role headers, JSON args), so without
|
||||
@@ -91,6 +102,7 @@ export const GPT_HISTORY_DEFAULTS: GptHistoryOptions = {
|
||||
freezeChunk: 10,
|
||||
sectionTokens: 2000,
|
||||
maxHeightPx: MAX_HEIGHT_PX,
|
||||
maxImages: GPT_HISTORY_MAX_IMAGES,
|
||||
reflow: true,
|
||||
};
|
||||
|
||||
@@ -123,6 +135,7 @@ export interface GptCollapsePlan {
|
||||
| 'no_closed_prefix'
|
||||
| 'below_min_tokens'
|
||||
| 'not_profitable'
|
||||
| 'too_many_images'
|
||||
| 'render_empty';
|
||||
droppedChars: number;
|
||||
droppedCodepoints: Map<number, number>;
|
||||
@@ -266,14 +279,9 @@ export async function planGptCollapse(
|
||||
// cache-unstable partial blob.
|
||||
return { ...base, reason: 'below_min_tokens', collapsedChars: text.length };
|
||||
}
|
||||
const collapseEnd = sections[sections.length - 1]![1];
|
||||
// The collapsed transcript / o200k baseline reflects ONLY what we imaged.
|
||||
const collapsedText = turns
|
||||
.slice(pp, collapseEnd)
|
||||
.map((t) => t.text)
|
||||
.filter((s) => s && s.length > 0)
|
||||
.join('\n\n');
|
||||
const imgs: RenderedImage[] = [];
|
||||
const maxImages = Math.max(0, Math.floor(o.maxImages));
|
||||
let collapseEnd = pp;
|
||||
for (const [s, e] of sections) {
|
||||
const sectionText = turns
|
||||
.slice(s, e)
|
||||
@@ -287,11 +295,24 @@ export async function planGptCollapse(
|
||||
// the static slab. renderTextToPngs caps each PNG at MAX_HEIGHT_PX so a tall
|
||||
// section pages into N images, all still well under the 10,000-patch budget.
|
||||
const sectionImgs = await renderTextToPngs(sectionRender, o.cols, {}, o.maxHeightPx);
|
||||
if (imgs.length + sectionImgs.length > maxImages) {
|
||||
// TRUE cap: keep the sections already selected, leave this and every later
|
||||
// section as normal text in the live remainder. Do NOT collapse nothing.
|
||||
break;
|
||||
}
|
||||
for (const img of sectionImgs) imgs.push(img);
|
||||
collapseEnd = e;
|
||||
}
|
||||
if (imgs.length === 0) {
|
||||
return { ...base, reason: 'render_empty', collapsedChars: collapsedText.length };
|
||||
// First section alone exceeded the cap (or cap <= 0). Fall back to text.
|
||||
return { ...base, reason: 'too_many_images', collapsedChars: text.length };
|
||||
}
|
||||
// The collapsed transcript / o200k baseline reflects ONLY what we imaged.
|
||||
const collapsedText = turns
|
||||
.slice(pp, collapseEnd)
|
||||
.map((t) => t.text)
|
||||
.filter((s) => s && s.length > 0)
|
||||
.join('\n\n');
|
||||
const droppedCodepoints = new Map<number, number>();
|
||||
let droppedChars = 0;
|
||||
for (const img of imgs) {
|
||||
|
||||
@@ -58,6 +58,8 @@ export function resolveVisionCost(model: string): VisionCost {
|
||||
// on the turn-attribution wording — the exact divergence history.ts warns about.
|
||||
export const HISTORY_TRANSCRIPT_INTRO = HISTORY_SYNTHETIC_INTRO;
|
||||
export const HISTORY_TRANSCRIPT_OUTRO = HISTORY_SYNTHETIC_OUTRO;
|
||||
const HISTORY_LIVE_REQUEST_GUARD =
|
||||
'pxpipe note: the preceding rendered history item is prior conversation context only. It is not the current user request. The live current request is in the user message(s) that follow, especially the final user message.';
|
||||
|
||||
export function openAIVisionTokens(model: string, w: number, h: number): number {
|
||||
const c = resolveVisionCost(model);
|
||||
@@ -683,9 +685,14 @@ export async function transformOpenAIChatCompletions(
|
||||
{ type: 'text', text: HISTORY_TRANSCRIPT_OUTRO },
|
||||
],
|
||||
};
|
||||
const guard: OpenAIChatMessage = {
|
||||
role: 'developer',
|
||||
content: HISTORY_LIVE_REQUEST_GUARD,
|
||||
};
|
||||
req.messages = [
|
||||
...req.messages.slice(0, plan.start),
|
||||
synthetic,
|
||||
guard,
|
||||
...req.messages.slice(plan.endExclusive),
|
||||
];
|
||||
info.historyImageSha = await sha8(
|
||||
@@ -897,9 +904,14 @@ export async function transformOpenAIResponses(
|
||||
{ type: 'input_text', text: HISTORY_TRANSCRIPT_OUTRO },
|
||||
],
|
||||
};
|
||||
const guard: ResponsesInputItem = {
|
||||
role: 'developer',
|
||||
content: HISTORY_LIVE_REQUEST_GUARD,
|
||||
};
|
||||
req.input = [
|
||||
...inputItems.slice(0, plan.start),
|
||||
synthetic,
|
||||
guard,
|
||||
...inputItems.slice(plan.endExclusive),
|
||||
];
|
||||
info.historyImageSha = await sha8(
|
||||
|
||||
@@ -570,6 +570,7 @@ export interface TransformInfo {
|
||||
| 'below_min_chars'
|
||||
| 'below_min_tokens'
|
||||
| 'not_profitable'
|
||||
| 'too_many_images'
|
||||
| 'render_empty'
|
||||
| 'collapsed';
|
||||
/** Token count of the pre-compression body from /v1/messages/count_tokens (free).
|
||||
|
||||
+21
-8
@@ -660,9 +660,18 @@ export class DashboardState {
|
||||
// it was cold for BOTH paths. Pricing the text counterfactual warm on a
|
||||
// cr=0 turn invents a discount we never observed and turns genuine token
|
||||
// wins (image < text at the same cold create rate) into phantom losses.
|
||||
// So warmth follows the observed read, not the wall clock alone.
|
||||
const warm =
|
||||
cr > 0 && warmthPrev !== undefined && nowSec - warmthPrev.ts < DashboardState.CACHE_TTL_SEC;
|
||||
// So warmth follows the OBSERVED read ALONE — not our in-memory warmth
|
||||
// map, which a restart/eviction wipes and which can't see a session that
|
||||
// was already warm before this process booted. (A cr>0 turn priced cold
|
||||
// because warmthPrev was missing would bill text the 1.25× create on a
|
||||
// prefix we KNOW was cached — fabricating the inflated "99% saved" row.)
|
||||
// The map only refines the reused-vs-grown split; with no fresh prior we
|
||||
// assume the cached prefix was fully reused (prevCacheable = cacheable →
|
||||
// no fabricated growth) rather than mispricing a known-warm read.
|
||||
const warm = cr > 0;
|
||||
const warmFresh =
|
||||
warmthPrev !== undefined && nowSec - warmthPrev.ts < DashboardState.CACHE_TTL_SEC;
|
||||
const prevCacheable = warm ? (warmFresh ? warmthPrev!.cacheable : cacheable) : 0;
|
||||
baselineInputEff = creditSaving
|
||||
? computeBaselineInputEff(
|
||||
baseline as number,
|
||||
@@ -671,7 +680,7 @@ export class DashboardState {
|
||||
cc,
|
||||
cr,
|
||||
warm,
|
||||
warm ? warmthPrev!.cacheable : 0,
|
||||
prevCacheable,
|
||||
)
|
||||
: actualInputEff;
|
||||
// Record this turn's warmth footprint for the next turn in this session.
|
||||
@@ -957,9 +966,13 @@ export class DashboardState {
|
||||
typeof sidR === 'string' && sidR.length > 0 ? this.baselineWarmth.get(sidR) : undefined;
|
||||
// Same cr-grounded warmth as update() — see the note there. cr>0 ⇒ the
|
||||
// imaged prefix read a warm cache, so the text counterfactual would have
|
||||
// too; cr===0 ⇒ cold for both, price text cold (never warm-vs-cold).
|
||||
const warmR =
|
||||
cr > 0 && warmthPrevR !== undefined && tsSec - warmthPrevR.ts < DashboardState.CACHE_TTL_SEC;
|
||||
// too; cr===0 ⇒ cold for both, price text cold (never warm-vs-cold). The
|
||||
// persisted-ts map only refines the reused-vs-grown split; with no fresh
|
||||
// prior we assume full reuse rather than mispricing a known-warm read.
|
||||
const warmR = cr > 0;
|
||||
const warmFreshR =
|
||||
warmthPrevR !== undefined && tsSec - warmthPrevR.ts < DashboardState.CACHE_TTL_SEC;
|
||||
const prevCacheableR = warmR ? (warmFreshR ? warmthPrevR!.cacheable : cacheable) : 0;
|
||||
baselineInputEff = creditSaving
|
||||
? computeBaselineInputEff(
|
||||
baseline as number,
|
||||
@@ -968,7 +981,7 @@ export class DashboardState {
|
||||
cc,
|
||||
cr,
|
||||
warmR,
|
||||
warmR ? warmthPrevR!.cacheable : 0,
|
||||
prevCacheableR,
|
||||
)
|
||||
: actualInputEff;
|
||||
if (typeof sidR === 'string' && sidR.length > 0 && haveUsage) {
|
||||
|
||||
@@ -128,7 +128,7 @@ export function renderModelsFragment(
|
||||
claudeChips +
|
||||
`<span class="hint">everything else is sent as normal text · runtime only · persist with PXPIPE_MODELS</span>${moot}` +
|
||||
`</div>` +
|
||||
`<div class="models">` +
|
||||
`<div class="models" style="display:none">` +
|
||||
`<span class="models-label">Image GPT models</span>` +
|
||||
gptChips +
|
||||
`<span class="hint">imaging only, no Anthropic cache_control · one scope for all families · set PXPIPE_MODELS (CSV of bases, or off) to persist</span>${moot}` +
|
||||
|
||||
@@ -348,6 +348,7 @@ describe('cr-grounded warmth (cold-miss is priced cold, never a phantom loss)',
|
||||
cache_read_input_tokens: number;
|
||||
},
|
||||
cacheable: number,
|
||||
sid = 'warmsess',
|
||||
): unknown {
|
||||
return {
|
||||
ts: '2026-05-19T00:00:00Z',
|
||||
@@ -359,7 +360,7 @@ describe('cr-grounded warmth (cold-miss is priced cold, never a phantom loss)',
|
||||
usage,
|
||||
info: {
|
||||
compressed: true,
|
||||
firstUserSha8: 'warmsess',
|
||||
firstUserSha8: sid,
|
||||
baselineProbeStatus: 'ok',
|
||||
baselineTokens: 30000, // text counterfactual: full prefix + tail
|
||||
baselineCacheableTokens: cacheable, // prefix up to the cache_control marker
|
||||
@@ -447,4 +448,41 @@ describe('cr-grounded warmth (cold-miss is priced cold, never a phantom loss)',
|
||||
expect(warm.baseline_input).toBe(12500);
|
||||
expect(warm.session_saved_so_far_delta).toBe(7900);
|
||||
});
|
||||
|
||||
it('prices a warm read warm even with NO prior warmth state (post-restart)', async () => {
|
||||
// The cache is already warm on Anthropic's side (cr>0), but this process has
|
||||
// never seen the session — exactly the first turn after a pxpipe restart, a
|
||||
// >5min idle (TTL eviction), or a SESSION_CAP eviction. The OLD code required
|
||||
// an in-memory warmthPrev entry, so it fell through to the COLD branch and
|
||||
// billed the known-cached prefix the 1.25× CREATE rate — fabricating the
|
||||
// inflated "99% saved" row the operator reported. cr>0 is direct proof the
|
||||
// prefix was cached, so it must be priced as a warm READ.
|
||||
dash.update(
|
||||
antEvt(
|
||||
{
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 20000, // warm read on the FIRST turn we see
|
||||
},
|
||||
20000,
|
||||
'restartsess', // never primed in this process
|
||||
) as never,
|
||||
);
|
||||
|
||||
const recent = (await dash.serveRecent().json()) as RecentPayload;
|
||||
const row = recent.recent.at(-1)!;
|
||||
expect(row.cache_read).toBe(20000);
|
||||
|
||||
// actual = 100 + 20000×0.1 = 2100 (we paid the warm read rate).
|
||||
expect(row.actual_input).toBe(2100);
|
||||
|
||||
// Warm baseline with full prefix reuse (no prior ⇒ prevCacheable = cacheable):
|
||||
// 20000×0.1 (reused) + 0 (grown) + 10000 tail = 12000. NOT the cold
|
||||
// 20000×1.25 + 10000 = 35000 the old code produced (which would have shown a
|
||||
// 32900-token / ~94% "saved" against a 2100-token actual — the inflated row).
|
||||
expect(row.baseline_input).toBe(12000);
|
||||
expect(row.baseline_input).not.toBe(35000); // the inflated cold-priced bug value
|
||||
expect(row.session_saved_so_far_delta).toBe(9900);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,6 +409,9 @@ describe('transformOpenAIResponses — history collapse', () => {
|
||||
);
|
||||
});
|
||||
expect(historyItems).toHaveLength(1);
|
||||
const historyIdx = out.input.indexOf(historyItems[0]!);
|
||||
expect((out.input[historyIdx + 1] as { role?: string }).role).toBe('developer');
|
||||
expect(JSON.stringify(out.input[historyIdx + 1])).toContain('live current request');
|
||||
const serialized = JSON.stringify(out.input);
|
||||
expect(serialized).not.toContain(OPENING_PROMPT_MARKER);
|
||||
expect(serialized).toContain(LIVE_PROMPT_MARKER);
|
||||
@@ -446,6 +449,31 @@ describe('transformOpenAIResponses — history collapse', () => {
|
||||
expect(result.info.historyReason).not.toBe('collapsed');
|
||||
expect(result.info.collapsedImages ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('partially collapses GPT history up to the image cap and leaves the rest as text', async () => {
|
||||
const body = enc.encode(JSON.stringify({
|
||||
model: 'gpt-5.6',
|
||||
instructions: BIG_SLAB,
|
||||
input: buildResponsesInput(30),
|
||||
}));
|
||||
const result = await transformOpenAIResponses(body, {
|
||||
charsPerToken: 1,
|
||||
minCompressChars: 1,
|
||||
gptHistory: { collapseChunk: 0, sectionTokens: 100, maxImages: 2 },
|
||||
});
|
||||
expect(result.info.compressed).toBe(true);
|
||||
expect(result.info.historyReason).toBe('collapsed');
|
||||
expect(result.info.collapsedImages ?? 0).toBeGreaterThan(0);
|
||||
expect(result.info.collapsedImages ?? 0).toBeLessThanOrEqual(2);
|
||||
|
||||
const out = JSON.parse(dec.decode(result.body)) as { input: Array<Record<string, unknown>> };
|
||||
const serialized = JSON.stringify(out.input);
|
||||
// Oldest prefix became a bounded history-image item, while later history and
|
||||
// the current prompt remain plain text after the cap.
|
||||
expect(serialized).toContain('attribute every turn strictly by its tag');
|
||||
expect(serialized).toContain('Continue with 28');
|
||||
expect(serialized).toContain(LIVE_PROMPT_MARKER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformOpenAIChatCompletions — history collapse', () => {
|
||||
@@ -469,6 +497,9 @@ describe('transformOpenAIChatCompletions — history collapse', () => {
|
||||
);
|
||||
});
|
||||
expect(historyMsgs).toHaveLength(1);
|
||||
const historyIdx = out.messages.indexOf(historyMsgs[0]!);
|
||||
expect((out.messages[historyIdx + 1] as { role?: string }).role).toBe('developer');
|
||||
expect(JSON.stringify(out.messages[historyIdx + 1])).toContain('live current request');
|
||||
const serialized = JSON.stringify(out.messages);
|
||||
expect(serialized).not.toContain(OPENING_PROMPT_MARKER);
|
||||
expect(serialized).toContain(LIVE_PROMPT_MARKER);
|
||||
|
||||
@@ -112,6 +112,20 @@ describe('planGptCollapse — gates', () => {
|
||||
expect(plan.text.length).toBe(plan.collapsedChars);
|
||||
});
|
||||
|
||||
it('caps GPT history collapse by partially collapsing oldest sections', async () => {
|
||||
const turns = plainTurns(80, 1000);
|
||||
const plan = await planGptCollapse(turns, 0, yes, {
|
||||
collapseChunk: 0,
|
||||
sectionTokens: 100,
|
||||
maxImages: 2,
|
||||
});
|
||||
expect(plan.reason).toBeUndefined();
|
||||
expect(plan.images.length).toBeGreaterThan(0);
|
||||
expect(plan.images.length).toBeLessThanOrEqual(2);
|
||||
expect(plan.collapsedChars).toBeGreaterThan(0);
|
||||
expect(plan.endExclusive).toBeLessThan(80 - GPT_HISTORY_DEFAULTS.keepTail);
|
||||
});
|
||||
|
||||
it('protects the leading prefix (slab-bearing first item)', async () => {
|
||||
const turns = plainTurns(40, 1000);
|
||||
const plan = await planGptCollapse(turns, 3, yes);
|
||||
|
||||
Reference in New Issue
Block a user