Files
ModelRouter/internal/lua/channels_trae_solo_test.go
JianFeeeee ff9b460eba 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} 全绿。
2026-10-13 23:28:27 +08:00

103 lines
4.3 KiB
Go
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.

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)
}
}