Files
ModelRouter/internal/lua/plugins/channels.lua
JianFeeeee 2f5cde7fea test(e2e): channels device-poll 真二进制端到端 + 修正插件 HTTP 前缀
TestChannelsDevicePollE2E 用真二进制跑通完整链路(对齐参考插件形态的终局验收):

  假平台上游(设备码/token/模型/对话端点)
  → 真网关进程 + plugin_dir(SeedBundled 落地 channels)
  → PUT /api/plugins/channels/kernel 授权(运维批准动作面)
  → call/set_endpoints 把 minimax 端点指到假上游(自建代理能力复用)
  → POST /api/plugins/http/channels/login 发起 device-poll
  → GET .../login/status 轮询:先 pending(authorization_pending),
    后 completed(token 下发)且 account 取 nickname
  → 终局证据:/v1/models 出现 channels-minimax 的模型 MiniMax-M3

调试中抓到并修复:channels.lua 三处旧挂载前缀 /api/plugins/channels/http/
(正确为 /api/plugins/http/,spawnBrowser 回调路径、callback redirect_uri、
UI fetch 基址)—— UI 上点登录/回调一直会 404。

顺带:e2e 就绪探针复用 startGateway(无源时 /v1/models 空列表也 200)。
2026-10-11 21:44:40 +08:00

913 lines
40 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" }
-- ============================================================================
-- 平台表
-- ============================================================================
--
-- 每个平台声明登录方式与各端点。字段:
-- 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 = "{}",
},
},
-- 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 = "{}",
},
},
-- 服务端浏览器兜底示范(回环重定向类)。真实厂商若需要可改 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 = "{}" },
},
}
-- 申请的额外出站主机:从平台声明推导(登录域 + 上游域),避免两份清单漂移。
-- 内核里**不含**任何厂商名 —— 这些只存在于本插件,且需运维在设置页逐个批准。
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
local status = rec.status
local ecode = rec.error
-- 形态一:HTTP 200 + status
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) + 5
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
-- 形态二:非 200 + error(OAuth 标准形态)
if ecode == "authorization_pending" then return false end
if ecode == "slow_down" then
sess.device_interval = (sess.device_interval or d.interval or 5) + 5
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
-- 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
-- ============================================================================
-- 登录
-- ============================================================================
-- 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
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 == "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)。
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
-- 成功:落凭据 + 接入源
local res = plugin.complete_login({ platform = s.platform, credential = payload.access_token, account = payload.account })
if res.json and res.json.ok then
s.state = "completed"
s.account = payload.account
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
else
s.state = "failed"
s.message = payload.message
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
local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token)
if err then return { status = 400, json = { error = err } } end
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 = token,
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
local tok = __llmsproxy_secrets.get("channels:" .. plat.id)
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 = plugin.state.accounts[plat.id] or {}
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
plugin.state.accounts[plat.id] = acct
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()
for _, plat in ipairs(PLATFORMS) do
local acct = accountOf(plat.id)
if acct and acct.enabled then
plugin.claim_daily({ platform = plat.id })
end
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 回调" },
}
-- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、
-- base_url)。两个用途:
-- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求)
-- 2. 测试把端点指到假上游,行为测试才能不打真厂商
-- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段
-- 会破坏后续逻辑对形状的假设。
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.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 #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 = __llmsproxy_secrets.get("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,然后交给 complete_login。
--
-- nonce 校验是必需的:没有它,别人构造一个回调就能把**自己的**凭据塞进
-- 这个网关的登录流程。
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 code = req.query.code
if not code or code == "" then
return { status = 400, body = "missing code" }
end
local res = plugin.complete_login({ platform = s.platform, credential = code, account = "oauth" })
if res.error then
return { status = 400, body = res.error }
end
s.state = "completed"
s.account = "oauth"
return { status = 200, body = "ok" }
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">
<h2>平台接入</h2>
<p class="muted">登录在你自己的浏览器完成;凭据加密保存在服务端,凭据永不回显。</p>
<div id="ch-list" class="tbl-wrap"></div>
</div>
<div class="card" id="ch-login-box" style="display:none">
<h2 id="ch-login-title">登录</h2>
<div id="ch-login-body"></div>
<!-- 服务端浏览器内嵌区(仅 VNC 兜底流程用;本机浏览器流程此区隐藏)。 -->
<div id="ch-vnc-wrap" style="display:none;margin-top:10px">
<p class="muted">请在下面的窗口里完成登录,完成后本页会自动继续。</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>
<div class="card">
<h2>最近日志</h2>
<ul id="ch-log" style="font-size:12px"></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(() => ({}));
}
let pollTimer = null;
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
async function refresh() {
const j = await call("channels/platforms");
const rows = (j.platforms || []).map((p) => {
const st = p.logged_in ? '<span class="tag tag-green">已登录</span>' : '<span class="tag">未登录</span>';
const done = p.logged_in
? '<button class="small" onclick="chClaim(\'' + esc(p.id) + '\')">领取额度</button>'
: '<button class="small" onclick="chLogin(\'' + esc(p.id) + '\')">登录</button>';
return "<tr><td><b>" + esc(p.name) + "</b><div class='muted'>" + esc(p.flow) + "</div></td>" +
"<td>" + st + "</td><td>" + esc(p.account || "") + "</td><td>" + done + "</td></tr>";
}).join("");
q("#ch-list").innerHTML = "<table><tr><th>平台</th><th>状态</th><th>账号</th><th></th></tr>" +
(rows || "<tr><td colspan=4 class='empty'>无平台</td></tr>") + "</table>";
const sj = await call("channels/status");
q("#ch-log").innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
}
window.chLogin = async function (id) {
const j = await call("channels/login", "POST", { platform: id });
if (j.error) { alert(j.error); return; }
const box = q("#ch-login-box"); box.style.display = "block";
q("#ch-login-title").textContent = "登录:" + id;
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 {
// 用户自己的浏览器足够(link-poll / callback / paste)
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();
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;
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"; refresh();
}
}, 2000);
};
window.chClaim = async function (id) {
const j = await call("channels/claim", "POST", { platform: id });
alert(j.error ? ("失败: " + j.error) : ("领取返回 " + j.status));
refresh();
};
refresh();
})();
</script>
]],
},
}
return plugin