mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
fix: empty-array content becomes invalid {} on every pass-through adapter; gemini/ollama drop tool calls
Three related forwarding defects found by auditing every adapter with a
tool-calling replay (assistant turn with content:[] + tool_calls).
1) content:[] -> content:{} (all 12 openai-adapter sources, plus
deepseek/trae/sensenova/agentrouter/github/groq/kimicode/mistral)
Lua adapters json.decode the request and re-encode it, and an empty Lua
table is indistinguishable from an empty JSON array — the encoder emits
{} for both. Agent clients serialise a tool-calling assistant turn with
no text as content:[], so every pass-through adapter rewrote it to
content:{} — not valid OpenAI (content is string|array|null). Verified
against a live upstream: content:[] produced "400 invalid arguments"
while content:"" was accepted.
Fixed once at the decode boundary (types.ChatMessage.UnmarshalJSON):
empty-array content normalises to "" and an empty tool_calls array is
dropped, so every adapter — including future ones — sees a valid shape.
2) gemini dropped tool_calls and never emitted functionCall /
functionResponse; the tool role also stayed as an invalid role inside
contents and system was not moved to systemInstruction.
3) ollama copied only role/content, dropping tool_calls and the call
attribution entirely (it needs tool_name, not tool_call_id).
Test: TestAdaptersPreserveToolCalls asserts, for every adapter, that the
call id (or function name where the wire format has no id), the function
name, the tool result and the trailing user turn all survive, plus a
negative control for plain text.
This commit is contained in:
@ -7,6 +7,10 @@ adapter.headers = {}
|
|||||||
|
|
||||||
-- Gemini API: POST /v1/models/{model}:generateContent
|
-- Gemini API: POST /v1/models/{model}:generateContent
|
||||||
-- Auth: API key in query param ?key=XXX or Authorization: Bearer XXX
|
-- Auth: API key in query param ?key=XXX or Authorization: Bearer XXX
|
||||||
|
--
|
||||||
|
-- 请求方向必须把 OpenAI 的工具调用翻译成 Gemini 的 functionCall /
|
||||||
|
-- functionResponse,否则 agent 回放的历史里助手那一轮的调用会凭空消失,
|
||||||
|
-- 紧随其后的工具结果就成了「无来源」的孤立结果,模型只能反复重发同一个调用。
|
||||||
function adapter.transform_request(raw_body)
|
function adapter.transform_request(raw_body)
|
||||||
local ok, req = pcall(json.decode, raw_body)
|
local ok, req = pcall(json.decode, raw_body)
|
||||||
if not ok then return raw_body end
|
if not ok then return raw_body end
|
||||||
@ -14,16 +18,23 @@ function adapter.transform_request(raw_body)
|
|||||||
-- 将 OpenAI 风格 content(字符串或 [{type:*}] 数组)拆成 Gemini parts
|
-- 将 OpenAI 风格 content(字符串或 [{type:*}] 数组)拆成 Gemini parts
|
||||||
local function to_parts(content)
|
local function to_parts(content)
|
||||||
if type(content) == "string" then
|
if type(content) == "string" then
|
||||||
|
if content == "" then return {} end
|
||||||
return { { text = content } }
|
return { { text = content } }
|
||||||
end
|
end
|
||||||
local parts = {}
|
local parts = {}
|
||||||
for _, p in ipairs(content or {}) do
|
for _, p in ipairs(content or {}) do
|
||||||
if p.type == "text" then
|
if type(p) == "string" then
|
||||||
table.insert(parts, { text = p.text })
|
if p ~= "" then table.insert(parts, { text = p }) end
|
||||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
elseif type(p) == "table" then
|
||||||
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
|
if p.type == "text" then
|
||||||
if b64 then
|
if p.text ~= nil and p.text ~= "" then
|
||||||
table.insert(parts, { inline_data = { mime_type = mt or "image/png", data = b64 } })
|
table.insert(parts, { text = p.text })
|
||||||
|
end
|
||||||
|
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||||||
|
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
|
||||||
|
if b64 then
|
||||||
|
table.insert(parts, { inline_data = { mime_type = mt or "image/png", data = b64 } })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -31,11 +42,59 @@ function adapter.transform_request(raw_body)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local contents = {}
|
local contents = {}
|
||||||
|
local system = ""
|
||||||
|
local call_names = {} -- tool_call_id -> 函数名(functionResponse 只认名字)
|
||||||
|
|
||||||
for _, m in ipairs(req.messages or {}) do
|
for _, m in ipairs(req.messages or {}) do
|
||||||
table.insert(contents, {
|
local role = m.role or "user"
|
||||||
role = (m.role == "assistant") and "model" or m.role,
|
|
||||||
parts = to_parts(m.content)
|
if role == "system" then
|
||||||
})
|
if type(m.content) == "string" then
|
||||||
|
system = system .. m.content .. "\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif role == "assistant" then
|
||||||
|
local parts = to_parts(m.content)
|
||||||
|
if type(m.tool_calls) == "table" then
|
||||||
|
for _, tc in ipairs(m.tool_calls) do
|
||||||
|
if type(tc) == "table" then
|
||||||
|
local fn = tc["function"] or {}
|
||||||
|
local args = fn.arguments
|
||||||
|
if type(args) == "string" and args ~= "" then
|
||||||
|
local aok, decoded = pcall(json.decode, args)
|
||||||
|
args = aok and decoded or {}
|
||||||
|
elseif type(args) ~= "table" then
|
||||||
|
args = {}
|
||||||
|
end
|
||||||
|
if tc.id ~= nil then call_names[tc.id] = fn.name or "" end
|
||||||
|
table.insert(parts, {
|
||||||
|
functionCall = { name = fn.name or "", args = args }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Gemini 不接受空的 parts 数组;无可发送内容的轮次直接跳过
|
||||||
|
if #parts > 0 then
|
||||||
|
table.insert(contents, { role = "model", parts = parts })
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif role == "tool" then
|
||||||
|
local name = call_names[m.tool_call_id] or m.name or ""
|
||||||
|
local text = type(m.content) == "string" and m.content or ""
|
||||||
|
table.insert(contents, {
|
||||||
|
role = "user",
|
||||||
|
parts = { { functionResponse = {
|
||||||
|
name = name,
|
||||||
|
response = { content = text },
|
||||||
|
} } }
|
||||||
|
})
|
||||||
|
|
||||||
|
else
|
||||||
|
local parts = to_parts(m.content)
|
||||||
|
if #parts > 0 then
|
||||||
|
table.insert(contents, { role = "user", parts = parts })
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local gemini_req = {
|
local gemini_req = {
|
||||||
@ -46,6 +105,30 @@ function adapter.transform_request(raw_body)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if system ~= "" then
|
||||||
|
gemini_req.systemInstruction = { parts = { { text = system } } }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- tools -> functionDeclarations
|
||||||
|
if type(req.tools) == "table" then
|
||||||
|
local decls = {}
|
||||||
|
for _, t in ipairs(req.tools) do
|
||||||
|
if type(t) == "table" and type(t["function"]) == "table" then
|
||||||
|
local fn = t["function"]
|
||||||
|
local params = fn.parameters
|
||||||
|
if type(params) ~= "table" then params = { type = "object", properties = {} } end
|
||||||
|
table.insert(decls, {
|
||||||
|
name = fn.name or "",
|
||||||
|
description = fn.description or "",
|
||||||
|
parameters = params,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #decls > 0 then
|
||||||
|
gemini_req.tools = { { functionDeclarations = decls } }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if req.stream then
|
if req.stream then
|
||||||
gemini_req.stream = true
|
gemini_req.stream = true
|
||||||
end
|
end
|
||||||
|
|||||||
@ -6,6 +6,10 @@ adapter.endpoint = "/api/chat"
|
|||||||
adapter.headers = {}
|
adapter.headers = {}
|
||||||
|
|
||||||
-- Ollama API 格式:{ model, messages, stream, options:{temperature,num_predict} }
|
-- Ollama API 格式:{ model, messages, stream, options:{temperature,num_predict} }
|
||||||
|
-- 工具调用必须一并翻译:Ollama 的 assistant 消息用 tool_calls(arguments 是对象
|
||||||
|
-- 而非 JSON 字符串),工具结果用 tool 角色 + tool_name。此前这里只复制了
|
||||||
|
-- role/content,助手那轮的调用和 tool 消息的归属全部丢失,模型看到无来源的
|
||||||
|
-- 工具结果就只能反复重发同一个调用。
|
||||||
function adapter.transform_request(raw_body)
|
function adapter.transform_request(raw_body)
|
||||||
local ok, req = pcall(json.decode, raw_body)
|
local ok, req = pcall(json.decode, raw_body)
|
||||||
if not ok then return raw_body end
|
if not ok then return raw_body end
|
||||||
@ -22,6 +26,7 @@ function adapter.transform_request(raw_body)
|
|||||||
-- 转换 messages 格式(Ollama messages 支持 images base64 数组)
|
-- 转换 messages 格式(Ollama messages 支持 images base64 数组)
|
||||||
if req.messages then
|
if req.messages then
|
||||||
local msgs = {}
|
local msgs = {}
|
||||||
|
local call_names = {} -- tool_call_id -> 函数名
|
||||||
for _, m in ipairs(req.messages) do
|
for _, m in ipairs(req.messages) do
|
||||||
local text, images
|
local text, images
|
||||||
if type(m.content) == "string" then
|
if type(m.content) == "string" then
|
||||||
@ -30,22 +35,61 @@ function adapter.transform_request(raw_body)
|
|||||||
text = ""
|
text = ""
|
||||||
images = {}
|
images = {}
|
||||||
for _, p in ipairs(m.content or {}) do
|
for _, p in ipairs(m.content or {}) do
|
||||||
if p.type == "text" then
|
if type(p) == "table" then
|
||||||
text = text .. (p.text or "")
|
if p.type == "text" then
|
||||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
text = text .. (p.text or "")
|
||||||
local b64 = string.match(p.image_url.url, "^data:[^,]+;base64,(.+)$")
|
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||||||
if b64 then table.insert(images, b64) end
|
local b64 = string.match(p.image_url.url, "^data:[^,]+;base64,(.+)$")
|
||||||
|
if b64 then table.insert(images, b64) end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #images == 0 then images = nil end
|
if #images == 0 then images = nil end
|
||||||
end
|
end
|
||||||
local msg = { role = m.role, content = text }
|
local msg = { role = m.role, content = text }
|
||||||
if images then msg.images = images end
|
if images then msg.images = images end
|
||||||
|
|
||||||
|
-- 助手轮的工具调用:arguments 转成对象
|
||||||
|
if type(m.tool_calls) == "table" and #m.tool_calls > 0 then
|
||||||
|
local tcs = {}
|
||||||
|
for _, tc in ipairs(m.tool_calls) do
|
||||||
|
if type(tc) == "table" then
|
||||||
|
local fn = tc["function"] or {}
|
||||||
|
local args = fn.arguments
|
||||||
|
if type(args) == "string" and args ~= "" then
|
||||||
|
local aok, decoded = pcall(json.decode, args)
|
||||||
|
args = aok and decoded or {}
|
||||||
|
elseif type(args) ~= "table" then
|
||||||
|
args = {}
|
||||||
|
end
|
||||||
|
if tc.id ~= nil then call_names[tc.id] = fn.name or "" end
|
||||||
|
table.insert(tcs, {
|
||||||
|
["function"] = { name = fn.name or "", arguments = args }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #tcs > 0 then msg.tool_calls = tcs end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 工具结果:Ollama 用 tool_name 标识归属(不认 tool_call_id)
|
||||||
|
if m.role == "tool" then
|
||||||
|
local name = call_names[m.tool_call_id] or m.name or ""
|
||||||
|
if m.tool_call_id ~= nil then
|
||||||
|
msg.tool_call_id = m.tool_call_id
|
||||||
|
end
|
||||||
|
if name ~= "" then msg.tool_name = name end
|
||||||
|
end
|
||||||
|
|
||||||
table.insert(msgs, msg)
|
table.insert(msgs, msg)
|
||||||
end
|
end
|
||||||
ollama_req.messages = msgs
|
ollama_req.messages = msgs
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- tools 透传(Ollama 的 shape 与 OpenAI 一致)
|
||||||
|
if type(req.tools) == "table" and #req.tools > 0 then
|
||||||
|
ollama_req.tools = req.tools
|
||||||
|
end
|
||||||
|
|
||||||
return json.encode(ollama_req)
|
return json.encode(ollama_req)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
120
internal/lua/toolcall_preservation_test.go
Normal file
120
internal/lua/toolcall_preservation_test.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package lua
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAdaptersPreserveToolCalls is the fleet-wide guard for the
|
||||||
|
// "orphaned tool result" class of bug: when an agent client replays a turn
|
||||||
|
// whose assistant message carries tool_calls, EVERY adapter must forward both
|
||||||
|
// the call and the attribution of its result. Dropping the call (or the
|
||||||
|
// tool_call_id / tool_name that ties the result to it) makes the model see a
|
||||||
|
// result for a call it never made and re-issue the same call forever.
|
||||||
|
//
|
||||||
|
// The input deliberately uses content:[] — the shape most agent clients emit
|
||||||
|
// for a tool-calling assistant turn with no text.
|
||||||
|
func TestAdaptersPreserveToolCalls(t *testing.T) {
|
||||||
|
vm := NewVM(freshAdapterDir(t))
|
||||||
|
if err := vm.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer vm.Stop()
|
||||||
|
|
||||||
|
body := `{"model":"m","messages":[
|
||||||
|
{"role":"user","content":"read /tmp/x"},
|
||||||
|
{"role":"assistant","content":[],"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"read","arguments":"{\"path\":\"/tmp/x\"}"}}]},
|
||||||
|
{"role":"tool","tool_call_id":"call_abc","content":"hello world"},
|
||||||
|
{"role":"user","content":"summarize"}
|
||||||
|
]}`
|
||||||
|
const (
|
||||||
|
callID = "call_abc"
|
||||||
|
fnName = "read"
|
||||||
|
toolOut = "hello world"
|
||||||
|
lastUser = "summarize"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Adapters that identify a tool result by the call id.
|
||||||
|
byID := []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral",
|
||||||
|
"sensenova", "trae", "agentrouter", "opencode", "anthropic"}
|
||||||
|
|
||||||
|
for _, a := range byID {
|
||||||
|
out, err := vm.Transform(a, "transform_request", body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: transform_request: %v", a, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, callID) {
|
||||||
|
t.Errorf("%s dropped the tool call id %q: %s", a, callID, out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, fnName) {
|
||||||
|
t.Errorf("%s dropped the function name %q: %s", a, fnName, out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, toolOut) {
|
||||||
|
t.Errorf("%s dropped the tool result: %s", a, out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, lastUser) {
|
||||||
|
t.Errorf("%s dropped the final user turn: %s", a, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ollama identifies the result with tool_name (its API has no tool_call_id
|
||||||
|
// on assistant turns) and takes arguments as an object, not a JSON string.
|
||||||
|
out, err := vm.Transform("ollama", "transform_request", body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ollama: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"tool_calls"`, fnName, `"tool_name"`, toolOut} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("ollama missing %s: %s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini identifies the result by function NAME and has no call id in its
|
||||||
|
// wire format; both directions must be present as functionCall /
|
||||||
|
// functionResponse parts, and the system role must move to
|
||||||
|
// systemInstruction rather than sitting in contents.
|
||||||
|
gBody := `{"model":"m","messages":[
|
||||||
|
{"role":"system","content":"be brief"},
|
||||||
|
{"role":"user","content":"read /tmp/x"},
|
||||||
|
{"role":"assistant","content":[],"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"read","arguments":"{\"path\":\"/tmp/x\"}"}}]},
|
||||||
|
{"role":"tool","tool_call_id":"call_abc","content":"hello world"}
|
||||||
|
]}`
|
||||||
|
gout, err := vm.Transform("gemini", "transform_request", gBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gemini: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"functionCall"`, `"functionResponse"`, fnName, toolOut, `"systemInstruction"`} {
|
||||||
|
if !strings.Contains(gout, want) {
|
||||||
|
t.Errorf("gemini missing %s: %s", want, gout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No message may keep the OpenAI-only roles inside contents.
|
||||||
|
var greq struct {
|
||||||
|
Contents []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
} `json:"contents"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(gout), &greq); err != nil {
|
||||||
|
t.Fatalf("gemini unmarshal: %v (%s)", err, gout)
|
||||||
|
}
|
||||||
|
for _, c := range greq.Contents {
|
||||||
|
if c.Role != "user" && c.Role != "model" {
|
||||||
|
t.Errorf("gemini contents carry an invalid role %q: %s", c.Role, gout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative control: a text-only turn must still be forwarded verbatim.
|
||||||
|
plain := `{"model":"m","messages":[{"role":"user","content":"just text"}]}`
|
||||||
|
for _, a := range append(byID, "ollama", "gemini") {
|
||||||
|
pout, err := vm.Transform(a, "transform_request", plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s plain: %v", a, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(pout, "just text") {
|
||||||
|
t.Errorf("%s dropped plain text: %s", a, pout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
@ -61,6 +62,39 @@ type ChatMessage struct {
|
|||||||
|
|
||||||
func StringContent(s string) json.RawMessage { b, _ := json.Marshal(s); return b }
|
func StringContent(s string) json.RawMessage { b, _ := json.Marshal(s); return b }
|
||||||
|
|
||||||
|
// isEmptyJSONArray reports whether raw is the literal empty JSON array [].
|
||||||
|
func isEmptyJSONArray(raw json.RawMessage) bool {
|
||||||
|
t := bytes.TrimSpace(raw)
|
||||||
|
return len(t) == 2 && t[0] == '[' && t[1] == ']'
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON decodes a chat message, normalising an empty-array content
|
||||||
|
// ("content":[]) to an empty string and dropping an empty tool_calls array.
|
||||||
|
//
|
||||||
|
// Why this exists: Lua adapters json.decode the request and re-encode it, and
|
||||||
|
// an empty Lua table is indistinguishable from an empty JSON array — the
|
||||||
|
// encoder emits {} for both. Agent clients serialise an assistant turn that
|
||||||
|
// carries tool_calls and no text as content:[], so every pass-through adapter
|
||||||
|
// turned it into content:{} — a shape that is not valid OpenAI (content is
|
||||||
|
// string | array of parts | null) and that real upstreams reject with
|
||||||
|
// "400 invalid arguments". Normalising at the decode boundary fixes every
|
||||||
|
// adapter at once, including ones added later.
|
||||||
|
func (m *ChatMessage) UnmarshalJSON(b []byte) error {
|
||||||
|
type alias ChatMessage
|
||||||
|
var a alias
|
||||||
|
if err := json.Unmarshal(b, &a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*m = ChatMessage(a)
|
||||||
|
if isEmptyJSONArray(m.Content) {
|
||||||
|
m.Content = StringContent("")
|
||||||
|
}
|
||||||
|
if isEmptyJSONArray(m.ToolCalls) {
|
||||||
|
m.ToolCalls = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type ToolCall struct {
|
type ToolCall struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|||||||
@ -68,3 +68,52 @@ func TestTokenUsageMarshalCacheFields(t *testing.T) {
|
|||||||
t.Logf("DeepSeek JSON: %s", string(b))
|
t.Logf("DeepSeek JSON: %s", string(b))
|
||||||
t.Logf("OpenAI JSON: %s", string(b2))
|
t.Logf("OpenAI JSON: %s", string(b2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestChatMessageNormalizesEmptyArrayContent pins the fix for the malformed
|
||||||
|
// content:{} that pass-through adapters produced for an assistant turn carrying
|
||||||
|
// tool_calls with no text. Lua adapters cannot tell an empty JSON array from an
|
||||||
|
// empty object, so the decode boundary normalises it for every adapter at once.
|
||||||
|
func TestChatMessageNormalizesEmptyArrayContent(t *testing.T) {
|
||||||
|
var msg ChatMessage
|
||||||
|
raw := `{"role":"assistant","content":[],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read","arguments":"{}"}}]}`
|
||||||
|
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(msg.Content) != `""` {
|
||||||
|
t.Fatalf("empty-array content must normalise to \"\", got %s", msg.Content)
|
||||||
|
}
|
||||||
|
if len(msg.ToolCalls) == 0 {
|
||||||
|
t.Fatal("tool_calls must survive normalisation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-empty content array must be left byte-identical (multimodal path).
|
||||||
|
var mm ChatMessage
|
||||||
|
multi := `{"role":"user","content":[{"type":"text","text":"hi"}]}`
|
||||||
|
if err := json.Unmarshal([]byte(multi), &mm); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(mm.Content) != `[{"type":"text","text":"hi"}]` {
|
||||||
|
t.Fatalf("multimodal content must pass through untouched, got %s", mm.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain string content is untouched.
|
||||||
|
var sm ChatMessage
|
||||||
|
if err := json.Unmarshal([]byte(`{"role":"user","content":"plain"}`), &sm); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(sm.Content) != `"plain"` {
|
||||||
|
t.Fatalf("string content must pass through untouched, got %s", sm.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An omitted content stays omitted (omitempty semantics preserved).
|
||||||
|
var om ChatMessage
|
||||||
|
if err := json.Unmarshal([]byte(`{"role":"assistant","tool_calls":[]}`), &om); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if om.Content != nil {
|
||||||
|
t.Fatalf("absent content must stay absent, got %s", om.Content)
|
||||||
|
}
|
||||||
|
if om.ToolCalls != nil {
|
||||||
|
t.Fatalf("empty tool_calls array must be dropped, got %s", om.ToolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user