-- channels:多平台账号接入(登录认证 → 凭据 → 自动领取额度 → 注册为上游源) -- -- 为什么需要它 -- ------------ -- 网关的上游源(sources)一直是**运维手工配置**的:填 base_url、填 api_key。 -- 而现实里有大量平台是"注册个账号、每天领免费额度"的形态,它们的接入方式是 -- **登录**而不是"申请一个 key"。这类平台要接入网关,就需要:驱动登录 → 拿到 -- 凭据 → 定时领取额度 → 把端点当源用。 -- -- 本插件把这套流程做成通用的:一个插件内建平台表,每个平台声明自己的 -- 登录方式(link-poll / callback / paste)、额度领取接口、上游端点形状。 -- 新增一个平台是往 PLATFORMS 里加一条,不改内核。 -- -- 设计边界(与内核的约定) -- ------------------------ -- 内核提供三样基础设施,插件自己不实现这些: -- -- __llmsproxy_login 登录会话生命周期(create/poll/resolve) -- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读) -- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…) -- -- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状。 -- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"—— -- 内核里出现厂商名就是分层被破坏了。 -- -- 权限(本插件需要,全部可在设置页看到"申请中") -- ------------------------------------------------ -- add_source 把平台端点注册为上游源 -- list_sources 显示当前已接入的平台 -- probe_all 接入后立刻验证可达 -- reset_health 上游恢复后清冷却 -- status 面板显示运行状态 -- -- 登录主机也需批准(plugin.auth_hosts):登录页所在的域名不是上游源, -- 默认会被出站白名单拒绝——这是刻意的。 local plugin = { name = "channels", version = "1.0.0", description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源", author = "llmsproxy", } -- 申请的内核权限。设置页会把"申请中但未授权"标红。 plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" } -- 申请的登录主机(出站白名单)。内核里**不含**任何厂商名——这些只存在于插件。 plugin.auth_hosts = { "auth.example.com", "accounts.google.com" } -- ============================================================================ -- 平台表 -- ============================================================================ -- -- 每个平台声明四件事: -- flow 登录方式:link-poll(给 URL 轮询)/ callback(OAuth 重定向)/ -- paste(用户粘贴 cookie) -- login_url 用户要在**自己浏览器**打开的地址(link-poll/callback) -- claim_path 额度领取接口(相对 providers 的 base) -- source 接入网关时用的源定义模板({platform} 会被替换) -- -- 新增平台 = 加一条。不需要动内核,也不需要动本文件的其它部分。 local PLATFORMS = { { id = "example", name = "Example 平台", flow = "link-poll", login_url = "https://auth.example.com/device", claim_path = "/api/daily/claim", base_url = "https://api.example.com/v1", models = { "example-chat", "example-reason" }, }, { id = "googleish", name = "Google 系", flow = "callback", login_url = "https://accounts.google.com/o/oauth2/auth", claim_path = "/api/quota/claim", base_url = "https://api.googleish.example/v1", models = { "g-model-1" }, }, { id = "loopbackish", name = "回环登录平台", -- 该类平台把授权重定向到 127.0.0.1。用户机器上的浏览器收不到(那是它自己的 -- 回环),所以必须在服务端开浏览器 —— 本插件会把它内嵌进这个页签。 flow = "vnc", needs_browser = true, login_url = "https://auth.loopbackish.example/authorize?redirect_uri=http://127.0.0.1:%d/callback", claim_path = "/api/quota/claim", base_url = "https://api.loopbackish.example/v1", models = { "lb-model-1" }, }, { id = "cookieish", name = "Cookie 平台", flow = "paste", claim_path = "/api/credit/claim", base_url = "https://api.cookieish.example/v1", models = { "c-model-1" }, }, } -- 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 -- 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 流程)或本插件轮询到结果 -- (link-poll 流程)。 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 local s, err = __llmsproxy_login.begin({ platform = plat.id, flow = plat.flow, url = plat.login_url, }) if err then return { status = 400, json = { error = err } } end logEvent("开始登录 " .. plat.name) -- 回环类平台:必须在**服务端**开浏览器。 -- 理由:授权重定向打到 127.0.0.1,用户机器上的那个是**用户自己的**回环, -- 服务端收不到 code。只有浏览器跑在服务端,重定向才落到本进程的监听器上。 local view_url if plat.flow == "vnc" then -- 先建一个回环监听会话(拿到 callback_port 才能拼出 redirect_uri) local bs, berr = __llmsproxy_login.start_browser({ platform = plat.id, start_url = string.format(plat.login_url, 0), -- 端口占位,下面替换 callback_port = 0, }) if berr then logEvent("服务端浏览器启动失败: " .. berr) return { status = 200, json = { url = s.url, session = s, platform = plat.id, browser_error = berr, hint = "服务端浏览器未启用或不可用;可在设置页开启 VNC 流程(默认关闭)", }, } end view_url = bs.view_url logEvent("服务端浏览器已就绪 " .. plat.id .. " 回调端口=" .. tostring(bs.callback_port)) end return { status = 200, json = { url = s.url, session = s, platform = plat.id, view_url = view_url, } } end -- plugin.poll_login(session_id) 由页面轮询:会话是否完成。 function plugin.poll_login(req) local id = (req and req.query and req.query.id) or "" local s = __llmsproxy_login.get(id) if not s then return { status = 200, json = { state = "gone" } } end return { status = 200, json = { state = s.state, account = s.account, message = s.message } } 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, } 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 local tok = __llmsproxy_secrets.get("channels:" .. plat.id) if not tok then return { status = 400, json = { error = "未登录" } } end local resp, err = __llmsproxy_http({ url = plat.base_url .. plat.claim_path, method = "POST", headers = { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }, body = "{}", }) 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 plugin.state.accounts[plat.id] = acct logEvent("领取返回 " .. tostring(resp.status)) return { status = 200, json = { ok = true, upstream_status = resp.status, body = resp.body } } 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 回调" }, } 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 local s = __llmsproxy_login.get(nonce) 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 __llmsproxy_login.resolve(s.id, "completed", "oauth", "") return { status = 200, body = "ok" } end -- ============================================================================ -- UI:独立标签页(登录 / 认证 / 配置) -- ============================================================================ plugin.ui = { page = { page_id = "channels", title = "账号接入", icon = "🔌", order = 40, mount = [[
登录在你自己的浏览器完成;凭据加密保存在服务端,凭据永不回显。