diff --git a/src/adapters/openai-responses/reasoning.ts b/src/adapters/openai-responses/reasoning.ts index ae585b4c45b..82e20df262d 100644 --- a/src/adapters/openai-responses/reasoning.ts +++ b/src/adapters/openai-responses/reasoning.ts @@ -209,8 +209,26 @@ export function mapRoutedResponsesReasoningEffort( modelId: string, ): unknown { if (provider.authMode === "forward") return body; - if (configuredReasoningEfforts(provider, modelId) === undefined) return body; if (!isPlainObject(body) || !isPlainObject(body.reasoning)) return body; + // An unconfigured provider still needs the wire mapper: it folds caller spellings + // outside the Codex ladder ("minimal"->"low", "ultra"->"max") and drops unknown + // values before they can hit a strict upstream's enum validator. Skipping this on + // `configuredReasoningEfforts === undefined` let those spellings leak through + // verbatim (observed: DeepSeek-channel 400 "Invalid option" on "minimal"/"ultra"). + if (configuredReasoningEfforts(provider, modelId) === undefined) { + const effort = body.reasoning.effort; + // "none" and provider-wire spellings the mapper cannot rank ("enabled"/"disabled"/ + // "adaptive") pass through verbatim — the mapper's undefined for those means + // "not a Codex rung", not "invalid", and downstream adapters translate them. + if (typeof effort !== "string" || effort === "none" + || effort === "enabled" || effort === "disabled" || effort === "adaptive") return body; + const mappedUnconfigured = mapReasoningEffort(provider, modelId, effort); + if (mappedUnconfigured === effort) return body; + const nextReasoning = { ...body.reasoning }; + if (mappedUnconfigured === undefined) delete nextReasoning.effort; + else nextReasoning.effort = mappedUnconfigured; + return { ...body, reasoning: Object.keys(nextReasoning).length > 0 ? nextReasoning : undefined }; + } const declaredEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; // An explicitly empty ladder means no effort control, not no reasoning output. // Omit only effort so the upstream default applies; unknown/non-rankable ladders stay untouched. diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 69361aa9f93..98dcc6fe236 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -116,7 +116,22 @@ function normalizePinnedChatEffort(options: HandleNativeChatOptions): void { const pinned = !compaction ? resolvePinnedEffort(route, selector, config) : undefined; - let normalizeForWire = false; + // Run the wire mapper whenever a reasoning_effort is present on the body, not only + // after a pin/cap rewrite: mapReasoningEffort is the identity for valid ladder + // spellings and folds "minimal"->"low" / "ultra"->"max" before they can leak to a + // strict upstream. "none" is a real caller instruction (thinking disable), not an + // invalid spelling — keep it verbatim so downstream adapters can translate it to + // thinking:{type:"disabled"} instead of dropping the field (absent ≠ disabled). + const callerEffort = typeof chatBody.reasoning_effort === "string" ? chatBody.reasoning_effort : undefined; + // Compaction turns keep their exemption: they are maintenance, not agent turns, so + // their effort reaches the wire untouched just as pin/cap leave them alone. + // "none" and provider-wire spellings the mapper cannot rank ("enabled"/"disabled"/ + // "adaptive" — consumed by thinkingToggleModels adapters) also pass through: only + // spellings the mapper can fold or clamp ("minimal", "ultra", unknown ranks) get + // rewritten, while values it returns undefined for would otherwise be deleted. + let normalizeForWire = !compaction && callerEffort !== undefined + && callerEffort !== "none" && callerEffort !== "enabled" + && callerEffort !== "disabled" && callerEffort !== "adaptive"; if (pinned !== undefined) { logCtx.requestedEffort = from ? `${from}->${pinned}` : pinned; if (pinned === "none") delete chatBody.reasoning_effort; diff --git a/tests/codex-integration/model-pinned-effort.test.ts b/tests/codex-integration/model-pinned-effort.test.ts index 9aa8063f57b..75d7624688d 100644 --- a/tests/codex-integration/model-pinned-effort.test.ts +++ b/tests/codex-integration/model-pinned-effort.test.ts @@ -435,10 +435,12 @@ describe("operator pins on the actual request wire", () => { expect(wire.reasoning).toEqual({ effort: "max", summary: "auto" }); }); - test("native Chat without pins caps spawned children and preserves unqualified caller spelling", async () => { + test("native Chat without pins caps spawned children and maps unqualified caller spelling through the wire map", async () => { const c = config({ reasoningEfforts: ["low"], reasoningEffortMap: { max: "enabled" } }, { effortCap: "low", subagentEffortCap: "low" }); expect((await request(c, "chat", { reasoning_effort: "ultra" }, { "x-openai-subagent": "collab_spawn" })).reasoning_effort).toBe("low"); - expect((await request(c, "chat", { reasoning_effort: "ultra" })).reasoning_effort).toBe("ultra"); + // Unpinned main turns still run mapReasoningEffort: ultra folds to max, then the + // provider wire alias maps max -> enabled (was: verbatim "ultra" passthrough). + expect((await request(c, "chat", { reasoning_effort: "ultra" })).reasoning_effort).toBe("enabled"); expect(Object.hasOwn(await request(c, "chat", { reasoning_effort: undefined }), "reasoning_effort")).toBe(false); }); @@ -448,14 +450,18 @@ describe("operator pins on the actual request wire", () => { expect((await request(c, "chat", { tools, reasoning_effort: "ultra" })).reasoning_effort).toBe("medium"); expect((await request(c, "chat", { tools, reasoning_effort: "ultra" }, { "x-openai-subagent": "collab_spawn" })).reasoning_effort).toBe("low"); c.multiAgentMode = "v1"; - expect((await request(c, "chat", { tools, reasoning_effort: "ultra" }, { "x-openai-subagent": "collab_spawn" })).reasoning_effort).toBe("ultra"); + // Cap-exempt v1 turn: effort still passes through the wire mapper, so ultra folds + // to max (was: verbatim "ultra" passthrough). + expect((await request(c, "chat", { tools, reasoning_effort: "ultra" }, { "x-openai-subagent": "collab_spawn" })).reasoning_effort).toBe("max"); }); test("native Chat maps newly capped values and preserves lower, non-ladder and absent efforts", async () => { const c = config({ reasoningEffortMap: { medium: "enabled", low: "disabled" } }, { subagentEffortCap: "medium" }); const headers = { "x-codex-turn-metadata": JSON.stringify({ subagent_kind: "thread_spawn" }) }; expect((await request(c, "chat", { reasoning_effort: "ultra" }, headers)).reasoning_effort).toBe("enabled"); - expect((await request(c, "chat", { reasoning_effort: "low" }, headers)).reasoning_effort).toBe("low"); + // A low value the cap leaves alone still resolves through the provider wire map: + // low -> disabled (was: verbatim "low" passthrough). + expect((await request(c, "chat", { reasoning_effort: "low" }, headers)).reasoning_effort).toBe("disabled"); expect((await request(c, "chat", { reasoning_effort: "enabled" }, headers)).reasoning_effort).toBe("enabled"); expect(Object.hasOwn(await request(c, "chat", { reasoning_effort: undefined }, headers), "reasoning_effort")).toBe(false); c.providers.fixture!.pinnedReasoningEffort = "medium";