diff --git a/internal/lua/adapters/deepseek.lua b/internal/lua/adapters/deepseek.lua index dab4410..cc1811d 100644 --- a/internal/lua/adapters/deepseek.lua +++ b/internal/lua/adapters/deepseek.lua @@ -89,10 +89,48 @@ function adapter.transform_stream_chunk(raw_chunk) if not chunk.choices or #chunk.choices == 0 then return "" end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason - return json.encode({ + local unified = { content = delta.content or "", done = (fr ~= nil) - }) + } + if delta.reasoning_content then + unified.reasoning_content = delta.reasoning_content + end + -- ★ 必须处理流式 tool_calls —— 此前只透 content/done,导致 deepseek 源 + -- 在**流式**模式下工具调用全部丢失,模型调不动任何工具且无任何报错。 + -- + -- 为什么难发现:非流式路径(transform_response)是好的,所以端到端 + -- 手工测试也过;而内核的 tool call 循环默认走流式。 + -- 功能判据(core 包的批内测试)直接构造 Go 结构体,绕过适配器。 + -- + -- 形态与 openai.lua 一致:OpenAI 兼容流式格式 + -- {function:{name,arguments}, id, type, index} → homed 扁平结构 + -- {id, type, name, raw_arguments, stream_index}。 + if delta.tool_calls then + local tcs = {} + for _, tc in ipairs(delta.tool_calls) do + local fn = tc["function"] + local name = (type(fn) == "table" and fn.name) or tc.name or "" + local raw_args = "" + if type(fn) == "table" and type(fn.arguments) == "string" then + raw_args = fn.arguments + elseif type(tc.arguments) == "string" then + raw_args = tc.arguments + end + -- 不能按 name 过滤:流式续传片 name 为空但携带 arguments, + -- 内核 accumulateStream 按 stream_index 分桶并累积 + table.insert(tcs, { + id = tc.id or "", + type = tc.type or "function", + name = name, + raw_arguments = raw_args, + -- 透传上游分片 index:并行多工具调用时内核按它区分归属桶 + stream_index = tc.index or 0 + }) + end + unified.tool_calls = tcs + end + return json.encode(unified) end return adapter diff --git a/internal/lua/deepseek_stream_test.go b/internal/lua/deepseek_stream_test.go new file mode 100644 index 0000000..3832df9 --- /dev/null +++ b/internal/lua/deepseek_stream_test.go @@ -0,0 +1,66 @@ +package lua + +import ( + "encoding/json" + "testing" +) + +// TestDeepSeekAdapterHandlesStreamToolCalls 钉住「deepseek 源的流式模式也能工具调用」。 +// +// ★ 为什么单独给 deepseek 写判据: +// +// 它的 transform_stream_chunk 只透 content/done,**完全不处理 tool_calls** +// (那部分代码只存在于 transform_response,即非流式路径)。于是: +// +// · 非流式请求 ⇒ 工具调用正常 +// · 流式请求 ⇒ 工具调用**全部丢失**,模型只收到纯文本 +// +// 而内核的 tool call 循环默认走流式(provider.go 的 stream 分支)。所以 +// 配了 deepseek 源的用户,模型调不动任何工具,且**没有任何报错** —— +// 只是"工具好像不听话"。 +// +// 这类缺陷极难察觉:功能判据(core 包的批内测试)直接构造 Go 结构体, +// 完全绕过适配器;而非流式的端到端路径又是好的。 +func TestDeepSeekAdapterHandlesStreamToolCalls(t *testing.T) { + vm := NewVM(t.TempDir()) + loadBundled(t, vm, "deepseek") + + // 一个含 2 个 tool_call 的 chunk(首片 + 续传片各一) + frags := []string{ + `{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` + + `{"index":0,"id":"t0","type":"function","function":{"name":"cmd_run","arguments":"{}"}}]}}]}`, + `{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` + + `{"index":1,"id":"t1","type":"function","function":{"name":"cmd_run","arguments":"{}"}}]}}]}`, + } + for i, f := range frags { + out, err := vm.CallTransformStreamChunk("deepseek", f) + if err != nil { + t.Fatalf("第 %d 片: %v", i, err) + } + var u struct { + ToolCalls []struct { + StreamIndex int `json:"stream_index"` + ID string `json:"id"` + Name string `json:"name"` + RawArguments string `json:"raw_arguments"` + } `json:"tool_calls"` + } + if json.Unmarshal([]byte(out), &u) != nil { + t.Fatalf("第 %d 片输出非法: %s", i, out) + } + if len(u.ToolCalls) == 0 { + t.Errorf("第 %d 片:deepseek 适配器的流式路径**丢掉了 tool_call**\n"+ + " 输出:%s\n"+ + " ⇒ deepseek 源在流式模式下无法调用任何工具,且无任何报错。\n"+ + " 它的 tool_calls 处理只存在于 transform_response(非流式路径)。", + i, out) + continue + } + if u.ToolCalls[0].StreamIndex != i { + t.Errorf("第 %d 片的 stream_index = %d,应为 %d", i, u.ToolCalls[0].StreamIndex, i) + } + if u.ToolCalls[0].Name != "cmd_run" { + t.Errorf("第 %d 片 name = %q,应为 cmd_run", i, u.ToolCalls[0].Name) + } + } +}