mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
47b5e412b3
Collapse old conversation history into rendered PNG sections so the model reads a compact image instead of re-billed text, while preserving prompt caching and tool-call behavior. Measures real vs compressed token/cost. Core: - GPT history collapse (openai-history.ts): append-only, o200k token-length sectioning. Sections seal only at a tool-closed boundary (open call-id set empty), so a function_call and its function_call_output never split across the collapse cut. Fixes the OpenAI 400 "No tool call found for function call output with call_id ..." that hit long Responses-API sessions. - Anthropic cache contract (history.ts): append-only per-chunk rendering; cache_control markers are preserved/moved, never added; chunk boundaries align with caller marker seams for byte-stable prefix caching. - GPT image budget (openai.ts): detail:'original' for gpt-5.x, flagship vision-multiplier fix, patch cap; schema-strip preserves real descriptions. - Savings accounting (openai-savings.ts): cached_tokens + vision-token basis. Model scope (applicability.ts): - Default imaged scope = claude-fable-5 + gpt-5.6. - gpt-5.5 and claude-opus-4-8 stay opt-in: same pipeline, but they degrade reading dense imaged history (gist drift), so silently imaging them by default is wrong. Promotion is gated on an OCR/recall threshold. Dashboard: GPT + Anthropic rendering, per-family model toggles, persisted metrics, thumbnail-expired session UI, reflow/newline handling. Tests: cache-alignment (GPT + Anthropic), history sectioning + tool-boundary invariants, savings, dashboard, sessions/restart-restore. 452 passing.
163 lines
7.7 KiB
TypeScript
163 lines
7.7 KiB
TypeScript
/**
|
|
* GPT (OpenAI Responses / Chat) history-collapse CONTRACT (TDD).
|
|
*
|
|
* The GPT path has NO `cache_control` markers — OpenAI prompt-caching is
|
|
* automatic and prefix-based. So the whole game is byte-stability: keep the
|
|
* collapsed-history image byte-identical as the conversation grows, so the
|
|
* rendered prefix keeps hitting OpenAI's automatic prefix cache.
|
|
*
|
|
* Some of these pin behaviour that already holds; some are EXPECTED-FAIL today
|
|
* and define the work:
|
|
* - APPEND-ONLY: frozen earlier chunks stay byte-identical as turns are added.
|
|
* - TOKEN FLOOR: the min-collapse gate is measured in o200k tokens, not chars.
|
|
*
|
|
* Run just this file: pnpm vitest run tests/gpt-cache-align.test.ts
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { planGptCollapse, GPT_HISTORY_DEFAULTS } from '../src/core/openai-history.js';
|
|
import type { HistoryTurn } from '../src/core/openai-history.js';
|
|
|
|
const yes = () => true;
|
|
const no = () => false;
|
|
|
|
/** N plain user/assistant turns, each `chars` long of body text. */
|
|
function plainTurns(n: number, chars = 1000): HistoryTurn[] {
|
|
return Array.from({ length: n }, (_, i) => ({
|
|
text: `--- ${i % 2 === 0 ? 'user' : 'assistant'} ---\n${'x'.repeat(chars)}`,
|
|
openIds: [],
|
|
closeIds: [],
|
|
opaque: false,
|
|
}));
|
|
}
|
|
|
|
/** Hex of the first image's PNG bytes — for byte-identity assertions. */
|
|
function firstPng(p: { images: { png: Uint8Array }[] }): string | undefined {
|
|
const png = p.images[0]?.png;
|
|
return png ? Buffer.from(png).toString('hex') : undefined;
|
|
}
|
|
function allPng(p: { images: { png: Uint8Array }[] }): string[] {
|
|
return p.images.map((im) => Buffer.from(im.png).toString('hex'));
|
|
}
|
|
|
|
describe('GPT cache contract — invariants that should already hold', () => {
|
|
it('is deterministic — same input twice = identical image bytes', async () => {
|
|
const a = await planGptCollapse(plainTurns(40, 1000), 0, yes, { collapseChunk: 10 });
|
|
const b = await planGptCollapse(plainTurns(40, 1000), 0, yes, { collapseChunk: 10 });
|
|
expect(a.images.length).toBeGreaterThan(0);
|
|
expect(allPng(a)).toEqual(allPng(b));
|
|
});
|
|
|
|
it('snaps the boundary to a chunk grid — text byte-stable within a window', async () => {
|
|
const base = plainTurns(60, 1000);
|
|
const a = await planGptCollapse(base.slice(0, 34), 0, yes, { collapseChunk: 10 });
|
|
const b = await planGptCollapse(base.slice(0, 35), 0, yes, { collapseChunk: 10 });
|
|
expect(a.endExclusive).toBe(b.endExclusive);
|
|
expect(a.text).toBe(b.text);
|
|
});
|
|
|
|
it('emits no cache_control — GPT prefix cache is automatic, markerless', async () => {
|
|
const plan = await planGptCollapse(plainTurns(40, 1000), 0, yes, { collapseChunk: 10 });
|
|
// RenderedImage carries pixels only; the caller (openai.ts) splices a plain
|
|
// synthetic user message with no cache_control. Nothing here adds a marker.
|
|
for (const img of plan.images) {
|
|
expect(img).not.toHaveProperty('cache_control');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('GPT cache contract — token floor (the design change)', () => {
|
|
it('floor gate is measured in tokens (below_min_tokens), not chars', async () => {
|
|
// A prefix far above any char floor — but a huge token floor must still
|
|
// reject it, proving the unit is o200k tokens, not characters.
|
|
const turns = plainTurns(40, 1000); // ~40k chars
|
|
const plan = await planGptCollapse(turns, 0, yes, {
|
|
collapseChunk: 10,
|
|
minCollapseTokens: 10_000_000,
|
|
});
|
|
expect(plan.images).toHaveLength(0);
|
|
expect(plan.reason).toBe('below_min_tokens');
|
|
});
|
|
|
|
it('collapses once the token count clears the floor', async () => {
|
|
const turns = plainTurns(40, 1000);
|
|
const plan = await planGptCollapse(turns, 0, yes, {
|
|
collapseChunk: 10,
|
|
minCollapseTokens: 0,
|
|
});
|
|
expect(plan.images.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('GPT cache contract — APPEND-ONLY (EXPECTED FAIL today)', () => {
|
|
it('earlier history image stays byte-identical as the conversation grows past a chunk', async () => {
|
|
// a collapses [0..20); b collapses [0..40) — both pp=0, chunk=10.
|
|
// The first frozen chunk [0..10) must render the SAME bytes in both, or the
|
|
// grown prefix busts OpenAI's automatic prefix cache every turn.
|
|
const base = plainTurns(60, 1000);
|
|
const a = await planGptCollapse(base.slice(0, 28), 0, yes, { collapseChunk: 10 });
|
|
const b = await planGptCollapse(base.slice(0, 48), 0, yes, { collapseChunk: 10 });
|
|
expect(a.images.length).toBeGreaterThan(0);
|
|
expect(b.images.length).toBeGreaterThan(0);
|
|
// b collapses strictly more turns than a (crossed a chunk boundary).
|
|
expect(b.endExclusive).toBeGreaterThan(a.endExclusive);
|
|
// …but the FIRST frozen page must be byte-identical.
|
|
expect(firstPng(b)).toBe(firstPng(a));
|
|
});
|
|
|
|
it('every image of the smaller collapse is a byte-identical prefix of the larger', async () => {
|
|
const base = plainTurns(60, 1000);
|
|
const a = await planGptCollapse(base.slice(0, 28), 0, yes, { collapseChunk: 10 });
|
|
const b = await planGptCollapse(base.slice(0, 48), 0, yes, { collapseChunk: 10 });
|
|
const aPng = allPng(a);
|
|
const bPng = allPng(b);
|
|
// Append-only: a's frozen chunk images are a prefix of b's image list.
|
|
expect(bPng.slice(0, aPng.length)).toEqual(aPng);
|
|
});
|
|
});
|
|
|
|
describe('GPT cache contract — TOKEN-LENGTH sectioning (the design)', () => {
|
|
// Sections are sealed at deterministic cumulative-o200k-token boundaries from
|
|
// protectedPrefix. A sealed section's bytes depend only on its turn range, so
|
|
// it stays byte-identical (prefix-cache hit) as the conversation grows. Leftover
|
|
// tail turns that don't fill a whole section are NOT collapsed (stay live text).
|
|
|
|
it('a smaller section target produces more (smaller) image sections', async () => {
|
|
const turns = plainTurns(40, 1000); // 128 o200k tokens/turn (~30 collapsible after keepTail/snap)
|
|
const big = await planGptCollapse(turns, 0, yes, { sectionTokens: 1500 });
|
|
const small = await planGptCollapse(turns, 0, yes, { sectionTokens: 600 });
|
|
expect(big.images.length).toBeGreaterThan(0);
|
|
expect(small.images.length).toBeGreaterThan(big.images.length);
|
|
});
|
|
|
|
it('seals sections by TOKEN target, not turn count: collapsedTurns ≈ sectionTokens/perTurn', async () => {
|
|
// 128 tok/turn, sectionTokens=2000 → ~16 turns seal one section. Collapse end
|
|
// lands on a section boundary (multiple of ~16), never mid-section.
|
|
const turns = plainTurns(60, 1000);
|
|
const plan = await planGptCollapse(turns, 0, yes, { sectionTokens: 2000 });
|
|
expect(plan.collapsedTurns).toBeGreaterThanOrEqual(16);
|
|
// collapsedTurns is a whole number of sealed sections (~16 turns each).
|
|
expect(plan.collapsedTurns % 16).toBe(0);
|
|
});
|
|
|
|
it('every emitted image stays within the gpt-5.x patch budget (≤6000px, ≤10000 patches)', async () => {
|
|
const plan = await planGptCollapse(plainTurns(80, 1500), 0, yes, { sectionTokens: 2000 });
|
|
expect(plan.images.length).toBeGreaterThan(0);
|
|
for (const im of plan.images) {
|
|
expect(im.width).toBeLessThanOrEqual(6000);
|
|
expect(im.height).toBeLessThanOrEqual(6000);
|
|
const patches = Math.ceil(im.width / 32) * Math.ceil(im.height / 32);
|
|
expect(patches).toBeLessThanOrEqual(10000);
|
|
}
|
|
});
|
|
|
|
it('sealed sections are append-only byte-stable across growth (token-grid)', async () => {
|
|
const base = plainTurns(90, 1000);
|
|
const a = await planGptCollapse(base.slice(0, 40), 0, yes, { sectionTokens: 1500 });
|
|
const b = await planGptCollapse(base.slice(0, 80), 0, yes, { sectionTokens: 1500 });
|
|
expect(a.images.length).toBeGreaterThan(0);
|
|
expect(b.endExclusive).toBeGreaterThan(a.endExclusive);
|
|
// Every image a emitted (all sealed sections) is a byte-identical prefix of b.
|
|
expect(allPng(b).slice(0, allPng(a).length)).toEqual(allPng(a));
|
|
});
|
|
});
|