feat(codex): add encrypted reasoning replay opt-in

This commit is contained in:
jackjinke
2026-07-31 02:31:40 +08:00
committed by diegosouzapw
parent aae408f585
commit 06c105b3d4
9 changed files with 211 additions and 12 deletions

View File

@@ -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.

View File

@@ -235,11 +235,15 @@ function convertSystemToDeveloperRole(body: Record<string, unknown>): 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<string, unknown>): void {
export function stripStoredItemReferences(
body: Record<string, unknown>,
preserveEncryptedReasoning = false
): void {
if (Array.isArray(body.input) && body.input.length === 0) {
body.input = [
{
@@ -273,21 +277,24 @@ export function stripStoredItemReferences(body: Record<string, unknown>): 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<string, unknown>).type === "reasoning"
) {
const encryptedContent = (item as Record<string, unknown>).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<string, unknown>;
@@ -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.

View File

@@ -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")}
/>
<Toggle
checked={formData.codexPreserveEncryptedReasoning}
onChange={(checked) =>
setFormData({ ...formData, codexPreserveEncryptedReasoning: checked })
}
label={providerText(
t,
"preserveEncryptedReasoningLabel",
"Preserve encrypted reasoning"
)}
description={providerText(
t,
"preserveEncryptedReasoningDescription",
"Forward encrypted Responses reasoning items supplied by the client."
)}
/>
</div>
)}
{isClaude && (

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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",

View File

@@ -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"),

View File

@@ -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<string, unknown> = {
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<string, unknown> = {
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: [

View File

@@ -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<HTMLButtonElement>(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);