diff --git a/internal/lua/channels_trae_solo_test.go b/internal/lua/channels_trae_solo_test.go new file mode 100644 index 0000000..fbb329a --- /dev/null +++ b/internal/lua/channels_trae_solo_test.go @@ -0,0 +1,102 @@ +package lua + +import ( + "encoding/json" + "strings" + "testing" +) + +// TRAE 不是 OpenAI 兼容:参考 trae.ts:30 明确「载荷必须转换 —— 映射为 SOLO +// 格式(function、config_name 等字段),不能透传」。此前我们把 trae 声明成 +// adapter=openai 直发,端点对了、载荷全错。 +// +// 本测试走**真实路径**:装插件(插件自己装 channels-trae 适配器)→ 用 +// vm.Transform 调那个**已安装**的适配器。这比在插件 VM 里 load 一份副本更 +// 可信:副本拿不到内核注入的共享前导,且无法发现"装了但没生效"。 +func TestChannelsTraeSoloAdapter(t *testing.T) { + vm, ps, _ := runtimePlugins(t) + src, err := bundledAdapters.ReadFile("plugins/channels.lua") + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", string(src)); err != nil { + t.Fatal(err) + } + // 插件应在加载时自装 TRAE SOLO 适配器(名字 channels-trae,避开内置 trae) + names := map[string]bool{} + for _, a := range vm.ListAdapters() { + names[a.Name] = true + } + if !names["channels-trae"] { + t.Fatalf("插件未装 channels-trae 适配器;已装: %v", names) + } + if owner := ps.AdapterOwnerOf("channels-trae"); owner != "channels" { + t.Errorf("归属应为 channels,实得 %q", owner) + } + // 内置 trae(供运维的 trae-local-api 反代用)必须**不被顶掉** + if !names["trae"] { + t.Error("内置 trae 适配器被顶掉了(它仍服务运维自建的本地反代)") + } + + in := `{"model":"glm-5.2","messages":[` + + `{"role":"system","content":"you are helpful"},` + + `{"role":"user","content":"hi"},` + + `{"role":"assistant","content":"ok","tool_calls":[` + + `{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}},` + + `{"id":"c2","type":"function","function":{"name":"","arguments":"{}"}}]}` + + `],"tools":[{"type":"function","function":{"name":"f","description":"d",` + + `"parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}],` + + `"tool_choice":{"type":"function","function":{"name":"f"}},` + + `"stream":false,"extra_body":{"x":1}}` + out, err := vm.Transform("channels-trae", "transform_request", in) + if err != nil { + t.Fatalf("Transform: %v", err) + } + t.Logf("SOLO 载荷: %s", out) + + for _, want := range []struct{ desc, needle string }{ + {"规则2 stream 必须强制 true(非流式由服务端聚合)", `"stream":true`}, + {"规则3 model → config_name + model 双字段", `"config_name":"glm-5.2"`}, + {"规则4 function 通道(缺省 solo_work_lite)", `"function":"solo_work_lite"`}, + {"规则1 content 字符串 → parts 数组", `"text":"hi"`}, + {"规则6 tool_calls 的 function → function_call", `"function_call"`}, + {"规则7 parameters 必须是 JSON 字符串(SOLO 要求)", `{\"properties\"`}, + {"规则5 tool_choice 为 function 时归一成 name 字符串", `"tool_choice":"f"`}, + } { + if !strings.Contains(out, want.needle) { + t.Errorf("%s\n 缺 %s\n 实际: %s", want.desc, want.needle, out) + } + } + // 反向断言 + if strings.Contains(out, `"extra_body"`) { + t.Errorf("内核私有字段 extra_body 未剥离:%s", out) + } + if strings.Contains(out, `"id":"c2"`) { + t.Errorf("无 name 的 tool_call 应被剔除(孤儿会让整条会话被拒):%s", out) + } + // 必须是合法 JSON,且 model/config_name 同值、stream 为 true + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("转换结果不是合法 JSON: %v\n%s", err, out) + } + if parsed["model"] != "glm-5.2" || parsed["config_name"] != "glm-5.2" { + t.Errorf("model/config_name 应同为 glm-5.2:%v / %v", parsed["model"], parsed["config_name"]) + } + if parsed["stream"] != true { + t.Errorf("stream 应为 true,实得 %v", parsed["stream"]) + } + // tool_choice=none 时必须同时删掉 tools 与 tool_choice 本身 + noneIn := `{"model":"glm-5.2","messages":[{"role":"user","content":"x"}],` + + `"tools":[{"type":"function","function":{"name":"f","parameters":{"type":"object"}}}],` + + `"tool_choice":"none"}` + noneOut, err := vm.Transform("channels-trae", "transform_request", noneIn) + if err != nil { + t.Fatalf("Transform(none): %v", err) + } + if strings.Contains(noneOut, `"tools"`) { + t.Errorf("tool_choice=none 时 tools 必须被删:%s", noneOut) + } + if strings.Contains(noneOut, `"tool_choice"`) { + t.Errorf("tool_choice=none 时该字段本身也应被删:%s", noneOut) + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 7895b1d..b9efe22 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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 + -- 是语法错误('' 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