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 = ""