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
11 changes: 8 additions & 3 deletions src/codex-compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,18 @@ export function codexCompactMode(): CodexCompactMode {
}

// The `originator` header is only sent for non-default thread originators, so
// the UA prefix (DEFAULT_ORIGINATOR in codex's default_client.rs) is the
// reliable client signal.
// the UA (DEFAULT_ORIGINATOR in codex's default_client.rs) is the reliable
// client signal. Codex ships multiple clients with different UA prefixes
// (codex_cli_rs/, codex_exec/, codex_sdk_ts/, ...), so in addition to the known
// prefixes match "codex" anywhere in the UA (case-sensitive) — a new client
// variant must not silently fall out of detection (#645).
export function isCodexClient(headers: Record<string, string | string[] | undefined>): boolean {
const ua = headers["user-agent"];
if (!ua) return false;
const s = Array.isArray(ua) ? ua[0] : ua;
return typeof s === "string" && CODEX_UA_PREFIXES.some((p) => s.startsWith(p));
if (typeof s !== "string") return false;
if (CODEX_UA_PREFIXES.some((p) => s.startsWith(p))) return true;
return s.includes("codex");
}

export function hasCompactionTrigger(input: unknown): boolean {
Expand Down
36 changes: 22 additions & 14 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1830,24 +1830,31 @@ function diagNudge(turn: { nudge?: { shouldInject: boolean; reason: string; cont
return `[${sessionId}] nudge ${inject}: usage=${pct} (${tokenCount}/${limit}), growth=${growth}/${floor} (ref=${ref}, interval=${interval}), pendingT1=${pendingT1}/${interval}${modelTag}, reason="${n.reason.slice(0, 120)}"`;
}

// #408/#590/#623/#648: hosts that get the #408 uncompressed-baseline usage
// #408/#590/#623/#645/#648: hosts that get the #408 uncompressed-baseline usage
// backfill. pi's AND omp's bili extensions cancel the host's NATIVE compaction
// so ACP owns compression — pi cancels auto-compaction, omp cancels ALL
// compaction (its session_before_compact event carries no reason field, so
// manual /compact can't be preserved). With the host's compaction off, the
// uncompressed baseline drives nothing on the host side; reporting it only puts
// a >100% footer that mismatches the folded request actually forwarded
// (#590 pi 302.7%, #623 omp 205%). Gate on pluginAgent so ONLY the
// bili-launched extensions are exempted: plain proxy clients and codex
// native-compact interception keep the #408 behavior (their native compaction
// stays live and consumes the baseline). The `hostUsageCredit` config option
// (#648) additionally lets a plain proxy client opt out entirely ("off") —
// ZCode and similar plain anthropic clients otherwise show the cumulative,
// drifting baseline as inflated context in their UI.
// manual /compact can't be preserved). Codex is exempted the same way (#645):
// the backfilled baseline is a virtual number the model never receives — it
// drifts turn-to-turn (real post-fold usage + a character-based estimate of
// the folded-out tokens, so the metric can decrement with no compress), it
// exceeds the window (user saw 1315/950k = 138%), and it drives nothing in
// codex: codex's auto-compact keys off total_tokens, which the backfill never
// touches. Detected by UA (same signal as native-compact interception) because
// bili-launched codex sessions carry pluginAgent "mcp" (shared with claude).
// With the host's compaction off (pi/omp) or the backfill inert (codex),
// reporting the baseline only puts a >100% footer that mismatches the folded
// request actually forwarded (#590 pi 302.7%, #623 omp 205%, #645 codex 138%).
// Plain proxy clients keep the #408 behavior by default (their native
// compaction stays live and consumes the baseline); the `hostUsageCredit`
// config option (#648) additionally lets such a client opt out entirely
// ("off") — ZCode and similar plain anthropic clients otherwise show the
// cumulative, drifting baseline as inflated context in their UI.
function armHostUsageCredit(
session: Session,
originalMessages: CoreMessage[],
processedMessages: CoreMessage[],
headers: http.IncomingHttpHeaders,
hostUsageCredit: "auto" | "off",
log: (level: string, msg: string) => void,
): void {
Expand All @@ -1858,6 +1865,7 @@ function armHostUsageCredit(
// drifting baseline that overstates real context pressure.
if (hostUsageCredit === "off") return;
if (session.metadata.pluginAgent === "pi" || session.metadata.pluginAgent === "omp") return;
if (isCodexClient(headers)) return;
// #408: tokens folded out of the forwarded view vs the host's own (unfolded)
// view — added back into the usage reported to the host so its anchor
// reflects the uncompressed baseline. Same estimator both sides, so
Expand Down Expand Up @@ -2007,7 +2015,7 @@ function prepareAnthropic(
// identity chain (#268), not part of the Anthropic Messages API — strip it
// so the real upstream never sees a field it doesn't know.
delete (rebuilt as Record<string, unknown>).prompt_cache_key;
armHostUsageCredit(session, originalMessages, processedMessages, opts.hostUsageCredit, log);
armHostUsageCredit(session, originalMessages, processedMessages, req.headers, opts.hostUsageCredit, log);
return { body: JSON.stringify(rebuilt), session, processedMessages, originalMessages, anthropicSystem: parsed.system, protocol: "anthropic", stream, compressInjected: injectTools, pluginMode, nudge, prompts, renderTags: "text-only" } as Prepared;
}

Expand Down Expand Up @@ -2262,7 +2270,7 @@ function prepareOpenai(
if (stream && (rebuilt as Record<string, unknown>).stream_options === undefined) {
(rebuilt as Record<string, unknown>).stream_options = { include_usage: true };
}
armHostUsageCredit(session, originalMessages, processedMessages, opts.hostUsageCredit, log);
armHostUsageCredit(session, originalMessages, processedMessages, req.headers, opts.hostUsageCredit, log);
// #532: title-gen side requests carry their own tiny system and would
// clobber the conversation's measured overhead — skip them.
if (!isTitleGen && openaiOutboundSystem !== undefined) {
Expand Down Expand Up @@ -2518,7 +2526,7 @@ function prepareResponses(
});
log("info", `[${sessionId}] responses forward tools=[${fwdTools.join(",")}] injectTool=${injectTools}${pluginMode ? " (plugin mode: wire injection suppressed)" : ""} NO_INJECT_TOOL=${!!process.env.ACP_NO_INJECT_TOOL} NO_COMPRESS_PROMPT=${!!process.env.ACP_NO_COMPRESS_PROMPT}`);
}
armHostUsageCredit(session, originalMessages, processedMessages, opts.hostUsageCredit, log);
armHostUsageCredit(session, originalMessages, processedMessages, req.headers, opts.hostUsageCredit, log);
// #532: measure the outbound developer(system)+tools overhead for the panel.
// On this wire the system rides the injected developer message outside the
// fold space, so counting devContent + tools does not double-count the
Expand Down
8 changes: 8 additions & 0 deletions tests/codex-compact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,20 @@ test("isCodexClient: UA prefix detection (Node lowercases header keys)", () => {
assert.equal(isCodexClient({ "user-agent": "codex_cli_rs/0.1.0 (linux x86_64)" }), true);
assert.equal(isCodexClient({ "user-agent": "codex_cli_rs/0.2.1" }), true);
assert.equal(isCodexClient({ "user-agent": "codex_exec/0.147.0 (linux x86_64)" }), true, "exec-mode originator (codex 0.147 real-device UA)");
assert.equal(isCodexClient({ "user-agent": "codex_sdk_ts/0.153.4 (Ubuntu 24.4.0; x86_64) vt100 (codex_exec; 0.153.4)" }), true, "TS-SDK client (#645 real-device UA)");
assert.equal(isCodexClient({ "user-agent": "openai-node/3.0" }), false);
assert.equal(isCodexClient({}), false, "no UA");
assert.equal(isCodexClient({ "user-agent": ["codex_cli_rs/0.1.0", "other"] }), true, "array UA takes first");
assert.equal(isCodexClient({ "user-agent": ["other", "codex_cli_rs/0.1.0"] }), false, "array UA first is not codex");
});

test("isCodexClient: lenient 'codex' substring fallback for unknown client variants (#645)", () => {
assert.equal(isCodexClient({ "user-agent": "codex_new_variant/9.9.9" }), true, "unknown prefix still contains codex");
assert.equal(isCodexClient({ "user-agent": "Mozilla/5.0 (codex-embed)" }), true, "codex mentioned mid-UA");
assert.equal(isCodexClient({ "user-agent": "Codex_CLI_RS/0.53.0" }), false, "case-sensitive: uppercase Codex does not match");
assert.equal(isCodexClient({ "user-agent": "node-fetch/3.1" }), false, "no codex at all");
});

test("hasCompactionTrigger: only a FINAL compaction_trigger counts", () => {
assert.equal(hasCompactionTrigger([{ type: "message" }, { type: "compaction_trigger" }]), true);
assert.equal(hasCompactionTrigger([{ type: "compaction_trigger" }]), true);
Expand Down
1 change: 1 addition & 0 deletions tests/codex-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ test("not in table: 272K fallback (codex's model_info_from_slug), NOT unlimited"
test("isCodexClient: UA prefixes codex_cli_rs/ and codex_exec/", () => {
assert.equal(isCodexClient({ "user-agent": CODEX_UA }), true);
assert.equal(isCodexClient({ "user-agent": "codex_exec/0.147.0" }), true, "exec-mode UA");
assert.equal(isCodexClient({ "user-agent": "codex_sdk_ts/0.153.4 (Ubuntu 24.4.0; x86_64) vt100 (codex_exec; 0.153.4)" }), true, "TS-SDK client (#645 real-device UA)");
assert.equal(isCodexClient({ "user-agent": "node-fetch/3.1" }), false);
assert.equal(isCodexClient({}), false);
assert.equal(isCodexClient({ "user-agent": [CODEX_UA, "node-fetch/3.1"] }), true, "array headers (first entry)");
Expand Down
154 changes: 154 additions & 0 deletions tests/host-usage-backfill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,3 +910,157 @@ test("#648 control: plain client (anthropic wire, hostUsageCredit auto) still ge
assert.ok(zcodeInputTokensOf(raw) > 1000, "plain proxy client with hostUsageCredit auto must still see the uncompressed baseline (#408)");
});
});

// #645: codex — a plain proxy client on the responses wire identified by UA —
// must report the folded request's own usage; the #408 uncompressed-baseline
// backfill is suppressed (virtual number the model never receives, drifts
// turn-to-turn, exceeds the window: 1315/950k). The control test pins the
// other side of the gate: an identical non-codex client still gets the
// backfill. Harness mirrors codex-compact-e2e.test.ts (real fold, not a
// vacuous pass).

const CODEX_UA_645 = "codex_cli_rs/0.1.0 (linux x86_64)";
const CODEX_CONV_645 = "codex-usage-645";

function sseFrame(type: string, data: unknown): string {
return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
}

function completedFrame(inputTokens: number): string {
return sseFrame("response.completed", {
response: { id: "resp_done", status: "completed", output: [], usage: { input_tokens: inputTokens, output_tokens: 5, total_tokens: inputTokens + 5 } },
});
}

function compressFcEvents(callId: string): string {
const args = JSON.stringify({
content: [{ startId: "m00001", endId: "m00002", topic: "setup", summary: "MAIN-SUMMARY-SETUP-CONTEXT-FOLDED-BY-COMPRESSION-LONG-ENOUGH-FOR-KERNEL-MIN-LENGTH-CHECK" }],
});
return [
sseFrame("response.output_item.added", { item: { type: "function_call", id: `fc_${callId}`, call_id: callId, name: "compress" }, output_index: 0 }),
sseFrame("response.function_call_arguments.delta", { item_id: `fc_${callId}`, delta: args }),
sseFrame("response.output_item.done", { item: { type: "function_call", id: `fc_${callId}`, call_id: callId, name: "compress", arguments: args }, output_index: 0 }),
].join("");
}

// 7 messages × ~3800 chars (~6.7k tokens). Below the 10k window so preflight
// never fires. The sentinel sits in m00002 — the assistant message of the
// compressed head — because the kernel keeps the block's user anchor (m00001)
// resident and folds the assistant (same placement as the #590 pi test).
function codexConversation(): Array<{ type: string; role: string; content: string }> {
const input: Array<{ type: string; role: string; content: string }> = [];
for (let i = 0; i < 7; i++) {
input.push({ type: "message", role: i % 2 === 0 ? "user" : "assistant", content: `Message ${i} of the working session. ${i === 1 ? "SENTINEL_FOLD_GONE " : ""}` + `WORK_${i}_content_`.repeat(290) });
}
return input;
}

function completedUsageOf(raw: string): { input_tokens: number; total_tokens: number } {
const m = raw.match(/event: response\.completed\ndata: (\{[\s\S]*?\})\n\n/);
assert.ok(m, `response.completed frame missing: ${raw.slice(0, 400)}`);
const frame = JSON.parse(m[1]!) as { response: { usage: { input_tokens: number; total_tokens: number } } };
return frame.response.usage;
}

async function withCodexHarness(fn: (h: { proxy: http.Server; upstream: http.Server; bodies: string[]; url: string }) => Promise<void>): Promise<void> {
const bodies: string[] = [];
const upstream = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => {
bodies.push(Buffer.concat(chunks).toString("utf8"));
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
if (bodies.length === 1) {
res.write(compressFcEvents("call_645"));
}
res.write(completedFrame(1000));
res.end();
});
});
upstream.listen(0, "127.0.0.1");
await once(upstream, "listening");
const upstreamPort = (upstream.address() as { port: number }).port;
_setStoreForTest(new SessionStore({ enabled: false }));
_resetSessionsForTest();
setRegistryForTest({});
const proxy = await startServer({
port: 0,
host: "127.0.0.1",
upstream: "http://127.0.0.1",
routes: { [`http://127.0.0.1:${upstreamPort}`]: { models: { "gpt-resp": { context: 10_000 } } } },
modelContextLimit: 10_000,
kernelConfig: defaultConfig(10_000),
compress: { injectTool: true, injectNudge: false },
promptCache: { routing: "auto" },
sessionHeader: "x-acp-session",
log: false,
debug: false,
passthrough: false,
autoUpdate: false,
mitm: { enabled: false, domains: [] },
} as ProxyOptions);
await once(proxy, "listening");
const proxyPort = (proxy.address() as { port: number }).port;
const h = { proxy, upstream, bodies, url: `http://127.0.0.1:${proxyPort}/bili/http://127.0.0.1:${upstreamPort}/v1/responses` };
try {
await fn(h);
} finally {
proxy.close();
await once(proxy, "close");
upstream.close();
await once(upstream, "close");
}
}

// Returns the upstream-request count after setup (the compress execution
// triggers a re-ask, so setup is more than one upstream call — same shape as
// codex-compact-e2e's setupCompressedSession).
async function setupCodexCompressedSession(h: { bodies: string[]; url: string }, ua?: string): Promise<number> {
const headers: Record<string, string> = { "content-type": "application/json" };
if (ua) headers["user-agent"] = ua;
const r1 = await fetch(h.url, {
method: "POST",
headers,
body: JSON.stringify({ model: "gpt-resp", stream: true, session_id: CODEX_CONV_645, instructions: "You are the test coding agent.", input: codexConversation() }),
});
assert.equal(r1.status, 200);
const raw = await r1.text();
assert.equal(completedUsageOf(raw).input_tokens, 1000, "pre-fold turn must pass the raw usage through");
const s = listSessions().find((x) => x.meta.label === CODEX_CONV_645);
assert.ok(s, "session exists");
assert.ok((s!.state.blocks ?? []).some((b) => b.active), "setup created an active block");
assert.ok(h.bodies[0]!.includes("SENTINEL_FOLD_GONE"), "setup forwarded the unfolded head (sentinel present)");
return h.bodies.length;
}

test("#645: codex (responses wire, UA) reports folded usage — host backfill suppressed", async () => {
await withCodexHarness(async (h) => {
const afterSetup = await setupCodexCompressedSession(h, CODEX_UA_645);
const r2 = await fetch(h.url, {
method: "POST",
headers: { "content-type": "application/json", "user-agent": CODEX_UA_645 },
body: JSON.stringify({ model: "gpt-resp", stream: true, session_id: CODEX_CONV_645, instructions: "You are the test coding agent.", input: codexConversation() }),
});
assert.equal(r2.status, 200);
const raw = await r2.text();
assert.equal(h.bodies.length, afterSetup + 1, "post-fold turn forwarded to upstream exactly once");
assert.ok(!h.bodies[h.bodies.length - 1]!.includes("SENTINEL_FOLD_GONE"), "post-fold upstream body must not carry the folded head content");
assert.equal(completedUsageOf(raw).input_tokens, 1000, "codex must report the folded request's own usage — no uncompressed-baseline backfill (#645)");
});
});

test("#645 control: non-codex plain client (responses wire) still gets the #408 backfill", async () => {
await withCodexHarness(async (h) => {
const afterSetup = await setupCodexCompressedSession(h);
const r2 = await fetch(h.url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gpt-resp", stream: true, session_id: CODEX_CONV_645, instructions: "You are the test coding agent.", input: codexConversation() }),
});
assert.equal(r2.status, 200);
const raw = await r2.text();
assert.equal(h.bodies.length, afterSetup + 1, "post-fold turn forwarded to upstream exactly once");
assert.ok(!h.bodies[h.bodies.length - 1]!.includes("SENTINEL_FOLD_GONE"), "post-fold upstream body must not carry the folded head content");
assert.ok(completedUsageOf(raw).input_tokens > 1000, "plain proxy client must still see the uncompressed baseline (#408)");
});
});
Loading