mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 12:17:23 +00:00
feat(trae): batch 模型目录(含参考记录的两个真实缺陷修复 + 元数据)
我们此前 trae 的模型列表是**纯硬编码**(32 项名字),既没有真实元数据,也没打 batch 端点。参考的 parseTraeBatchModelList 记录了**两个已修的缺陷**,移植时 必须一起带上,否则等于把 bug 再引进一遍: ## ① 通道白名单是「准入」而不只是「排序」(issue IKJOZ7) 22 个 function 里有一半在本插件推理端点下**不可调用**(实测 chat→4023 model unknown、builder→4001、inline_chat→3003)。只把白名单用于"同模型择优" 的旧写法 = "目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,而错误文案 指向**模型**,极易被误判成"这个模型坏了"。现在不在表内的通道**整组丢弃**。 ## ② 空档位不得覆盖有档位(issue IKI7WT/IKILR7) 旧实现无条件"后面的覆盖前面的",其假设(后面的配置更全)与真实数据**相反** —— 上游把空档位的 solo_work_lite/solo_design_remote 排在**最后**,实测 13 个 模型因此丢掉 reasoning_effort_config、全部显示"未提供推理等级"。 合并规则(与参考逐条对齐):空档位不得覆盖有档位;两侧都有档位时按通道优先级 取更靠前者;其余保持"后覆盖前"。 ## ③ is_custom_model 是「仅可见但不可调用」的权威判据 该标志为 true 的条目被上游以流内 event:error + 4001 拒绝。 ⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志随服务端下发变化, 曾据名字把 qwen3.8-flash 误记为应剔除(它其实正常可用)。 ## 附带:远端目录**元数据**不再丢 原来目录只回 id ⇒ 参考能提供的上下文窗口/输出上限/思考档位/消耗倍率全丢。 现按参考存下完整条目并在 model_list 以 `remote` 字段报出。 ⚠ contextWindow 取 `context_window_tokens.dev` 而非 `max`:max 需开启 display_config.max_mode 才可用(我们不实现该开关),采信 max 会让上层以为有 1M 窗口、实际被上游拒。 ## 基础设施 - refreshRemoteModels 支持 ep.body(POST 型目录端点)与 ep.sign(复用平台既有 的头部构造,不另写一套 —— 否则"chat 能通、目录 401"极难查)。 - trae 走 sign="trae_solo" 复用 traeCheckinHeaders(Cloud-IDE-JWT + 约 20 个 客户端头;参考实测简化头会被拒)。 - 新增 __trae_entry_probe(诊断"合并规则选中了哪条"),此前只能靠猜。 ## 判据(3 条 + 变异 3/3) internal/core/channels_trae_catalog_test.go - 白名单准入 + 档位来源(空档位不得覆盖有档位) - 四条硬性过滤(usage/config_switch/is_invisible_to_user/is_custom_model) 且 **is_custom_model=false 的 custom_model_* 条目必须保留**(判据是标志值) - 请求体必须传全部 22 个 function(与真实 IDE 一致;只传聊天通道会让非聊天 通道条目位置错乱甚至被跳过) 变异:白名单不做准入 → 报红;无条件后覆盖前 → 报红;不过滤 custom → 报红。 ★ 调试中发现并修掉我自己写的一个**静默失效**: `declaresEffort` 读 `e.reasoning_effort_config` 而 `parseEntry` 存的是 `e.reasoning` ⇒ 判定恒为 false ⇒ 规则①永不生效(正是它要防的缺陷)。 判据当场抓到。**教训:跨函数用同一份数据时,字段名必须对齐 —— 这类不一致 不会报错,只会让规则静默失效。** 验证:internal/core 369s 全绿。
This commit is contained in:
230
internal/core/channels_trae_catalog_test.go
Normal file
230
internal/core/channels_trae_catalog_test.go
Normal file
@ -0,0 +1,230 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 的 batch 模型目录解析:两个**真实缺陷**必须在移植时一起带上
|
||||
// (参考 parseTraeBatchModelList 的注释记录了它们)。
|
||||
//
|
||||
// ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7):
|
||||
//
|
||||
// 22 个 function 里有一半在本插件推理端点下不可调用(实测 chat→4023
|
||||
// model unknown、builder→4001、inline_chat→3003)。旧实现只把白名单用于
|
||||
// "同模型择优",于是"目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,
|
||||
// 且错误指向**模型**,极易被误判成"这个模型坏了"。
|
||||
//
|
||||
// ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7):
|
||||
//
|
||||
// 旧实现无条件"后覆盖前",假设(后面的配置更全)与真实数据**相反** ——
|
||||
// 上游把空档位的 solo_work_lite 排在最后,实测 13 个模型因此丢掉
|
||||
// reasoning_effort_config,全部显示"未提供推理等级"。
|
||||
func TestTraeBatchModelCatalogAdmissionAndReasoningProvenance(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var gotBody string
|
||||
h := newTraeCatalogHarness(t, func(body string) {
|
||||
mu.Lock()
|
||||
gotBody = body
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
ids := catalogIDs(t, h, "trae")
|
||||
joined := strings.Join(ids, ",")
|
||||
|
||||
// ① 白名单准入:不可调用通道里的模型必须**被丢弃**
|
||||
if strings.Contains(joined, "only-in-bad-channel") {
|
||||
t.Fatalf("不可调用通道(chat/builder/inline_chat)里的模型必须整组丢弃,"+
|
||||
"否则用户选中它必然失败且错误指向模型:%v", ids)
|
||||
}
|
||||
// 但通用通道里的模型要在
|
||||
if !strings.Contains(joined, "glm-5.2") {
|
||||
t.Fatalf("可调用通道里的模型不该被丢:%v", ids)
|
||||
}
|
||||
|
||||
// ② 空档位不得覆盖有档位:同一模型在 solo_agent(有档位)与
|
||||
// solo_work_lite(空档位,**排在后面**)都出现 ⇒ 必须保留有档位那条。
|
||||
// 判据看**报出的档位**,不是"哪条被选中"这类内部信号。
|
||||
if !h.modelHasReasoningOptions("glm-5.2") {
|
||||
t.Fatalf("glm-5.2 的思考档位被空档位条目覆盖了 —— 上游把空档位的"+
|
||||
"solo_work_lite 排在最后,无条件后覆盖前会让模型显示"+
|
||||
"「未提供推理等级」(issue IKI7WT/IKILR7):%v", ids)
|
||||
}
|
||||
|
||||
// ③ 请求体必须传**全部 22 个 function**(与真实 IDE 一致)——
|
||||
// 只传聊天通道会让非聊天通道条目在响应里位置错乱甚至被跳过。
|
||||
mu.Lock()
|
||||
b := gotBody
|
||||
mu.Unlock()
|
||||
if b == "" {
|
||||
t.Fatal("未捕获到目录请求体")
|
||||
}
|
||||
for _, want := range []string{"ui_builder_v2", "inline_chat", "system_diagnosis", "show_custom_model"} {
|
||||
if !strings.Contains(b, want) {
|
||||
t.Fatalf("目录请求体必须与真实 IDE 一致传全部 function(缺 %q):%s", want, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 四条硬性过滤:usage / config_switch / is_invisible_to_user / 必须有 function。
|
||||
func TestTraeBatchModelCatalogHardFilters(t *testing.T) {
|
||||
h := newTraeCatalogHarness(t, nil)
|
||||
ids := catalogIDs(t, h, "trae")
|
||||
joined := strings.Join(ids, ",")
|
||||
for _, bad := range []string{"summary-model", "disabled-model", "hidden-model", "custom-flag-model"} {
|
||||
if strings.Contains(joined, bad) {
|
||||
t.Errorf("%s 应被硬性过滤掉(usage/switch/invisible):%v", bad, ids)
|
||||
}
|
||||
}
|
||||
// ⚠ 判据是**标志的值**,不是模型名:名字带 custom_model_ 前缀但标志为 false
|
||||
// 的条目必须保留(参考实测:曾据此把 qwen3.8-flash 误记为应剔除,它其实可用)。
|
||||
if !strings.Contains(joined, "custom_model_prefix_but_ok") {
|
||||
t.Errorf("is_custom_model=false 的条目必须保留(判据是标志值而非模型名):%v", ids)
|
||||
}
|
||||
// ⚠ 我第一版把 no-function-model 放进 solo_agent 的 config_info_list 里,
|
||||
// 却断言它应被丢弃 —— 那是**夹具写错**:它在合法组内,保留才是对的。
|
||||
// 「未声明 function」指的是**整个 function_configs 组**没有 function 字段。
|
||||
if !strings.Contains(joined, "no-function-model") {
|
||||
t.Errorf("合法组内的条目不该被丢:%v", ids)
|
||||
}
|
||||
t.Logf("✓ 过滤后目录: %v", ids)
|
||||
}
|
||||
|
||||
// ── harness:假上游返回一份 batch_get_detail_param 形态的目录 ──
|
||||
|
||||
type traeCatalogHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarness {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if onBody != nil {
|
||||
onBody(string(b))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 三个通道:
|
||||
// solo_agent(**可调用**,白名单内)—— glm-5.2 **有**档位
|
||||
// solo_work_lite(白名单内,**排在后面**)—— glm-5.2 **空档位**
|
||||
// inline_chat(**白名单外**)—— 只列出 only-in-bad-channel
|
||||
fmt.Fprint(w, `{"function_configs":[`+
|
||||
// ① solo_agent:glm-5.2 有档位;另有三个必被硬性过滤的条目
|
||||
`{"function":"solo_agent","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+
|
||||
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}},`+
|
||||
`{"config_name":"summary-model","usage":"summary","config_switch":true},`+
|
||||
`{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+
|
||||
`{"config_name":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+
|
||||
`{"config_name":"no-function-model","usage":"chat_completion","config_switch":true},`+
|
||||
// is_custom_model=true ⇒ 上游以流内 4001 拒绝("仅可见但不可调用")
|
||||
`{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+
|
||||
`"display_config":{"is_custom_model":true}},`+
|
||||
// 对照组:名字带 custom_model_ 前缀但**标志为 false** ⇒ 必须**保留**
|
||||
// (参考点名:判据是标志的值,不是模型名)
|
||||
`{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+
|
||||
`"display_config":{"is_custom_model":false}}`+
|
||||
`]},`+
|
||||
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置
|
||||
`{"function":"solo_work_lite","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true}`+
|
||||
`]},`+
|
||||
// ③ 白名单外通道:整组必须被丢弃
|
||||
`{"function":"inline_chat","config_info_list":[`+
|
||||
`{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+
|
||||
`]}`+
|
||||
`]}`)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &traeCatalogHarness{t: t, ps: ps}
|
||||
// 把目录端点指到假上游
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"models_remote": map[string]interface{}{"endpoint0": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 塞一个 TRAE 账号(目录请求需要凭据)
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"account_id": "cat-trae",
|
||||
"credential": `{"access_token":"AT","uid":"u1","refresh_token":"RT"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func catalogIDs(t *testing.T, h *traeCatalogHarness, platform string) []string {
|
||||
t.Helper()
|
||||
if _, err := h.ps.Call("channels", "model_refresh",
|
||||
map[string]interface{}{"platform": platform}); err != nil {
|
||||
t.Fatalf("model_refresh: %v", err)
|
||||
}
|
||||
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": platform})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(t, res)
|
||||
var out []string
|
||||
arr, _ := j["models"].([]interface{})
|
||||
for _, e := range arr {
|
||||
if m, ok := e.(map[string]interface{}); ok {
|
||||
if id, ok := m["id"].(string); ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// modelHasReasoningOptions 查某模型是否被报出思考档位(走 model_list 的字段)。
|
||||
func (h *traeCatalogHarness) modelHasReasoningOptions(modelID string) bool {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(h.t, res)
|
||||
arr, _ := j["models"].([]interface{})
|
||||
for _, e := range arr {
|
||||
m, ok := e.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["id"] != modelID {
|
||||
continue
|
||||
}
|
||||
// 档位的真实出口:model_list 把远端目录解出的档位放在
|
||||
// `remote.efforts`(并镜像到顶层 efforts),另有 reasoning=true。
|
||||
for _, k := range []string{"efforts", "reasoning_options", "reasoningOptions"} {
|
||||
if v, ok := m[k].([]interface{}); ok && len(v) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if rm, ok := m["remote"].(map[string]interface{}); ok {
|
||||
if v, ok := rm["efforts"].([]interface{}); ok && len(v) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user