Files
ModelRouter/internal/lua/plugins/channels.lua
JianFeeeee 0232b9cd24 fix(channels): 登录后账号拿不到凭据 —— 完成路径不传 account_id + 只存裸 token
用户报障:「登录了,但插件这边无法获取账号信息」。实证:
  account_list → has_credential=false,credits/balances → 「无凭据」,
  而 UI 上账号显示已登录。

## 根因一:完成路径没把凭据写进池账号的键
UI 的真实流程是「account/add 建占位账号(credentialRef=channels:<plat>:<aid>)
→ login 带 account_id → 轮询 login/status」。但 poll_login 的两条完成分支
(device-poll / state-poll)调用 complete_login 时**没有传 account_id**,
于是走了兼容旧路径,凭据落到 `channels:<plat>` 这个与占位账号无关的键;
池账号的 credentialRef 指向 `channels:<plat>:<aid>`,从未写入
→ account_list 的 has_credential 恒为 false。

修:两条分支都传 `account_id = s.account_id`(对齐参考 BuddyAuth.login 的
`flowOptions.accountId` 语义:多账号场景下凭据按账号 ref 写入)。

## 根因二:只存裸 access_token,丢掉 refresh_token 与过期信息
state-poll 分支只取 `data.accessToken` 当凭据存,而参考
`runBuddyLoginFlow` 存的是 `buildCredential(token, account)` 的**完整 JSON**
(access_token + refresh_token + expires_at + refresh_expires_at + token_type
+ scope + domain + user_id + nickname + enterprise_id + account_type)。
只存裸 token 的后果:refreshable 恒为 false → 无静默续期;expires_at 未知。

修:新增 buildCredential(逐字段对齐参考 buddy.ts)+ jwtClaim/absExpiryMs
(参照实现:token 端点只回 expiresIn 不回 expiresAt,昵称只在 JWT 声明里)
+ credentialExpiresAtMs。state-poll 完成时存完整凭据 JSON 并回报
expires_at/refreshable。

## 配套
- 新增 credentialOf(ref):凭据取值助手,**裸 token 与完整 JSON 两种形态都认**
  (历史数据/device-poll/paste 是裸 token,buddy 是 JSON)。所有消费点
  (上游源 api_key、Bearer 头、HMAC 签名、备份)改走它,无须关心存储形态。
- complete_login 从凭据自动补齐 nickname/expiresAt/refreshable,调用方漏传也不丢。
- 内核新增 base64_decode(标准/base64url/无填充都认):插件读 JWT payload
  必需,否则要在 Lua 里重写 base64。通用能力,无厂商知识。

## 判据
TestBuddyStatePollBehavior 行2/行4:
- 行2 断言凭据是完整 JSON(含 refresh_token + 昵称),非裸 token
- 行4 回归用户报障:走 account/add → 带 account_id 登录,断言
  ① 凭据落在 channels:buddy:<aid> ② account_list 报 has_credential=true
  ③ refreshable=true
变异验证:去掉 account_id → 行4 立即 FAIL(已实测)。

go test ./internal/core/ 与 ./internal/lua/ 全绿。
2026-10-13 07:41:42 +08:00

2948 lines
139 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- channels:多平台账号接入(登录认证 → 凭据 → 自动领取额度 → 注册为上游源)
--
-- 为什么需要它
-- ------------
-- 网关的上游源(sources)一直是**运维手工配置**的:填 base_url、填 api_key。
-- 而现实里有大量平台是"注册个账号、每天领免费额度"的形态,它们的接入方式是
-- **登录**而不是"申请一个 key"。这类平台要接入网关,就需要:驱动登录 → 拿到
-- 凭据 → 定时领取额度 → 把端点当源用。
--
-- 本插件把这套流程做成通用的:一个插件内建平台表,每个平台声明自己的
-- 登录方式(link-poll / callback / paste)、额度领取接口、上游端点形状。
-- 新增一个平台是往 PLATFORMS 里加一条,不改内核。
--
-- 设计边界(与内核的约定)
-- ------------------------
-- 内核提供基础设施,插件自己不实现这些:
--
-- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它)
-- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读)
-- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…)
--
-- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状、模型清单。
-- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"——
-- 内核里出现厂商名就是分层被破坏了。登录会话、服务端浏览器栈也全归插件自持。
--
-- 权限(本插件需要,全部可在设置页看到"申请中")
-- ------------------------------------------------
-- add_source 把平台端点注册为上游源
-- list_sources 显示当前已接入的平台
-- probe_all 接入后立刻验证可达
-- reset_health 上游恢复后清冷却
-- status 面板显示运行状态
--
-- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被
-- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。
--
-- 登录会话归本插件自持(内核无"登录"概念):会话表在 plugin.state.sessions,
-- 带 nonce 与过期时间;浏览器栈(Xvfb/chromium/x11vnc/websockify)由本插件用
-- os.execute 直接拉起并记录 pid,插件卸载/会话结束时自己回收。
-- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化流程(轮询/回调)
-- → ③ VNC 无头浏览器兜底(无浏览器系统 / 自动化也无法完成的场景)。
local plugin = {
name = "channels",
version = "1.0.0",
description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源",
author = "llmsproxy",
}
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" }
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_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" }
-- ============================================================================
-- 平台表
-- ============================================================================
--
-- 每个平台声明登录方式与各端点。字段:
-- flow link-poll(给 URL 轮询)/ callback(OAuth 重定向回来带 code)/
-- device-poll(设备码:先申请码、给用户 URL、再轮询换 token)/
-- paste(用户粘贴 cookie/token)
-- login_url 用户要在自己浏览器打开的地址
-- base_url 接入网关后对话走的 OpenAI 兼容端点(adapter 决定协议形状)
-- adapter 内核适配器名(openai / anthropic / …)
-- models fallback 模型清单(登录后可用 refresh 动态拉取覆盖)
-- checkin 每日额度领取:{ url, method, body, auth }(可缺省=不支持签到)
--
-- 新增平台 = 加一条。不改内核,也不改本文件其它部分。
--
-- ⚠️ 真实厂商端点逆向自参考实现(dsh-our-free-model/vendor/channel-pack)。
-- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。
-- 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" },
},
-- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)=====
-- TRAE(字节跳动)—— callback 登录 + ExchangeToken 续期 + 每日签到。
-- 这是最标准的一条,作为"同类厂商"的实现模板。
{
id = "trae",
name = "TRAE(字节)",
vendor = "bytedance",
flow = "callback",
-- 用户在自己浏览器打开授权页;回调带 token 直传(auth_type=local)。
login_url = "https://www.trae.cn/authorization",
hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" },
base_url = "https://trae-api-cn.mchost.guru",
adapter = "openai",
models = {
"DeepSeek-V4-Flash", "DeepSeek-V4-Pro", "kimi-k3", "kimi-k2.6",
"glm-5", "glm-5.2", "Doubao-Seed-2.1-Pro",
},
-- 每日签到:POST /trae/api/v2/ug/checkin_credits/claim body {} 到 UG 主机。
checkin = {
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);
-- 无 refreshToken 用 userJwt.Token 兑底;
-- 只有 code/authCodeInfo 时是 PKCE 流 → 明确报"暂不支持"而不是含糊失败。
callback = {
mode = "trae",
exchange_url = "https://api.trae.com.cn/cloudide/api/v3/trae/oauth/ExchangeToken",
exchange_body = '{"ClientID":"{client_id}","RefreshToken":"{refresh_token}","ClientSecret":"-","UserID":""}',
client_id = "", -- 参考用 product.clientId;TRAE 回调直传形态不强制,留空
},
},
-- MiniMax —— PKCE 设备码流(RFC 8628)。三步:申请码 → 用户授权 → 轮询换
-- token。全程纯 HTTP,无需本地服务器;user_code 显示给用户输到授权页。
{
id = "minimax",
name = "MiniMax",
vendor = "minimax",
flow = "device-poll",
device = {
-- 申请设备码:PKCE S256
code_url = "https://account.minimax.cn/oauth2/device/code",
code_body = "client_id=mcode-public&scope=agent.default&audience=agent-backend"
.. "&code_challenge={challenge}&code_challenge_method=S256",
-- 轮询换 token
token_url = "https://account.minimax.cn/oauth2/token",
token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
.. "&device_code={device_code}&client_id=mcode-public&code_verifier={verifier}",
interval = 5, -- 轮询间隔秒
slow_down_guard = true, -- 响应 authorization_pending / slow_down 按标准处理
},
hosts = { "account.minimax.cn", "agent.minimax.cn" },
base_url = "https://agent.minimax.cn",
adapter = "openai",
models = { "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7-highspeed" },
-- 每日签到(两步:状态预检 + 领取;幂等靠服务端状态)
checkin = {
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 逆向)。
-- 与 MiniMax 同为 device-poll,但判据形态完全不同(参考注释反复强调):
-- 判据看响应体 error 字段(HTTP 可能非 2xx),不看 status 字段;
-- slow_down 累积退避 +1 秒(WorkOS 会持续限流不退避的客户端);
-- 成功后还有第二步 register:拿 WorkOS token 换 Cline 自己的 token
-- (POST /api/v1/auth/register,body 驼峰 accessToken/refreshToken)。
{
id = "cline",
name = "Cline",
vendor = "cline",
flow = "device-poll",
device = {
code_url = "https://api.workos.com/user_management/authorize/device",
code_body = "client_id=client_01K3A541FN8TA3EPPHTD2325AR",
token_url = "https://api.workos.com/user_management/authenticate",
token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
.. "&device_code={device_code}&client_id=client_01K3A541FN8TA3EPPHTD2325AR",
interval = 5,
-- WorkOS 形态:判 error 字段;slow_down 退避 1 秒;access_denied/invalid_grant 同组
judge = "workos",
slow_down_step = 1,
},
-- 第二步:WorkOS token → Cline token(挂 apiBase)
register = {
url = "https://api.cline.bot/api/v1/auth/register",
method = "POST",
body = '{"accessToken":"{access_token}","refreshToken":"{refresh_token}"}',
},
hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" },
base_url = "https://api.cline.bot",
adapter = "openai",
-- 免费模型(远端 free 数组实测,2026-10-05;内嵌目录只有这 3 个免费)
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-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 逆向)。
-- 两步:POST /v2/plugin/auth/state → {state, authUrl};用户在 authUrl 完成登录后
-- 插件轮询 GET /v2/plugin/auth/token?state=...。判据:业务码 11217 = 未就绪继续等;
-- 网络错误同样继续轮询不中断(对齐 Rust loop_get_token)。token 就绪后再拿账号。
{
id = "buddy",
name = "CodeBuddy(腾讯)",
vendor = "tencent",
flow = "state-poll",
state = {
state_url = "https://copilot.tencent.com/v2/plugin/auth/state?platform=ide",
token_url = "https://copilot.tencent.com/v2/plugin/auth/token",
account_url = "https://copilot.tencent.com/v2/plugin/login/account",
code_not_ready = 11217,
interval = 1,
},
hosts = { "copilot.tencent.com", "www.codebuddy.cn" },
base_url = "https://copilot.tencent.com",
adapter = "openai",
models = {
"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"。
{
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)。
-- client_id 是 Cloud Code 的公开客户端(逆向自 binary),scope 六项逐字照抄。
{
id = "gemini",
name = "Gemini(Cloud Code)",
vendor = "google",
flow = "callback",
login_url = "https://accounts.google.com/o/oauth2/v2/auth"
.. "?client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
.. "&redirect_uri=http://127.0.0.1:8845/auth/callback"
.. "&response_type=code"
.. "&scope=openid+email+profile+cloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email"
.. "&include_granted_scopes=true",
callback = {
mode = "code",
token_url = "https://oauth2.googleapis.com/token",
token_body = "grant_type=authorization_code&code={code}"
.. "&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
.. "&redirect_uri=http://127.0.0.1:8845/auth/callback",
pkce = true, -- 授权页带 verifier(S256),交换时回传
},
hosts = { "accounts.google.com", "oauth2.googleapis.com", "cloudcode-pa.googleapis.com" },
base_url = "https://cloudcode-pa.googleapis.com",
adapter = "gemini",
models = { "gemini-3.1-flash-lite-preview", "gemini-3.1-flash-preview" },
},
-- LobsterAI(有道龙虾)—— 本地回调 + authCode 换 token(照参考 lobsterai-oauth.ts)。
{
id = "lobsterai",
name = "LobsterAI(有道)",
vendor = "youdao",
flow = "callback",
login_url = "https://lobsterai.youdao.com/login",
callback = {
mode = "code",
token_url = "https://lobsterai-server.youdao.com/api/auth/login",
token_body = '{"authCode":"{code}","firstKeyfrom":"cli","latestKeyfrom":"cli","uuid":"","version":"1.0"}',
},
hosts = { "lobsterai.youdao.com", "lobsterai-server.youdao.com" },
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)。
{
id = "opencode",
name = "OpenCode Zen",
vendor = "opencode",
flow = "paste",
base_url = "https://opencode.ai/zen",
adapter = "openai",
models = { "big-pickle", "claude-sonnet-4-20250514", "gpt-5.1-codex-mini" },
},
-- CodeArts(华为)—— 凭据 + SDK-HMAC-SHA256 签到(照参考 codearts-credits.ts)。
-- 登录是常规企业凭据(paste);签到走 snap-access 的 SDK-HMAC-SHA256 签名,
-- 放在 claim_fn 钩子里(checkin 表驱动驱动不动签名请求,参考注释也点明这一点)。
{
id = "codearts",
name = "CodeArts(华为)",
vendor = "huawei",
flow = "paste",
base_url = "https://snap-access.cn-north-4.myhuaweicloud.com",
adapter = "openai",
models = { "CodeArts-Pangu" },
checkin = {
url = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
method = "GET",
},
-- claim_fn 由下方 claimCodeArts 实现(引用前向声明,见文件末尾)
claim_fn_name = "claimCodeArts",
},
-- Qoder —— PKCE 设备码 + WASM 加密签名(照参考 qoder-oauth.ts 逆向)。
-- WASM 签名头无法在纯 Lua 环境完成,登录标 manual 给明确说明。
{
id = "qoder",
name = "Qoder",
vendor = "qoder",
flow = "manual",
manual_hint = "Qoder 登录要 WASM 签名头,纯 Lua 环境无法生成。请在浏览器完成"
.. "授权后,把最终凭证粘贴进来(手动输入凭据)。",
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 逆向)。
-- captcha 求解(mint captcha + 补激活上报)无法在纯 Lua 完成,标 manual。
{
id = "zcode",
name = "ZCode",
vendor = "zcode",
flow = "manual",
manual_hint = "ZCode 登录要解 captcha(client/configs 拿 sceneId 后 mint),"
.. "纯 Lua 环境无法完成。请完成 captcha 验证后,把最终凭证粘贴进来。",
base_url = "https://zcode.example.com/v1",
adapter = "openai",
models = { "zcode-default" },
},
-- Loomy —— 微信扫码登录(照参考 loomy-oauth.ts 逆向)。
-- 扫码要在微信 App 里完成,纯 Lua 环境拿不到微信凭据,标 manual。
{
id = "loomy",
name = "Loomy",
vendor = "loomy",
flow = "manual",
manual_hint = "Loomy 登录要微信扫码(在微信公众号里完成授权)。请在微信里"
.. "扫码后,把拿到的凭证粘贴进来。",
base_url = "https://loomy.example.com/v1",
adapter = "openai",
models = { "loomy-default" },
},
-- Raccoon(商汤小浣熊)—— 扫码登录(照参考 raccoon-oauth.ts 逆向)。
-- 扫码是图片码,纯 Lua 环境无法展示与回取,标 manual。
{
id = "raccoon",
name = "Raccoon(商汤小浣熊)",
vendor = "sensetime",
flow = "manual",
manual_hint = "Raccoon 登录要扫图片二维码(商汤小浣熊官网)。请扫码后,"
.. "把 login_with_qrcode_code 拿到的凭证粘贴进来。",
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 },
},
}
-- CodeArts 的 SDK-HMAC-SHA256 签到钩子(照参考 codearts-credits.ts 逆向)。
-- 绑在 PLATFORMS 表定义之后:claim_daily 读取 plat.claim_fn,checkin 表驱动
-- 不动签名请求(参考注释也点明这一点),所以用钩子生成签名后的请求形状。
do
local ca
for _, p in ipairs(PLATFORMS) do
if p.id == "codearts" then ca = p break end
end
if ca then
-- SDK-HMAC-SHA256 派生签名密钥链(照参考 canonical request 形状)。
ca.claim_fn = function(plat, tok, args)
local url = plat.checkin.url
local method = plat.checkin.method or "GET"
local host = url:match("^https?://([^/:]+)")
local path = url:match("^https?://[^/]+(/[^?]*)")
local ts = tostring(os.time and os.time() or 0)
local nonce = tostring(math.random(1e12))
local canonical = method .. "\n" .. (path or "/") .. "\n" .. ts .. "\n" .. nonce
local secret = tok or ""
local kDate = hmac_sha256_hex("SDK" .. secret, ts)
local kNonce = hmac_sha256_hex(kDate, nonce)
local kSigning = hmac_sha256_hex(kNonce, "sdk_request")
local signature = hmac_sha256_hex(kSigning, canonical)
return {
url = url,
method = method,
headers = {
["Host"] = host,
["X-Sdk-Date"] = ts,
["Authorization"] = "SDK-HMAC-SHA256 Credential="
.. (args and args.access_key or "") .. "/" .. ts .. "/" .. nonce
.. "/sdk_request, SignedHeaders=host, Signature=" .. signature,
},
}
end
end
end
-- 申请的额外出站主机:从平台声明推导(登录域 + 上游域),避免两份清单漂移。
-- 内核里**不含**任何厂商名 —— 这些只存在于本插件,且需运维在设置页逐个批准。
local function declaredHosts()
local seen, out = {}, {}
for _, p in ipairs(PLATFORMS) do
for _, h in ipairs(p.hosts or {}) do
local hn = h:lower()
if not seen[hn] then seen[hn] = true out[#out+1] = hn end
end
end
-- 上游 base_url 的 host 也要(发对话/签到用)
for _, p in ipairs(PLATFORMS) do
local h = (p.base_url or ""):match("^https?://([^/:]+)")
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
end
for _, p in ipairs(PLATFORMS) do
local h = (p.checkin and p.checkin.url or ""):match("^https?://([^/:]+)")
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
end
table.sort(out)
return out
end
plugin.hosts = declaredHosts()
-- bodyOf 把 handler 的 req.body(**JSON 字符串**)解成 table。
--
-- 为什么需要它:handler 收到的是原始 body 字符串,不是解码后的表(内核不猜
-- 插件的载荷形状)。载荷坏掉时返回空表而不是报错 —— 调用方随后会因为缺少
-- 必要字段而得到明确的错误,比一个 JSON 解析异常更好读。
local function bodyOf(req)
if not req or not req.body or req.body == "" then return {} end
if type(req.body) == "table" then return req.body end
local ok, v = pcall(json.decode, req.body)
if not ok or type(v) ~= "table" then return {} end
return v
end
-- ============================================================================
-- device-poll 通用执行器(移植自参考实现 minimax-oauth.ts / cline-oauth.ts)
-- ============================================================================
-- 行为逐字对齐参考实现,测试用例也照它搬(见 channels_device_test.go):
--
-- 1. PKCE S256:verifier = 随机字节 base64url;challenge = SHA256(verifier)
-- 的 base64url(参考 createMinimaxPkce)。
-- 2. 轮询决策分支的**顺序**就是行为(参考 pollMinimaxDeviceToken):
-- HTTP 200 + status=pending → 继续等
-- HTTP 200 + status=slow_down → interval += 5s,继续等
-- HTTP 200 + status=denied /
-- access_denied → 会话 failed(用户拒绝)
-- HTTP 200 + status=expired /
-- expired_token → 会话 failed(码过期)
-- 非 200 + error=authorization_pending → 继续等(OAuth 标准形态)
-- 非 200 + error=slow_down → interval += 5s,继续等
-- HTTP 200(无上述状态) → 成功,提取 access_token
-- 其它 → 会话 failed(带 error 文本)
-- ⚠️ 「两种还在等」都要认:形一是 200+status 字段,形二是非 200+error 字段。
-- 3. 轮询由页面驱动(poll_login 每次调用推进一步),不在 Lua 里阻塞循环:
-- 插件单 VM 单锁,阻塞循环会卡住本插件的全部钩子。
-- b64url:内核的 base64_encode 是标准 base64;PKCE 要 base64url(无 +/= )。
local function b64url(s)
return (base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", ""))
end
-- hexToBin:把 hex 字符串还原成二进制字符串(PKCE challenge 输入用)。
local function hexToBin(h)
return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end))
end
-- devicePkce 生成一次授权的 verifier/challenge 对(SHA256 S256)。
-- verifier 自造:熵拼接后哈希。不依赖文件后段才定义的 newToken —— Lua 词法
-- 作用域下那会是 nil 全局调用。熵用 os.time + math.random 两次哈希拼接。
local function devicePkce()
local v = sha256_hex(tostring(os.time and os.time() or 0) .. math.random(1e12))
.. sha256_hex(tostring(math.random(1e12)))
return v, b64url(hexToBin(sha256_hex(v)))
end
-- deviceOneStep 推进一次设备码轮询,返回 (done, payload)。
-- done=true payload={ ok=true, access_token=.., refresh_token=.., account=.. }
-- 或 { ok=false, reason="denied"|"expired", message=.. }
-- done=false payload=nil(还在等:pending / slow_down)
-- sess 携带 device 上下文(device_code/verifier/interval),被本函数原地更新
--(slow_down 后 interval 增大,与参考一致)。
local function deviceOneStep(plat, sess)
local d = plat.device
local body = (d.token_body
:gsub("{device_code}", sess.device_code)
:gsub("{verifier}", sess.device_verifier or "")
:gsub("{challenge}", sess.device_challenge or ""))
local resp, err = __llmsproxy_http({
url = d.token_url,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
body = body,
})
if err then return false end -- 网络抖动不算失败:与参考一致(pending 语义)
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then rec = {} end
-- 判据形态分两套(这正是 MiniMax 与 Cline 的核心差异,参考注释反复强调):
-- minimax(默认):看 HTTP 200 + status 字段;非 200 才看 error 字段。
-- workos(Cline):看响应体 error 字段(HTTP 可能非 2xx);
-- slow_down 退避 d.slow_down_step 秒(WorkOS 会持续限流不退避的客户端);
-- access_denied / expired_token / invalid_grant 同组判失败。
-- 退避步长不硬编码:slow_down_step 缺省 5(MiniMax 语义)。
local workos = (d.judge == "workos")
local status = rec.status
local ecode = rec.error
local step = tonumber(d.slow_down_step) or 5
if workos then
if ecode == "authorization_pending" then return false end
if ecode == "slow_down" then
sess.device_interval = (sess.device_interval or d.interval or 5) + step
return false
end
if ecode == "access_denied" or ecode == "expired_token" or ecode == "invalid_grant" then
local msg = rec.error_description or (ecode == "access_denied" and "用户拒绝了授权" or "授权已过期")
return true, { ok = false, reason = (ecode == "access_denied" and "denied" or "expired"), message = msg }
end
if resp.status == 200 and rec.access_token and rec.refresh_token then
return true, { ok = true, access_token = rec.access_token, refresh_token = rec.refresh_token,
need_register = true, account = "cline" }
end
if resp.status ~= 200 and next(rec) ~= nil then
return true, { ok = false, reason = "error",
message = "WorkOS token 轮询失败(HTTP " .. tostring(resp.status) .. ")"
.. (rec.error_description and (" - " .. rec.error_description) or "") }
end
return false -- 2xx 但既无 error 又无 token:等下一拍(避免误报死循环)
end
-- MiniMax 形态(原样保留,已由决策表测试钉死)
if resp.status == 200 and status == "pending" then return false end
if resp.status == 200 and status == "slow_down" then
sess.device_interval = (sess.device_interval or d.interval or 5) + step
return false
end
if resp.status == 200 and (status == "denied" or status == "access_denied") then
return true, { ok = false, reason = "denied", message = "用户拒绝了授权" }
end
if resp.status == 200 and (status == "expired" or status == "expired_token") then
return true, { ok = false, reason = "expired", message = "设备码已过期,请重新发起登录" }
end
if ecode == "authorization_pending" then return false end
if ecode == "slow_down" then
sess.device_interval = (sess.device_interval or d.interval or 5) + step
return false
end
if resp.status == 200 and rec.access_token then
return true, { ok = true, access_token = rec.access_token,
refresh_token = rec.refresh_token, account = rec.nickname or rec.sub or "" }
end
return true, { ok = false, reason = "error", message = "授权失败: " .. tostring(ecode or ("HTTP " .. tostring(resp.status))) }
end
-- deviceRegister:设备码成功后的第二跳(Cline:WorkOS token → Cline token)。
-- 参考注释强调:两步是必要的(浏览器 transient activation 约 5 秒,阻塞式
-- 等授权会让 URL 过期)。返回 (access_token, account, nil) 或 (nil, nil, err)。
local function deviceRegister(plat, payload)
local r = plat.register
if not r or not r.url then return payload.access_token, payload.account, nil end
local body = (r.body or "")
:gsub("{access_token}", payload.access_token or "")
:gsub("{refresh_token}", payload.refresh_token or "")
local resp, err = __llmsproxy_http({
url = r.url,
method = r.method or "POST",
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
body = body,
})
if err then return nil, nil, "token 注册失败: " .. err end
if resp.status ~= 200 then
return nil, nil, "token 注册失败(HTTP " .. tostring(resp.status) .. ")"
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return nil, nil, "token 注册响应无法解析"
end
-- 响应套 {success, data} 信封(参考 parseClineTokenPayload)
local data = rec.data or rec
local access = data.accessToken or data.access_token or data.Token or data.token
if not access or access == "" then
return nil, nil, "token 注册响应缺少 accessToken"
end
local acct = "cline"
if type(data.userInfo) == "table" and (data.userInfo.email or data.userInfo.clineUserId) then
acct = tostring(data.userInfo.email or data.userInfo.clineUserId)
end
return access, acct, nil
end
-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)
local function platformById(id)
for _, p in ipairs(PLATFORMS) do
if p.id == id then return p end
end
return nil
end
-- ============================================================================
-- 状态
-- ============================================================================
plugin.state = plugin.state or {}
plugin.state.accounts = plugin.state.accounts or {}
plugin.state.log = plugin.state.log or {}
local function logEvent(msg)
table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg })
while #plugin.state.log > 50 do table.remove(plugin.state.log) end
end
-- accountOf 取某平台的账号记录(凭据本身在保险库,不在 state 里)
local function accountOf(id)
return plugin.state.accounts[id]
end
-- ============================================================================
-- 账号池(对齐参考 account.* RPC 契约):一个平台可挂多个账号
-- ============================================================================
-- plugin.state.pool[provider] = { [accountId] = 条目 },条目字段对齐参考
-- AccountEntry:{ id, provider, nickname, enabled, credentialRef, refreshable,
-- createdAt, expiresAt }。凭据在保险库(credentialRef 即保险库键),不入 state。
--
-- 兼容:旧的单账号路径(state.accounts)保留 —— e2e 与既有端点仍走它。
local function poolTable()
if not plugin.state.pool then plugin.state.pool = {} end
return plugin.state.pool
end
local function poolAccounts(provider)
local t = poolTable()[provider]
if not t then t = {} poolTable()[provider] = t end
return t
end
local function poolList(provider)
local out = {}
for _, a in pairs(poolAccounts(provider)) do out[#out+1] = a end
table.sort(out, function(x, y)
local px, py = x.priority or 0, y.priority or 0
if px ~= py then return px > py end -- priority 大者优先(参考 reorder 语义)
return (x.createdAt or 0) < (y.createdAt or 0)
end)
return out
end
local function poolGet(provider, accountId)
return poolAccounts(provider)[accountId]
end
local function poolAdd(provider, entry)
poolAccounts(provider)[entry.id] = entry
end
local function poolRemove(provider, accountId)
poolAccounts(provider)[accountId] = nil
end
-- 凭据取值:保险库里存的可能是**裸 token**(历史/device-poll/paste 路径),
-- 也可能是**完整凭据 JSON**( buddy / workbuddy 等 state-poll 平台,对齐参考
-- buildCredential 的结构:access_token + refresh_token + expires_at + domain + ...)。
--
-- 本助手统一取出可用的 access token(及 refresh_token),使所有 HTTP 消费点
-- (Bearer 头、HMAC 签名)无需关心存的是哪种形态:
-- 裸 token → 原样返回
-- 完整凭据 JSON 字符串 → 取 .access_token
-- 已经是 table → 取 .access_token
-- 返回 (accessToken, refreshToken, fullTableOrNil)。两者都空时返回 (nil, nil, nil)。
local function credentialOf(ref)
local raw = ref and __llmsproxy_secrets.get(ref)
if not raw or raw == "" then return nil, nil, nil end
-- 裸 token 快路:不含 '{' 的字符串直接当 token(绝大多数历史数据)
if type(raw) == "string" and raw:sub(1, 1) ~= "{" then return raw, nil, nil end
local ok, rec = pcall(json.decode, type(raw) == "string" and raw or "")
if ok and type(rec) == "table" then
local at = rec.access_token or rec.accessToken or rec.token
local rt = rec.refresh_token or rec.refreshToken
if at and at ~= "" then return at, rt, rec end
-- 是 JSON 但没有 access_token:回退原样(可能是别的形状的凭据)
return raw, rt, rec
end
return raw, nil, nil
end
-- credentialFull 取完整凭据(供需要 refresh_token/expires_at 的路径用)。
local function credentialFull(ref)
local _, _, full = credentialOf(ref)
return full
end
-- jwtClaim 从 JWT 的 payload 读一个声明(不验签,仅用于读取展示/续期元数据)。
-- 对齐参考 buddy.ts 的 jwtNickname / jwtExpiresAtMs / jwtSubject:
-- CodeBuddy 的 login/account 响应**不含昵称**,昵称只在 access_token 的声明里;
-- token 端又只返回 expiresIn(相对秒数)而不含 expiresAt。两处都得从 JWT 兜底。
-- 解析失败返回 nil(调用方自行处理)。
local function jwtClaim(token, name)
if type(token) ~= "string" or token == "" then return nil end
local payload = token:match("^[^%.]+%.([^%.]+)")
if not payload then return nil end
local bin, derr = base64_decode(payload) -- 内核新增:base64url 兼容
if not bin or derr then return nil end
local ok, rec = pcall(json.decode, bin)
if not ok or type(rec) ~= "table" then return nil end
return rec[name]
end
-- stripControl 去掉控制字符(含 CR/LF/Tab)并把连续空白折叠为单个空格。
-- 对齐参考 stripControlChars:CodeBuddy 的 scope 字段有时返回多行文本,
-- 原样落盘会在序列化时被当作多行标量,破坏后续 JSON 解析。
local function stripControl(v)
if type(v) ~= "string" then return v end
v = v:gsub("[%z\1-\31\127]+", " ")
v = v:gsub("%s%s+", " ")
return (v:gsub("^%s+", ""):gsub("%s+$", ""))
end
-- readStr 从响应表安全读字符串字段(兼容后端把数字返为数字)。
local function readStr(rec, key)
if type(rec) ~= "table" then return "" end
local v = rec[key]
if type(v) == "string" then return stripControl(v) end
if type(v) == "number" then return tostring(v) end
return ""
end
-- absExpiryMs 把绝对字段或相对秒数换算为毫秒时间戳字符串。
-- 对齐参考 absoluteExpiryMs:
-- ① 绝对字段(expiresAt/refreshExpiresAt)存在 → 归一化为毫秒字符串
-- ② 否则用相对秒数(expiresIn/refreshExpiresIn)+ access_token 的 JWT iat 兜底
-- ③ 都没有 → 返回空串(由 credentialExpiresAtMs 再从 JWT exp 兜底)
-- 实测:CodeBuddy 的 /v2/plugin/auth/token 只返回 expiresIn,没有 expiresAt;
-- 不换算的话凭据的 expires_at 永远是空串,UI 一直显示"有效期未知"。
local function absExpiryMs(rec, absKey, relKey, accessToken)
local absolute = readStr(rec, absKey)
if absolute ~= "" then
if absolute:match("^%d+$") then
local n = tonumber(absolute)
if n then
return tostring(n > 1e12 and n or n * 1000)
end
end
return absolute
end
local rel = rec[relKey]
local relSec = nil
if type(rel) == "number" then relSec = rel
elseif type(rel) == "string" and rel:match("^%-?%d+%.?%d*$") then relSec = tonumber(rel) end
if relSec == nil then return "" end
-- 基准:access_token 的 iat(签发时刻)优先,缺失时用当前时刻。
local iat = jwtClaim(accessToken, "iat")
local baseMs = (type(iat) == "number" and iat * 1000) or (os.time and os.time() * 1000 or 0)
return tostring(baseMs + relSec * 1000)
end
-- buildCredential 把 token 响应 + account 响应组合为可持久化的完整凭据 JSON。
-- 逐字段对齐参考 buddy.ts 的 buildCredential / parseTokenData / parseAccountData:
-- access_token / refresh_token / expires_at / refresh_expires_at /
-- token_type / scope / domain / user_id / nickname / enterprise_id / account_type
-- 昵称回填顺序:account.nickname → JWT.nickname → JWT.preferred_username。
-- expires_at 同理:token.expiresAt 为空时由 absExpiryMs 用 expiresIn 或 JWT exp 兜底。
local function buildCredential(data, accountData)
local access = readStr(data, "accessToken")
if access == "" then access = readStr(data, "access_token") end
local refresh = readStr(data, "refreshToken")
if refresh == "" then refresh = readStr(data, "refresh_token") end
local tokenType = readStr(data, "tokenType")
if tokenType == "" then tokenType = "Bearer" end
local acct = type(accountData) == "table" and accountData or {}
local nickname = readStr(acct, "nickname")
if nickname == "" then nickname = readStr(acct, "nickName") end
if nickname == "" then
local jn = jwtClaim(access, "nickname")
if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "preferred_username") end
if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "name") end
if type(jn) == "string" then nickname = stripControl(jn) end
end
local uid = readStr(acct, "uid")
if uid == "" then
local sub = jwtClaim(access, "sub")
if type(sub) == "string" then uid = sub end
end
local acctType = readStr(acct, "type")
if acctType == "" then acctType = "personal" end
return {
access_token = access,
refresh_token = refresh,
expires_at = absExpiryMs(data, "expiresAt", "expiresIn", access),
refresh_expires_at = absExpiryMs(data, "refreshExpiresAt", "refreshExpiresIn", access),
token_type = tokenType,
scope = readStr(data, "scope"),
domain = readStr(data, "domain"),
user_id = uid,
nickname = nickname,
enterprise_id = readStr(acct, "enterpriseId"),
account_type = acctType,
}
end
-- credentialExpiresAtMs 从凭据表解析毫秒过期时刻(兼容毫秒/秒级时间戳/ISO)。
-- 对齐参考 credentialExpiresAtMs:expires_at 为空时回退解析 access_token 的 JWT exp。
local function credentialExpiresAtMs(cred)
if type(cred) ~= "table" then return nil end
local raw = cred.expires_at
if type(raw) == "string" and raw ~= "" then
if raw:match("^%d+$") then
local n = tonumber(raw)
if n then return n > 1e12 and n or n * 1000 end
end
-- ISO 8601:解析 "YYYY-MM-DDThh:mm:ss" 形态(无时区偏移时按 UTC)
local Y, Mo, D, H, Mi, S = raw:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)")
if Y then return os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D),
hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false }) * 1000 end
end
local exp = jwtClaim(cred.access_token, "exp")
if type(exp) == "number" then return exp * 1000 end
return nil
end
-- credentialIsRefreshable 凭据是否携带可静默续期的 refresh_token。
local function credentialIsRefreshable(cred)
return type(cred) == "table" and type(cred.refresh_token) == "string" and cred.refresh_token ~= ""
end
-- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true }
local function disabledModelsOf(provider)
if not plugin.state.modelDisabled then plugin.state.modelDisabled = {} end
local m = plugin.state.modelDisabled[provider]
if not m then m = {} plugin.state.modelDisabled[provider] = m end
return m
end
-- 渠道积分能力表(对齐参考 CHANNEL_CREDITS):声明各渠道有没有领取/余额。
local CHANNEL_CREDITS = {
codearts = { claim = true },
buddy = { claim = true, status = true },
lobsterai = { claim = true },
qoder = { claim = true },
trae = { claim = true },
cline = { unit = "dollars" },
loomy = { claim = true },
raccoon = {},
minimax = { claim = true, status = true },
zcode = { claim = true, unit = "tokens" },
gemini = { unit = "quota", quota = true },
}
-- ---- 账号级 source 同步 ----
-- 每个账号一条上游源:channels-<provider>-<accountId>。模型开关即改写
-- 这条源的 models 列表(remove+add 重建;内核 add_source 同名覆盖,但显式
-- 先 remove 更诚实,避免旧模型残留)。
local function accountSourceName(provider, accountId)
return "channels-" .. provider .. "-" .. accountId
end
-- syncAccountSource 把某账号的上游源与(平台模型目录 - 黑名单)对齐。
-- 无凭据(占位账号)不动源。
local function syncAccountSource(provider, accountId)
local plat = platformById(provider)
local acct = poolGet(provider, accountId)
if not plat or not acct then return end
-- 凭据可能是裸 token 或完整凭据 JSON;上游源只吃 access token。
local tok = credentialOf(acct.credentialRef)
if not tok or tok == "" then return end
local name = accountSourceName(provider, accountId)
local models = {}
local disabled = disabledModelsOf(provider)
for _, m in ipairs(plat.models or {}) do
if not disabled[m] then
models[#models+1] = { id = m, kind = "chat", priority = 20 }
end
end
-- 重建:先 remove(容错:源可能不存在)再 add
__llmsproxy_kernel.call("remove_source", { name = name })
local r, aerr = __llmsproxy_kernel.call("add_source", {
source = {
name = name,
base_url = plat.base_url,
adapter = plat.adapter or "openai",
api_key = tok,
max_concurrent = 2,
models = models,
},
})
if aerr then logEvent("接入源失败 " .. name .. ": " .. aerr)
else logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)") end
end
local function dropAccountSource(provider, accountId)
__llmsproxy_kernel.call("remove_source", { name = accountSourceName(provider, accountId) })
end
-- ============================================================================
-- 登录
-- ============================================================================
-- plugin.start_login(platform_id) 由设置页调用:创建一个登录会话。
--
-- 返回 {url=…, session=…} 给页面显示"请在自己的浏览器打开这个地址"。
-- 登录完成后用户浏览器会回到本插件(callback 流程)或本插件轮询到结果
-- ============================================================================
-- 登录会话(插件自持)与服务端浏览器栈
-- ============================================================================
-- 登录会话放 plugin.state.sessions:内核没有"登录"概念,这正是插件该自己干的
-- 事。表结构 { id, platform, flow, url, user_code, nonce, state, account,
-- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。
local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理
local function newToken()
-- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex)
return sha256_hex(tostring(os.time()) .. math.random(1e12) .. (plugin.name or "channels"))
end
local function sessions()
if not plugin.state.sessions then plugin.state.sessions = {} end
return plugin.state.sessions
end
-- sweepSessions 惰性清理过期/终态会话(无后台定时器,读取时顺带做)
local function sweepSessions()
local now = os.time and os.time() or 0
for id, s in pairs(sessions()) do
if s.expires_at and s.expires_at < now then
sessions()[id] = nil
end
end
end
-- ---- 服务端浏览器栈(VNC 兜底用)----
-- 每个会话一组进程:Xvfb + chromium + x11vnc + websockify。pid 记在
-- plugin.state.browser.pids,插件卸载或会话结束时 kill。
-- 绑定地址默认 127.0.0.1(同机操作);远程运维可把 settings.vnc_bind 设成
-- "0.0.0.0" 并依赖 x11vnc 随机会话密码(不设密码的裸 VNC 永远不会出现)。
local function browserStack()
if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end
return plugin.state.browser
end
local function reapBrowser()
local b = browserStack()
for _, pid in ipairs(b.pids or {}) do
os.execute("kill -9 " .. tostring(pid) .. " 2>/dev/null")
end
b.pids = {}
b.session = nil
end
-- spawnBrowser 拉起一组服务端浏览器。返回 view_url(给 iframe 内嵌)或 nil, err。
-- start_url 是浏览器要打开的登录页;redirect_host 是拼 redirect_uri 用的主机名
-- (浏览器在服务端,127.0.0.1 就是服务端自己的回环 —— 回调直接命中网关)。
local function spawnBrowser(start_url, callback_path)
local b = browserStack()
if b.session then return nil, "已有浏览器会话在跑,请先停止" end
b.seq = b.seq + 1
local disp = 90 + (b.seq % 10)
local vport = 59100 + (b.seq % 100)
local wport = 60100 + (b.seq % 100)
local bind = (plugin.state.settings and plugin.state.settings.vnc_bind) or "127.0.0.1"
-- 会话密码:bind 到非回环时是唯一的防线,随机生成、只在响应里给一次
local pass = sha256_hex(newToken()):sub(1, 16)
-- 端口占位换成网关回环(浏览器在服务端,回调直接命中网关挂载点)
local url = start_url:gsub("redirect_uri=127%.0%.0%.1:%%d+", "redirect_uri=127.0.0.1:8081" .. callback_path)
local cmds = {
("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp & echo $!"):format(disp),
("chromium --no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/llmsproxy-ch-%d --no-first-run --window-size=1280,800 --display=:%d '%s' & echo $!"):format(b.seq, disp, url),
("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet & echo $!"):format(disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass),
("websockify --web /usr/share/novnc %s:%d 127.0.0.1:%d & echo $!"):format(bind, wport, vport),
}
local pids = {}
for _, c in ipairs(cmds) do
local h = io.popen(c)
local pid = h:read("*l")
h:close()
if not pid or pid == "" then
reapBrowser()
return nil, "进程启动失败: " .. c:match("^(%S+)") .. "(缺依赖?运行 deploy 脚本一键安装)"
end
pids[#pids+1] = pid
end
-- 给 X/chromium 一点起进程时间;不等待就绪探针(保持插件逻辑简单,失败可重试)
os.execute("sleep 2")
b.pids = pids
b.session = { display = disp, vnc = vport, ws = wport, pass = pass }
-- view_url 指向 websockify 伺服的 noVNC;同机直接 127.0.0.1,远程用 settings.vnc_host 覆盖
local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1"
return {
view_url = ("http://%s:%d/vnc.html?autoconnect=1&host=%s&port=%d&password=%s"):format(host, wport, host, wport, pass),
password = pass,
}
end
-- ============================================================================
-- 登录流程:优先级阶梯(① 本机浏览器 → ② 自动化 → ③ VNC 兜底)
-- ============================================================================
-- start_login 按优先级阶梯响应:
-- ① 本机浏览器:直接返回登录 URL(绝大多数场景到此为止 —— 平台重定向打在
-- 用户自己的机器上,插件轮询/收回调拿凭据)
-- ② 自动化:link-poll/callback 流程本身就是自动的(轮询 + nonce 回调),
-- 无需用户之外的动作
-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、
-- 回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。
function plugin.start_login(req)
local args = bodyOf(req)
local plat = platformById(args and args.platform)
if not plat then
return { status = 400, json = { error = "未知平台: " .. tostring(args and args.platform) } }
end
sweepSessions()
local now = os.time and os.time() or 0
local sess = {
id = newToken():sub(1, 24),
platform = plat.id,
flow = plat.flow,
url = plat.login_url,
nonce = newToken():sub(1, 32),
state = "pending",
created_at = now,
expires_at = now + SESSION_TTL,
}
sessions()[sess.id] = sess
sess.account_id = args and args.account_id -- 账号池路径:登录完成回填这个池账号
logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")")
-- 响应里只带一次 nonce(回调校验要用),存侧不回显
local out = {
session = { id = sess.id, platform = sess.platform, flow = sess.flow,
state = sess.state, nonce = sess.nonce, expires_at = sess.expires_at },
platform = plat.id,
}
if plat.flow == "manual" then
-- 无法自动化的登录(WASM/captcha/扫码):给明确说明而不是假装成功。
sess.state = "failed"
sess.message = plat.manual_hint or "该平台不支持自动登录"
out.manual_hint = sess.message
out.url = plat.login_url
logEvent("手动流程 " .. plat.id)
return { status = 200, json = out }
elseif plat.flow == "state-poll" then
-- Buddy:先拿 state + authUrl。页面给 authUrl,轮询 token 每推进一步。
local resp, err = __llmsproxy_http({
url = plat.state.state_url,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
body = "{\"platform\":\"ide\"}",
})
if err or resp.status ~= 200 then
sess.state = "failed"
out.error = "auth/state 失败: " .. (err or ("HTTP " .. tostring(resp.status)))
return { status = 200, json = out }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or type(rec.data) ~= "table"
or not rec.data.state or rec.data.state == "" then
sess.state = "failed"
out.error = "auth/state 响应缺少 state 字段"
return { status = 200, json = out }
end
sess.state_token = rec.data.state
out.url = rec.data.authUrl or plat.login_url
logEvent("state 已申请 " .. plat.id)
elseif plat.flow == "vnc" then
-- ③ 兜底路径:服务端浏览器。重定向打在服务端回环上,回调命中本插件的挂载点。
local bs, berr = spawnBrowser(string.format(plat.login_url, 0), "/api/plugins/http/channels/callback")
if berr then
out.browser_error = berr
out.hint = "服务端浏览器不可用;先装依赖(见插件一键初始化),或改用其他登录流程"
logEvent("服务端浏览器启动失败: " .. berr)
else
out.view_url = bs.view_url
logEvent("服务端浏览器已就绪 " .. plat.id)
end
elseif plat.flow == "device-poll" then
-- 设备码:申请码,把 user_code + 授权 URL 给用户,verifier 存进会话。
-- 行为对齐参考 startMinimaxDeviceAuthorization:PKCE S256。
local verifier, challenge = devicePkce()
local body = (plat.device.code_body:gsub("{challenge}", challenge))
local resp, err = __llmsproxy_http({
url = plat.device.code_url,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
body = body,
})
if err then
sess.state = "failed"
out.error = "设备码申请失败: " .. err
return { status = 200, json = out }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or not rec.device_code then
sess.state = "failed"
out.error = "设备码响应无法解析(HTTP " .. tostring(resp.status) .. ")"
return { status = 200, json = out }
end
sess.device_code = rec.device_code
sess.device_verifier = verifier
sess.device_challenge = challenge
sess.device_interval = tonumber(rec.interval) or plat.device.interval or 5
sess.user_code = rec.user_code
-- 授权页 URL:优先用 verification_uri_complete(免手输 user_code)
out.url = rec.verification_uri_complete or rec.verification_uri or rec.verification_url or plat.login_url
out.session.user_code = rec.user_code
out.session.interval = sess.device_interval
logEvent("设备码已申请 " .. plat.id .. " user_code=" .. tostring(rec.user_code))
else
-- ①②:URL 交给用户自己的浏览器;callback 流程的 redirect_uri 指向本插件挂载点
if plat.flow == "callback" then
out.url = plat.login_url .. "&redirect_uri=" .. "127.0.0.1:8081/api/plugins/http/channels/callback&state=" .. sess.nonce
else
out.url = plat.login_url
end
end
return { status = 200, json = out }
end
-- plugin.poll_login(session_id) 由页面轮询:会话是否完成。
--
-- 对 device-poll 会话,每次轮询**推进一步**设备码换 token(deviceOneStep):
-- 轮询由页面驱动而不在 Lua 里阻塞,因为插件是单 VM 单锁,阻塞循环会卡死
-- 本插件全部钩子。成功则落凭据+接入源(走 complete_login)。
--
-- ⚠️ **必须把 session 上的 account_id 传给 complete_login**(真实缺陷):
-- 页面流程是「先 account/add 建占位账号(credentialRef=channels:<plat>:<aid>)
-- → login 带 account_id → 轮询」。若这里不带 account_id,complete_login 会走
-- 兼容旧路径,凭据落到 `channels:<plat>` 这个**与占位账号无关的键**,而账号池
-- 条目的 credentialRef 指向 `channels:<plat>:<aid>` —— 于是 account/list 报
-- has_credential=false、credits/balances 报「无凭据」,表现为「登录成功但无法
-- 获取账号信息」。对齐参考 BuddyAuth.login 的 `flowOptions.accountId` 语义。
function plugin.poll_login(req)
local id = (req and req.query and req.query.id) or ""
sweepSessions()
local s = sessions()[id]
if not s then return { status = 200, json = { state = "gone" } } end
if s.flow == "device-poll" and s.state == "pending" and s.device_code then
local plat = platformById(s.platform)
local done, payload = deviceOneStep(plat, s)
if done and payload then
if payload.ok then
-- 设备码到 token 后,有的平台还要第二跳(Cline register)换最终凭据
local cred, acct, rerr = deviceRegister(plat, payload)
if rerr then
s.state = "failed"
s.message = rerr
else
-- ⚠️ 必须带 account_id:页面流程先 account/add 建了占位账号,
-- 不带就会落到与占位账号无关的 `channels:<plat>` 键(见 poll_login 顶部注释)。
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = acct or payload.account, account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct or payload.account
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
else
s.state = "failed"
s.message = payload.message
end
end
elseif s.flow == "state-poll" and s.state == "pending" and s.state_token then
-- Buddy(CodeBuddy):state 轮询。状态里的 state_token 是 auth/state 下发的。
local plat = platformById(s.platform)
local sp = plat and plat.state
local resp, err = __llmsproxy_http({
url = (sp.token_url or "") .. "?state=" .. tostring(s.state_token),
method = "GET",
headers = { ["Accept"] = "application/json", ["X-No-Authorization"] = "true" },
})
-- 网络错误:继续轮询不中断(对齐参考 loopGetToken 的 catch 继续)
if not err and resp then
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" then
local code = rec.code
local data = rec.data
if resp.status == 200 and data ~= nil and type(data) == "table" and (data.accessToken or data.access_token) then
-- token 就绪。再拿账号(失败不阻塞,回退默认名)。
local access = data.accessToken or data.access_token
local accountData = nil
local aresp, aerr = __llmsproxy_http({
url = (sp.account_url or "") .. "?state=" .. tostring(s.state_token),
method = "GET",
headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. access },
})
if not aerr and aresp and aresp.status == 200 then
local aok, arec = pcall(json.decode, aresp.body or "")
if aok and type(arec) == "table" and type(arec.data) == "table" then
accountData = arec.data
end
end
-- ① 组合完整凭据 JSON(对齐参考 buildCredential):带 refresh_token /
-- expires_at / domain / user_id / nickname,而非只存裸 access_token。
-- 只存裸 token 会丢掉 refresh_token,导致 refreshable=false、无法静默
-- 续期,且 expires_at 未知。
local credTable = buildCredential(data, accountData)
local credJson = json.encode(credTable)
local acct = credTable.nickname
if acct == nil or acct == "" then
acct = credTable.user_id
end
if acct == nil or acct == "" then acct = "codebuddy" end
-- ② 必须带 account_id(页面流程的占位账号);否则凭据落到无关的
-- `channels:<plat>` 键,账号池查不到。
local res = plugin.complete_login({ platform = s.platform, credential = credJson,
account = acct, account_id = s.account_id,
expires_at = credTable.expires_at, refreshable = credentialIsRefreshable(credTable) })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
elseif code == sp.code_not_ready then
-- 11217 = 未就绪,继续轮询(对齐参考:业务码 11217 继续等)
elseif resp.status == 200 and data == nil then
-- data 为 null,继续轮询(参考明确此分支)
else
s.state = "failed"
s.message = "auth/token HTTP " .. tostring(resp.status) .. " code=" .. tostring(code)
end
end
end
end
return { status = 200, json = { state = s.state, account = s.account, message = s.message, interval = s.device_interval } }
end
-- plugin.complete_login(args) 由页面/回调调用:登录成功,存凭据并接入源。
--
-- 凭据只进保险库(0600、跨插件不可读),不进 state:state 会被
-- PUT /api/plugins/channels/state 整体替换,也会出现在 WebUI 里。
function plugin.complete_login(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 token = args and args.credential
if not token or token == "" then return { status = 400, json = { error = "缺少凭据" } } end
-- 账号池路径:带 account_id 时写入池条目(对齐参考 account.create 的
-- 占位账号语义 —— 先有账号行,凭据随后填入)。
local aid = args.account_id
if aid and aid ~= "" then
local acct = poolGet(plat.id, aid)
if not acct then return { status = 400, json = { error = "账号不存在: " .. tostring(aid) } } end
local ref = acct.credentialRef
local _, serr = __llmsproxy_secrets.set(ref, token)
if serr then return { status = 400, json = { error = serr } } end
-- 从凭据自动补齐展示/续期字段(调用方漏传也不丢)。
-- 对齐参考 saveCredential:昵称取 credential.nickname,
-- expiresAt 由 credentialExpiresAtMs 推断(expires_at 或 JWT exp),
-- refreshable 看是否携带 refresh_token。
local credTbl = nil
if type(token) == "string" and token:sub(1, 1) == "{" then
local dok, drec = pcall(json.decode, token)
if dok and type(drec) == "table" then credTbl = drec end
end
local derivedNick = credTbl and credTbl.nickname
if derivedNick == nil or derivedNick == "" then derivedNick = credTbl and credTbl.user_id end
local derivedExp = credTbl and credentialExpiresAtMs(credTbl)
acct.nickname = args.account or derivedNick or acct.nickname or aid
acct.expiresAt = args.expires_at or (derivedExp and tostring(derivedExp)) or acct.expiresAt
if args.refreshable ~= nil then
acct.refreshable = args.refreshable and true or false
elseif credTbl ~= nil then
acct.refreshable = credentialIsRefreshable(credTbl)
else
acct.refreshable = acct.refreshable or false
end
poolAdd(plat.id, acct)
-- 终结该账号的未决会话
for id, sess in pairs(sessions()) do
if sess.platform == plat.id and sess.state == "pending"
and (sess.account_id == aid or sess.pool_account_id == aid) then
sess.state = "completed"
end
end
logEvent("账号凭据已入池 " .. plat.id .. "/" .. aid)
syncAccountSource(plat.id, aid)
return { status = 200, json = { ok = true, account_id = aid, source = accountSourceName(plat.id, aid) } }
end
-- 兼容旧路径:单账号模式(e2e 与既有端点仍走它)。
local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token)
if err then return { status = 400, json = { error = err } } end
-- 上游源只吃 access token:凭据可能是完整 JSON(buddy 等 state-poll 平台),
-- 必须经 credentialOf 取出 access_token,不能把整个 JSON 当 api_key 发上去。
local legacyTok = credentialOf("channels:" .. plat.id) or token
plugin.state.accounts[plat.id] = {
account = args.account or "已登录",
added_at = os.time and os.time() or 0,
enabled = true,
}
-- 终结该平台未决会话
for id, sess in pairs(sessions()) do
if sess.platform == plat.id and sess.state == "pending" then sess.state = "completed" end
end
logEvent("已登录 " .. plat.name)
-- 接入为上游源 —— 这是本插件存在的意义
local r, aerr = __llmsproxy_kernel.call("add_source", {
source = {
name = "channels-" .. plat.id,
base_url = plat.base_url,
adapter = "openai",
api_key = legacyTok,
max_concurrent = 2,
models = (function()
local ms = {}
for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end
return ms
end)(),
},
})
if aerr then
-- 凭据已存下,但接入源失败。如实分开报告:把两者混成一个 ok=false 会让
-- 调用方以为登录失败从而重复登录。
logEvent("接入源失败 " .. plat.id .. ": " .. aerr)
return { status = 200, json = { ok = true, source_error = aerr } }
end
logEvent("已接入源 " .. r.name)
return { status = 200, json = { ok = true, source = r.name } }
end
-- ============================================================================
-- 额度领取
-- ============================================================================
-- plugin.claim_daily(platform_id) 领每日额度。设计为**可被定时任务调用**。
--
-- 只返回结果,不改 state 之外的东西:真正的额度体现在平台的响应里
-- (已用/剩余),本插件记录以便面板显示。
function plugin.claim_daily(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
if not plat.checkin or not plat.checkin.url then
return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } }
end
-- 账号池路径:claim 某个池账号;否则旧路径单凭据。
local tok, acctEntry
if args.account_id and args.account_id ~= "" then
acctEntry = poolGet(plat.id, args.account_id)
if not acctEntry then return { status = 400, json = { error = "账号不存在" } } end
tok = credentialOf(acctEntry.credentialRef)
else
tok = credentialOf("channels:" .. plat.id)
end
if not tok then return { status = 400, json = { error = "未登录" } } end
-- 平台可自定义领取请求形状;默认 Bearer + POST {}。
-- 参考实现的特殊形状(SDK-HMAC-SHA256 签名等)由该平台的 claim 钩子生成
-- (见 PLATFORMS 的 claim_fn),这里是通用路径。
local spec = {
url = plat.checkin.url,
method = plat.checkin.method or "POST",
headers = plat.checkin.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
body = plat.checkin.body or "{}",
}
if plat.claim_fn then
local custom, cerr = plat.claim_fn(plat, tok, args)
if cerr then return { status = 400, json = { error = cerr } } end
if custom then spec = custom end
end
local resp, err = __llmsproxy_http(spec)
if err then
logEvent("领取失败 " .. plat.id .. ": " .. err)
return { status = 400, json = { error = err } }
end
local acct
if acctEntry then
acct = acctEntry
else
acct = plugin.state.accounts[plat.id] or {}
end
acct.last_claim = os.time and os.time() or 0
acct.last_claim_status = resp.status
-- 平台可提供解析器把响应转成可读摘要(余额/获得额度)
if plat.claim_result_fn and resp.status == 200 then
local ok, summary = pcall(plat.claim_result_fn, resp.body)
if ok and summary then acct.last_claim_summary = summary end
end
if acctEntry then
poolAdd(plat.id, acctEntry)
else
plugin.state.accounts[plat.id] = acct
end
logEvent("领取返回 " .. tostring(resp.status))
return { status = 200, json = { ok = true, upstream_status = resp.status, body = resp.body, summary = acct.last_claim_summary } }
end
-- ============================================================================
-- 定时任务:自动领取
-- ============================================================================
--
-- 声明了 schedule 的插件会得到内核 ticker 驱动(无浏览器也执行),
-- 且任务跑在本插件自己的 VM 上 —— 所以任务里读 state/保险库与钩子看到的是同一份。
plugin.schedule = {
{ fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 },
}
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
local cap = CHANNEL_CREDITS[plat.id]
if not cap or not cap.claim then goto continue end
local legacy = accountOf(plat.id)
if legacy and legacy.enabled then
plugin.claim_daily({ platform = plat.id })
end
for _, a in ipairs(poolList(plat.id)) do
if a.enabled ~= false and credentialOf(a.credentialRef) then
plugin.claim_daily({ platform = plat.id, account_id = a.id })
end
end
::continue::
end
end
-- ============================================================================
-- 对外 HTTP 端点(设置页调用)
-- ============================================================================
plugin.http = {
{ path = "channels/platforms", method = "GET", handler = "list_platforms",
roles = { "admin" }, description = "平台清单与账号状态" },
{ path = "channels/login", method = "POST", handler = "start_login",
roles = { "admin" }, description = "发起某平台的登录" },
{ path = "channels/login/status", method = "GET", handler = "poll_login",
roles = { "admin" }, description = "轮询登录会话状态" },
{ path = "channels/login/complete", method = "POST", handler = "complete_login",
roles = { "admin" }, description = "登录完成:存凭据并接入源" },
{ path = "channels/claim", method = "POST", handler = "claim_daily",
roles = { "admin" }, description = "手动领取某平台每日额度" },
{ path = "channels/status", method = "GET", handler = "status_page",
roles = { "admin" }, description = "接入状态与最近日志" },
-- OAuth 回调落点:平台重定向回来带 code。
{ path = "channels/callback", method = "GET", handler = "oauth_callback",
public = false, roles = { "admin" }, description = "OAuth 回调" },
-- 账号池 / 模型开关 / 供应商治理 / 备份(对齐参考 channel-pack RPC 面)
{ path = "channels/account/add", method = "POST", handler = "account_add",
roles = { "admin" }, description = "向平台添加账号(占位或带凭据)" },
{ path = "channels/account/list", method = "POST", handler = "account_list",
roles = { "admin" }, description = "列平台账号池" },
{ path = "channels/account/update", method = "POST", handler = "account_update",
roles = { "admin" }, description = "改账号昵称/启用态" },
{ path = "channels/account/delete", method = "POST", handler = "account_delete",
roles = { "admin" }, description = "删除账号(含凭据与上游源)" },
{ path = "channels/account/test", method = "POST", handler = "account_test",
roles = { "admin" }, description = "测账号可达" },
{ path = "channels/account/refresh", method = "POST", handler = "account_refresh",
roles = { "admin" }, description = "刷新账号(重同步源)" },
{ path = "channels/account/reorder", method = "POST", handler = "account_reorder",
roles = { "admin" }, description = "调账号优先级" },
{ path = "channels/model/list", method = "POST", handler = "model_list",
roles = { "admin" }, description = "列平台模型与开关态" },
{ path = "channels/model/set_disabled", method = "POST", handler = "model_set_disabled",
roles = { "admin" }, description = "开关单个模型" },
{ path = "channels/model/set_all_disabled", method = "POST", handler = "model_set_all_disabled",
roles = { "admin" }, description = "全开/全关平台模型" },
{ path = "channels/provider/status", method = "POST", handler = "provider_status",
roles = { "admin" }, description = "全平台状态(账号数/模型数/关闭态)" },
{ path = "channels/provider/set_enabled", method = "POST", handler = "provider_set_enabled",
roles = { "admin" }, description = "供应商整体开关" },
{ path = "channels/credits/claim_all", method = "POST", handler = "credits_claim_all",
roles = { "admin" }, description = "一键领取全平台每日额度" },
{ path = "channels/backup/export", method = "POST", handler = "backup_export",
roles = { "admin" }, description = "导出账号备份(含凭据)" },
{ path = "channels/backup/import", method = "POST", handler = "backup_import",
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、
-- base_url)。两个用途:
-- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求)
-- 2. 测试把端点指到假上游,行为测试才能不打真厂商
-- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段
-- 会破坏后续逻辑对形状的假设。
-- ============================================================================
-- 账号池 RPC(对齐参考 account.* 契约)
-- ============================================================================
-- account_add:向平台加一个账号(占位或带凭据)。
-- 对应参考 account.create;登录类的流程返回登录 URL,凭据在 login 完成后回填。
function plugin.account_add(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 aid = args.account_id or newToken():sub(1, 12)
if poolGet(plat.id, aid) then return { status = 400, json = { error = "账号已存在: " .. aid } } end
local ref = "channels:" .. plat.id .. ":" .. aid
if args.credential and args.credential ~= "" then
local _, serr = __llmsproxy_secrets.set(ref, args.credential)
if serr then return { status = 400, json = { error = serr } } end
end
local now = os.time and os.time() or 0
local entry = {
id = aid, provider = plat.id,
nickname = args.nickname or aid,
enabled = args.enabled ~= false,
credentialRef = ref,
refreshable = args.refreshable or false,
createdAt = now,
expiresAt = args.expires_at,
last_claim = 0, last_claim_status = 0, last_claim_summary = "",
}
poolAdd(plat.id, entry)
logEvent("账号入池 " .. plat.id .. "/" .. aid)
-- 已带凭据的账号立即同步上游源;未带凭据的等 login 完成
if args.credential and args.credential ~= "" then syncAccountSource(plat.id, aid) end
return { status = 200, json = { ok = true, account_id = aid, credentialRef = ref } }
end
-- account_list:列平台的账号(对齐参考 account.list,含余额状态字段)
function plugin.account_list(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 has = credentialOf(a.credentialRef)
out[#out+1] = {
id = a.id, nickname = a.nickname, enabled = a.enabled ~= false,
credentialRef = a.credentialRef, refreshable = a.refreshable or false,
createdAt = a.createdAt, expiresAt = a.expiresAt,
has_credential = has ~= nil and has ~= "",
-- 签到/余额展示字段
last_claim = a.last_claim, last_claim_status = a.last_claim_status,
last_claim_summary = a.last_claim_summary,
}
end
return { status = 200, json = { ok = true, accounts = out } }
end
-- account_update:改昵称 / enabled(对齐 account.update)
function plugin.account_update(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
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
local patch = type(args.patch) == "table" and args.patch or {}
if patch.nickname ~= nil then acct.nickname = tostring(patch.nickname) end
if patch.enabled ~= nil then acct.enabled = patch.enabled and true or false end
poolAdd(plat.id, acct)
-- enabled 变化要同步上游源(禁用即删掉该源)
if patch.enabled ~= nil then
if acct.enabled then syncAccountSource(plat.id, acct.id)
else dropAccountSource(plat.id, acct.id) end
end
return { status = 200, json = { ok = true } }
end
-- account_delete:删账号 + 删凭据 + 删源(对齐 account.delete)
function plugin.account_delete(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
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
dropAccountSource(plat.id, acct.id)
__llmsproxy_secrets.delete(acct.credentialRef)
poolRemove(plat.id, acct.id)
logEvent("账号删除 " .. plat.id .. "/" .. acct.id)
return { status = 200, json = { ok = true } }
end
-- account_test:测账号(对齐 account.test)—— 做一次上游可达探测。
-- 简化:内核 probe_all 是全局的;这里用 list_sources 里该账号源存在与否 + 凭据非空作为可达判据。
function plugin.account_test(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
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
local tok = credentialOf(acct.credentialRef)
if not tok or tok == "" then return { status = 200, json = { ok = false, error = "无凭据" } } end
-- 同步一次源(确保最新凭据已入),返回同步结果
syncAccountSource(plat.id, acct.id)
local srcs, _ = __llmsproxy_kernel.call("list_sources", {})
local found = false
for _, s in ipairs(srcs or {}) do
if s.name == accountSourceName(plat.id, acct.id) then found = true break end
end
if not found then return { status = 200, json = { ok = false, error = "源未同步(上游可能不可达)" } } end
return { status = 200, json = { ok = true, ms = 1 } }
end
-- account_refresh:刷新账号(对齐 account.refresh)—— 重新同步源 + 重新查余额
function plugin.account_refresh(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
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
local tok = credentialOf(acct.credentialRef)
if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end
return { status = 200, json = { ok = true } }
end
-- account_reorder:调账号优先级(对齐 account.reorder)
function plugin.account_reorder(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 ordered = args and args.ordered_ids
if type(ordered) ~= "table" then return { status = 400, json = { error = "ordered_ids 必须是数组" } } end
for i, aid in ipairs(ordered) do
local acct = poolGet(plat.id, tostring(aid))
if acct then acct.priority = #ordered - i + 1 poolAdd(plat.id, acct) end
end
return { status = 200, json = { ok = true } }
end
-- ============================================================================
-- 模型开关 RPC(对齐参考 model.*)
-- ============================================================================
function plugin.model_list(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 out = {}
for i, m in ipairs(plat.models or {}) do
out[#out+1] = { id = m, name = m, disabled = disabled[m] and true or false, isFree = false }
end
return { status = 200, json = { ok = true, models = out } }
end
function plugin.model_set_disabled(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 mid = tostring(args and args.model_id or "")
if mid == "" then return { status = 400, json = { error = "缺 model_id" } } end
local disabled = disabledModelsOf(plat.id)
if args.disabled then disabled[mid] = true else disabled[mid] = nil end
-- 重同步该平台的全部源,让模型开关立即生效
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true, disabled = disabled[mid] and true or false } }
end
function plugin.model_set_all_disabled(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)
if args.disabled then
for _, m in ipairs(plat.models or {}) do disabled[m] = true end
else
for k in pairs(disabled) do disabled[k] = nil end
end
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true } }
end
-- ============================================================================
-- 供应商开关 + 状态(对齐参考 provider.*)
-- ============================================================================
function plugin.provider_status(req)
local out = {}
for _, p in ipairs(PLATFORMS) 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
local disabled = disabledModelsOf(p.id)
local disabledN = 0
for _, m in ipairs(p.models or {}) do if disabled[m] then disabledN = disabledN + 1 end end
local closed = (p.models and #p.models > 0) and disabledN == #p.models
local state_accounts = plugin.state.accounts[p.id]
local legacyN = (state_accounts and credentialOf("channels:" .. p.id)) and 1 or 0
out[#out+1] = {
id = p.id, name = p.name, flow = p.flow, closed = closed,
models = { total = #(p.models or {}), disabled = disabledN },
accounts = { total = #accts + legacyN, enabled = enabledN + legacyN },
}
end
return { status = 200, json = { ok = true, statuses = out } }
end
function plugin.provider_set_enabled(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 enabled = args.enabled and true or false
-- 对齐参考语义:关闭 = 全关模型 + 停用全部账号;打开 = 清空模型黑名单 + 启用全部账号。
local disabled = disabledModelsOf(plat.id)
if enabled then
for k in pairs(disabled) do disabled[k] = nil end
for _, a in ipairs(poolList(plat.id)) do a.enabled = true poolAdd(plat.id, a) syncAccountSource(plat.id, a.id) end
-- 旧路径也启用
local legacy = plugin.state.accounts[plat.id]
local legacyTok = credentialOf("channels:" .. plat.id)
if legacy and legacyTok then
__llmsproxy_kernel.call("add_source", { source = {
name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai",
api_key = legacyTok, max_concurrent = 2,
models = (function() local ms = {} for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(),
} })
end
else
for _, m in ipairs(plat.models or {}) do disabled[m] = true end
for _, a in ipairs(poolList(plat.id)) do a.enabled = false poolAdd(plat.id, a) dropAccountSource(plat.id, a.id) end
__llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id })
end
logEvent("供应商" .. (enabled and "启用 " or "关闭 ") .. plat.id)
return { status = 200, json = { ok = true } }
end
-- ============================================================================
-- 一键领取(对齐参考 credits.claimAll)
-- ============================================================================
function plugin.credits_claim_all(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 results = {}
local claimed = 0
for _, a in ipairs(poolList(plat.id)) do
local r = plugin.claim_daily({ platform = plat.id, account_id = a.id, credential_ref = a.credentialRef })
local ok = r.json and r.json.ok and true or false
if ok then claimed = claimed + 1 end
results[#results+1] = { accountId = a.id, outcome = ok
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
end
-- 无池账号的走旧路径
if #poolList(plat.id) == 0 then
local r = plugin.claim_daily({ platform = plat.id })
local ok = r.json and r.json.ok and true or false
if ok then claimed = claimed + 1 end
results[#results+1] = { accountId = "legacy", outcome = ok
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
end
return { status = 200, json = { ok = true, summary = { claimed = claimed }, results = results } }
end
-- ============================================================================
-- 备份导出/导入(对齐参考 backup.*)
-- ============================================================================
function plugin.backup_export(req)
local payload = {}
payload.version = 1
payload.exportedAt = os.time and os.time() or 0
payload.accounts = {}
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 _, a in ipairs(poolList(p.id)) do
payload.accounts[#payload.accounts+1] = a
local tok = credentialOf(a.credentialRef)
if tok then payload.credentials[a.credentialRef] = tok end
end
-- 旧路径单账号也备份
local lt = credentialOf("channels:" .. p.id)
if lt then payload.credentials["channels:" .. p.id] = lt end
end
return { status = 200, json = { ok = true, payload = payload } }
end
function plugin.backup_import(req)
local args = bodyOf(req)
local payload = args and args.payload
if type(payload) ~= "table" then return { status = 400, json = { error = "payload 必须是对象" } } end
local n = 0
-- 恢复凭据
if type(payload.credentials) == "table" then
for ref, tok in pairs(payload.credentials) do
if type(ref) == "string" and type(tok) == "string" and tok ~= "" then
__llmsproxy_secrets.set(ref, tok) n = n + 1
end
end
end
-- 恢复账号池
if type(payload.accounts) == "table" then
for _, a in ipairs(payload.accounts) do
if type(a) == "table" and a.id and a.provider then
poolAdd(tostring(a.provider), {
id = tostring(a.id), provider = tostring(a.provider),
nickname = a.nickname or a.id, enabled = a.enabled ~= false,
credentialRef = a.credentialRef or ("channels:" .. a.provider .. ":" .. a.id),
refreshable = a.refreshable or false, createdAt = a.createdAt or (os.time and os.time() or 0),
expiresAt = a.expiresAt, last_claim = 0, last_claim_status = 0, last_claim_summary = "",
})
end
end
end
-- 恢复模型黑名单
if type(payload.modelDisabled) == "table" then
plugin.state.modelDisabled = payload.modelDisabled
end
-- 恢复自动签到开关
if payload.autoCheckin ~= nil then
plugin.state.settings = plugin.state.settings or {}
plugin.state.settings.auto_checkin = payload.autoCheckin and true or false
end
-- 全部账号重新同步源
for _, p in ipairs(PLATFORMS) do
for _, a in ipairs(poolList(p.id)) do syncAccountSource(p.id, a.id) end
end
logEvent("备份已导入,恢复 " .. n .. " 条凭据")
return { status = 200, json = { ok = true, restored = n } }
end
-- auto_checkin 开关读写
function plugin.auto_checkin(req)
plugin.state.settings = plugin.state.settings or {}
local args = bodyOf(req)
if args.enabled ~= nil then
plugin.state.settings.auto_checkin = args.enabled and true or false
logEvent("自动签到" .. (plugin.state.settings.auto_checkin and "开启" or "关闭"))
end
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 = credentialOf(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 = credentialOf(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 = credentialOf("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
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local allowed = { base_url = true, login_url = true }
local changed = {}
for k in pairs(allowed) do
if args[k] then plat[k] = tostring(args[k]) changed[#changed+1] = k end
end
if type(args.device) == "table" and plat.device then
for _, k in ipairs({ "code_url", "token_url" }) do
if args.device[k] then plat.device[k] = tostring(args.device[k]) changed[#changed+1] = "device." .. k end
end
end
if type(args.register) == "table" and plat.register then
for _, k in ipairs({ "url" }) do
if args.register[k] then plat.register[k] = tostring(args.register[k]) changed[#changed+1] = "register." .. k end
end
end
if type(args.state) == "table" and plat.state then
for _, k in ipairs({ "state_url", "token_url", "account_url" }) do
if args.state[k] then plat.state[k] = tostring(args.state[k]) changed[#changed+1] = "state." .. k end
end
end
if type(args.checkin) == "table" and plat.checkin then
for _, k in ipairs({ "url" }) do
if args.checkin[k] then plat.checkin[k] = tostring(args.checkin[k]) changed[#changed+1] = "checkin." .. 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
end
end
if #changed == 0 then return { status = 400, json = { error = "无可识别的端点字段" } } end
logEvent("端点已改写 " .. plat.id .. ": " .. table.concat(changed, ","))
return { status = 200, json = { ok = true, changed = changed } }
end
function plugin.list_platforms(req)
local out = {}
for _, p in ipairs(PLATFORMS) do
local acct = accountOf(p.id) or {}
local has = credentialOf("channels:" .. p.id)
out[#out + 1] = {
id = p.id, name = p.name, flow = p.flow,
logged_in = has ~= nil,
account = acct.account,
enabled = acct.enabled,
last_claim = acct.last_claim,
}
end
return { status = 200, json = { platforms = out } }
end
function plugin.status_page(req)
local r = __llmsproxy_kernel.call("status", {})
return { status = 200, json = {
accounts = plugin.state.accounts,
log = plugin.state.log,
kernel_ready = r and r.ready or false,
} }
end
-- oauth_callback 处理平台重定向:校验 nonce,然后按平台的 callback 模式解析。
--
-- nonce 校验是必需的:没有它,别人构造一个回调就能把**自己的**凭据塞进
-- 这个网关的登录流程。
--
-- callback 模式(platform.callback.mode)决定如何从回调 query 解出凭据:
-- nil/"code" 标准 OAuth:直接把 code 当凭据交给 complete_login。
-- "trae" 字节 TRAE 的 token 直传形态,解析逐字对齐参考实现
-- parseTraeCallbackDetailed + exchangeTraeCallback(见 traeCallback)。
function plugin.oauth_callback(req)
local nonce = req.query and req.query.state
if not nonce or nonce == "" then
return { status = 400, body = "missing state" }
end
sweepSessions()
local s = nil
for _, sess in pairs(sessions()) do
if sess.nonce == nonce then s = sess break end
end
if not s then
return { status = 400, body = "unknown or expired login session" }
end
if s.flow ~= "callback" then
return { status = 400, body = "session is not a callback flow" }
end
local plat = platformById(s.platform)
local cb = plat and plat.callback or {}
local mode = cb.mode or "code"
local credential, account, cerr
if mode == "trae" then
credential, account, cerr = traeCallback(plat, req.query)
elseif mode == "code" and cb.token_url then
-- 标准 OAuth code → token 交换(Gemini / LobsterAI)。凭据是交换回的 access_token。
local code = req.query.code
if not code or code == "" then
cerr = "missing code"
else
local body = (cb.token_body or "")
:gsub("{code}", code)
:gsub("{verifier}", s.device_verifier or "")
local resp, err = __llmsproxy_http({
url = cb.token_url,
method = "POST",
headers = { ["Content-Type"] = "application/x-www-form-urlencoded", ["Accept"] = "application/json" },
body = body,
})
if err then
cerr = "token 交换失败: " .. err
elseif resp.status ~= 200 then
cerr = "token 交换失败(HTTP " .. tostring(resp.status) .. ")"
else
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or not rec.access_token then
cerr = "token 交换响应缺少 access_token"
else
credential = rec.access_token
account = "oauth"
end
end
end
else
credential = req.query.code
account = "oauth"
if not credential or credential == "" then cerr = "missing code" end
end
if cerr then
s.state = "failed"
s.message = cerr
return { status = 400, body = cerr }
end
local res = plugin.complete_login({ platform = s.platform, credential = credential, account = account })
if res.json and res.json.error then
return { status = 400, body = res.json.error }
end
s.state = "completed"
s.account = account
return { status = 200, body = "ok" }
end
-- traeCallback 解析 TRAE 的 token 直传回调并(必要时)走 ExchangeToken。
-- 返回 (credential, account, nil) 或 (nil, nil, err)。
--
-- 行为逐行对齐参考实现 trae-oauth.ts:
-- refreshToken(query 或 userJwt.RefreshToken 回退)存在 → ExchangeToken 换新
-- access(并轮换 refreshToken);凭据 = 新 access。
-- 无 refreshToken 但有 userJwt.Token → 直接用它兜底(不走 ExchangeToken)。
-- 二者皆无、但有 code/authCodeInfo → **PKCE 流**,明确报"暂不支持"而非含糊失败。
-- 都没有 → 回调无效。
local function jsonField(raw, key)
if not raw or raw == "" then return "" end
local ok, v = pcall(json.decode, raw)
if not ok or type(v) ~= "table" then return "" end
return v[key] or ""
end
function traeCallback(plat, q)
q = q or {}
local userJwt = q.userJwt or ""
local refreshToken = q.refreshToken
if not refreshToken or refreshToken == "" then
refreshToken = jsonField(userJwt, "RefreshToken")
end
local jwtToken = jsonField(userJwt, "Token")
local account = jsonField(q.userInfo or "", "ScreenName")
if account == "" then account = "trae" end
-- PKCE 形态探测:仅在 refreshToken / jwtToken 都空时才判,与参考一致。
local authCode = q.code or q.authCode or jsonField(q.authCodeInfo or "", "code")
if (not refreshToken or refreshToken == "") and (not jwtToken or jwtToken == "") then
if authCode and authCode ~= "" then
return nil, nil, "上游返回了 PKCE 授权码(code/authCodeInfo),本实现暂不支持该流程"
end
return nil, nil, "回调未携带 refreshToken / userJwt.Token / code"
end
-- 分支 1:有 refreshToken → ExchangeToken 换新 access(轮换 refresh)
if refreshToken and refreshToken ~= "" and plat.callback.exchange_url then
local body = (plat.callback.exchange_body
:gsub("{client_id}", plat.callback.client_id or "")
:gsub("{refresh_token}", refreshToken))
local resp, err = __llmsproxy_http({
url = plat.callback.exchange_url,
method = "POST",
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
body = body,
})
if err then return nil, nil, "TRAE ExchangeToken 失败: " .. err end
if resp.status ~= 200 then
return nil, nil, "TRAE ExchangeToken HTTP " .. tostring(resp.status)
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return nil, nil, "TRAE ExchangeToken 响应无法解析"
end
local access = rec.Token or rec.token or rec.AccessToken or rec.access_token
if not access or access == "" then
return nil, nil, "TRAE ExchangeToken 响应缺少 Token"
end
return access, account, nil
end
-- 分支 2:无 refreshToken,用 userJwt.Token 兜底
return jwtToken, account, nil
end
-- plugin.on_unload:插件被卸载/重载时回收自己拉起的浏览器栈。进程是插件起的,
-- 收尾也是插件的责任 —— 内核不应知道这些进程的存在。
plugin.on_unload = function()
reapBrowser()
end
-- ============================================================================
-- UI:独立标签页(登录 / 认证 / 配置)
-- ============================================================================
plugin.ui = {
page = {
page_id = "channels",
title = "账号接入",
icon = "🔌",
order = 40,
mount = [[
<div id="channels-page">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<h2 style="margin:0">渠道控制台</h2>
<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)">
<label style="display:flex;gap:6px;align-items:center;font-size:12px">
<input type="checkbox" id="ch-auto" onchange="chAutoToggle(this.checked)"> 每日自动签到
</label>
</div>
</div>
<div id="ch-summary" style="display:flex;gap:18px;margin:10px 0;font-size:13px;color:var(--muted,#888)"></div>
<div id="ch-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px"></div>
</div>
<!-- 渠道详情:账号池 + 模型开关(对齐参考 provider-detail) -->
<div class="card" id="ch-detail" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center">
<button class="small" onclick="chSelect(undefined)">← 返回全部渠道</button>
<span class="muted" style="font-size:12px">账号凭据加密保存在服务端,页面永远拿不到明文。</span>
</div>
<h2 id="ch-detail-name" style="margin:10px 0 4px"></h2>
<p class="muted" id="ch-detail-org" style="margin:0 0 12px;font-size:13px"></p>
<div id="ch-detail-actions" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px"></div>
<p id="ch-notice" class="muted" style="font-size:13px;min-height:18px"></p>
<div id="ch-accts"></div>
<div style="display:flex;gap:8px;align-items:center;margin:14px 0 6px">
<b style="font-size:13px">模型</b>
<span id="ch-model-count" class="muted" style="font-size:12px"></span>
<button class="small" onclick="chModelAll(false)">全部启用</button>
<button class="small" onclick="chModelAll(true)">全部关闭</button>
</div>
<div id="ch-models" style="display:flex;flex-direction:column;gap:4px"></div>
<!-- 登录区:发起登录后显示(device-poll 轮询 / callback URL / vnc / paste / manual) -->
<div id="ch-login-box" style="display:none;margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
<h3 style="margin:4px 0">登录</h3>
<div id="ch-login-body"></div>
<div id="ch-vnc-wrap" style="display:none;margin-top:10px">
<p class="muted" style="font-size:12px">请在下面的窗口里完成登录,完成后本页会自动继续。</p>
<iframe id="ch-vnc" style="width:100%;height:640px;border:1px solid var(--line);border-radius:8px" allow="clipboard-read; clipboard-write"></iframe>
</div>
</div>
<h3 style="margin:16px 0 6px;font-size:13px">最近日志</h3>
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px"></ul>
</div>
</div>
<script>
(function () {
const q = (s) => document.querySelector(s);
const esc = (v) => String(v == null ? "" : v).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
async function call(path, method, body) {
const r = await fetch("/api/plugins/http/" + path, {
method: method || "GET",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: body ? JSON.stringify(body) : undefined,
});
return await r.json().catch(() => ({}));
}
// POST 端点统一走 call(path,"POST",...);账号/模型/供应商操作都按这个形状。
let state = { statuses: [], filter: "all", search: "", selected: undefined,
accounts: undefined, models: undefined, login: null, pollTimer: null };
const stopPoll = () => { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } };
async function loadStatuses() {
const j = await call("channels/provider/status", "POST", {});
state.statuses = j.statuses || [];
renderSummary(); renderGrid();
const sj = await call("channels/status");
const log = q("#ch-log"); if (log) log.innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
const aj = await call("channels/auto_checkin", "POST", {});
q("#ch-auto").checked = aj.autoCheckin !== false;
}
function renderSummary() {
const total = state.statuses.length;
const connected = state.statuses.filter((s) => !s.closed && s.accounts.enabled > 0).length;
const accounts = state.statuses.reduce((n, s) => n + (s.accounts.total || 0), 0);
q("#ch-summary").innerHTML =
"<span>已接入渠道 <b>" + connected + " / " + total + "</b></span>" +
"<span>本机账号 <b>" + accounts + "</b></span>";
const counts = { all: total, connected, pending: total - connected };
const defs = [{ id: "all", label: "全部" }, { id: "connected", label: "已接入" }, { id: "pending", label: "待接入" }];
q("#ch-filters").innerHTML = defs.map((d) =>
'<button class="small" data-f="' + d.id + '" style="' + (state.filter===d.id?"font-weight:bold;":"") + '" onclick="chFilter(\'' + d.id + '\')">' + d.label + ' (' + counts[d.id] + ')</button>').join("");
}
function renderGrid() {
const list = state.statuses.filter((s) => {
const hit = (s.id + " " + s.name).toLowerCase().includes(state.search);
const on = !s.closed && s.accounts.enabled > 0;
return hit && (state.filter === "all" || (state.filter === "connected") === on);
});
q("#ch-grid").innerHTML = list.map((s) => {
const on = !s.closed && s.accounts.enabled > 0;
const st = s.closed ? '<span class="tag">已关闭</span>'
: on ? '<span class="tag tag-green">已接入</span>' : '<span class="tag">待接入</span>';
return '<div class="card" style="margin:0;padding:12px;cursor:pointer" onclick="chSelect(\'' + esc(s.id) + '\')">' +
'<div style="display:flex;justify-content:space-between;align-items:center">' +
'<b>' + esc(s.name) + '</b>' + st + '</div>' +
'<div class="muted" style="font-size:12px;margin:6px 0">' + esc(s.id) + ' · ' + esc(s.flow) + '</div>' +
'<div style="font-size:12px">账号 <b>' + (s.accounts.enabled||0) + '/' + (s.accounts.total||0) + '</b>' +
' · 模型 <b>' + ((s.models.total||0)-(s.models.disabled||0)) + '/' + (s.models.total||0) + '</b></div>' +
'</div>';
}).join("") || '<p class="muted">没有匹配的渠道</p>';
}
async function renderDetail() {
const id = state.selected;
const box = q("#ch-detail");
if (!id) { box.style.display = "none"; q("#ch-grid").parentElement.style.display = "block"; return; }
box.style.display = "block";
q("#ch-grid").parentElement.style.display = "none";
const s = state.statuses.find((x) => x.id === id) || {};
q("#ch-detail-name").textContent = s.name || id;
q("#ch-detail-org").textContent = id + " · " + (s.flow || "");
const cap = (await call("channels/auto_checkin","POST",{})) && true; // noop,保持顺序
// 动作区:登录(加账号)/ 刷新全部 / 一键领取 / 供应商开关
const acts = [];
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 = "";
await Promise.all([renderAccounts(id), renderModels(id)]);
}
async function renderAccounts(id) {
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) => {
const badge = a.has_credential
? (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 + 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>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'refresh\')">刷新</button>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'toggle\')">' + (a.enabled !== false ? "停用" : "启用") + '</button>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'priority\')">优先</button>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'delete\')">删除</button>' +
'</div>';
}).join("");
}
async function renderModels(id) {
const j = await call("channels/model/list", "POST", { platform: id });
const ms = j.models || [];
state.models = ms;
const on = ms.filter((m) => !m.disabled).length;
q("#ch-model-count").textContent = on + "/" + ms.length + " 启用";
q("#ch-models").innerHTML = ms.map((m) =>
'<div style="display:flex;gap:8px;align-items:center;padding:4px 0">' +
'<span style="flex:1;font-size:13px" title="' + esc(m.id) + '">' + esc(m.name || m.id) + '</span>' +
'<button class="small" onclick="chModel(\'' + esc(m.id) + '\',' + (!m.disabled) + ')">' + (m.disabled ? "开启" : "关闭") + '</button>' +
'</div>').join("") || '<p class="muted">—</p>';
}
// ---- 动作 ----
window.chFilter = (f) => { state.filter = f; renderSummary(); renderGrid(); };
q("#ch-search").addEventListener("input", (e) => { state.search = e.target.value.toLowerCase(); renderGrid(); });
window.chSelect = async (id) => { state.selected = id; await renderDetail(); };
window.chAcct = async (aid, act) => {
const id = state.selected; if (!id) return;
if (act === "delete" && !confirm("删除账号 " + aid + "?(连同凭据与上游源)")) return;
if (act === "toggle") {
const a = (state.accounts || []).find((x) => x.id === aid);
await call("channels/account/update", "POST", { platform: id, account_id: aid, patch: { enabled: !(a && a.enabled !== false) } });
} else if (act === "priority") {
await call("channels/account/reorder", "POST", { platform: id, ordered_ids: [aid].concat((state.accounts||[]).map((x)=>x.id).filter((x)=>x!==aid)) });
} else {
await call("channels/account/" + act, "POST", { platform: id, account_id: aid });
}
await renderAccounts(id); await loadStatuses();
};
window.chModel = async (mid, disabled) => {
const id = state.selected; if (!id) return;
await call("channels/model/set_disabled", "POST", { platform: id, model_id: mid, disabled });
await renderModels(id); await loadStatuses();
};
window.chModelAll = async (disabled) => {
const id = state.selected; if (!id) return;
await call("channels/model/set_all_disabled", "POST", { platform: id, disabled });
await renderModels(id); await loadStatuses();
};
window.chProviderToggle = async (enable) => {
const id = state.selected; if (!id) return;
if (!enable && !confirm("关闭渠道会停用全部账号与模型,确认?")) return;
await call("channels/provider/set_enabled", "POST", { platform: id, enabled: enable });
await loadStatuses(); await renderDetail();
};
window.chRefreshAll = async () => {
const id = state.selected; if (!id) return;
for (const a of state.accounts || []) await call("channels/account/refresh", "POST", { platform: id, account_id: a.id });
notice("已刷新全部账号"); await renderAccounts(id);
};
window.chClaimAll = async () => {
const id = state.selected; if (!id) return;
notice("领取中…");
const j = await call("channels/credits/claim_all", "POST", { platform: id });
const res = j.results || [];
const okN = res.filter((r) => r.outcome && r.outcome.kind === "success").length;
const failN = res.filter((r) => r.outcome && r.outcome.kind === "failed");
notice("领取完成:成功 " + okN + " 个账号" + (failN.length ? ";失败:" + failN.map((r) => r.outcome.message).join(";") : ""));
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" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "channels-backup-" + new Date().toISOString().slice(0,10) + ".json";
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
};
window.chImportFile = async (ev) => {
const f = ev.target.files[0]; ev.target.value = ""; if (!f) return;
if (f.size > 8*1024*1024) { alert("备份文件不能超过 8MB"); return; }
if (!confirm("导入会合并备份中的账号凭据、模型开关与自动签到设置,确认?")) return;
const payload = JSON.parse(await f.text());
const j = await call("channels/backup/import", "POST", { payload });
alert(j.error ? ("导入失败:" + j.error) : ("已导入,恢复 " + (j.restored||0) + " 条凭据"));
await loadStatuses();
};
function notice(msg) { q("#ch-notice").textContent = msg; }
// ---- 登录(对齐参考两步式:立即拿 URL/弹窗,后台轮询完成)----
window.chStartLogin = async () => {
const id = state.selected; if (!id) return;
// 池路径:先建占位账号,登录完成后回填凭据到这个账号
const aj = await call("channels/account/add", "POST", { platform: id });
if (aj.error) { alert(aj.error); return; }
const aid = aj.account_id;
const j = await call("channels/login", "POST", { platform: id, account_id: aid });
if (j.error) { alert(j.error); return; }
const box = q("#ch-login-box"); box.style.display = "block";
const vnc = q("#ch-vnc-wrap");
if (j.view_url) {
q("#ch-login-body").innerHTML = "<p class='muted' id='ch-login-state'>请在下方窗口完成登录…</p>";
vnc.style.display = "block"; q("#ch-vnc").src = j.view_url;
} else if (j.manual_hint) {
vnc.style.display = "none";
q("#ch-login-body").innerHTML =
"<p class='muted'>" + esc(j.manual_hint) + "</p>" +
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='把凭据粘贴到这里'></textarea></p>" +
"<p><button class='small' onclick='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
"<p class='muted' id='ch-login-state'></p>";
} else if (j.session && j.session.flow === "paste") {
vnc.style.display = "none";
q("#ch-login-body").innerHTML =
"<p>把该平台的 API key / token 粘贴到这里:</p>" +
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='API key / token'></textarea></p>" +
"<p><button class='small' onclick='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
"<p class='muted' id='ch-login-state'></p>";
} else {
vnc.style.display = "none";
q("#ch-login-body").innerHTML =
"<p>请在你自己的浏览器打开下面的地址完成登录:</p>" +
"<p><a href='" + esc(j.url) + "' target='_blank' rel='noopener'>" + esc(j.url) + "</a></p>" +
(j.session && j.session.user_code ? "<p>设备码:<b>" + esc(j.session.user_code) + "</b></p>" : "") +
"<p class='muted' id='ch-login-state'>等待完成…</p>";
}
stopPoll();
if (!(j.manual_hint || (j.session && j.session.flow === "paste"))) {
state.pollTimer = setInterval(async () => {
const st = await call("channels/login/status?id=" + encodeURIComponent(j.session.id));
const el = q("#ch-login-state"); if (el) el.textContent = "状态:" + st.state + (st.account ? "(" + st.account + ")" : "");
if (st.state === "completed" || st.state === "failed" || st.state === "expired") {
stopPoll(); q("#ch-vnc").src = "about:blank"; vnc.style.display = "none";
box.style.display = "none";
if (st.state === "failed") notice("登录未完成:" + (st.message || st.state));
await renderAccounts(id); await loadStatuses();
}
}, 2000);
}
};
// paste / manual 的凭据提交:落到占位账号(池路径),完成即同步源
window.chPaste = async (aid) => {
const id = state.selected; if (!id || !aid) return;
const cred = (q("#ch-cred") && q("#ch-cred").value || "").trim();
if (!cred) { alert("凭据为空"); return; }
const el = q("#ch-login-state"); if (el) el.textContent = "提交中…";
const j = await call("channels/login/complete", "POST", { platform: id, credential: cred, account_id: aid });
if (j.error) { if (el) el.textContent = "失败:" + j.error; return; }
q("#ch-login-box").style.display = "none"; q("#ch-cred").value = "";
notice("账号已接入:" + aid);
await renderAccounts(id); await loadStatuses();
};
loadStatuses();
})();
</script>
]],
},
}
return plugin