diff --git a/scripts/restart.sh b/scripts/restart.sh index 0e12356e..63420d36 100755 --- a/scripts/restart.sh +++ b/scripts/restart.sh @@ -31,14 +31,18 @@ cd "$(dirname "$0")/.." # --- Parse our own flags out of "$@". --no-build only — pxpipe takes none. ---- DO_BUILD=1 +DETACH=0 for arg in "$@"; do case "$arg" in --no-build) DO_BUILD=0 ;; + --detach) + DETACH=1 + ;; *) echo "[restart] unknown argument: $arg" >&2 - echo "[restart] this script only accepts --no-build (pxpipe takes no flags)" >&2 + echo "[restart] this script only accepts --no-build/--detach (pxpipe takes no flags)" >&2 exit 2 ;; esac @@ -85,13 +89,13 @@ if [ -n "$PIDS_RAW" ]; then # Poll up to 5s for graceful exit. for _ in $(seq 1 50); do - STILL=$(pgrep -f 'node.*bin/[c]li\.js' 2>/dev/null || true) + STILL=$(list_serving_pids || true) [ -z "$STILL" ] && break sleep 0.1 done # --- 3. Escalate to SIGKILL only if still alive --- - STILL=$(pgrep -f 'node.*bin/[c]li\.js' 2>/dev/null || true) + STILL=$(list_serving_pids || true) if [ -n "$STILL" ]; then echo "[restart] WARNING: PID(s) still alive after 5s, escalating to SIGKILL: $STILL" for pid in $STILL; do @@ -131,5 +135,31 @@ if command -v lsof >/dev/null 2>&1; then fi # --- 6. Start fresh in the foreground. exec so Ctrl-C goes straight to Node. +if [ "$DETACH" -eq 1 ]; then + # Survive the calling shell: non-interactive callers (CI, agent tool calls, + # `ssh host cmd`) get SIGHUP'd on exit, which would take the proxy with them. + LOG="${TMPDIR:-/tmp}/pxpipe-proxy.log" + echo "[restart] starting detached proxy on :$TARGET_PORT (log: $LOG)" + nohup node bin/cli.js >>"$LOG" 2>&1 & + NEW_PID=$! + disown "$NEW_PID" 2>/dev/null || true + # Confirm it actually bound the port instead of dying on startup. + for _ in $(seq 1 100); do + if ! kill -0 "$NEW_PID" 2>/dev/null; then + echo "[restart] ERROR: proxy exited during startup. Last log lines:" >&2 + tail -20 "$LOG" >&2 + exit 1 + fi + if lsof -nP -iTCP:"$TARGET_PORT" -sTCP:LISTEN -t 2>/dev/null | grep -qx "$NEW_PID"; then + echo "[restart] proxy PID $NEW_PID listening on :$TARGET_PORT" + exit 0 + fi + sleep 0.1 + done + echo "[restart] ERROR: PID $NEW_PID never bound :$TARGET_PORT within 10s" >&2 + tail -20 "$LOG" >&2 + exit 1 +fi + echo "[restart] starting fresh proxy on :$TARGET_PORT (Ctrl-C to stop)" exec node bin/cli.js diff --git a/src/core/claude-model-profiles.ts b/src/core/claude-model-profiles.ts index 3743ba6f..363220a9 100644 --- a/src/core/claude-model-profiles.ts +++ b/src/core/claude-model-profiles.ts @@ -27,7 +27,49 @@ export const CLAUDE_PROFILE: GptModelProfile = { maxHeightPx: ANTHROPIC_MAX_HEIGHT_PX, visionTier: 'high-res', factSheetFormat: 'full', - history: BASE_HISTORY, + // BASE_HISTORY.maxImages is a page count, and a page is not a fixed amount of + // text: at GPT geometry (84 cols x 1954 px) a page holds ~660 chars, at + // Anthropic geometry (312 cols x 728 px) it holds ~2750. The shared 32 was + // tuned as a latency budget against the former, so on Claude it stopped being + // a latency budget and became a coverage limit: live Opus traffic pinned at + // 32 images on 70% of requests with collapsed text flatlined at ~97k chars + // while untouched history kept growing past 250k. + // + // Re-derived against the same latency signal on Claude pages (first-byte p50 + // / p95, n=11k): 32 -> 5.2s/10.9s, 64 -> 8.3s/15.3s, 96 -> 11.4s/18.5s, + // 128 -> 12.5s/27.0s. Upstream 502s stay ~0.2% through 96 images and jump to + // 2.2-3.9% at 112+. 96 is the last point that is both cheap and clean. + // + // The same page-count-vs-geometry mismatch applies to the per-image framing. + // BASE_HISTORY pairs `framing: 'full'` with `factSheetScope: 'per-segment'`, + // which costs a 221-token intro + 25-token outro + a ~158-token fact sheet on + // EVERY segment. At GPT's 32-page ceiling that is a rounding error; at 96 + // Claude pages it measured 425 tokens/image, 25.2k tokens/request, eating 57% + // of the gross saving the higher cap unlocked. Compact framing (36 + 8) with a + // single combined sheet carries the same attribution wording the transcript + // needs and projects 2.8k tokens/request on the same traffic. + // + // responsesMode: BASE_HISTORY's 'pairs' planner only groups tool rounds that + // are INDEX-CONTIGUOUS. Codex emits an assistant message between rounds, so + // every round lands in its own run and every run renders its own image. Live + // Claude-on-Responses traffic averaged 2,921 chars/image (10% of the 28,080 + // a page holds) at 2.4 turns/image, against 10,456 (37%) for gpt-5.6-sol on + // the same endpoint — the only difference being that GPT already runs + // 'mixed'. Replaying a 60-round transcript through both planners isolates it + // to the interleaving, not the volume: + // pairs, no interleave -> 1 segment, 6 images, 27,653 chars/image + // pairs, interleaved -> 54 segments, 54 images, 3,073 chars/image + // mixed, interleaved -> 2 segments, 8 images, 21,109 chars/image + // 'mixed' treats safe textual messages as groupable instead of as barriers; + // every non-message item stays a hard barrier, so protocol order and open + // call/output state are preserved exactly as in 'pairs'. + history: { + ...BASE_HISTORY, + maxImages: 96, + framing: 'compact', + factSheetScope: 'combined', + responsesMode: 'mixed', + }, style: { ...BASE_STYLE }, // No maxSerializedRequestBytes: no Anthropic request-size limit has ever been // sourced. The 768 KiB entry that used to sit here was a guess, and live diff --git a/src/core/openai-history.ts b/src/core/openai-history.ts index 7609c390..0dc390f8 100644 --- a/src/core/openai-history.ts +++ b/src/core/openai-history.ts @@ -178,6 +178,9 @@ export interface ResponsesPairCollapsePlan extends GptCollapsePlan { segments: ResponsesPairCollapseSegment[]; selectedIndices: number[]; pairState: ResponsesPairState; + /** Item `type` values that ended a collapse run, with counts. Each barrier + * forces a page break, so this names what is under-filling images. */ + barrierTypes?: Map; } export interface GptCollapsePlan { @@ -582,9 +585,18 @@ function responseMessageText(item: unknown): ResponsesMessageText | null { for (const part of o.content) { const p = part as Record | null; const type = p && typeof p.type === 'string' ? p.type : ''; - if (!p || !['input_text', 'output_text', 'text'].includes(type) || typeof p.text !== 'string') { - return null; + if (!p) return null; + // A refusal is plain model-authored prose that happens to ride in its own + // part type. Rejecting it made the whole message non-imageable, and since + // a non-imageable message is a hard barrier, ONE refusal split the run and + // cost a page break. Same for any future part that carries a string + // `text`: the content is losslessly renderable, so gate on the payload + // being text rather than on an allow-list of part names. + if (type === 'refusal' && typeof p.refusal === 'string') { + parts.push(p.refusal); + continue; } + if (typeof p.text !== 'string') return null; parts.push(p.text); } body = parts.join('\n\n'); @@ -595,6 +607,33 @@ function responseMessageText(item: unknown): ResponsesMessageText | null { return { role: o.role, text: body }; } +/** True for a `message` item that carries no renderable text at all: empty + * content array, or parts whose text is present but blank. Such an item is + * inert — imaging it would produce nothing and skipping it changes no + * ordering — so it must not end a collapse run. Anything with non-text parts + * (images, unknown shapes) is NOT contentless and stays a barrier. */ +function isContentlessMessage(item: unknown): boolean { + const o = item as Record | null; + if (!o || (o.role !== 'user' && o.role !== 'assistant')) return false; + const itemType = typeof o.type === 'string' ? o.type : ''; + if (itemType && itemType !== 'message') return false; + if (typeof o.content === 'string') return !o.content.trim(); + if (!Array.isArray(o.content)) return false; + for (const part of o.content) { + const p = part as Record | null; + const type = p && typeof p.type === 'string' ? p.type : ''; + if (!p) return false; + if (type === 'refusal' && typeof p.refusal === 'string') { + if (p.refusal.trim()) return false; + continue; + } + // A non-text part means real payload we cannot render — not contentless. + if (typeof p.text !== 'string') return false; + if (p.text.trim()) return false; + } + return true; +} + function responseMessageTranscript(item: unknown, index: number): string | null { const msg = responseMessageText(item); return msg ? `<${msg.role} t="${index}">\n${msg.text}\n` : null; @@ -795,6 +834,16 @@ async function planResponsesMixedCollapse( const runs: ResponsesMixedUnit[][] = []; let current: ResponsesMixedUnit[] = []; + // Every flush ends a run, and every run renders at least one image. Naming + // the item type that caused it is the only way to tell an unavoidable + // barrier (protected tail, item_reference) from an incidental one that is + // silently costing a page break per occurrence. + const barrierTypes = new Map(); + const noteBarrier = (index: number): void => { + if (current.length === 0) return; // nothing to split — not a real break + const t = responseItemType(items[index]) || 'untyped'; + barrierTypes.set(t, (barrierTypes.get(t) ?? 0) + 1); + }; const flush = (): void => { if (current.length > 0) runs.push(current); current = []; @@ -825,6 +874,17 @@ async function planResponsesMixedCollapse( }); continue; } + // A contentless message (empty content array, whitespace-only text) carries + // nothing to image and nothing to reorder, but treating it as a barrier + // still split the run and cost a page break. Leave it native in place and + // keep the run open: `selectedIndices` never includes it, so the splice in + // openai.ts re-emits it at its original position and protocol order is + // unchanged. Only applies when the item is genuinely a message with no + // renderable payload — anything unrecognized remains a hard barrier. + if (!protectedMessages.has(i) && !referenced && isContentlessMessage(items[i])) { + continue; + } + noteBarrier(i); flush(); } flush(); @@ -889,6 +949,7 @@ async function planResponsesMixedCollapse( ...base, reason: hitImageCap ? 'too_many_images' : 'not_profitable', collapsedChars: allText.length, + barrierTypes, }; } const selectedIndices = segments.flatMap((segment) => segment.selectedIndices).sort((a, b) => a - b); @@ -911,6 +972,7 @@ async function planResponsesMixedCollapse( } return { ...base, + barrierTypes, segments, images, imageSources, diff --git a/src/core/openai.ts b/src/core/openai.ts index cc475ec3..378c92fb 100644 --- a/src/core/openai.ts +++ b/src/core/openai.ts @@ -867,6 +867,14 @@ async function applyResponsesHistoryCollapse( rc.collapsedFunctionPairs = ps.collapsedPairs; rc.collapsedFunctionCalls = ps.collapsedFunctionCallTokens; rc.collapsedFunctionOutputs = ps.collapsedFunctionOutputTokens; + // Descending count so the dominant page-breaker is first. Bounded to 8 so a + // pathological body cannot bloat the event row. + if (plan.barrierTypes && plan.barrierTypes.size > 0) { + rc.barrierTypes = [...plan.barrierTypes.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 8) + .map(([type, count]) => `${type}:${count}`); + } foldGptHistory(info, req.model, plan); if (plan.segments.length === 0) return false; diff --git a/src/core/transform.ts b/src/core/transform.ts index efffe563..3b0d63b3 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -600,6 +600,11 @@ export interface TransformInfo { collapsedFunctionPairs?: number; collapsedFunctionCalls?: number; collapsedFunctionOutputs?: number; + /** Item `type` values that acted as a hard barrier in the Responses + * planner, with occurrence counts (`local_shell_call:12`). Every barrier + * forces a page break, so a frequent type here is directly responsible + * for under-filled images. Diagnostic only — never affects routing. */ + barrierTypes?: string[]; }; /** Length of the static (cacheable) slab rendered into the image. */ staticChars: number; diff --git a/tests/responses-barrier-fill.test.ts b/tests/responses-barrier-fill.test.ts new file mode 100644 index 00000000..c6f1cf99 --- /dev/null +++ b/tests/responses-barrier-fill.test.ts @@ -0,0 +1,89 @@ +/** + * Responses planner PAGE-FILL contract. + * + * Every run the mixed planner emits renders at least one image, so any item + * that ends a run costs a page break. Live Codex traffic showed 174.6 `message` + * barriers per request and images filled to ~10% of the 28,080 chars a page + * holds. Two message shapes were responsible, and both are losslessly + * renderable or inert — neither is a reason to split a run. + * + * These pin the fix and, critically, the ordering invariant that makes it safe: + * an item the planner skips must still be re-emitted at its original position. + */ +import { describe, expect, it } from 'vitest'; +import { planResponsesPairCollapse } from '../src/core/openai-history.js'; + +const yes = () => true; +const PAGE_CHARS = 28080; + +function transcript(rounds: number, between: unknown): unknown[] { + const items: unknown[] = [ + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'go' }] }, + ]; + for (let i = 0; i < rounds; i++) { + items.push(JSON.parse(JSON.stringify(between))); + items.push({ type: 'function_call', call_id: 'c' + i, name: 'sh', arguments: '{}' }); + items.push({ type: 'function_call_output', call_id: 'c' + i, output: 'x'.repeat(3000) }); + } + items.push({ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'now' }] }); + return items; +} + +const opts = { + cols: 312, maxHeightPx: 728, maxImages: 96, keepTail: 6, + keepRecentPairs: 6, minCollapseTokens: 2000, responsesMode: 'mixed' as const, +}; + +async function fill(between: unknown): Promise { + const plan = await planResponsesPairCollapse(transcript(40, between), yes, opts); + const images = plan.segments.reduce((n, s) => n + s.images.length, 0); + return images ? plan.collapsedChars / images : 0; +} + +describe('interleaved messages must not fragment collapse runs', () => { + it('packs pages when rounds are separated by a refusal part', async () => { + // Model-authored prose in its own part type — renderable, so groupable. + const f = await fill({ type: 'message', role: 'assistant', content: [{ type: 'refusal', refusal: 'no' }] }); + expect(f).toBeGreaterThan(PAGE_CHARS * 0.5); + }); + + it('packs pages when rounds are separated by a contentless message', async () => { + // Nothing to image and nothing to reorder — must not end a run. + const f = await fill({ type: 'message', role: 'assistant', content: [] }); + expect(f).toBeGreaterThan(PAGE_CHARS * 0.5); + }); + + it('packs pages when rounds are separated by whitespace-only text', async () => { + const f = await fill({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: ' ' }] }); + expect(f).toBeGreaterThan(PAGE_CHARS * 0.5); + }); + + it('still treats a message carrying a non-text part as a hard barrier', async () => { + // An image part is real payload the planner cannot render. Fragmenting is + // the correct, conservative outcome — this is the guard that keeps the + // relaxation above from swallowing content. + const withImage = { + type: 'message', role: 'user', + content: [{ type: 'input_image', image_url: 'data:image/png;base64,AAAA' }], + }; + const plan = await planResponsesPairCollapse(transcript(40, withImage), yes, opts); + const barriers = plan.barrierTypes?.get('message') ?? 0; + expect(barriers).toBeGreaterThan(0); + }); + + it('never selects a skipped contentless item, so order is preserved', async () => { + // The splice in openai.ts re-emits every index absent from selectedIndices. + // If a skipped item were ever selected it would be silently deleted. + const items = transcript(40, { type: 'message', role: 'assistant', content: [] }); + const plan = await planResponsesPairCollapse(items, yes, opts); + const contentless = items + .map((it, i) => [it, i] as const) + .filter(([it]) => { + const o = it as Record; + return o.type === 'message' && Array.isArray(o.content) && o.content.length === 0; + }) + .map(([, i]) => i); + expect(contentless.length).toBeGreaterThan(0); + for (const i of contentless) expect(plan.selectedIndices).not.toContain(i); + }); +});