P1:chat 不能原样透传 OpenAI 工具调用响应,pi 这类 coding agent 接不上(dissect3) #29

Closed
opened 2026-10-02 00:30:10 +08:00 by iomgaa · 1 comment
Owner

背景

下游项目 dissect3 用 pi(开源 coding agent)做 KernelBench 题。pi 跑在不能联网的沙箱里,它的模型请求先到 dissect3 核心里的一个本地代理(dissect3/src/d3/engine/proxy.py),代理再转发到中转站 new-api。代理现在自己逐块转发 SSE,没有任何超时、重试或换源。

2026-10-01 试点实测,中转站上游不稳:MiniMax-M3 的流式回复中途会断(中转站在流里发 data: {"error":{"code":"upstream_stream_read_error","message":"Upstream response stream was interrupted","type":"upstream_error"}},随后仍发带 usage 的 chunk 和 data: [DONE];另一类 errorMessage 为 Upstream request failed),还有中段几十秒不出 token 的沉默。直连中转站的 3 条真题请求全部在约 300 秒处被断,最长沉默 62 秒。负责人决定:代理里凡是 PolyGateway 能做的(重试、退避、对冲、活性超时、并发闸)一律交给 PolyGateway,不在 dissect3 里另写。

按 1.3.8 的现状接不上,原因是 PolyGateway 只交回纯文本补全,而 coding agent 的每一轮回答几乎都是工具调用。下面的行号都对 site-packages/polygateway(1.3.8,cf6e3fa)核过。dissect2 能用,是因为它的 agent 只发纯文本、从不用 tool_calls。

缺口一:响应里的工具调用、finish_reason、响应 id 被丢弃

  • 流式解析 _sse_delta(transports/openai_compat.py:65-85)只取 delta.content 与 delta.reasoning_content,delta.tool_calls 被丢掉。
  • LLMResponse(types.py:426-487)没有 tool_calls、finish_reason、响应 id 字段;raw 里只留了 usage(openai_compat.py:645)。
  • pi 下一步要执行哪个工具、参数是什么,全在 tool_calls 里;finish_reason(tool_calls / stop / length)决定 pi 是继续还是结束;响应 id 用于我们账本与引擎侧记录逐条对账。

缺口二:只有工具调用、没有文字的回答被当成空补全重试

_reject_empty_completion(openai_compat.py:673-685)在 content 为空时抛 TransientError。工具调用型回答 content 常常为空,按现状会被反复重试直到耗尽,一条正常回答都交不回来。

缺口三:流里的 error 帧被跳过

中转站的 {"error":…} 帧没有 choices,_sse_delta 在 :76-77 直接返回 None。之后:没有 [DONE] 时按 missing_done 重试(:656-671),这是对的;但上面实测的中转站在 error 帧之后还会发 [DONE],这时截断的半截结果会被当成成功交回,错误被静默吞掉。

需要的能力

  1. OpenAI chat completions 原样透传模式:请求侧,messages 原样发出(含 role: tool 消息、assistant 消息里的 tool_calls 与 reasoning_content),tools、tool_choice、max_tokens、temperature、reasoning_effort 等经 overlay 原样发出(reasoning_effort 由调用方直接给值,例如 glm 的 max,不经受管推理档位映射,thinking.py:475-497 现在会拒绝 raw 推理控制与受管档位并存)。响应侧,交回完整的 assistant 消息:content、reasoning_content、按 index 拼好的 tool_calls(id、type、function.name、function.arguments)、finish_reason、响应 id、usage 原文。流式时由库内读完整个流、拼好再交回即可,不需要逐块回调。
  2. 透传模式下,「content 为空但有 tool_calls」是正常回答,不算空补全。
  3. 流里出现 error 帧(顶层有 error 键)一律视为这次尝试失败、按瞬时错误重试,不论后面有没有 [DONE]。
  4. 保留现有的重试、退避、对冲(HEDGE__STALL_AFTER_S)、TTFT / inter-token / 总超时与并发闸,透传模式同样生效。

不需要的:失败尝试的计费(dissect3 负责人决定,外部环境不稳造成的花费最后本来要剔除,不必记);美元预算(dissect3 代理自己做);逐块回调与保活(代理自己给 pi 发 SSE 注释行保活)。

验收

  • 一条带 tools 的请求,上游流式返回两个并行 tool_calls(arguments 分多个 delta 到达)、content 为空、finish_reason=tool_calls:交回的消息里两个 tool_calls 的 id、name、arguments 与上游拼接结果逐字一致,finish_reason 与响应 id 正确,不触发重试。
  • 下一轮把上一轮的 assistant(含 tool_calls)与 role: tool 结果作为 messages 发出:上游收到的请求体里这些消息逐字段不变。
  • 上游第一次发 error 帧后接 [DONE]、第二次正常:库重试一次,交回第二次的结果,attempts=2。
  • reasoning_effort 作为 raw 字段原样出现在上游请求体里。

其他

  • 仓库根目录没有 LICENSE 文件,pyproject 也没有 license 字段;dissect3 计划开源(Apache-2.0),作为依赖引入前需要明确许可。
  • 库只有 async chat(client.py:360)。dissect3 的代理是 socketserver 线程模型,我们打算单开一个事件循环线程用 run_coroutine_threadsafe 调用;如果库有推荐的同步用法,请在文档里写一句。

dissect3 侧在本 issue 解决之前不改代理,试点暂停等待。

## 背景 下游项目 dissect3 用 pi(开源 coding agent)做 KernelBench 题。pi 跑在不能联网的沙箱里,它的模型请求先到 dissect3 核心里的一个本地代理(`dissect3/src/d3/engine/proxy.py`),代理再转发到中转站 new-api。代理现在自己逐块转发 SSE,没有任何超时、重试或换源。 2026-10-01 试点实测,中转站上游不稳:MiniMax-M3 的流式回复中途会断(中转站在流里发 `data: {"error":{"code":"upstream_stream_read_error","message":"Upstream response stream was interrupted","type":"upstream_error"}}`,随后仍发带 usage 的 chunk 和 `data: [DONE]`;另一类 errorMessage 为 `Upstream request failed`),还有中段几十秒不出 token 的沉默。直连中转站的 3 条真题请求全部在约 300 秒处被断,最长沉默 62 秒。负责人决定:代理里凡是 PolyGateway 能做的(重试、退避、对冲、活性超时、并发闸)一律交给 PolyGateway,不在 dissect3 里另写。 按 1.3.8 的现状接不上,原因是 PolyGateway 只交回纯文本补全,而 coding agent 的每一轮回答几乎都是工具调用。下面的行号都对 `site-packages/polygateway`(1.3.8,`cf6e3fa`)核过。dissect2 能用,是因为它的 agent 只发纯文本、从不用 tool_calls。 ## 缺口一:响应里的工具调用、finish_reason、响应 id 被丢弃 - 流式解析 `_sse_delta`(`transports/openai_compat.py:65-85`)只取 `delta.content` 与 `delta.reasoning_content`,`delta.tool_calls` 被丢掉。 - `LLMResponse`(`types.py:426-487`)没有 tool_calls、finish_reason、响应 id 字段;`raw` 里只留了 usage(`openai_compat.py:645`)。 - pi 下一步要执行哪个工具、参数是什么,全在 tool_calls 里;finish_reason(`tool_calls` / `stop` / `length`)决定 pi 是继续还是结束;响应 id 用于我们账本与引擎侧记录逐条对账。 ## 缺口二:只有工具调用、没有文字的回答被当成空补全重试 `_reject_empty_completion`(`openai_compat.py:673-685`)在 content 为空时抛 TransientError。工具调用型回答 content 常常为空,按现状会被反复重试直到耗尽,一条正常回答都交不回来。 ## 缺口三:流里的 error 帧被跳过 中转站的 `{"error":…}` 帧没有 choices,`_sse_delta` 在 `:76-77` 直接返回 None。之后:没有 [DONE] 时按 `missing_done` 重试(`:656-671`),这是对的;但上面实测的中转站在 error 帧之后**还会发 [DONE]**,这时截断的半截结果会被当成成功交回,错误被静默吞掉。 ## 需要的能力 1. **OpenAI chat completions 原样透传模式**:请求侧,`messages` 原样发出(含 `role: tool` 消息、assistant 消息里的 `tool_calls` 与 `reasoning_content`),`tools`、`tool_choice`、`max_tokens`、`temperature`、`reasoning_effort` 等经 overlay 原样发出(`reasoning_effort` 由调用方直接给值,例如 glm 的 `max`,不经受管推理档位映射,`thinking.py:475-497` 现在会拒绝 raw 推理控制与受管档位并存)。响应侧,交回完整的 assistant 消息:content、reasoning_content、按 index 拼好的 tool_calls(id、type、function.name、function.arguments)、finish_reason、响应 id、usage 原文。流式时由库内读完整个流、拼好再交回即可,不需要逐块回调。 2. 透传模式下,「content 为空但有 tool_calls」是正常回答,不算空补全。 3. 流里出现 error 帧(顶层有 `error` 键)一律视为这次尝试失败、按瞬时错误重试,不论后面有没有 [DONE]。 4. 保留现有的重试、退避、对冲(`HEDGE__STALL_AFTER_S`)、TTFT / inter-token / 总超时与并发闸,透传模式同样生效。 不需要的:失败尝试的计费(dissect3 负责人决定,外部环境不稳造成的花费最后本来要剔除,不必记);美元预算(dissect3 代理自己做);逐块回调与保活(代理自己给 pi 发 SSE 注释行保活)。 ## 验收 - 一条带 tools 的请求,上游流式返回两个并行 tool_calls(arguments 分多个 delta 到达)、content 为空、finish_reason=`tool_calls`:交回的消息里两个 tool_calls 的 id、name、arguments 与上游拼接结果逐字一致,finish_reason 与响应 id 正确,不触发重试。 - 下一轮把上一轮的 assistant(含 tool_calls)与 `role: tool` 结果作为 messages 发出:上游收到的请求体里这些消息逐字段不变。 - 上游第一次发 error 帧后接 [DONE]、第二次正常:库重试一次,交回第二次的结果,attempts=2。 - `reasoning_effort` 作为 raw 字段原样出现在上游请求体里。 ## 其他 - 仓库根目录没有 LICENSE 文件,pyproject 也没有 license 字段;dissect3 计划开源(Apache-2.0),作为依赖引入前需要明确许可。 - 库只有 `async chat`(`client.py:360`)。dissect3 的代理是 socketserver 线程模型,我们打算单开一个事件循环线程用 `run_coroutine_threadsafe` 调用;如果库有推荐的同步用法,请在文档里写一句。 dissect3 侧在本 issue 解决之前不改代理,试点暂停等待。
Author
Owner

已在 PolyGateway 1.3.9 解决并正式发布;本条关闭仅指库侧能力,不代表已修改或部署 dissect3 代理。

  • 使用 chat(..., response_mode="openai"),通过 LLMResponse.completion 获取 assistant 消息、按 index 聚合的并行 tool_calls、finish_reason、上游响应 ID 和 usage;工具参数逐字保留,纯工具响应不再误判为空。请求中的 assistant/tool 历史消息及 raw-only 推理参数沿既有路径透传。
  • SSE 顶层 error 即判本次尝试失败,即使后续发送 DONE 也不能交回半截成功;同帧正文/推理内容均保留,工具进展参与 TTFT、inter-token 与中段对冲。治理链、重试、缓存与取消路径复用。
  • 边界与迁移:首版仅单 choice、不执行工具、不支持与 structured 合用;raw-only 仍要求源级/请求级均无受管意图。自定义 Transport.complete 必须适配新增 response_mode 参数。受旧版吞错影响的文本缓存集合在升级首次读写前须更换 namespace/salt(含 per-call 覆盖),库不自动识别旧污染。
  • 已加入 Apache-2.0 LICENSE/包元数据;README 提供常驻事件循环线程和 run_coroutine_threadsafe 桥接说明,没有新增同步 API。

发布验收:main/tag 指向 22609c90d65a986f7a75560bc536442ba52c6bdb;合并后新跑 lint、格式、依赖门及全套 1828 passed / 23 skipped / 110 deselected,95% 覆盖。复用本任务此前相同生产实现的6项真实网关测试(工具双轮流式/非流式+chat4项,全部通过),之后仅文档/版本变化;不重复全模型 thinking 矩阵。

registry 的 wheel/sdist 均已实际下载,来源和散列对上;隔离 pip 安装后检查1.3.9版本、协议 API、全部Python源码与LICENSE一致。包页面正文、仓库关联与Release均已匿名核验。

  • Release v1.3.9
  • registry 包页面
  • wheel SHA256:24ef2992d2b0e5c9f9d0930b536b9c428500f9c5cfaf5a838d54e7b568215e19
  • 安装:pip install --index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ "polygateway==1.3.9"(若缺依赖请从已配置的通用索引安装依赖;私有索引不镜像所有依赖)。
已在 **PolyGateway 1.3.9** 解决并正式发布;本条关闭仅指库侧能力,不代表已修改或部署 dissect3 代理。 - 使用 `chat(..., response_mode="openai")`,通过 `LLMResponse.completion` 获取 assistant 消息、按 index 聚合的并行 tool_calls、finish_reason、上游响应 ID 和 usage;工具参数逐字保留,纯工具响应不再误判为空。请求中的 assistant/tool 历史消息及 raw-only 推理参数沿既有路径透传。 - SSE 顶层 error 即判本次尝试失败,即使后续发送 DONE 也不能交回半截成功;同帧正文/推理内容均保留,工具进展参与 TTFT、inter-token 与中段对冲。治理链、重试、缓存与取消路径复用。 - 边界与迁移:首版仅单 choice、不执行工具、不支持与 structured 合用;raw-only 仍要求源级/请求级均无受管意图。自定义 `Transport.complete` 必须适配新增 `response_mode` 参数。受旧版吞错影响的文本缓存集合在升级首次读写前须更换 namespace/salt(含 per-call 覆盖),库不自动识别旧污染。 - 已加入 Apache-2.0 LICENSE/包元数据;README 提供常驻事件循环线程和 `run_coroutine_threadsafe` 桥接说明,没有新增同步 API。 发布验收:main/tag 指向 `22609c90d65a986f7a75560bc536442ba52c6bdb`;合并后新跑 lint、格式、依赖门及全套 **1828 passed / 23 skipped / 110 deselected,95% 覆盖**。复用本任务此前相同生产实现的6项真实网关测试(工具双轮流式/非流式+chat4项,全部通过),之后仅文档/版本变化;不重复全模型 thinking 矩阵。 registry 的 wheel/sdist 均已实际下载,来源和散列对上;隔离 pip 安装后检查1.3.9版本、协议 API、全部Python源码与LICENSE一致。包页面正文、仓库关联与Release均已匿名核验。 - [Release v1.3.9](https://gitea.iomgaa.online/iomgaa/PolyGateway/releases/tag/v1.3.9) - [registry 包页面](https://gitea.iomgaa.online/iomgaa/-/packages/pypi/polygateway/1.3.9) - wheel SHA256:`24ef2992d2b0e5c9f9d0930b536b9c428500f9c5cfaf5a838d54e7b568215e19` - 安装:`pip install --index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ "polygateway==1.3.9"`(若缺依赖请从已配置的通用索引安装依赖;私有索引不镜像所有依赖)。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#29