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
37 changes: 31 additions & 6 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -2320,6 +2321,17 @@ 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");
Comment on lines +2324 to +2326

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Capture compaction output-item events before terminal

When a streaming Responses backend emits the native compaction blob in response.output_item.done and follows it with response.completed whose output is empty—a protocol shape already exercised in tests/server-auth.test.ts—this parser ignores the item because it only searches the terminal snapshot. A combo child using this parsed adapter path therefore emits done without compactionEncryptedContent, causing the bridge to synthesize an empty ocx1: envelope and /responses/compact to return 502 instead of the valid native compaction. Retain compaction items from response.output_item.done as well, with the terminal's non-empty output remaining authoritative.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

if (isPlainObject(compaction) && typeof compaction.encrypted_content === "string") {
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;
const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" });
Expand All @@ -2336,7 +2348,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<AdapterEvent[]> {
Expand All @@ -2354,14 +2370,23 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
if (payload.status === "incomplete") {
return [{ type: "incomplete", reason: responsesErrorMessage(payload) }];
}
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;
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.
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" }];
}
const usage = usageFromResponsesPayload(payload);
return [{ type: "text_delta", text }, { type: "done", ...(usage ? { usage } : {}) }];
return [...(text ? [{ type: "text_delta" as const, text }] : []), {
type: "done",
...(usage ? { usage } : {}),
...(compactionEncryptedContent ? { compactionEncryptedContent } : {}),
}];
},
};
}
14 changes: 11 additions & 3 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1574,6 +1576,7 @@ function buildResponseJSONWithBudget(
let sawTerminal = false;
let compactionText = "";
let compactionTextBytes = 0;
let compactionEncryptedContent: string | undefined;

let currentText = "";
let currentTextBytes = 0;
Expand Down Expand Up @@ -1915,6 +1918,7 @@ function buildResponseJSONWithBudget(
break;
case "done":
usage = e.usage;
compactionEncryptedContent = e.compactionEncryptedContent;
sawTerminal = true;
endTurn = e.endTurn;
cleanDone = e.stopReason === undefined;
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 14 additions & 6 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,10 @@ 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) {
// 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");
}
Expand Down Expand Up @@ -1003,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" });
Expand Down Expand Up @@ -1078,9 +1083,12 @@ 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 (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" },
});
Expand Down
2 changes: 2 additions & 0 deletions src/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions tests/responses-compaction.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading