package core import ( "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "llmsproxy/internal/lua" ) // 远端模型目录的判据:**远端为主 + 兜底补位**(照参考 reconcileWithFallback)。 // // 参考明确:"正确做法是**合并**,而不是「远端失败就用兜底」的简单回退" // (cline-models.ts)。故这里逐条锁定: // - 远端有的以远端为准(顺序也照远端) // - 远端没有的用兜底表补位 // - 拉取失败**不清空**既有缓存(宁可留旧目录也别让平台变空) // - 平台声明的真实目录 key 必须在上游源里(占位名会让选模型调不通) type modelsHarness struct { t *testing.T ps *lua.Plugins ts *httptest.Server mu sync.Mutex body string code int hits int } func newModelsHarness(t *testing.T, platform, body string) *modelsHarness { t.Helper() h := &modelsHarness{t: t, body: body, code: 200} h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h.mu.Lock() h.hits++ code, body := h.code, h.body h.mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) fmt.Fprint(w, body) })) t.Cleanup(h.ts.Close) c := kernelTestCore(t) h.ps = c.Plugins() src, err := readBundledChannels(t) if err != nil { t.Fatal(err) } if err := h.ps.LoadSource("channels", src); err != nil { t.Fatal(err) } _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "list_sources", "status"}, false) // 说明:测试上游是 127.0.0.1(loopback),出站白名单默认允许, // 不需要额外授权 —— 也就不需要碰内核的授权 API。 // 把该平台的远端端点指到假上游 if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ "platform": platform, "models_remote": map[string]interface{}{ "endpoint0": h.ts.URL, }, }); err != nil { t.Logf("set_endpoints(models_remote) 未支持: %v", err) } return h } // modelIDs 从 model_list 的 Call 返回值({json:{models:[...]}})取 id 列表。 func modelIDs(t *testing.T, got interface{}) []string { t.Helper() j := got.(map[string]interface{})["json"].(map[string]interface{}) ms, _ := j["models"].([]interface{}) ids := make([]string, 0, len(ms)) for _, e := range ms { ids = append(ids, e.(map[string]interface{})["id"].(string)) } return ids } func TestRemoteModelCatalogMergesWithFallback(t *testing.T) { // zcode:远端 builtinModels 是**对象**(键为序号),且远端/兜底有重叠 body := `{"data":{"builtinModels":{"0":{"modelId":"GLM-5.3"},"1":{"modelId":"GLM-5.3-Flash"},"2":{"modelId":"NEW-MODEL-X"}}}}` h := newModelsHarness(t, "zcode", body) t.Run("行1 远端拉到就用远端并保留兜底独有的", func(t *testing.T) { got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"}) if err != nil { t.Fatal(err) } m := got.(map[string]interface{})["json"].(map[string]interface{}) res := m["results"].([]interface{})[0].(map[string]interface{}) if res["ok"] != true { t.Fatalf("刷新应成功, 实得 %v", res) } // NEW-MODEL-X 只由远端下发,必须出现(证明用的不是硬编码表) ml, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"}) if err != nil { t.Fatal(err) } ids := modelIDs(t, ml) joined := strings.Join(ids, ",") if !strings.Contains(joined, "NEW-MODEL-X") { t.Fatalf("只由远端下发的模型必须出现(否则是硬编码表): %v", ids) } if !strings.Contains(joined, "GLM-5.3") { t.Fatalf("兜底/远端共有项应保留: %v", ids) } // 远端与兜底重叠项不得重复 seen := map[string]int{} for _, id := range ids { seen[id]++ if seen[id] > 1 { t.Fatalf("模型 %s 重复出现(合并应去重): %v", id, ids) } } }) t.Run("行2 拉取失败不清空既有缓存", func(t *testing.T) { // 先成功一次建立缓存 if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"}); err != nil { t.Fatal(err) } // 再让上游 500 h.mu.Lock() h.code = 500 h.body = `{}` h.mu.Unlock() got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"}) if err != nil { t.Fatal(err) } res := got.(map[string]interface{})["json"].(map[string]interface{})["results"].([]interface{})[0].(map[string]interface{}) if res["ok"] == true { t.Fatalf("上游 500 时刷新应报失败, 实得 %v", res) } ml, _ := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"}) ids := modelIDs(t, ml) if !strings.Contains(strings.Join(ids, ","), "NEW-MODEL-X") { t.Fatalf("拉取失败必须保留既有目录(不能让平台变空): %v", ids) } }) } // TestPlatformModelsAreRealCatalogKeys 锁定 12 平台的模型名**不是编的占位名**。 // 此前 qoder/zcode/loomy/raccoon 用的是 "*-default"(我编的),上游不认 —— // 后果是这些平台登录成功后选任何模型都调不通,而测试全绿(它测的就是我编的名字)。 func TestPlatformModelsAreRealCatalogKeys(t *testing.T) { 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) } got, err := ps.Call("channels", "provider_status", map[string]interface{}{}) if err != nil { t.Fatal(err) } plats := got.(map[string]interface{})["json"].(map[string]interface{})["statuses"].([]interface{}) if len(plats) != 12 { t.Fatalf("应有 12 个真实平台, 实得 %d", len(plats)) } realQoder := []string{"qfmodel", "qmodel_38max", "kmodel_latest", "dmodel"} sawQoder := 0 for _, e := range plats { p := e.(map[string]interface{}) id := p["id"].(string) ml, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": id}) if err != nil { t.Fatalf("平台 %s model_list: %v", id, err) } ids := modelIDs(t, ml) if len(ids) == 0 { t.Fatalf("平台 %s 无模型", id) } for _, mid := range ids { // 占位名特征:以 -default 结尾 if strings.HasSuffix(mid, "-default") { t.Fatalf("平台 %s 的模型 %q 是编的占位名(上游不认,选了调不通)", id, mid) } } if id == "qoder" { j := map[string]bool{} for _, mid := range ids { j[mid] = true } for _, want := range realQoder { if j[want] { sawQoder++ } } } } if sawQoder == 0 { t.Fatal("qoder 应含参考的真实目录 key(如 qfmodel),而非编造的占位名") } }