Files
HomeAgent/internal/lua/adapters/openai.lua
JianFeeeee 66200ff226 test(lua): 补适配器 stream_index 透传判据 + 全适配器体检
## 先纠正一件事:这个修复在生产上早就存在

最初我判断"`openai.lua` 缺 stream_index 透传、批内并发在生产走不通",并据此
写了实现。**核对生产实例后,这个判断是错的**:

    生产 /home/newqqagent/adapters/openai.lua   130 行  含 stream_index
    仓库 950b21b^                                128 行  无 stream_index

生产那份的注释是「透传上游分片 index:并行多工具调用时内核按它区分归属桶」——
简洁,与本提交新增的长注释不同。**也就是说仓库版本落后于生产,生产一直没这个
问题。** 我修的是"仓库与生产的差距",不是"生产正在发生的故障"。

## 真正缺的是判据

`internal/agent/core/stream_index_test.go` 的
TestAccumulateStreamParallelToolCallsByIndex 直接构造 Go 结构体
`agentAPI.StreamChunk{...}`,**不经过 Lua 适配器** —— 所以"适配器有没有把
index 透传出来"它永远测不到。生产有、仓库没有,判据也发现不了。

而提交 ddef195(2026-08-26,"流式并行 tool_call 按 JSON index 分桶")的说明里
写着「openai.lua 输出 stream_index 字段」,Go 侧也加了
`StreamIndex int json:"stream_index,omitempty"` 并注明"lua 适配器以
stream_index 键透传" —— 但那次提交**根本没改 openai.lua**(6 个文件里没有它)。
说明与实现不符,而没有任何判据能发现。

## 本提交做的事

① 让 openai.lua 与生产一致(补 stream_index 透传),并说明为何缺它会静默失效:
   多个分片全部并到槽 0 → argsRaw 混拼 → 每个工具报"参数不是合法 JSON",
   而**工具一次都没真跑过**。单工具时上游 index 恒为 0,缺省也是 0,
   所以问题只在"一轮多个 tool_call"时显形。

② 新增 internal/lua/adapter_streamindex_test.go,**真正加载并执行内嵌的
   openai.lua**(复用 VM 的真实路径),三条判据:
   - TestOpenAIAdapterPassesThroughStreamIndex  3 个 tool_call 的
     stream_index 必须是 0/1/2
   - TestOpenAIAdapterKeepsContinuationFragment 续传分片(只有 arguments、
     没有 name)的 stream_index 必须正确 —— 它是分桶的**唯一**依据
   - TestAllBundledAdaptersStreamToolCallStatus  全 10 个适配器体检

★ 第三条刻意**不**用 t.Skip 掩盖不支持的适配器 —— 早期版本一律 Skip,结果
"完全不支持流式工具调用"也会让整体显示为绿,而绿会被误读成"都支持"。
现在分类记录:openai ✓ / kimicode+server 透传嵌套形态需另修 /
其余 7 个未产出 tool_calls。

③ 体检顺带暴露的、与本提交无关但已记录的问题:
   - **仓库 vs 生产漂移无判据**:仓库适配器落后于生产时,只有靠人工对比才发现
   - **部署陷阱**:vm.go writeBundledAdapters 是
     `if 文件已存在 { continue }`,升级二进制**不会更新已有适配器文件**。
     这可能正是"仓库缺透传却没人发现"的原因之一
2026-09-27 17:48:16 +08:00

143 lines
5.5 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

local adapter = {}
adapter.name = "openai"
adapter.version = "2.0.0"
adapter.endpoint = "/chat/completions"
adapter.headers = {}
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
req.extra_body = nil
if req.messages then
for _, msg in ipairs(req.messages) do
msg.reasoning_content = nil
end
end
return json.encode(req)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local fn = tc["function"]
local name = tc.name
local raw_args = tc.arguments
if type(fn) == "table" then
name = fn.name or name
raw_args = fn.arguments or raw_args
end
local args = {}
if type(raw_args) == "table" then
args = raw_args
elseif type(raw_args) == "string" and raw_args ~= "" then
local args_ok, decoded = pcall(json.decode, raw_args)
if args_ok and type(decoded) == "table" then
args = decoded
elseif args_ok then
args = { value = decoded }
else
args = { raw = raw_args }
end
end
if name ~= nil and name ~= "" then
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = name,
arguments = args
})
end
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
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
local unified = {
content = delta.content or "",
done = (fr ~= nil)
}
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if delta.tool_calls then
local tcs = {}
for _, tc in ipairs(delta.tool_calls) do
-- OpenAI 流式格式: {function:{name,arguments}, id, type, index}
-- homed StreamChunk.ToolCalls 期望扁平格式: {id, type, name, raw_arguments}
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 过滤:OpenAI 流式分片中后续块 name 为空但携带 arguments
-- accumulateStream 按 index 累积并在 flushToolCall 时校验 name
table.insert(tcs, {
id = tc.id or "",
type = tc.type or "function",
name = name,
raw_arguments = raw_args,
-- ★ 必须透传上游 index(键名是 stream_index,不是 index)。
--
-- 内核按 stream_index 分桶累积同一轮多个 tool_call 的分片
-- (process.go:347 `idx := tc.StreamIndex`)。缺了这一项,
-- 所有分片的 StreamIndex 都是缺省 0 ⇒ 全部并进同一个桶 ⇒
-- argsRaw 混拼 ⇒ 每个工具都报"参数不是合法 JSON",
-- 而工具一次都没真跑过。
--
-- 单工具调用时上游 index 恒为 0,缺省也是 0,所以这个问题
-- 在生产上长期不显形 —— 直到模型一轮发多个工具才炸。
--
-- 续传分片(只有 arguments、没有 name)尤其依赖它:
-- 那种分片除了 index 没有任何可归位的依据。
stream_index = tc.index or 0
})
end
unified.tool_calls = tcs
end
return json.encode(unified)
end
return adapter