diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 786a56383fb..efaf75a48d1 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/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; @@ -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..f30b65f2602 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"; @@ -177,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; @@ -518,6 +521,7 @@ async function handleChatCompletionsWithBudget( abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", + 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/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 f027351cd47..cb92ad07e48 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -270,6 +270,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/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 529d3dcce68..1b8cebc37e8 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -555,6 +555,7 @@ export async function executeResponsesSidecars( ); const imgLoopResponse = await runWithImageBridge({ parsed, adapter: transportState.adapter, + chatRawReasoningVisible: options.chatRawReasoningVisible, ...(transportState.adapter.name === "anthropic" ? { thinkingRecovery } : {}), clientToolCatalogRestriction: route.provider.authMode === "forward" ? undefined : requestState.clientToolCatalogRestriction, incomingMeta: { headers: requestState.selectedForwardHeaders, providerName: route.providerName, abortSignal: options.abortSignal, translatorBudget, ...(options.comboInitialSend ? { providerFetch: imageProviderFetch } : {}), ...sidecarBudget.incomingMeta }, @@ -662,6 +663,7 @@ export async function executeResponsesSidecars( })(input, init)) as typeof globalThis.fetch; const loopResponse = await runWithWebSearch({ parsed, adapter: transportState.adapter, + chatRawReasoningVisible: options.chatRawReasoningVisible, ...(transportState.adapter.name === "anthropic" ? { thinkingRecovery } : {}), clientToolCatalogRestriction: route.provider.authMode === "forward" ? undefined : requestState.clientToolCatalogRestriction, onIterationEnd: sidecarBudget.releaseUnsentHop, diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 83bcadecb31..c4a51b2ea0d 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -309,6 +309,8 @@ function loopFailure(e: LoopError): SidecarLoopFailure { */ export interface WebSearchLoopDeps { parsed: OcxParsedRequest; + /** Chat delivery only; never copied into parsed generation options. */ + chatRawReasoningVisible?: boolean; clientToolCatalogRestriction?: ClientToolCatalogRestriction; adapter: ProviderAdapter; incomingMeta: IncomingMeta; @@ -1209,6 +1211,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise 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` @@ -479,6 +479,19 @@ 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. +`src/server/responses/sidecar-execution.ts` forwards the same delivery-only flag through +`WebSearchLoopDeps` and `ImageBridgeDeps` to `src/web-search/loop.ts` and `src/images/loop.ts`. +These sidecar bridges use internal Responses SSE even with direct encoders enabled; Chat folds +that stream for buffered callers. Parsed generation options and upstream bytes remain unchanged. +`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/structure/transports/responses.md b/structure/transports/responses.md index 83fad737cdd..1ce74f5b921 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -428,7 +428,7 @@ Final-route normalization in `src/server/responses/core-normalize.ts` decodes cl | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | | `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads to retain only allowlisted error type and code, keep status and non-replayability plus `x-should-retry: false` when present, and withhold upstream body text and all other upstream headers; the client receives a fixed generic message and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | | `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | -| `sidecar-execution.ts`, `sidecar-send-budget.ts` | Image/video versus web-search execution, per-build adapter prompt estimates for usage logging, shared rotation, and adapter-aware prepaid inference/producer ownership under the [spend contract](responses-spend.md#prepaid-initial-sends). | +| `sidecar-execution.ts`, `sidecar-send-budget.ts` | Image/video versus web-search execution, Chat raw-reasoning preference forwarded to both loop bridges under [reasoning display parity](../providers/chat-compat.md#reasoning-display-parity-hidethinkingsummary), per-build adapter prompt estimates for usage logging, shared rotation, and adapter-aware prepaid inference/producer ownership under the [spend contract](responses-spend.md#prepaid-initial-sends). | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns; refreshed retries reject previously failed key bindings and revalidate after each queued admission; adapter-owned CLI spawns receive the post-setup `IncomingMeta.beforePhysicalSend` fence, with [replacement-key readmission](responses-failover.md#upstream-key-attempt-accounting). | | `adapter-dispatch.ts` | Translated initial dispatch, bounded recovery (including [Anthropic thinking-signature recovery](anthropic-thinking-recovery.md)) and the shared continuation retry counter. Initial and rebuilt adapter budget refusals retain the HTTP 413 [byte-accounting contract](byte-accounting.md#request-copy-accounting); ordinary build errors remain 400. | | `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; Anthropic routed summaries pass through `src/responses/compaction-summary.ts` before either bridge. A streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d790e5fb463..8e69fd55d99 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -348,6 +348,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..06ba378e670 --- /dev/null +++ b/tests/responses/chat-raw-reasoning-delivery.test.ts @@ -0,0 +1,294 @@ +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 { handleClaudeMessages } from "../../src/server/claude-messages"; +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; +let sidecar: "web-search" | "video" | undefined; + +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; + sidecar = undefined; + 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, 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 } }, + }; + 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; +} + +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, 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", ...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( + direct && !sidecar ? ["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; +} + +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"}`; + 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(`${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", () => { + 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); + }); +});