From 304ef17a2dc9e21d4dab3518271cc827f672adda Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 10:38:05 +0900 Subject: [PATCH 1/2] fix(responses): give every reasoning input item the summary the upstream requires A reasoning item forwarded without `summary` is refused with `Missing required parameter: 'input[N].summary'` before inference, while responsesRequestSchema marks the field optional so it passed every local gate. The chat ingress minted such an item for a replayed assistant turn, and the reasoning sanitizer passed any client-supplied one through untouched. The ingress now carries the replayed thinking as a summary_text part, mirroring src/claude/inbound.ts, and the sanitizer supplies an empty summary for any item that arrives without the key. --- src/adapters/openai-responses/reasoning.ts | 13 +++++++++- src/chat/inbound.ts | 13 +++++++++- .../deepseek-reasoning-replay.test.ts | 26 +++++++++++++++++++ .../chat-inbound-reasoning-replay.test.ts | 24 +++++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/adapters/openai-responses/reasoning.ts b/src/adapters/openai-responses/reasoning.ts index 5e84defec69..52f4cf2d408 100644 --- a/src/adapters/openai-responses/reasoning.ts +++ b/src/adapters/openai-responses/reasoning.ts @@ -12,6 +12,15 @@ import { isPlainObject } from "./internal"; * destinations, a present non-array `content` field is omitted. Otherwise non-empty array content * is blanked unless raw reasoning preservation is enabled; removing an `ocxr1:` envelope selects * the same blanking path when non-array omission is not active. + * + * A reasoning item that arrives with no `summary` key at all also gets an empty one. The field is + * required on a reasoning input item by the Responses API — a missing one is refused with + * `Missing required parameter: 'input[N].summary'` before inference — while + * responsesRequestSchema marks it optional, so such an item passes every local gate and fails only + * on the wire. This is not gated on the destination, because it reshapes nothing a canonical + * backend issued: every reasoning item Codex and this proxy emit already carries `summary`, so an + * item missing the key came from a translated ingress (`/v1/chat/completions`, `/v1/messages`) or + * a third-party client, and injecting the empty array is the whole shape it was missing. */ export function sanitizeReasoningInputContent( body: unknown, @@ -36,6 +45,7 @@ export function sanitizeReasoningInputContent( const hasOcxEnvelope = typeof rec.encrypted_content === "string" && rec.encrypted_content.startsWith(OCX_REASONING_PREFIX); const hasOutputStatus = Object.prototype.hasOwnProperty.call(rec, "status"); const hasEncryptedContent = Object.prototype.hasOwnProperty.call(rec, "encrypted_content"); + const missingSummary = !Object.prototype.hasOwnProperty.call(rec, "summary"); const stripEncryptedContent = hasOcxEnvelope || (opts?.stripEncryptedContent === true && hasEncryptedContent); // Codex serializes an absent reasoning content channel as `"content": null`. The field is @@ -59,11 +69,12 @@ export function sanitizeReasoningInputContent( const blankContent = !dropNullContentChannel && !opts?.preserveRawReasoningContent && (hasRawContent || hasOcxEnvelope); - if (!blankContent && !stripOutputStatus && !stripEncryptedContent && !dropNullContentChannel) { + if (!blankContent && !stripOutputStatus && !stripEncryptedContent && !dropNullContentChannel && !missingSummary) { return item; } changed = true; const next: Record = { ...rec }; + if (missingSummary) next.summary = []; if (dropNullContentChannel) delete next.content; if (stripOutputStatus) delete next.status; if (stripEncryptedContent) delete next.encrypted_content; diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index bdf7b305516..b12761c8e4e 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -313,7 +313,18 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { // here keeps that adjacency intact. const reasoningText = assistantReasoningText(msg); if (reasoningText !== undefined) { - input.push({ type: "reasoning", content: [{ type: "reasoning_text", text: reasoningText }] }); + // `summary` is required on a reasoning input item by the OpenAI Responses API, and our + // own responsesRequestSchema marks it optional, so a summary-less item validated locally + // and was refused upstream with `Missing required parameter: 'input[N].summary'`. It also + // has to carry the text, not just satisfy the field: sanitizeReasoningInputContent blanks + // `content` for every destination except a `preserveResponsesReasoningContent` provider, + // so summary is the only channel that survives to a native backend. This mirrors the + // Claude ingress (src/claude/inbound.ts), which has always minted both. + input.push({ + type: "reasoning", + summary: [{ type: "summary_text", text: reasoningText }], + content: [{ type: "reasoning_text", text: reasoningText }], + }); } const blocks = assistantContentToBlocks(msg.content); if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks }); diff --git a/tests/providers/deepseek-reasoning-replay.test.ts b/tests/providers/deepseek-reasoning-replay.test.ts index 37869afa179..251f4f61c3b 100644 --- a/tests/providers/deepseek-reasoning-replay.test.ts +++ b/tests/providers/deepseek-reasoning-replay.test.ts @@ -38,10 +38,36 @@ describe("sanitizeReasoningInputContent scoping", () => { type: "reasoning", id: "rs_1", content: [], + // `reasoningItem` omits `summary`, and the sanitizer now supplies the empty array the + // Responses API requires on every reasoning input item. + summary: [], encrypted_content: "native-blob", }); }); + // Regression: a reasoning item translated from `/v1/chat/completions` or `/v1/messages` carried + // no `summary`, which responsesRequestSchema allows and the upstream does not — the request was + // refused with `Missing required parameter: 'input[N].summary'` before inference. + test("a summary-less reasoning item gains the required empty summary", () => { + const out = inputOf(sanitizeReasoningInputContent({ model: "m", input: [reasoningItem()] })); + expect(out[0]!.summary).toEqual([]); + }); + + test("an existing summary is left exactly as it arrived", () => { + const summary = [{ type: "summary_text", text: "chain" }]; + const out = inputOf(sanitizeReasoningInputContent({ model: "m", input: [reasoningItem({ summary })] })); + expect(out[0]!.summary).toEqual(summary); + }); + + test("a summary-less item is repaired even where content is preserved", () => { + const out = inputOf(sanitizeReasoningInputContent( + { model: "m", input: [reasoningItem()] }, + { preserveRawReasoningContent: true }, + )); + expect(out[0]!.summary).toEqual([]); + expect(out[0]!.content).toEqual([{ type: "reasoning_text", text: "think step by step" }]); + }); + test("default behavior still blanks reasoning content (ChatGPT backend rule)", () => { const out = inputOf(sanitizeReasoningInputContent({ model: "m", input: [reasoningItem()] })); expect(out[0]!.content).toEqual([]); diff --git a/tests/responses/chat-inbound-reasoning-replay.test.ts b/tests/responses/chat-inbound-reasoning-replay.test.ts index de78b3eaaa3..2f946a5f06c 100644 --- a/tests/responses/chat-inbound-reasoning-replay.test.ts +++ b/tests/responses/chat-inbound-reasoning-replay.test.ts @@ -15,6 +15,7 @@ */ import { describe, expect, test } from "bun:test"; import { chatCompletionsToResponsesBody } from "../../src/chat/inbound"; +import { sanitizeReasoningInputContent } from "../../src/adapters/openai-responses"; import { responsesRequestSchema } from "../../src/responses/schema"; type Item = Record; @@ -41,6 +42,29 @@ describe("F6 assistant reasoning survives translation", () => { expect(out[idx + 1]).toMatchObject({ type: "message", role: "assistant" }); }); + // Regression: a Pi/Aside chat replay reached a Responses backend as + // `{ type: "reasoning", content: [...] }` with no `summary`, and the upstream refused the + // whole request with `Missing required parameter: 'input[2].summary'`. The field is optional + // in responsesRequestSchema, so only the live call failed. + test("the synthesized reasoning item carries the summary the Responses API requires", () => { + const item = items(body([USER, { role: "assistant", content: "a", reasoning_content: "prior analysis" }])) + .find(i => i.type === "reasoning")!; + + expect(item.summary).toEqual([{ type: "summary_text", text: "prior analysis" }]); + }); + + // sanitizeReasoningInputContent blanks `content` on every destination that does not opt into + // plaintext replay, so the summary is what actually reaches a native backend. + test("the replayed thinking survives reasoning-content sanitization", () => { + const sanitized = sanitizeReasoningInputContent( + body([USER, { role: "assistant", content: "a", reasoning_content: "prior analysis" }]), + ) as Record; + const item = (sanitized.input as Item[]).find(i => i.type === "reasoning")!; + + expect(item.content).toEqual([]); + expect(item.summary).toEqual([{ type: "summary_text", text: "prior analysis" }]); + }); + test("reasoning_details segments are joined in order", () => { const out = items(body([USER, { role: "assistant", From ffcc18917202eca74fc09388bdc2ed022cabda48 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 10:43:50 +0900 Subject: [PATCH 2/2] test(codex-integration): expect the sanitizer-supplied reasoning summary in issue-702 replay --- .../issue-702-expired-replay-state.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/issue-702-expired-replay-state.test.ts b/tests/codex-integration/issue-702-expired-replay-state.test.ts index 1a9b3bd092a..84100540d4f 100644 --- a/tests/codex-integration/issue-702-expired-replay-state.test.ts +++ b/tests/codex-integration/issue-702-expired-replay-state.test.ts @@ -379,7 +379,11 @@ describe("routed replay recovery", () => { expect(upstreamRequests).toHaveLength(1); expect(upstreamRequests[0]!.previous_response_id).toBeUndefined(); expect(upstreamRequests[0]!.input).toEqual([ - history[0], reasoning, + history[0], + // The client replayed this reasoning item with no `summary`; the reasoning sanitizer + // supplies the empty array the Responses API requires on a reasoning input item, so the + // forwarded item is the replayed one plus that field. + { ...reasoning, summary: [] }, { type: "function_call", call_id: "call_replay", name: custom ? "exec" : "lookup", arguments: custom ? JSON.stringify({ input: "text(1)" }) : "{}", status: "completed" }, { ...toolResult, type: "function_call_output" },