From 0d63913daf0733d3467ccf3110b095d40352dbda Mon Sep 17 00:00:00 2001 From: Dimas Febriyanto Date: Tue, 20 Oct 2026 11:46:06 +0700 Subject: [PATCH 1/2] fix: preserve reasoning_content for every assistant message (handle cancel/partial) --- proxy.mjs | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/proxy.mjs b/proxy.mjs index 8ec2def..13cfd99 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -669,6 +669,9 @@ function buildCcRequest(openaiReq) { for (let i = 0; i < systemBlocks.length - 1; i++) systemBlocks[i].text += '\n'; const chatMessages = messages.filter(m => m.role !== 'system' && m.role !== 'developer'); + // 跟踪是否存在缺少 reasoning 的 assistant 历史消息,用于决定是否回退禁用 thinking 模式。 + let hasMissingReasoning = false; + // Build tool_call_id → tool_name reverse lookup // 名字原样透传(理由见 toWireToolName 删除处的注释)。 const toolNameMap = {}; @@ -710,8 +713,27 @@ function buildCcRequest(openaiReq) { // 思考内容必须回传:CC 在 thinking 模式下校验 reasoning 是否随历史带回, // 丢弃会让上游直接拒绝。次序也必须与 CC CLI 的抓包格式一致 —— // [reasoning, text, tool-call],reasoning 在最前。 - if (msg.reasoning_content) { - parts.push({ type: 'reasoning', text: msg.reasoning_content }); + // 推理模型可能要求历史 assistant 消息带 reasoning_content;但凭空制造 reasoning + // 会改变语义且可能被上游拒绝,因此这里不再用空串补齐,仅保留真实存在的 + // reasoning。缺失时记录 hasMissingReasoning,供后续回退禁用 thinking 模式。 + let reasoningText = null; + if (typeof msg.reasoning_content === 'string') reasoningText = msg.reasoning_content; + else if (msg.reasoning_content != null) reasoningText = String(msg.reasoning_content); + let reasoningAlreadyInContent = false; + if (reasoningText === null && Array.isArray(msg.content)) { + const rpart = msg.content.find(p => p && p.type === 'reasoning'); + if (rpart) { reasoningAlreadyInContent = true; } + } + // 协议要求每条历史 assistant 消息都带 reasoning_content(取消/部分响应 + // 常缺字段):缺失时用空串占位以满足上游校验,同时记 hasMissingReasoning + // 以便在客户端启用 thinking 时回退禁用 reasoning_effort,避免语义不匹配。 + // shortcut: 空串占位可能被严格上游拒绝,必要时改为丢弃该轮或透传 null;仅在取消/缺字段场景触发。 + if (reasoningText === null && !reasoningAlreadyInContent) { + reasoningText = ''; + hasMissingReasoning = true; + } + if (reasoningText !== null) { + parts.push({ type: 'reasoning', text: reasoningText }); } if (msg.content && typeof msg.content === 'string') { if (msg.content) parts.push({ type: 'text', text: msg.content }); @@ -721,7 +743,7 @@ function buildCcRequest(openaiReq) { if (part.type === 'text') parts.push(part); // 客户端直接把 reasoning 放在 content 数组里时同样透传; // 已有 reasoning_content 字段则不重复 - else if (part.type === 'reasoning' && !msg.reasoning_content) parts.push(part); + else if (part.type === 'reasoning' && reasoningText === null) parts.push(part); } } if (msg.tool_calls) { @@ -803,7 +825,10 @@ function buildCcRequest(openaiReq) { if (temperature !== undefined) { body.params.temperature = temperature; } - if (reasoning_effort !== undefined) { + // 回退策略:若存在缺少 reasoning 的 assistant 历史消息且客户端启用了 thinking + // 模式,上游会因缺少 reasoning_content 而拒绝;此时禁用 thinking 模式(不下发 + // reasoning_effort)作为有效回退,而非凭空制造 reasoning。 + if (reasoning_effort !== undefined && !hasMissingReasoning) { body.params.reasoning_effort = reasoning_effort; } // CLI 总是下发 tools(没有工具时是空数组)—— 空数组与缺键在 wire 上可观测,这里对齐 From ae15d3c26dac1663603d858692ee96688f1a6cbd Mon Sep 17 00:00:00 2001 From: DIMAS FEBRIYANTO <62858781+dimassfeb-09@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:36:40 +0700 Subject: [PATCH 2/2] fix: backfill non-empty reasoning placeholder for assistant history --- proxy.mjs | 64 ++++++++++++++++++------------- test/wire.test.mjs | 93 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 26 deletions(-) diff --git a/proxy.mjs b/proxy.mjs index 13cfd99..7c236f5 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -638,6 +638,9 @@ function getDateStr() { * @property {CcRequestParams} params */ +/** 历史 assistant 消息确实没有 reasoning 时的非空占位符(见 buildCcRequest 内注释)。 */ +const MISSING_REASONING_PLACEHOLDER = '[reasoning unavailable: previous turn was interrupted]'; + /** * @returns {CcRequestBody} */ @@ -669,8 +672,11 @@ function buildCcRequest(openaiReq) { for (let i = 0; i < systemBlocks.length - 1; i++) systemBlocks[i].text += '\n'; const chatMessages = messages.filter(m => m.role !== 'system' && m.role !== 'developer'); - // 跟踪是否存在缺少 reasoning 的 assistant 历史消息,用于决定是否回退禁用 thinking 模式。 - let hasMissingReasoning = false; + // 缺少 reasoning 的 assistant 历史消息计数:用非空占位符回填以满足上游 + // 「每条历史 assistant 消息都带 reasoning_content」的校验(严格上游会把空串 + // 也当成缺失,见 9router#4805 的 Zen 案例)。占位符是诚实的标记文本、 + // 绝不伪装成真实思考内容;仅在取消/部分响应等确实无 reasoning 的场景触发。 + let missingReasoningCount = 0; // Build tool_call_id → tool_name reverse lookup // 名字原样透传(理由见 toWireToolName 删除处的注释)。 @@ -713,37 +719,39 @@ function buildCcRequest(openaiReq) { // 思考内容必须回传:CC 在 thinking 模式下校验 reasoning 是否随历史带回, // 丢弃会让上游直接拒绝。次序也必须与 CC CLI 的抓包格式一致 —— // [reasoning, text, tool-call],reasoning 在最前。 - // 推理模型可能要求历史 assistant 消息带 reasoning_content;但凭空制造 reasoning - // 会改变语义且可能被上游拒绝,因此这里不再用空串补齐,仅保留真实存在的 - // reasoning。缺失时记录 hasMissingReasoning,供后续回退禁用 thinking 模式。 + // reasoning 的真实来源依次是:顶层 reasoning_content 字段、content 数组 + // 里的 reasoning 块(text 或 reasoning 字段)。两者都缺失或为空白时 + // (典型场景:客户端把该轮取消在 reasoning 产出之前——AI SDK 的 + // openai-compatible 仅在 reasoning 非空时才下发 reasoning_content—— + // 以及非 thinking 模型的历史消息、压缩摘要),用非空占位符回填: + // 空串会被严格上游当作缺失拒绝(Missing required parameter: + // 'messages[N].reasoning_content'),占位符保证字段「在场且非空」, + // 而真实值一律原样保留、不改写。 let reasoningText = null; - if (typeof msg.reasoning_content === 'string') reasoningText = msg.reasoning_content; - else if (msg.reasoning_content != null) reasoningText = String(msg.reasoning_content); - let reasoningAlreadyInContent = false; + if (typeof msg.reasoning_content === 'string') { + if (msg.reasoning_content.trim() !== '') reasoningText = msg.reasoning_content; + } else if (msg.reasoning_content != null) { + reasoningText = String(msg.reasoning_content); + } if (reasoningText === null && Array.isArray(msg.content)) { const rpart = msg.content.find(p => p && p.type === 'reasoning'); - if (rpart) { reasoningAlreadyInContent = true; } + const t = rpart && (typeof rpart.text === 'string' ? rpart.text + : (typeof rpart.reasoning === 'string' ? rpart.reasoning : '')); + if (t && t.trim() !== '') reasoningText = t; } - // 协议要求每条历史 assistant 消息都带 reasoning_content(取消/部分响应 - // 常缺字段):缺失时用空串占位以满足上游校验,同时记 hasMissingReasoning - // 以便在客户端启用 thinking 时回退禁用 reasoning_effort,避免语义不匹配。 - // shortcut: 空串占位可能被严格上游拒绝,必要时改为丢弃该轮或透传 null;仅在取消/缺字段场景触发。 - if (reasoningText === null && !reasoningAlreadyInContent) { - reasoningText = ''; - hasMissingReasoning = true; - } - if (reasoningText !== null) { - parts.push({ type: 'reasoning', text: reasoningText }); + if (reasoningText === null) { + reasoningText = MISSING_REASONING_PLACEHOLDER; + missingReasoningCount++; } + parts.push({ type: 'reasoning', text: reasoningText }); if (msg.content && typeof msg.content === 'string') { if (msg.content) parts.push({ type: 'text', text: msg.content }); } else if (msg.content && Array.isArray(msg.content)) { for (const part of msg.content) { if (!part) continue; if (part.type === 'text') parts.push(part); - // 客户端直接把 reasoning 放在 content 数组里时同样透传; - // 已有 reasoning_content 字段则不重复 - else if (part.type === 'reasoning' && reasoningText === null) parts.push(part); + // reasoning 块已统一在上方处理(真实文本已并入 reasoningText, + // 此处不再重复下发 content 数组里的 reasoning 块) } } if (msg.tool_calls) { @@ -825,10 +833,14 @@ function buildCcRequest(openaiReq) { if (temperature !== undefined) { body.params.temperature = temperature; } - // 回退策略:若存在缺少 reasoning 的 assistant 历史消息且客户端启用了 thinking - // 模式,上游会因缺少 reasoning_content 而拒绝;此时禁用 thinking 模式(不下发 - // reasoning_effort)作为有效回退,而非凭空制造 reasoning。 - if (reasoning_effort !== undefined && !hasMissingReasoning) { + // reasoning_effort 照常下发:缺失的 reasoning 已被非空占位符回填、历史 + // 重新合法,没有理由为此牺牲当前轮的 thinking。(早先版本曾在缺失时整段 + // 不下发 reasoning_effort —— 对「按模型决定 thinking」的上游无效,且白白 + // 关掉当前轮推理。) + if (missingReasoningCount > 0) { + log('info', 'Backfilled missing reasoning_content with placeholder', { count: missingReasoningCount, model: body.params.model }); + } + if (reasoning_effort !== undefined) { body.params.reasoning_effort = reasoning_effort; } // CLI 总是下发 tools(没有工具时是空数组)—— 空数组与缺键在 wire 上可观测,这里对齐 diff --git a/test/wire.test.mjs b/test/wire.test.mjs index d774b1b..ecd61c7 100644 --- a/test/wire.test.mjs +++ b/test/wire.test.mjs @@ -272,3 +272,96 @@ test('#47 tool_choice: { type: "none" }(Anthropic 格式)同样清空 tools }); + +test('chat:无 reasoning 的 assistant 历史回填非空占位符并保留 reasoning_effort', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/chat/completions', { + model: 'm', stream: true, reasoning_effort: 'medium', + messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: '', + tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f', arguments: '{}' } }] }, + { role: 'tool', tool_call_id: 'c1', content: 'result' }, + { role: 'user', content: 'next' }, + ], + }); + const asst = params.messages.find(m => m.role === 'assistant'); + assert.deepEqual(asst.content.map(p => p.type), ['reasoning', 'tool-call'], + '取消的轮次也要带 reasoning 在最前'); + assert.ok(typeof asst.content[0].text === 'string' && asst.content[0].text.length > 0, + '占位符必须是非空串(空串会被严格上游当作缺失)'); + assert.equal(params.reasoning_effort, 'medium', + '历史上回填好后当前轮的 reasoning_effort 不应再被扣下'); + } finally { await s.close(); } +}); + +test('chat:reasoning_content 为空串时同样回填占位符', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/chat/completions', { + model: 'm', stream: true, messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', reasoning_content: '', content: 'answer' }, + { role: 'user', content: 'next' }, + ], + }); + const asst = params.messages.find(m => m.role === 'assistant'); + assert.deepEqual(asst.content.map(p => p.type), ['reasoning', 'text']); + assert.ok(asst.content[0].text.length > 0, '空串 reasoning 必须被占位符替换'); + } finally { await s.close(); } +}); + +test('chat:真实 reasoning_content 原样保留(不被占位符覆盖)', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/chat/completions', { + model: 'm', stream: true, messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', reasoning_content: 'real thinking', content: 'answer' }, + { role: 'user', content: 'next' }, + ], + }); + const asst = params.messages.find(m => m.role === 'assistant'); + assert.equal(asst.content[0].text, 'real thinking'); + } finally { await s.close(); } +}); + +test('chat:content 数组的 reasoning 块提升为 reasoning_content(不重复)', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/chat/completions', { + model: 'm', stream: true, messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: [ + { type: 'reasoning', text: 'via parts' }, + { type: 'text', text: 'answer' }, + ] }, + { role: 'user', content: 'next' }, + ], + }); + const asst = params.messages.find(m => m.role === 'assistant'); + assert.deepEqual(asst.content.map(p => p.type), ['reasoning', 'text'], + 'reasoning 块应恰好出现一次且在最前'); + assert.equal(asst.content[0].text, 'via parts'); + } finally { await s.close(); } +}); + +test('responses:function_call 无 reasoning item 时也回填占位符', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/responses', { + model: 'm', stream: true, + input: [ + { type: 'message', role: 'user', content: 'q' }, + { type: 'function_call', call_id: 'c1', name: 'f', arguments: '{}' }, + { type: 'function_call_output', call_id: 'c1', output: 'result' }, + { type: 'message', role: 'user', content: 'next' }, + ], + }); + const asst = params.messages.find(m => m.role === 'assistant'); + assert.ok(asst, '应有 assistant 消息'); + assert.equal(asst.content[0].type, 'reasoning'); + assert.ok(asst.content[0].text.length > 0); + } finally { await s.close(); } +});