diff --git a/changelog.d/features/9000-encrypted-reasoning-replay.md b/changelog.d/features/9000-encrypted-reasoning-replay.md new file mode 100644 index 0000000000..61379f1617 --- /dev/null +++ b/changelog.d/features/9000-encrypted-reasoning-replay.md @@ -0,0 +1 @@ +- Add an opt-in Codex connection setting that forwards client-supplied encrypted Responses reasoning items for replay, including per-target combo routing. diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index f595b7f1b8..c41f30b7f9 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -235,11 +235,15 @@ function convertSystemToDeveloperRole(body: Record): void { * This function: * 1. Removes bare string references ("rs_abc123") from the input array * 2. Removes object items with type "item_reference" (explicit stored-item refs) - * 3. Strips the "id" field from any object in input whose id matches a - * server-generated prefix (rs_, fc_, resp_, msg_) — so the content is - * preserved but the backend won't try to look it up + * 3. Removes reasoning items unless encrypted reasoning preservation is enabled + * and the item has non-empty encrypted_content + * 4. Strips the "id" field from any remaining object in input whose id matches + * a server-generated prefix (rs_, fc_, resp_, msg_) */ -export function stripStoredItemReferences(body: Record): void { +export function stripStoredItemReferences( + body: Record, + preserveEncryptedReasoning = false +): void { if (Array.isArray(body.input) && body.input.length === 0) { body.input = [ { @@ -273,21 +277,24 @@ export function stripStoredItemReferences(body: Record): void { return false; } - // Reasoning blobs (encrypted_content) are unusable with store=false since - // previous_response_id is deleted — strip them to avoid wasting context - // tokens (O(n^2) growth across agentic turns). + // Reasoning items normally cannot be replayed with store=false. A selected + // connection may explicitly preserve encrypted reasoning input, which is + // self-contained and must remain unchanged for the upstream to consume it. if ( item && typeof item === "object" && !Array.isArray(item) && (item as Record).type === "reasoning" ) { + const encryptedContent = (item as Record).encrypted_content; + if (preserveEncryptedReasoning && typeof encryptedContent === "string" && encryptedContent) { + return true; + } strippedCount++; return false; } // Object items with server-generated IDs: strip the id field but keep the item. - // e.g. { id: "rs_...", type: "reasoning", summary: [...] } → keep content, remove id // e.g. { id: "fc_...", type: "function_call", ... } → keep content, remove id if (item && typeof item === "object" && !Array.isArray(item)) { const record = item as Record; @@ -1420,9 +1427,12 @@ export class CodexExecutor extends BaseExecutor { }); // Strip stored response item references (rs_, resp_, msg_ IDs) from input. - // The /codex/responses endpoint does not persist responses even with store=true, - // so any references to previous response items would cause 404 errors. - stripStoredItemReferences(body); + // The selected connection may opt into replaying self-contained encrypted + // reasoning items; plaintext and summary-only reasoning remains stripped. + stripStoredItemReferences( + body, + credentials?.providerSpecificData?.preserveEncryptedReasoning === true + ); // Issue #806: Even for native passthrough, some clients (purist completions) might indiscriminately inject // a `messages` or `prompt` array which the strict Codex Responses schema rejects. diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index b355de6cc9..553b458dc2 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -127,6 +127,7 @@ export default function EditConnectionModal({ codexReasoningEffort: "medium", codexServiceTier: "default" as CodexServiceTier, codexOpenaiStoreEnabled: false, + codexPreserveEncryptedReasoning: false, consoleApiKey: "", newApiUserId: "", newApiAggregatorBalance: false, @@ -318,6 +319,8 @@ export default function EditConnectionModal({ codexReasoningEffort: codexRequestDefaults.reasoningEffort, codexServiceTier: codexRequestDefaults.serviceTier ?? "default", codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true, + codexPreserveEncryptedReasoning: + connection.providerSpecificData?.preserveEncryptedReasoning === true, consoleApiKey: existingConsoleApiKey, newApiUserId: existingNewApiUserId, newApiAggregatorBalance: connection.providerSpecificData?.newApiAggregatorBalance === true, @@ -588,6 +591,8 @@ export default function EditConnectionModal({ }; updates.providerSpecificData.openaiStoreEnabled = formData.codexOpenaiStoreEnabled === true; + updates.providerSpecificData.preserveEncryptedReasoning = + formData.codexPreserveEncryptedReasoning === true; } if (isAntigravityFamily) { updates.providerSpecificData.projectId = trimmedCloudCodeProjectId || null; @@ -704,6 +709,22 @@ export default function EditConnectionModal({ label={t("openaiResponsesStoreLabel")} description={t("openaiResponsesStoreDescription")} /> + + setFormData({ ...formData, codexPreserveEncryptedReasoning: checked }) + } + label={providerText( + t, + "preserveEncryptedReasoningLabel", + "Preserve encrypted reasoning" + )} + description={providerText( + t, + "preserveEncryptedReasoningDescription", + "Forward encrypted Responses reasoning items supplied by the client." + )} + /> )} {isClaude && ( diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 29689d4128..0ca217af92 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -193,6 +193,13 @@ export function normalizeProviderSpecificData( delete normalized.openaiStoreEnabled; } + if ( + "preserveEncryptedReasoning" in normalized && + typeof normalized.preserveEncryptedReasoning !== "boolean" + ) { + delete normalized.preserveEncryptedReasoning; + } + if ("blockExtraUsage" in normalized && typeof normalized.blockExtraUsage !== "boolean") { delete normalized.blockExtraUsage; } diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index 7b888514e6..aaa830f1c2 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -154,6 +154,15 @@ export function validateProviderSpecificData( }); } + const preserveEncryptedReasoning = data.preserveEncryptedReasoning; + if (preserveEncryptedReasoning !== undefined && typeof preserveEncryptedReasoning !== "boolean") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "providerSpecificData.preserveEncryptedReasoning must be a boolean", + path: ["preserveEncryptedReasoning"], + }); + } + const blockExtraUsage = data.blockExtraUsage; if (blockExtraUsage !== undefined && typeof blockExtraUsage !== "boolean") { ctx.addIssue({ diff --git a/tests/unit/provider-specific-data-schema.test.ts b/tests/unit/provider-specific-data-schema.test.ts index f8f084034b..8166e9cb15 100644 --- a/tests/unit/provider-specific-data-schema.test.ts +++ b/tests/unit/provider-specific-data-schema.test.ts @@ -42,6 +42,36 @@ test("provider schemas reject non-boolean openaiStoreEnabled values", () => { assert.equal(updated.success, false); }); +test("provider schemas accept boolean preserveEncryptedReasoning in providerSpecificData", () => { + const created = createProviderSchema.safeParse({ + provider: "codex", + apiKey: "token", + name: "Codex", + providerSpecificData: { preserveEncryptedReasoning: true }, + }); + const updated = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { preserveEncryptedReasoning: false }, + }); + + assert.equal(created.success, true); + assert.equal(updated.success, true); +}); + +test("provider schemas reject non-boolean preserveEncryptedReasoning values", () => { + const created = createProviderSchema.safeParse({ + provider: "codex", + apiKey: "token", + name: "Codex", + providerSpecificData: { preserveEncryptedReasoning: "yes" }, + }); + const updated = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { preserveEncryptedReasoning: 1 }, + }); + + assert.equal(created.success, false); + assert.equal(updated.success, false); +}); + test("provider schemas accept boolean CC-compatible request defaults", () => { const created = createProviderSchema.safeParse({ provider: "anthropic-compatible-cc-demo", diff --git a/tests/unit/request-defaults-store-session.test.ts b/tests/unit/request-defaults-store-session.test.ts index 45122e0465..96ea50f202 100644 --- a/tests/unit/request-defaults-store-session.test.ts +++ b/tests/unit/request-defaults-store-session.test.ts @@ -15,6 +15,21 @@ test("Codex request defaults accept max but leave ultra to the Codex client", () assert.equal(normalizeCodexReasoningEffort("ultra"), undefined); }); +test("normalizeProviderSpecificData keeps only boolean preserveEncryptedReasoning", () => { + assert.equal( + normalizeProviderSpecificData("codex", { preserveEncryptedReasoning: true }) + ?.preserveEncryptedReasoning, + true + ); + assert.equal( + normalizeProviderSpecificData("codex", { + preserveEncryptedReasoning: "yes", + tag: "primary", + })?.preserveEncryptedReasoning, + undefined + ); +}); + test("buildOpenAIStoreSessionId normalizes external and generated session ids", () => { assert.equal( buildOpenAIStoreSessionId("ext:client session/abc"), diff --git a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts index f19a12f5f6..e52a85c3d8 100644 --- a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts +++ b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { stripStoredItemReferences } from "../../open-sse/executors/codex.ts"; +import { CodexExecutor, stripStoredItemReferences } from "../../open-sse/executors/codex.ts"; import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.ts"; // Port of decolua/9router#1599 — strip reasoning blobs from agentic context to @@ -45,6 +45,69 @@ test("stripStoredItemReferences drops object items with type=reasoning", () => { assert.equal(input[1].id, undefined, "fc_ server id stripped, item kept"); }); +test("Codex selected connection preserves encrypted reasoning input", () => { + const encryptedReasoning = { + id: "rs_encrypted123", + type: "reasoning", + encrypted_content: "encrypted-blob", + summary: [{ type: "summary_text", text: "safe summary" }], + }; + const executor = new CodexExecutor(); + + const result = executor.transformRequest( + "gpt-5.3-codex", + { + _nativeCodexPassthrough: true, + input: [ + encryptedReasoning, + { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, + ], + }, + true, + { providerSpecificData: { preserveEncryptedReasoning: true } } + ); + + assert.deepEqual(result.input, [ + encryptedReasoning, + { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, + ]); +}); + +test("preserving encrypted reasoning still removes stored references", () => { + const body: Record = { + input: [ + { id: "rs_encrypted123", type: "reasoning", encrypted_content: "encrypted-blob" }, + "rs_stored123", + { type: "item_reference", id: "resp_stored123" }, + { type: "function_call", id: "fc_stored123", call_id: "call_1" }, + ], + }; + + stripStoredItemReferences(body, true); + + assert.deepEqual(body.input, [ + { id: "rs_encrypted123", type: "reasoning", encrypted_content: "encrypted-blob" }, + { type: "function_call", call_id: "call_1" }, + ]); +}); + +test("stripStoredItemReferences still drops summary-only reasoning when preservation is enabled", () => { + const body: Record = { + input: [ + { id: "rs_summary123", type: "reasoning", summary: [{ text: "thinking..." }] }, + { type: "reasoning", encrypted_content: "" }, + { type: "reasoning", encrypted_content: 42 }, + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ], + }; + + stripStoredItemReferences(body, true); + + assert.deepEqual(body.input, [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); +}); + test("filterToOpenAIFormat strips reasoning_content from assistant+tool_calls messages", () => { const body = { messages: [ diff --git a/tests/unit/ui/edit-connection-modal-free-models.test.tsx b/tests/unit/ui/edit-connection-modal-free-models.test.tsx index 757b0cff84..f57b4c43df 100644 --- a/tests/unit/ui/edit-connection-modal-free-models.test.tsx +++ b/tests/unit/ui/edit-connection-modal-free-models.test.tsx @@ -169,6 +169,49 @@ describe("EditConnectionModal — import only free models", () => { }); }); +describe("EditConnectionModal — encrypted Codex reasoning", () => { + const PRESERVE_TOGGLE = 'button[role="switch"][aria-label="Preserve encrypted reasoning"]'; + + it("defaults existing Codex connections to disabled", () => { + const el = render({ + providerId: "codex", + connection: { + id: "conn-codex-default", + provider: "codex", + authType: "oauth", + providerSpecificData: {}, + }, + }); + + expect(el.querySelector(PRESERVE_TOGGLE)?.getAttribute("aria-checked")).toBe("false"); + }); + + it("loads and saves the persisted boolean", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ + providerId: "codex", + connection: { + id: "conn-codex-preserve", + provider: "codex", + authType: "oauth", + providerSpecificData: { preserveEncryptedReasoning: true }, + }, + onSave, + }); + const toggle = el.querySelector(PRESERVE_TOGGLE)!; + expect(toggle.getAttribute("aria-checked")).toBe("true"); + + act(() => toggle.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const saveBtn = Array.from(el.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "save" + )!; + act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + await waitFor(() => onSave.mock.calls.length > 0); + expect(onSave.mock.calls[0][0].providerSpecificData?.preserveEncryptedReasoning).toBe(false); + }); +}); + describe("EditConnectionModal — quota scraping fields", () => { it("saves OpenCode Go workspace and replacement auth cookie", async () => { const onSave = vi.fn().mockResolvedValue(undefined);