Files
HomeAgent/internal/lua/adapters/mistral.lua
JianFeeeee cff8e10ad5 fix(lua): 补齐 6 个适配器的流式 tool_calls 支持
体检判据(TestAllBundledAdaptersStreamToolCallStatus)报出的三类问题,
本提交解决其中两类;第三类(gemini)未动,原因见下。

## ① OpenAI 兼容族:github / groq / mistral(3 个)

它们的 transform_stream_chunk 与修复前的 deepseek **逐字相同** ——
只透 content/done,tool_calls 处理只存在于 transform_response(非流式)。

后果与 deepseek 相同:流式模式下工具调用全部丢失,模型调不动任何工具,
且**没有任何报错**。生产当前未启用这三个源,但按预设配置的用户会踩到。

照 deepseek 的修法补上(含 reasoning_content 透传)。

## ② 嵌套形态 + 键名错:server / kimicode / anthropic / ollama(4 个)

这四个**有** tool_calls 处理,但发的是:

    { index = N, id = ..., ["function"] = { name = ..., arguments = ... } }

而 homed 的 `agentAPI.ToolCall` 是**扁平**结构,json tag 为:

    id / type / name / arguments / raw_arguments / stream_index

两处都是**静默**失效(Go 侧按 json tag 反序列化,取不到就是零值,无报错):
- **嵌套** `["function"]` ⇒ `name` / `raw_arguments` 取零值
  ⇒ flush 时判「无 name」丢弃,或参数为空
- **键名 `index`** ⇒ `StreamIndex` 取零值
  ⇒ 多个分片并到同一个桶,argsRaw 混拼 ⇒ 每个工具报「参数不是合法 JSON」
  而**一个都没真跑**

已逐项对齐为扁平 + `stream_index`。协议差异都保留:
- anthropic:`content_block_start` / `input_json_delta`,续传片 name 留空
  (内核按 stream_index 累积,补齐 name 后才 flush)
- ollama:tool_calls **整条一次发完**(不分片),故 stream_index 取数组下标

## ③ gemini 未动

它的流式函数处理 `candidates[].content.parts`,**全文件没有任何
tool_calls / functionCall 处理** —— 连非流式路径也没有。补它不是"对齐"
而是新实现,且 gemini 的 functionCall 形态(`functionCall: {name, args}`,
args 是对象而非 JSON 字符串)与 OpenAI 族不同,需要单独判据。

生产三个源(llmsproxy / visionllm / justworker)全部用 `openai.lua`,
不阻塞。留作独立项。

## 判据

- TestOpenAICompatibleFamilyHandlesStreamToolCalls  5 个 OpenAI 族适配器,
  逐个验证 tool_calls 未丢 + stream_index 正确
- TestAnthropicAdapterEmitsFlatToolCallsWithStreamIndex  用 **Anthropic 协议**
  的 fixture(不用 OpenAI 的,否则会因"不适用该 chunk"跳过 —— 看着绿,
  实则没测)
- TestOllamaAdapterEmitsFlatToolCallsWithStreamIndex  用 Ollama 协议形态
- TestDeepSeekAdapterHandlesStreamToolCalls  单列,因它有源预设指向

★ 三个判据按**协议**分文件而非逐适配器:这几个文件的流式函数逐字相同,
共用一个 fixture 会因协议不适用而静默跳过 —— 那等于没测。

## 体检分类

    修前: ✓ [openai]        ⚠ [kimicode server]  ✗ [anthropic deepseek gemini github groq mistral ollama]
    修后: ✓ [openai deepseek github groq mistral]  ⚠ []  ✗ [gemini]
2026-09-27 18:34:33 +08:00

133 lines
5.1 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 = "mistral"
adapter.version = "2.0.0"
adapter.endpoint = "/v1/chat/completions"
adapter.headers = {}
-- Mistral API is OpenAI-compatible, just passes through
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.model = req.model or "mistral-large-latest"
req.temperature = req.temperature or 0.7
req.max_tokens = req.max_tokens or 4096
req.stream = req.stream or false
req.disable_thinking = nil
req.extra_body = nil
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 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
-- ★ 必须处理流式 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