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
2 changes: 2 additions & 0 deletions changelog.d/features/9000-encrypted-reasoning-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Add a default-off connection setting for Codex, OpenAI, and OpenAI-compatible Responses API providers that preserves client-supplied `reasoning.encrypted_content` items for replay, including per-target combo routing.
- Omit opaque encrypted reasoning values from persisted call logs while retaining compact diagnostic markers.
97 changes: 7 additions & 90 deletions open-sse/executors/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -222,90 +223,6 @@ function convertSystemToDeveloperRole(body: Record<string, unknown>): 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. 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
*/
export function stripStoredItemReferences(body: Record<string, unknown>): 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<string, unknown>).type === "item_reference"
) {
strippedCount++;
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).
if (
item &&
typeof item === "object" &&
!Array.isArray(item) &&
(item as Record<string, unknown>).type === "reasoning"
) {
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>;
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<string, unknown>): void {
if (!Array.isArray(body.input)) return;
Expand Down Expand Up @@ -1296,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",
Expand Down Expand Up @@ -1419,11 +1336,6 @@ export class CodexExecutor extends BaseExecutor {
preserveCustomTools: nativeCodexPassthrough,
});

// 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);

// 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;
Expand Down Expand Up @@ -1515,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;
}
Expand Down
16 changes: 8 additions & 8 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -207,7 +208,6 @@ import { stageTrace } from "./chatCore/stageTrace.ts";
import { attachCompressionUsageReceiptAfterAnalytics as attachCompressionUsageReceiptAfterAnalyticsFor } from "./chatCore/compressionUsageReceipt.ts";
import { prepareUpstreamBody } from "./chatCore/upstreamBody.ts";
import { getQuotaScopeLabelForProvider } from "../services/antigravityQuotaFamily.ts";

import {
getCallLogPipelineCaptureStreamChunks,
getCallLogPipelineMaxSizeBytes,
Expand Down Expand Up @@ -367,9 +367,7 @@ import {
isTpmExhausted,
isRpmExhausted,
} from "../services/geminiRateLimitTracker.ts";

import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";

/**
* Core chat handler - shared between SSE and Worker
* Returns { success, response, status, error } for caller to handle fallback
Expand All @@ -389,10 +387,8 @@ import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
* @param {boolean} options.isCombo - Whether this request is from a combo
* @param {string} options.connectionId - Connection ID for settings lookup
*/

// extractSystemRoleMessages extracted to chatCore/claudeSystemRole.ts (#3501); re-exported above so
// existing importers (e.g. tests/unit/system-role-extraction.test.ts) keep resolving it from here.

export async function handleChatCore({
body,
modelInfo,
Expand Down Expand Up @@ -428,7 +424,6 @@ export async function handleChatCore({
/* fail open */
}
}

// Per-request model-routing metadata (first extracted slice of the request-setup phase).
const { apiFormat, customModelTargetFormat, requestedModel } = resolveChatCoreRequestSetup(
modelInfo,
Expand All @@ -442,7 +437,6 @@ export async function handleChatCore({
// (not Math.random) purely to satisfy CodeQL js/insecure-randomness — this id
// is a log-correlation token, not a security secret.
const traceId = globalThis.crypto.randomUUID().slice(0, 6);

// Emit request.started event for real-time dashboard
setImmediate(() => {
emit("request.started", {
Expand Down Expand Up @@ -1071,6 +1065,13 @@ export async function handleChatCore({
return cacheHit;
}

if (targetFormat === FORMATS.OPENAI_RESPONSES && body && typeof body === "object") {
applyResponsesInputPolicy(
body as Record<string, unknown>,
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
Expand Down Expand Up @@ -5025,7 +5026,6 @@ export async function handleChatCore({
}),
};
}

export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
if (!expiresAt) return false;
const expiresAtMs = new Date(expiresAt).getTime();
Expand Down
55 changes: 55 additions & 0 deletions open-sse/services/responsesInputPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
type JsonRecord = Record<string, unknown>;

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<string, unknown>,
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;
});
}
Loading
Loading