From 6bc8eaa652a42db66a17e8b929c84c7f6d445b2f Mon Sep 17 00:00:00 2001 From: ework-agent Date: Tue, 8 Sep 2026 19:44:42 +0800 Subject: [PATCH 1/2] fix #645: suppress #408 host-usage backfill for codex (report folded usage) --- src/server.ts | 36 ++++--- tests/host-usage-backfill.test.ts | 154 ++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 14 deletions(-) diff --git a/src/server.ts b/src/server.ts index 877440db..b95f2284 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { @@ -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 @@ -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).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; } @@ -2262,7 +2270,7 @@ function prepareOpenai( if (stream && (rebuilt as Record).stream_options === undefined) { (rebuilt as Record).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) { @@ -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 diff --git a/tests/host-usage-backfill.test.ts b/tests/host-usage-backfill.test.ts index bf0a5981..073e3968 100644 --- a/tests/host-usage-backfill.test.ts +++ b/tests/host-usage-backfill.test.ts @@ -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): Promise { + 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 { + const headers: Record = { "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)"); + }); +}); From 941d0b2b8b3d6088ab4b2d9d8d3e5aa02e0556c1 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Thu, 10 Sep 2026 19:11:51 +0800 Subject: [PATCH 2/2] fix #645: match codex TS-SDK UA via lenient substring fallback isCodexClient previously matched only the known prefixes (codex_cli_rs/, codex_exec/). Codex ships multiple clients with different UAs (incl. the TS SDK, codex_sdk_ts/), so also match 'codex' anywhere in the UA (case-sensitive) so a new client variant does not silently fall out of detection (#645). --- src/codex-compact.ts | 11 ++++++++--- tests/codex-compact.test.ts | 8 ++++++++ tests/codex-models.test.ts | 1 + 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/codex-compact.ts b/src/codex-compact.ts index 7b0022c2..08bcf26d 100644 --- a/src/codex-compact.ts +++ b/src/codex-compact.ts @@ -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): 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 { diff --git a/tests/codex-compact.test.ts b/tests/codex-compact.test.ts index c2724e81..4a92510a 100644 --- a/tests/codex-compact.test.ts +++ b/tests/codex-compact.test.ts @@ -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); diff --git a/tests/codex-models.test.ts b/tests/codex-models.test.ts index 06c36dcd..5a75fe95 100644 --- a/tests/codex-models.test.ts +++ b/tests/codex-models.test.ts @@ -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)");