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.
155 lines
6.7 KiB
TypeScript
155 lines
6.7 KiB
TypeScript
/**
|
|
* Anthropic cache-control CONTRACT (TDD).
|
|
*
|
|
* Encodes the invariants we agreed on for the Anthropic /v1/messages path.
|
|
* Some of these pin behaviour that already holds; some are EXPECTED-FAIL today
|
|
* and define the work (append-only byte-stability + mark alignment).
|
|
*
|
|
* Run just this file: pnpm vitest run tests/anthropic-cache-align.test.ts
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { collapseHistory } from '../src/core/history.js';
|
|
import { transformRequest } from '../src/core/transform.js';
|
|
import { countCacheControlMarkers } from '../src/core/measurement.js';
|
|
import type { Message } from '../src/core/types.js';
|
|
|
|
const yes = () => true;
|
|
const big = (n: number) => 'x'.repeat(n);
|
|
|
|
function asst(content: Message['content']): Message {
|
|
return { role: 'assistant', content };
|
|
}
|
|
function usr(content: Message['content']): Message {
|
|
return { role: 'user', content };
|
|
}
|
|
function enc(obj: unknown): Uint8Array {
|
|
return new TextEncoder().encode(JSON.stringify(obj));
|
|
}
|
|
function dec(b: Uint8Array): any {
|
|
return JSON.parse(new TextDecoder().decode(b));
|
|
}
|
|
function imagesOf(msgs: Message[]): any[] {
|
|
// the synthetic history message holds the image blocks
|
|
const out: any[] = [];
|
|
for (const m of msgs) {
|
|
if (Array.isArray(m.content)) {
|
|
for (const b of m.content as any[]) if (b?.type === 'image') out.push(b);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** N closed plain turns, each `chars` long. */
|
|
function convo(n: number, chars = 3500): Message[] {
|
|
const out: Message[] = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const body = `turn ${i}: ` + big(chars);
|
|
out.push(i % 2 === 0 ? usr(body) : asst(body));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe('Anthropic cache contract — invariants that should already hold', () => {
|
|
it('never adds a cache_control marker (out count <= in count)', async () => {
|
|
const msgs = convo(15);
|
|
const body = enc({
|
|
model: 'claude-3-5-sonnet',
|
|
system: [{ type: 'text', text: big(80_000), cache_control: { type: 'ephemeral' } }],
|
|
messages: msgs,
|
|
});
|
|
const inMarks = countCacheControlMarkers(body);
|
|
const { body: out } = await transformRequest(body);
|
|
const outMarks = countCacheControlMarkers(out);
|
|
expect(outMarks).toBeLessThanOrEqual(inMarks);
|
|
});
|
|
|
|
it('relocates the single slab marker onto an IMAGE block (not lost, not duplicated)', async () => {
|
|
const msgs = convo(15);
|
|
const body = enc({
|
|
model: 'claude-3-5-sonnet',
|
|
system: [{ type: 'text', text: big(80_000), cache_control: { type: 'ephemeral' } }],
|
|
messages: msgs,
|
|
});
|
|
const { body: out } = await transformRequest(body);
|
|
expect(countCacheControlMarkers(out)).toBe(1); // exactly one, conserved
|
|
});
|
|
|
|
it('keeps the last 4 turns as live text (keepTail)', async () => {
|
|
const msgs = convo(15);
|
|
const { messages } = await collapseHistory(msgs, yes, { protectedPrefix: 1 });
|
|
// tail = last 4 original turns, preserved verbatim at the end
|
|
const tail = messages.slice(-4);
|
|
expect(tail.map((m) => m.content)).toEqual(msgs.slice(-4).map((m) => m.content));
|
|
});
|
|
|
|
it('never splits a tool_use from its tool_result across the image boundary', async () => {
|
|
// Put an OPEN tool_use right at the would-be boundary; closed prefix must
|
|
// pull back so the pair is never separated.
|
|
const msgs: Message[] = [];
|
|
for (let i = 0; i < 11; i++) msgs.push(i % 2 ? asst(big(3500)) : usr(big(3500)));
|
|
msgs.push(asst([{ type: 'tool_use', id: 'OPEN', name: 't', input: {} } as any]));
|
|
msgs.push(usr('still flowing, no tool_result yet'));
|
|
const { info } = await collapseHistory(msgs, yes, { protectedPrefix: 1, keepTail: 0, collapseChunk: 0 });
|
|
// boundary stops before the open tool_use → it stays in the live tail
|
|
expect(info.collapsedTurns).toBeLessThanOrEqual(11);
|
|
});
|
|
|
|
it('is deterministic — same input twice = identical image bytes', async () => {
|
|
const a = await collapseHistory(convo(20), yes, { protectedPrefix: 1 });
|
|
const b = await collapseHistory(convo(20), yes, { protectedPrefix: 1 });
|
|
expect(JSON.stringify(imagesOf(a.messages))).toBe(JSON.stringify(imagesOf(b.messages)));
|
|
});
|
|
});
|
|
|
|
describe('Anthropic cache contract — our agreed model (EXPECTED FAIL today)', () => {
|
|
it('APPEND-ONLY: earlier history image stays byte-identical as the conversation grows past a chunk boundary', async () => {
|
|
// Conversation P, then P + more turns that advance the collapse boundary.
|
|
// The frozen earlier chunk's FIRST image must be byte-identical in both —
|
|
// otherwise old cached prefix re-writes every time the boundary moves.
|
|
const p = await collapseHistory(convo(20), yes, { protectedPrefix: 1 });
|
|
const pPlus = await collapseHistory(convo(70), yes, { protectedPrefix: 1 });
|
|
const firstP = imagesOf(p.messages)[0];
|
|
const firstPlus = imagesOf(pPlus.messages)[0];
|
|
expect(firstP).toBeDefined();
|
|
expect(firstPlus).toBeDefined();
|
|
expect(JSON.stringify(firstPlus)).toBe(JSON.stringify(firstP));
|
|
});
|
|
|
|
it('ALIGN: a caller cache_control marker placed mid-history is preserved (image boundary aligns to the mark)', async () => {
|
|
const msgs = convo(15);
|
|
// caller marks the END of an early segment (index 6) — a roaming breakpoint
|
|
(msgs[6] as any).content = [
|
|
{ type: 'text', text: (msgs[6].content as string), cache_control: { type: 'ephemeral' } },
|
|
];
|
|
const body = enc({
|
|
model: 'claude-3-5-sonnet',
|
|
system: [{ type: 'text', text: big(80_000), cache_control: { type: 'ephemeral' } }],
|
|
messages: msgs,
|
|
});
|
|
const inMarks = countCacheControlMarkers(body); // 2: slab + mid-history
|
|
const { body: out } = await transformRequest(body);
|
|
// Contract: the mid-history mark is not silently dropped — both segments
|
|
// remain independently cacheable, so the count is conserved (== 2), and the
|
|
// image set has a boundary at that mark.
|
|
expect(countCacheControlMarkers(out)).toBe(inMarks);
|
|
});
|
|
});
|
|
|
|
describe('Anthropic cache contract — gate never produces negative savings', () => {
|
|
it('tiny prefix is not profitable (no image emitted)', async () => {
|
|
const msgs = convo(15, 30); // 30 chars/turn → trivially below the gate
|
|
const { info } = await collapseHistory(msgs, (t) => t.length > 8000, { protectedPrefix: 1 });
|
|
expect(info.reason).toBe('not_profitable');
|
|
expect(info.collapsedImages ?? 0).toBe(0);
|
|
});
|
|
|
|
it('large prefix is profitable (image emitted)', async () => {
|
|
const msgs = convo(15, 3500);
|
|
const { info } = await collapseHistory(msgs, (t) => t.length > 8000, { protectedPrefix: 1 });
|
|
// collapseHistory leaves `reason` undefined on success (the 'collapsed'
|
|
// sentinel is applied later in transform.ts). Success = images emitted.
|
|
expect(info.reason).toBeUndefined();
|
|
expect(info.collapsedImages ?? 0).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|