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
13 changes: 12 additions & 1 deletion src/adapters/openai-responses/reasoning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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<string, unknown> = { ...rec };
if (missingSummary) next.summary = [];
if (dropNullContentChannel) delete next.content;
if (stripOutputStatus) delete next.status;
if (stripEncryptedContent) delete next.encrypted_content;
Expand Down
13 changes: 12 additions & 1 deletion src/chat/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the owned reasoning contract for the new summary shape

This changes translated Chat replay from a content-only reasoning item to one containing a non-empty summary, and the sanitizer now makes that summary the only text sent to canonical Responses targets, but none of the owning structure documents were updated. In particular, structure/providers/chat-compat.md:247-253 still says raw reasoning remains in content and explicitly records that routing raw chain-of-thought through the summary channel was reverted. Update structure/data-planes/inbound-compat.md and the reasoning-channel invariant to describe this new exception, or keep the raw text out of summary, so the repository's architecture contract no longer contradicts the wire behavior.

AGENTS.md reference: AGENTS.md:L33-L41

Useful? React with 👍 / 👎.

content: [{ type: "reasoning_text", text: reasoningText }],
});
}
const blocks = assistantContentToBlocks(msg.content);
if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
26 changes: 26 additions & 0 deletions tests/providers/deepseek-reasoning-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
24 changes: 24 additions & 0 deletions tests/responses/chat-inbound-reasoning-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand All @@ -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<string, unknown>;
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",
Expand Down
Loading