From 2cfd53b9232f95c4096644fb27d02faade7ef2fb Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Wed, 21 Oct 2026 20:27:50 +0800 Subject: [PATCH] fix(model-bridge): handle SDK context hints and Responses metadata --- ...PT桥接context_management修复-2026-10-21.md | 58 +++++++++++++++++++ scripts/protocol-bridge-regression.ts | 21 ++++++- src/main/model-bridge/protocol.ts | 21 ++++++- src/main/model-bridge/stream.ts | 3 + 4 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 docs/GPT桥接context_management修复-2026-10-21.md diff --git a/docs/GPT桥接context_management修复-2026-10-21.md b/docs/GPT桥接context_management修复-2026-10-21.md new file mode 100644 index 0000000..5478743 --- /dev/null +++ b/docs/GPT桥接context_management修复-2026-10-21.md @@ -0,0 +1,58 @@ +# GPT 对话桥接兼容修复(2026-10-21) + +## 问题与定位 + +用户当前运行的是安装版 `C:\Users\Administrator\AppData\Local\Programs\YCode\YCode.exe`。 +模型 `gpt-6.1-sol`(GPT-6.1 Sol)配置为 `openai-responses`。 +真实会话在北京时间 2026-10-21 19:21:59 记录 +`API Error: 400 Unsupported Messages field: context_management`。 +该错误来自 YCode 本地 `model-bridge/protocol.ts`,请求在转发远程服务之前被拒绝。 + +真实本地 Claude SDK/原生 CLI 请求复现: + +- 关闭思考时不附带该字段。 +- `effort: high` 时附带 + `context_management: { edits: [{ type: 'clear_thinking_20251015', keep: 'all' }] }`。 +- SDK 还在消息数组尾部附带一条 `role: system` 的文本环境说明,旧转换器也会拒绝。 + +## 实现 + +- 仅接受不修改历史的上下文配置:缺省、null、空配置、空 edits,以及保留全部思考的指令。桥接后的上游请求不携带该字段。 +- 仍拒绝真实清理、压缩、未知或非法配置,避免静默忽略会改变历史的操作。 +- 接受消息数组里的文本系统消息,保留顺序和角色;系统消息中的其他内容类型继续拒绝。 +- Responses 的加密推理历史重放保持原逻辑,并增加携带 keep-all 配置的重放回归。 + +## 验证与边界 + +- `npm run test:model-bridge`:146 项协议检查、16 组离线宿主回归通过。 +- `node scripts/ops.mjs build --json`:`PASS / BUILT`,含类型检查;产物位于 `out/`,基于 `194853a` 加本地修改。最终日志 `.git/ycode-ops/logs/1792582138654-3a0f3ac4-1914-49d8-919a-ad6ad8e551bf.log`。 +- `.tmp/context-management-probe.mjs`:真实原生 SDK 在隔离临时目录请求本地服务,确认字段与系统消息的实际形态。 +- `node .tmp/context-management-sdk-verification.mjs`:真实 SDK 开启 high 思考,经真实桥接层分别连接本地 Responses / Chat Completions SSE 模拟服务,均收到 `LOCAL_SDK_OK` 并成功结束。共两次本地上游请求。 +- 上述验证没有调用用户远程模型服务,也没有修改用户模型配置或会话数据。 +- 修复尚未发布、安装或加载到用户当前安装版窗口;远程 GPT 服务端的完整对话/工具调用仍需在更新后验证。 + +## 安装后续报错:流式 metadata 中断(2026-10-21) + +用户安装首轮修复包后,19:59 开始收到真实 GPT 思考与工具调用, +期间多次发生 `Server error mid-response`,20:00:27 最终报 +`400 Responses reasoning history is not available in this lease; start a new conversation`。 +该会话的桥接历史文件存在,但 entries 为 0;这是流被提前终止后的后续错误。 + +使用用户配置的远程 GPT 接口发送独立诊断提示和无外部动作的 +`diagnostic_echo` 工具定义,确认上游在 `response.completed` 前发送 +`response.metadata`。该事件只有 moderation 元数据,旧桥接器的默认分支拒绝了它, +因此没有读到完整终态并保存推理历史。 + +修复 `stream.ts`,将 `response.metadata` 视为无正文的元数据事件, +继续读取终态;不修改未知事件、真正断流、内容不一致等异常的处理。 +协议回归中的所有 Responses 流均补入与实际上游一致的 metadata 形态, +包括推理历史持久化和重新获取桥接租约场景。 + +验证: + +- `npm run test:model-bridge`:148 项协议检查、16 组宿主回归通过。 +- `.tmp/responses-live-events.sse`:实际远程独立诊断流,确认 metadata 的顺序与内容;未发送用户会话。 +- `.tmp/responses-sdk-tool-probe.mjs`:真实原生 SDK + 本地模拟 Responses,经思考摘要、正文、Read 工具、工具结果、最终回复连续两轮成功;包含 metadata,验证加密推理重放。 +- `.tmp/responses-live-bridge-probe.cjs`:真实桥接器连接用户配置的 GPT 接口,独立诊断工具调用和后续回复两轮均返回干净 `message_stop`。仅模拟返回 `OK`,没有执行外部工具,也没有修改用户模型或会话。 +- 原会话已经保存的中断思考没有对应完整加密历史,不能从摘要重建;安装本轮修复后应新建会话。此次未删除或改写原会话。 +- `node scripts/ops.mjs package --json`:`PASS / PACKAGED`,包含类型检查、完整构建与 Windows x64 NSIS 打包。产物 `release/YCode-0.0.12-Setup-x64.exe` 已更新为本轮修复包,401801432 字节,SHA-256 `cee3c43810e0fcb93af7f33351d14789b0ee28444bcea1520b991acdbfb12e97`。日志 `.git/ycode-ops/logs/1792584536233-a08ce860-240e-41de-99a0-255bcf26dfd4.log`。本地未签名包,未发布或自动安装。 diff --git a/scripts/protocol-bridge-regression.ts b/scripts/protocol-bridge-regression.ts index 62398c6..c6fb01f 100644 --- a/scripts/protocol-bridge-regression.ts +++ b/scripts/protocol-bridge-regression.ts @@ -34,6 +34,15 @@ for (const api of ['openai-completions', 'openai-responses'] as const) { } assert.throws(() => convertRequest({ ...request, messages: [{ role: 'user', content: [{ type: 'document' }] }] }, model(api), context()), /Unsupported/); checks++ assert.throws(() => convertRequest({ ...request, messages: [{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: [{ type: 'document' }] }] }] }, model(api), context()), /Only text and image tool results/); checks++ + for (const context_management of [null, {}, { edits: [] }, { edits: [{ type: 'clear_thinking_20251015', keep: 'all' }] }, { edits: [{ type: 'clear_thinking_20251015', keep: { type: 'all' } }] }]) { + assert.deepEqual(convertRequest({ ...request, context_management }, model(api), context()), value); checks++ + } + for (const context_management of [false, { unknown: true }, { edits: null }, { edits: {} }, { edits: [null] }, { edits: [{ type: 'clear_thinking_20251015' }] }, { edits: [{ type: 'clear_thinking_20251015', keep: { type: 'thinking_turns', value: 1 } }] }, { edits: [{ type: 'clear_tool_uses_20250919' }] }, { edits: [{ type: 'compact_20260112' }] }, { edits: [{ type: 'clear_thinking_20251015', keep: 'all', unknown: true }] }]) { + assert.throws(() => convertRequest({ ...request, context_management }, model(api), context()), /context_management/); checks++ + } + const environment = convertRequest({ ...request, messages: [...request.messages, { role: 'system', content: [{ type: 'text', text: 'SDK environment', cache_control: { type: 'ephemeral' } }] }] }, model(api), context()) + check((environment.input ?? environment.messages).at(-1).role === 'system' && (environment.input ?? environment.messages).at(-1).content[0].text === 'SDK environment', `${api}: SDK in-history system instruction preserved in order`) + assert.throws(() => convertRequest({ ...request, messages: [{ role: 'system', content: [{ type: 'image', source: { type: 'url', url: 'https://invalid.example/image.png' } }] }] }, model(api), context()), /Only text system blocks/); checks++ } const chat = convertResponse({ id: 'chat_1', choices: [{ message: { content: 'ok', reasoning_content: 'reason', tool_calls: [{ id: 'call_1', function: { name: 'echo', arguments: '{"v":1}' } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 20, completion_tokens: 8, prompt_tokens_details: { cached_tokens: 5 } } }, 'm', false, context()) check(chat.content[0].thinking === 'reason' && !chat.content[0].signature && chat.stop_reason === 'tool_use', 'Chat reasoning and tools') @@ -54,7 +63,7 @@ for (const api of ['openai-completions', 'openai-responses'] as const) { } const rc = context(), responseData = { id: 'r1', status: 'completed', output: [{ type: 'reasoning', id: 'rs1', encrypted_content: 'opaque', summary: [{ type: 'summary_text', text: 'reason' }] }, { type: 'function_call', id: 'fc1', call_id: 'call_1', name: 'echo', arguments: '{"v":1}' }], usage: { input_tokens: 7, output_tokens: 4 } } const converted = convertResponse(responseData, 'm', true, rc) -const replay = convertRequest({ max_tokens: 30, messages: [{ role: 'assistant', content: converted.content }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }] }] }, model('openai-responses'), rc) +const replay = convertRequest({ max_tokens: 30, context_management: { edits: [{ type: 'clear_thinking_20251015', keep: 'all' }] }, messages: [{ role: 'assistant', content: converted.content }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }] }] }, model('openai-responses'), rc) check(replay.input[0].encrypted_content === 'opaque' && replay.input[1].call_id === 'call_1', 'Responses opaque reasoning exact-history replay') assert.throws(() => convertRequest({ max_tokens: 30, messages: [{ role: 'assistant', content: converted.content }] }, model('openai-responses'), context()), /history is not available/); checks++ rc.responsePrefix = undefined @@ -113,6 +122,10 @@ const upstream = createServer(async (req, res) => { { type: 'response.reasoning_summary_text.delta', output_index: 2, summary_index: 0, delta: 'one' } as any, { type: 'response.reasoning_summary_text.delta', output_index: 2, summary_index: 1, delta: 'two' } as any, { type: 'response.output_item.done', output_index: 2, item: completion.output[2] } as any) + if (responses) events.splice(events.length - 1, 0, { + type: 'response.metadata', sequence_number: 27, response_id: 'r2', + metadata: { openai_chatgpt_moderation_metadata: { prompt: {}, generation: {}, tool_call: {}, tool_response: {} } } + } as any) if (responses && mode === 'mismatch') (events[2] as any).delta = 'truncated' const wire = events.map(x => `data: ${JSON.stringify(x)}\r\n\r\n`).join('') + (responses ? '' : 'data: [DONE]\n\n') const bytes = Buffer.from(mode === 'eof' ? wire.slice(0, wire.indexOf('data:', 10)) : wire) @@ -129,12 +142,14 @@ try { try { for (const stream of [false, true]) { mode = 'normal' - const result = await send({ ...request, stream }) + const result = await send({ ...request, stream, context_management: { edits: [{ type: 'clear_thinking_20251015', keep: 'all' }] } }) check(result.status === 200, `${api}: HTTP ${stream ? 'stream' : 'json'}`) + check(!('context_management' in last.body), `${api}: SDK keep-all hint is not forwarded upstream`) check(last.path === (api === 'openai-responses' ? '/v1/responses' : '/v1/chat/completions') && last.body.model === 'offline-model' && last.auth === 'Bearer mock-upstream-secret', `${api}: frozen route and credentials`) if (stream) { const frames: any[] = []; for await (const event of readSSE(result.body!)) frames.push(event) check(frames[0].type === 'message_start' && frames.at(-1).type === 'message_stop', `${api}: valid stream envelope`) + check(!frames.some(x => x.type === 'error'), `${api}: provider metadata does not interrupt the stream`) check(frames.filter(x => x.type === 'content_block_start').length === 2 && frames.filter(x => x.type === 'content_block_stop').length === 2, `${api}: closed text/tool blocks`) check(frames.find(x => x.type === 'message_delta').usage.input_tokens === 9, `${api}: reported stream usage`) check(frames.find(x => x.type === 'message_delta').delta.stop_reason === 'tool_use', `${api}: tool stop reason`) @@ -147,7 +162,7 @@ try { check((await send(request, '/v1/other')).status === 404, 'Unknown path denied') check((await send(request, '/v1/messages?url=http://bad')).status === 404, 'Query injection denied') check(hits === before, 'Invalid requests never reach upstream') - const count = await send(request, '/v1/messages/count_tokens'), countBody = await count.json() + const count = await send({ ...request, context_management: { edits: [{ type: 'clear_thinking_20251015', keep: 'all' }] } }, '/v1/messages/count_tokens'), countBody = await count.json() check(countBody.estimated === true && count.headers.get('x-ycode-token-count-source')?.includes('estimate'), 'Token count explicitly estimated') mode = 'error'; const error = await send(request); check(error.status === 401 && !(await error.text()).includes('mock-upstream-secret'), 'Upstream errors redacted') mode = 'redirect'; check((await send(request)).status === 502, 'Redirect not followed') diff --git a/src/main/model-bridge/protocol.ts b/src/main/model-bridge/protocol.ts index 5226045..2f8ab59 100644 --- a/src/main/model-bridge/protocol.ts +++ b/src/main/model-bridge/protocol.ts @@ -43,10 +43,25 @@ export function historyKey(content: Json[]): string { function replayKey(prefix: string, content: Json[]): string { return createHash('sha256').update(prefix).digest('hex') + ':' + createHash('sha256').update(historyKey(content)).digest('hex') } +function validateContextManagement(value: unknown): void { + if (value === undefined || value === null) return + requireValue(record(value) && Object.keys(value).every(key => key === 'edits'), 'Unsupported Messages context_management') + if (value.edits === undefined) return + requireValue(Array.isArray(value.edits), 'Invalid Messages context_management edits') + // Adaptive thinking adds "keep all" even for custom models. It is a no-op; + // actual server-side clearing/compaction cannot be translated across protocols. + for (const edit of value.edits) { + requireValue(record(edit) && Object.keys(edit).every(key => key === 'type' || key === 'keep') + && edit.type === 'clear_thinking_20251015' + && (edit.keep === 'all' || record(edit.keep) && edit.keep.type === 'all' && Object.keys(edit.keep).length === 1), + 'Unsupported Messages context_management edit: only keeping all thinking is supported') + } +} export function convertRequest(body: Json, model: ResolvedModel, context: ConversionContext): Json { const { config: c, profile: p } = model, responses = c.api === 'openai-responses' requireValue(Array.isArray(body.messages), 'messages must be an array') - for (const key of ['mcp_servers', 'container', 'context_management']) requireValue(body[key] === undefined, `Unsupported Messages field: ${key}`) + for (const key of ['mcp_servers', 'container']) requireValue(body[key] === undefined, `Unsupported Messages field: ${key}`) + validateContextManagement(body.context_management) const input: Json[] = [] const prefix: Json[] = [] context.responsePrefix = undefined @@ -56,8 +71,10 @@ export function convertRequest(body: Json, model: ResolvedModel, context: Conver if (system.length) input.push({ role: 'system', content: system.map(b => b.text).join('\n\n') }) } for (const message of body.messages) { - requireValue(record(message) && ['user', 'assistant'].includes(message.role), 'Invalid message role') + requireValue(record(message) && ['user', 'assistant', 'system'].includes(message.role), 'Invalid message role') const role = message.role, content = blocks(message.content) + // The SDK also appends environment instructions as an in-history system message. + if (role === 'system') requireValue(content.every(b => b.type === 'text' && typeof b.text === 'string'), 'Only text system blocks are supported') const prior = JSON.stringify(prefix) prefix.push({ role, content: JSON.parse(historyKey(content)) }) if (responses && role === 'assistant') { diff --git a/src/main/model-bridge/stream.ts b/src/main/model-bridge/stream.ts index aa5bbaf..9d731a1 100644 --- a/src/main/model-bridge/stream.ts +++ b/src/main/model-bridge/stream.ts @@ -136,6 +136,9 @@ export async function streamResponse(upstream: Response, response: ServerRespons await context.persist?.() finish = converted.stop_reason; usage = converted.usage; completed = true; break } + // Gateway metadata carries no model content. Keep reading to the terminal + // event so reasoning history is saved before the SDK starts its next turn. + case 'response.metadata': case 'response.created': case 'response.in_progress': case 'response.queued': case 'response.content_part.added': case 'response.content_part.done': case 'response.reasoning_summary_part.added': case 'response.reasoning_summary_part.done':