diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index c41f30b7f9..2ff814889e 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -32,6 +32,7 @@ import { } from "../config/codexIdentity.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; +import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts"; import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { CORS_HEADERS } from "../utils/cors.ts"; @@ -222,97 +223,6 @@ function convertSystemToDeveloperRole(body: Record): void { } } -/** - * Strip server-generated item IDs from the input array. - * - * The Codex /codex/responses endpoint does not persist response items even when - * store=true is sent. When proxy clients (e.g. OpenClaw) include response items - * from previous turns in the input array, those items carry server-assigned IDs - * (prefixed with "rs_", "fc_", "resp_", "msg_"). The Codex backend tries to - * validate these IDs against its persistence store and returns 404 when the items - * are not found (because store was effectively false). - * - * 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. 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, - preserveEncryptedReasoning = false -): void { - if (Array.isArray(body.input) && body.input.length === 0) { - body.input = [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "continue" }], - }, - ]; - } - - if (!Array.isArray(body.input)) return; - - const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; - let strippedCount = 0; - - body.input = body.input.filter((item) => { - // Bare string references: "rs_abc123", "resp_abc123" - if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) { - strippedCount++; - return false; - } - - // Object references: { type: "item_reference", id: "rs_..." } - if ( - item && - typeof item === "object" && - !Array.isArray(item) && - (item as Record).type === "item_reference" - ) { - strippedCount++; - return false; - } - - // 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: "fc_...", type: "function_call", ... } → keep content, remove id - if (item && typeof item === "object" && !Array.isArray(item)) { - const record = item as Record; - if (typeof record.id === "string" && SERVER_ID_PATTERN.test(record.id)) { - delete record.id; - strippedCount++; - } - } - - return true; - }); - - if (strippedCount > 0) { - console.debug( - `[Codex] stripStoredItemReferences: sanitized ${strippedCount} server-generated ID(s) from input` - ); - } -} function stripOrphanedCodexFunctionCallOutputs(body: Record): void { if (!Array.isArray(body.input)) return; @@ -1303,7 +1213,7 @@ export class CodexExecutor extends BaseExecutor { } // Issue #1832 & #1853: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input. - // This MUST run before convertSystemToDeveloperRole and stripStoredItemReferences. + // This MUST run before convertSystemToDeveloperRole. if (!body.input && Array.isArray(body.messages)) { body.input = body.messages.map((msg: ResponsesMessageInput) => ({ type: "message", @@ -1426,14 +1336,6 @@ export class CodexExecutor extends BaseExecutor { preserveCustomTools: nativeCodexPassthrough, }); - // Strip stored response item references (rs_, resp_, msg_ IDs) from input. - // 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. delete body.messages; @@ -1525,6 +1427,11 @@ export class CodexExecutor extends BaseExecutor { delete body.session_id; delete body.conversation_id; + applyResponsesInputPolicy( + body, + credentials?.providerSpecificData?.preserveEncryptedReasoning === true + ); + if (nativeCodexPassthrough) { return body; } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2b110b47f4..9d4e6b3a63 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -21,6 +21,7 @@ import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHe import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts"; import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; +import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts"; import { getHeaderValueCaseInsensitive, isNoMemoryRequested, @@ -1071,6 +1072,13 @@ export async function handleChatCore({ return cacheHit; } + if (targetFormat === FORMATS.OPENAI_RESPONSES && body && typeof body === "object") { + applyResponsesInputPolicy( + body as Record, + credentials?.providerSpecificData?.preserveEncryptedReasoning === true + ); + } + body = sanitizeChatRequestBody(body, sourceFormat, targetFormat); // Per-request opt-out: clients that manage their own context send // `x-omniroute-no-memory: true` to skip memory+skills injection (a null owner diff --git a/open-sse/services/responsesInputPolicy.ts b/open-sse/services/responsesInputPolicy.ts new file mode 100644 index 0000000000..d80dcc7bec --- /dev/null +++ b/open-sse/services/responsesInputPolicy.ts @@ -0,0 +1,55 @@ +type JsonRecord = Record; + +const SERVER_ITEM_ID_PATTERN = /^(rs|fc|resp|msg)_/; + +/** + * Applies the persistence-independent policy for replayed Responses input items. + * Stored references can only be resolved by the upstream that created them, so + * they are always removed. Self-contained encrypted reasoning is retained only + * when the selected connection explicitly opts in. + */ +export function applyResponsesInputPolicy( + body: Record, + preserveEncryptedReasoning = false +): void { + if (Array.isArray(body.input) && body.input.length === 0) { + body.input = [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }, + ]; + } + + if (!Array.isArray(body.input)) return; + + body.input = body.input.filter((item) => { + if (typeof item === "string" && SERVER_ITEM_ID_PATTERN.test(item)) { + return false; + } + + const record = + item && typeof item === "object" && !Array.isArray(item) ? (item as JsonRecord) : null; + if (!record) return true; + + if (record.type === "item_reference") { + return false; + } + + if ( + record.type === "reasoning" && + (!preserveEncryptedReasoning || + typeof record.encrypted_content !== "string" || + record.encrypted_content.trim().length === 0) + ) { + return false; + } + + if (typeof record.id === "string" && SERVER_ITEM_ID_PATTERN.test(record.id)) { + delete record.id; + } + + return true; + }); +} 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 553b458dc2..e66aeab809 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -127,7 +127,7 @@ export default function EditConnectionModal({ codexReasoningEffort: "medium", codexServiceTier: "default" as CodexServiceTier, codexOpenaiStoreEnabled: false, - codexPreserveEncryptedReasoning: false, + preserveEncryptedReasoning: false, consoleApiKey: "", newApiUserId: "", newApiAggregatorBalance: false, @@ -194,6 +194,13 @@ export default function EditConnectionModal({ const openRouterPreset = useOpenRouterPresetControl(provider, t); const setOpenRouterPreset = openRouterPreset.setValue; const isCodex = provider === "codex"; + const isResponsesConnection = + isCodex || + provider === "openai" || + (isOpenAICompatibleProvider(provider) && + (provider.startsWith("openai-compatible-responses-") || + connectionProviderSpecificData?.apiType === "responses" || + formData.targetFormat === "openai-responses")); const isClaude = provider === "claude"; const isAntigravityFamily = provider === "antigravity" || provider === "agy"; const localProviderMetadata = getLocalProviderMetadata(provider); @@ -319,7 +326,7 @@ export default function EditConnectionModal({ codexReasoningEffort: codexRequestDefaults.reasoningEffort, codexServiceTier: codexRequestDefaults.serviceTier ?? "default", codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true, - codexPreserveEncryptedReasoning: + preserveEncryptedReasoning: connection.providerSpecificData?.preserveEncryptedReasoning === true, consoleApiKey: existingConsoleApiKey, newApiUserId: existingNewApiUserId, @@ -591,8 +598,6 @@ export default function EditConnectionModal({ }; updates.providerSpecificData.openaiStoreEnabled = formData.codexOpenaiStoreEnabled === true; - updates.providerSpecificData.preserveEncryptedReasoning = - formData.codexPreserveEncryptedReasoning === true; } if (isAntigravityFamily) { updates.providerSpecificData.projectId = trimmedCloudCodeProjectId || null; @@ -616,6 +621,10 @@ export default function EditConnectionModal({ updates.providerSpecificData.targetFormat = formData.targetFormat || null; } } + if (isResponsesConnection && updates.providerSpecificData) { + updates.providerSpecificData.preserveEncryptedReasoning = + formData.preserveEncryptedReasoning === true; + } const freeOnlyChanged = showFreeModelsToggle && formData.importFreeModelsOnly !== @@ -709,22 +718,6 @@ 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 && ( @@ -1097,6 +1090,25 @@ export default function EditConnectionModal({ /> )} + {isResponsesConnection && ( + + setFormData({ ...formData, preserveEncryptedReasoning: checked }) + } + label={providerText( + t, + "preserveEncryptedReasoningLabel", + "Preserve encrypted reasoning" + )} + description={providerText( + t, + "preserveEncryptedReasoningDescription", + "Forward encrypted Responses reasoning items supplied by the client." + )} + /> + )} + { + const reasoningItems = [ + { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }, + { type: "reasoning", encrypted_content: "" }, + { type: "reasoning", summary: [{ text: "not self-contained" }] }, + { type: "item_reference", id: "rs_reference" }, + { id: "fc_call", type: "function_call", call_id: "call_1", name: "search", arguments: "{}" }, + ]; + + for (const preserveEncryptedReasoning of [false, true]) { + const { call, result } = await invokeChatCore({ + provider: "openai-compatible-sp-openai", + model: "gpt-5.4", + endpoint: "/v1/responses", + credentials: { + apiKey: "sk-test", + providerSpecificData: { + apiType: "responses", + baseUrl: "https://proxy.example.com/v1", + prefix: "sp-openai", + preserveEncryptedReasoning, + }, + }, + body: { model: "gpt-5.4", stream: false, input: reasoningItems }, + responseFormat: "openai-responses", + }); + + assert.equal(result.success, true); + const input = call.body.input as Array>; + assert.deepEqual( + input.filter((item) => item.type === "reasoning"), + preserveEncryptedReasoning ? [{ type: "reasoning", encrypted_content: "encrypted-blob" }] : [] + ); + assert.equal( + input.some((item) => item.type === "item_reference"), + false + ); + assert.equal(input.find((item) => item.type === "function_call")?.id, undefined); + } +}); + +test("chatCore preserves opted-in encrypted reasoning for Codex", async () => { + const { call, result } = await invokeChatCore({ + provider: "codex", + model: "gpt-5.1-codex", + endpoint: "/v1/responses", + credentials: { + accessToken: "codex-token", + providerSpecificData: { preserveEncryptedReasoning: true }, + }, + body: { + model: "gpt-5.1-codex", + stream: false, + input: [ + { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }, + { type: "reasoning", encrypted_content: "" }, + { type: "item_reference", id: "rs_reference" }, + { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, + ], + }, + responseFormat: "openai-responses", + }); + + assert.equal(result.success, true); + assert.deepEqual( + call.body.input.filter((item) => item.type === "reasoning"), + [{ type: "reasoning", encrypted_content: "encrypted-blob" }] + ); + assert.equal( + call.body.input.some((item) => item.type === "item_reference"), + false + ); +}); + test("chatCore helper exports detect responses passthrough paths and token expiry windows", () => { assert.equal( shouldUseNativeCodexPassthrough({ 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 e52a85c3d8..246c0668db 100644 --- a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts +++ b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts @@ -1,19 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { CodexExecutor, stripStoredItemReferences } from "../../open-sse/executors/codex.ts"; +import { applyResponsesInputPolicy } from "../../open-sse/services/responsesInputPolicy.ts"; import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.ts"; -// Port of decolua/9router#1599 — strip reasoning blobs from agentic context to -// prevent O(n^2) token growth across turns. -// -// (1) codex.ts stripStoredItemReferences: object items of type "reasoning" -// (encrypted_content) are unusable with store=false (previous_response_id is -// deleted) and must be dropped from the Responses `input` array. -// (2) openaiHelper.ts filterToOpenAIFormat: assistant+tool_calls messages must -// have `reasoning_content` stripped instead of being returned as-is. +// Port of decolua/9router#1599 — strip unusable reasoning blobs from agentic +// context to prevent O(n^2) token growth across turns. Encrypted reasoning is +// self-contained and may be replayed only through an explicit connection opt-in. -test("stripStoredItemReferences drops object items with type=reasoning", () => { +test("applyResponsesInputPolicy drops object items with type=reasoning", () => { const body: Record = { input: [ { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, @@ -29,7 +24,7 @@ test("stripStoredItemReferences drops object items with type=reasoning", () => { ], }; - stripStoredItemReferences(body); + applyResponsesInputPolicy(body); const input = body.input as Array>; // Both reasoning items must be gone. @@ -45,30 +40,27 @@ 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" }], +test("selected connection policy preserves encrypted reasoning input", () => { + const body: Record = { + input: [ + { + id: "rs_encrypted123", + type: "reasoning", + encrypted_content: "encrypted-blob", + summary: [{ type: "summary_text", text: "safe summary" }], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, + ], }; - const executor = new CodexExecutor(); - const result = executor.transformRequest( - "gpt-5.3-codex", + applyResponsesInputPolicy(body, true); + + assert.deepEqual(body.input, [ { - _nativeCodexPassthrough: true, - input: [ - encryptedReasoning, - { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, - ], + type: "reasoning", + encrypted_content: "encrypted-blob", + summary: [{ type: "summary_text", text: "safe summary" }], }, - true, - { providerSpecificData: { preserveEncryptedReasoning: true } } - ); - - assert.deepEqual(result.input, [ - encryptedReasoning, { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, ]); }); @@ -83,15 +75,15 @@ test("preserving encrypted reasoning still removes stored references", () => { ], }; - stripStoredItemReferences(body, true); + applyResponsesInputPolicy(body, true); assert.deepEqual(body.input, [ - { id: "rs_encrypted123", type: "reasoning", encrypted_content: "encrypted-blob" }, + { type: "reasoning", encrypted_content: "encrypted-blob" }, { type: "function_call", call_id: "call_1" }, ]); }); -test("stripStoredItemReferences still drops summary-only reasoning when preservation is enabled", () => { +test("applyResponsesInputPolicy still drops summary-only reasoning when enabled", () => { const body: Record = { input: [ { id: "rs_summary123", type: "reasoning", summary: [{ text: "thinking..." }] }, @@ -101,7 +93,7 @@ test("stripStoredItemReferences still drops summary-only reasoning when preserva ], }; - stripStoredItemReferences(body, true); + applyResponsesInputPolicy(body, true); assert.deepEqual(body.input, [ { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, 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 f57b4c43df..5ff4a11d6d 100644 --- a/tests/unit/ui/edit-connection-modal-free-models.test.tsx +++ b/tests/unit/ui/edit-connection-modal-free-models.test.tsx @@ -169,47 +169,105 @@ describe("EditConnectionModal — import only free models", () => { }); }); -describe("EditConnectionModal — encrypted Codex reasoning", () => { +describe("EditConnectionModal — encrypted Responses 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 () => { + it("loads and saves the opt-in for an OpenAI-compatible Responses connection", async () => { const onSave = vi.fn().mockResolvedValue(undefined); const el = render({ - providerId: "codex", + providerId: "openai-compatible-responses-12345678-1234-1234-1234-123456789abc", connection: { - id: "conn-codex-preserve", - provider: "codex", - authType: "oauth", + id: "conn-responses", + provider: "openai-compatible-responses-12345678-1234-1234-1234-123456789abc", + authType: "apikey", 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); }); + + it("defaults off and persists an opt-in for first-party OpenAI", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ + providerId: "openai", + connection: { + id: "conn-openai", + provider: "openai", + authType: "apikey", + providerSpecificData: {}, + }, + onSave, + }); + const toggle = el.querySelector(PRESERVE_TOGGLE)!; + expect(toggle.getAttribute("aria-checked")).toBe("false"); + 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(true); + }); + + it("is absent for a chat-only compatible connection", () => { + const el = render({ + providerId: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc", + connection: { + id: "conn-chat", + provider: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc", + authType: "apikey", + providerSpecificData: {}, + }, + }); + expect(el.querySelector(PRESERVE_TOGGLE)).toBeNull(); + }); + + it("appears when a compatible connection selects the Responses target format", () => { + const el = render({ + providerId: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc", + connection: { + id: "conn-selected-responses", + provider: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc", + authType: "apikey", + providerSpecificData: { targetFormat: "openai-responses" }, + }, + }); + expect(el.querySelector(PRESERVE_TOGGLE)?.getAttribute("aria-checked")).toBe("false"); + }); + + it("keeps Codex controls and persists the opt-in on its OAuth save path", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ + providerId: "codex", + connection: { + id: "conn-codex", + provider: "codex", + authType: "oauth", + providerSpecificData: { preserveEncryptedReasoning: true }, + }, + onSave, + }); + expect(el.querySelector(PRESERVE_TOGGLE)?.getAttribute("aria-checked")).toBe("true"); + expect(el.textContent).toContain("defaultThinkingStrengthLabel"); + expect( + el.querySelector('button[role="switch"][aria-label="openaiResponsesStoreLabel"]') + ).toBeTruthy(); + 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(true); + }); }); describe("EditConnectionModal — quota scraping fields", () => {