From 00e59a7e1d12014a145b8f8ca7df0ed90afca64e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 16 Oct 2026 14:52:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(opencodezen):=20=E5=BF=85=E9=A1=BB=E5=9B=9E?= =?UTF-8?q?=E4=BC=A0=20reasoning=5Fcontent=EF=BC=88thinking=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E7=A1=AC=E8=A6=81=E6=B1=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 真实缺陷(我自己的会话被它打断过) 生产实测(2026-10-16): provider localzen: api error 400: Upstream request failed: [invalid_request_error] The `reasoning_content` in the thinking mode must be passed back to the API. 审计文件里成串 502,紧跟在一个 prompt_tokens=139330 的成功请求之后。 根因:`transform_request` 里写着 `msg.reasoning_content = nil`,注释理由是 "Zen 免费池不接受回传(Go 侧相反)"。**该假设方向是反的** —— 上游要的就是它。 ## ★ 为什么这个缺陷极难归因(值得记) 失败是**交替**出现的:只有当上一轮**真的产出了 reasoning**(thinking 模型 + reasoning_effort 开启 + 长上下文)时,回放那段历史才会被拒;普通回合照常 200。 于是"同一个模型同一会话,成功与失败交替"——看起来像**上游抖动或限流**, 而客户端看到的 502 与"上一轮想过"这件事**毫无关联**。 我一开始也走了弯路:用简短的 curl 复现不出来(8/8 成功),因为那些请求的 历史里没有"模型自己产出的 reasoning"。**必须让上一轮真的产生思考**才复现。 参考实现的方向一致:`openai-compat.ts` 的 `serializeMessages`(opencode 适配器 直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段: `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}` 即「有就回传」,无额外条件。 ## 修法 保留 `reasoning_content`(只把**空串**丢掉 —— 多余字段部分上游会挑)。 不改成"仅当 thinking 开启才回传":我们**无法**从请求可靠判定上游是否处于 thinking 模式(真正知道的是上游)。而"有就回传"是零风险方向 —— 上游不需要时忽略即可(实测传与不传同为 200),需要时缺了就是硬 400。 ## ★ 顺带发现的第二处问题 提交 `6113924` 的说明声称 "Test: TestOpenCodeGoVsZenReasoning pins the Go-keeps / Zen-strips split" —— **该测试在全仓不存在**(`git log -S` 全历史搜索为空)。也就是说这条行为 **从未被任何判据固定**,所以它反了也没人知道。本轮补上真正的判据。 ## 判据(2 条 + 变异 1/1) - `TestOpenCodeZenKeepsAssistantReasoningContent`:assistant 回合带 reasoning 时 出站体必须有它。**咬住"被剥掉"这个具体行为**,而不是"请求成功"—— 后者在非 thinking 回合下永远为真,测不出本缺陷。 - `TestOpenCodeZenDropsEmptyReasoningContent`:空串必须丢掉(配对判据, 防"改成无条件保留"这类过度修正)。 变异:恢复 `msg.reasoning_content = nil` → 报红。 ## 说明 `opencodezen.lua` 在仓库内,而 `deploy.sh` 的 `sync_adapters` 只覆盖 **与仓库同名**的适配器文件 ⇒ 本修复会自动生效(生产另有 opencodego/opencode/ agentrouter 三个仅存在于线上的文件,不受影响)。 --- .../lua/adapter_opencodezen_reasoning_test.go | 112 ++++++++++++++++++ internal/lua/adapters/opencodezen.lua | 54 +++++++-- 2 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 internal/lua/adapter_opencodezen_reasoning_test.go diff --git a/internal/lua/adapter_opencodezen_reasoning_test.go b/internal/lua/adapter_opencodezen_reasoning_test.go new file mode 100644 index 0000000..d2c8670 --- /dev/null +++ b/internal/lua/adapter_opencodezen_reasoning_test.go @@ -0,0 +1,112 @@ +package lua + +import ( + "encoding/json" + "strings" + "testing" +) + +// loadZenVM 装载 opencodezen 适配器(照 adapter_opencodezen_test.go 的方式)。 +func loadZenVM(t *testing.T) *VM { + t.Helper() + src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") + if err != nil { + t.Fatal(err) + } + vm := NewVM(t.TempDir()) + t.Cleanup(vm.Stop) + if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil { + t.Fatalf("加载失败: %v", err) + } + return vm +} + +// ★ assistant 回合的 reasoning_content **必须回传**给 opencode 上游。 +// +// 真实缺陷(生产实测,2026-10-16): +// +// provider localzen: api error 400: Upstream request failed: [invalid_request_error] +// The `reasoning_content` in the thinking mode must be passed back to the API. +// +// 根因:transform_request 里写了 `msg.reasoning_content = nil`,理由是 +// "Zen 免费池不接受回传"。该假设**方向反了** —— 上游要的就是它。 +// +// ★★ 这个缺陷极难归因,因为失败是**交替**出现的: +// 只有当上一轮**真的产出了 reasoning**(thinking 模型 + reasoning_effort 开启) +// 时,回放那段历史才会被拒;普通回合照常 200。审计里表现为成串 502 紧随一个 +// 成功的长上下文请求(实测 prompt_tokens=139330)之后。 +// +// 判据必须**咬住「被剥掉」这个具体行为**,而不是"请求成功" —— 后者在 +// 非 thinking 回合下永远为真,测不出这个缺陷。 +func TestOpenCodeZenKeepsAssistantReasoningContent(t *testing.T) { + vm := loadZenVM(t) + + body := map[string]interface{}{ + "model": "deepseek-v4.1-flash-free", + "stream": true, + "messages": []map[string]interface{}{ + {"role": "user", "content": "1+1=?"}, + { + "role": "assistant", + "content": "2", + // 这段正是 agent 回放历史时会带上的东西 + "reasoning_content": "一加一等于二。", + }, + {"role": "user", "content": "再加一"}, + }, + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + out, err := vm.Transform("opencodezen", "transform_request", string(raw)) + if err != nil { + t.Fatal(err) + } + + // 解析出站体,找 assistant 回合 + var sent struct { + Messages []map[string]interface{} `json:"messages"` + } + if err := json.Unmarshal([]byte(out), &sent); err != nil { + t.Fatalf("出站体不是合法 JSON: %v\n%s", err, out) + } + var found bool + for _, m := range sent.Messages { + if m["role"] != "assistant" { + continue + } + found = true + rc, _ := m["reasoning_content"].(string) + if rc == "" { + t.Fatalf("assistant 回合的 reasoning_content 被剥掉了 ⇒ 上游会回 "+ + "`The reasoning_content in the thinking mode must be passed back to the API`\n"+ + "出站 messages: %s", out) + } + if rc != "一加一等于二。" { + t.Errorf("reasoning_content 被改写:%q", rc) + } + } + if !found { + t.Fatalf("没找到 assistant 回合(判据失效)\n%s", out) + } + t.Log("✓ assistant 的 reasoning_content 已保留") +} + +// ★ 反向:**空的** reasoning_content 应被丢掉(不要把空串发给上游)。 +// 与上一条配对 —— 缺了这条,"改成无条件保留"这类过度修正不会被发现。 +func TestOpenCodeZenDropsEmptyReasoningContent(t *testing.T) { + vm := loadZenVM(t) + + raw := `{"model":"m","stream":true,"messages":[ + {"role":"assistant","content":"hi","reasoning_content":""} + ]}` + out, err := vm.Transform("opencodezen", "transform_request", raw) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, `"reasoning_content":""`) { + t.Errorf("空的 reasoning_content 不该发给上游(多余字段,部分上游会挑),实得: %s", out) + } + t.Log("✓ 空 reasoning_content 已丢弃") +} diff --git a/internal/lua/adapters/opencodezen.lua b/internal/lua/adapters/opencodezen.lua index 2f867e9..cd4b68f 100644 --- a/internal/lua/adapters/opencodezen.lua +++ b/internal/lua/adapters/opencodezen.lua @@ -11,8 +11,14 @@ adapter.endpoint = "/chat/completions" -- -- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端 -- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in --- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传 --- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。 +-- OpenCode"。 +-- ★ reasoning_content:**保留**(与 Go 侧一致)。此前这里写「剥掉」是基于 +-- 「免费池以非 thinking 模型为主、回传会报错」的假设,**生产实测已证伪** —— +-- 剥掉恰恰会引发 +-- The `reasoning_content` in the thinking mode must be passed back to the API +-- (详见 transform_request 内的长注释:失败只在「上一轮真的产出了 reasoning」 +-- 时才出现,故表现为同一会话里成功/失败交替,极难归因)。 +-- 参考的共享序列化器(openai-compat.ts)也是「有就回传」。 -- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content, -- 否则 thinking 模式直接 400。 -- @@ -219,26 +225,50 @@ function adapter.transform_request(raw_body) if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then msg.role = "system" end - -- Zen 免费池不接受回传 reasoning_content(Go 侧相反) - msg.reasoning_content = nil + -- ★ 必须**保留** reasoning_content(不再剥离)。 + -- + -- 历史:这里原先写 `msg.reasoning_content = nil`,理由是「Zen 免费池 + -- 不接受回传」。**生产实测证伪**(2026-10-16): + -- provider localzen: api error 400: Upstream request failed: + -- [invalid_request_error] The `reasoning_content` in the thinking + -- mode must be passed back to the API. + -- 失败形态很隐蔽:**同一模型同一会话里成功与失败交替出现** —— + -- 因为只有当上一轮**真的产出了 reasoning**(thinking 模型、 + -- reasoning_effort 开启、长上下文)时,回放该历史才会被拒; + -- 剥掉之后,客户端看到的 502 与「上一轮想过」这件事毫无关联。 + -- 审计文件里能看到成串 502 紧跟在一个 prompt_tokens=139330 的 + -- 成功请求之后 —— 那正是「想了、但下一轮我们没把它还回去」。 + -- + -- 参考实现(`openai-compat.ts` 的 serializeMessages,opencode 适配器 + -- 直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段: + -- `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}` + -- 即「有就回传」,没有额外条件 —— 与本适配器此前的假设方向相反。 + -- + -- ⚠️ 不改成「仅当 thinking 开启才回传」:我们**无法**从请求里可靠 + -- 判定上游是否处于 thinking 模式(真正知道的是上游)。而「有就回传」 + -- 是零风险方向 —— 上游不需要时忽略即可(实测传与不传同为 200), + -- 需要时缺了就是硬 400。 + if type(msg.reasoning_content) == "string" and msg.reasoning_content == "" then + msg.reasoning_content = nil + end local drop = false if type(msg.content) == "table" then local parts = {} for _, part in ipairs(msg.content) do if type(part) == "table" and part.type ~= nil and part.type ~= "text" then - -- multimodal part not supported by zen + -- multimodal part not supported by zen else table.insert(parts, part) end end if #parts == 0 then - -- Content collapsed to nothing after stripping unsupported - -- parts. A message that still carries a tool call must - -- NEVER be dropped: the very next message is its tool - -- result, and dropping the call orphans that result. The - -- model then sees a result for a call it never made and - -- re-issues the same tool call on every turn (observed as - -- an infinite "repeated tool call" loop). + -- Content collapsed to nothing after stripping unsupported + -- parts. A message that still carries a tool call must + -- NEVER be dropped: the very next message is its tool + -- result, and dropping the call orphans that result. The + -- model then sees a result for a call it never made and + -- re-issues the same tool call on every turn (observed as + -- an infinite "repeated tool call" loop). if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0) or msg.tool_call_id ~= nil then msg.content = ""