-- 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/channels/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/channels/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 = [[

平台接入

登录在你自己的浏览器完成;凭据加密保存在服务端,凭据永不回显。

最近日志

]], }, } return plugin