mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 652a035d47 | |||
| 6bec260513 | |||
| ed954b77bd | |||
| cb3ea25586 |
47
deploy.sh
47
deploy.sh
@ -393,13 +393,17 @@ sync_adapters() {
|
||||
|
||||
# 同步内置插件到 plugin_dir。
|
||||
#
|
||||
# 为什么需要它:新内置插件(如 channels.lua)过去只能靠内核的 SeedBundled 落地,
|
||||
# 而旧版 SeedBundled "目录已存在就什么都不做",于是升级后新插件永远不会出现在
|
||||
# 生产 —— 它在二进制里、却不在 /api/plugins。内核端已修(改为按文件补齐),
|
||||
# 这里再显式同步一道,让部署与二进制内容对齐、不依赖内核那一侧的行为。
|
||||
# 内置插件是**内核发行物的一部分**(go:embed 进二进制,随版本演进),与内置适配器
|
||||
# 同性质。因此这里的纪律与 sync_adapters 完全一致:**覆盖内置同名文件**(带备份),
|
||||
# 保留目录里运行时新增/运维自加的 .lua。
|
||||
#
|
||||
# 只补缺:已存在的插件文件一律不动。运维自己改过、或运行时新增的插件必须跨
|
||||
# 部署存活;覆盖它们会静默抹掉线上正在用的逻辑。
|
||||
# 早期实现是「只补缺,已存在一律不动」,看着安全,实际造成一个功能性缺口:
|
||||
# 内置插件本身的**修复无法下发**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
|
||||
# 全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版,
|
||||
# 生产行为毫无变化。内置源码的正确性由仓库保证,部署必须与二进制内容对齐。
|
||||
#
|
||||
# 「只补缺」原本想保护的是「运维改过 / 运行时新增的插件」,但那些都是**非内置
|
||||
# 名字**:内置名一律以发行物为准,其余名字一律不动。两者不是一回事。
|
||||
sync_plugins() {
|
||||
log "同步内置插件"
|
||||
|
||||
@ -408,24 +412,35 @@ sync_plugins() {
|
||||
|
||||
mkdir -p "$TARGET_PLUGINS"
|
||||
|
||||
# 备份将被触及的文件(只会是本次新增的那些)
|
||||
# 备份将被覆盖/新增的内置插件(非内置名字不进入循环,天然不动)
|
||||
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
|
||||
local added=0
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local added=0 updated=0 same=0
|
||||
for src in "$SRC_PLUGINS"/*.lua; do
|
||||
[[ -f "$src" ]] || continue
|
||||
local name; name="$(basename "$src")"
|
||||
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
|
||||
continue # 已存在:运维版本优先,不覆盖
|
||||
if cmp -s "$src" "$TARGET_PLUGINS/$name"; then
|
||||
((same++)) || true
|
||||
continue
|
||||
fi
|
||||
cp -f "$TARGET_PLUGINS/$name" "$BACKUP_DIR/$name.bak" 2>/dev/null || true
|
||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||||
log " ~ 更新内置插件: $name"
|
||||
((updated++)) || true
|
||||
else
|
||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||||
log " + 新增内置插件: $name"
|
||||
((added++)) || true
|
||||
fi
|
||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||||
log " + 新增内置插件: $name"
|
||||
((added++)) || true
|
||||
done
|
||||
if (( added == 0 )); then
|
||||
log " 无新增内置插件(均已存在)"
|
||||
# 没有任何改动时不必留空备份目录
|
||||
if (( updated == 0 && added == 0 )); then
|
||||
rmdir "$BACKUP_DIR" 2>/dev/null || true
|
||||
else
|
||||
log " 旧插件已备份到 $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
|
||||
@ -434,7 +449,7 @@ sync_plugins() {
|
||||
local name; name="$(basename "$src")"
|
||||
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
|
||||
done
|
||||
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,已有文件未改动)"
|
||||
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
|
||||
}
|
||||
|
||||
# ---------- 重启服务 ----------
|
||||
|
||||
@ -27,6 +27,9 @@ func TestChannelsPluginEndToEnd(t *testing.T) {
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatalf("加载 channels: %v", err)
|
||||
}
|
||||
// 测试夹具经运行时注入:生产目录只含真实厂商,夹具不再内联进源码
|
||||
// (否则会出现在生产 UI 的平台列表里)。
|
||||
injectTestPlatforms(t, ps, examplePlatformFixture)
|
||||
|
||||
// 1. 平台清单可用(未授权任何东西也应能列出)
|
||||
out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{
|
||||
@ -239,3 +242,101 @@ func stateHasSecret(ps *lua.Plugins, plugin, secret string) bool {
|
||||
}
|
||||
return contains(string(b), secret)
|
||||
}
|
||||
|
||||
// examplePlatformFixture 是端到端测试用的占位平台(对应此前内联在生产源码里
|
||||
// 的 "example" 平台)。它经 inject_platforms 注入,且**只在测试进程内存在** ——
|
||||
// 生产插件的 PLATFORMS 只含真实厂商。
|
||||
const examplePlatformFixture = `[
|
||||
{
|
||||
"id": "example",
|
||||
"name": "Example 平台(测试夹具)",
|
||||
"flow": "link-poll",
|
||||
"login_url": "https://auth.example.com/device",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"adapter": "openai",
|
||||
"models": ["example-chat", "example-reason"],
|
||||
"checkin": {"url": "https://api.example.com/v1/api/daily/claim", "method": "POST", "body": "{}"}
|
||||
}
|
||||
]`
|
||||
|
||||
// injectTestPlatforms 把测试夹具注入插件(运行时,不落盘)。
|
||||
func injectTestPlatforms(t *testing.T, ps *lua.Plugins, fixtureJSON string) {
|
||||
t.Helper()
|
||||
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platforms":` + fixtureJSON + `}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("inject_platforms: %v", err)
|
||||
}
|
||||
m, _ := out.JSON.(map[string]interface{})
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("注入测试平台失败: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectPlatformsRejectsBadInput 锁定注入机制的三条边界:
|
||||
// 1. 不得覆盖内建平台(注入不能劫持真实厂商)
|
||||
// 2. 重复 id 拒绝
|
||||
// 3. 空数组 = 清空注入(夹具不跨测试残留)
|
||||
//
|
||||
// 这个机制存在的理由:夹具内联进生产源码会让它们出现在生产 UI 的平台列表里
|
||||
// (真实踩过 —— 16 个平台里有 4 个是夹具)。
|
||||
func TestInjectPlatformsRejectsBadInput(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
call := func(body string) map[string]interface{} {
|
||||
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
|
||||
Method: "POST", Body: body, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("inject_platforms(%s): %v", body, err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
platforms := func() []interface{} {
|
||||
out, _ := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
return out.JSON.(map[string]interface{})["platforms"].([]interface{})
|
||||
}
|
||||
|
||||
base := len(platforms())
|
||||
if base == 0 {
|
||||
t.Fatal("生产目录应有真实厂商平台")
|
||||
}
|
||||
|
||||
// 注入一个合法夹具 → 列表 +1
|
||||
if m := call(`{"platforms":[{"id":"fixture-a","name":"A","flow":"paste","base_url":"https://a.example/v1"}]}`); m["ok"] != true {
|
||||
t.Fatalf("合法注入应成功: %v", m)
|
||||
}
|
||||
if got := len(platforms()); got != base+1 {
|
||||
t.Fatalf("注入后平台数 = %d, 期望 %d", got, base+1)
|
||||
}
|
||||
|
||||
// 1. 覆盖内建平台 → 拒绝
|
||||
if m := call(`{"platforms":[{"id":"buddy","name":"劫持"}]}`); m["error"] == nil {
|
||||
t.Fatal("覆盖内建平台必须被拒绝")
|
||||
}
|
||||
// 2. 重复 id → 拒绝
|
||||
if m := call(`{"platforms":[{"id":"fixture-b"},{"id":"fixture-b"}]}`); m["error"] == nil {
|
||||
t.Fatal("重复 id 必须被拒绝")
|
||||
}
|
||||
// 无 id → 拒绝
|
||||
if m := call(`{"platforms":[{"name":"no-id"}]}`); m["error"] == nil {
|
||||
t.Fatal("缺 id 必须被拒绝")
|
||||
}
|
||||
|
||||
// 3. 空数组 = 清空注入,回到基线
|
||||
if m := call(`{"platforms":[]}`); m["ok"] != true {
|
||||
t.Fatalf("清空应成功: %v", m)
|
||||
}
|
||||
if got := len(platforms()); got != base {
|
||||
t.Fatalf("清空后平台数 = %d, 期望回到基线 %d", got, base)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,10 @@ import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。
|
||||
@ -320,3 +324,95 @@ func TestChannelsExtendedRPCBehavior(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuddyBillingHeadersMatchReference 锁定 buddy 计费请求的头(逐行移植参考
|
||||
// checkinHeaders)。真实缺陷:实现只发 Authorization,上游直接 401 —— 修好后
|
||||
// 账号能读到凭据但余额仍失败,运维看到的仍是「拿不到账号信息」。
|
||||
func TestBuddyBillingHeadersMatchReference(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
var got map[string]string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = map[string]string{}
|
||||
for k := range r.Header {
|
||||
got[k] = r.Header.Get(k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":{"totalCredit":42}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// 把 buddy 的余额/签到端点指到本地假上游
|
||||
invoke(t, ps, "set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
|
||||
m := invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("account_add: %v", m)
|
||||
}
|
||||
invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("余额请求未发出")
|
||||
}
|
||||
// 参考 checkinHeaders 的必备身份头(缺一个上游就 401)
|
||||
want := map[string]string{
|
||||
"Authorization": "Bearer BD-AT",
|
||||
"X-Domain": "copilot.tencent.com",
|
||||
"X-Product": "SaaS",
|
||||
"X-Product-Code": "codebuddy",
|
||||
"User-Agent": "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("头 %s = %q, 期望 %q(buddy 计费端点缺它会被上游 401)", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuddyBalanceParseDoubleNested 逐行锁定参考 fetchCreditBalance 的解析口径:
|
||||
// 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 data 不同,
|
||||
// 这是该接口最容易错的地方),且只累加**有效包**的 CycleCapacityRemain ——
|
||||
// 失效包服务端仍返回,并进总额会让数字虚高(参考实测 155.67 → 655.67)。
|
||||
func TestBuddyBalanceParseDoubleNested(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
// 两个有效包 + 一个失效包(ExpiredTime 在 2020 年)
|
||||
body := `{"code":0,"msg":"OK","data":{"Response":{"Data":{"Accounts":[
|
||||
{"PackageName":"A","Status":1,"CycleCapacityRemain":100.5,"ExpiredTime":"2099-01-01 00:00:00"},
|
||||
{"PackageName":"B","Status":1,"CycleCapacityRemain":55.17000031,"ExpiredTime":"2099-01-01 00:00:00"},
|
||||
{"PackageName":"C","Status":1,"CycleCapacityRemain":500,"ExpiredTime":"2020-01-01 00:00:00"}
|
||||
]}}}}`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
invoke(t, ps, "set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
|
||||
invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
|
||||
|
||||
j := invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
var row map[string]interface{}
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
row = a.(map[string]interface{})
|
||||
}
|
||||
bal, _ := row["balance"].(map[string]interface{})
|
||||
if bal == nil {
|
||||
t.Fatalf("应解析出余额, 实得 error=%v", row["error"])
|
||||
}
|
||||
// 100.5 + 55.17 = 155.67(失效包的 500 不计入)
|
||||
if got, _ := bal["total"].(float64); got != 155.67 {
|
||||
t.Fatalf("余额 = %v, 期望 155.67(只累加有效包、两位小数)", bal["total"])
|
||||
}
|
||||
|
||||
// 单层 data(缺 Response 层)必须判为解析失败,不能当 0
|
||||
body = `{"code":0,"data":{"TotalDosage":609}}`
|
||||
j = invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
row = a.(map[string]interface{})
|
||||
}
|
||||
if row["balance"] != nil {
|
||||
t.Fatalf("双层缺失时应报错而非当 0(参考语义:区分「查不到」与「余额为 0」): %v", row)
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,18 @@ func channelsCore(t *testing.T, src string) (*Core, *lua.Plugins) {
|
||||
return c, ps
|
||||
}
|
||||
|
||||
// cookieishPlatformFixture 是压力测试用的占位平台(对应此前内联在生产源码里的
|
||||
// "cookieish")。夹具经运行时注入,生产目录只含真实厂商。
|
||||
const cookieishPlatformFixture = `[{
|
||||
"id": "cookieish",
|
||||
"name": "Cookie 平台(测试夹具)",
|
||||
"flow": "paste",
|
||||
"base_url": "https://api.cookieish.example/v1",
|
||||
"adapter": "openai",
|
||||
"models": ["c-model-1"],
|
||||
"checkin": {"url": "https://api.cookieish.example/v1/api/credit/claim", "method": "POST", "body": "{}"}
|
||||
}]`
|
||||
|
||||
// TestChannelsConcurrentLogins 压力:并发走 device-poll 登录。
|
||||
//
|
||||
// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、
|
||||
@ -134,6 +146,7 @@ func TestChannelsClaimStress(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_, ps := channelsCore(t, mustChannelsSrc(t))
|
||||
injectTestPlatforms(t, ps, cookieishPlatformFixture)
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "cookieish",
|
||||
"checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"},
|
||||
|
||||
@ -171,3 +171,67 @@ func TestTraeCallbackBehavior(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTraeCallbackPoolPath 回归:callback 流的**池路径**(UI 真实流程)。
|
||||
//
|
||||
// 与 buddy poll_login 的池路径同一缺陷:complete_login 不带 account_id 时,
|
||||
// 凭据落到与占位账号无关的 `channels:<plat>` 键,池账号永远 has_credential=false。
|
||||
// 此前只有 device-poll / state-poll 分支修了,callback 分支是漏网。
|
||||
func TestTraeCallbackPoolPath(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
atomic.StoreInt32(&h.exchOK, 1)
|
||||
|
||||
// UI 流程:先建占位账号,再带 account_id 发起登录
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"trae","account_id":%q}`, aid),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
|
||||
|
||||
st, body := h.invokeCallback(map[string]string{
|
||||
"state": nonce, "refreshToken": "RT-FROM-CALLBACK", "userInfo": `{"UserID":"u9","ScreenName":"池用户"}`,
|
||||
})
|
||||
if st != 200 {
|
||||
t.Fatalf("回调应 200: %d %s", st, body)
|
||||
}
|
||||
|
||||
// ① 凭据必须落在池账号的 ref
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid)
|
||||
if !ok || v == "" {
|
||||
t.Fatalf("★ callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid)
|
||||
}
|
||||
// ② account_list 必须报 has_credential=true
|
||||
lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range lst.JSON.(map[string]interface{})["accounts"].([]interface{}) {
|
||||
a := raw.(map[string]interface{})
|
||||
if a["id"] == aid && a["has_credential"] != true {
|
||||
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// invokeCallback 直打 oauth_callback(Query 已含 state)。
|
||||
func (h *traeHarness) invokeCallback(q map[string]string) (int, string) {
|
||||
res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{
|
||||
Method: "GET", Query: q,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
|
||||
}
|
||||
|
||||
@ -338,3 +338,67 @@ return plugin
|
||||
t.Errorf("persisted n = %v after three exported calls, want 3", saved.State["n"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeHandlerIsPersisted 是真实事故的回归:channels 插件声明 hooks=[],
|
||||
// 一切状态变更(账号入池、凭据引用、模型黑名单、事件日志)都经过自己的 HTTP
|
||||
// handler。而 InvokeHandler 是唯一一条**从不 markDirty** 的分发路径 —— hook 路径
|
||||
// 有、Call 路径有,只有它漏了。后果:
|
||||
//
|
||||
// .channels.state.json 从未被写出 → 重启后账号池静默清空。
|
||||
// 运维在升级前的重启里丢掉了刚登录的账号,界面显示"已登录"但列表为空。
|
||||
//
|
||||
// billing/quota 侥幸没踩到,只因为它们有 hooks(billing)或用 Call(quota)。
|
||||
// 本测试用**无 hooks、只走 handler** 的插件复现该形态。
|
||||
func TestInvokeHandlerIsPersisted(t *testing.T) {
|
||||
ps, _ := newPersistVM(t)
|
||||
if err := ps.LoadSource("handlersonly", `
|
||||
local plugin = { name = "handlersonly", version = "1.0" }
|
||||
plugin.state = { accounts = {} }
|
||||
-- 不声明任何 hooks:状态只能经 HTTP handler 变更(channels 的形态)
|
||||
plugin.hooks = {}
|
||||
plugin.http = {
|
||||
{ path = "add", method = "POST", handler = "add" },
|
||||
}
|
||||
function plugin.add(req)
|
||||
local body = req.body or ""
|
||||
local id = body:match('"id":"([^"]+)"') or "unknown"
|
||||
plugin.state.accounts[id] = { id = id, enabled = true }
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
return plugin
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, id := range []string{"acct-1", "acct-2"} {
|
||||
resp, err := ps.InvokeHandler("handlersonly", "add", &handlerRequest{
|
||||
Method: "POST", Body: `{"id":"` + id + `"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("handler returned nil response")
|
||||
}
|
||||
}
|
||||
// Close() waits for the final flush, so no timing is involved: anything the
|
||||
// handlers did NOT mark dirty cannot appear here.
|
||||
ps.Close()
|
||||
|
||||
b, err := os.ReadFile(ps.stateFile("handlersonly"))
|
||||
if err != nil {
|
||||
t.Fatalf("★ 无 hooks 的插件经 HTTP handler 改状态后未落盘: %v\n"+
|
||||
" —— InvokeHandler 必须像 Call / hook 路径一样 markDirty,\n"+
|
||||
" 否则 channels 的账号池会在每次重启后静默清空", err)
|
||||
}
|
||||
var saved struct {
|
||||
State map[string]interface{} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accts, _ := saved.State["accounts"].(map[string]interface{})
|
||||
if len(accts) != 2 {
|
||||
t.Fatalf("落盘的 accounts = %v,应为 2 个账号", saved.State["accounts"])
|
||||
}
|
||||
}
|
||||
|
||||
@ -264,6 +264,10 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
|
||||
L.Pop(1)
|
||||
}
|
||||
if errMsg != "" {
|
||||
// A handler that raised partway may already have mutated plugin.state
|
||||
// (an account added before a later step failed), so mark it dirty before
|
||||
// bailing: the mutation it managed to make is still real.
|
||||
ps.markDirtyLocked(p)
|
||||
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
|
||||
}
|
||||
if L.GetTop() < 1 || L.IsNil(-1) {
|
||||
@ -273,6 +277,18 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
|
||||
if err := luaToJSON(L, -1, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A handler mutates plugin.state just as a hook or an exported Call does, and
|
||||
// this was the one dispatch path that never marked the plugin dirty.
|
||||
//
|
||||
// Consequence (real defect): a plugin with NO hooks — channels is exactly
|
||||
// that, it declares hooks=[] and drives everything through its own HTTP
|
||||
// routes — accumulated accounts, credential refs, model blacklists and its
|
||||
// event log entirely in memory. Nothing ever marked it dirty, so
|
||||
// .channels.state.json was never written and a restart silently wiped the
|
||||
// account pool: the operator saw "logged in", restarted for an upgrade, and
|
||||
// the account was gone. billing/quota escape this only because they declare
|
||||
// hooks and/or use Call, both of which already mark dirty.
|
||||
ps.markDirtyLocked(p)
|
||||
return parseHandlerResponse(raw)
|
||||
}
|
||||
|
||||
|
||||
@ -77,28 +77,12 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda
|
||||
-- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua
|
||||
-- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。
|
||||
local PLATFORMS = {
|
||||
-- ===== 测试/示范用占位平台(example.com,不打真实厂商)=====
|
||||
{
|
||||
id = "example",
|
||||
name = "Example 平台",
|
||||
flow = "link-poll",
|
||||
login_url = "https://auth.example.com/device",
|
||||
base_url = "https://api.example.com/v1",
|
||||
adapter = "openai",
|
||||
models = { "example-chat", "example-reason" },
|
||||
checkin = { url = "https://api.example.com/v1/api/daily/claim", method = "POST", body = "{}" },
|
||||
},
|
||||
{
|
||||
id = "googleish",
|
||||
name = "Google 系(示范 callback)",
|
||||
flow = "callback",
|
||||
login_url = "https://accounts.google.com/o/oauth2/auth",
|
||||
base_url = "https://api.googleish.example/v1",
|
||||
adapter = "openai",
|
||||
models = { "g-model-1" },
|
||||
},
|
||||
|
||||
-- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)=====
|
||||
--
|
||||
-- ⚠️ 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的
|
||||
-- 占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接
|
||||
-- 展示给运维,夹具写进来就是「生产 UI 里凭空多出 4 个假平台」。
|
||||
-- 夹具由测试经 channels/inject_platforms 端点运行时注入。
|
||||
|
||||
-- TRAE(字节跳动)—— callback 登录 + ExchangeToken 续期 + 每日签到。
|
||||
-- 这是最标准的一条,作为"同类厂商"的实现模板。
|
||||
@ -287,6 +271,30 @@ local PLATFORMS = {
|
||||
hosts = { "copilot.tencent.com", "www.codebuddy.cn" },
|
||||
base_url = "https://copilot.tencent.com",
|
||||
adapter = "openai",
|
||||
-- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。
|
||||
-- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。
|
||||
-- X-Domain 以产品配置为准(不用凭据里的 domain 快照,那是登录时的区域,
|
||||
-- 跨产品迁移后会指错区);X-Product 是部署类型(SaaS);
|
||||
-- X-Product-Code 是产品码(codebuddy);X-User-Id 取凭据的 user_id。
|
||||
headers_fn = function(accessToken, cred)
|
||||
local h = {
|
||||
["Authorization"] = "Bearer " .. accessToken,
|
||||
["Accept"] = "application/json",
|
||||
["Content-Type"] = "application/json",
|
||||
["X-Domain"] = "copilot.tencent.com",
|
||||
["X-Product"] = "SaaS",
|
||||
["X-Product-Code"] = "codebuddy",
|
||||
["User-Agent"] = "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
local uid = cred and (cred.user_id or cred.uid)
|
||||
if type(uid) == "string" and uid ~= "" then h["X-User-Id"] = uid end
|
||||
local eid = cred and cred.enterprise_id
|
||||
if type(eid) == "string" and eid ~= "" then
|
||||
h["X-Enterprise-Id"] = eid
|
||||
h["X-Tenant-Id"] = eid
|
||||
end
|
||||
return h
|
||||
end,
|
||||
models = {
|
||||
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x",
|
||||
"glm-5.3", "glm-5.3-flash", "glm-5.2",
|
||||
@ -307,37 +315,61 @@ local PLATFORMS = {
|
||||
end,
|
||||
},
|
||||
-- 余额(对齐参考 USER_RESOURCE_PATH /v2/billing/meter/get-user-resource)
|
||||
--
|
||||
-- ⚠️ 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层
|
||||
-- data 不同),这是本接口最容易解析错的地方。
|
||||
--
|
||||
-- ⚠️ 刻意**不用**服务端的 TotalDosage:它是**终身口径**且取整(实测同一
|
||||
-- 响应里 TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts[]
|
||||
-- 现算:只累加**有效包**的 CycleCapacityRemain(失效包服务端仍会返回,
|
||||
-- 但不能用于扣费,并进总额会让数字虚高),最后按两位小数规整。
|
||||
balance = {
|
||||
url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource",
|
||||
method = "POST", body = "{}",
|
||||
total_fn = function(rec)
|
||||
local d = rec.data or rec
|
||||
return tonumber(d.totalCredit or d.total_credit or d.balance or d.amount) or 0
|
||||
local data = rec.data
|
||||
if type(data) ~= "table" then return nil end
|
||||
local resp = data.Response
|
||||
if type(resp) ~= "table" then return nil end
|
||||
local inner = resp.Data
|
||||
if type(inner) ~= "table" then return nil end
|
||||
local accounts = inner.Accounts
|
||||
if type(accounts) ~= "table" then return nil end
|
||||
local now = os.time and os.time() or 0
|
||||
local sum = 0
|
||||
for _, pkg in ipairs(accounts) do
|
||||
if type(pkg) == "table" then
|
||||
-- 失效判定:Status 显式已过期,或 ExpiredTime / DeductionEndTime 已过。
|
||||
local active = true
|
||||
local st = pkg.Status
|
||||
if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end
|
||||
local function past(t)
|
||||
if type(t) ~= "string" or t == "" then return false end
|
||||
local Y, Mo, D, H, Mi, S = t:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)")
|
||||
if Y then
|
||||
local ts = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D),
|
||||
hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false })
|
||||
return ts and now >= ts
|
||||
end
|
||||
return false
|
||||
end
|
||||
if past(pkg.ExpiredTime) then active = false end
|
||||
local ded = tonumber(pkg.DeductionEndTime)
|
||||
if ded and ded > 0 then
|
||||
-- 毫秒时间戳
|
||||
if now >= math.floor(ded / 1000) then active = false end
|
||||
end
|
||||
if active then
|
||||
sum = sum + (tonumber(pkg.CycleCapacityRemain) or 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 两位小数规整(服务端精确值带浮点噪声,如 55.67000031)
|
||||
return math.floor(sum * 100 + 0.5) / 100
|
||||
end,
|
||||
},
|
||||
},
|
||||
|
||||
-- 服务端浏览器兜底示范(回环重定向类)。真实厂商若需要可改 flow="vnc"。
|
||||
{
|
||||
id = "loopbackish",
|
||||
name = "回环登录平台(示范 VNC 兜底)",
|
||||
flow = "vnc",
|
||||
needs_browser = true,
|
||||
login_url = "https://auth.loopbackish.example/authorize?redirect_uri=http://127.0.0.1:%d/callback",
|
||||
base_url = "https://api.loopbackish.example/v1",
|
||||
adapter = "openai",
|
||||
models = { "lb-model-1" },
|
||||
},
|
||||
{
|
||||
id = "cookieish",
|
||||
name = "Cookie 平台(示范 paste)",
|
||||
flow = "paste",
|
||||
base_url = "https://api.cookieish.example/v1",
|
||||
adapter = "openai",
|
||||
models = { "c-model-1" },
|
||||
checkin = { url = "https://api.cookieish.example/v1/api/credit/claim", method = "POST", body = "{}" },
|
||||
},
|
||||
|
||||
-- Gemini(Google Cloud Code)—— 标准 OAuth 授权码流 + token 交换。
|
||||
-- 照参考 gemini-oauth.ts:授权页 client_id/redirect_uri/scope 齐全,回环回调
|
||||
-- 拿到 code 后 POST oauth2.googleapis.com/token 换 token(带 code_verifier)。
|
||||
@ -735,14 +767,39 @@ local function deviceRegister(plat, payload)
|
||||
return access, acct, nil
|
||||
end
|
||||
|
||||
-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)
|
||||
-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)。
|
||||
--
|
||||
-- 先查内建目录,再查运行时注入表(plugin.state.injected_platforms)。注入表的存在
|
||||
-- 是因为**测试夹具绝不能写成生产源码**:此前 example/googleish/loopbackish/
|
||||
-- cookieish 四个夹具直接内联在 PLATFORMS 里,于是它们真的出现在生产 UI(16 个
|
||||
-- 平台里有 4 个是夹具)供运维选择。夹具属于测试,不属于发行物 —— 正确形态是
|
||||
-- 测试用自己的那几行注入(见 channels/test_platforms 端点),生产目录只留真实厂商。
|
||||
local function platformById(id)
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
if p.id == id then return p end
|
||||
end
|
||||
local injected = plugin.state and plugin.state.injected_platforms
|
||||
if type(injected) == "table" then
|
||||
for _, p in ipairs(injected) do
|
||||
if type(p) == "table" and p.id == id then return p end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- allPlatforms 返回内建目录 + 注入表(列表/遍历用)。
|
||||
local function allPlatforms()
|
||||
local out = {}
|
||||
for _, p in ipairs(PLATFORMS) do out[#out + 1] = p end
|
||||
local injected = plugin.state and plugin.state.injected_platforms
|
||||
if type(injected) == "table" then
|
||||
for _, p in ipairs(injected) do
|
||||
if type(p) == "table" and p.id then out[#out + 1] = p end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 状态
|
||||
-- ============================================================================
|
||||
@ -1562,7 +1619,7 @@ function plugin.auto_claim()
|
||||
-- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。
|
||||
plugin.state.settings = plugin.state.settings or {}
|
||||
if plugin.state.settings.auto_checkin == false then return end
|
||||
for _, plat in ipairs(PLATFORMS) do
|
||||
for _, plat in ipairs(allPlatforms()) do
|
||||
local cap = CHANNEL_CREDITS[plat.id]
|
||||
if not cap or not cap.claim then goto continue end
|
||||
local legacy = accountOf(plat.id)
|
||||
@ -1665,6 +1722,8 @@ plugin.http = {
|
||||
roles = { "admin" }, description = "新人任务状态" },
|
||||
{ path = "channels/onboarding/claim", method = "POST", handler = "onboarding_claim",
|
||||
roles = { "admin" }, description = "领取新人奖励" },
|
||||
{ path = "channels/inject_platforms", method = "POST", handler = "inject_platforms",
|
||||
roles = { "admin" }, description = "注入测试/扩展平台定义(运行时,不落盘)" },
|
||||
}
|
||||
|
||||
-- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、
|
||||
@ -1865,7 +1924,7 @@ end
|
||||
-- ============================================================================
|
||||
function plugin.provider_status(req)
|
||||
local out = {}
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
for _, p in ipairs(allPlatforms()) do
|
||||
local accts = poolList(p.id)
|
||||
local enabledN = 0
|
||||
for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end
|
||||
@ -1955,7 +2014,7 @@ function plugin.backup_export(req)
|
||||
payload.credentials = {}
|
||||
payload.modelDisabled = plugin.state.modelDisabled or {}
|
||||
payload.autoCheckin = plugin.state.settings and plugin.state.settings.auto_checkin or true
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
for _, p in ipairs(allPlatforms()) do
|
||||
for _, a in ipairs(poolList(p.id)) do
|
||||
payload.accounts[#payload.accounts+1] = a
|
||||
local tok = credentialOf(a.credentialRef)
|
||||
@ -2005,7 +2064,7 @@ function plugin.backup_import(req)
|
||||
plugin.state.settings.auto_checkin = payload.autoCheckin and true or false
|
||||
end
|
||||
-- 全部账号重新同步源
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
for _, p in ipairs(allPlatforms()) do
|
||||
for _, a in ipairs(poolList(p.id)) do syncAccountSource(p.id, a.id) end
|
||||
end
|
||||
logEvent("备份已导入,恢复 " .. n .. " 条凭据")
|
||||
@ -2027,8 +2086,18 @@ end
|
||||
-- 签到状态 + 逐账号余额(对齐参考 credits.status / credits.balances)
|
||||
-- ============================================================================
|
||||
-- fetchAccountJSON 用某账号凭据发一次请求,返回 (rec_table, nil) 或 (nil, err)。
|
||||
local function fetchAccountJSON(spec, tok)
|
||||
local headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }
|
||||
local function fetchAccountJSON(spec, tok, credTable, plat)
|
||||
local headers
|
||||
-- 头钩子位:spec(checkin_status / balance)自己的优先,否则用平台级的。
|
||||
-- buddy 在平台层声明一次,签到与余额复用同一套身份头。
|
||||
local hfn = spec.headers_fn or (plat and plat.headers_fn)
|
||||
if hfn then
|
||||
local okf, h = pcall(hfn, tok, credTable or {})
|
||||
if okf and type(h) == "table" then headers = h end
|
||||
end
|
||||
if not headers then
|
||||
headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }
|
||||
end
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = spec.url, method = spec.method or "GET",
|
||||
headers = headers, body = spec.body,
|
||||
@ -2054,7 +2123,7 @@ function plugin.credits_status(req)
|
||||
if cs then
|
||||
local tok = credentialOf(a.credentialRef)
|
||||
if tok then
|
||||
local rec, err = fetchAccountJSON(cs, tok)
|
||||
local rec, err = fetchAccountJSON(cs, tok, credentialFull(a.credentialRef), plat)
|
||||
if rec and plat.checkin_status.status_fn then
|
||||
local okf, st = pcall(plat.checkin_status.status_fn, rec)
|
||||
if okf then status = st end
|
||||
@ -2088,7 +2157,7 @@ function plugin.credits_balances(req)
|
||||
if not tok then
|
||||
entry.error = "无凭据"
|
||||
else
|
||||
local rec, err = fetchAccountJSON(b, tok)
|
||||
local rec, err = fetchAccountJSON(b, tok, credentialFull(a.credentialRef), plat)
|
||||
if err then
|
||||
entry.error = err
|
||||
elseif b.total_fn then
|
||||
@ -2217,7 +2286,7 @@ end
|
||||
-- 承担,这里改为读取当前接入状态做查询、写入复用 provider_set_enabled。
|
||||
function plugin.gateway_get_enabled(req)
|
||||
local any = false
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
for _, p in ipairs(allPlatforms()) do
|
||||
local disabled = disabledModelsOf(p.id)
|
||||
local total = #(p.models or {})
|
||||
local allOff = total > 0
|
||||
@ -2234,7 +2303,7 @@ function plugin.gateway_set_enabled(req)
|
||||
return { status = 400, json = { error = "非空布尔 enabled 必填" } }
|
||||
end
|
||||
-- 全平台整体开关(复用 provider_set_enabled 语义)
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
for _, p in ipairs(allPlatforms()) do
|
||||
plugin.provider_set_enabled({ platform = p.id, enabled = args.enabled })
|
||||
end
|
||||
logEvent("网关整体" .. (args.enabled and "开启" or "关闭"))
|
||||
@ -2244,7 +2313,7 @@ end
|
||||
-- backup.status:备份健康度(对齐参考 backup.status:账号数/无过期时间账号数)
|
||||
function plugin.backup_status(req)
|
||||
local total, noExpiry = 0, 0
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
for _, p in ipairs(allPlatforms()) do
|
||||
for _, a in ipairs(poolList(p.id)) do
|
||||
total = total + 1
|
||||
if a.expiresAt == nil then noExpiry = noExpiry + 1 end
|
||||
@ -2399,6 +2468,26 @@ function plugin.set_endpoints(req)
|
||||
if args.checkin[k] then plat.checkin[k] = tostring(args.checkin[k]) changed[#changed+1] = "checkin." .. k end
|
||||
end
|
||||
end
|
||||
-- checkin_status / balance:声明式端点覆盖。参考把各平台的计费/状态端点当
|
||||
-- 产品配置,运维要能整体改指(自建代理、镜像、测试假上游)。此前只覆盖了
|
||||
-- 旧的 plat.checkin,buddy 这类"声明 checkin_status/balance"的平台无从覆盖 ——
|
||||
-- 自动化测试也就无法把它们的端点指到假上游。
|
||||
if type(args.checkin_status) == "table" and plat.checkin_status then
|
||||
for _, k in ipairs({ "url", "method", "body" }) do
|
||||
if args.checkin_status[k] then
|
||||
plat.checkin_status[k] = tostring(args.checkin_status[k])
|
||||
changed[#changed+1] = "checkin_status." .. k
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(args.balance) == "table" and plat.balance then
|
||||
for _, k in ipairs({ "url", "method", "body" }) do
|
||||
if args.balance[k] then
|
||||
plat.balance[k] = tostring(args.balance[k])
|
||||
changed[#changed+1] = "balance." .. k
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(args.callback) == "table" and plat.callback then
|
||||
for _, k in ipairs({ "exchange_url" }) do
|
||||
if args.callback[k] then plat.callback[k] = tostring(args.callback[k]) changed[#changed+1] = "callback." .. k end
|
||||
@ -2411,7 +2500,7 @@ end
|
||||
|
||||
function plugin.list_platforms(req)
|
||||
local out = {}
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
for _, p in ipairs(allPlatforms()) do
|
||||
local acct = accountOf(p.id) or {}
|
||||
local has = credentialOf("channels:" .. p.id)
|
||||
out[#out + 1] = {
|
||||
@ -2425,6 +2514,55 @@ function plugin.list_platforms(req)
|
||||
return { status = 200, json = { platforms = out } }
|
||||
end
|
||||
|
||||
-- inject_platforms:**运行时**注入平台定义(测试夹具 / 运维扩展)。
|
||||
--
|
||||
-- 为何存在:测试夹具(打 example.com 的假平台、验证各 flow 的占位平台)
|
||||
-- 绝不能写成生产源码 —— PLATFORMS 会被 UI/status/list_platforms 直接展示,
|
||||
-- 夹具内联进去就是「生产 UI 里实际出现 4 个假平台」(真实踩过)。
|
||||
-- 正确分层:生产目录只留真实厂商,夹具由测试运行时注入。
|
||||
--
|
||||
-- 语义:整表替换(与 set_endpoints 的按字段覆盖不同)—— 注入是声明式的
|
||||
-- “这就是本轮我要用的目录”,避免测试之间残留状态。传空数组即清空。
|
||||
-- 容量上限:注入属于运维/测试能力,不承担生产目录职责,限 32 条够用。
|
||||
local INJECT_PLATFORM_LIMIT = 32
|
||||
function plugin.inject_platforms(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platforms == nil then args = req end
|
||||
local list = args and (args.platforms or args.items)
|
||||
if type(list) ~= "table" then
|
||||
return { status = 400, json = { error = "platforms 必须是数组" } }
|
||||
end
|
||||
if #list > INJECT_PLATFORM_LIMIT then
|
||||
return { status = 400, json = { error = "最多注入 " .. INJECT_PLATFORM_LIMIT .. " 个平台" } }
|
||||
end
|
||||
-- 校验:每条必须有 id,且不得与内建目录重名(注入不能劫持真实厂商)。
|
||||
local builtin = {}
|
||||
for _, p in ipairs(PLATFORMS) do builtin[p.id] = true end
|
||||
local clean, seen = {}, {}
|
||||
for _, p in ipairs(list) do
|
||||
if type(p) ~= "table" or type(p.id) ~= "string" or p.id == "" then
|
||||
return { status = 400, json = { error = "每个平台必须有 id" } }
|
||||
end
|
||||
if builtin[p.id] then
|
||||
return { status = 400, json = { error = "不得覆盖内建平台: " .. p.id } }
|
||||
end
|
||||
if seen[p.id] then
|
||||
return { status = 400, json = { error = "重复的 id: " .. p.id } }
|
||||
end
|
||||
seen[p.id] = true
|
||||
clean[#clean + 1] = p
|
||||
end
|
||||
plugin.state.injected_platforms = clean
|
||||
if #clean == 0 then
|
||||
logEvent("已清空注入平台")
|
||||
else
|
||||
local ids = {}
|
||||
for _, p in ipairs(clean) do ids[#ids + 1] = p.id end
|
||||
logEvent("已注入平台 " .. #clean .. " 个: " .. table.concat(ids, ","))
|
||||
end
|
||||
return { status = 200, json = { ok = true, injected = #clean } }
|
||||
end
|
||||
|
||||
function plugin.status_page(req)
|
||||
local r = __llmsproxy_kernel.call("status", {})
|
||||
return { status = 200, json = {
|
||||
@ -2506,7 +2644,12 @@ function plugin.oauth_callback(req)
|
||||
return { status = 400, body = cerr }
|
||||
end
|
||||
|
||||
local res = plugin.complete_login({ platform = s.platform, credential = credential, account = account })
|
||||
-- 与 poll_login 的两条完成分支同一纪律:页面流程先建了占位账号
|
||||
-- (credentialRef=channels:<plat>:<aid>),完成时必须带 account_id,
|
||||
-- 否则凭据落到与占位账号无关的 `channels:<plat>` 键 → 池账号永远
|
||||
-- has_credential=false(同一缺陷在 callback 路径的漏网)。
|
||||
local res = plugin.complete_login({ platform = s.platform, credential = credential,
|
||||
account = account, account_id = s.account_id })
|
||||
if res.json and res.json.error then
|
||||
return { status = 400, body = res.json.error }
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user