feat(channels): TRAE 用插件自装的 SOLO 适配器(channels-trae)—— 载荷真转换

TRAE **不是 OpenAI 兼容**。参考 trae.ts:30 点名:「载荷必须转换:OpenAI 的
{model,messages,tools,tool_choice,stream} 需要映射为 SOLO 格式(function、
config_name 等字段),不能透传」。上一轮我把端点改对了(/api/agent/v3/
llm_utils_chat)但**载荷仍原样直发** —— 上游会回流内 4001,而 HTTP 状态码
仍是 200,属静默失败。

本次补齐 transformToSOLOBody 的 7 条规则(逐条对齐参考):
  1. messages.content 字符串 → [{type:"text",text:…}];数组透传
  2. stream 强制 true(非流式由服务端聚合)
  3. model → config_name + model **双字段**(支持 __dev 后缀消除)
  4. function 通道名,缺省 solo_work_lite
  5. tool_choice 归一化:none → 删 tool_choice **与** tools;
     auto/required 保留;{type:function,function:{name}} → 字符串 name
  6. assistant 的 tool_calls: function → function_call,并剔除无 name 的条目
     (孤儿 tool_call 会让整条会话被拒)
  7. tools[].function.parameters: object → JSON **字符串**(SOLO 要求)

★ 命名为什么是 channels-trae 而不是 trae:内置 trae.lua 服务的是**运维自建
的 trae-local-api 反代**(base_url 127.0.0.1:19900,生产 config.yaml 在用),
与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)。
判据里专门断言"内置 trae 未被顶掉"。

★ 踩到的坑:`function` 是 Lua 关键字,写 `body.function = x` 是**语法错误**
('<name>' expected near 'function'),必须 `body["function"]`。它只在 load()
时才暴露 —— 插件脚本本身能编译通过,适配器源码要运行期才报错。

判据(internal/lua/channels_trae_solo_test.go):走**真实路径**
(装插件 → 调已安装的适配器),而不是在插件 VM 里 load 一份副本 ——
副本拿不到内核注入的共享前导,也发现不了"装了但没生效"。逐条断言 7 条规则
+ 三处反向断言(extra_body 必剥离、无 name 的 tool_call 必剔除、
tool_choice=none 时 tools 与 tool_choice 都必须消失)。

回归:internal/{lua,core,provider,gateway} 全绿。
This commit is contained in:
JianFeeeee
2026-10-13 23:28:27 +08:00
parent 1f54143956
commit ff9b460eba
2 changed files with 293 additions and 3 deletions

View File

@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe" }
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe" }
@ -125,8 +125,10 @@ local PLATFORMS = {
hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" },
-- trae 非 OpenAI 兼容:端点是 SOLO 的 /api/agent/v3/llm_utils_chat,
-- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。
base_url = "https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat",
adapter = "openai",
base_url = "https://trae-api-cn.mchost.guru",
-- TRAE 非 OpenAI 兼容,用**本插件自装**的 SOLO 适配器
-- (见 TRAE_SOLO_ADAPTER_SRC)。端点由适配器声明 /api/agent/v3/llm_utils_chat。
adapter = "channels-trae",
-- 照参考 TRAE_FALLBACK_MODELS 全量(32 项)。其中 4 项带 isHidden
-- (内部子 agent)—— 不进用户可选列表,单列在 models_hidden。
models = {
@ -6674,6 +6676,189 @@ end
return adapter
]==]
-- ============================================================================
-- TRAE 专用适配器(插件自装:channels-trae)
-- ============================================================================
-- TRAE **不是 OpenAI 兼容**。参考 trae.ts:30 注释点名:
-- 「载荷必须转换:OpenAI 的 {model,messages,tools,tool_choice,stream} 需要
-- 映射为 SOLO 格式(function、config_name 等字段),不能透传」
--
-- 为什么名字不叫 "trae":内置适配器 `trae.lua` 服务的是**运维自建的
-- trae-local-api 反代**(base_url 127.0.0.1:19900,生产 config.yaml 在用),
-- 与本插件的直连通道是两回事。重名会被内核的冲突仲裁挂起(正确的护栏),
-- 所以本插件用 `channels-trae`。
--
-- SOLO 转换规则(逐条对齐参考 trae.ts:transformToSOLOBody):
-- 1. messages.content 字符串 → [{type:"text",text:...}];已是数组则透传
-- 2. stream 强制 true(非流式由服务端聚合)
-- 3. model → config_name + model **双字段**
-- 4. function: 通道名,缺省 "solo_work_lite"
-- 5. tool_choice 归一化("none" 删 tools;auto/required 保留;
-- {type:"function",function:{name}} → 字符串 name)
-- 6. assistant 消息的 tool_calls: function → function_call(SOLO 字段名),
-- 并剔除没有 name 的条目(孤儿会让整条会话被拒)
-- 7. tools[].function.parameters: object → JSON **字符串**(SOLO 要求)
local TRAE_SOLO_ADAPTER_SRC = [==[
local adapter = {}
adapter.name = "channels-trae"
adapter.version = "1.0.0"
adapter.endpoint = "/api/agent/v3/llm_utils_chat"
local TRAE_DEFAULT_MODEL = "glm-5.2"
local TRAE_FUNCTION = "solo_work_lite"
local TRAE_MAX_MODE_TYPE = 1
local TRAE_MAX_PROMPT_TOKENS = 936000
local TRAE_MAX_OUTPUT_TOKENS = 64000
-- normalizeToolChoice:照参考 normalizeToolChoice(Go payload.go:111-154)
local function normalize_tool_choice(body)
local tc = body.tool_choice
if tc == nil then return end
local function suppress()
body.tools = nil
body.functions = nil
end
if type(tc) == "string" then
if string.lower(tc):gsub("%s+", "") == "none" then
body.tool_choice = nil
suppress()
end
return
end
if type(tc) == "table" then
local typ = type(tc.type) == "string" and string.lower(tc.type):gsub("%s+", "") or ""
if typ == "none" then
body.tool_choice = nil
suppress()
elseif typ == "auto" or typ == "required" then
body.tool_choice = typ
elseif typ == "function" then
local name = ""
local fn = tc["function"]
if type(fn) == "table" and type(fn.name) == "string" then name = fn.name end
if name == "" and type(tc.name) == "string" then name = tc.name end
if #(name:gsub("%s+", "")) > 0 then body.tool_choice = name
else body.tool_choice = "auto" end
else
body.tool_choice = nil
end
return
end
body.tool_choice = nil
end
-- normalizeTools:parameters 必须是**字符串**(SOLO 要求),OpenAI 标准是对象。
local function normalize_tools(body)
local raw = body.tools
if type(raw) ~= "table" or #raw == 0 then return end
local out = {}
for _, item in ipairs(raw) do
if type(item) == "table" then
local fn = item["function"]
if type(fn) == "table" then
if type(fn.parameters) == "table" then
fn.parameters = json.encode(fn.parameters)
end
out[#out + 1] = item
end
end
end
if #out > 0 then body.tools = out end
end
-- transformSOLOMessage:单条消息(content 字符串→parts;tool_calls 字段改名)
local function transform_solo_message(msg)
if type(msg) ~= "table" then return msg end
local result = {}
for k, v in pairs(msg) do result[k] = v end
if result.role == "assistant" and type(result.tool_calls) == "table" then
local kept = {}
for _, tc in ipairs(result.tool_calls) do
if type(tc) == "table" then
-- function → function_call(SOLO 字段名)
if type(tc["function"]) == "table" then
tc.function_call = tc["function"]
tc["function"] = nil
end
local fc = tc.function_call
local name = (type(fc) == "table" and type(fc.name) == "string") and fc.name or ""
-- 剔除没有 name 的条目(孤儿 tool_call 会让整条会话被拒)
if #(name:gsub("%s+", "")) > 0 then kept[#kept + 1] = tc end
end
end
if #kept > 0 then result.tool_calls = kept else result.tool_calls = nil end
end
-- content:字符串 → [{type:"text",text:...}];数组/其它原样透传
if type(result.content) == "string" then
result.content = { { type = "text", text = result.content } }
end
return result
end
-- transform_request:OpenAI 请求体 → SOLO 格式
function adapter.transform_request(raw_body)
local body = __prelude.decode_body(raw_body)
if body == nil then return raw_body end
__prelude.strip_control(body)
body.stream = true
-- ⚠️ `function` 是 Lua 关键字:必须用 body["function"],写 body.function
-- 是语法错误('<name>' expected near 'function'),且只在 load() 时才暴露
-- —— 模块级 local function 不会在编译期报出来。
if body["function"] == nil then body["function"] = TRAE_FUNCTION end
if type(body["function"]) == "string" and body["function"] == "" then
body["function"] = TRAE_FUNCTION
end
-- messages 转换
if type(body.messages) == "table" then
local msgs = {}
for _, m in ipairs(body.messages) do msgs[#msgs + 1] = transform_solo_message(m) end
body.messages = msgs
end
-- model → config_name + model(双字段)。支持 __dev 后缀消除。
local model = type(body.model) == "string" and body.model or ""
local base = model
local sep = string.find(model, "__", 1, true)
if sep then base = string.sub(model, 1, sep - 1) end
local config_name = (base ~= "" and base) or TRAE_DEFAULT_MODEL
body.config_name = config_name
body.model = config_name
normalize_tool_choice(body)
normalize_tools(body)
return json.encode(body)
end
function adapter.transform_response(raw_body)
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
return __prelude.openai_stream_chunk(raw_chunk)
end
function adapter.transform_error(status, body)
return __prelude.openai_error(status, body)
end
return adapter
]==]
-- installTraeAdapter():装/更新本插件自己的 TRAE 直连适配器。
local function installTraeAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local _, uerr = __llmsproxy_adapters.update("channels-trae", TRAE_SOLO_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-trae", TRAE_SOLO_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
-- installCodeartsAdapter():装/更新本插件自己的 codearts 适配器。
-- 幂等(update 失败时退化为 install);失败只记日志,不阻断插件加载 ——
-- 适配器装不上时源注册会报"未知适配器",比让整个插件加载失败更好定位。
@ -7239,6 +7424,9 @@ end
do
local ok, err = installCodeartsAdapter()
plugin._codearts_adapter = { ok = ok, err = err }
local tok2, terr2 = installTraeAdapter()
plugin._trae_adapter = { ok = tok2, err = terr2 }
if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end
end
return plugin