Skip to content

Commit 19779a6

Browse files
authored
Merge pull request #416 from code-yeongyu/fix/415-codex-reasoning-summary-payload-test
fix(ai): omit the Codex reasoning summary so compaction stops 400ing
2 parents 4e8cc79 + f6c02c2 commit 19779a6

5 files changed

Lines changed: 138 additions & 22 deletions

File tree

packages/ai/src/api/openai-codex-responses.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
4949
import { extractOpenAiCodexAccountId } from "../utils/openai-codex-auth.ts";
5050
import { uuidv7 } from "../utils/uuid.ts";
5151
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
52+
import { buildCodexReasoning, type CodexReasoningSummaryInput } from "./openai-codex-responses/reasoning.ts";
5253
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
5354
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
5455
import {
@@ -91,7 +92,7 @@ const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([
9192

9293
export interface OpenAICodexResponsesOptions extends StreamOptions {
9394
reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
94-
reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on" | null;
95+
reasoningSummary?: CodexReasoningSummaryInput;
9596
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
9697
textVerbosity?: "low" | "medium" | "high";
9798
toolChoice?: "auto" | "none" | "required";
@@ -110,7 +111,7 @@ interface RequestBody {
110111
tool_choice?: OpenAICodexResponsesOptions["toolChoice"];
111112
parallel_tool_calls?: boolean;
112113
temperature?: number;
113-
reasoning?: { effort?: string; summary?: string };
114+
reasoning?: ReturnType<typeof buildCodexReasoning>;
114115
service_tier?: ResponseCreateParamsStreaming["service_tier"];
115116
text?: { verbosity?: string };
116117
include?: string[];
@@ -595,17 +596,13 @@ function buildRequestBody(
595596
});
596597
}
597598

598-
if (reasoningEffort !== undefined && reasoningEffort !== null) {
599-
body.reasoning = {
600-
effort: reasoningEffort,
601-
summary: options?.reasoningSummary === null ? "off" : (options?.reasoningSummary ?? "auto"),
602-
};
603-
} else if (reasoningEffort === undefined && model.reasoning && model.thinkingLevelMap?.off !== null) {
604-
body.reasoning = {
605-
effort: model.thinkingLevelMap?.off ?? "none",
606-
summary: options?.reasoningSummary === null ? "off" : (options?.reasoningSummary ?? "auto"),
607-
};
608-
}
599+
const reasoning = buildCodexReasoning(
600+
reasoningEffort,
601+
options?.reasoningSummary,
602+
model.reasoning,
603+
model.thinkingLevelMap?.off,
604+
);
605+
if (reasoning) body.reasoning = reasoning;
609606

610607
applyExtraBody(body, options?.extraBody, OPENAI_RESPONSES_RESERVED_BODY_KEYS);
611608

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
export type CodexReasoningSummary = "auto" | "concise" | "detailed";
2+
export interface CodexReasoning {
3+
effort?: string;
4+
summary?: CodexReasoningSummary;
5+
}
6+
7+
/** @deprecated Pass `null` to omit reasoning summaries. */
8+
type LegacyCodexReasoningSummaryOff = "off";
9+
10+
/** @deprecated Pass `"auto"` to request the default reasoning summary. */
11+
type LegacyCodexReasoningSummaryOn = "on";
12+
13+
export type CodexReasoningSummaryInput =
14+
| CodexReasoningSummary
15+
| LegacyCodexReasoningSummaryOff
16+
| LegacyCodexReasoningSummaryOn
17+
| null;
18+
19+
export function normalizeCodexReasoningSummary(
20+
reasoningSummary: CodexReasoningSummaryInput | undefined,
21+
): CodexReasoningSummary | undefined {
22+
switch (reasoningSummary) {
23+
case null:
24+
case "off":
25+
return undefined;
26+
case undefined:
27+
case "on":
28+
return "auto";
29+
default:
30+
return reasoningSummary;
31+
}
32+
}
33+
34+
export function buildCodexReasoning(
35+
reasoningEffort: string | null | undefined,
36+
reasoningSummary: CodexReasoningSummaryInput | undefined,
37+
modelSupportsReasoning: boolean,
38+
thinkingOff: string | null | undefined,
39+
): CodexReasoning | undefined {
40+
let effort: string;
41+
if (reasoningEffort !== undefined && reasoningEffort !== null) {
42+
effort = reasoningEffort;
43+
} else if (reasoningEffort === undefined && modelSupportsReasoning && thinkingOff !== null) {
44+
effort = thinkingOff ?? "none";
45+
} else {
46+
return undefined;
47+
}
48+
49+
const summary = normalizeCodexReasoningSummary(reasoningSummary);
50+
return { effort, ...(summary === undefined ? {} : { summary }) };
51+
}

packages/ai/src/changes.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
# AI Source Changes
22

3+
## 2026-07-27 - Codex reasoning summary null omits the field instead of sending "off"
4+
5+
### What changed and why
6+
7+
- `api/openai-codex-responses.ts` `buildRequestBody()` and the internal
8+
`api/openai-codex-responses/reasoning.ts` normalizer: `reasoningSummary: null` now omits the `summary`
9+
field from `body.reasoning` instead of sending the literal string `"off"`. The Codex backend's
10+
`ReasoningSummaryParam` accepts only `concise`, `detailed`, and `auto`, so every request carrying
11+
`reasoningSummary: null` failed with a 400 `invalid_enum_value`. The coding-agent builtin compaction
12+
(`summarizationReasoningOptions()`) passes exactly that value to keep summarization turns cheap, which
13+
made compaction unusable on Codex models. The adapter now also preserves the shipped legacy union while
14+
normalizing `"off"` to omission and `"on"` to `"auto"`. These semantics match the sibling adapters and
15+
the official OpenAI Codex CLI reference client, whose `ReasoningSummary::None` is encoded as an absent
16+
`summary` field for both ordinary and compaction requests. Current upstream pi-mono instead maps null to
17+
`"auto"`, so this fork intentionally follows the official Codex wire contract rather than claiming
18+
upstream parity.
19+
- An extension cannot fix this: the invalid value is produced inside the wire adapter's request builder,
20+
below every extension hook.
21+
- `../test/openai-responses-thinking-matrix.test.ts`: pins both `buildRequestBody()` branches — explicit
22+
`reasoningEffort` and the thinking-off fallback — across null, legacy `"off"` / `"on"`, and `"auto"`.
23+
24+
### Expected merge conflict zones
25+
26+
- LOW: `api/openai-codex-responses.ts` `buildRequestBody()` reasoning block and the internal
27+
`api/openai-codex-responses/reasoning.ts` normalizer. Upstream writes
28+
`summary: options.reasoningSummary ?? "auto"` without the null branch; a clean upstream touch of these
29+
two object literals should resolve by keeping the null-omit spread.
30+
331
## 2026-07-27 - Retry Cloudflare 522 connection timeouts
432

533
### What changed and why

packages/ai/test/openai-responses-thinking-matrix.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,49 @@ describe("OpenAI Responses thinking matrix", () => {
8080
expect(payload).toMatchObject({ reasoning: { effort: "none", summary: "auto" } });
8181
});
8282

83+
it.each([
84+
["null", null, undefined],
85+
["off", "off", undefined],
86+
["on", "on", "auto"],
87+
["auto", "auto", "auto"],
88+
] as const)("normalizes Codex %s summary on the explicit-effort path", async (_, reasoningSummary, expectedSummary) => {
89+
const payload = await capturePayload((onPayload) =>
90+
streamOpenAICodexResponses(getModel("openai-codex", "gpt-5.6-sol"), context, {
91+
apiKey: "test-key",
92+
transport: "sse",
93+
reasoningEffort: "low",
94+
reasoningSummary,
95+
onPayload,
96+
}),
97+
);
98+
99+
expect(payload.reasoning).toEqual({
100+
effort: "low",
101+
...(expectedSummary ? { summary: expectedSummary } : {}),
102+
});
103+
});
104+
105+
it.each([
106+
["null", null, undefined],
107+
["off", "off", undefined],
108+
["on", "on", "auto"],
109+
["auto", "auto", "auto"],
110+
] as const)("normalizes Codex %s summary on the thinking-off fallback", async (_, reasoningSummary, expectedSummary) => {
111+
const payload = await capturePayload((onPayload) =>
112+
streamOpenAICodexResponses(getModel("openai-codex", "gpt-5.6-sol"), context, {
113+
apiKey: "test-key",
114+
transport: "sse",
115+
reasoningSummary,
116+
onPayload,
117+
}),
118+
);
119+
120+
expect(payload.reasoning).toEqual({
121+
effort: "none",
122+
...(expectedSummary ? { summary: expectedSummary } : {}),
123+
});
124+
});
125+
83126
it("omits Codex reasoning when the catalog says thinking cannot be disabled", async () => {
84127
const model = {
85128
...getModel("openai-codex", "gpt-5.6-sol"),

packages/coding-agent/test/compaction/summarization-reasoning-payload.test.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,11 @@ const payloadCases = [
9393
name: "OpenAI Responses",
9494
model: { ...getModel("openai", "gpt-5.4"), baseUrl: "http://127.0.0.1:9" },
9595
effort: "low",
96-
summary: undefined,
9796
},
9897
{
9998
name: "Codex Responses",
10099
model: { ...getModel("openai-codex", "gpt-5.4"), baseUrl: "http://127.0.0.1:9" },
101100
effort: "low",
102-
summary: "off",
103101
},
104102
{
105103
name: "Azure Responses",
@@ -108,19 +106,18 @@ const payloadCases = [
108106
baseUrl: "https://test-resource.openai.azure.com/openai/v1",
109107
},
110108
effort: "low",
111-
summary: undefined,
112109
},
113110
] as const;
114111

115112
describe("compaction summarization provider payloads", () => {
116-
it.each(payloadCases)("uses the cheapest legal effort without a summary for $name", async ({
117-
model,
118-
effort,
119-
summary,
120-
}) => {
113+
// Every Responses-family adapter must omit `reasoning.summary` when compaction
114+
// asks for no summary. The Codex backend rejects a string sentinel such as
115+
// "off" with `[ReasoningSummaryParam] [invalid_enum_value]`, which aborted
116+
// compaction outright (issue #415).
117+
it.each(payloadCases)("uses the cheapest legal effort without a summary for $name", async ({ model, effort }) => {
121118
const payload = await captureSummaryPayload(model);
122119
expect(payload.reasoning?.effort).toBe(effort);
123-
expect(payload.reasoning?.summary).toBe(summary);
120+
expect(payload.reasoning).not.toHaveProperty("summary");
124121
});
125122

126123
it("uses low reasoning for Kimi when its catalog rejects minimal", async () => {

0 commit comments

Comments
 (0)