diff --git a/internal/lua/adapters/opencodego.lua b/internal/lua/adapters/opencodego.lua index 64675fa..98022aa 100644 --- a/internal/lua/adapters/opencodego.lua +++ b/internal/lua/adapters/opencodego.lua @@ -79,6 +79,17 @@ function adapter.transform_request(raw_body) -- 上游直接 400("The `reasoning_content` in the thinking mode must -- be passed back to the API"),agent 的每一轮都会失败。 -- 注意:与 opencodezen.lua 的行为**相反**,不要在这里剥。 + -- + -- 但绝大多数客户端(pi 等)根本不保存也不回传 reasoning,只保留 + -- 工具调用本身。上游只在“带 tool_calls 的助手轮”上校验这个字段, + -- 实测**空串即可通过校验**,所以缺省时补空串:既满足上游的 + -- 一致性要求,又不伪造任何推理内容(用户看到的 reasoning 仍然是 + -- 上游本轮真实返回的)。 + if msg.role == "assistant" + and type(msg.tool_calls) == "table" and #msg.tool_calls > 0 + and msg.reasoning_content == nil then + msg.reasoning_content = "" + end local drop = false if type(msg.content) == "table" then local parts = {} diff --git a/internal/lua/toolcall_preservation_test.go b/internal/lua/toolcall_preservation_test.go index 2b953d4..31e18ad 100644 --- a/internal/lua/toolcall_preservation_test.go +++ b/internal/lua/toolcall_preservation_test.go @@ -194,6 +194,32 @@ func TestOpenCodeGoVsZenReasoning(t *testing.T) { t.Errorf("opencodezen must still keep the tool call: %s", zout) } + // An assistant turn that carries tool_calls but no reasoning_content must + // get an empty one injected: Go validates that field on tool-calling turns + // and 400s when it is absent, while most clients never send it. + // Verified against the live endpoint that an empty string passes. + bare := `{"model":"m","messages":[ + {"role":"user","content":"go"}, + {"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"read","arguments":"{}"}}]}, + {"role":"tool","tool_call_id":"c1","content":"r"} + ]}` + bout, err := vm.Transform("opencodego", "transform_request", bare) + if err != nil { + t.Fatalf("opencodego bare: %v", err) + } + if !strings.Contains(bout, `"reasoning_content":""`) { + t.Errorf("opencodego must inject an empty reasoning_content on a tool-calling turn: %s", bout) + } + // A plain assistant turn (no tool calls) must NOT gain the field. + plainAsst := `{"model":"m","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"}]}` + pout, err := vm.Transform("opencodego", "transform_request", plainAsst) + if err != nil { + t.Fatal(err) + } + if strings.Contains(pout, "reasoning_content") { + t.Errorf("opencodego must not inject reasoning_content on a plain assistant turn: %s", pout) + } + // Both must still omit stream_options on non-streaming requests. for _, a := range []string{"opencodego", "opencodezen"} { out, err := vm.Transform(a, "transform_request", `{"model":"m","messages":[{"role":"user","content":"hi"}]}`)