Files
HomeAgent/internal/lua/adapters/ollama.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

114 lines
4.0 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 = "ollama"
adapter.version = "2.0.0"
adapter.endpoint = "/api/chat"
adapter.headers = {}
-- Ollama API 格式:{ model, messages, stream, options:{temperature,num_predict} }
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
local ollama_req = {
model = req.model or "llama3",
stream = req.stream or false,
options = {
temperature = req.temperature or 0.7,
num_predict = req.max_tokens or 2048
}
}
-- 转换 messages 格式(Ollama messages 支持 images base64 数组)
if req.messages then
local msgs = {}
for _, m in ipairs(req.messages) do
local text, images
if type(m.content) == "string" then
text, images = m.content, nil
else
text = ""
images = {}
for _, p in ipairs(m.content or {}) do
if p.type == "text" then
text = text .. (p.text or "")
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
local b64 = string.match(p.image_url.url, "^data:[^,]+;base64,(.+)$")
if b64 then table.insert(images, b64) end
end
end
if #images == 0 then images = nil end
end
local msg = { role = m.role, content = text }
if images then msg.images = images end
table.insert(msgs, msg)
end
ollama_req.messages = msgs
end
return json.encode(ollama_req)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok then return raw_body end
local unified = {
content = "",
finish_reason = resp.done_reason or "",
tool_calls = {},
usage = { prompt = 0, completion = 0, total = 0 }
}
if resp.message then
unified.content = resp.message.content 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.message then return "" end
local unified = {
content = chunk.message.content or "",
done = chunk.done or false
}
if chunk.message.reasoning_content then
unified.reasoning_content = chunk.message.reasoning_content
end
if chunk.message.tool_calls then
local tools = {}
for i, tc in ipairs(chunk.message.tool_calls) do
-- ★ 必须是**扁平**结构(name / raw_arguments 在顶层)且键名是
-- stream_index —— homed 的 agentAPI.ToolCall 按 json tag 反序列化:
-- · 嵌套 ["function"]={...} ⇒ Go 侧取不到 name/raw_arguments(零值)
-- · 键名写 index ⇒ StreamIndex 取零值 ⇒ 多个分片并到同一个桶,
-- argsRaw 混拼 ⇒ 每个工具报"参数不是合法 JSON"而一个都没真跑
-- 两种都是**静默**失效,所以这里逐项对齐。
local fn = tc["function"]
local name = (type(fn) == "table" and fn.name) or tc.name or ""
local args = "{}"
if type(fn) == "table" and fn.arguments ~= nil then
args = fn.arguments
elseif type(tc.arguments) == "string" then
args = tc.arguments
end
-- ollama 的 tool_calls **整条一次发完**(不分片),所以序号即下标
table.insert(tools, {
id = tc.id or ("call_" .. i),
type = "function",
name = name,
raw_arguments = args,
stream_index = (tc.index or (i - 1))
})
end
unified.tool_calls = tools
end
return json.encode(unified)
end
return adapter