From 9689dcef9fc500d8dad91d78b2b95401aace4bb4 Mon Sep 17 00:00:00 2001 From: Ke Jin Date: Sun, 23 Aug 2026 09:58:58 +0800 Subject: [PATCH] fix(reasoning): preserve mixed plaintext and drop incompatible state (#10949, #10959) (#10961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined batch board + this branch: 231/231 across chatcore-translation-paths, reasoning-cache, strip-reasoning-blobs, and both Responses translator suites. Pre-merge: propagated the #11110/#11129 summary:[] defaults into five assertions here (each commented with its PR) — without it this branch red against the tip, and as a bonus the merge drains the 4 reasoning reds that were live on the tip from those merges. Plaintext now wins over a coexisting opaque companion; opaque-only drops cleanly for plaintext targets; combos keep explicit Skip. Fixes #10949 and #10959. Thank you @jackjinke! --- .../10550-responses-reasoning-transport.md | 2 +- .../fixes/10949-mixed-reasoning-plaintext.md | 1 + open-sse/executors/codex.ts | 1 - open-sse/handlers/chatCore.ts | 4 +- open-sse/services/reasoningInputPolicy.ts | 48 ++++---- .../translator/request/openai-responses.ts | 12 +- src/app/(dashboard)/dashboard/combos/page.tsx | 9 -- src/sse/handlers/chat.ts | 2 +- src/sse/handlers/chatHelpers.ts | 2 +- tests/unit/chatcore-translation-paths.test.ts | 49 +++++---- tests/unit/reasoning-cache.test.ts | 103 +++++++++++------- ...asoning-blobs-agentic-context-1599.test.ts | 60 ++++++++-- .../translator-openai-responses-req.test.ts | 38 +++---- .../translator-resp-openai-responses.test.ts | 46 ++++++++ 14 files changed, 233 insertions(+), 144 deletions(-) create mode 100644 changelog.d/fixes/10949-mixed-reasoning-plaintext.md diff --git a/changelog.d/fixes/10550-responses-reasoning-transport.md b/changelog.d/fixes/10550-responses-reasoning-transport.md index d34c433deb..e2b40cdb8c 100644 --- a/changelog.d/fixes/10550-responses-reasoning-transport.md +++ b/changelog.d/fixes/10550-responses-reasoning-transport.md @@ -1 +1 @@ -- Preserve portable plaintext reasoning by default across streaming and non-streaming Chat Completions and Responses routes while keeping provider-bound opaque state target-compatible. Combos now drop incompatible continuation reasoning by default and can explicitly skip incompatible targets, while known providers no longer show redundant encrypted-reasoning controls. (#10550) +- Preserve portable plaintext reasoning by default across streaming and non-streaming Chat Completions and Responses routes while keeping provider-bound opaque state target-compatible. Direct requests drop incompatible continuation reasoning by default; combos can explicitly skip incompatible targets without mutating the request. Known providers no longer show redundant encrypted-reasoning controls. (#10550, #10959) diff --git a/changelog.d/fixes/10949-mixed-reasoning-plaintext.md b/changelog.d/fixes/10949-mixed-reasoning-plaintext.md new file mode 100644 index 0000000000..05a055ec53 --- /dev/null +++ b/changelog.d/fixes/10949-mixed-reasoning-plaintext.md @@ -0,0 +1 @@ +- Preserve explicit plaintext reasoning when a Responses reasoning item also carries opaque provider state (rare OpenCode Go `deepseek-v4-flash` responses). Mixed plaintext + opaque input is projected onto the target transport: plaintext targets keep portable text, opaque targets keep provider state. Opaque-only reasoning is dropped when the selected target cannot replay it, allowing cross-model conversations to continue. (#10949, #10959) diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 6e39bb81ca..b9f6cf113a 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -1398,7 +1398,6 @@ export class CodexExecutor extends BaseExecutor { provider: "codex", preserveEncryptedReasoning: credentials?.providerSpecificData?.preserveEncryptedReasoning === true, - onIncompatibleReasoning: "drop", }); if (nativeCodexPassthrough) { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8f11373b1f..fea8640f9d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -516,7 +516,7 @@ export async function handleChatCore({ conversationId = null, modelPinned = false, skipResourcePressureGuard = false, - reasoningTransportFallback = "skip", + reasoningTransportFallback = "drop", managedLease = null, }) { let { provider, model, extendedContext } = modelInfo; @@ -1213,7 +1213,7 @@ export async function handleChatCore({ provider, preserveEncryptedReasoning: credentials?.providerSpecificData?.preserveEncryptedReasoning === true, - onIncompatibleReasoning: reasoningTransportFallback === "drop" ? "drop" : "reject", + onIncompatibleReasoning: reasoningTransportFallback === "skip" ? "reject" : "drop", } ); if (policy.incompatibleReasoning) { diff --git a/open-sse/services/reasoningInputPolicy.ts b/open-sse/services/reasoningInputPolicy.ts index 94f27ddcdc..0e9e8d194d 100644 --- a/open-sse/services/reasoningInputPolicy.ts +++ b/open-sse/services/reasoningInputPolicy.ts @@ -37,7 +37,6 @@ export interface ReasoningInputPolicyOptions { export interface ReasoningInputPolicyResult { incompatibleReasoning: boolean; } - export function resolveReasoningTransport( provider: string | null | undefined, preserveEncryptedReasoning = false @@ -47,18 +46,6 @@ export function resolveReasoningTransport( return transport ?? (preserveEncryptedReasoning ? "opaque" : "plaintext"); } -export function createReasoningTransportIncompatibleError(): Error & { - statusCode: number; - errorType: string; -} { - const error = new Error( - "Reasoning continuation is not compatible with the selected target" - ) as Error & { statusCode: number; errorType: string }; - error.statusCode = 400; - error.errorType = "reasoning_transport_incompatible"; - return error; -} - function asRecord(value: unknown): JsonRecord | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; } @@ -101,11 +88,12 @@ function hasChatPlaintextReasoning(record: JsonRecord): boolean { /** * Returns only provider-authentic plaintext continuation state. Display summaries - * are excluded, and a record carrying opaque state is never cross-converted. + * and opaque-only records are excluded. Explicit plaintext remains independently + * portable when the same record also carries an opaque companion (#10949). */ export function extractReplayableResponsesReasoningText(value: unknown): string { const record = asRecord(value); - if (!record || record.type !== "reasoning" || hasOpaqueReasoningState(record)) return ""; + if (!record || record.type !== "reasoning") return ""; if (!Array.isArray(record.content)) return ""; return record.content @@ -307,9 +295,9 @@ function sanitizeResponsesInput( } /** - * Applies one protocol-independent compatibility decision before request translation. - * Plaintext is portable by default; opaque state requires an explicit target declaration. - * Display summaries do not affect compatibility; stateless input drops orphan summaries. + * Projects reasoning continuation onto the selected target transport. + * Incompatible active state is dropped by default; combo routing may reject an + * attempt instead so it can fall through without mutating the request. */ export function applyReasoningInputPolicy( body: Record, @@ -321,14 +309,17 @@ export function applyReasoningInputPolicy( inputFormat === "responses" ? inspectResponsesReasoning(body.input) : inspectChatReasoning(body.messages); - const incompatibleReasoning = !isReasoningCompatible(inspection, transport); + const mixedState = inspection.hasPlaintext && inspection.hasOpaque; + const incompatibleReasoning = !mixedState && !isReasoningCompatible(inspection, transport); + // Mixed plaintext + opaque input (#10949) is never a rejection: it is projected + // onto the target transport by the per-item sanitizers below. - if (incompatibleReasoning && options.onIncompatibleReasoning !== "drop") { + if (incompatibleReasoning && options.onIncompatibleReasoning === "reject") { return { incompatibleReasoning: true }; } if (inputFormat === "chat") { - if (incompatibleReasoning && Array.isArray(body.messages)) { + if ((incompatibleReasoning || mixedState) && Array.isArray(body.messages)) { body.messages = dropIncompatibleChatReasoning(body.messages, transport); } return { incompatibleReasoning: false }; @@ -343,12 +334,13 @@ export function applyReasoningInputPolicy( }, ]; } - if (!Array.isArray(body.input)) return { incompatibleReasoning: false }; - body.input = sanitizeResponsesInput( - body.input, - transport, - incompatibleReasoning, - body.store === false - ); + if (Array.isArray(body.input)) { + body.input = sanitizeResponsesInput( + body.input, + transport, + incompatibleReasoning || mixedState, + body.store === false + ); + } return { incompatibleReasoning: false }; } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index b886650252..d48dcdf181 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -8,11 +8,7 @@ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; import { FORMATS } from "../formats.ts"; import { register } from "../registry.ts"; import { normalizeResponsesInputForChat } from "../../utils/responsesInputNormalization.ts"; -import { - createReasoningTransportIncompatibleError, - hasOpaqueReasoningState, - extractReplayableResponsesReasoningText, -} from "../../services/reasoningInputPolicy.ts"; +import { extractReplayableResponsesReasoningText } from "../../services/reasoningInputPolicy.ts"; import { getRegisteredProviders, requiresPlainStringContent, @@ -454,10 +450,8 @@ export function openaiResponsesToOpenAIRequest( if (itemType === "reasoning") { // Only genuine plaintext reasoning can cross into Chat reasoning_content. - // Opaque encrypted state and its display summary have no Chat replay form. - if (preserveReasoningContent && hasOpaqueReasoningState(item)) { - throw createReasoningTransportIncompatibleError(); - } + // Opaque encrypted state and its display summary have no Chat replay form, + // so opaque-only items are dropped while mixed items replay their plaintext. if (preserveReasoningContent) { const reasoning = extractReplayableResponsesReasoningText(item); if (reasoning) { diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 7cb1c6c0ee..7ca3af2024 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -3896,15 +3896,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo )} - {config.reasoningTransportFallback !== "skip" && ( -

- {getI18nOrFallback( - t, - "reasoningTransportFallbackDropWarning", - "May lose continuation context or cause tool-call continuations to fail." - )} -

- )}
{ diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 08cc564cc4..6ecb048788 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -369,7 +369,7 @@ async function invokeChatCore({ onCredentialsRefreshed = null, onRequestSuccess = null, sessionAffinityKey = null, - reasoningTransportFallback = "skip", + reasoningTransportFallback = "drop", managedLease = null, cachedSettings = null, }: any = {}) { @@ -631,7 +631,7 @@ test("chatCore translates a streaming Responses upstream for a Chat client", asy assert.match(streamed, /"content":"ok"/); assert.match(streamed, /data: \[DONE\]/); }); -test("chatCore rejects opaque reasoning for unknown Responses targets unless explicitly enabled", async () => { +test("chatCore drops opaque reasoning for plaintext Responses targets by default (#10959)", async () => { const reasoningItems = [ { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }, { type: "reasoning", encrypted_content: "" }, @@ -640,7 +640,7 @@ test("chatCore rejects opaque reasoning for unknown Responses targets unless exp { id: "fc_call", type: "function_call", call_id: "call_1", name: "search", arguments: "{}" }, ]; - const rejected = await invokeChatCore({ + const dropped = await invokeChatCore({ provider: "openai-compatible-sp-openai", model: "gpt-5.4", endpoint: "/v1/responses", @@ -656,9 +656,12 @@ test("chatCore rejects opaque reasoning for unknown Responses targets unless exp responseFormat: "openai-responses", }); - assert.equal(rejected.result.success, false); - assert.equal(rejected.result.status, 400); - assert.equal(rejected.calls.length, 0); + assert.equal(dropped.result.success, true); + assert.equal(dropped.calls.length, 1); + assert.deepEqual( + dropped.call.body.input.filter((item) => item.type === "reasoning"), + [{ type: "reasoning", summary: [{ text: "not self-contained" }] }] + ); const enabled = await invokeChatCore({ provider: "openai-compatible-sp-openai", @@ -682,7 +685,7 @@ test("chatCore rejects opaque reasoning for unknown Responses targets unless exp assert.deepEqual( input.filter((item) => item.type === "reasoning"), [ - { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }, + { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob", summary: [] }, // summary defaulted by #11110 { type: "reasoning", summary: [{ text: "not self-contained" }] }, ] ); @@ -693,9 +696,9 @@ test("chatCore rejects opaque reasoning for unknown Responses targets unless exp assert.equal(input.find((item) => item.type === "function_call")?.id, undefined); }); -test("chatCore applies Chat reasoning compatibility before stream mode diverges", async () => { +test("chatCore drops incompatible Chat reasoning before stream mode diverges (#10959)", async () => { for (const stream of [false, true]) { - const rejected = await invokeChatCore({ + const dropped = await invokeChatCore({ provider: "openai-compatible-sp-openai", model: "gpt-5.4", endpoint: "/v1/chat/completions", @@ -728,14 +731,14 @@ test("chatCore applies Chat reasoning compatibility before stream mode diverges" }, }); - assert.equal(rejected.result.success, false, `stream=${stream}`); - assert.equal(rejected.result.status, 400, `stream=${stream}`); - assert.equal(rejected.calls.length, 0, `stream=${stream}`); + assert.equal(dropped.result.success, true, `stream=${stream}`); + assert.equal(dropped.calls.length, 1, `stream=${stream}`); + assert.equal(dropped.call.body.messages[0].reasoning_details, undefined, `stream=${stream}`); } }); -test("chatCore can drop incompatible reasoning for an opted-in Combo attempt", async () => { - const dropped = await invokeChatCore({ +test("chatCore preserves Combo skip behavior for incompatible reasoning", async () => { + const skipped = await invokeChatCore({ provider: "openai-compatible-sp-openai", model: "gpt-5.4", endpoint: "/v1/responses", @@ -757,15 +760,12 @@ test("chatCore can drop incompatible reasoning for an opted-in Combo attempt", a }, responseFormat: "openai-responses", isCombo: true, - reasoningTransportFallback: "drop", + reasoningTransportFallback: "skip", }); - assert.equal(dropped.result.success, true); - assert.equal(dropped.calls.length, 1); - assert.equal( - dropped.call.body.input.some((item) => item.type === "reasoning"), - false - ); + assert.equal(skipped.result.success, false); + assert.equal(skipped.result.status, 400); + assert.equal(skipped.calls.length, 0); }); test("chatCore carries Chat reasoning_content into official DeepSeek Responses input", async () => { @@ -800,6 +800,7 @@ test("chatCore carries Chat reasoning_content into official DeepSeek Responses i assert.deepEqual(call.body.input.slice(0, 3), [ { type: "reasoning", + summary: [], // defaulted on freshly-built reasoning items (#11129) content: [{ type: "reasoning_text", text: "Inspect before calling the tool" }], }, { @@ -866,7 +867,7 @@ test("chatCore replays nonstream DeepSeek Responses reasoning across a Chat tool assert.equal(second.result.success, true); assert.deepEqual( second.call.body.input.find((item) => item.type === "reasoning"), - { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }] } + { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }], summary: [] } // summary defaulted by #11129 ); }); @@ -930,7 +931,7 @@ test("chatCore replays streamed DeepSeek Responses reasoning across a Chat tool assert.equal(second.result.success, true); assert.deepEqual( second.call.body.input.find((item) => item.type === "reasoning"), - { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }] } + { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }], summary: [] } // summary defaulted by #11129 ); }); @@ -1132,7 +1133,7 @@ test("chatCore automatically preserves provider-generated opaque reasoning for C assert.equal(result.success, true); assert.deepEqual( call.body.input.filter((item) => item.type === "reasoning"), - [{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }] + [{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob", summary: [] }] // summary defaulted by #11110 ); assert.equal( call.body.input.some((item) => item.type === "item_reference"), diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts index aded469ca8..db538a8031 100644 --- a/tests/unit/reasoning-cache.test.ts +++ b/tests/unit/reasoning-cache.test.ts @@ -752,47 +752,70 @@ describe("Reasoning Replay Cache — Translator Replay", () => { assert.equal(lookupReasoning(callId), "Authentic provider reasoning"); }); - it("should never cache Responses summaries or opaque plaintext companions", () => { - for (const [suffix, reasoningItem] of [ - [ - "summary", - { - type: "reasoning", - summary: [{ type: "summary_text", text: "Display-only summary" }], - }, - ], - [ - "mixed", - { - type: "reasoning", - encrypted_content: "opaque-provider-state", - content: [{ type: "reasoning_text", text: "Unsafe plaintext companion" }], - summary: [{ type: "summary_text", text: "Display-only mixed summary" }], - }, - ], - ] as const) { - clearReasoningCacheAll(); - const callId = `call_nonstream_${suffix}_reasoning`; - const translated = translateNonStreamingResponse( - { - object: "response", - model: "deepseek-v4-flash", - output: [ - reasoningItem, - { type: "function_call", call_id: callId, name: "read_file", arguments: "{}" }, - ], - }, - FORMATS.OPENAI_RESPONSES, - FORMATS.OPENAI - ) as { choices?: Array<{ message?: Record }> }; - const message = translated.choices?.[0]?.message; + it("preserves plaintext reasoning from a mixed plaintext + encrypted_content item (#10949)", () => { + clearReasoningCacheAll(); + const callId = "call_nonstream_mixed_reasoning"; + const translated = translateNonStreamingResponse( + { + object: "response", + model: "deepseek-v4-flash", + output: [ + { + type: "reasoning", + content: [ + { + type: "reasoning_text", + text: "Let me start by reading the directory to understand the structure of the corpus.", + }, + ], + encrypted_content: "", + summary: [], + }, + { type: "function_call", call_id: callId, name: "read_file", arguments: "{}" }, + ], + }, + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI + ) as { choices?: Array<{ message?: Record }> }; + const message = translated.choices?.[0]?.message; - assert.ok(message); - assert.equal(message.reasoning_content, undefined); - assert.ok(Array.isArray(message.reasoning_summary)); - assert.equal(cacheReasoningFromAssistantMessage(message, "deepseek", "deepseek-v4-flash"), 0); - assert.equal(lookupReasoning(callId), null); - } + assert.ok(message); + assert.equal( + message.reasoning_content, + "Let me start by reading the directory to understand the structure of the corpus." + ); + assert.equal(cacheReasoningFromAssistantMessage(message, "deepseek", "deepseek-v4-flash"), 1); + assert.equal( + lookupReasoning(callId), + "Let me start by reading the directory to understand the structure of the corpus." + ); + }); + + it("should never cache summary-only Responses reasoning", () => { + clearReasoningCacheAll(); + const callId = "call_nonstream_summary_reasoning"; + const translated = translateNonStreamingResponse( + { + object: "response", + model: "deepseek-v4-flash", + output: [ + { + type: "reasoning", + summary: [{ type: "summary_text", text: "Display-only summary" }], + }, + { type: "function_call", call_id: callId, name: "read_file", arguments: "{}" }, + ], + }, + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI + ) as { choices?: Array<{ message?: Record }> }; + const message = translated.choices?.[0]?.message; + + assert.ok(message); + assert.equal(message.reasoning_content, undefined); + assert.ok(Array.isArray(message.reasoning_summary)); + assert.equal(cacheReasoningFromAssistantMessage(message, "deepseek", "deepseek-v4-flash"), 0); + assert.equal(lookupReasoning(callId), null); }); it("should preserve client-provided reasoning content", () => { diff --git a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts index 25eae7a839..8b3059236d 100644 --- a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts +++ b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts @@ -8,7 +8,7 @@ import { omitEncryptedReasoningForLog } from "../../src/lib/logPayloads.ts"; // Responses reasoning replay is target-scoped. Plaintext DeepSeek state and // provider-generated opaque state are never interchangeable. -test("unknown Responses targets reject opaque reasoning and ignore display summaries", () => { +test("unknown Responses targets drop opaque reasoning and preserve display summaries (#10959)", () => { const body: Record = { input: [ { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, @@ -24,11 +24,14 @@ test("unknown Responses targets reject opaque reasoning and ignore display summa ], }; - const originalInput = structuredClone(body.input); const result = applyReasoningInputPolicy(body, "responses"); - assert.equal(result.incompatibleReasoning, true); - assert.deepEqual(body.input, originalInput, "rejection must not mutate the request"); + assert.equal(result.incompatibleReasoning, false); + assert.deepEqual(body.input, [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + { type: "reasoning", summary: [{ text: "display only" }] }, + { type: "function_call", name: "search", arguments: "{}", call_id: "call_1" }, + ]); }); test("unannotated targets preserve plaintext Responses reasoning without synthetic IDs", () => { @@ -132,7 +135,7 @@ test("Chat drop removes opaque state while preserving plaintext and summary deta ]); }); -test("DeepSeek rejects plaintext reasoning carrying opaque provider state", () => { +test("DeepSeek projects plaintext reasoning carrying opaque provider state onto the plaintext transport (#10949)", () => { for (const opaqueField of ["signature", "format"] as const) { const body: Record = { input: [ @@ -148,13 +151,11 @@ test("DeepSeek rejects plaintext reasoning carrying opaque provider state", () = const result = applyReasoningInputPolicy(body, "responses", { provider: "deepseek" }); - assert.equal(result.incompatibleReasoning, true, opaqueField); + assert.equal(result.incompatibleReasoning, false, opaqueField); assert.deepEqual(body.input, [ { - id: "rs_mixed123", type: "reasoning", content: [{ type: "reasoning_text", text: "untrusted companion" }], - [opaqueField]: "provider-state", }, { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, ]); @@ -199,6 +200,49 @@ test("drop fallback removes only the incompatible active transport and preserves assert.equal(opaqueReasoning.encrypted_content, "provider-state"); }); +test("mixed plaintext + opaque reasoning follows the target transport instead of rejecting (#10949)", () => { + const mixedReasoning = { + id: "rs_mixed", + type: "reasoning", + content: [{ type: "reasoning_text", text: "inspect first" }], + encrypted_content: "provider-state", + summary: [{ type: "summary_text", text: "display only" }], + }; + + // Plaintext target (deepseek): keep the portable plaintext, strip opaque state. + const toPlaintext: Record = { + input: [structuredClone(mixedReasoning)], + }; + const plaintextResult = applyReasoningInputPolicy(toPlaintext, "responses", { + provider: "deepseek", + }); + assert.equal(plaintextResult.incompatibleReasoning, false); + assert.deepEqual(toPlaintext.input, [ + { + type: "reasoning", + content: [{ type: "reasoning_text", text: "inspect first" }], + summary: [{ type: "summary_text", text: "display only" }], + }, + ]); + + // Opaque target (openai): keep the provider state, strip the plaintext. + const toOpaque: Record = { + input: [structuredClone(mixedReasoning)], + }; + const opaqueResult = applyReasoningInputPolicy(toOpaque, "responses", { + provider: "openai", + }); + assert.equal(opaqueResult.incompatibleReasoning, false); + assert.deepEqual(toOpaque.input, [ + { + id: "rs_mixed", + type: "reasoning", + encrypted_content: "provider-state", + summary: [{ type: "summary_text", text: "display only" }], + }, + ]); +}); + test("drop fallback preserves reasoning when its transport is compatible", () => { const body: Record = { input: [ diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index da87b26116..2a05f57310 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -172,28 +172,26 @@ test("Responses -> Chat keeps summary-only reasoning out of continuation state", assert.equal(result.messages[0].reasoning_content, undefined); }); -test("Responses -> Chat rejects opaque reasoning instead of replaying its plaintext companion", () => { - assert.throws( - () => - openaiResponsesToOpenAIRequest( - "deepseek-v4-pro", +test("Responses -> Chat replays the plaintext companion of an opaque reasoning item (#10949)", () => { + const result = openaiResponsesToOpenAIRequest( + "deepseek-v4-pro", + { + input: [ { - input: [ - { - id: "rs_opaque", - type: "reasoning", - encrypted_content: "opaque-provider-state", - content: [{ type: "reasoning_text", text: "Untrusted plaintext companion" }], - summary: [{ type: "summary_text", text: "Display summary" }], - }, - { type: "function_call", call_id: "call_1", name: "search", arguments: "{}" }, - ], + id: "rs_opaque", + type: "reasoning", + encrypted_content: "opaque-provider-state", + content: [{ type: "reasoning_text", text: "Untrusted plaintext companion" }], + summary: [{ type: "summary_text", text: "Display summary" }], }, - false, - { _preserveReasoningContent: true } - ), - /Reasoning continuation is not compatible/ - ); + { type: "function_call", call_id: "call_1", name: "search", arguments: "{}" }, + ], + }, + false, + { _preserveReasoningContent: true } + ) as { messages: Array> }; + + assert.equal(result.messages[0].reasoning_content, "Untrusted plaintext companion"); }); test("Responses -> Chat merges assistant text that follows a function call", () => { diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 27cc877f2e..2253c655ac 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -423,6 +423,52 @@ test("Responses -> OpenAI: preserves non-object Read JSON-string arguments", () assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, "null"); }); +test("Responses -> OpenAI: mixed plaintext + encrypted_content reasoning replays its plaintext (#10949)", () => { + const state = {}; + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { + type: "reasoning", + id: "rs_mixed", + content: [ + { + type: "reasoning_text", + text: "Let me start by reading the directory to understand the structure of the corpus.", + }, + ], + encrypted_content: "", + summary: [], + }, + }, + state + ); + + assert.ok(done, "mixed reasoning item must surface a delta"); + assert.equal( + done.choices[0].delta.reasoning_content, + "Let me start by reading the directory to understand the structure of the corpus." + ); +}); + +test("Responses -> OpenAI: opaque-only reasoning still emits no fabricated plaintext", () => { + const state = {}; + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { + type: "reasoning", + id: "rs_opaque_only", + encrypted_content: "", + summary: [], + }, + }, + state + ); + + assert.equal(done, null); +}); + test("Responses -> OpenAI: strips empty optional args from JSON-string output_item.done arguments", () => { const state = {}; openaiResponsesToOpenAIResponse(