From 4c756fdc7beed71b7fce96776582a6df4c524d0a Mon Sep 17 00:00:00 2001 From: x3M3x Date: Mon, 31 Aug 2026 13:14:08 +0400 Subject: [PATCH 1/5] fix(compact): route combo compact requests through failover path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a compact request resolved through a combo, the native-compact fast path sent the request directly to the picked provider without failover. A 429 or 5xx from that target surfaced as an exhausted-retry error to the client instead of advancing to the next combo target. Skip native compact when route.combo is set so the request falls through to the synthetic compaction path, which dispatches through handleResponses → handleComboResponses with full combo failover (cooldown + advanceToNext). (cherry picked from commit df4c54423e23f220cdde69988675e206ff8e5401) --- src/server/responses/compact.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 073df4c787..3e855c7bd6 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -559,10 +559,13 @@ export async function handleResponsesCompact( } } - // Native /responses/compact exists on the canonical ChatGPT backend and on the - // official OpenAI API. Any other Responses-shaped gateway must take the routed - // summarizer path below, or compaction fails against an endpoint it never had (#422). - if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel) { + // Native /responses/compact exists on the canonical ChatGPT backend and on the + // official OpenAI API. Any other Responses-shaped gateway must take the routed + // summarizer path below, or compaction fails against an endpoint it never had (#422). + // Combo-resolved targets skip native compact so failover can advance through the + // combo target list when the picked model returns 429/5xx — the routed path below + // dispatches through handleResponses → handleComboResponses with full failover. + if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel && !route.combo) { if (req.signal.aborted) { return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); } From 0cf8cfb628174a62ef4499b50e31fa226891f040 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Mon, 31 Aug 2026 19:40:17 +0400 Subject: [PATCH 2/5] test(compact): cover combo failover and streaming (cherry picked from commit 78855ed06b0d9d8540db1379433c91c3f63b2f02) --- src/server/responses/compact.ts | 12 ++- tests/server-combo-failover-e2e.test.ts | 132 +++++++++++++++++++++++- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 3e855c7bd6..1390c3a9f0 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -559,9 +559,9 @@ export async function handleResponsesCompact( } } - // Native /responses/compact exists on the canonical ChatGPT backend and on the - // official OpenAI API. Any other Responses-shaped gateway must take the routed - // summarizer path below, or compaction fails against an endpoint it never had (#422). + // Native /responses/compact exists on the canonical ChatGPT backend and on the + // official OpenAI API. Any other Responses-shaped gateway must take the routed + // summarizer path below, or compaction fails against an endpoint it never had (#422). // Combo-resolved targets skip native compact so failover can advance through the // combo target list when the picked model returns 429/5xx — the routed path below // dispatches through handleResponses → handleComboResponses with full failover. @@ -1006,8 +1006,10 @@ export async function handleResponsesCompact( ...raw, // Canonical ChatGPT Responses rejects non-streaming turns. Daybreak cannot use the // native compact endpoint either, so run its synthetic compaction as SSE and collapse - // the completed event back into the v1 compact JSON contract below. - stream: accountGatedCompactWireModel ? true : false, + // the completed event back into the v1 compact JSON contract below. Combo-dispatched + // turns also go out as SSE: failover can land on a canonical child that rejects a + // non-streaming turn, and every combo-capable provider already serves streaming traffic. + stream: accountGatedCompactWireModel || route.combo ? true : false, input: [...inputItems, { type: "compaction_trigger" }], }; const internalHeaders = new Headers({ "content-type": "application/json" }); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index e8bf387c94..19fc826fff 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -36,6 +36,7 @@ import { responseStatePersistPendingForTests, } from "../src/responses/state"; import { clearCursorThreadContinuityForTests } from "../src/adapters/cursor/thread-continuity"; +import { COMPACT_PROMPT, encodeCompactionSummary } from "../src/responses/compaction"; // Full-suite Windows load: startServer + combo rename/delete management flows exceed the // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). @@ -112,6 +113,7 @@ mock.module("../src/lib/upstream-retry", () => ({ })); const { handleResponses } = await import("../src/server/responses"); +const { handleResponsesCompact } = await import("../src/server/responses/compact"); type HandleOptions = NonNullable[3]>; const TOKEN_ENDPOINT = "https://auth.x.ai/oauth/token"; @@ -2305,7 +2307,7 @@ describe("server combo failover 030 activation matrix", () => { let backupHits = 0; const auth: string[] = []; globalThis.fetch = (async (input, init) => { - const url = input instanceof Request ? input.url : String(input); + const url = typeof input === "object" && input !== null && "url" in input ? String((input as Request).url) : String(input); if (url === XAI_OAUTH_DISCOVERY_URL) { return Response.json({ authorization_endpoint: "https://auth.x.ai/oauth/authorize", token_endpoint: TOKEN_ENDPOINT }); } @@ -2952,3 +2954,131 @@ describe("cursor conversation continuity across store:false chains", () => { expect(seen[1]).toBe(seen[0]); }); }); + +describe("combo compact failover", () => { + function compactRequest(body: Record): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + async function postCompactLogged(config: OcxConfig): Promise { + const logCtx: RequestLogContext = { model: "", provider: "" }; + const start = Date.now(); + const response = await handleResponsesCompact(compactRequest({ + model: "combo/free", + stream: false, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "earlier turn" }] }], + }), config, logCtx); + loggedRequestSequence += 1; + return responseWithDeferredRequestLog(response, `combo-compact-${loggedRequestSequence}`, start, logCtx); + } + + function canonicalPoolConfig( + targets: Array<{ provider: string; model: string }>, + backupUrl?: string, + ): { config: OcxConfig } { + const config = comboConfig({ + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "key", + apiKey: "combo-compact-key", + }, + backup: provider("openai-chat", backupUrl ?? "http://127.0.0.1:9", "key-b"), + }, targets); + return { config }; + } + + test("native-capable first target 429 hops compact to the backup target", async () => { + const childBodies: Array> = []; + const b = serve(async request => { + childBodies.push(JSON.parse(await request.text()) as Record); + return chatStream("compact backup"); + }); + const { config } = canonicalPoolConfig([ + { provider: "openai", model: "gpt-5.4" }, + { provider: "backup", model: "m1" }, + ], baseUrl(b)); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = typeof input === "object" && input !== null && "url" in input ? String((input as Request).url) : String(input); + if (url.includes("chatgpt.com")) { + return Response.json({ error: { message: "rate limited" } }, { status: 429 }); + } + return originalFetch(input as RequestInfo, init); + }) as typeof fetch; + + const response = await postCompactLogged(config); + expect(response.status).toBe(200); + const json = await response.json() as { output?: unknown[] }; + expect(JSON.stringify(json.output)).toContain("compact backup"); + + // The backup child received the synthetic summarizer turn as SSE, with the + // summarizer prompt present in its chat wire body. + expect(childBodies).toHaveLength(1); + expect(childBodies[0]!.stream).toBe(true); + expect(JSON.stringify(childBodies[0]!.messages)).toContain("CONTEXT CHECKPOINT COMPACTION"); + + const { log } = await latestAttemptReceipts(config); + const attempts = log.attempts as Array>; + expect(attempts).toHaveLength(2); + expect(attempts[0]).toMatchObject({ + provider: "openai", + adapter: "openai-responses", + status: 429, + }); + expect(attempts[1]).toMatchObject({ provider: "backup", adapter: "openai-chat", status: 200 }); + }); + + test("combo compact runs the synthetic turn as SSE so a canonical child can serve it", async () => { + const bodies: Array> = []; + const { config } = canonicalPoolConfig([{ provider: "openai", model: "gpt-5.4" }]); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (!url.includes("chatgpt.com")) { + return originalFetch(input as RequestInfo, init); + } + // Only the codex/responses child turn is under test; side probes (e.g. the + // wham/usage quota check) just get a tolerated non-2xx. + if (!url.includes("backend-api/codex/responses")) { + return Response.json({ error: { message: "probe not under test" } }, { status: 403 }); + } + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + bodies.push(body); + // Canonical ChatGPT Responses rejects non-streaming turns; a stream:false child + // request would strand every canonical-only combo here before the SSE coercion. + if (body.stream !== true) { + return Response.json({ error: { message: "non-streaming turns are rejected" } }, { status: 400 }); + } + const completed = { + type: "response.completed", + response: { + id: "resp_compact", + status: "completed", + output: [{ type: "compaction", encrypted_content: encodeCompactionSummary("compact summary") }], + }, + }; + return new Response([ + "event: response.created", + 'data: {"type":"response.created","response":{"id":"resp_compact","status":"in_progress"}}', + "", + `event: ${completed.type}`, + `data: ${JSON.stringify(completed)}`, + "", + "", + ].join("\n"), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + const response = await postCompactLogged(config); + expect(response.status).toBe(200); + const json = await response.json() as { output?: unknown[] }; + expect(JSON.stringify(json.output)).toContain("compact summary"); + expect(bodies).toHaveLength(1); + expect(bodies[0]!.stream).toBe(true); + // Canonical children keep the native compaction_trigger item — only + // non-native targets get the summarizer prompt injected (covered above). + expect(JSON.stringify(bodies[0]!.input)).toContain("compaction_trigger"); + }); +}); From a67d630dfb85eef28700eb29e169f679ba62b7d4 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Mon, 31 Aug 2026 20:19:04 +0400 Subject: [PATCH 3/5] preserve opaque compaction ciphertext (cherry picked from commit 9582fc35f209e99544bb5774f26d8d4ba6b4730b) --- src/adapters/openai-responses.ts | 24 ++++++++++++++++++-- src/bridge.ts | 14 +++++++++--- src/server/responses/compact.ts | 8 ++++--- src/types/request.ts | 2 ++ tests/server-combo-failover-e2e.test.ts | 30 +++++++++++++------------ 5 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 0d91807617..da6194ec35 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2286,6 +2286,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let doneText = ""; let snapshot = ""; let usage: OcxUsage | undefined; + let compactionEncryptedContent: string | undefined; for await (const event of decodeServerSentEvents(response.body, { translatorBudget: budget })) { let payload: unknown; try { payload = JSON.parse(event.data); } catch { continue; } @@ -2320,6 +2321,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): return; case "response.completed": { + const responsePayload = isPlainObject(payload.response) ? payload.response : undefined; + const output = Array.isArray(responsePayload?.output) ? responsePayload.output : []; + const compaction = output.find(item => isPlainObject(item) && item.type === "compaction"); + if (isPlainObject(compaction) && typeof compaction.encrypted_content === "string") { + compactionEncryptedContent = compaction.encrypted_content; + } const next = responsesPayloadText(payload.response); const previousBytes = budgetEncoder.encode(snapshot).byteLength; const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" }); @@ -2336,7 +2343,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const text = snapshot || doneText || deltas; if (text) yield { type: "text_delta", text }; budget.releaseRetained(budgetEncoder.encode(deltas).byteLength + budgetEncoder.encode(doneText).byteLength + budgetEncoder.encode(snapshot).byteLength, { kind: "retained_collectors" }); - yield { type: "done", ...(usage ? { usage } : {}) }; + yield { + type: "done", + ...(usage ? { usage } : {}), + ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}), + }; }, async parseResponse(response: Response, budget: TranslatorBudget): Promise { @@ -2361,7 +2372,16 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): return [{ type: "error", message: "upstream compaction returned no summary text" }]; } const usage = usageFromResponsesPayload(payload); - return [{ type: "text_delta", text }, { type: "done", ...(usage ? { usage } : {}) }]; + const output = Array.isArray(payload.output) ? payload.output : []; + const compaction = output.find(item => isPlainObject(item) && item.type === "compaction"); + const compactionEncryptedContent = isPlainObject(compaction) && typeof compaction.encrypted_content === "string" + ? compaction.encrypted_content + : undefined; + return [{ type: "text_delta", text }, { + type: "done", + ...(usage ? { usage } : {}), + ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}), + }]; }, }; } diff --git a/src/bridge.ts b/src/bridge.ts index 145913ddab..c1666cb84b 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -1223,10 +1223,12 @@ export function bridgeToResponsesSSE( // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0. const item = { type: "compaction", id: `cmp_${uuid()}`, - encrypted_content: encodeCompactionSummary(compactionText), + encrypted_content: event.compactionEncryptedContent ?? encodeCompactionSummary(compactionText), }; emit("response.output_item.done", { output_index: outputIndex, item }); - retainFinishedItem(item as OutputItem, compactionTextBytes); + retainFinishedItem(item as OutputItem, event.compactionEncryptedContent + ? bytesOf(event.compactionEncryptedContent) + : compactionTextBytes); outputIndex++; } // Recognize every adapter's truncation vocabulary, not just the canonical pair. @@ -1574,6 +1576,7 @@ function buildResponseJSONWithBudget( let sawTerminal = false; let compactionText = ""; let compactionTextBytes = 0; + let compactionEncryptedContent: string | undefined; let currentText = ""; let currentTextBytes = 0; @@ -1915,6 +1918,7 @@ function buildResponseJSONWithBudget( break; case "done": usage = e.usage; + compactionEncryptedContent = e.compactionEncryptedContent; sawTerminal = true; endTurn = e.endTurn; cleanDone = e.stopReason === undefined; @@ -1967,7 +1971,11 @@ function buildResponseJSONWithBudget( && sawTerminal && !isTruncatedStopReason(rawStopReason) ) { - pushOutput({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) }, compactionTextBytes); + const item = { + type: "compaction", id: `cmp_${uuid()}`, + encrypted_content: compactionEncryptedContent ?? encodeCompactionSummary(compactionText), + }; + pushOutput(item, compactionEncryptedContent ? bytesOf(compactionEncryptedContent) : compactionTextBytes); } const failure = errorEvent ? adapterFailureFromEvent(errorEvent) : undefined; diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 1390c3a9f0..cdaa4e4f46 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1083,9 +1083,11 @@ export async function handleResponsesCompact( `compaction turn produced ${compactionItems.length} compaction items, expected exactly 1`, ); } - // The canonical Responses stream returns a real OpenAI-encrypted compaction item. OCX cannot - // and should not decrypt it; /responses/compact callers can consume that item directly. - if (accountGatedCompactWireModel) { + // Native Responses backends return a real opaque OpenAI-encrypted compaction item. OCX cannot + // and should not decrypt it; preserve that item for /responses/compact callers. Synthetic + // routed summaries are our `ocx1:` envelope and must be decoded into v1 history items. + if (accountGatedCompactWireModel || (typeof compactionItems[0]!.encrypted_content === "string" + && !compactionItems[0]!.encrypted_content.startsWith("ocx1:"))) { const result = new Response(JSON.stringify({ output: compactionItems }), { headers: { "Content-Type": "application/json" }, }); diff --git a/src/types/request.ts b/src/types/request.ts index d78c25b416..25c4a26ef4 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -333,6 +333,8 @@ export type AdapterEvent = | { type: "done"; usage?: OcxUsage; + /** Native opaque compaction ciphertext returned by a Responses backend. */ + compactionEncryptedContent?: string; stopReason?: string; endTurn?: boolean; providerState?: OcxProviderContinuationState; diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 19fc826fff..ec52bb3d8d 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -2981,9 +2981,9 @@ describe("combo compact failover", () => { backupUrl?: string, ): { config: OcxConfig } { const config = comboConfig({ - openai: { + "openai-apikey": { adapter: "openai-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", + baseUrl: "https://api.openai.com/v1", authMode: "key", apiKey: "combo-compact-key", }, @@ -2999,12 +2999,12 @@ describe("combo compact failover", () => { return chatStream("compact backup"); }); const { config } = canonicalPoolConfig([ - { provider: "openai", model: "gpt-5.4" }, + { provider: "openai-apikey", model: "gpt-5.4" }, { provider: "backup", model: "m1" }, ], baseUrl(b)); globalThis.fetch = (async (input: unknown, init?: RequestInit) => { const url = typeof input === "object" && input !== null && "url" in input ? String((input as Request).url) : String(input); - if (url.includes("chatgpt.com")) { + if (url.includes("api.openai.com")) { return Response.json({ error: { message: "rate limited" } }, { status: 429 }); } return originalFetch(input as RequestInfo, init); @@ -3025,7 +3025,7 @@ describe("combo compact failover", () => { const attempts = log.attempts as Array>; expect(attempts).toHaveLength(2); expect(attempts[0]).toMatchObject({ - provider: "openai", + provider: "openai-apikey", adapter: "openai-responses", status: 429, }); @@ -3034,15 +3034,17 @@ describe("combo compact failover", () => { test("combo compact runs the synthetic turn as SSE so a canonical child can serve it", async () => { const bodies: Array> = []; - const { config } = canonicalPoolConfig([{ provider: "openai", model: "gpt-5.4" }]); + const { config } = canonicalPoolConfig([{ provider: "openai-apikey", model: "gpt-5.4" }]); globalThis.fetch = (async (input: unknown, init?: RequestInit) => { - const url = input instanceof Request ? input.url : String(input); - if (!url.includes("chatgpt.com")) { + const url = typeof input === "object" && input !== null && "url" in input + ? String((input as Request).url) + : String(input); + if (!url.includes("api.openai.com")) { return originalFetch(input as RequestInfo, init); } // Only the codex/responses child turn is under test; side probes (e.g. the // wham/usage quota check) just get a tolerated non-2xx. - if (!url.includes("backend-api/codex/responses")) { + if (!url.includes("api.openai.com/v1/responses")) { return Response.json({ error: { message: "probe not under test" } }, { status: 403 }); } const body = JSON.parse(String(init?.body ?? "{}")) as Record; @@ -3057,7 +3059,7 @@ describe("combo compact failover", () => { response: { id: "resp_compact", status: "completed", - output: [{ type: "compaction", encrypted_content: encodeCompactionSummary("compact summary") }], + output: [{ type: "compaction", encrypted_content: "gAAAAABm-native-openai-ciphertext" }], }, }; return new Response([ @@ -3074,11 +3076,11 @@ describe("combo compact failover", () => { const response = await postCompactLogged(config); expect(response.status).toBe(200); const json = await response.json() as { output?: unknown[] }; - expect(JSON.stringify(json.output)).toContain("compact summary"); + expect(json.output).toEqual([expect.objectContaining({ + type: "compaction", encrypted_content: "gAAAAABm-native-openai-ciphertext", + })]); expect(bodies).toHaveLength(1); expect(bodies[0]!.stream).toBe(true); - // Canonical children keep the native compaction_trigger item — only - // non-native targets get the summarizer prompt injected (covered above). - expect(JSON.stringify(bodies[0]!.input)).toContain("compaction_trigger"); + expect(JSON.stringify(bodies[0]!.input)).toContain("CONTEXT CHECKPOINT COMPACTION"); }); }); From f42f20952705cc447979692b0c10e51c3e119628 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 1 Sep 2026 09:17:21 +0900 Subject: [PATCH 4/5] fix: preserve native compaction completion ownership --- src/adapters/openai-responses.ts | 21 +++++--- src/server/responses/compact.ts | 4 +- tests/responses-compaction.test.ts | 65 +++++++++++++++++++++++++ tests/server-combo-failover-e2e.test.ts | 21 ++++++++ 4 files changed, 101 insertions(+), 10 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index da6194ec35..0209bc63c3 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2325,7 +2325,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const output = Array.isArray(responsePayload?.output) ? responsePayload.output : []; const compaction = output.find(item => isPlainObject(item) && item.type === "compaction"); if (isPlainObject(compaction) && typeof compaction.encrypted_content === "string") { - compactionEncryptedContent = compaction.encrypted_content; + const nextEncryptedContent = compaction.encrypted_content; + const previousBytes = budgetEncoder.encode(compactionEncryptedContent ?? "").byteLength; + const reservation = budget.reserveTransient(budgetEncoder.encode(nextEncryptedContent).byteLength, { kind: "retained_collectors" }); + compactionEncryptedContent = nextEncryptedContent; + reservation.commitRetained(); + budget.releaseRetained(previousBytes, { kind: "retained_collectors" }); } const next = responsesPayloadText(payload.response); const previousBytes = budgetEncoder.encode(snapshot).byteLength; @@ -2365,19 +2370,19 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (payload.status === "incomplete") { return [{ type: "incomplete", reason: responsesErrorMessage(payload) }]; } - const text = responsesPayloadText(payload); - if (!text) { - // A completed turn with no usable text cannot become a summary; saying so is - // better than installing an empty compaction as replacement history. - return [{ type: "error", message: "upstream compaction returned no summary text" }]; - } const usage = usageFromResponsesPayload(payload); const output = Array.isArray(payload.output) ? payload.output : []; const compaction = output.find(item => isPlainObject(item) && item.type === "compaction"); const compactionEncryptedContent = isPlainObject(compaction) && typeof compaction.encrypted_content === "string" ? compaction.encrypted_content : undefined; - return [{ type: "text_delta", text }, { + const text = responsesPayloadText(payload); + if (!text && !compactionEncryptedContent) { + // A completed turn with neither text nor a native compaction blob cannot become a + // replacement-history item. A ciphertext-only native completion is valid, though. + return [{ type: "error", message: "upstream compaction returned no summary text" }]; + } + return [...(text ? [{ type: "text_delta" as const, text }] : []), { type: "done", ...(usage ? { usage } : {}), ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}), diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index cdaa4e4f46..affd481494 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1086,8 +1086,8 @@ export async function handleResponsesCompact( // Native Responses backends return a real opaque OpenAI-encrypted compaction item. OCX cannot // and should not decrypt it; preserve that item for /responses/compact callers. Synthetic // routed summaries are our `ocx1:` envelope and must be decoded into v1 history items. - if (accountGatedCompactWireModel || (typeof compactionItems[0]!.encrypted_content === "string" - && !compactionItems[0]!.encrypted_content.startsWith("ocx1:"))) { + if (typeof compactionItems[0]!.encrypted_content === "string" + && !compactionItems[0]!.encrypted_content.startsWith("ocx1:")) { const result = new Response(JSON.stringify({ output: compactionItems }), { headers: { "Content-Type": "application/json" }, }); diff --git a/tests/responses-compaction.test.ts b/tests/responses-compaction.test.ts index 5cc02d2e56..1f1ea9b25f 100644 --- a/tests/responses-compaction.test.ts +++ b/tests/responses-compaction.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { bridgeToResponsesSSE, buildResponseJSON } from "../src/bridge"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses"; +import { createTranslatorBudget } from "../src/lib/translator-budget"; import { CODEX_FORWARD_BASE_URL } from "../src/providers/openai-tiers"; import { parseRequest } from "../src/responses/parser"; import { @@ -165,6 +166,70 @@ describe("buildResponseJSON compaction mode", () => { }); }); +describe("native Responses compaction passthrough", () => { + const provider = { + adapter: "openai-responses", + baseUrl: "https://responses.example/v1", + authMode: "key" as const, + apiKey: "test-key", + }; + + test("buffered ciphertext-only completion yields done without a text delta", async () => { + const adapter = createResponsesPassthroughAdapterProduction(provider); + const encryptedContent = "gAAAAABm-native-buffered-ciphertext"; + const budget = createTranslatorBudget(); + try { + const events = await adapter.parseResponse!(Response.json({ + status: "completed", + output: [{ type: "compaction", encrypted_content: encryptedContent }], + }), budget); + + expect(events).toEqual([{ type: "done", compactionEncryptedContent: encryptedContent }]); + } finally { + budget.dispose(); + } + }); + + test("streaming ciphertext is charged before the compaction item takes ownership", async () => { + const adapter = createResponsesPassthroughAdapterProduction(provider); + const encryptedContent = "gAAAAABm-native-streaming-ciphertext"; + const budget = createTranslatorBudget(); + try { + const events: AdapterEvent[] = []; + const stream = [ + "event: response.completed", + `data: ${JSON.stringify({ + type: "response.completed", + response: { + status: "completed", + output: [{ type: "compaction", encrypted_content: encryptedContent }], + }, + })}`, + "", + "", + ].join("\n"); + for await (const event of adapter.parseStream(new Response(stream, { + headers: { "content-type": "text/event-stream" }, + }), budget)) events.push(event); + + expect(events).toEqual([{ type: "done", compactionEncryptedContent: encryptedContent }]); + expect(budget.snapshot().currentBytes).toBe(Buffer.byteLength(encryptedContent)); + + const json = buildResponseJSON(events, "test/model", { + compaction: true, + translatorBudget: budget, + }) as { output: Array<{ type: string; encrypted_content?: string }> }; + expect(json.output).toEqual([expect.objectContaining({ + type: "compaction", + encrypted_content: encryptedContent, + })]); + expect(budget.snapshot().currentBytes).toBe(Buffer.byteLength(JSON.stringify(json.output[0]))); + } finally { + budget.dispose(); + } + }); +}); + describe("COMPACT_PROMPT", () => { test("mirrors the codex-rs checkpoint instruction", () => { expect(COMPACT_PROMPT).toContain("CONTEXT CHECKPOINT COMPACTION"); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index ec52bb3d8d..7eda7eb8cf 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -3032,6 +3032,27 @@ describe("combo compact failover", () => { expect(attempts[1]).toMatchObject({ provider: "backup", adapter: "openai-chat", status: 200 }); }); + test("account-gated first target failover decodes the backup ocx1 compaction", async () => { + const b = serve(() => chatStream("mixed combo backup summary")); + const { config } = canonicalPoolConfig([ + { provider: "openai-apikey", model: "gpt-daybreak-blue-latest" }, + { provider: "backup", model: "m1" }, + ], baseUrl(b)); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = typeof input === "object" && input !== null && "url" in input ? String((input as Request).url) : String(input); + if (url.includes("api.openai.com")) { + return Response.json({ error: { message: "rate limited" } }, { status: 429 }); + } + return originalFetch(input as RequestInfo, init); + }) as typeof fetch; + + const response = await postCompactLogged(config); + expect(response.status).toBe(200); + const json = await response.json() as { output?: unknown[] }; + expect(JSON.stringify(json.output)).toContain("mixed combo backup summary"); + expect(JSON.stringify(json.output)).not.toContain("ocx1:"); + }); + test("combo compact runs the synthetic turn as SSE so a canonical child can serve it", async () => { const bodies: Array> = []; const { config } = canonicalPoolConfig([{ provider: "openai-apikey", model: "gpt-5.4" }]); From a0f036f8759a86789a1881d4a05db4972a516f05 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 1 Sep 2026 09:36:12 +0900 Subject: [PATCH 5/5] fix(compact): reject empty native ciphertext --- src/server/responses/compact.ts | 1 + tests/server-combo-failover-e2e.test.ts | 30 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index affd481494..dd9bd47d63 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1087,6 +1087,7 @@ export async function handleResponsesCompact( // and should not decrypt it; preserve that item for /responses/compact callers. Synthetic // routed summaries are our `ocx1:` envelope and must be decoded into v1 history items. if (typeof compactionItems[0]!.encrypted_content === "string" + && compactionItems[0]!.encrypted_content.trim().length > 0 && !compactionItems[0]!.encrypted_content.startsWith("ocx1:")) { const result = new Response(JSON.stringify({ output: compactionItems }), { headers: { "Content-Type": "application/json" }, diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 7eda7eb8cf..0ce1063c79 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -3104,4 +3104,34 @@ describe("combo compact failover", () => { expect(bodies[0]!.stream).toBe(true); expect(JSON.stringify(bodies[0]!.input)).toContain("CONTEXT CHECKPOINT COMPACTION"); }); + + test("native compact rejects an empty ciphertext item", async () => { + const { config } = canonicalPoolConfig([{ provider: "openai-apikey", model: "gpt-5.4" }]); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = typeof input === "object" && input !== null && "url" in input + ? String((input as Request).url) + : String(input); + if (!url.includes("api.openai.com/v1/responses")) { + return Response.json({ error: { message: "probe not under test" } }, { status: 403 }); + } + const completed = { + type: "response.completed", + response: { + id: "resp_compact_empty", + status: "completed", + output: [{ type: "compaction", encrypted_content: "" }], + }, + }; + return new Response([ + `event: ${completed.type}`, + `data: ${JSON.stringify(completed)}`, + "", + "", + ].join("\n"), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + const response = await postCompactLogged(config); + expect(response.status).toBe(502); + expect(await response.text()).toContain("empty summary"); + }); });