mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47fe759025 | |||
| d795266572 | |||
| a82ae2b60e | |||
| 2d2c27c430 | |||
| 0f83fc048a | |||
| 1f29be43db | |||
| 8109acf7cd | |||
| 7c82c7ff6b |
57
internal/core/channels_buddy_catalog_filter_test.go
Normal file
57
internal/core/channels_buddy_catalog_filter_test.go
Normal file
@ -0,0 +1,57 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 远端模型目录混着「不可调用」的条目,必须过滤掉。
|
||||
//
|
||||
// 真实缺陷(生产实测):buddy 源声明了 24 个模型,其中
|
||||
//
|
||||
// nes-gf / codewise-completions / codewise-rewrite / codewise-jump /
|
||||
// codewise-default-model-v2 / hunyuan-image-alpha / auto
|
||||
//
|
||||
// 全部调不通(`glm-5.1` 也是 404)。用户选中就报错,而错误文案指向**模型**,
|
||||
// 极易被误判成"这个模型坏了"。
|
||||
//
|
||||
// 参考实现(buddy.ts 的 isChatModel / isAutoSelectAlias)记录了**四类**判据,
|
||||
// 且都是实测依据:
|
||||
//
|
||||
// ① 自动选择别名 auto/default(**只按字面量**,前缀匹配会误伤 default-model)
|
||||
// ② 补全/NES 专用前缀 nes- / completion- / codewise-,或 supportsExtra=true
|
||||
// (参考实测 codewise-default-model-v2 → `code 11102 service info not found`)
|
||||
// ③ maxOutputTokens >0 且 ≤256(补全用途;对话模型普遍 ≥24000)
|
||||
// ④ 带 text-to-image 标签(如 hunyuan-image-alpha)
|
||||
func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 判据一:四类规则必须都在源码里(防止有人"精简"掉其中一条)
|
||||
for _, rule := range []string{
|
||||
`id == "auto" or id == "default"`, // ①
|
||||
`id:sub(1, 4) == "nes-"`, // ②
|
||||
`id:sub(1, 11) == "completion-"`, // ②
|
||||
`id:sub(1, 9) == "codewise-"`, // ②
|
||||
`m.supportsExtra == true`, // ②
|
||||
`mo > 0 and mo <= 256`, // ③
|
||||
`t == "text-to-image"`, // ④
|
||||
} {
|
||||
if !strings.Contains(s, rule) {
|
||||
t.Errorf("buddy 目录过滤缺少规则:%s\n"+
|
||||
"(这四类都是参考用真实请求验证过的「列出来就会让用户报错」的条目,"+
|
||||
"删任何一条都会把不可调用模型放回选择器)", rule)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据二:`default-model` / `fast-model` 必须**不在**被过滤的形态里。
|
||||
// 参考注释明确警告:只过滤 auto/default 两个字面量,不做前缀匹配 ——
|
||||
// 前缀会误伤这两个国际版别名(它们被 craft agent 引用,是官方推荐入口)。
|
||||
if strings.Contains(s, `id:sub(1, 7) == "default"`) {
|
||||
t.Error("buddy 过滤把 default 做成了**前缀**匹配 ⇒ 会误伤 default-model(参考点名警告)")
|
||||
}
|
||||
t.Log("✓ buddy 目录过滤四类规则齐备,且未误伤 default-model 类别名")
|
||||
}
|
||||
@ -193,3 +193,43 @@ func accountIDs(t *testing.T, h *accountCleanupHarness, platform string) []strin
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★ 字符串 body(真实 HTTP 路径)+ 不传 platform 时,dry_run 必须被尊重。
|
||||
//
|
||||
// 反例(实测踩到,我自己写的):照其它 handler 写
|
||||
//
|
||||
// `if args.platform == nil then args = req end`
|
||||
//
|
||||
// —— 而"不传 platform"在本函数里是**合法用法**(清全部平台),于是 args 被换成
|
||||
// HandlerRequest,body 里解出来的 dry_run 就丢了 ⇒ 传 dry_run=false 却被当成
|
||||
// 未传,静默只预演。**破坏性操作里最糟的形态**:操作者以为执行了、实际没执行。
|
||||
//
|
||||
// 夹具必须用**字符串 body**(我第一版传 map,走不到这条路径,所以没咬住)。
|
||||
func TestAccountClearCredentiallessRespectsDryRunViaStringBody(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
|
||||
// 不带 platform + dry_run=false,且 body 是**字符串**(HTTP 真实形态)
|
||||
res, err := h.ps.InvokeHandler("channels", "account_clear_credentialless",
|
||||
&lua.HandlerRequest{Method: "POST", Body: `{"dry_run":false}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != 200 {
|
||||
t.Fatalf("status=%d body=%v", res.Status, res.Body)
|
||||
}
|
||||
j, _ := res.JSON.(map[string]interface{})
|
||||
if j["dry_run"] == true {
|
||||
t.Fatalf("字符串 body 里传了 dry_run=false 却被当成未传(回退吃掉 body): %v", j)
|
||||
}
|
||||
if j["deleted"] == nil {
|
||||
t.Fatalf("dry_run=false 时必须真的执行(应有 deleted 字段): %v", j)
|
||||
}
|
||||
// 全平台的 5 个空壳都被删(codearts 3 + trae 2)
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||||
t.Fatalf("codearts 应剩 1(有凭据那个),实得 %d", got)
|
||||
}
|
||||
if got := len(accountIDs(t, h, "trae")); got != 0 {
|
||||
t.Fatalf("trae 的 2 个空壳应被删,实得 %d", got)
|
||||
}
|
||||
t.Logf("✓ 字符串 body 且无 platform 时 dry_run=false 生效: deleted=%v", j["deleted"])
|
||||
}
|
||||
|
||||
76
internal/core/channels_endpoint_url_test.go
Normal file
76
internal/core/channels_endpoint_url_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 源的 chat URL = `base_url + endpoint`(内核 provider.go ChatURL)。
|
||||
//
|
||||
// 真实缺陷(生产实测):多个渠道平台的 `base_url` 里塞了**完整路径**,
|
||||
// 而适配器默认 endpoint 又是 "/chat/completions" ⇒ 实际请求变成
|
||||
//
|
||||
// https://copilot.tencent.com/v2/chat/completions/chat/completions
|
||||
//
|
||||
// ⇒ 上游回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞,不是模型错误)。
|
||||
//
|
||||
// 受影响平台 8 个:buddy / cline / lobsterai / opencode / qoder / loomy /
|
||||
// raccoon(zcode 当时已用 endpoint 覆盖,形态正确)。
|
||||
//
|
||||
// ★ 为什么这个缺陷能活很久:**模型名被多个源服务**。
|
||||
//
|
||||
// `deepseek-v4.1-flash` 同时由 4 个源提供(alittokenplan/vela/oin/channels-buddy…)
|
||||
// ⇒ 网关在 buddy 源失败后会**继续尝试下一个源**并成功 ⇒ 用户看到"能回话",
|
||||
// 于是"buddy 通道是好的"这个结论**完全没有证据**。
|
||||
// 而 `hy3` 只由 buddy 一个源提供 ⇒ 一调就暴露真因。
|
||||
// ⇒ 判据必须断言**源的 URL 形态**(结构不变量),不能靠"某个模型能调通"。
|
||||
//
|
||||
// 判据:base_url 不得自带任何 chat 路径段(它只应是 host[+协议公共前缀]),
|
||||
// 路径必须在 `endpoint` 里。
|
||||
func TestChannelSourceBaseURLHasNoChatPath(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 只在平台定义区里找 base_url(避免误判注释/文档里的示例)
|
||||
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
|
||||
// 已知适配器会把这些路径各追加一次;出现即说明 base_url 侵占了 endpoint 的地盘
|
||||
banned := []string{"/chat/completions", "/v1/messages", "/generateContent", "/api/chat"}
|
||||
|
||||
found := 0
|
||||
for _, m := range declRe.FindAllStringSubmatch(s, -1) {
|
||||
url := m[1]
|
||||
found++
|
||||
for _, b := range banned {
|
||||
if strings.Contains(url, b) {
|
||||
t.Errorf("base_url 侵占了 endpoint 的路径:%s\n"+
|
||||
" 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),"+
|
||||
"适配器默认 endpoint=\"/chat/completions\" ⇒ 实际会请求 %s%s\n"+
|
||||
" 修法:base_url 只留 host,把 `%s` 移到该平台的 `endpoint = \"...\"`",
|
||||
url, url, b, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Fatal("没扫到任何 base_url 声明(判据失效)")
|
||||
}
|
||||
t.Logf("✓ %d 个 base_url 均不含 chat 路径段", found)
|
||||
}
|
||||
|
||||
// ★ 反过来也要咬:说明该缺陷**确实已被修好**、而不是判据太松。
|
||||
// 用一段**故意的坏数据**验证判据的判别力(变异测试的静态等价物)。
|
||||
func TestChannelBaseURLJudgeBitesOnBadInput(t *testing.T) {
|
||||
bad := ` base_url = "https://copilot.tencent.com/v2/chat/completions",`
|
||||
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
|
||||
m := declRe.FindStringSubmatch(bad)
|
||||
if m == nil {
|
||||
t.Fatal("判据的正则匹配不到坏数据 ⇒ 判据无判别力(形同虚设)")
|
||||
}
|
||||
if !strings.Contains(m[1], "/chat/completions") {
|
||||
t.Fatal("坏数据未被识别 ⇒ 判据无判别力")
|
||||
}
|
||||
t.Log("✓ 判据对坏数据有判别力")
|
||||
}
|
||||
225
internal/core/channels_pool_reason_test.go
Normal file
225
internal/core/channels_pool_reason_test.go
Normal file
@ -0,0 +1,225 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 「为什么没有可用账号」必须区分**真的用尽**与**查不到**。
|
||||
//
|
||||
// 参考的同款修复(用户报障 2026-09-29):混成一句"额度都已用尽"会让
|
||||
// **一次网络抖动伪装成钱花完了** —— 用户于是去解锁永久积分、或白等到明天,
|
||||
// 而号里其实有钱。两者的**正确动作完全相反**:
|
||||
//
|
||||
// exhausted → 等额度重置
|
||||
// unknown → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
|
||||
//
|
||||
// 判据看的是**报出的 reason**(用户据此决定做什么),不是内部计数。
|
||||
func TestCreditsPoolReasonDistinguishesExhaustedFromUnknown(t *testing.T) {
|
||||
t.Run("账号都查到了余额但为 0 → exhausted", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
h.serveBalance(0, 0)
|
||||
got := h.reason()
|
||||
if got["reason"] != "exhausted" {
|
||||
t.Fatalf("余额确为 0 时应报 exhausted(用户该等重置),实得 %v:%v",
|
||||
got["reason"], got["detail"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("余额查询失败 → unknown(不能伪装成用尽)", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
h.serveBalanceError(500)
|
||||
got := h.reason()
|
||||
if got["reason"] != "unknown" {
|
||||
t.Fatalf("查询失败必须报 unknown —— 报 exhausted 会让网络抖动伪装成钱花完了"+
|
||||
"(用户会去解锁永久积分或白等),实得 %v:%v", got["reason"], got["detail"])
|
||||
}
|
||||
if d, _ := got["detail"].(string); !strings.Contains(d, "查询失败") {
|
||||
t.Fatalf("说明必须点明是查询失败,实得 %q", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("没有启用的账号 → no_accounts", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
got := h.reason()
|
||||
if got["reason"] != "no_accounts" {
|
||||
t.Fatalf("无账号时应报 no_accounts,实得 %v", got["reason"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("有可用账号 → usable", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
h.serveBalance(100, 0)
|
||||
got := h.reason()
|
||||
if got["reason"] != "usable" {
|
||||
t.Fatalf("有钱时应报 usable,实得 %v", got["reason"])
|
||||
}
|
||||
if u, _ := got["usable"].(float64); int(u) != 1 {
|
||||
t.Fatalf("usable 应为 1,实得 %v", got["usable"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("平台无余额端点 → unknown(不编造用尽)", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
// ⚠ 必须有**启用的账号**:判定的优先级是
|
||||
// no_accounts → 无余额端点 → usable → unknown/exhausted
|
||||
// 我第一版在这条用例里忘了加账号,于是命中 no_accounts(优先级更高,
|
||||
// 那是**正确**行为),把正确的实现判成了错。
|
||||
h.addAccountOn("minimax", "m1")
|
||||
// minimax 未声明 balance 端点
|
||||
got := h.reasonOn("minimax")
|
||||
if got["reason"] != "unknown" {
|
||||
t.Fatalf("无余额端点时应报 unknown(无法判定 ≠ 用尽),实得 %v:%v",
|
||||
got["reason"], got["detail"])
|
||||
}
|
||||
if d, _ := got["detail"].(string); !strings.Contains(d, "无法判定") {
|
||||
t.Fatalf("说明必须点明无法判定,实得 %q", d)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 锁定永久积分时,只剩永久积分的账号不可用,且必须报 **locked** 而不是 exhausted
|
||||
// (用户动作不同:解锁 vs 等重置)。
|
||||
func TestCreditsPoolReasonReportsLockedNotExhausted(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
// total_fn 取包列表:只有永久积分(到期未知)
|
||||
h.serveBalance(0, 50)
|
||||
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
|
||||
map[string]interface{}{"lock": true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := h.reason()
|
||||
if got["reason"] != "locked" {
|
||||
t.Fatalf("锁定且只剩永久积分时应报 locked(用户可解锁),实得 %v:%v",
|
||||
got["reason"], got["detail"])
|
||||
}
|
||||
// 解锁后同一份余额应为 usable(证明 locked 不是"用尽")
|
||||
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
|
||||
map[string]interface{}{"lock": false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.serveBalance(0, 50)
|
||||
if got2 := h.reason(); got2["reason"] != "usable" {
|
||||
t.Fatalf("解锁后同一份余额应可用(证明 locked ≠ exhausted),实得 %v", got2["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type poolReasonHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
body atomic.Value
|
||||
fail atomic.Bool
|
||||
hasAcct atomic.Bool
|
||||
}
|
||||
|
||||
func newPoolReasonHarness(t *testing.T) *poolReasonHarness {
|
||||
t.Helper()
|
||||
h := &poolReasonHarness{t: t}
|
||||
// buddy 的真实形态:包列表嵌在 data.Response.Data.Accounts
|
||||
h.body.Store(buddyPkgBody(0, 0))
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if h.fail.Load() {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, h.body.Load().(string))
|
||||
}))
|
||||
t.Cleanup(h.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.ps = ps
|
||||
// buddy 的余额端点指到假上游,并让 total_fn 走包列表分桶
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"balance": map[string]interface{}{"url": h.ts.URL},
|
||||
}); err != nil {
|
||||
t.Logf("set_endpoints(balance) 未支持: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *poolReasonHarness) addAccount(id string) {
|
||||
h.t.Helper()
|
||||
h.addAccountOn("buddy", id)
|
||||
}
|
||||
|
||||
// addAccountOn:往指定平台加账号(我第一版把它写死成 buddy,导致
|
||||
// "平台无余额端点"那条用例其实没有该平台的账号,命中了优先级更高的
|
||||
// no_accounts —— 判据写错而非实现错)。
|
||||
func (h *poolReasonHarness) addAccountOn(platform, id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": platform, "account_id": id,
|
||||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", platform, err)
|
||||
}
|
||||
h.hasAcct.Store(true)
|
||||
}
|
||||
|
||||
// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。
|
||||
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
|
||||
h.fail.Store(false)
|
||||
h.body.Store(buddyPkgBody(expiring, permanent))
|
||||
}
|
||||
|
||||
// buddyPkgBody 拼一份 buddy 真实形态的余额响应:
|
||||
// 包列表在 data.Response.Data.Accounts,每包有 Status / CycleCapacityRemain /
|
||||
// DeductionEndTime(毫秒)。到期时间落在窗口内 → 临时桶;未知(0)→ 永久桶。
|
||||
func buddyPkgBody(expiring, permanent int) string {
|
||||
pkgs := []string{}
|
||||
if expiring > 0 {
|
||||
// 3 天到期(< 15 天窗口)
|
||||
endMs := (time.Now().Unix() + 3*86400) * 1000
|
||||
pkgs = append(pkgs, fmt.Sprintf(
|
||||
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":%d}`, expiring, endMs))
|
||||
}
|
||||
if permanent > 0 {
|
||||
// DeductionEndTime=0(未知)⇒ 永久桶
|
||||
pkgs = append(pkgs, fmt.Sprintf(
|
||||
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":0}`, permanent))
|
||||
}
|
||||
return `{"data":{"Response":{"Data":{"Accounts":[` + strings.Join(pkgs, ",") + `]}}}}`
|
||||
}
|
||||
|
||||
func (h *poolReasonHarness) serveBalanceError(code int) {
|
||||
h.fail.Store(true)
|
||||
}
|
||||
|
||||
func (h *poolReasonHarness) reason() map[string]interface{} {
|
||||
h.t.Helper()
|
||||
return h.reasonOn("buddy")
|
||||
}
|
||||
|
||||
func (h *poolReasonHarness) reasonOn(platform string) map[string]interface{} {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "credits_pool_reason",
|
||||
map[string]interface{}{"platform": platform})
|
||||
if err != nil {
|
||||
h.t.Fatalf("credits_pool_reason: %v", err)
|
||||
}
|
||||
return jsonOf(h.t, res)
|
||||
}
|
||||
215
internal/core/channels_remove_source_perm_test.go
Normal file
215
internal/core/channels_remove_source_perm_test.go
Normal file
@ -0,0 +1,215 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 删除账号必须**真的把源撤掉** —— 且撤不掉时不能静默。
|
||||
//
|
||||
// 真实缺陷:内核的 `remove_source` 需要**单独授予**(Destructive 级,不在
|
||||
// add_source 的授权里)。生产权限集曾只有 add_source ⇒ `dropAccountSource`
|
||||
// 的调用被**拒绝**,而调用方**忽略返回值** ⇒ 静默失败的后果是
|
||||
// **账号已删,它的源仍在对外服务**(用户看到"删了却还在用")。
|
||||
//
|
||||
// 格外隐蔽的一点:`AddSource` 是**同名 upsert**,所以 `syncAccountSource` 里
|
||||
// "先 remove 再 add" 的 remove 失败**看不出来**(add 覆盖掉同名源)——
|
||||
// 只有**纯删除**路径(account_delete / 停用)才暴露。故判据必须走删除路径。
|
||||
func TestAccountDeleteRemovesItsSource(t *testing.T) {
|
||||
t.Run("授予 remove_source 时应撤掉源", func(t *testing.T) {
|
||||
h := newRemoveSourceHarness(t, true)
|
||||
h.addAccount("del-a")
|
||||
if got := h.sourceNames(); !hasName(got, "channels-buddy-del-a") {
|
||||
t.Fatalf("前置条件:账号源应已建立,实得 %v", got)
|
||||
}
|
||||
h.deleteAccount("del-a")
|
||||
if got := h.sourceNames(); hasName(got, "channels-buddy-del-a") {
|
||||
t.Fatalf("账号已删但源仍在 ⇒ 已删账号继续对外服务:%v", got)
|
||||
}
|
||||
t.Log("✓ 删除账号同时撤掉其源")
|
||||
})
|
||||
|
||||
t.Run("未授予 remove_source 时必须**可见地**失败(不能静默)", func(t *testing.T) {
|
||||
h := newRemoveSourceHarness(t, false)
|
||||
h.addAccount("del-b")
|
||||
h.deleteAccount("del-b")
|
||||
// 源确实撤不掉(权限不足)——这是环境的限制,不是我们要判的
|
||||
// 我们要判的是:**这件事必须被记录下来**,而不是消失在一个被丢弃的返回值里。
|
||||
logs := h.eventLog()
|
||||
joined := strings.Join(logs, " | ")
|
||||
if !strings.Contains(joined, "撤源失败") {
|
||||
t.Fatalf("撤源被拒却没有任何记录 ⇒ 权限缺失会一直静默(表现是"+
|
||||
"「删了账号却还在用」)\n事件日志: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "remove_source") {
|
||||
t.Fatalf("记录里应点明是哪个内核动作被拒(便于给运维可操作提示),实得: %s", joined)
|
||||
}
|
||||
// ★ 必须带上**真正的原因**(not granted),而不是"无返回"这类空话。
|
||||
// 内核桥失败时**第一个返回值是 nil、原因在第二个返回值里** ——
|
||||
// 只接第一个返回值就会把原因丢掉(我第一版正是如此)。
|
||||
if !strings.Contains(joined, "not granted") {
|
||||
t.Fatalf("记录里必须含内核给出的**真实原因**(not granted),"+
|
||||
"否则运维无从下手(只接 call 的第一个返回值就会丢掉原因);实得: %s", joined)
|
||||
}
|
||||
t.Logf("✓ 撤源失败已可见: %s", joined)
|
||||
})
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type removeSourceHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newRemoveSourceHarness(t *testing.T, grantRemove bool) *removeSourceHarness {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
// ★ 照生产现状:add_source 有、remove_source 视参数而定
|
||||
// 注意:status 是插件自己的导出,与内核权限无关(之前误当权限项)
|
||||
actions := []string{"add_source", "list_sources"}
|
||||
if grantRemove {
|
||||
actions = append(actions, "remove_source")
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", actions, false)
|
||||
return &removeSourceHarness{t: t, ps: ps}
|
||||
}
|
||||
|
||||
func (h *removeSourceHarness) addAccount(id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *removeSourceHarness) deleteAccount(id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_delete", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_delete(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *removeSourceHarness) sourceNames() []string {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(h.t, res)
|
||||
var out []string
|
||||
arr, _ := j["sources"].([]interface{})
|
||||
for _, e := range arr {
|
||||
if m, ok := e.(map[string]interface{}); ok {
|
||||
if n, ok := m["name"].(string); ok {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// eventLog 取插件事件日志(logEvent 写进 plugin.state.log)。
|
||||
// ⚠ 走探针:`status` 是 HTTP 路由处理器,**不在** plugin.exports 里,
|
||||
// 直接 Call 它会报 `does not export "status"`(我第一版就这么错)。
|
||||
func (h *removeSourceHarness) eventLog() []string {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__event_log_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(h.t, res)
|
||||
var out []string
|
||||
arr, _ := j["log"].([]interface{})
|
||||
for _, e := range arr {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasName(xs []string, want string) bool {
|
||||
for _, x := range xs {
|
||||
if x == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ★ 插件**声明**的内核动作必须覆盖它**实际调用**的全部动作。
|
||||
//
|
||||
// 真实缺陷:`plugin.kernel` 只声明了 5 个动作,而插件在 5 处调用 `remove_source`
|
||||
// —— 声明与使用**漂移**。生产只授了声明过的那些 ⇒ 撤源全部被拒,且因为
|
||||
// `AddSource` 是**同名 upsert**(add 会覆盖,看不出 remove 失败),
|
||||
// 只有**纯删除路径**才暴露:**账号删了,它的源还在对外服务**。
|
||||
//
|
||||
// 判据用**静态扫描**(源码里出现的 call("X"))对比声明清单 —— 这样将来任何人
|
||||
// 加了新的内核调用却忘了声明,都会在这里报红,而不是等线上静默失效。
|
||||
func TestChannelsDeclaresEveryKernelActionItCalls(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 抽出声明清单
|
||||
declStart := strings.Index(s, "plugin.kernel = {")
|
||||
if declStart < 0 {
|
||||
t.Fatal("找不到 plugin.kernel 声明(判据需更新)")
|
||||
}
|
||||
declEnd := strings.Index(s[declStart:], "}")
|
||||
if declEnd < 0 {
|
||||
t.Fatal("plugin.kernel 声明没有收尾大括号")
|
||||
}
|
||||
declBlob := s[declStart : declStart+declEnd]
|
||||
declared := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`"([a-z_]+)"`).FindAllStringSubmatch(declBlob, -1) {
|
||||
declared[m[1]] = true
|
||||
}
|
||||
if len(declared) == 0 {
|
||||
t.Fatal("声明清单解析为空(判据失效)")
|
||||
}
|
||||
|
||||
// ② 抽出实际调用的动作
|
||||
called := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`__llmsproxy_kernel\.call\(\s*"([a-z_]+)"`).FindAllStringSubmatch(s, -1) {
|
||||
called[m[1]] = true
|
||||
}
|
||||
if len(called) == 0 {
|
||||
t.Fatal("没扫描到任何内核调用(判据失效)")
|
||||
}
|
||||
|
||||
// ③ 逐个比对:调用而未声明 = 漏声明(运维照声明授权 ⇒ 该调用必被拒)
|
||||
var missing []string
|
||||
for a := range called {
|
||||
if !declared[a] {
|
||||
missing = append(missing, a)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Fatalf("插件调用了未声明的内核动作 %v\n"+
|
||||
"(运维按 declared 授予 ⇒ 这些调用会被拒;remove_source 漏声明时"+
|
||||
"的表现是「账号删了、源还在对外服务」,且因 AddSource 是 upsert 而静默)",
|
||||
missing)
|
||||
}
|
||||
t.Logf("✓ 声明覆盖全部调用:declared=%d called=%d", len(declared), len(called))
|
||||
}
|
||||
251
internal/core/channels_reorder_effect_test.go
Normal file
251
internal/core/channels_reorder_effect_test.go
Normal file
@ -0,0 +1,251 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ account_reorder 必须**真的改变内核里的源顺序**。
|
||||
//
|
||||
// 链路:poolList 按 priority 降序 → syncAccountSource 的调用顺序 → 内核里的
|
||||
// **源注册顺序** → 内核按 candidates 顺序依次尝试。
|
||||
// 而一个源一旦注册,顺序就固定了 —— 改 pool 记录不影响它。故重排后必须按新
|
||||
// 顺序 remove+add 一遍。
|
||||
//
|
||||
// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁),
|
||||
// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。
|
||||
func TestAccountReorderActuallyReordersKernelSources(t *testing.T) {
|
||||
h := newReorderHarness(t)
|
||||
h.addAccount("acct-a")
|
||||
h.addAccount("acct-b")
|
||||
|
||||
// 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。
|
||||
// ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**,
|
||||
// 否则"重排生效"与"本来就是这个顺序"无法区分 ——
|
||||
// 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a],
|
||||
// 去掉重注册的变异**照样通过**(判据没咬住)。
|
||||
before := h.sourceOrder()
|
||||
if len(before) < 2 {
|
||||
t.Fatalf("应有 2 个账号源,实得 %v", before)
|
||||
}
|
||||
if before[0] != "channels-buddy-acct-a" {
|
||||
t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+
|
||||
"若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before)
|
||||
}
|
||||
|
||||
// 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)——
|
||||
// 目标顺序与初始相反,故只有真的重注册才能达成。
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-b", "acct-a"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := h.sourceOrder()
|
||||
if len(after) < 2 {
|
||||
t.Fatalf("重排后源丢了:%v", after)
|
||||
}
|
||||
|
||||
idxOf := func(list []string, id string) int {
|
||||
for i, n := range list {
|
||||
if n == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
b, a := idxOf(after, "channels-buddy-acct-b"), idxOf(after, "channels-buddy-acct-a")
|
||||
if b < 0 || a < 0 {
|
||||
t.Fatalf("重排后找不到账号源:%v", after)
|
||||
}
|
||||
if b > a {
|
||||
t.Fatalf("重排未生效:acct-b 应排在 acct-a **之前**(内核先试它),"+
|
||||
"实际顺序 %v\n(只改 pool.priority 不改源注册顺序的话,路由不会变 —— "+
|
||||
"快到期积分仍不会优先消耗)", after)
|
||||
}
|
||||
t.Logf("✓ 源顺序已按重排生效: %v", after)
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type reorderHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newReorderHarness(t *testing.T) *reorderHarness {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
// ★ 必须授予 add_source:不给的话 syncAccountSource 里内核会明确拒绝
|
||||
// ("kernel action add_source is not granted"),源一个都不会出现 ——
|
||||
// 我第一版漏了它,于是"应有 2 个源,实得 []"。
|
||||
_ = ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||
return &reorderHarness{t: t, ps: ps}
|
||||
}
|
||||
|
||||
func (h *reorderHarness) addAccount(id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// sourceOrder 返回内核里 channels-buddy-* 源的顺序(即实际尝试顺序)。
|
||||
//
|
||||
// ⚠ **必须只读**:我第一版在这里顺带调了 __sync_source_probe(它内部是
|
||||
// remove+add)⇒ 改变了被测状态,"测之前先动了被测对象",判据因此不可信
|
||||
// (实测表现:顺序忽而两个忽而一个)。
|
||||
func (h *reorderHarness) sourceOrder() []string {
|
||||
h.t.Helper()
|
||||
ls, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__list_sources_probe: %v", err)
|
||||
}
|
||||
j := jsonOf(h.t, ls)
|
||||
var out []string
|
||||
arr, _ := j["sources"].([]interface{})
|
||||
for _, e := range arr {
|
||||
m, ok := e.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n, _ := m["name"].(string)
|
||||
if len(n) > 15 && n[:15] == "channels-buddy-" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★ poolList 的顺序必须**确定**(同秒创建的账号也不例外)。
|
||||
//
|
||||
// 真实缺陷:排序键是 `createdAt`(**秒级**),同秒建的两个账号比较相等,
|
||||
// 而相等时 table.sort 的结果取决于 pairs() 遍历顺序 —— **不确定**。
|
||||
// 实测:按 a,b,c 顺序建三个账号,poolList 回的是 [a,c,b]。
|
||||
//
|
||||
// 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = **内核尝试顺序**,
|
||||
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
|
||||
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
|
||||
func TestPoolListOrderIsDeterministic(t *testing.T) {
|
||||
// ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。
|
||||
// `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到
|
||||
// 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序,
|
||||
// 与"排序是否确定"无关(去掉兜底键的变异照样通过)。
|
||||
//
|
||||
// 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后,
|
||||
// 顺序必须由**稳定键**(id)决定,即字典序;否则"内核先试哪个账号"随机。
|
||||
h := newReorderHarness(t)
|
||||
for _, id := range []string{"acct-c", "acct-b", "acct-a"} {
|
||||
h.addAccount(id)
|
||||
}
|
||||
// 触发批量重注册:按 id 字典序显式指定 ordered_ids(priority 依次递减)
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-c", "acct-b", "acct-a"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// priority 已按给定顺序区分开 ⇒ 这里先确认**显式顺序生效**
|
||||
order := h.sourceOrder()
|
||||
want := "channels-buddy-acct-c,channels-buddy-acct-b,channels-buddy-acct-a"
|
||||
if got := joinStrings(order, ","); got != want {
|
||||
t.Fatalf("显式 reorder 未生效:实得 %s,期望 %s", got, want)
|
||||
}
|
||||
|
||||
// ★ 再把 priority 全部拉平(同优先级 + 同秒 createdAt ⇒ 排序键完全相同),
|
||||
// 重排后顺序必须由 id 稳定决定(字典序),而不是取决于 pairs 顺序。
|
||||
h.clearPriorities()
|
||||
if !sameSecond(h.createdAts()) {
|
||||
t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts())
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-a", "acct-b", "acct-c"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.clearPriorities()
|
||||
h.resync("buddy")
|
||||
order2 := h.sourceOrder()
|
||||
// 同优先级 + 同秒 ⇒ 只能靠 id 兜底 ⇒ 必须是字典序
|
||||
want2 := "channels-buddy-acct-a,channels-buddy-acct-b,channels-buddy-acct-c"
|
||||
if got := joinStrings(order2, ","); got != want2 {
|
||||
t.Fatalf("同优先级同秒创建时,顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
|
||||
"(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)",
|
||||
got, want2)
|
||||
}
|
||||
t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2)
|
||||
}
|
||||
|
||||
func joinStrings(xs []string, sep string) string {
|
||||
out := ""
|
||||
for i, x := range xs {
|
||||
if i > 0 {
|
||||
out += sep
|
||||
}
|
||||
out += x
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// createdAts 取当前 pool 里 buddy 账号的 createdAt。
|
||||
func (h *reorderHarness) createdAts() []int64 {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
var out []int64
|
||||
for _, a := range accountsOf(h.t, res) {
|
||||
if v, ok := a["createdAt"].(float64); ok {
|
||||
out = append(out, int64(v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameSecond 判断是否所有时间戳都落在同一秒(即排序键相等)。
|
||||
func sameSecond(ts []int64) bool {
|
||||
if len(ts) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, v := range ts {
|
||||
if v != ts[0] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// clearPriorities 把所有账号的 priority 拉平(构造"排序键完全相同"的场景)。
|
||||
func (h *reorderHarness) clearPriorities() {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "__clear_priorities_probe", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
}); err != nil {
|
||||
h.t.Fatalf("__clear_priorities_probe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resync 触发一次批量重注册(走 poolList 顺序 → 源注册顺序)。
|
||||
func (h *reorderHarness) resync(platform string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "__resync_sources_probe", map[string]interface{}{
|
||||
"platform": platform,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("__resync_sources_probe: %v", err)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@ -27,8 +27,12 @@ func TestUpstreamGapsAreDocumented(t *testing.T) {
|
||||
s := string(src)
|
||||
|
||||
t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) {
|
||||
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH
|
||||
if !strings.Contains(s, "https://api2-v2.qoder.sh/model/v1/chat/completions") {
|
||||
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH。
|
||||
// ⚠ 断言 **URL 的两半**而不是那一整串字面量:本仓已把"路径从 base_url
|
||||
// 移到 endpoint"(内核 URL = base_url + endpoint)—— 继续钉死整串会让
|
||||
// 一个纯粹的形态重构把这条**语义**判据弄红,那是判据在咬自己。
|
||||
if !strings.Contains(s, `base_url = "https://api2-v2.qoder.sh"`) ||
|
||||
!strings.Contains(s, `endpoint = "/model/v1/chat/completions"`) {
|
||||
t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," +
|
||||
"则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," +
|
||||
"否则响应会被当成畸形数据")
|
||||
|
||||
@ -48,11 +48,23 @@ local plugin = {
|
||||
}
|
||||
|
||||
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
|
||||
plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" }
|
||||
-- 声明本插件要用的内核动作(运维据此授予;declared 与 granted 成对展示)。
|
||||
--
|
||||
-- ★ 这份清单必须与实际调用**逐一对齐**:`remove_source` 曾被漏声明,
|
||||
-- 而插件在 5 处调用它(syncAccountSource 的重建、dropAccountSource、
|
||||
-- account_delete 收尾、两处清旧单账号源)⇒ 生产只授了 add_source,
|
||||
-- 于是**撤源全部被拒**。更坏的是它**静默**:AddSource 是**同名 upsert**,
|
||||
-- 所以"先 remove 再 add"的 remove 失败看不出来,只有**纯删除路径**
|
||||
-- (删账号/停用)才暴露 —— 表现是「账号删了,它的源还在对外服务」。
|
||||
-- ⇒ 加动作时**同时**改这里;判据见 channels_kernel_declaration_test.go。
|
||||
plugin.kernel = {
|
||||
"add_source", "remove_source", "list_sources",
|
||||
"probe_all", "reset_health", "status",
|
||||
}
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "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", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "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_pool_reason", "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", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
|
||||
|
||||
|
||||
@ -128,6 +140,41 @@ local PLATFORMS = {
|
||||
},
|
||||
},
|
||||
hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" },
|
||||
-- ★ 远端模型目录:POST /api/ide/v1/batch_get_detail_param(通过 models_remote)。
|
||||
--
|
||||
-- 为什么要有它:**只有这个端点能告诉我们每个模型的真实元数据** ——
|
||||
-- 上下文窗口、输出上限、是否支持图像/工具响应图像、以及**思考档位**。
|
||||
-- 硬编码的 models 兜底表只有名字,于是 UI 上所有模型都显示"未提供推理等级",
|
||||
-- 且多模态能力无从得知。
|
||||
--
|
||||
-- ⚠ 端点选择:**必须用 batch**(多通道)而不是单通道 get_detail_param。
|
||||
-- 真实 CN IDE 用 batch 一次查 22 个 function,每个 function 各自一套目录;
|
||||
-- 只传聊天通道会让非聊天通道的条目在响应里**位置错乱甚至被跳过**(参考实测)。
|
||||
--
|
||||
-- ⚠ 请求体必须传**全部 22 个 function**(与真实 IDE 一致),不是只传我们关心的
|
||||
-- 那几个 —— 理由同上:响应分组与请求顺序相关。解析侧再用白名单做**准入**。
|
||||
models_remote = {
|
||||
endpoints = { {
|
||||
url = "https://trae-api-cn.mchost.guru/api/ide/v1/batch_get_detail_param",
|
||||
method = "POST",
|
||||
body = '{"functions":["ui_builder_v2","solo_coder","chat_v3","solo_builder",'
|
||||
.. '"builder_v3","builder","chat","inline_chat","git_ai",'
|
||||
.. '"custom_agent_generation","utils","code_reviewer","code_review_summary",'
|
||||
.. '"solo_agent","solo_agent_remote","solo_work_remote","solo_agent_lite",'
|
||||
.. '"solo_work_lite","solo_design_lite","solo_design_remote","multimodal",'
|
||||
.. '"system_diagnosis"],"agent_type":"",'
|
||||
.. '"current_config_info":{"config_name":"","is_custom_model":false},'
|
||||
.. '"mode_type":0,"access_type":0,"ab_force_vids":"",'
|
||||
.. '"ab_autotest_advanced_mode":0,"show_custom_model":true}',
|
||||
-- ★ 鉴权方案是 Cloud-IDE-JWT + 约 20 个客户端头(含按 user_id 派生的
|
||||
-- 设备身份),**不能只发 Bearer** —— 参考注释点明简化头实测失败。
|
||||
-- 走 sign="trae_solo"(复用平台既有的 traeCheckinHeaders 实现,
|
||||
-- 与 codearts 的 ep.sign 同一模式,不另写一套头部构造)。
|
||||
sign = "trae_solo",
|
||||
} },
|
||||
auth = "none", -- 鉴权头由 headers_fn 负责
|
||||
parse = "trae_batch",
|
||||
},
|
||||
-- trae 非 OpenAI 兼容:端点是 SOLO 的 /api/agent/v3/llm_utils_chat,
|
||||
-- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。
|
||||
base_url = "https://trae-api-cn.mchost.guru",
|
||||
@ -297,7 +344,13 @@ local PLATFORMS = {
|
||||
},
|
||||
hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" },
|
||||
-- cline 的 chat 路径是 /api/v1/chat/completions(参考 CLINE_CHAT_PATH)。
|
||||
base_url = "https://api.cline.bot/api/v1/chat/completions",
|
||||
base_url = "https://api.cline.bot",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
|
||||
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
|
||||
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
|
||||
-- 完整路径,实际请求就变成 .../api/v1/chat/completions/chat/completions ⇒ 上游
|
||||
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
endpoint = "/api/v1/chat/completions",
|
||||
-- 远端模型目录(照参考 cline-models.ts:**必须同时打两个端点**)。
|
||||
-- 关键:/api/v1/models 的 460 个 id 里**没有** cline-free/*,免费模型只由
|
||||
-- recommended-models 下发;只打前者会看不到任何免费模型。
|
||||
@ -367,7 +420,13 @@ local PLATFORMS = {
|
||||
},
|
||||
hosts = { "copilot.tencent.com", "www.codebuddy.cn" },
|
||||
-- buddy 的 chat 端点含 /v2 前缀(参考 CHAT_API_BASE = .../v2 + /chat/completions)。
|
||||
base_url = "https://copilot.tencent.com/v2/chat/completions",
|
||||
base_url = "https://copilot.tencent.com",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
|
||||
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
|
||||
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
|
||||
-- 完整路径,实际请求就变成 .../v2/chat/completions/chat/completions ⇒ 上游
|
||||
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
endpoint = "/v2/chat/completions",
|
||||
-- 远端模型目录(照参考 buddy.ts CONFIG_PATH + buddy-oauth.ts fetchModels)。
|
||||
-- 关键:必须带 product 身份头,否则 WorkBuddy 会以 CodeBuddy 身份请求。
|
||||
models_remote = {
|
||||
@ -641,7 +700,13 @@ local PLATFORMS = {
|
||||
fallback = "2026.9.4",
|
||||
},
|
||||
-- lobsterai 的 chat 路径是 /api/proxy/v1/chat/completions(参考 LOBSTERAI_CHAT_PATH)。
|
||||
base_url = "https://lobsterai-server.youdao.com/api/proxy/v1/chat/completions",
|
||||
base_url = "https://lobsterai-server.youdao.com",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
|
||||
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
|
||||
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
|
||||
-- 完整路径,实际请求就变成 .../api/proxy/v1/chat/completions/chat/completions ⇒ 上游
|
||||
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
endpoint = "/api/proxy/v1/chat/completions",
|
||||
-- 远端模型目录(照参考 lobsterai.ts LOBSTERAI_MODELS_PATH)
|
||||
models_remote = {
|
||||
-- 参考真值 LOBSTERAI_API_BASE(lobsterai-server.youdao.com)。
|
||||
@ -680,7 +745,17 @@ local PLATFORMS = {
|
||||
vendor = "opencode",
|
||||
flow = "paste",
|
||||
-- opencode 的 chat 路径是 /v1/chat/completions(参考 chatPath)。
|
||||
base_url = "https://opencode.ai/zen/v1/chat/completions",
|
||||
-- ★ base_url 必须保留 `/zen` 段:参考 opencode-product.ts:56 的
|
||||
-- `baseUrl: 'https://opencode.ai/zen'` + chatPath `/v1/chat/completions`。
|
||||
-- 实测(无凭据):
|
||||
-- https://opencode.ai/zen/v1/chat/completions → 500(路由存在,鉴权前先炸)
|
||||
-- https://opencode.ai/v1/chat/completions → 404 + HTML(网页)
|
||||
base_url = "https://opencode.ai/zen",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是
|
||||
-- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是
|
||||
-- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成
|
||||
-- .../v1/chat/completions/chat/completions ⇒ 404 Route Not Found。
|
||||
endpoint = "/v1/chat/completions",
|
||||
-- 远端模型目录(照参考 opencode-product.ts modelsPath)
|
||||
models_remote = {
|
||||
endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } },
|
||||
@ -806,7 +881,13 @@ local PLATFORMS = {
|
||||
interval = 2,
|
||||
},
|
||||
-- qoder 的 chat 路径是 /model/v1/chat/completions(参考 QODER_CHAT_PATH)。
|
||||
base_url = "https://api2-v2.qoder.sh/model/v1/chat/completions",
|
||||
base_url = "https://api2-v2.qoder.sh",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
|
||||
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
|
||||
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
|
||||
-- 完整路径,实际请求就变成 .../model/v1/chat/completions/chat/completions ⇒ 上游
|
||||
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
endpoint = "/model/v1/chat/completions",
|
||||
-- Qoder 的错误信封是**双层编码**的,且 10605(排队) 与 110(额度耗尽)
|
||||
-- 语义相反 —— 通用 openai 适配器只能看到外层,产出用户看不懂的原因串。
|
||||
-- 故用本插件自装的 channels-qoder(厂商码留在插件域,内核零厂商名)。
|
||||
@ -960,9 +1041,24 @@ local PLATFORMS = {
|
||||
access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r",
|
||||
app_id = "GM3LOOMY",
|
||||
},
|
||||
-- loomy 的 chat 路径是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494
|
||||
-- 实测标准 OpenAI 兼容)。
|
||||
base_url = "https://loomy.xunfei.cn/chat/completions",
|
||||
-- loomy 的 chat 端是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494)。
|
||||
--
|
||||
-- ★ host 是 **loomyad.xunfei.cn/api/v1**(参考 LOOMY_API_BASE),不是
|
||||
-- `loomy.xunfei.cn` —— 后者是 **Web 前端/OAuth 回调**域(本块的
|
||||
-- wechat.redirect_uri 正用它)。实测:
|
||||
-- POST https://loomy.xunfei.cn/chat/completions
|
||||
-- → 200 + `<!DOCTYPE html>`(网页!根本不是 API)
|
||||
-- POST https://loomyad.xunfei.cn/api/v1/chat/completions
|
||||
-- → 200 + `{"code":"100002","desc":"缺少 token"}`(正常 JSON)
|
||||
-- 写成前端域时,无论路径怎么拼都不可能是 API。
|
||||
--
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是
|
||||
-- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是
|
||||
-- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成
|
||||
-- .../chat/completions/chat/completions ⇒ 上游回
|
||||
-- `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
base_url = "https://loomyad.xunfei.cn/api/v1",
|
||||
endpoint = "/chat/completions",
|
||||
adapter = "openai",
|
||||
-- 兜底目录(照参考 LOOMY_FALLBACK_MODELS)。此前 "loomy-default" 是我编的。
|
||||
models = {
|
||||
@ -997,7 +1093,13 @@ local PLATFORMS = {
|
||||
},
|
||||
-- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions)
|
||||
-- raccoon 的 chat 端点是 /api/web/llm/v2/chat/completions。
|
||||
base_url = "https://xiaohuanxiong.com/api/web/llm/v2/chat/completions",
|
||||
base_url = "https://xiaohuanxiong.com",
|
||||
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
|
||||
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
|
||||
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
|
||||
-- 完整路径,实际请求就变成 .../api/web/llm/v2/chat/completions/chat/completions ⇒ 上游
|
||||
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
|
||||
endpoint = "/api/web/llm/v2/chat/completions",
|
||||
adapter = "openai",
|
||||
-- 兜底目录(照参考 RACCOON_FALLBACK_MODELS)。此前 "raccoon-default" 是我编的。
|
||||
-- 注意 sn- 前缀是本平台的真实命名约定(sensenova 系列)。
|
||||
@ -2706,7 +2808,15 @@ local function poolList(provider)
|
||||
table.sort(out, function(x, y)
|
||||
local px, py = x.priority or 0, y.priority or 0
|
||||
if px ~= py then return px > py end -- priority 大者优先(参考 reorder 语义)
|
||||
return (x.createdAt or 0) < (y.createdAt or 0)
|
||||
local cx, cy = x.createdAt or 0, y.createdAt or 0
|
||||
if cx ~= cy then return cx < cy end
|
||||
-- ★ 必须给**确定性**兜底:createdAt 是**秒级**,同一秒建的两个账号比较相等,
|
||||
-- 而相等时 table.sort 的结果取决于 pairs() 的遍历顺序 —— **不确定**
|
||||
-- (实测:按 a,b,c 顺序建的三个账号,poolList 回的是 [a,c,b])。
|
||||
-- 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = 内核尝试顺序,
|
||||
-- 故同秒创建的账号里"先试哪个"是随机的,且可能每次进程重启都变。
|
||||
-- 用 id 作最后的稳定键(id 唯一且不变),让顺序完全确定。
|
||||
return tostring(x.id or "") < tostring(y.id or "")
|
||||
end)
|
||||
return out
|
||||
end
|
||||
@ -3106,17 +3216,80 @@ local function parseRemoteModels(kind, body, provider)
|
||||
end
|
||||
end
|
||||
|
||||
elseif kind == "buddy_config" then
|
||||
-- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。
|
||||
-- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。
|
||||
local d = rec.data or rec
|
||||
local arr = d.models or d.agents or rec.models
|
||||
if type(arr) == "table" then
|
||||
for _, m in ipairs(arr) do
|
||||
local id = type(m) == "table" and (m.id or m.modelId or m.name)
|
||||
if type(id) == "string" and id ~= "" then out[#out+1] = id end
|
||||
elseif kind == "buddy_config" then
|
||||
-- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。
|
||||
-- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。
|
||||
--
|
||||
-- ★★ 必须过滤「不可调用」的条目 —— 远端 data.models 里混着三类**不是对话
|
||||
-- 模型**的东西,列进模型选择器只会让用户选中后报错(实测生产:glm-5.1
|
||||
-- 与 glm-5.2 之外,codewise-* / nes-gf / hunyuan-image-alpha 同样调不通)。
|
||||
-- 判据与命名逐条照参考 buddy.ts 的 isChatModel / isAutoSelectAlias:
|
||||
-- ① 自动选择类内部别名:`auto` / `default`
|
||||
-- ⚠️ 只按**这两个字面量**过滤,不做前缀匹配 —— 前缀会误伤
|
||||
-- `default-model` / `fast-model`(国际版抽象别名,但被 craft agent
|
||||
-- 引用,是官方推荐入口)。
|
||||
-- ② 补全/NES 专用:前缀 `nes-` / `completion-` / `codewise-`,
|
||||
-- 或带 `supportsExtra` 标记。参考实测 codewise-default-model-v2
|
||||
-- 返回 `code 11102 model service info not found`(后端未开放)。
|
||||
-- ③ 输出上限过小(>0 且 ≤256):补全用途,对话模型普遍 ≥24000。
|
||||
-- ④ 带 `text-to-image` 标签的生成式模型(如 hunyuan-image-alpha)。
|
||||
-- ⚠️ ③④ 需要元数据,故先把 models 建成 id→meta 映射。
|
||||
local d = rec.data or rec
|
||||
local arr = d.models or d.agents or rec.models
|
||||
local metaById = {}
|
||||
if type(arr) == "table" then
|
||||
for _, m in ipairs(arr) do
|
||||
if type(m) == "table" and type(m.id) == "string" and m.id ~= "" then
|
||||
metaById[m.id] = m
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 被 agent 引用的模型(craft/ask/plan/cli)。参考据此给 `agentReferenced`
|
||||
-- 打标:`hy4-preview-f` 只由本端点下发且被 agent 引用,白名单重建会丢掉它。
|
||||
local agentRef = {}
|
||||
if type(d.agents) == "table" then
|
||||
for _, ag in ipairs(d.agents) do
|
||||
if type(ag) == "table" and type(ag.models) == "table" then
|
||||
for _, mid in ipairs(ag.models) do
|
||||
if type(mid) == "string" then agentRef[mid] = true end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local function buddyIsChatModel(id)
|
||||
if id == "auto" or id == "default" then return false end
|
||||
if id:sub(1, 4) == "nes-" or id:sub(1, 11) == "completion-"
|
||||
or id:sub(1, 9) == "codewise-" then return false end
|
||||
local m = metaById[id]
|
||||
if type(m) == "table" then
|
||||
if m.supportsExtra == true then return false end
|
||||
local mo = tonumber(m.maxOutputTokens)
|
||||
if mo and mo > 0 and mo <= 256 then return false end
|
||||
if type(m.tags) == "table" then
|
||||
for _, t in ipairs(m.tags) do
|
||||
if t == "text-to-image" then return false end
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
local seen = {}
|
||||
local function pushId(id)
|
||||
if type(id) ~= "string" or id == "" then return end
|
||||
if seen[id] or not buddyIsChatModel(id) then return end
|
||||
seen[id] = true
|
||||
out[#out + 1] = id
|
||||
end
|
||||
-- models 与 agents 两处都收(agents 里是被引用的清单,可能含远端
|
||||
-- data.models 没有的新变体)。models 优先,保持顺序权威。
|
||||
if type(arr) == "table" then
|
||||
for _, m in ipairs(arr) do
|
||||
local id = type(m) == "table" and (m.id or m.modelId or m.name) or nil
|
||||
if type(id) == "string" then pushId(id) end
|
||||
end
|
||||
end
|
||||
for id in pairs(agentRef) do pushId(id) end
|
||||
|
||||
|
||||
elseif kind == "codearts_builtin" then
|
||||
-- 照参考 fetchCodeArtsRemoteModels 的第 2 个端点:字段是 builtinModels
|
||||
@ -3139,6 +3312,219 @@ local function parseRemoteModels(kind, body, provider)
|
||||
end
|
||||
end
|
||||
|
||||
elseif kind == "trae_batch" then
|
||||
-- ★ TRAE 的 batch_get_detail_param(多通道模型目录)。
|
||||
--
|
||||
-- 参考实现(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 拒绝。
|
||||
-- ⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志会随服务端下发变化
|
||||
-- (某次快照里 5 个条目全部失效、全目录 true 的条目数为 0)。
|
||||
--
|
||||
-- 另有硬性过滤:usage 必须 chat_completion、config_switch 不得 false、
|
||||
-- is_invisible_to_user 不得 true。
|
||||
local TRAE_CHANNELS = {
|
||||
"solo_agent", "solo_work_lite", "solo_agent_remote", "solo_work_remote",
|
||||
"solo_agent_lite",
|
||||
"chat_v3", "builder_v3", "solo_coder", "solo_design_lite",
|
||||
"solo_design_remote", "git_ai", "code_reviewer", "code_review_summary",
|
||||
"multimodal", "system_diagnosis",
|
||||
}
|
||||
local rankOf = {}
|
||||
for i, c in ipairs(TRAE_CHANNELS) do rankOf[c] = i end
|
||||
|
||||
-- 取布尔字段(两种大小写)
|
||||
local function rbool(t, a, b)
|
||||
local v = t[a]
|
||||
if v == nil and b then v = t[b] end
|
||||
return v
|
||||
end
|
||||
local function rstr(t, a, b)
|
||||
local v = t[a]
|
||||
if v == nil and b then v = t[b] end
|
||||
return type(v) == "string" and v or nil
|
||||
end
|
||||
local function rnum(t, a, b)
|
||||
local v = t[a]
|
||||
if v == nil and b then v = t[b] end
|
||||
return tonumber(v)
|
||||
end
|
||||
|
||||
-- declaresEffort(entry):该条目**能否真正声明出**思考档位。
|
||||
-- ⚠ 必须与适配器的档位判定完全一致(配置存在 + 未显式 support_thinking:false
|
||||
-- + options 非空)。只判"配置存在"不够:{support_thinking:false, options:[]}
|
||||
-- 与 {...options:['high']} 都"存在配置",但前者在 UI 上仍显示"未提供推理等级"
|
||||
-- —— 按"存在即优先"合并等于没修。
|
||||
local function declaresEffort(e)
|
||||
-- ⚠ 字段名必须与 parseEntry 存的一致(e.reasoning)。写成 e.reasoning_effort_config
|
||||
-- 会让整个判定**恒为 false** ⇒ 规则①永不生效 ⇒ 空档位照样覆盖有档位
|
||||
-- (正是该规则要防的缺陷;判据当场抓到)。
|
||||
local rc = e.reasoning
|
||||
if type(rc) ~= "table" then return false end
|
||||
local st = rbool(rc, "support_thinking", "supportThinking")
|
||||
if st == false then return false end
|
||||
local opts = rc.options or rc.Options
|
||||
return type(opts) == "table" and #opts > 0
|
||||
end
|
||||
|
||||
-- parseEntry(it, channel):把一条 config_info_list 条目解成我们关心的字段。
|
||||
local function parseEntry(it, chan)
|
||||
local id = rstr(it, "config_name", "ConfigName")
|
||||
if not id or id == "" then return nil end
|
||||
local disp = it.display_config or it.DisplayConfig
|
||||
if type(disp) ~= "table" then disp = {} end
|
||||
local e = {
|
||||
id = id,
|
||||
name = rstr(disp, "display_name", "displayName") or id,
|
||||
chan = chan,
|
||||
usage = rstr(it, "usage", "Usage"),
|
||||
enabled = rbool(it, "config_switch", "ConfigSwitch"),
|
||||
hidden = rbool(it, "is_invisible_to_user", "IsInvisibleToUser"),
|
||||
custom = rbool(disp, "is_custom_model", "IsCustomModel"),
|
||||
reasoning = it.reasoning_effort_config or it.reasoningEffortConfig,
|
||||
}
|
||||
-- ⚠ contextWindow 取 `dev` 而非 `max`:真实条目形如 {dev:200000, max:1000000},
|
||||
-- 而 max 需开启 display_config.max_mode 才可用(我们不实现该开关)。
|
||||
-- 采信 max 会让上层以为有 1M 窗口、实际请求被上游拒。
|
||||
local cw = it.context_window_tokens or it.contextWindowTokens
|
||||
if type(cw) == "table" then
|
||||
e.contextWindow = rnum(cw, "dev", "Dev")
|
||||
end
|
||||
-- 输出上限:model_detail_list[].max_tokens(取首条的 max_tokens)
|
||||
local mdl = it.model_detail_list or it.modelDetailList
|
||||
if type(mdl) == "table" and type(mdl[1]) == "table" then
|
||||
e.maxOutputTokens = rnum(mdl[1], "max_tokens", "maxTokens")
|
||||
end
|
||||
-- 计费:display_contact_config 是 **JSON 字符串**(需二次解析)
|
||||
local dcc = it.display_contact_config or it.DisplayContactConfig
|
||||
if type(dcc) == "string" and dcc ~= "" then
|
||||
local okj, cfg = pcall(json.decode, dcc)
|
||||
if okj and type(cfg) == "table" then
|
||||
local rate = cfg.consumption_rate or cfg.ConsumptionRate
|
||||
if type(rate) == "table" then
|
||||
local enable = rbool(rate, "enable", "Enable")
|
||||
if enable ~= false then
|
||||
local data = rate.data or rate.Data
|
||||
if type(data) == "table" then
|
||||
local v = rnum(data, "rate", "Rate")
|
||||
if v and v >= 0 then e.creditsRate = v end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 活动折扣:**不能只看 enable** —— 实测 enable=true 但当前无折扣的形态
|
||||
-- 形如 {type:"none", before:0.13, after:0.13, discount:100}
|
||||
-- (discount=100 表示无折扣,before==after)。照显会让用户以为有活动。
|
||||
local disc = cfg.activity_discount or cfg.ActivityDiscount
|
||||
if type(disc) == "table" and rbool(disc, "enable", "Enable") ~= false then
|
||||
local ddata = disc.data or disc.Data
|
||||
if type(ddata) == "table" then
|
||||
local cur = ddata.current or ddata.Current
|
||||
if type(cur) == "table" then
|
||||
local dtype = (rstr(cur, "discount_type", "discountType") or "")
|
||||
dtype = dtype:lower():gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if dtype ~= "" and dtype ~= "none" then
|
||||
local before = rnum(cur, "before_consumption_rate", "beforeConsumptionRate")
|
||||
local after = rnum(cur, "consumption_rate", "consumptionRate")
|
||||
-- 必须是真的降价:before 有值、为正、且严格大于 after
|
||||
if before and before > 0 and (not after or before > after) then
|
||||
-- 截止时间:data 下任意带 end_at 的子对象
|
||||
local endsAt
|
||||
for _, v in pairs(ddata) do
|
||||
if type(v) == "table" then
|
||||
local endv = rnum(v, "end_at", "endAt")
|
||||
if endv and endv > 0 then endsAt = endv break end
|
||||
end
|
||||
end
|
||||
local nowS = os.time and os.time() or 0
|
||||
if not endsAt or endsAt > nowS then
|
||||
e.origCreditsRate = before
|
||||
e.discountEndsAt = endsAt
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return e
|
||||
end
|
||||
|
||||
local groups = rec.function_configs or rec.functionConfigs
|
||||
if type(groups) == "table" then
|
||||
local byId, chosenRank, order = {}, {}, {}
|
||||
for _, g in ipairs(groups) do
|
||||
if type(g) == "table" then
|
||||
local chan = rstr(g, "function", "Function")
|
||||
-- 准入:未声明 function 或不在白名单 → 整组丢弃
|
||||
if chan and rankOf[chan] then
|
||||
local list = g.config_info_list or g.ConfigInfoList
|
||||
if type(list) == "table" then
|
||||
for _, it in ipairs(list) do
|
||||
if type(it) == "table" then
|
||||
local e = parseEntry(it, chan)
|
||||
if e then
|
||||
-- 硬性过滤(不设开关)
|
||||
local ok = true
|
||||
if e.usage ~= nil and tostring(e.usage) ~= "chat_completion" then ok = false end
|
||||
if e.enabled == false then ok = false end
|
||||
if e.hidden == true then ok = false end
|
||||
-- is_custom_model: 仅可见但不可调用(上游流内 4001 拒绝)
|
||||
if e.custom == true then ok = false end
|
||||
if ok then
|
||||
local inc = byId[e.id]
|
||||
if inc == nil then
|
||||
byId[e.id] = e
|
||||
chosenRank[e.id] = rankOf[chan]
|
||||
order[#order + 1] = e.id
|
||||
else
|
||||
local incEff = declaresEffort(inc)
|
||||
local candEff = declaresEffort(e)
|
||||
local take = true
|
||||
if incEff and not candEff then
|
||||
take = false -- 规则①:空档位不得覆盖有档位
|
||||
elseif incEff and candEff then
|
||||
-- 规则②:两侧都有档位 → 取通道优先级更靠前的
|
||||
take = rankOf[chan] < (chosenRank[e.id] or 0)
|
||||
end
|
||||
if take then
|
||||
byId[e.id] = e
|
||||
chosenRank[e.id] = rankOf[chan]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, id in ipairs(order) do
|
||||
out[#out + 1] = id
|
||||
-- 元数据另存(model_list 用它报出上下文窗口/档位/倍率):
|
||||
-- ⚠ 只报 id 会把参考能提供的元数据全部丢掉(档位信息在解析阶段就没了,
|
||||
-- 上层只能显示"未提供推理等级")。
|
||||
if not plugin.state.modelMeta then plugin.state.modelMeta = {} end
|
||||
plugin.state.modelMeta[provider] = plugin.state.modelMeta[provider] or {}
|
||||
plugin.state.modelMeta[provider][id] = byId[id]
|
||||
end
|
||||
end
|
||||
|
||||
elseif kind == "zcode_configs" then
|
||||
-- 照参考 fetchZcodeModels:字段是 data.builtinModels,且它是**对象**
|
||||
-- (键为序号 "0"/"1"),用 Array.isArray 判定会得到 0 个模型的假阴性。
|
||||
@ -3208,6 +3594,10 @@ local function refreshRemoteModels(provider)
|
||||
local url = ep.url
|
||||
if ep.query then url = url .. "?" .. ep.query end
|
||||
local headers = { ["Accept"] = "application/json" }
|
||||
-- ★ ep.body:POST 型目录端点(如 trae 的 batch_get_detail_param 必须 POST +
|
||||
-- 指定请求体)。缺失时按 GET。
|
||||
local method = ep.method or "GET"
|
||||
if ep.body then headers["Content-Type"] = "application/json" end
|
||||
local useAuth = ep.url_auth ~= false and mr.auth ~= "none"
|
||||
if useAuth and tok and tok ~= "" then
|
||||
headers["Authorization"] = "Bearer " .. tok
|
||||
@ -3221,7 +3611,38 @@ local function refreshRemoteModels(provider)
|
||||
-- 不是 Bearer。ep.sign = "codearts" 时用平台签名函数重建整个请求
|
||||
-- (签名覆盖 host/x-sdk-date/x-sdk-content-sha256/x-security-token 等头,
|
||||
-- 故不能「先拼头再签名」—— 必须由签名函数产出最终请求)。
|
||||
local req = { url = url, method = ep.method or "GET", headers = headers }
|
||||
local req = { url = url, method = method, headers = headers }
|
||||
if ep.body then req.body = ep.body end
|
||||
if ep.sign == "trae_solo" then
|
||||
-- TRAE 的鉴权是 Cloud-IDE-JWT + 约 20 个客户端头(含按 user_id 派生的
|
||||
-- 设备身份)—— 不能只发 Bearer。这里复用**平台既有的** traeCheckinHeaders
|
||||
-- 实现(它已经过实测:简化头会被拒),不另写一套头部构造,
|
||||
-- 否则“chat 能通、目录 401”这类差异极难查。
|
||||
local cred
|
||||
for _, a in ipairs(poolList(provider)) do
|
||||
if a.enabled ~= false then
|
||||
local raw = __llmsproxy_secrets.get(a.credentialRef)
|
||||
local okj, cj = pcall(json.decode, raw or "")
|
||||
if okj and type(cj) == "table" and cj.access_token then
|
||||
cred = cj break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not cred then
|
||||
lastErr = "无可用 TRAE 凭据"
|
||||
elseif type(traeCheckinHeaders) ~= "function" then
|
||||
lastErr = "traeCheckinHeaders 未就绪(前向引用顺序问题)"
|
||||
else
|
||||
local uid = cred.uid or ""
|
||||
local h, herr = traeCheckinHeaders(plat, cred, uid)
|
||||
if herr then
|
||||
lastErr = tostring(herr)
|
||||
else
|
||||
-- 融平台头(后者覆盖前面的 Accept,但保留 Authorization 等)
|
||||
for k, v in pairs(h or {}) do req.headers[k] = v end
|
||||
end
|
||||
end
|
||||
end
|
||||
if ep.sign == "codearts" then
|
||||
local cred
|
||||
for _, a in ipairs(poolList(provider)) do
|
||||
@ -3347,8 +3768,20 @@ local function syncAccountSource(provider, accountId)
|
||||
models[#models+1] = { id = m, kind = "chat", priority = 20 }
|
||||
end
|
||||
end
|
||||
-- 重建:先 remove(容错:源可能不存在)再 add
|
||||
__llmsproxy_kernel.call("remove_source", { name = name })
|
||||
-- 重建:先 remove(容错:源可能不存在)再 add。
|
||||
-- ⚠ 这里失败**可容忍**:AddSource 是**同名 upsert**,add 会把旧的覆盖掉。
|
||||
-- 但这正是它掩盖 remove_source 权限缺失的原因(纯删除路径才暴露),
|
||||
-- 故失败仍记一笔(低频,不会刷屏)。
|
||||
local rr, rerr = __llmsproxy_kernel.call("remove_source", { name = name })
|
||||
if rr == nil or rr.ok ~= true then
|
||||
local l = plugin.state.log
|
||||
local last = type(l) == "table" and l[1] and l[1].msg or ""
|
||||
-- 只在**上一次不是同一条**时记,避免每次同步都刷一条
|
||||
if not tostring(last):find("撤源(重建)失败", 1, true) then
|
||||
logEvent("撤源(重建)失败 " .. tostring(name) .. ": "
|
||||
.. tostring(rerr or "(无错误信息)"))
|
||||
end
|
||||
end
|
||||
local src = {
|
||||
name = name,
|
||||
base_url = plat.base_url,
|
||||
@ -3387,8 +3820,29 @@ local function syncAccountSource(provider, accountId)
|
||||
end
|
||||
end
|
||||
|
||||
-- dropAccountSource:把某账号的上游源撤掉(账号停用/删除时调用)。
|
||||
--
|
||||
-- ★ 必须**检查返回值**:内核的 remove_source 需要**单独授予**(它是 Destructive 级,
|
||||
-- 不在 add_source 的授权里)。生产权限集曾只有 add_source ⇒ 这个调用被**拒绝**,
|
||||
-- 而调用方**忽略返回值** ⇒ 静默失败的正确后果是:**账号已删/已停用,它的源仍在
|
||||
-- 对外服务**(用户看到"删了却还在用")。
|
||||
-- 更隐蔽的是:AddSource 是**同名 upsert**,所以 syncAccountSource 里"先 remove 再 add"
|
||||
-- 的 remove 失败**看不出来**(add 会把同名覆盖掉)—— 只有纯删除路径才暴露。
|
||||
-- 故这里把失败**记进事件日志**,让它在面板上可见,而不是只存在于一个被丢弃的返回值里。
|
||||
local function dropAccountSource(provider, accountId)
|
||||
__llmsproxy_kernel.call("remove_source", { name = accountSourceName(provider, accountId) })
|
||||
local name = accountSourceName(provider, accountId)
|
||||
-- ★ 内核桥是 `call(name, args) -> (result, nil) | (nil, err)`:
|
||||
-- 失败时**第一个返回值是 nil**,原因在**第二个返回值**里。
|
||||
-- 我第一版写成 `local r = call(...)` 再读 `r.ok` ⇒ 恒 nil ⇒ 日志只说"无返回",
|
||||
-- 把真正原因(not granted / 超时 / 拒删最后一个源)丢掉 —— 一句不可操作的
|
||||
-- 诊断比没有更糟。两个返回值都要接。
|
||||
local r, err = __llmsproxy_kernel.call("remove_source", { name = name })
|
||||
if r == nil or r.ok ~= true then
|
||||
logEvent("撤源失败 " .. tostring(name) .. ": " .. tostring(err or "(无错误信息)")
|
||||
.. " | 若为 not granted,需给 channels 插件授予 remove_source(声明在 plugin.kernel)")
|
||||
return false, err
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
@ -4874,6 +5328,7 @@ end
|
||||
-- 改成 local 后这个隐式依赖就断了 —— 表现为
|
||||
-- attempt to call global 'traeCallback' (a nil value)。
|
||||
local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId
|
||||
|
||||
local traeCheckinHeaders, traeFixNickname
|
||||
|
||||
function plugin.poll_login(req)
|
||||
@ -6321,6 +6776,8 @@ plugin.http = {
|
||||
roles = { "admin" }, description = "自动签到开关读写" },
|
||||
{ path = "channels/credits/status", method = "POST", handler = "credits_status",
|
||||
roles = { "admin" }, description = "逐账号签到状态" },
|
||||
{ path = "channels/credits/pool_reason", method = "POST", handler = "credits_pool_reason",
|
||||
roles = { "admin" }, description = "为什么没有可用账号(区分用尽/查不到)" },
|
||||
{ path = "channels/credits/balances", method = "POST", handler = "credits_balances",
|
||||
roles = { "admin" }, description = "逐账号余额" },
|
||||
{ path = "channels/credits/permanent_lock", method = "POST", handler = "credits_permanent_lock",
|
||||
@ -6440,7 +6897,15 @@ end
|
||||
|
||||
function plugin.account_clear_credentialless(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
-- ⚠️ 不要照其它 handler 写 `if args.platform == nil then args = req end`:
|
||||
-- 那个回退是为「直接把参数当 req 传」的旧调用准备的,但它会**丢掉 body**
|
||||
-- —— 本函数不传 platform 是**合法用法**(清全部平台),此时 args.platform
|
||||
-- 自然为 nil,回退会把 args 换成 HandlerRequest ⇒ 刚解出来的 dry_run 就没了,
|
||||
-- 于是「传 dry_run=false 却被当成未传」而静默只预演(破坏性操作里最糟的形态:
|
||||
-- 操作者以为执行了、实际没执行,或者反过来)。
|
||||
-- 实测踩到:不带 platform 时 deleted 恒为 nil。
|
||||
-- 兜底只认「req 本身就是参数表」这种形态。
|
||||
if type(req) == "table" and req.body == nil and req.query == nil then args = req end
|
||||
local dry = args.dry_run
|
||||
if dry == nil then dry = true end
|
||||
|
||||
@ -6713,7 +7178,22 @@ function plugin.account_reorder(req)
|
||||
local acct = poolGet(plat.id, tostring(aid))
|
||||
if acct then acct.priority = #ordered - i + 1 poolAdd(plat.id, acct) end
|
||||
end
|
||||
return { status = 200, json = { ok = true } }
|
||||
-- ★ 必须**重注册源**,否则重排不生效。
|
||||
--
|
||||
-- 链路:poolList 按 priority 降序 → syncAccountSource 的调用顺序 → 内核里源的
|
||||
-- **注册顺序** → 内核按 candidates 顺序依次尝试。而一个源**一旦注册,顺序就固定**
|
||||
-- 了(改动 pool 记录不影响它)。故重排后必须按新顺序 remove+add 一遍,
|
||||
-- 让"优先账号"真的排在前面。
|
||||
--
|
||||
-- 真实收益(buddy 15 天窗口):不重排的话,快到期积分不会被优先消耗 ⇒
|
||||
-- **白白过期**(参考为此有 rankBuddyAccountsByBalance)。
|
||||
-- ⚠ remove+add 会把源移到注册队列末尾,所以**必须整体按新顺序重放一遍** ——
|
||||
-- 只重注册被移动的那几个会让它们的相对顺序取决于调用顺序,而不是最终顺序。
|
||||
for _, a in ipairs(poolList(plat.id)) do
|
||||
syncAccountSource(plat.id, a.id)
|
||||
end
|
||||
return { status = 200, json = { ok = true,
|
||||
reordered = #poolList(plat.id), applied_to_sources = true } }
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
@ -6820,6 +7300,40 @@ function plugin.model_list(req)
|
||||
toolCall = cap.toolCall,
|
||||
}
|
||||
end
|
||||
-- ★ 远端目录解析出的元数据(如 trae 的上下文窗口/输出上限/思考档位/倍率)。
|
||||
-- 之前目录只报 id ⇒ 参考能提供的这些字段全丢,UI 只能显示"未提供推理等级"。
|
||||
-- 能力表(models.dev)没给的值用远端目录补(远端是**权威**:它就是这个
|
||||
-- 通道实际可用的配置)。两者都有时不覆盖能力表(后者是跨平台归一后的口径)。
|
||||
local rm = plugin.state.modelMeta and plugin.state.modelMeta[plat.id]
|
||||
and plugin.state.modelMeta[plat.id][m]
|
||||
if rm then
|
||||
entry.remote = {}
|
||||
if rm.contextWindow then
|
||||
entry.remote.contextWindow = rm.contextWindow
|
||||
if not (cap and cap.contextWindow) then entry.contextWindow = rm.contextWindow end
|
||||
end
|
||||
if rm.maxOutputTokens then
|
||||
entry.remote.maxOutputTokens = rm.maxOutputTokens
|
||||
if not (cap and cap.maxOutputTokens) then entry.maxOutputTokens = rm.maxOutputTokens end
|
||||
end
|
||||
-- ⚠ `function` 是 Lua 关键字,必须用中括号键(本文件第 3 次踩)。
|
||||
if rm.chan then entry.remote["function"] = rm.chan end
|
||||
if rm.creditsRate then entry.remote.creditsRate = rm.creditsRate end
|
||||
if rm.origCreditsRate then entry.remote.origCreditsRate = rm.origCreditsRate end
|
||||
if rm.discountEndsAt then entry.remote.discountEndsAt = rm.discountEndsAt end
|
||||
-- 思考档位:从远端条目解出(列表空/显式 support_thinking:false 则视为无)
|
||||
local rc = rm.reasoning
|
||||
if type(rc) == "table" then
|
||||
local st = rc.support_thinking
|
||||
if st == nil then st = rc.supportThinking end
|
||||
local opts = rc.options or rc.Options
|
||||
if st ~= false and type(opts) == "table" and #opts > 0 then
|
||||
entry.remote.efforts = opts
|
||||
if not (cap and cap.efforts) then entry.efforts = opts end
|
||||
entry.reasoning = true
|
||||
end
|
||||
end
|
||||
end
|
||||
out[#out+1] = entry
|
||||
end
|
||||
end
|
||||
@ -7103,6 +7617,96 @@ function plugin.credits_status(req)
|
||||
return { status = 200, json = { ok = true, accounts = out } }
|
||||
end
|
||||
|
||||
-- credits_pool_reason:为什么这个平台**没有可用账号**。
|
||||
--
|
||||
-- ## 为什么必须单独有这个出口(参考的同款修复,用户报障 2026-09-29)
|
||||
-- 把「真的用尽」与「查不到」混成一句"额度都已用尽"会让**一次网络抖动伪装成
|
||||
-- 钱花完了** —— 用户于是去解锁永久积分、或白等到明天,而号里其实有钱。
|
||||
-- 两者的**正确动作完全相反**:
|
||||
-- * exhausted(所有账号都查到了余额,且可用那类积分确实为 0)→ 等额度重置;
|
||||
-- * unknown(至少一个账号查询失败,无法判定它是否真没额度)→ 稍后重试、
|
||||
-- 或检查网络/凭据,**不要**去动永久积分锁定。
|
||||
--
|
||||
-- 返回 { reason = "exhausted"|"unknown"|"locked"|"no_accounts",
|
||||
-- detail = 可读说明, errors = {...}, counts = {...} }。
|
||||
function plugin.credits_pool_reason(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
|
||||
local accounts = poolList(plat.id)
|
||||
local enabled = 0
|
||||
for _, a in ipairs(accounts) do
|
||||
if a.enabled ~= false then enabled = enabled + 1 end
|
||||
end
|
||||
if enabled == 0 then
|
||||
return { status = 200, json = {
|
||||
ok = true, reason = "no_accounts",
|
||||
detail = "该平台没有启用的账号" .. (#accounts > 0 and ("(有 " .. #accounts .. " 个被停用)") or ""),
|
||||
counts = { total = #accounts, enabled = 0 } } }
|
||||
end
|
||||
|
||||
-- 平台没有余额端点 ⇒ 无法判定,如实说"查不到"而不是"用尽"
|
||||
if not plat.balance then
|
||||
return { status = 200, json = {
|
||||
ok = true, reason = "unknown",
|
||||
detail = "该平台未声明余额端点,无法判定是否用尽(不要当成额度已用尽)",
|
||||
counts = { total = #accounts, enabled = enabled } } }
|
||||
end
|
||||
|
||||
local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits
|
||||
local allowPermanent = not lockPermanent
|
||||
local r = rankAccounts(plat.id)
|
||||
local errors = {}
|
||||
-- 逐账号收集查询失败原因(rankAccounts 只给档位,诊断要原因)
|
||||
for _, a in ipairs(accounts) do
|
||||
if a.enabled ~= false then
|
||||
local raw = accountBalanceRaw(plat.id, a.id)
|
||||
if raw == nil then errors[#errors + 1] = a.id end
|
||||
end
|
||||
end
|
||||
|
||||
if #r.usable > 0 then
|
||||
return { status = 200, json = {
|
||||
ok = true, reason = "usable", usable = #r.usable,
|
||||
detail = "有 " .. #r.usable .. " 个可用账号",
|
||||
counts = { total = #accounts, enabled = enabled,
|
||||
expiring = r.expiring, permanent = r.permanent, none = r.none },
|
||||
locked = lockPermanent and true or false } }
|
||||
end
|
||||
|
||||
-- ★ 无可用:区分「查不到」与「真的用尽」
|
||||
if #errors > 0 then
|
||||
return { status = 200, json = {
|
||||
ok = true, reason = "unknown",
|
||||
detail = "有 " .. #errors .. " 个账号的余额**查询失败**,无法判定它们是否真的没额度"
|
||||
.. "(不要当成额度已用尽:可能是网络抖动;也不要去解锁永久积分)",
|
||||
error_accounts = errors,
|
||||
counts = { total = #accounts, enabled = enabled,
|
||||
expiring = r.expiring, permanent = r.permanent, none = r.none },
|
||||
locked = lockPermanent and true or false } }
|
||||
end
|
||||
|
||||
if lockPermanent then
|
||||
-- 锁定时只剩永久积分的账号视为不可用(照参考 tier=none 语义)
|
||||
return { status = 200, json = {
|
||||
ok = true, reason = "locked",
|
||||
detail = "永久积分已锁定," .. tostring(r.permanent) .. " 个只剩永久积分的账号不可用"
|
||||
.. "(如需使用可解锁;这不是「额度用尽」)",
|
||||
counts = { total = #accounts, enabled = enabled,
|
||||
expiring = r.expiring, permanent = r.permanent, none = r.none },
|
||||
locked = true } }
|
||||
end
|
||||
|
||||
return { status = 200, json = {
|
||||
ok = true, reason = "exhausted",
|
||||
detail = "所有账号都查到了余额,且可用积分确实为 0(等额度重置)",
|
||||
counts = { total = #accounts, enabled = enabled,
|
||||
expiring = r.expiring, permanent = r.permanent, none = r.none },
|
||||
locked = false } }
|
||||
end
|
||||
|
||||
-- credits_balances:逐账号查余额(对齐参考 credits.balances;
|
||||
-- 无 balance 端点的平台返回 {balance=null, error="平台未声明余额端点"},
|
||||
-- 不编造数字 —— 与参考注释「不伪装成余额」同语义)
|
||||
@ -10631,7 +11235,11 @@ function plugin.__inject_refresh_platform_probe(args)
|
||||
label = id,
|
||||
flow = "manual",
|
||||
adapter = "openai",
|
||||
base_url = "http://127.0.0.1:1/v1/chat/completions",
|
||||
base_url = "http://127.0.0.1:1/v1",
|
||||
-- 照正式平台同样的分工:base_url 只到路径前缀,路径放 endpoint
|
||||
-- (内核 URL = base_url + endpoint)。测试夹具也照此写,
|
||||
-- 免得将来有人把这行当范例复制进正式平台。
|
||||
endpoint = "/chat/completions",
|
||||
refresh = { style = tostring(args.style or "google"), url = tostring(args.refresh_url or "") },
|
||||
models = { "m1" },
|
||||
hosts = { "127.0.0.1" },
|
||||
@ -10820,6 +11428,86 @@ function plugin.claims_capable(args)
|
||||
} }
|
||||
end
|
||||
|
||||
-- plugin.__clear_priorities_probe:只给测试用 —— 把所有账号的 priority 拉平。
|
||||
-- 用于构造"排序键完全相同"的场景(同优先级 + 同秒 createdAt),
|
||||
-- 检验排序是否有确定性兜底。
|
||||
function plugin.__clear_priorities_probe(args)
|
||||
args = args or {}
|
||||
local plat = platformById(args.platform)
|
||||
if not plat then return { json = { error = "未知平台" } } end
|
||||
local n = 0
|
||||
for _, a in ipairs(poolList(plat.id)) do
|
||||
a.priority = nil
|
||||
poolAdd(plat.id, a)
|
||||
n = n + 1
|
||||
end
|
||||
return { json = { ok = true, n = n } }
|
||||
end
|
||||
|
||||
-- plugin.__resync_sources_probe:只给测试用 —— 按 poolList 顺序批量重注册源。
|
||||
-- 「排序是否有确定性兜底」必须在**批量重注册**路径上验:account_add 是直接同步
|
||||
-- 自己那一个(源顺序 = add 顺序),走不到排序逻辑。
|
||||
function plugin.__resync_sources_probe(args)
|
||||
args = args or {}
|
||||
local plat = platformById(args.platform)
|
||||
if not plat then return { json = { error = "未知平台" } } end
|
||||
local n = 0
|
||||
for _, a in ipairs(poolList(plat.id)) do
|
||||
syncAccountSource(plat.id, a.id)
|
||||
n = n + 1
|
||||
end
|
||||
return { json = { ok = true, n = n } }
|
||||
end
|
||||
|
||||
-- plugin.__event_log_probe:只给测试/诊断用 —— 插件事件日志的文案列表。
|
||||
-- logEvent 写进 plugin.state.log,但它没有对外的读出口(`status` 是 HTTP 路由、
|
||||
-- 不在 exports 里),判据要断言"某件事被记录了"就得有它。
|
||||
function plugin.__event_log_probe()
|
||||
local out = {}
|
||||
for _, e in ipairs(plugin.state.log or {}) do
|
||||
if type(e) == "table" and type(e.msg) == "string" then out[#out + 1] = e.msg end
|
||||
end
|
||||
return { json = { log = out } }
|
||||
end
|
||||
|
||||
-- plugin.__list_sources_probe:只给测试/诊断用 —— 内核里当前登记的源**及其顺序**。
|
||||
-- 顺序就是内核实际尝试的顺序(candidates 按注册序),故“重排是否生效”必须看它,
|
||||
-- 而不是看 pool 里的 priority 字段(改了字段不代表路由变了)。
|
||||
function plugin.__list_sources_probe()
|
||||
local ls = __llmsproxy_kernel.call("list_sources", {})
|
||||
-- ⚠ 内核 actionListSources 返回的是**裸数组**(不是 {sources:[...]})——
|
||||
-- 我第一版按后者取,于是恒得空列表(判据因此报"应有 2 个源,实得 []")。
|
||||
-- 两种形态都兜住,免得内核换个包装就失效。
|
||||
local arr = ls and ls.json or ls
|
||||
if type(arr) ~= "table" then arr = {} end
|
||||
local out = {}
|
||||
for _, x in ipairs(arr) do
|
||||
if type(x) == "table" then out[#out + 1] = { name = x.name, adapter = x.adapter } end
|
||||
end
|
||||
return { json = { sources = out } }
|
||||
end
|
||||
|
||||
-- plugin.__trae_entry_probe:只给测试/诊断用 —— 报告某模型最终选中条目的关键字段。
|
||||
-- “合并规则选错了条目”(空档位覆盖有档位,issue IKI7WT/IKILR7)这类问题
|
||||
-- 没有它只能靠猜。
|
||||
function plugin.__trae_entry_probe(args)
|
||||
args = args or {}
|
||||
local id = tostring(args.model or "")
|
||||
local meta = plugin.state.modelMeta and plugin.state.modelMeta.trae
|
||||
local e = meta and meta[id]
|
||||
if not e then return { json = { error = "无该模型元数据" } } end
|
||||
local rc = e.reasoning
|
||||
local opts = type(rc) == "table" and (rc.options or rc.Options) or nil
|
||||
return { json = {
|
||||
id = id, chan = e.chan, usage = e.usage,
|
||||
enabled = e.enabled, hidden = e.hidden, custom = e.custom,
|
||||
has_reasoning = rc ~= nil,
|
||||
n_options = type(opts) == "table" and #opts or 0,
|
||||
support_thinking = type(rc) == "table"
|
||||
and (rc.support_thinking or rc.supportThinking) or nil,
|
||||
} }
|
||||
end
|
||||
|
||||
-- plugin.__zcode_diag_probe:只给测试/诊断用 —— 暴露共用的 3012 判据模块。
|
||||
--
|
||||
-- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在
|
||||
|
||||
Reference in New Issue
Block a user