Skip to content

Commit 39a9a7a

Browse files
committed
fix(openai): preserve opaque reasoning transcript fields
1 parent 520992a commit 39a9a7a

6 files changed

Lines changed: 161 additions & 2 deletions

src/agents/transcript-redact.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ function cfg(mode: "tools" | "off", patterns?: string[]): OpenClawConfig {
2828
}
2929

3030
const EMAIL_PATTERN = String.raw`([\w]|[-.])+@([\w]|[-.])+\.\w+`;
31+
const CIPHERTEXT_WITH_TOKEN_SHAPED_BYTES =
32+
"gAAAAABpQnQrXzzZqcAfo3unbAY-ku84xgsvB0fpLkbDvSh3WS5qzfSCmcgwr8_abcdefghijvK2RyV2GQ4ohzcfYwhRwTvY76TvR7Tvr_";
3133

3234
describe("redactTranscriptMessage", () => {
3335
it("redacts text block matching default patterns (sk- token)", () => {
@@ -50,6 +52,75 @@ describe("redactTranscriptMessage", () => {
5052
expect(block.thinking).not.toContain("sk-abcdef1234567890xyz");
5153
});
5254

55+
it("preserves OpenAI encrypted reasoning inside thinkingSignature", () => {
56+
const thinkingSignature = JSON.stringify({
57+
type: "reasoning",
58+
encrypted_content: CIPHERTEXT_WITH_TOKEN_SHAPED_BYTES,
59+
summary: [],
60+
});
61+
const msg = {
62+
role: "assistant",
63+
content: [
64+
{
65+
type: "thinking",
66+
thinking: "secret sk-abcdef1234567890xyz",
67+
thinkingSignature,
68+
},
69+
],
70+
} as unknown as AgentMessage;
71+
72+
const result = redactTranscriptMessage(msg, cfg("tools"));
73+
const block = (msgContent(result) as Array<{ thinking: string; thinkingSignature: string }>)[0];
74+
expect(block.thinking).not.toContain("sk-abcdef1234567890xyz");
75+
expect(block.thinkingSignature).toBe(thinkingSignature);
76+
expect(block.thinkingSignature).toContain(CIPHERTEXT_WITH_TOKEN_SHAPED_BYTES);
77+
expect(block.thinkingSignature).not.toContain("…");
78+
});
79+
80+
it("preserves opaque encrypted_content fields while redacting siblings", () => {
81+
const msg = {
82+
role: "assistant",
83+
content: [
84+
{
85+
type: "gatewayCustom",
86+
encrypted_content: CIPHERTEXT_WITH_TOKEN_SHAPED_BYTES,
87+
payload: {
88+
apiKey: "plainsecretvalue123",
89+
},
90+
},
91+
],
92+
} as unknown as AgentMessage;
93+
94+
const result = redactTranscriptMessage(msg, cfg("tools"));
95+
const block = (
96+
msgContent(result) as Array<{ encrypted_content: string; payload: { apiKey: string } }>
97+
)[0];
98+
expect(block.encrypted_content).toBe(CIPHERTEXT_WITH_TOKEN_SHAPED_BYTES);
99+
expect(block.payload.apiKey).toBe("plains…e123");
100+
});
101+
102+
it("preserves Anthropic redacted_thinking data while redacting siblings", () => {
103+
const msg = {
104+
role: "assistant",
105+
content: [
106+
{
107+
type: "redacted_thinking",
108+
data: CIPHERTEXT_WITH_TOKEN_SHAPED_BYTES,
109+
metadata: {
110+
accessToken: "nestedplainsecret123",
111+
},
112+
},
113+
],
114+
} as unknown as AgentMessage;
115+
116+
const result = redactTranscriptMessage(msg, cfg("tools"));
117+
const block = (
118+
msgContent(result) as Array<{ data: string; metadata: { accessToken: string } }>
119+
)[0];
120+
expect(block.data).toBe(CIPHERTEXT_WITH_TOKEN_SHAPED_BYTES);
121+
expect(block.metadata.accessToken).toBe("nested…t123");
122+
});
123+
53124
it("redacts partialJson block", () => {
54125
const msg = {
55126
role: "assistant",

src/agents/transcript-redact.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,38 @@ function isPlainTranscriptObject(value: object): value is Record<string, unknown
5454
return prototype === Object.prototype || prototype === null;
5555
}
5656

57+
const OPAQUE_TRANSCRIPT_FIELD_KEYS = new Set([
58+
"encrypted_content",
59+
"thinkingSignature",
60+
"thoughtSignature",
61+
"thought_signature",
62+
]);
63+
64+
function isOpaqueTranscriptField(fieldKey: string | undefined): boolean {
65+
return fieldKey !== undefined && OPAQUE_TRANSCRIPT_FIELD_KEYS.has(fieldKey);
66+
}
67+
68+
function isOpaqueTranscriptObjectField(source: Record<string, unknown>, key: string): boolean {
69+
if (typeof source[key] !== "string") {
70+
return false;
71+
}
72+
const type = source.type;
73+
return (
74+
(type === "redacted_thinking" && key === "data") ||
75+
((type === "thinking" || type === "redacted_thinking") && key === "signature")
76+
);
77+
}
78+
5779
function redactTranscriptStructuredValue(
5880
value: unknown,
5981
cfg?: OpenClawConfig,
6082
fieldKey?: string,
6183
seen: WeakSet<object> = new WeakSet<object>(),
6284
): unknown {
6385
if (typeof value === "string") {
86+
if (isOpaqueTranscriptField(fieldKey)) {
87+
return value;
88+
}
6489
if (fieldKey) {
6590
return redactTranscriptStructuredFieldValue(fieldKey, value, cfg);
6691
}
@@ -98,6 +123,9 @@ function redactTranscriptStructuredValue(
98123
const source = value;
99124
let next: Record<string, unknown> | null = null;
100125
for (const [key, item] of Object.entries(source)) {
126+
if (isOpaqueTranscriptObjectField(source, key)) {
127+
continue;
128+
}
101129
const redacted = redactTranscriptStructuredValue(item, cfg, key, seen);
102130
if (redacted === item) {
103131
continue;

src/llm/providers/openai-chatgpt-responses.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ describe("streamOpenAICodexResponses transport", () => {
151151
expect(result.errorMessage).toContain("Request timed out after 5ms");
152152
});
153153

154-
it("does not replay Responses item ids for store-disabled ChatGPT requests", async () => {
154+
it("does not replay Responses item ids or encrypted reasoning for store-disabled ChatGPT requests", async () => {
155155
let capturedPayload:
156156
| {
157157
store?: unknown;
@@ -228,10 +228,10 @@ describe("streamOpenAICodexResponses transport", () => {
228228
const reasoningItem = capturedPayload?.input?.find((item) => item.type === "reasoning");
229229
expect(reasoningItem).toMatchObject({
230230
type: "reasoning",
231-
encrypted_content: "ciphertext",
232231
summary: [],
233232
});
234233
expect(reasoningItem).not.toHaveProperty("id");
234+
expect(reasoningItem).not.toHaveProperty("encrypted_content");
235235
const messageItem = capturedPayload?.input?.find((item) => item.type === "message");
236236
expect(messageItem).toMatchObject({
237237
type: "message",

src/llm/providers/openai-chatgpt-responses.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ function buildRequestBody(
482482
const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, {
483483
includeSystemPrompt: false,
484484
replayResponsesItemIds: false,
485+
replayEncryptedReasoningContent: false,
485486
});
486487

487488
const body: RequestBody = {
@@ -1495,6 +1496,7 @@ async function processWebSocketStream(
14951496
{
14961497
includeSystemPrompt: false,
14971498
replayResponsesItemIds: false,
1499+
replayEncryptedReasoningContent: false,
14981500
},
14991501
).filter((item) => item.type !== "function_call_output");
15001502
entry.continuation = {

src/llm/providers/openai-responses-shared.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,4 +327,55 @@ describe("convertResponsesMessages", () => {
327327
});
328328
expect(functionCall).not.toHaveProperty("id");
329329
});
330+
331+
it("omits encrypted reasoning content when requested by native ChatGPT callers", () => {
332+
const input = convertResponsesMessages(
333+
nativeOpenAIModel,
334+
{
335+
messages: [
336+
{
337+
role: "assistant",
338+
api: nativeOpenAIModel.api,
339+
provider: nativeOpenAIModel.provider,
340+
model: nativeOpenAIModel.id,
341+
usage: {
342+
input: 0,
343+
output: 0,
344+
cacheRead: 0,
345+
cacheWrite: 0,
346+
totalTokens: 0,
347+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
348+
},
349+
stopReason: "toolUse",
350+
timestamp: 1,
351+
content: [
352+
{
353+
type: "thinking",
354+
thinking: "Need a tool.",
355+
thinkingSignature: JSON.stringify({
356+
type: "reasoning",
357+
id: "rs_prior",
358+
encrypted_content: "ciphertext",
359+
}),
360+
},
361+
],
362+
},
363+
],
364+
} satisfies Context,
365+
allowedToolCallProviders,
366+
{
367+
includeSystemPrompt: false,
368+
replayResponsesItemIds: false,
369+
replayEncryptedReasoningContent: false,
370+
},
371+
) as unknown as Array<Record<string, unknown>>;
372+
373+
const reasoningItem = input.find((item) => item.type === "reasoning");
374+
expect(reasoningItem).toMatchObject({
375+
type: "reasoning",
376+
summary: [],
377+
});
378+
expect(reasoningItem).not.toHaveProperty("id");
379+
expect(reasoningItem).not.toHaveProperty("encrypted_content");
380+
});
330381
});

src/llm/providers/openai-responses-shared.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type ReplayableResponseReasoningItem = Omit<ResponseReasoningItem, "id"> & { id?
4747
function normalizeResponsesReasoningReplayItem(params: {
4848
item: ReplayableResponseReasoningItem;
4949
replayResponsesItemIds: boolean;
50+
replayEncryptedReasoningContent: boolean;
5051
}): ReplayableResponseReasoningItem {
5152
const next = { ...(params.item as ReplayableResponseReasoningItem & Record<string, unknown>) };
5253
if (!Array.isArray(next.summary)) {
@@ -55,6 +56,9 @@ function normalizeResponsesReasoningReplayItem(params: {
5556
if (!params.replayResponsesItemIds) {
5657
delete next.id;
5758
}
59+
if (!params.replayEncryptedReasoningContent) {
60+
delete next.encrypted_content;
61+
}
5862
return next as ReplayableResponseReasoningItem;
5963
}
6064

@@ -123,6 +127,7 @@ export interface OpenAIResponsesStreamOptions {
123127
export interface ConvertResponsesMessagesOptions {
124128
includeSystemPrompt?: boolean;
125129
replayResponsesItemIds?: boolean;
130+
replayEncryptedReasoningContent?: boolean;
126131
}
127132
export { convertResponsesTools };
128133
export type { ConvertResponsesToolsOptions } from "./openai-responses-tools.js";
@@ -174,6 +179,7 @@ export function convertResponsesMessages<TApi extends Api>(
174179
): ResponseInput {
175180
const messages: ResponseInput = [];
176181
const shouldReplayResponsesItemIds = options?.replayResponsesItemIds ?? true;
182+
const shouldReplayEncryptedReasoningContent = options?.replayEncryptedReasoningContent ?? true;
177183

178184
const normalizeIdPart = (part: string): string => {
179185
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
@@ -268,6 +274,7 @@ export function convertResponsesMessages<TApi extends Api>(
268274
const reasoningItem = normalizeResponsesReasoningReplayItem({
269275
item: JSON.parse(block.thinkingSignature) as ReplayableResponseReasoningItem,
270276
replayResponsesItemIds: shouldReplayResponsesItemIds,
277+
replayEncryptedReasoningContent: shouldReplayEncryptedReasoningContent,
271278
});
272279
output.push(reasoningItem as ResponseInputItem);
273280
previousReplayItemWasReasoning = true;

0 commit comments

Comments
 (0)