mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(channels): 对外功能面全量对齐参考 channel-pack RPC + 逐平台余额/签到状态端点
此前只实现了参考 RPC 面的一部分(账号池/模型开关/供应商/备份)。本次按
用户要求把对外呈现的功能补齐到与参考 channel-pack-rpc.ts 一致,并逐平台
移植真实余额/签到状态端点(不再'选一部分')。
## 新增 RPC(对齐 channel-pack-rpc.ts 的 case 清单)
credits.status 逐账号签到状态(平台声明 checkin_status 时查,status_fn 解析;
无端点平台如实 null,与参考对 CodeArts/LobsterAI 处理一致)
credits.balances 逐账号余额(平台声明 balance 时查,total_fn 解析;无端点/无凭据
返回 {balance=null,error=...} 不编造数字 —— 对齐参考'不伪装余额')
credits.permanentLock 永久积分锁定读写(buddy/loomy 等)
account.reset/retest 清限流标记 / 重测(支持 __all__ 批量)
model.setDisabledMany 批量模型开关
model.clearDead 清黑名单里已下线的死模型(只清不在 models 表的 id)
provider.getOrder/setOrder 供应商显示顺序(bogus id 过滤)
gateway.getEnabled/setEnabled 网关整体开关(复用 provider_set_enabled 语义)
backup.status 备份健康度(账号数/无过期时间账号数)
usage.tokenLedger 本网关 token 用量快照(数据源 /api/stats,与 usage 插件同思路)
usage.badge 渠道徽标(credits.balances 汇总)
usage.badgePreference 徽标偏好读写(非法值拒绝不回落,对齐参考注释)
onboarding.status/claim 新人任务(raccoon 桌面登录奖励 3000 分实测端点)
## 逐平台真实余额/签到端点(照各 *-credits.ts 逐字移植)
minimax signin/status 预检 + credit/details 余额(total=Σ remaining_amount,
避开参考踩过的 total_count 误读坑)
trae checkin_credits/status + ide_user_ent_usage(remain=Σ(limit-amount))
buddy checkin-activity-status(不是 checkin-status 占位坑)+ get-user-resource
cline usage-limits(dollars,取剩余百分比最小的限额)
lobsterai profile-summary;qoder me/usage(userQuota+addOnQuota);raccoon points/v1/balance
每平台 CHANNEL_CREDITS 能力表声明 unit/claim(对齐参考同名表)。
## WebUI 补齐
账号行展示逐账号余额(credits.balances);详情动作区加「重置限流」「清死模型」;
顶栏加「Token 用量」弹窗(usage.tokenLedger)。node --check 过。
## 测试
TestChannelsExtendedRPCBehavior 10 子项全过:balances 无凭据报错不编造、status
无端点 null、reset、setDisabledMany、clearDead 只清死模型、provider order 过滤、
backup.status、badgePreference 非法拒绝、onboarding supported=false、permanentLock 读写。
全量 8 包回归通过。
已知未移植(如实说明,均需本机无法提供的运行时):captcha.*(zcode 验证码载体,
需浏览器端求解器)、login.sendSms/submitSms(loomy 短信,备用路径)、
cline.requestLog/usage.tokenLedgerHistory(历史聚合,内核未落盘日维度表)。
This commit is contained in:
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<input id="ch-search" placeholder="搜索渠道或供应商…" style="padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);min-width:200px">
|
||||
<div id="ch-filters" style="display:flex;gap:6px"></div>
|
||||
<button class="small" onclick="chLedger()">Token 用量</button>
|
||||
<button class="small" onclick="chBackup(1)">导出备份</button>
|
||||
<button class="small" onclick="document.getElementById('ch-import-file').click()">导入备份</button>
|
||||
<input type="file" id="ch-import-file" hidden accept=".json,application/json" onchange="chImportFile(event)">
|
||||
@ -2014,6 +2517,8 @@ plugin.ui = {
|
||||
acts.push('<button class="small" onclick="chStartLogin()">添加账号(登录)</button>');
|
||||
if ((s.accounts.total||0) > 0) acts.push('<button class="small" onclick="chRefreshAll()">刷新全部账号</button>');
|
||||
acts.push('<button class="small" onclick="chClaimAll()">一键领取额度</button>');
|
||||
acts.push('<button class="small" onclick="chResetAll()">重置限流标记</button>');
|
||||
acts.push('<button class="small" onclick="chClearDead()">清死模型</button>');
|
||||
acts.push('<button class="small" onclick="chProviderToggle(' + (!s.closed) + ')">' + (s.closed ? "启用渠道" : "关闭渠道") + '</button>');
|
||||
q("#ch-detail-actions").innerHTML = acts.join(" ");
|
||||
q("#ch-notice").textContent = "";
|
||||
@ -2024,6 +2529,12 @@ plugin.ui = {
|
||||
const j = await call("channels/account/list", "POST", { platform: id });
|
||||
const accts = j.accounts || [];
|
||||
state.accounts = accts;
|
||||
// 逐账号余额(credits.balances 对齐参考;无端点平台如实显示不可用)
|
||||
let balances = {};
|
||||
try {
|
||||
const bj = await call("channels/credits/balances", "POST", { platform: id });
|
||||
for (const row of (bj.accounts || [])) balances[row.accountId] = row;
|
||||
} catch {}
|
||||
q("#ch-accts").innerHTML = accts.length === 0
|
||||
? '<p class="muted" style="font-size:13px">暂无账号。点「添加账号(登录)」开始。</p>'
|
||||
: accts.map((a) => {
|
||||
@ -2031,8 +2542,12 @@ plugin.ui = {
|
||||
? (a.enabled !== false ? '<span class="tag tag-green">启用</span>' : '<span class="tag">停用</span>')
|
||||
: '<span class="tag">待凭据</span>';
|
||||
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'
|
||||
? '<span class="tag tag-green">' + esc(b.balance.unit || 'credits') + ' ' + b.balance.total + '</span>'
|
||||
: '<span class="muted" title="' + esc(b.error || '') + '" style="font-size:12px">余额不可用</span>';
|
||||
return '<div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px dashed var(--line);flex-wrap:wrap">' +
|
||||
'<b style="min-width:120px">' + esc(a.nickname || a.id) + '</b>' + badge +
|
||||
'<b style="min-width:120px">' + esc(a.nickname || a.id) + '</b>' + badge + balTxt +
|
||||
'<span class="muted" style="font-size:12px">' + claimTxt + '</span>' +
|
||||
'<span style="flex:1"></span>' +
|
||||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'test\')">测试</button>' +
|
||||
@ -2106,6 +2621,26 @@ plugin.ui = {
|
||||
await renderAccounts(id);
|
||||
};
|
||||
window.chAutoToggle = async (on) => { await call("channels/auto_checkin", "POST", { enabled: on }); notice(on ? "自动签到已开启" : "自动签到已关闭"); };
|
||||
window.chResetAll = async () => {
|
||||
const id = state.selected; if (!id) return;
|
||||
await call("channels/account/reset", "POST", { platform: id, account_id: "__all__" });
|
||||
notice("已重置全部账号限流标记");
|
||||
};
|
||||
window.chClearDead = async () => {
|
||||
const id = state.selected; if (!id) return;
|
||||
const j = await call("channels/model/clear_dead", "POST", { platform: id });
|
||||
const removed = (j.removed || []).length;
|
||||
notice(removed ? ("已清理 " + removed + " 个死模型") : "没有死模型");
|
||||
await renderModels(id);
|
||||
};
|
||||
// 用量快照(usage.tokenLedger 对齐参考):弹窗展示 prompt/completion/请求数
|
||||
window.chLedger = async () => {
|
||||
const j = await call("channels/usage/token_ledger", "POST", { limit: 100 });
|
||||
const s = j.snapshot || {};
|
||||
if (!s.available) { alert("用量不可用:" + (s.error || "未知")); return; }
|
||||
alert("本网关 token 用量\nPrompt: " + (s.tokens?.prompt ?? 0) + "\nCompletion: " + (s.tokens?.completion ?? 0) +
|
||||
"\nTotal: " + (s.tokens?.total ?? 0) + "\nRequests: " + (s.requests ?? 0));
|
||||
};
|
||||
window.chBackup = async () => {
|
||||
const j = await call("channels/backup/export", "POST", {});
|
||||
const blob = new Blob([JSON.stringify(j.payload, null, 2)], { type: "application/json" });
|
||||
|
||||
Reference in New Issue
Block a user