Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,9 @@ function getDateStr() {
* @property {CcRequestParams} params
*/

/** 历史 assistant 消息确实没有 reasoning 时的非空占位符(见 buildCcRequest 内注释)。 */
const MISSING_REASONING_PLACEHOLDER = '[reasoning unavailable: previous turn was interrupted]';

/**
* @returns {CcRequestBody}
*/
Expand Down Expand Up @@ -669,6 +672,12 @@ 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 历史消息计数:用非空占位符回填以满足上游
// 「每条历史 assistant 消息都带 reasoning_content」的校验(严格上游会把空串
// 也当成缺失,见 9router#4805 的 Zen 案例)。占位符是诚实的标记文本、
// 绝不伪装成真实思考内容;仅在取消/部分响应等确实无 reasoning 的场景触发。
let missingReasoningCount = 0;

// Build tool_call_id → tool_name reverse lookup
// 名字原样透传(理由见 toWireToolName 删除处的注释)。
const toolNameMap = {};
Expand Down Expand Up @@ -710,18 +719,39 @@ 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 });
// 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') {
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');
const t = rpart && (typeof rpart.text === 'string' ? rpart.text
: (typeof rpart.reasoning === 'string' ? rpart.reasoning : ''));
if (t && t.trim() !== '') reasoningText = t;
}
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' && !msg.reasoning_content) parts.push(part);
// reasoning 块已统一在上方处理(真实文本已并入 reasoningText,
// 此处不再重复下发 content 数组里的 reasoning 块)
}
}
if (msg.tool_calls) {
Expand Down Expand Up @@ -803,6 +833,13 @@ function buildCcRequest(openaiReq) {
if (temperature !== undefined) {
body.params.temperature = temperature;
}
// 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;
}
Expand Down
93 changes: 93 additions & 0 deletions test/wire.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
});