mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a82ae2b60e | |||
| 2d2c27c430 | |||
| 0f83fc048a |
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)
|
||||
}
|
||||
}
|
||||
@ -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_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", "__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" }
|
||||
|
||||
|
||||
|
||||
@ -2741,7 +2753,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
|
||||
@ -3630,8 +3650,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,
|
||||
@ -3670,8 +3702,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
|
||||
|
||||
-- ============================================================================
|
||||
@ -7007,7 +7060,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
|
||||
|
||||
-- ============================================================================
|
||||
@ -11238,6 +11306,65 @@ 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)这类问题
|
||||
-- 没有它只能靠猜。
|
||||
|
||||
Reference in New Issue
Block a user