Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions scripts/restart.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
44 changes: 43 additions & 1 deletion src/core/claude-model-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 64 additions & 2 deletions src/core/openai-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
}

export interface GptCollapsePlan {
Expand Down Expand Up @@ -582,9 +585,18 @@ function responseMessageText(item: unknown): ResponsesMessageText | null {
for (const part of o.content) {
const p = part as Record<string, unknown> | 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');
Expand All @@ -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<string, unknown> | 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<string, unknown> | 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</${msg.role}>` : null;
Expand Down Expand Up @@ -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<string, number>();
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 = [];
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -911,6 +972,7 @@ async function planResponsesMixedCollapse(
}
return {
...base,
barrierTypes,
segments,
images,
imageSources,
Expand Down
8 changes: 8 additions & 0 deletions src/core/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/core/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
89 changes: 89 additions & 0 deletions tests/responses-barrier-fill.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<string, unknown>;
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);
});
});
Loading