diff --git a/internal/core/channels_pool_test.go b/internal/core/channels_pool_test.go index 92d98c3..3692b1a 100644 --- a/internal/core/channels_pool_test.go +++ b/internal/core/channels_pool_test.go @@ -194,3 +194,129 @@ func TestChannelsAccountPoolBehavior(t *testing.T) { } }) } + +// TestChannelsExtendedRPCBehavior:对齐参考剩余 RPC 面(credits.status/balances、 +// account.reset/retest、model.setDisabledMany/clearDead、provider 排序、 +// gateway/backup.status、usage.badgePreference、onboarding)。 +func TestChannelsExtendedRPCBehavior(t *testing.T) { + _, ps := poolCore(t) + + t.Run("credits_balances_无凭据报错_有凭据解析", func(t *testing.T) { + m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"b1"}`) + aid, _ := m["account_id"].(string) + j := invoke(t, ps, "credits_balances", `{"platform":"minimax"}`) + var row map[string]interface{} + for _, a := range asArr(j["accounts"]) { + x := a.(map[string]interface{}) + if x["accountId"] == aid { + row = x + } + } + if row == nil { + t.Fatal("balances 应含该账号") + } + if row["balance"] != nil || row["error"] == nil { + t.Fatalf("无凭据应 error 而非编造余额: %v", row) + } + }) + + t.Run("credits_status_无端点平台如实null", func(t *testing.T) { + m := invoke(t, ps, "account_add", `{"platform":"opencode","nickname":"oc1"}`) + aid, _ := m["account_id"].(string) + j := invoke(t, ps, "credits_status", `{"platform":"opencode"}`) + for _, a := range asArr(j["accounts"]) { + x := a.(map[string]interface{}) + if x["accountId"] == aid && x["status"] != nil { + t.Fatalf("opencode 无状态端点应 null: %v", x) + } + } + }) + + t.Run("account_reset_clears", func(t *testing.T) { + invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"r1"}`) + j := invoke(t, ps, "account_reset", `{"platform":"minimax","account_id":"__all__"}`) + if j["ok"] != true { + t.Fatalf("resetAll: %v", j) + } + }) + + t.Run("model_set_disabled_many", func(t *testing.T) { + invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":true}`) + j := invoke(t, ps, "model_list", `{"platform":"minimax"}`) + off := 0 + for _, x := range asArr(j["models"]) { + mm := x.(map[string]interface{}) + if mm["disabled"] == true { + off++ + } + } + if off != 2 { + t.Fatalf("批量关2模型后应2个disabled, 实得 %d", off) + } + invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":false}`) + }) + + t.Run("model_clear_dead_只清黑名单里已下线的", func(t *testing.T) { + invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) + j := invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) + if n := len(asArr(j["removed"])); n != 0 { + t.Fatalf("在册模型不算死模型, 实得 %d", n) + } + // 手动放一个不在 models 表里的黑名单项 + invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["ghost-model"],"disabled":true}`) + j = invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) + if n := len(asArr(j["removed"])); n != 1 { + t.Fatalf("死模型应被清: %v", j) + } + invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":false}`) + }) + + t.Run("provider_order", func(t *testing.T) { + invoke(t, ps, "provider_set_order", `{"order":["minimax","trae","bogus"]}`) + j := invoke(t, ps, "provider_get_order", `{}`) + order := asArr(j["order"]) + if len(order) != 2 { + t.Fatalf("bogus 应被过滤, 实得 %v", order) + } + if order[0] != "minimax" { + t.Fatalf("顺序应保留: %v", order) + } + }) + + t.Run("backup_status", func(t *testing.T) { + invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"s1"}`) + j := invoke(t, ps, "backup_status", `{}`) + if n, _ := j["accounts"].(float64); n < 1 { + t.Fatalf("backup_status 应统计账号: %v", j) + } + }) + + t.Run("badge_preference_非法值拒绝", func(t *testing.T) { + j := invoke(t, ps, "usage_badge_preference", `{"preference":"bogus"}`) + if j["error"] == nil { + t.Fatalf("非法偏好应拒绝: %v", j) + } + j = invoke(t, ps, "usage_badge_preference", `{"preference":"credits"}`) + if j["preference"] != "credits" { + t.Fatalf("合法偏好应保存: %v", j) + } + }) + + t.Run("onboarding_不支持平台如实报", func(t *testing.T) { + j := invoke(t, ps, "onboarding_status", `{"platform":"minimax"}`) + if j["supported"] != false { + t.Fatalf("minimax 无新人任务应 supported=false: %v", j) + } + }) + + t.Run("permanent_lock_读写", func(t *testing.T) { + j := invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy","locked":true}`) + if j["locked"] != true { + t.Fatalf("锁定应生效: %v", j) + } + j = invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy"}`) + if j["locked"] != true { + t.Fatalf("只读应回显锁定: %v", j) + } + }) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 28e6f58..8399cec 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "model_list", "model_set_disabled", "model_set_all_disabled", "provider_status", "provider_set_enabled", "credits_claim_all", "backup_export", "backup_import", "auto_checkin" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "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" } @@ -121,6 +121,34 @@ local PLATFORMS = { url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim", method = "POST", body = "{}", }, + -- 签到状态(对齐参考:POST /checkin_credits/status → {checked_in, credits, enable}) + checkin_status = { + url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/status", + method = "POST", body = "{}", + status_fn = function(rec) + return { + active = rec.enable == true, + todayCheckedIn = rec.checked_in == true, + dailyCredit = tonumber(rec.credits) or 0, + } + end, + }, + -- 余额(对齐参考:POST /trae/api/v2/pay/ide_user_ent_usage → remain = Σ(credits_limit - credits_amount)) + balance = { + url = "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage", + method = "POST", body = '{"require_usage":true,"req_source":2}', + total_fn = function(rec) + local sum = 0 + local list = rec.user_entitlement_pack_list or rec.userEntitlementPackList or {} + if type(list) ~= "table" then return 0 end + for _, p in ipairs(list) do + local lim = tonumber(p.credits_limit) or tonumber(p.creditsLimit) or 0 + local amt = tonumber(p.credits_amount) or tonumber(p.creditsAmount) or 0 + sum = sum + math.max(0, lim - amt) + end + return sum + end, + }, -- callback 解析模式(移植自参考 trae-oauth.ts parseTraeCallbackDetailed): -- trae token 直传:query 带 refreshToken / userJwt(JSON) / userInfo(JSON)。 -- refreshToken 存在时调 ExchangeToken 换新(轮换旧 refresh); @@ -162,6 +190,25 @@ local PLATFORMS = { url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/claim", method = "POST", body = "{}", }, + -- 签到状态预检(对齐参考 minimax-credits.ts,面板显示今日是否已签到) + checkin_status = { + url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/status", + method = "GET", + }, + -- 余额(对齐参考:GET /credit/details,total = Σ details[].remaining_amount) + balance = { + url = "https://agent.minimax.cn/minimax-cloud/api/v1/credit/details", + method = "GET", + total_fn = function(rec) + -- ⚠️ total_count 是记录条数不是余额(参考注释踩过的坑)。余额=Σ remaining_amount。 + if type(rec) ~= "table" or type(rec.details) ~= "table" then return 0 end + local sum = 0 + for _, d in ipairs(rec.details) do + sum = sum + (tonumber(d.remaining_amount) or 0) + end + return sum + end, + }, }, -- Cline —— WorkOS 设备码流(照参考 cline-oauth.ts / cline-product.ts 逆向)。 @@ -199,7 +246,26 @@ local PLATFORMS = { models = { "stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor", }, - -- 参考 cline-credits.ts:sidecar 里扫不到 checkin/campaign 端点 —— 无每日领取 + -- 参考 cline-credits.ts:sidecar 里扫不到 checkin/campaign 端点 —— 无每日领取。 + -- 余额(对齐参考 cline-quota.ts:GET usage-limits,单位 dollars,show percentUsed) + credit_unit = "dollars", + balance = { + url = "https://api.cline.bot/api/v1/users/me/plan/usage-limits", + method = "GET", + total_fn = function(rec) + local d = rec.data or rec + local limits = d.limits or {} + if type(limits) ~= "table" then return 0 end + -- 取剩余百分比最小的那个限额的 (100-percentUsed);没有就 0 + local minRemain + for _, l in ipairs(limits) do + local used = tonumber(l.percentUsed or l.percent_used) or 0 + local rem = 100 - used + if not minRemain or rem < minRemain then minRemain = rem end + end + return minRemain or 0 + end, + }, }, -- Buddy(腾讯 CodeBuddy)—— state 轮询流(照参考 buddy-oauth.ts 逆向)。 @@ -225,6 +291,30 @@ local PLATFORMS = { "deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x", "glm-5.3", "glm-5.3-flash", "glm-5.2", }, + -- 签到状态(对齐参考 credits.ts:必须用 checkin-activity-status,不能用 + -- checkin-status —— 后者返回占位数据 active:false,是个坑) + checkin_status = { + url = "https://copilot.tencent.com/v2/billing/meter/checkin-activity-status", + method = "POST", body = "{}", + status_fn = function(rec) + local d = rec.data or rec + return { + active = d.active == true, + todayCheckedIn = d.todayCheckedIn == true or d.today_checked_in == true, + streakDays = tonumber(d.streakDays or d.streak_days) or 0, + todayCredit = tonumber(d.todayCredit or d.today_credit) or 0, + } + end, + }, + -- 余额(对齐参考 USER_RESOURCE_PATH /v2/billing/meter/get-user-resource) + 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 + end, + }, }, -- 服务端浏览器兜底示范(回环重定向类)。真实厂商若需要可改 flow="vnc"。 @@ -293,6 +383,15 @@ local PLATFORMS = { base_url = "https://lobsterai-server.youdao.com", adapter = "openai", models = { "kimi-k3", "glm-5.2", "deepseek-v4-flash" }, + -- 余额(对齐参考:GET /api/user/profile-summary) + balance = { + url = "https://lobsterai-server.youdao.com/api/user/profile-summary", + method = "GET", + total_fn = function(rec) + local d = rec.data or rec + return tonumber(d.availableCredits or d.available_credits or d.credits or d.balance) or 0 + end, + }, }, -- OpenCode —— 直接粘贴 API key(无登录流,参考 opencode-auth.ts)。 @@ -337,6 +436,18 @@ local PLATFORMS = { base_url = "https://api2-v2.qoder.sh", adapter = "openai", models = { "qoder-default" }, + -- 余额(对齐参考 qoder-credits.ts:GET /sash/api/v2/me/usage,余额=userQuota.remaining + -- + addOnQuota.remaining,走 openApiBase) + balance = { + url = "https://openapi.qoder.sh/sash/api/v2/me/usage", + method = "GET", + total_fn = function(rec) + local d = rec.data or rec + local u = (d.userQuota or {}).remaining or 0 + local a = (d.addOnQuota or {}).remaining or 0 + return (tonumber(u) or 0) + (tonumber(a) or 0) + end, + }, }, -- ZCode —— cli/init + captcha(照参考 zcode-auth.ts 逆向)。 @@ -379,6 +490,17 @@ local PLATFORMS = { base_url = "https://raccoon.sensetime.com", adapter = "openai", models = { "raccoon-default" }, + -- 余额(对齐参考 raccoon-credits.ts:GET {apiBase}/points/v1/balance → data.available_points) + balance = { + url = "https://raccoon.sensetime.com/points/v1/balance", + method = "GET", + total_fn = function(rec) + local d = rec.data or rec + return tonumber(d.available_points or d.availablePoints) or 0 + end, + }, + -- 一次性新人奖励(对齐 onboarding:桌面登录奖励 3000 分) + onboarding = { reward_points = 3000 }, }, } @@ -1296,6 +1418,40 @@ plugin.http = { roles = { "admin" }, description = "导入账号备份" }, { path = "channels/auto_checkin", method = "POST", handler = "auto_checkin", roles = { "admin" }, description = "自动签到开关读写" }, + { path = "channels/credits/status", method = "POST", handler = "credits_status", + 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", + roles = { "admin" }, description = "永久积分锁定" }, + { path = "channels/account/reset", method = "POST", handler = "account_reset", + roles = { "admin" }, description = "清账号限流标记" }, + { path = "channels/account/retest", method = "POST", handler = "account_retest", + roles = { "admin" }, description = "重测账号" }, + { path = "channels/model/set_disabled_many", method = "POST", handler = "model_set_disabled_many", + roles = { "admin" }, description = "批量开关模型" }, + { path = "channels/model/clear_dead", method = "POST", handler = "model_clear_dead", + roles = { "admin" }, description = "清死模型" }, + { path = "channels/provider/get_order", method = "POST", handler = "provider_get_order", + roles = { "admin" }, description = "供应商显示顺序" }, + { path = "channels/provider/set_order", method = "POST", handler = "provider_set_order", + roles = { "admin" }, description = "设置供应商显示顺序" }, + { path = "channels/gateway/get_enabled", method = "POST", handler = "gateway_get_enabled", + roles = { "admin" }, description = "网关开关状态" }, + { path = "channels/gateway/set_enabled", method = "POST", handler = "gateway_set_enabled", + roles = { "admin" }, description = "网关整体开关" }, + { path = "channels/backup/status", method = "POST", handler = "backup_status", + roles = { "admin" }, description = "备份健康度" }, + { path = "channels/usage/token_ledger", method = "POST", handler = "usage_token_ledger", + roles = { "admin" }, description = "token 用量快照" }, + { path = "channels/usage/badge", method = "POST", handler = "usage_badge", + roles = { "admin" }, description = "渠道徽标" }, + { path = "channels/usage/badge_preference", method = "POST", handler = "usage_badge_preference", + roles = { "admin" }, description = "徽标显示偏好" }, + { path = "channels/onboarding/status", method = "POST", handler = "onboarding_status", + roles = { "admin" }, description = "新人任务状态" }, + { path = "channels/onboarding/claim", method = "POST", handler = "onboarding_claim", + roles = { "admin" }, description = "领取新人奖励" }, } -- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、 @@ -1653,6 +1809,352 @@ function plugin.auto_checkin(req) return { status = 200, json = { ok = true, autoCheckin = plugin.state.settings.auto_checkin ~= false } } 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 resp, err = __llmsproxy_http({ + url = spec.url, method = spec.method or "GET", + headers = headers, body = spec.body, + }) + if err then return nil, err end + if resp.status ~= 200 then return nil, "HTTP " .. tostring(resp.status) end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return nil, "响应无法解析" end + return rec, nil +end + +-- credits_status:逐账号查签到状态(对齐参考 credits.status; +-- 无状态端点的平台如实返回 null,与参考对 CodeArts/LobsterAI 的处理一致) +function plugin.credits_status(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 out = {} + for _, a in ipairs(poolList(plat.id)) do + local status = nil + local cs = plat.checkin_status + if cs then + local tok = __llmsproxy_secrets.get(a.credentialRef) + if tok then + local rec, err = fetchAccountJSON(cs, tok) + 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 + elseif err then + status = { error = err } + end + end + end + out[#out+1] = { accountId = a.id, nickname = a.nickname, status = status } + end + return { status = 200, json = { ok = true, accounts = out } } +end + +-- credits_balances:逐账号查余额(对齐参考 credits.balances; +-- 无 balance 端点的平台返回 {balance=null, error="平台未声明余额端点"}, +-- 不编造数字 —— 与参考注释「不伪装成余额」同语义) +function plugin.credits_balances(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 cap = CHANNEL_CREDITS[plat.id] or {} + local out = {} + for _, a in ipairs(poolList(plat.id)) do + local entry = { accountId = a.id, balance = nil, error = nil } + local b = plat.balance + if not b then + entry.error = "平台未声明余额端点" + else + local tok = __llmsproxy_secrets.get(a.credentialRef) + if not tok then + entry.error = "无凭据" + else + local rec, err = fetchAccountJSON(b, tok) + if err then + entry.error = err + elseif b.total_fn then + local okf, total = pcall(b.total_fn, rec) + if okf and type(total) == "number" then + entry.balance = { total = total, unit = cap.unit or "credits" } + else + entry.error = "余额解析失败" + end + end + end + end + out[#out+1] = entry + end + return { status = 200, json = { ok = true, accounts = out } } +end + +-- 永久积分锁定(对齐参考 credits.permanentLock:锁定后仅使用 windowDays 内到期的积分) +function plugin.credits_permanent_lock(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 + plugin.state.settings = plugin.state.settings or {} + if args.locked ~= nil then + if type(args.locked) ~= "boolean" then + return { status = 400, json = { error = "locked 必须是布尔值" } } + end + plugin.state.settings["lock_" .. plat.id] = args.locked + logEvent("永久积分" .. (args.locked and "锁定 " or "解除 ") .. plat.id) + end + return { status = 200, json = { ok = true, locked = plugin.state.settings["lock_" .. plat.id] == true, + windowDays = 3 } } +end + +-- account.reset / resetAll:清除账号限流标记(对齐参考 account.reset) +function plugin.account_reset(req) + local args = bodyOf(req) + if args.account_id == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + if args.account_id == "__all__" then + for _, a in ipairs(poolList(plat.id)) do a.rateLimited = nil poolAdd(plat.id, a) end + return { status = 200, json = { ok = true, reset = #poolList(plat.id) } } + end + local acct = poolGet(plat.id, args and args.account_id) + if not acct then return { status = 400, json = { error = "账号不存在" } } end + acct.rateLimited = nil + poolAdd(plat.id, acct) + return { status = 200, json = { ok = true } } +end + +-- account.retest / retestAll:重测账号(对齐参考 account.retest:触发条件不同,实现同 test) +function plugin.account_retest(req) + local args = bodyOf(req) + if args.account_id == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + if args.account_id == "__all__" then + local results = {} + for _, a in ipairs(poolList(plat.id)) do + local r = plugin.account_test({ platform = plat.id, account_id = a.id }) + results[#results+1] = { accountId = a.id, ok = r.json and r.json.ok and true or false, + error = r.json and r.json.error or nil } + end + return { status = 200, json = { ok = true, results = results } } + end + return plugin.account_test({ platform = plat.id, account_id = args.account_id }) +end + +-- model.setDisabledMany:批量开关(对齐参考 model.setDisabledMany) +function plugin.model_set_disabled_many(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 ids = args and args.model_ids + if type(ids) ~= "table" then return { status = 400, json = { error = "model_ids 必须是数组" } } end + local disabled = disabledModelsOf(plat.id) + for _, mid in ipairs(ids) do + if args.disabled then disabled[tostring(mid)] = true else disabled[tostring(mid)] = nil end + end + for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end + return { status = 200, json = { ok = true } } +end + +-- model.clearDead:清死模型(对齐参考 model.clearDead:从黑名单移除已下线的 id) +function plugin.model_clear_dead(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 disabled = disabledModelsOf(plat.id) + local removed = {} + for mid in pairs(disabled) do + local alive = false + for _, m in ipairs(plat.models or {}) do if m == mid then alive = true break end end + if not alive then disabled[mid] = nil removed[#removed+1] = mid end + end + for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end + return { status = 200, json = { ok = true, removed = removed } } +end + +-- 供应商排序(对齐参考 provider.getOrder/setOrder) +function plugin.provider_get_order(req) + local order = plugin.state.providerOrder + if type(order) ~= "table" then order = {} end + return { status = 200, json = { ok = true, order = order } } +end + +function plugin.provider_set_order(req) + local args = bodyOf(req) + local order = args and args.order + if type(order) ~= "table" then return { status = 400, json = { error = "order 必须是字符串数组" } } end + local cleaned = {} + for _, id in ipairs(order) do + if platformById(tostring(id)) then cleaned[#cleaned+1] = tostring(id) end + end + plugin.state.providerOrder = cleaned + return { status = 200, json = { ok = true } } +end + +-- 网关开关(对齐参考 gateway.getEnabled/setEnabled:本插件的一个上游源面开关) +-- 这里网关开关语义=channels 提供的源是否参与调度;用 set_plugin_enabled 内核动作 +-- 不合适(那是插件级),我们用「全部模型黑名单+全部账号停用」已由 provider_set_enabled +-- 承担,这里改为读取当前接入状态做查询、写入复用 provider_set_enabled。 +function plugin.gateway_get_enabled(req) + local any = false + for _, p in ipairs(PLATFORMS) do + local disabled = disabledModelsOf(p.id) + local total = #(p.models or {}) + local allOff = total > 0 + for _, m in ipairs(p.models or {}) do if not disabled[m] then allOff = false break end end + if not allOff then any = true break end + end + return { status = 200, json = { ok = true, enabled = any, + apiKey = nil, address = nil, models = { source = "channels" } } } +end + +function plugin.gateway_set_enabled(req) + local args = bodyOf(req) + if type(args.enabled) ~= "boolean" then + return { status = 400, json = { error = "非空布尔 enabled 必填" } } + end + -- 全平台整体开关(复用 provider_set_enabled 语义) + for _, p in ipairs(PLATFORMS) do + plugin.provider_set_enabled({ platform = p.id, enabled = args.enabled }) + end + logEvent("网关整体" .. (args.enabled and "开启" or "关闭")) + return { status = 200, json = { ok = true } } +end + +-- backup.status:备份健康度(对齐参考 backup.status:账号数/无过期时间账号数) +function plugin.backup_status(req) + local total, noExpiry = 0, 0 + for _, p in ipairs(PLATFORMS) do + for _, a in ipairs(poolList(p.id)) do + total = total + 1 + if a.expiresAt == nil then noExpiry = noExpiry + 1 end + end + end + return { status = 200, json = { ok = true, accounts = total, withoutExpiry = noExpiry } } +end + +-- usage.tokenLedger:本网关的 token 用量快照(对齐参考 usage.tokenLedger)。 +-- 数据源:网关 /api/stats(插件走回环取,与 usage 插件同思路)。 +function plugin.usage_token_ledger(req) + local args = bodyOf(req) + local limit = tonumber(args and args.limit) or 50 + local key = (req and req.key) or "" + local url = "http://127.0.0.1:8081/api/stats?period=all&limit=" .. tostring(limit) + if key ~= "" then + url = url .. "&key=" .. (key:len() <= 6 and key or ("***" .. key:sub(-6))) + end + local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000 }) + if err or not resp or resp.status ~= 200 then + return { status = 200, json = { ok = true, snapshot = { available = false, + error = err or (resp and tostring(resp.status) or "no response") } } } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then + return { status = 200, json = { ok = true, snapshot = { available = false, error = "stats 无法解析" } } } + end + -- 汇总 token 总量(兼容 stats 的不同形状) + local prompt, completion, requests = 0, 0, 0 + local rows = rec.requests or rec.rows or rec.data or {} + if type(rows) == "table" then + for _, r in ipairs(rows) do + if type(r) == "table" then + prompt = prompt + (tonumber(r.prompt_tokens) or 0) + completion = completion + (tonumber(r.completion_tokens) or 0) + requests = requests + 1 + end + end + end + return { status = 200, json = { ok = true, snapshot = { available = true, + tokens = { prompt = prompt, completion = completion, total = prompt + completion }, + requests = requests, raw = rec } } } +end + +-- usage.badge:渠道徽标(对齐参考 usage.badge;用 credits_balances 的汇总) +function plugin.usage_badge(req) + local args = bodyOf(req) + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "provider 不能为空" } } end + local j = plugin.credits_balances({ platform = plat.id }) + local accounts = asArrLua(j.json and j.json.accounts) + local total, known = 0, false + for _, a in ipairs(accounts) do + if type(a.balance) == "table" and type(a.balance.total) == "number" then + total = total + a.balance.total + known = true + end + end + return { status = 200, json = { ok = true, badge = known + and { kind = "credits", total = total, unit = (CHANNEL_CREDITS[plat.id] or {}).unit or "credits" } + or { kind = "unavailable" } } } +end + +-- usage.badgePreference:徽标显示偏好(读/写,全局一个;非法值拒绝不回落) +function plugin.usage_badge_preference(req) + plugin.state.settings = plugin.state.settings or {} + local args = bodyOf(req) + if args.preference ~= nil then + local valid = { auto = true, subscription = true, credits = true } + if not valid[args.preference] then + return { status = 400, json = { error = "preference 必须是 auto/subscription/credits 之一" } } + end + plugin.state.settings.badge_preference = args.preference + end + return { status = 200, json = { ok = true, preference = plugin.state.settings.badge_preference or "auto" } } +end + +-- onboarding.status / claim:新人任务(对齐参考 onboarding.*;仅 raccoon 有实测端点) +function plugin.onboarding_status(req) + local args = bodyOf(req) + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local ob = plat.onboarding + if not ob then + return { status = 200, json = { ok = true, supported = false, + message = plat.name .. " 未声明新人任务端点(参考实现同样如实返回 null)" } } + end + -- 已领取判定:查 state + local claimed = plugin.state.settings and plugin.state.settings["onboard_" .. plat.id] == true + return { status = 200, json = { ok = true, supported = true, + tasks = { desktop_login_reward = claimed }, earned = claimed and ob.reward_points or 0, + total = ob.reward_points } } +end + +function plugin.onboarding_claim(req) + local args = bodyOf(req) + local plat = platformById(args and args.platform) + if not plat or not plat.onboarding then + return { status = 400, json = { error = "该平台无新人任务" } } + end + -- raccoon 实测端点:POST {apiBase}/login/points/grant + local tok = __llmsproxy_secrets.get("channels:" .. plat.id) + if not tok then return { status = 400, json = { error = "未登录" } } end + local resp, err = __llmsproxy_http({ + url = plat.base_url .. "/login/points/grant", method = "POST", + headers = { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }, + body = "{}", + }) + if err then return { status = 400, json = { error = err } } end + if resp.status ~= 200 then return { status = 400, json = { error = "HTTP " .. tostring(resp.status) } } end + plugin.state.settings = plugin.state.settings or {} + plugin.state.settings["onboard_" .. plat.id] = true + logEvent("新人奖励已领 " .. plat.id) + return { status = 200, json = { ok = true, points = plat.onboarding.reward_points } } +end + +-- asArrLua(Lua 侧的空表规整):桥接层 Go→Lua 不涉及,但 handler 内部把 +-- credits_balances 的 json.accounts 再传给别的 handler 时要能处理空表。 +function asArrLua(t) + if type(t) ~= "table" then return {} end + if #t > 0 then return t end + return {} +end + function plugin.set_endpoints(req) local args = bodyOf(req) if args.platform == nil then args = req end @@ -1890,6 +2392,7 @@ plugin.ui = {
暂无账号。点「添加账号(登录)」开始。
' : accts.map((a) => { @@ -2031,8 +2542,12 @@ plugin.ui = { ? (a.enabled !== false ? '启用' : '停用') : '待凭据'; const claimTxt = a.last_claim ? ('上次领取 ' + new Date(a.last_claim*1000).toLocaleString() + (a.last_claim_summary ? ' · ' + esc(a.last_claim_summary) : '')) : ''; + const b = balances[a.id]; + const balTxt = !b ? '' : b.balance && typeof b.balance.total === 'number' + ? '' + esc(b.balance.unit || 'credits') + ' ' + b.balance.total + '' + : '余额不可用'; return '