-- 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 的假平台、示范各种 flow 的 -- 占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接 -- 展示给运维,夹具写进来就是「生产 UI 里凭空多出 4 个假平台」。 -- 夹具由测试经 channels/inject_platforms 端点运行时注入。 -- 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", -- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。 -- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。 -- X-Domain 以产品配置为准(不用凭据里的 domain 快照,那是登录时的区域, -- 跨产品迁移后会指错区);X-Product 是部署类型(SaaS); -- X-Product-Code 是产品码(codebuddy);X-User-Id 取凭据的 user_id。 headers_fn = function(accessToken, cred) local h = { ["Authorization"] = "Bearer " .. accessToken, ["Accept"] = "application/json", ["Content-Type"] = "application/json", ["X-Domain"] = "copilot.tencent.com", ["X-Product"] = "SaaS", ["X-Product-Code"] = "codebuddy", ["User-Agent"] = "CodeBuddyIDE/1.106.1", } local uid = cred and (cred.user_id or cred.uid) if type(uid) == "string" and uid ~= "" then h["X-User-Id"] = uid end local eid = cred and cred.enterprise_id if type(eid) == "string" and eid ~= "" then h["X-Enterprise-Id"] = eid h["X-Tenant-Id"] = eid end return h end, 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) -- -- ⚠️ 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 -- data 不同),这是本接口最容易解析错的地方。 -- -- ⚠️ 刻意**不用**服务端的 TotalDosage:它是**终身口径**且取整(实测同一 -- 响应里 TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts[] -- 现算:只累加**有效包**的 CycleCapacityRemain(失效包服务端仍会返回, -- 但不能用于扣费,并进总额会让数字虚高),最后按两位小数规整。 balance = { url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource", method = "POST", body = "{}", total_fn = function(rec) local data = rec.data if type(data) ~= "table" then return nil end local resp = data.Response if type(resp) ~= "table" then return nil end local inner = resp.Data if type(inner) ~= "table" then return nil end local accounts = inner.Accounts if type(accounts) ~= "table" then return nil end local now = os.time and os.time() or 0 local sum = 0 for _, pkg in ipairs(accounts) do if type(pkg) == "table" then -- 失效判定:Status 显式已过期,或 ExpiredTime / DeductionEndTime 已过。 local active = true local st = pkg.Status if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end local function past(t) if type(t) ~= "string" or t == "" then return false end local Y, Mo, D, H, Mi, S = t:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)") if Y then local ts = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D), hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false }) return ts and now >= ts end return false end if past(pkg.ExpiredTime) then active = false end local ded = tonumber(pkg.DeductionEndTime) if ded and ded > 0 then -- 毫秒时间戳 if now >= math.floor(ded / 1000) then active = false end end if active then sum = sum + (tonumber(pkg.CycleCapacityRemain) or 0) end end end -- 两位小数规整(服务端精确值带浮点噪声,如 55.67000031) return math.floor(sum * 100 + 0.5) / 100 end, }, }, -- 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(调用方必须处理,而不是拿到空表)。 -- -- 先查内建目录,再查运行时注入表(plugin.state.injected_platforms)。注入表的存在 -- 是因为**测试夹具绝不能写成生产源码**:此前 example/googleish/loopbackish/ -- cookieish 四个夹具直接内联在 PLATFORMS 里,于是它们真的出现在生产 UI(16 个 -- 平台里有 4 个是夹具)供运维选择。夹具属于测试,不属于发行物 —— 正确形态是 -- 测试用自己的那几行注入(见 channels/test_platforms 端点),生产目录只留真实厂商。 local function platformById(id) for _, p in ipairs(PLATFORMS) do if p.id == id then return p end end local injected = plugin.state and plugin.state.injected_platforms if type(injected) == "table" then for _, p in ipairs(injected) do if type(p) == "table" and p.id == id then return p end end end return nil end -- allPlatforms 返回内建目录 + 注入表(列表/遍历用)。 local function allPlatforms() local out = {} for _, p in ipairs(PLATFORMS) do out[#out + 1] = p end local injected = plugin.state and plugin.state.injected_platforms if type(injected) == "table" then for _, p in ipairs(injected) do if type(p) == "table" and p.id then out[#out + 1] = p end end end return out end -- ============================================================================ -- 状态 -- ============================================================================ -- ============================================================================ -- 插件自有持久化(账号池 / 模型黑名单 / 设置 / 日志) -- ============================================================================ -- -- 为什么插件自己落盘,而不是靠宿主框架的 plugin.state: -- 本插件声明 hooks = {},一切状态变更都走自己的 HTTP 路由。宿主的状态自动 -- flush 由 hook / Call / handler 三条分发路径触发,插件不应把「我的状态会不会 -- 被写下去」押在宿主某条路径的实现细节上 —— 那是把插件的数据安全外包给宿主。 -- 参考实现踩过同一课(channel-pack-store.ts):宿主 settings 能力一变,账号 -- 列表就退化为纯内存,必须改由插件自持文档。 -- -- 位置与凭据分离:凭据在 .secrets/(0600,跨插件不可读),这里只放**索引与 -- 配置**(账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。 -- 即便这个文件泄露,也不含任何 token。 -- -- 写入:tmp + rename 原子替换。读:缺失/损坏 → 用内置默认值(不阻塞启动)。 local STORE_NAME = ".channels-store.json" -- storeDir 求插件目录:优先 config.yaml 的 plugin_dir,否则用本文件所在目录。 -- 插件与内核同机、有完整 OS 权限,读自己的配置是能力范围内的事。 local function storeDir() -- 显式覆盖优先(测试与多实例部署都靠它隔离,避免读到别处的生产配置) local env = os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR") if type(env) == "string" and env ~= "" then return env end local s = plugin.state.settings and plugin.state.settings.store_dir if type(s) == "string" and s ~= "" then return s end for _, path in ipairs({ "/etc/llmsproxy/config.yaml", "/usr/local/etc/llmsproxy/config.yaml" }) do local f = io.open(path, "r") if f then local txt = f:read("*a") f:close() if txt then local d = txt:match("\nplugin_dir:%s*([^\n]%S*)") or txt:match("^plugin_dir:%s*([^\n]%S*)") if d and d ~= "" then return d end end end end return "/etc/llmsproxy/plugins" end -- persistKeys 是要落盘的字段。故意不含 sessions(登录会话是短命态,重启即失效, -- 写下去只会留下过期垃圾)与 browser(进程 pid 跨重启无意义)。 local PERSIST_KEYS = { "accounts", "pool", "modelDisabled", "settings", "providerOrder", "log", "injected_platforms", } local function persistState() local dir = storeDir() local path = dir .. "/" .. STORE_NAME local snap = {} for _, k in ipairs(PERSIST_KEYS) do if plugin.state[k] ~= nil then snap[k] = plugin.state[k] end end local body = json.encode({ version = 1, state = snap }) if not body then return false, "json.encode 失败" end local tmp = path .. ".tmp" local w = io.open(tmp, "w") if not w then return false, "无法写入 " .. tmp end w:write(body) w:close() os.execute(("chmod 0600 %s 2>/dev/null"):format(tmp)) local ok = os.rename(tmp, path) if not ok then os.remove(tmp) return false, "rename 失败" end return true end local function applySnapshot(snap) for _, k in ipairs(PERSIST_KEYS) do if snap[k] ~= nil then plugin.state[k] = snap[k] end end end -- dropLegacyKernelState 删除内核遗留文件(若存在)。 -- -- 必须删:内核的 restore() 在插件源码执行**之后**运行,会把这个文件的内容整体 -- 覆盖到 plugin.state 上。自有存储已经是权威,留下它等于给"下次启动被旧数据 -- 覆盖"留一个后门。删它不需要先读它(迁移路径自己会读)。 local function dropLegacyKernelState() local legacy = storeDir() .. "/.channels.state.json" local f = io.open(legacy, "r") if not f then return end f:close() os.remove(legacy) io.stderr:write("[channels] 已删除内核遗留状态文件(自有存储为权威)\n") end -- loadState 读插件自有文档;缺失时**从内核遗留文件迁移一次**。 -- -- 历史背景:v2.0.4 曾让内核的 InvokeHandler 补 markDirty,于是状态被内核写到了 -- `.channels.state.json`(内核的 .state.json 命名)。现在内核改动已回退, -- 那个文件不会再被内核读写;但生产上已经存在一份(含真实账号池),必须迁移过来, -- 否则升级即丢账号 —— 与本次要修的缺陷同类,不能自己再造一次。 -- 迁移后删除遗留文件:留着它只会让「哪个文件权威」变成永久的疑团。 local function loadOwnSnapshot() local dir = storeDir() local f = io.open(dir .. "/" .. STORE_NAME, "r") if f then local txt = f:read("*a") f:close() if txt and txt ~= "" then local ok, rec = pcall(json.decode, txt) if ok and type(rec) == "table" and type(rec.state) == "table" then applySnapshot(rec.state) return true end -- 损坏文件不阻塞:用内置默认值(下次写入覆盖它) io.stderr:write("[channels] 自有存储损坏,改用默认值\n") end end return false end -- migrateLegacyKernelState 把内核遗留的 .channels.state.json 搬进自有文档。 local function migrateLegacyKernelState() local dir = storeDir() local legacy = dir .. "/.channels.state.json" local f = io.open(legacy, "r") if not f then return false end local txt = f:read("*a") f:close() if not txt or txt == "" then return false end local ok, rec = pcall(json.decode, txt) if not ok or type(rec) ~= "table" or type(rec.state) ~= "table" then return false end applySnapshot(rec.state) -- 立即写成自有文档;**只有写成功才删遗留文件** —— 写失败还删它, -- 等于用一次迁移把数据弄丢,与本次要修的缺陷同类。 local written, werr = persistState() if written then os.remove(legacy) io.stderr:write("[channels] 已迁移遗留状态到 " .. STORE_NAME .. "\n") else io.stderr:write("[channels] 遗留状态迁移写入失败(保留原文件): " .. tostring(werr) .. "\n") end return true end local function loadState() if loadOwnSnapshot() then -- 自有存储已权威:遗留文件必须删掉,否则内核 restore() 会用它覆盖 dropLegacyKernelState() return true end return migrateLegacyKernelState() end -- markDirty = 立即同步落盘(tmp + rename 原子替换)。 -- -- 为什么不用「置标志 + 定时 flush」:本插件没有 hooks,45 个端点各自 -- 直接变更状态,还有大量内部调用(syncAccountSource / claim_daily / -- auto_checkin 互调)。把 flush 挂在某个边界上就必须保证**每一条**路径都经过 -- 它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。同步写在 -- 变更点,路径覆盖是结构性的,不依赖我数全 45 个入口。 -- -- 代价可接受:状态很小(实测 ~1.7KB),channels 没有 chat 钩子,写入只发生在 -- 运维动作(登录/领取/开关)与登录轮询(未完成时才有)上,不在请求热路径。 -- 参考实现同样是每次变更 writeFileSync + renameSync。 -- -- 失败处理:写失败**不从状态里回滚**内存变更(内存是对的),只留痕到 stderr -- ——不递归调 logEvent,那会再次触发 markDirty。 local function markDirty() local ok, err = persistState() if not ok then io.stderr:write("[channels] 状态落盘失败: " .. tostring(err) .. "\n") end end plugin.state = plugin.state or {} plugin.state.accounts = plugin.state.accounts or {} plugin.state.log = plugin.state.log or {} -- 启动时从插件自有文档恢复(必须在 plugin.state 建表之后:loadState 往表里填 -- 字段,先填再被 or {} 覆盖就白填了)。 loadState() 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 markDirty() 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 markDirty() end local function poolRemove(provider, accountId) poolAccounts(provider)[accountId] = nil markDirty() 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(调用方自行处理)。 -- b64url_decode 纯 Lua 的 base64url 解码(读 JWT payload 用)。 -- -- 为什么不用内核的 base64 能力:内核只提供 encode(适配器签名要用),没有 decode。 -- 为读一个 JWT 声明而给内核加一个通用解码函数,是把插件的事推给内核 —— -- 插件能做就该自己做。纯 Lua 实现约 20 行,无依赖、无内核改动。 -- 接受标准/URL-safe 两种字母表,容忍缺失填充。 local B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local B64_LOOKUP = {} for i = 1, #B64_ALPHABET do B64_LOOKUP[B64_ALPHABET:sub(i, i)] = i - 1 end local function b64url_decode(input) if type(input) ~= "string" or input == "" then return nil end -- 归一化:URL-safe → 标准,去空白,去掉填充(下面按 4 位一组补零处理) local s = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "") local out, acc, bits = {}, 0, 0 for i = 1, #s do local v = B64_LOOKUP[s:sub(i, i)] if v == nil then return nil end -- 非法字符:如实失败,不猜 acc = acc * 64 + v bits = bits + 6 if bits >= 8 then bits = bits - 8 local byte = math.floor(acc / (2 ^ bits)) % 256 out[#out + 1] = string.char(byte) acc = acc % (2 ^ bits) end end return table.concat(out) end 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 = b64url_decode(payload) if not bin 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 -- setModelDisabled 是**唯一**的模型黑名单写入口:写完即落盘。 -- 之所以收成一个函数:黑名单的写点曾有 5+ 处(单个开关/批量/清死模型/供应商 -- 整体开关),散着写必然漏标脏。收口后只有这里需要保证。 local function setModelDisabled(provider, modelId, disabled) local m = disabledModelsOf(provider) if disabled then m[modelId] = true else m[modelId] = nil end markDirty() 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--。模型开关即改写 -- 这条源的 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 永远不会出现)。 -- -- 为什么需要它:有些平台的登录**必须**在服务端完成 —— 回环重定向落不到用户 -- 机器上、或授权页要求浏览器内交互(扫码后确认、短信验证码)。这时只有服务端 -- 浏览器能走完,VNC 是把那个浏览器呈现给运维的唯一途径。 -- -- 登录后的取凭据不走「截图 + 人眼看」:浏览器以 --remote-debugging-port 起, -- 完成后经 CDP 读 document.cookie / localStorage / 显式取回的 token, -- 落到保险库。人只负责在 VNC 窗口里完成交互,机器负责把结果取出来。 local function browserStack() if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end return plugin.state.browser end -- browserDeps 探测浏览器栈的外部依赖。返回 { ok=bool, missing={...}, present={...} }。 -- 依赖是**外部系统包**(发行版装的),插件不擅自 apt/dnf 安装 —— 那需要 root 且 -- 会改动运维的机器;如实报告缺什么,由运维或部署脚本装。deploy.sh 提供安装路径。 local BROWSER_DEPS = { { bin = "Xvfb", pkg = "xvfb", why = "虚拟显示" }, { bin = "x11vnc", pkg = "x11vnc", why = "VNC 服务端" }, { bin = "websockify", pkg = "websockify", why = "VNC→WebSocket 网关" }, { bin = "python3", pkg = "python3", why = "CDP 取凭据" }, } local function which(bin) local h = io.popen("command -v " .. bin .. " 2>/dev/null") if not h then return false end local p = h:read("*l") h:close() return p ~= nil and p ~= "" end -- reapBrowser 回收本插件拉起的整组进程。进程是插件起的,插件负责杀 —— -- 留一个孤儿 chromium 会一直占着显示与内存,且下次 spawn 会撞端口。 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 local function browserDeps() local missing, present = {}, {} for _, d in ipairs(BROWSER_DEPS) do if which(d.bin) then present[#present + 1] = d.bin else missing[#missing + 1] = { bin = d.bin, pkg = d.pkg, why = d.why } end end -- chromium 的二进制名随发行版变化(chromium / chromium-browser / google-chrome) local chromium = nil for _, c in ipairs({ "chromium", "chromium-browser", "google-chrome", "google-chrome-stable" }) do if which(c) then chromium = c break end end if chromium then present[#present + 1] = chromium else missing[#missing + 1] = { bin = "chromium", pkg = "chromium", why = "浏览器" } end -- noVNC 静态资源(websockify --web 指向它) local novnc = nil for _, p in ipairs({ "/usr/share/novnc", "/usr/share/webapps/novnc", "/opt/novnc" }) do local f = io.open(p .. "/vnc.html", "r") if f then f:close() novnc = p break end end if not novnc then missing[#missing + 1] = { bin = "novnc", pkg = "novnc", why = "浏览器内嵌页面" } end -- CDP 需要 websocket 客户端 local pyws = false if which("python3") then local h = io.popen("python3 -c 'import websocket' 2>/dev/null && echo yes") if h then local r = h:read("*l") h:close() pyws = (r == "yes") end end if which("python3") and not pyws then missing[#missing + 1] = { bin = "python3-websocket", pkg = "python3-websocket", why = "CDP 取凭据(websocket-client)" } end return { ok = #missing == 0, missing = missing, present = present, chromium = chromium, novnc = novnc, } end -- gatewayPort 问内核要本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点, -- 端口不能硬编码(非默认部署会全错)。内核 status 里带 listen(如 "0.0.0.0:8081")。 -- gatewayPort 求本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点, -- 端口不能硬编码(非默认部署会全错)。 -- -- 端口由插件**自己**取得,不问内核:插件在信任模型里就是可信、有完整 OS 能力的 -- (能拉浏览器栈、能在磁盘上读写),读一下自己的 config.yaml 是它能力范围内的事。 -- 为了一个端口给内核加只读动作,既不必要,也污染了内核的「零厂商/零部署知识」边界。 -- 优先级:settings 显式覆盖 > config.yaml 的 listen > 环境变量 > 默认值。 local function gatewayPort() -- ① settings:运维可显式指定(前置代理 / 反代场景,对外端口与 listen 不同) local s = plugin.state.settings and plugin.state.settings.gateway_port if tonumber(s) then return tonumber(s) end -- ② 配置文件:插件与内核同机,直接读自己的配置 for _, path in ipairs({ "/etc/llmsproxy/config.yaml", "/usr/local/etc/llmsproxy/config.yaml", }) do local f = io.open(path, "r") if f then local listen = f:read("*a") f:close() if listen then local addr = listen:match("\nlisten:%s*([^\n]%S*)") or listen:match("^listen:%s*([^\n]%S*)") if addr then local p = addr:match(":(%d+)") if p then return tonumber(p) end end end end end -- ③ 实听端口:从内核自己的监听 socket 反查(配置文件缺失/被换时兜底) do local h = io.popen("ss -ltnp 2>/dev/null | grep 'pid=' | grep -o ':[0-9]*' | head -1") if h then local line = h:read("*l") h:close() local p = line and line:match(":(%d+)") if p then return tonumber(p) end end end return 8081 end -- waitPort 等端口就绪(就绪探针),避免「进程起来了但还不能连」。 -- 返回 true/false,最多等 timeout_ms。 local function waitPort(host, port, timeoutMs) local deadline = (os.time and os.time() or 0) * 1000 + timeoutMs while true do local h = io.popen(("bash -c 'exec 3<>/dev/tcp/%s/%d' 2>/dev/null && echo ok"):format(host, port)) local r = h and h:read("*l") if h then h:close() end if r == "ok" then return true end local now = (os.time and os.time() or 0) * 1000 if now >= deadline then return false end os.execute("sleep 0.3") end end -- CDP 取凭据的 Python 桥。argv: ]], }, } return plugin