From 3249fe947e2eb5ede28f9528a75b46c213687f03 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:20:57 +0900 Subject: [PATCH 1/3] fix(chat): deliver upstream raw reasoning without generation opt-in Separate Chat raw-reasoning delivery visibility from parsed thinking options on direct encoder and internal Responses bridge paths. Preserve upstream request bytes, explicit controls, summary behavior, and provider display policy. Add focused streaming and buffered regressions and document the delivery default. Carries the delivery fix from #7752 for #7750. Co-authored-by: thyjeff <183022254+thyjeff@users.noreply.github.com> --- .../content/docs/reference/proxy-formats.md | 7 + scripts/test-layout/layout-chat.json | 1 + src/bridge/response-json.ts | 4 +- src/bridge/sse.ts | 4 +- src/chat/inbound.ts | 15 +- src/protocols/encoders/adapter-events.ts | 4 +- src/server/chat-completions.ts | 2 + .../inference/client-encoder-delivery.ts | 1 + src/server/responses/adapter-delivery.ts | 3 + src/server/responses/core-options.ts | 2 + src/server/responses/run-turn-execution.ts | 2 + structure/data-planes/protocol-paths.md | 2 +- structure/providers/chat-compat.md | 11 +- tests/fixtures/test-layout-expected.json | 1 + .../chat-raw-reasoning-delivery.test.ts | 218 ++++++++++++++++++ 15 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 tests/responses/chat-raw-reasoning-delivery.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 61eb7839155..cdb85de0216 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -577,6 +577,13 @@ effort default to `reasoning.summary: "auto"` so thinking streams back as `reasoning.summary: "none"`. An explicit `reasoning.summary` of `auto`, `concise`, `detailed`, or `none` wins over `include_reasoning`. +Without reasoning options, raw reasoning already returned by the upstream is delivered as +`delta.reasoning_content` (streaming) or `message.reasoning_content` (buffered), without adding +reasoning options to the upstream request. Explicit thinking disable (`thinking.type: "disabled"` +or `reasoning.enabled: false`) and effort `none` also hide this raw text; an explicit enabled +boolean takes precedence over effort. Provider `hideRawReasoning` still applies. Provider-authored +summary visibility keeps its existing defaults; Responses and Anthropic Messages clients are unchanged. + When the model is Claude, the response also carries `reasoning_details` in the same shape OpenRouter uses: `{"type": "reasoning.text", "text", "signature", "format": "anthropic-claude-v1"}` for each signed thinking block and `{"type": "reasoning.encrypted", "data", ...}` for each redacted diff --git a/scripts/test-layout/layout-chat.json b/scripts/test-layout/layout-chat.json index 5085190f082..88e19056c9f 100644 --- a/scripts/test-layout/layout-chat.json +++ b/scripts/test-layout/layout-chat.json @@ -1,4 +1,5 @@ { + "chat-raw-reasoning-delivery.test.ts": "responses", "chat-incomplete-length-reason.test.ts": "responses", "openai-chat-required-tool.test.ts": "adapters/openai", "responses-required-chat-tool.test.ts": "responses" diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index a169e72256a..25200a43581 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -86,6 +86,8 @@ function buildResponseJSONWithBudget( modelId: string, options?: { hideThinkingSummary?: boolean; + /** Internal Chat delivery override for raw reasoning only; summaries keep their policy. */ + chatRawReasoningVisible?: boolean; /** Provider policy: suppress raw content-channel reasoning, keep provider-authored summaries. */ hideRawReasoning?: boolean; toolNsMap?: Map; @@ -297,7 +299,7 @@ function buildResponseJSONWithBudget( const rawText = joinChunks(currentRawReasoning); if (!rawText) return; rawReasoningForNextToolCall = rawText; - if (options?.hideThinkingSummary === true || options?.hideRawReasoning === true) { + if (!(options?.chatRawReasoningVisible ?? !options?.hideThinkingSummary) || options?.hideRawReasoning === true) { if (options?.omitHiddenReasoningEnvelope === true) { budget?.releaseRetained(currentRawReasoning.bytes, { kind: "reasoning" }); currentRawReasoning = emptyChunks(); diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 1e1a07268d1..954da659689 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -86,6 +86,8 @@ export function bridgeToResponsesSSE( */ localUpstream?: boolean; hideThinkingSummary?: boolean; + /** Internal Chat delivery override for raw reasoning only; summaries keep their policy. */ + chatRawReasoningVisible?: boolean; /** Provider policy: suppress raw content-channel reasoning, keep provider-authored summaries. */ hideRawReasoning?: boolean; /** @@ -1036,7 +1038,7 @@ export function bridgeToResponsesSSE( break; } case "reasoning_raw_delta": { - if (options?.hideThinkingSummary || options?.hideRawReasoning) { + if (!(options?.chatRawReasoningVisible ?? !options?.hideThinkingSummary) || options?.hideRawReasoning) { hiddenRawReasoning = appendString( hiddenRawReasoning, event.text, diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index a602efce8f7..2f22e256fba 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -436,6 +436,18 @@ function resolveReasoningSummary(raw: Rec): string | undefined { return undefined; } +function resolveThinkingEnabled(raw: Rec): boolean | undefined { + return isRec(raw.reasoning) && typeof raw.reasoning.enabled === "boolean" ? raw.reasoning.enabled + : isRec(raw.thinking) && ["enabled", "disabled"].includes(String(raw.thinking.type)) ? raw.thinking.type === "enabled" : undefined; +} + +/** Client delivery only: never put this default in the upstream body or parsed options. */ +export function chatRawReasoningVisible(raw: Rec): boolean { + if (resolveReasoningSummary(raw) === "none") return false; + const enabled = resolveThinkingEnabled(raw); + return enabled ?? resolveReasoningEffort(raw) !== "none"; +} + /** * Translate an OpenAI Chat Completions request body into a /v1/responses request body. * Throws ChatCompletionsRequestError (-> 400) on malformed input. @@ -672,8 +684,7 @@ function applyChatSettings(raw: ChatCompletionsRoutingBody, body: Rec): void { const effort = resolveReasoningEffort(raw); const summary = resolveReasoningSummary(raw); - const enabled = isRec(raw.reasoning) && typeof raw.reasoning.enabled === "boolean" ? raw.reasoning.enabled - : isRec(raw.thinking) && ["enabled", "disabled"].includes(String(raw.thinking.type)) ? raw.thinking.type === "enabled" : undefined; + const enabled = resolveThinkingEnabled(raw); if (effort || summary !== undefined || enabled !== undefined) { body.reasoning = { ...(effort ? { effort } : {}), diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index 4fc36ce90e3..de7a3a2a6ac 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -143,6 +143,8 @@ export interface ClientEncodeHooks { export interface AdapterEventEncodeOptions { translatorBudget: TranslatorBudget; hideThinkingSummary?: boolean; + /** Chat client delivery only, independent of upstream thinking/summary configuration. */ + chatRawReasoningVisible?: boolean; /** Provider policy: suppress raw content-channel reasoning, keep provider-authored summaries. */ hideRawReasoning?: boolean; toolNsMap?: ReadonlyMap; @@ -640,7 +642,7 @@ export function encodeAdapterEventStream( break; } case "reasoning_raw_delta": { - if (options.hideThinkingSummary || options.hideRawReasoning) { + if (!(options.chatRawReasoningVisible ?? !options.hideThinkingSummary) || options.hideRawReasoning) { hiddenRawBytes += Buffer.byteLength(event.text); break; } diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 44b30d48664..5efd2c7ac7f 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -8,6 +8,7 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { assertChatCompletionsRoutingBody, + chatRawReasoningVisible, ChatCompletionsRequestError, } from "../chat/inbound"; import { chatSettingsBody, chatToResponsesBody } from "../protocols/codecs/chat"; @@ -518,6 +519,7 @@ async function handleChatCompletionsWithBudget( abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", + chatRawReasoningVisible: chatRawReasoningVisible(chatBody), // A Chat caller may send an explicit empty `tools` array — a deny-all declaration the // translation drops along with the whole catalog. Carried separately so the // undeclared-tool guard can still enforce it; absent/partial catalogs stay deferred. diff --git a/src/server/inference/client-encoder-delivery.ts b/src/server/inference/client-encoder-delivery.ts index bfd0ec4deb8..ceddcd54961 100644 --- a/src/server/inference/client-encoder-delivery.ts +++ b/src/server/inference/client-encoder-delivery.ts @@ -87,6 +87,7 @@ export interface ClientEncodedDelivery { fold: { replayCacheScope?: OcxReasoningReplayScopeRef; hideThinkingSummary?: boolean; + chatRawReasoningVisible?: boolean; hideRawReasoning?: boolean; toolNsMap?: Map; declaredToolNames?: ReadonlySet; diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 34492d75e0e..40edc8f767d 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -234,6 +234,7 @@ export async function deliverAdapterResponse( fold: { replayCacheScope: parsed._reasoningReplayScope, hideThinkingSummary: parsed.options.hideThinkingSummary, + chatRawReasoningVisible: options.chatRawReasoningVisible, hideRawReasoning: parsed.options.hideRawReasoning, toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames, }, @@ -258,6 +259,7 @@ export async function deliverAdapterResponse( stallTimeoutSec: config.stallTimeoutSec, localUpstream, hideThinkingSummary: parsed.options.hideThinkingSummary, + chatRawReasoningVisible: options.chatRawReasoningVisible, hideRawReasoning: parsed.options.hideRawReasoning, declaredToolNames, bareCustomToolNames, @@ -365,6 +367,7 @@ export async function deliverAdapterResponse( translatorBudget, replayCacheScope: parsed._reasoningReplayScope, hideThinkingSummary: parsed.options.hideThinkingSummary, + chatRawReasoningVisible: options.chatRawReasoningVisible, hideRawReasoning: parsed.options.hideRawReasoning, toolNsMap, declaredToolNames, diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 195f2d0056e..e3b3be67cdd 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -268,6 +268,8 @@ export interface HandleResponsesOptions { * returning a Responses body, which the ingress converts as before. */ clientEncoder?: ClientEncoderOption; + /** Chat ingress raw-channel delivery preference; never an upstream generation option. */ + chatRawReasoningVisible?: boolean; /** Set only by the Chat or Messages ingress on an eligible Antigravity route; see {@link DirectRequestOption}. */ directRequest?: DirectRequestOption; } diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index c0e28cfd7c0..2af7e44355a 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -898,6 +898,7 @@ export async function executeResponsesRunTurn( ...(options.forceEmptyResponseId ? { responseId: "" } : {}), stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, + chatRawReasoningVisible: options.chatRawReasoningVisible, hideRawReasoning: parsed.options.hideRawReasoning, declaredToolNames, bareCustomToolNames, @@ -1055,6 +1056,7 @@ export async function executeResponsesRunTurn( translatorBudget, replayCacheScope: parsed._reasoningReplayScope, hideThinkingSummary: parsed.options.hideThinkingSummary, + chatRawReasoningVisible: options.chatRawReasoningVisible, hideRawReasoning: parsed.options.hideRawReasoning, toolNsMap, declaredToolNames, diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 16e940dd4ed..d30d7cd7be1 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -248,7 +248,7 @@ attempt is traced with `markAttemptProtocolPath`: the request path is still the side still decodes through the Responses projection, and the response path is `[upstream, "ir", inbound]`. When the encoder option carries `requestLane: "direct"` (the Antigravity direct lane) the request path is `[inbound, "ir", upstream]` and the mode -`translated`. +`translated`. Both paths separate Chat raw delivery from upstream thinking options under [reasoning display parity](../providers/chat-compat.md#reasoning-display-parity-hidethinkingsummary). ## Antigravity direct lane diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 47185905940..f170115c9cd 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -460,7 +460,7 @@ measurement rather than allocating a serialized copy just to measure it. > Decision record: [ADR-0067](../decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md) -`hideThinkingSummary` is set for explicit summary "none", or omitted summary without active thinking. An explicit `thinkingEnabled` boolean determines activity before effort is considered; otherwise accepted minimal/low/medium/high/xhigh/max (including ultra normalized to max) +For Responses delivery, `hideThinkingSummary` is set for explicit summary "none", or omitted summary without active thinking. An explicit `thinkingEnabled` boolean determines activity before effort is considered; otherwise accepted minimal/low/medium/high/xhigh/max (including ultra normalized to max) allow raw visibility when summary is omitted; none and invalid efforts do not. Explicit "auto" still permits raw visibility independently of effort. This flag is honored by BOTH reasoning paths: anthropic `thinking_delta` AND raw `reasoning_raw_delta` @@ -478,6 +478,15 @@ grouping evidence: `devlog/_fin/260709_native_response_pattern/`. Provider policy `hideRawReasoning` hides only this raw channel (summaries keep streaming); it controls display, not confidentiality ([Responses wire shapes](../transports/responses-wire-shapes.md)). +Chat ingress carries `chatRawReasoningVisible` separately from parsed options: raw reasoning already +produced upstream reaches `delta.reasoning_content` or buffered `message.reasoning_content` by default. +The existing explicit summary-over-`include_reasoning` precedence resolves display none first; +then an explicit thinking/enabled boolean wins over effort, and effort none hides raw text. +Provider `hideRawReasoning` still wins. Both bridge and direct encoders apply this only to the raw +channel; `thinking_delta` keeps its existing summary policy. No default reasoning field is injected +into the upstream body, and Responses and Messages ingress retain their existing behavior. +`tests/responses/chat-raw-reasoning-delivery.test.ts` pins delivery and unchanged upstream bytes. + For models requiring a reasoning placeholder, a preserved thinking-only assistant receives it even without tools, keeping strict DeepSeek continuations valid (#5421). Chat replay policy in `src/adapters/openai-chat/reasoning-replay.ts` honors explicit lists and otherwise recognizes thinking model families (`isThinkingModelFamily`): DeepSeek Flash/Pro (`isDeepSeekThinkingModel`) across versions and gateways, and Reflection/Stealth families (`isReflectionThinkingModel`: `/(?:^|\/)(?:reflection|stealth)(?:$|[-_:/.])/i`). Default replay policy sets `preserve: true` for all thinking model families; operators may override preservation via `provider.preserveReasoningContentModels` or placeholder synthesis via `provider.requiresReasoningPlaceholderModels`. While DeepSeek models scope reasoning replay to tool-enabled turns (`hasTools: true`), Reflection and Stealth families require reasoning replay across all prior assistant turns even when no tools are present (`hasTools: false`), synthesizing whitespace placeholder `[" "]` when `placeholder: true` and cached reasoning is absent. In `src/adapters/openai-chat/messages.ts`, `messagesToChatFormat` checks `isReflectionThinkingModel` to synthesize thinking placeholders when `wireSerializesThinking` is true. Upstream reasoning capture in `src/server/chat-native-reasoning.ts` extracts thinking from both `reasoning_content` and `reasoning` in SSE stream deltas and non-streaming choices. In `src/adapters/openai-chat/passthrough.ts`, `baseBytes` is measured unconditionally against `maxSerializedBytes - replayPlan.serializedBytes` via `jsonUtf8Bytes(transformedBody(), ...)` for both unchanged bodies and changed replays; exceeding budget raises `TranslatorBudgetExceededError("request_copies", maxSerializedBytes)` (HTTP 413), guaranteeing non-replay requests also enforce `maxSerializedBytes` and reserve transient budget. [Native continuation](../data-planes/inbound-compat.md#native-chat-thinking-continuation) shares this replay and byte-accounting policy. Model-contract evidence and compatibility policy: Verification date: 2026-10-25. The Reflection/Stealth family default is a defensive compatibility fallback policy based on observed vendor behavior: live testing against Vercel AI Gateway (`reflection-beam`, 2026-10-23) observed rejection of tool-free multi-turn requests with HTTP 400 (`Missing required parameter: 'messages[12].reasoning_content'`). Because this destination-specific observation is not a vendor guarantee across all possible stealth or reflection hosts, OpenCodex applies this heuristic as a fallback policy while providing explicit operator overrides via `preserveReasoningContentModels` and `requiresReasoningPlaceholderModels`. Continuous CI verification exercises hermetic local fixtures in `tests/responses/chat-native-reasoning-scope.test.ts` (pinning multi-turn non-tool replay, `reasoning` delta capture, and passthrough size checks) without live network requests. The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6959fb3a702..58843f8797e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -346,6 +346,7 @@ "messages-native-eligibility.test.ts": "responses", "messages-native-bridge-policy.test.ts": "responses", "responses-first-send-reservation.test.ts": "responses", "responses-first-send-spend-policy.test.ts": "responses", "responses-dispatch-receipt.test.ts": "responses", "responses-hosted-send-hop.test.ts": "responses", "responses-hosted-send-producer.test.ts": "responses", "responses-hosted-send-reservation.test.ts": "responses", "responses-hosted-send-round-parity.test.ts": "responses", "responses-hosted-send-unsent-hop.test.ts": "responses", "responses-hosted-send-ws.test.ts": "responses", "messages-native-oauth-eligibility.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", + "chat-raw-reasoning-delivery.test.ts": "responses", "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", "chat-anthropic-reasoning-details.test.ts": "responses", "chat-inline-document-bytes.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", diff --git a/tests/responses/chat-raw-reasoning-delivery.test.ts b/tests/responses/chat-raw-reasoning-delivery.test.ts new file mode 100644 index 00000000000..da1c057bd3d --- /dev/null +++ b/tests/responses/chat-raw-reasoning-delivery.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { chatCompletionsToResponsesBody } from "../../src/chat/inbound"; +import { parseRequest } from "../../src/responses/parser"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; +import { responsesJsonToChatCompletion, responsesSseToChatCompletionsSse } from "../../src/chat/outbound"; +import { foldChatCompletion } from "../../src/protocols/encoders/chat"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleResponses } from "../../src/server/responses/core"; +import type { RequestLogContext } from "../../src/server/request-log"; +import { protocolTraceForRequest } from "../../src/protocols/trace"; +import type { AdapterEvent, OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +type Rec = Record; +const THOUGHT = "FIXTURE_RAW_REASONING"; +const ANSWER = "FIXTURE_ANSWER"; +const originalFetch = globalThis.fetch; +let home: string; +let previousHome: string | undefined; +let codexHome: IsolatedCodexHome; +let releaseSpend: () => void; +let bodies: string[]; +let inlineTags: boolean; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + codexHome = installIsolatedCodexHome("ocx-chat-raw-codex-"); + home = mkdtempSync(join(tmpdir(), "ocx-chat-raw-")); + process.env.OPENCODEX_HOME = home; + releaseSpend = acquireOwnedSpendHome(); + bodies = []; + inlineTags = false; + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe("https://chat-reasoning.invalid/v1/chat/completions"); + bodies.push(String(init?.body)); + if (JSON.parse(bodies.at(-1)!).stream !== true) return Response.json({ + choices: [{ index: 0, message: { role: "assistant", content: ANSWER, reasoning_content: THOUGHT }, finish_reason: "stop" }], + usage: { prompt_tokens: 3, completion_tokens: 2 }, + }); + const frames = [ + { choices: [{ index: 0, delta: inlineTags ? { content: `${THOUGHT}` } : { reasoning_content: THOUGHT } }] }, + { choices: [{ index: 0, delta: { content: ANSWER } }] }, + { choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 3, completion_tokens: 2 } }, + ]; + return new Response(frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n", { + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; +}); + +afterEach(() => { + releaseSpend?.(); + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + codexHome?.restore(); + if (home) removeTreeWithRetry(home); +}); + +function config(direct: boolean, hideRawReasoning = false): OcxConfig { + const value: OcxConfig = { + port: 0, defaultProvider: "fixture", + providers: { fixture: { + adapter: "openai-chat", baseUrl: "https://chat-reasoning.invalid/v1", apiKey: "fixture-key", + models: ["gpt-4o"], liveModels: false, hideRawReasoning, + ...(inlineTags ? { inlineThinkTagModels: ["gpt-4o"] } : {}), + } }, + protocols: { rollout: { directEncoders: direct } }, + }; + saveConfig(value); + return value; +} + +function chatBody(extra: Rec = {}): Rec { + // store declines native passthrough; the reported defect is on the translated path. + return { model: "fixture/gpt-4o", store: true, messages: [{ role: "user", content: "fixture" }], ...extra }; +} + +function frames(text: string): Rec[] { + return text.split("\n").filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6))); +} + +async function dispatch(direct: boolean, stream: boolean, extra: Rec = {}, hideRaw = false) { + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(chatBody({ stream, ...extra })), + }), config(direct, hideRaw), logCtx); + expect(response.status).toBe(200); + const text = await response.text(); + expect(protocolTraceForRequest(logCtx, logCtx.attempts)?.responsePath).toEqual( + direct ? ["chat", "ir", "chat"] : ["chat", "ir", "responses-internal", "chat"], + ); + expect(bodies).toHaveLength(1); + const thought = stream + ? frames(text).map(frame => frame.choices?.[0]?.delta?.reasoning_content ?? "").join("") + : JSON.parse(text).choices[0].message.reasoning_content ?? ""; + const answer = stream + ? frames(text).map(frame => frame.choices?.[0]?.delta?.content ?? "").join("") + : JSON.parse(text).choices[0].message.content; + expect(answer).toBe(ANSWER); + return thought; +} + +describe("Chat raw reasoning delivery without upstream opt-in (#7750)", () => { + for (const direct of [false, true]) for (const stream of [true, false]) { + const lane = `${direct ? "direct encoder" : "bridge"}, ${stream ? "SSE" : "buffered"}`; + test(`${lane}: omitted reasoning delivers raw text and leaves the upstream body unchanged`, async () => { + const thought = await dispatch(direct, stream); + expect(JSON.parse(bodies[0]!)).not.toHaveProperty("reasoning"); + // Captured on unmodified origin/dev: pin the complete serialized upstream request. + expect(bodies[0]).toBe('{"model":"gpt-4o","messages":[{"role":"user","content":"fixture"}],"stream":true,"stream_options":{"include_usage":true}}'); + expect(thought).toBe(THOUGHT); + }); + for (const [name, extra] of [ + ["include false", { include_reasoning: false }], + ["summary none", { reasoning: { summary: "none" } }], + ["thinking disabled", { thinking: { type: "disabled" } }], + ["reasoning disabled", { reasoning: { enabled: false } }], + ["none effort", { reasoning_effort: "none" }], + ["include false with effort", { include_reasoning: false, reasoning_effort: "high" }], + ] as const) { + test(`${lane}: ${name} hides raw text`, async () => { + expect(await dispatch(direct, stream, extra)).toBe(""); + }); + } + test(`${lane}: explicit summary retains precedence over include false`, async () => { + expect(await dispatch(direct, stream, { include_reasoning: false, reasoning: { summary: "auto" } })).toBe(THOUGHT); + }); + test(`${lane}: provider hideRawReasoning still wins`, async () => { + expect(await dispatch(direct, stream, {}, true)).toBe(""); + }); + test(`${lane}: existing inline think tags use the same raw delivery default`, async () => { + inlineTags = true; + expect(await dispatch(direct, stream)).toBe(THOUGHT); + }); + test(`${lane}: explicit enabled wins over none effort`, async () => { + expect(await dispatch(direct, stream, { reasoning: { enabled: true, effort: "none" } })).toBe(THOUGHT); + }); + } + + test("translation and parsed upstream controls retain their dev values", () => { + expect(chatCompletionsToResponsesBody(chatBody())).not.toHaveProperty("reasoning"); + const plain = parseRequest(chatCompletionsToResponsesBody(chatBody())); + expect(plain.options.hideThinkingSummary).toBe(true); + expect(plain.options.reasoning).toBeUndefined(); + expect(plain.options.thinkingEnabled).toBeUndefined(); + const disabled = parseRequest(chatCompletionsToResponsesBody(chatBody({ thinking: { type: "disabled" } }))); + expect(disabled.options.thinkingEnabled).toBe(false); + const effort = parseRequest(chatCompletionsToResponsesBody(chatBody({ include_reasoning: false, reasoning_effort: "high" }))); + expect(effort.options.reasoning).toBe("high"); + expect(effort.options.hideThinkingSummary).toBe(true); + }); + + for (const stream of [true, false]) test(`Responses ingress still hides omitted-summary raw reasoning, stream=${stream}`, async () => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/gpt-4o", input: "fixture", stream }), + }), config(false), { model: "", provider: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).not.toContain(THOUGHT); + expect(text).toContain(ANSWER); + expect(text).toContain("ocxr1:"); + expect(JSON.parse(bodies[0]!)).not.toHaveProperty("reasoning"); + }); + + test("a plain Chat request to the Responses serializer gains no reasoning field", async () => { + const cfg = config(false); + cfg.providers.fixture!.adapter = "openai-responses"; + saveConfig(cfg); + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe("https://chat-reasoning.invalid/v1/responses"); + bodies.push(String(init?.body)); + return new Response([ + { type: "response.output_text.delta", item_id: "msg_fixture", output_index: 0, content_index: 0, delta: ANSWER }, + { type: "response.completed", response: { id: "resp_fixture", status: "completed", output: [ + { type: "message", id: "msg_fixture", role: "assistant", content: [{ type: "output_text", text: ANSWER }] }, + ] } }, + ].map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(chatBody({ stream: true })), + }), cfg, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.text()).toContain(ANSWER); + expect(bodies).toHaveLength(1); + expect(JSON.parse(bodies[0]!)).not.toHaveProperty("reasoning"); + }); + + test("the delivery override opens raw text only, including the JSON bridge fold", async () => { + const events: AdapterEvent[] = [ + { type: "thinking_delta", thinking: "FIXTURE_SUMMARY" }, + { type: "reasoning_raw_delta", text: THOUGHT }, + { type: "text_delta", text: ANSWER }, { type: "done" }, + ]; + const replay = async function* () { yield* events; }; + const options = { translatorBudget: createTestTranslatorBudget(), hideThinkingSummary: true, chatRawReasoningVisible: true }; + const json = buildResponseJSON(events, "fixture", options); + const buffered = responsesJsonToChatCompletion(json, "fixture", options.translatorBudget); + expect((buffered as Rec).choices[0].message.reasoning_content).toBe(THOUGHT); + const bridged = bridgeToResponsesSSE(replay(), "fixture", undefined, undefined, undefined, undefined, 2_000, options); + const text = await new Response(responsesSseToChatCompletionsSse(bridged, "fixture", options)).text(); + expect(text).toContain(THOUGHT); + expect(text).not.toContain("FIXTURE_SUMMARY"); + const direct = await (await foldChatCompletion(replay(), { ...options, model: "fixture" })).json() as Rec; + expect(direct.choices[0].message.reasoning_content).toBe(THOUGHT); + }); +}); From b207530dd4400ecf9d4e105219d401c8e8db5a3d Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:13:17 +0900 Subject: [PATCH 2/3] fix(chat): carry raw reasoning delivery through sidecar bridges Co-authored-by: thyjeff <183022254+thyjeff@users.noreply.github.com> --- src/images/loop.ts | 3 + src/server/responses/sidecar-execution.ts | 2 + src/web-search/loop.ts | 3 + structure/data-planes/protocol-paths.md | 2 +- structure/providers/chat-compat.md | 4 ++ structure/transports/responses.md | 2 +- .../chat-raw-reasoning-delivery.test.ts | 71 ++++++++++++++++++- 7 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/images/loop.ts b/src/images/loop.ts index 5dd189f8398..fdfd492e1db 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -307,6 +307,8 @@ function loopFailure(e: LoopError): SidecarLoopFailure { export interface ImageBridgeDeps { thinkingRecovery?: ReturnType; parsed: OcxParsedRequest; + /** Chat delivery only; never copied into parsed generation options. */ + chatRawReasoningVisible?: boolean; clientToolCatalogRestriction?: ClientToolCatalogRestriction; adapter: ProviderAdapter; incomingMeta: IncomingMeta; @@ -1213,6 +1215,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise void; let bodies: string[]; let inlineTags: boolean; +let sidecar: "web-search" | "video" | undefined; beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; @@ -37,6 +39,7 @@ beforeEach(() => { releaseSpend = acquireOwnedSpendHome(); bodies = []; inlineTags = false; + sidecar = undefined; globalThis.fetch = (async (input, init) => { expect(String(input)).toBe("https://chat-reasoning.invalid/v1/chat/completions"); bodies.push(String(init?.body)); @@ -74,6 +77,14 @@ function config(direct: boolean, hideRawReasoning = false): OcxConfig { } }, protocols: { rollout: { directEncoders: direct } }, }; + if (sidecar === "web-search") value.webSearchSidecar = { backend: "exa", exaApiKey: "fixture-search-key" }; + if (sidecar === "video") { + value.images = { videoBridgeEnabled: true }; + value.providers.xai = { + adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "fixture-video-key", + models: ["grok-imagine-video"], liveModels: false, + }; + } saveConfig(value); return value; } @@ -96,7 +107,7 @@ async function dispatch(direct: boolean, stream: boolean, extra: Rec = {}, hideR expect(response.status).toBe(200); const text = await response.text(); expect(protocolTraceForRequest(logCtx, logCtx.attempts)?.responsePath).toEqual( - direct ? ["chat", "ir", "chat"] : ["chat", "ir", "responses-internal", "chat"], + direct && !sidecar ? ["chat", "ir", "chat"] : ["chat", "ir", "responses-internal", "chat"], ); expect(bodies).toHaveLength(1); const thought = stream @@ -109,6 +120,64 @@ async function dispatch(direct: boolean, stream: boolean, extra: Rec = {}, hideR return thought; } +function assertSidecarRequest(): void { + // Synthetic tool admission proves the sidecar executed even though the fixture never calls it. + const body = JSON.parse(bodies[0]!); + expect(body.tools.map((tool: Rec) => tool.function.name)).toEqual([sidecar === "web-search" ? "web_search" : "video_gen"]); + expect(body).not.toHaveProperty("chatRawReasoningVisible"); + expect(body).not.toHaveProperty("reasoning"); + expect(body).not.toHaveProperty("reasoning_effort"); + expect(body).not.toHaveProperty("thinking"); +} + +describe("Chat raw reasoning through sidecar delivery (#7750)", () => { + for (const path of ["web-search", "video"] as const) for (const direct of [false, true]) for (const stream of [true, false]) { + const lane = `${path}, direct=${direct}, stream=${stream}`; + const tools = path === "web-search" ? { tools: [{ type: "web_search" }] } : {}; + test(`${lane}: raw delivery preference leaves upstream bytes unchanged`, async () => { + sidecar = path; + await dispatch(direct, stream, tools); + const visibleBody = bodies[0]; + assertSidecarRequest(); + bodies = []; + expect(await dispatch(direct, stream, { ...tools, include_reasoning: false })).toBe(""); + expect(bodies[0]).toBe(visibleBody); + }); + for (const policy of ["default", "include false", "provider hide"] as const) { + test(`${lane}: ${policy}`, async () => { + sidecar = path; + const thought = await dispatch(direct, stream, { ...tools, ...(policy === "include false" ? { include_reasoning: false } : {}) }, policy === "provider hide"); + assertSidecarRequest(); + expect(thought).toBe(policy === "default" ? THOUGHT : ""); + }); + } + for (const ingress of ["responses", "messages"] as const) { + test(`${lane}: ${ingress} ingress keeps its omitted-reasoning default`, async () => { + sidecar = path; + const body = ingress === "responses" + ? { model: "fixture/gpt-4o", input: "fixture", stream, ...tools } + : { model: "fixture/gpt-4o", messages: [{ role: "user", content: "fixture" }], max_tokens: 64, stream, + ...(path === "web-search" ? { tools: [{ type: "web_search_20250305", name: "web_search" }] } : {}) }; + const request = new Request(`http://localhost/v1/${ingress}`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }); + const cfg = config(direct); + const response = ingress === "responses" + ? await handleResponses(request, cfg, { model: "", provider: "" }) + : await handleClaudeMessages(request, cfg, { model: "", provider: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).not.toContain(THOUGHT); + expect(text).toContain(ANSWER); + expect(bodies).toHaveLength(1); + // Responses does not activate the video loop for buffered requests; Chat/Messages + // internally request SSE even when their caller asks for a buffered response. + if (path !== "video" || ingress !== "responses" || stream) assertSidecarRequest(); + }); + } + } +}); + describe("Chat raw reasoning delivery without upstream opt-in (#7750)", () => { for (const direct of [false, true]) for (const stream of [true, false]) { const lane = `${direct ? "direct encoder" : "bridge"}, ${stream ? "SSE" : "buffered"}`; From dc6af2cd8efc890398def8be26b4d749ffa8208f Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:49:06 +0900 Subject: [PATCH 3/3] fix(chat): read raw reasoning visibility before the Droid effort default The exported Droid header supplies a generation default that is written into the Chat body after routing. Computing the delivery choice afterwards read a configured "none" default as a caller request to hide raw reasoning. Co-authored-by: thyjeff <183022254+thyjeff@users.noreply.github.com> --- src/server/chat-completions.ts | 4 +++- .../responses/chat-raw-reasoning-delivery.test.ts | 15 +++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 5efd2c7ac7f..f30b65f2602 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -178,6 +178,8 @@ async function handleChatCompletionsWithBudget( const droidDefaultEffort = effortRow ? undefined : droidReasoningDefault(req.headers.get(DROID_DEFAULT_EFFORT_HEADER), chatBody); + // Read the caller's delivery choice before any generation default (Droid effort) is written. + const rawReasoningVisible = chatRawReasoningVisible(chatBody); if (effortRow) chatBody.model = effortRow.baseId; if (fastRow) { chatBody.model = fastRow.baseId; @@ -519,7 +521,7 @@ async function handleChatCompletionsWithBudget( abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", - chatRawReasoningVisible: chatRawReasoningVisible(chatBody), + chatRawReasoningVisible: rawReasoningVisible, // A Chat caller may send an explicit empty `tools` array — a deny-all declaration the // translation drops along with the whole catalog. Carried separately so the // undeclared-tool guard can still enforce it; absent/partial catalogs stay deferred. diff --git a/tests/responses/chat-raw-reasoning-delivery.test.ts b/tests/responses/chat-raw-reasoning-delivery.test.ts index 0e116af37ee..06ba378e670 100644 --- a/tests/responses/chat-raw-reasoning-delivery.test.ts +++ b/tests/responses/chat-raw-reasoning-delivery.test.ts @@ -67,13 +67,14 @@ afterEach(() => { if (home) removeTreeWithRetry(home); }); -function config(direct: boolean, hideRawReasoning = false): OcxConfig { +function config(direct: boolean, hideRawReasoning = false, providerExtra: Rec = {}): OcxConfig { const value: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: { adapter: "openai-chat", baseUrl: "https://chat-reasoning.invalid/v1", apiKey: "fixture-key", models: ["gpt-4o"], liveModels: false, hideRawReasoning, ...(inlineTags ? { inlineThinkTagModels: ["gpt-4o"] } : {}), + ...providerExtra, } }, protocols: { rollout: { directEncoders: direct } }, }; @@ -99,11 +100,11 @@ function frames(text: string): Rec[] { .map(line => JSON.parse(line.slice(6))); } -async function dispatch(direct: boolean, stream: boolean, extra: Rec = {}, hideRaw = false) { +async function dispatch(direct: boolean, stream: boolean, extra: Rec = {}, hideRaw = false, headers: Rec = {}, providerExtra: Rec = {}) { const logCtx: RequestLogContext = { model: "", provider: "" }; const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { - method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(chatBody({ stream, ...extra })), - }), config(direct, hideRaw), logCtx); + method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(chatBody({ stream, ...extra })), + }), config(direct, hideRaw, providerExtra), logCtx); expect(response.status).toBe(200); const text = await response.text(); expect(protocolTraceForRequest(logCtx, logCtx.attempts)?.responsePath).toEqual( @@ -213,6 +214,12 @@ describe("Chat raw reasoning delivery without upstream opt-in (#7750)", () => { test(`${lane}: explicit enabled wins over none effort`, async () => { expect(await dispatch(direct, stream, { reasoning: { enabled: true, effort: "none" } })).toBe(THOUGHT); }); + test(`${lane}: a configured Droid default effort is not a caller hide request`, async () => { + // The exported Droid header supplies a generation default, applied after routing; it must not + // be read back as the caller's delivery choice. + expect(await dispatch(direct, stream, {}, false, + { "x-opencodex-droid-default-effort": "none" }, { reasoningEfforts: ["none", "low", "high"] })).toBe(THOUGHT); + }); } test("translation and parsed upstream controls retain their dev values", () => {