From 5c5a721b8ba31360738b37d89c38dc83d1063abf Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 01:44:11 +0000 Subject: [PATCH 1/2] fix(responses): strip rejected reasoning toggle --- src/server/responses/core-normalize.ts | 8 +++++++- structure/transports/responses.md | 1 + .../reasoning-effort-summary-default.test.ts | 20 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/server/responses/core-normalize.ts b/src/server/responses/core-normalize.ts index 880f3a4d099..744ed28bdec 100644 --- a/src/server/responses/core-normalize.ts +++ b/src/server/responses/core-normalize.ts @@ -346,11 +346,17 @@ export async function applyFinalRouteRequestNormalization(args: { // Native passthrough keeps its snapshot-owned effort contract, including saved exclusions. if (!shouldApplyNativeEffortClamp(route.providerName, route.provider, finalSelectedModelId)) { const previousEffort = parsed.options.reasoning; + const previousThinkingEnabled = parsed.options.thinkingEnabled; normalizeThinkingOptions(route.provider, route.modelId, parsed.options); + const raw = parsed._rawBody as { reasoning?: unknown } | undefined; if (previousEffort !== undefined && parsed.options.reasoning === undefined) { - const raw = parsed._rawBody as { reasoning?: unknown } | undefined; if (raw) raw.reasoning = stripEmptyLadderEffort(raw.reasoning, []); } + if (previousThinkingEnabled !== undefined && parsed.options.thinkingEnabled === undefined + && raw?.reasoning && typeof raw.reasoning === "object" && !Array.isArray(raw.reasoning)) { + delete (raw.reasoning as { enabled?: boolean }).enabled; + if (Object.keys(raw.reasoning).length === 0) delete raw.reasoning; + } } const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy"); const surface = collabSurface(parsed); diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 810c8d782e4..62718ba8c1c 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -17,6 +17,7 @@ provider, lets the selected adapter speak the upstream protocol, then bridges ad Responses-compatible streaming output. Every Responses serializer projects unreferenced hosted-search history when the final body declares no hosted search tool; with search declared, the canonical Codex ordinary serializer retains structural hosted history by default, and only selected-provider `projectResponsesLiteHostedSearchHistory === true` plus exact canonical `/responses` and explicit Lite intent enables model-independent projection under the [hosted-search history boundary](../providers-and-adapters.md#hosted-search-continuation-binding), shared by HTTP/SSE and WS. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). The native passthrough undeclared-tool guard fails closed on the Responses inbound wire in SSE and buffered JSON before continuation state is stored. Chat and Anthropic inbounds defer to the caller's own registry except an explicit empty catalog; the [declared-tool contract](responses-wire-shapes.md#declared-tool-membership-by-inbound-wire) also covers managed native, translated, directly encoded, and final media output while preserving internal hosted operations. GitHub Copilot Auto admission rechecks the negotiated provider/model against the key scope captured at request admission (a key edited or removed mid-request cannot widen it) before route mutation or dispatch, on initial and renewal negotiation; denial returns the standard `model_not_allowed_for_key` 403 naming the caller's inbound selector without disclosing the destination. The web-search and image sidecars answer a denial during their credential rotation with that 403 instead of the original 429, and a combo child's denial keeps the same envelope. Local model-scope refusals are non-replayable. A denial during Copilot dispatch renewal propagates as an admission exception to the request boundary, never as a provider fetch response; account recovery and combo/policy fallback cannot continue after that denial. A committed continuation, including OAuth account rotation after a 429, emits the same refusal type in-stream without dispatching another inference; ordinary negotiation failures still preserve the original provider refusal. The `openai-responses` adapter preserves caller `User-Agent` unless provider headers own it, in either auth mode. +Final route capability normalization removes rejected reasoning effort and toggle fields from the native passthrough body before custom gateway-object serialization. `forwardClientHeaders` additionally opts into `originator`, `x-client-request-id`, `x-codex-app-version`, or `user-agent`. Provider headers win case-insensitively; arbitrary names are rejected on load/write and ignored at runtime. Canonical forward auth retains its separate fixed credential/metadata allowlist; absent caller identity is not invented. Upstream WebSocket reasoning-inclusion headers follow the [native prelude and connection-signal contract](responses-wire-shapes.md#passthrough-sse-stream-shapes-314). diff --git a/tests/responses/reasoning-effort-summary-default.test.ts b/tests/responses/reasoning-effort-summary-default.test.ts index 564af8427ef..155e41e5d85 100644 --- a/tests/responses/reasoning-effort-summary-default.test.ts +++ b/tests/responses/reasoning-effort-summary-default.test.ts @@ -111,6 +111,26 @@ describe("reasoning effort preserves visible thinking when summary is omitted", expect(child.reasoning).toEqual({ effort: "high", summary: "auto" }); }); + test("final route removes a rejected thinking toggle from native passthrough", async () => { + const config: OcxConfig = { + port: 0, + defaultProvider: "native", + providers: { native: { + adapter: "openai-responses", baseUrl: "https://example.test/v1", reasoningWireFormat: "gateway-object", + models: ["test"], + modelCapabilities: { test: { supportsReasoning: false, supportsThinkingToggle: false, supportsReasoningEffort: false } }, + } }, + }; + const parsed = parseRequest({ model: "native/test", input: [], reasoning: { enabled: true, effort: "high", summary: "auto" } }); + const route = routeModel(config, parsed.modelId); + await applyFinalRouteRequestNormalization({ + parsed, route, config, req: new Request("http://localhost/v1/responses"), + logCtx: { model: parsed.modelId, provider: route.providerName }, inboundWire: "responses", + }); + expect(parsed.options.thinkingEnabled).toBeUndefined(); + expect((parsed._rawBody as { reasoning?: unknown }).reasoning).toEqual({ summary: "auto" }); + }); + test("Chat final-route normalization strips both shapes without losing summary or later-route effort", async () => { const config: OcxConfig = { port: 0, From 5849c48950c72ddb11c1216fe197d1761c4d140b Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 05:36:25 +0000 Subject: [PATCH 2/2] docs(responses): qualify native reasoning cleanup --- structure/transports/responses.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 62718ba8c1c..3717c09f8ee 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -17,7 +17,7 @@ provider, lets the selected adapter speak the upstream protocol, then bridges ad Responses-compatible streaming output. Every Responses serializer projects unreferenced hosted-search history when the final body declares no hosted search tool; with search declared, the canonical Codex ordinary serializer retains structural hosted history by default, and only selected-provider `projectResponsesLiteHostedSearchHistory === true` plus exact canonical `/responses` and explicit Lite intent enables model-independent projection under the [hosted-search history boundary](../providers-and-adapters.md#hosted-search-continuation-binding), shared by HTTP/SSE and WS. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). The native passthrough undeclared-tool guard fails closed on the Responses inbound wire in SSE and buffered JSON before continuation state is stored. Chat and Anthropic inbounds defer to the caller's own registry except an explicit empty catalog; the [declared-tool contract](responses-wire-shapes.md#declared-tool-membership-by-inbound-wire) also covers managed native, translated, directly encoded, and final media output while preserving internal hosted operations. GitHub Copilot Auto admission rechecks the negotiated provider/model against the key scope captured at request admission (a key edited or removed mid-request cannot widen it) before route mutation or dispatch, on initial and renewal negotiation; denial returns the standard `model_not_allowed_for_key` 403 naming the caller's inbound selector without disclosing the destination. The web-search and image sidecars answer a denial during their credential rotation with that 403 instead of the original 429, and a combo child's denial keeps the same envelope. Local model-scope refusals are non-replayable. A denial during Copilot dispatch renewal propagates as an admission exception to the request boundary, never as a provider fetch response; account recovery and combo/policy fallback cannot continue after that denial. A committed continuation, including OAuth account rotation after a 429, emits the same refusal type in-stream without dispatching another inference; ordinary negotiation failures still preserve the original provider refusal. The `openai-responses` adapter preserves caller `User-Agent` unless provider headers own it, in either auth mode. -Final route capability normalization removes rejected reasoning effort and toggle fields from the native passthrough body before custom gateway-object serialization. +Final route capability normalization removes rejected reasoning effort and toggle fields from the native passthrough body when those fields were parsed into options, before custom gateway-object serialization. `forwardClientHeaders` additionally opts into `originator`, `x-client-request-id`, `x-codex-app-version`, or `user-agent`. Provider headers win case-insensitively; arbitrary names are rejected on load/write and ignored at runtime. Canonical forward auth retains its separate fixed credential/metadata allowlist; absent caller identity is not invented. Upstream WebSocket reasoning-inclusion headers follow the [native prelude and connection-signal contract](responses-wire-shapes.md#passthrough-sse-stream-shapes-314).