Files
ModelRouter/internal/lua/plugins/channels.lua
JianFeeeee 5ab047e48b fix(channels): 修复 12 平台登录的系统性偏差(callback 闭环 + 动态模型 + Trae 设备身份)
用户报障:「除了 codebuddy,其他的登陆都不太正常,尤其是有道那个」。逐平台审计
参考实现(vendor/channel-pack/src)后发现根因不是 12 个独立 bug,而是几个系统性偏差。

## ① callback 流的回调是回环地址 → 必须走服务端浏览器(核心认识)
Trae/Gemini/有道 的 redirect_uri 都是回环地址(127.0.0.1 / localhost)。回环地址由
「打开授权页的那台机器」解析:远程用户在自己电脑上打开,重定向就落在**用户机器**上,
回调永远到不了网关。CodeBuddy 一直是好的,因为它走 state-poll,**根本不需要回调**。
→ 插件自起 loopback 接收器(Python,绑回环、双栈,路径按各平台注册值),
  由服务端浏览器(VNC)承载授权页,重定向落回本机。

## ② 三个平台的回调路径各是注册值(写错就是 redirect_uri_mismatch)
  有道 /auth/callback、Trae /authorize、Gemini /oauth-callback(参考 GEMINI_CALLBACK_PATH)
  且 Gemini 的 redirect_uri 主机是 **localhost** 而不是 127.0.0.1。
  另:Trae 的回调**不带 state**(token 直接放 query),其余平台必须带 → 校验规则按形态分。

## ③ 有道版本号不能写死
exchange/续期/签到的 version 是必填。实测 2026-10-13 上游已是 2026.9.23,而参考的兜底
常量还停在 2026.9.4。新增 lobsteraiClientVersion:远端为主(data.value.version,**非统一
信封**)+ 失败用兜底但**不缓存**(照参考 resolver)。失败时把上游 message 带出来
(实测形如 code=40102「无效或已过期的授权码」)。

## ④ Gemini token 交换缺 client_secret(症状正是「授权成功但账号不出现」)
参考注释点名:token 端点强制校验客户端身份,只发 client_id 会 invalid_request。
同时补 access_type=offline / prompt=consent(缺了拿不到 refresh_token),
并去掉误标的 pkce(上游 Cloud Code 用的是最朴素的 authorization_code + client_secret)。

## ⑤ Trae 授权 URL 少了 5 个必需参数
参考列为「一个都不能少」:login_trace_id(回调**反查 pending 的唯一凭据**)、
machine_id/device_id(hex32,每账号必须不同,否则被「该设备已签到」拦)、
x_device_id/x_machine_id。此前**完全没造**设备身份 → 登录页停在「授权中」。

## ⑥ Trae 签到头与设备身份
授权方案是 `Cloud-IDE-JWT`(不是 Bearer);参考点明简化头**实测签到失败**,
需约 20 个客户端头,且 X-Device-Id 是**基于 user_id 确定性派生的 15 位数字**。
实现 seededStream(SHA256(utf8(salt:seed) ++ counterBE32) 串联)+ 三级派生,
并用参考实现(Node)生成期望值做**差分验证**逐字节对齐。
凭据改为 JSON 以保留 uid/device_id/machine_id(签到与轮换需要)。

## ⑦ 同段逻辑抽一个助手,防两条路漂移
lobsteraiExchange 被 oauth_callback 与 poll_login 两条路共用(原先各内联一份)。

## 判据(新增测试)
- TestLobsterLoopbackClosedLoop / TestLobsterExchangePreservesIdentity:loopback 闭环、
  state 不匹配 400、重放 409、凭据含 uuid/first_keyfrom
- TestCallbackPlatformsLoopback:三个平台的路径与 state 规则、Trae 无 state 也能接住
- TestAuthorizeURLsHaveNoUnfilledPlaceholders:授权 URL 不留 {占位符}
- TestTraeDeriveMatchesReference:与参考实现**逐字节**差分(tools/trae_derive_expected.mjs)
- TestTraeCheckinHeaders:Cloud-IDE-JWT + 派生设备身份 + 客户端头齐全
- TestRemoteModelCatalogMergesWithFallback:远端为主、兜底补位、失败不清缓存

## 顺带
- set_endpoints 补 callback / client_version 键(否则测试无法把端点指到假上游)
- 删掉 spawnBrowser 里会把 redirect_uri 一律改写为插件挂载点的死代码(对有道致害)
- session 过期与插件卸载时回收 loopback 接收器进程(防进程泄漏)
- e2e/channels_flows_e2e_test.go:qoder 已从 manual 改为 device-poll,断言同步更新
2026-10-13 13:42:45 +08:00

5575 lines
264 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

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

-- channels:多平台账号接入(登录认证 → 凭据 → 自动领取额度 → 注册为上游源)
--
-- 为什么需要它
-- ------------
-- 网关的上游源(sources)一直是**运维手工配置**的:填 base_url、填 api_key。
-- 而现实里有大量平台是"注册个账号、每天领免费额度"的形态,它们的接入方式是
-- **登录**而不是"申请一个 key"。这类平台要接入网关,就需要:驱动登录 → 拿到
-- 凭据 → 定时领取额度 → 把端点当源用。
--
-- 本插件把这套流程做成通用的:一个插件内建平台表,每个平台声明自己的
-- 登录方式(link-poll / callback / paste)、额度领取接口、上游端点形状。
-- 新增一个平台是往 PLATFORMS 里加一条,不改内核。
--
-- 设计边界(与内核的约定)
-- ------------------------
-- 内核提供基础设施,插件自己不实现这些:
--
-- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它)
-- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读)
-- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…)
--
-- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状、模型清单。
-- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"——
-- 内核里出现厂商名就是分层被破坏了。登录会话、服务端浏览器栈也全归插件自持。
--
-- 权限(本插件需要,全部可在设置页看到"申请中")
-- ------------------------------------------------
-- add_source 把平台端点注册为上游源
-- list_sources 显示当前已接入的平台
-- probe_all 接入后立刻验证可达
-- reset_health 上游恢复后清冷却
-- status 面板显示运行状态
--
-- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被
-- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。
--
-- 登录会话归本插件自持(内核无"登录"概念):会话表在 plugin.state.sessions,
-- 带 nonce 与过期时间;浏览器栈(Xvfb/chromium/x11vnc/websockify)由本插件用
-- os.execute 直接拉起并记录 pid,插件卸载/会话结束时自己回收。
-- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化流程(轮询/回调)
-- → ③ VNC 无头浏览器兜底(无浏览器系统 / 自动化也无法完成的场景)。
local plugin = {
name = "channels",
version = "1.0.0",
description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源",
author = "llmsproxy",
}
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" }
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "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", "__trae_derive_probe" }
-- ============================================================================
-- 平台表
-- ============================================================================
--
-- 每个平台声明登录方式与各端点。字段:
-- 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",
-- 授权页(照参考 buildTraeLoginUrl 的完整参数集)。
-- 注意参数名必须是 **auth_callback_url**(参考注释点名:早期用了
-- redirect_uri/auth_callback 等错名,TRAE 拿不到回调地址、登录必然失败)。
-- 端口用 {port} 占位,实际拼装时填本机监听端口。
authorize = {
base = "https://www.trae.cn/authorization",
params = {
login_version = "1", auth_from = "solo", login_channel = "native_ide",
plugin_version = "2.3.62834", auth_type = "local",
client_id = "en1oxy7wnw8j9n", redirect = "0",
-- Trae 注册的回调路径是 /authorize(参考 trae-oauth.ts:565
-- `http://127.0.0.1:${port}/authorize`)—— 换成别的路径授权页会拒绝。
-- loopback 接收器会把它变成 http://127.0.0.1:{实际端口}/authorize。
auth_callback_url = "{redirect_uri}",
-- 设备身份与反查凭据:参考 trae-oauth.ts 列为「一个都不能少」——
-- login_trace_id 是回调**反查 pending** 的唯一凭据;machine_id/device_id
-- 缺席可能被风控拦;它们还会写进凭据供签到发 X-Device-Id(day 轮换躲 9074)。
login_trace_id = "{trace_id}",
machine_id = "{machine_id}", device_id = "{device_id}",
x_device_id = "{device_id}", x_machine_id = "{machine_id}",
x_device_brand = "PC", x_device_type = "PC", x_os_version = "1.0",
x_app_version = "0.1.52", x_app_type = "stable",
},
},
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 = "{}",
-- 授权方案是 Cloud-IDE-JWT(不是 Bearer),且参考注释点明:
-- 简化头实测签到失败,必须带基于 user_id 派生的设备身份头(见 traeCheckinHeaders)。
auth = "cloud-ide-jwt",
},
-- 签到状态(对齐参考: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",
-- 远端模型目录(照参考 minimax-product.ts MINIMAX_MODELS_PATH)
models_remote = {
endpoints = { { url = "https://www.minimaxi.com/mavis/api/v1/models", method = "GET" } },
auth = "bearer",
parse = "id_list",
},
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",
-- 远端模型目录(照参考 cline-models.ts:**必须同时打两个端点**)。
-- 关键:/api/v1/models 的 460 个 id 里**没有** cline-free/*,免费模型只由
-- recommended-models 下发;只打前者会看不到任何免费模型。
models_remote = {
endpoints = {
{ url = "https://api.cline.bot/api/v1/ai/cline/recommended-models", method = "GET", url_auth = false },
{ url = "https://api.cline.bot/api/v1/models", method = "GET" },
},
auth = "bearer",
parse = "cline_merge",
},
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",
-- 远端模型目录(照参考 buddy.ts CONFIG_PATH + buddy-oauth.ts fetchModels)。
-- 关键:必须带 product 身份头,否则 WorkBuddy 会以 CodeBuddy 身份请求。
models_remote = {
endpoints = { { url = "https://copilot.tencent.com/v3/config", method = "GET" } },
auth = "bearer",
headers_fn = true,
parse = "buddy_config",
},
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",
-- login_url 已含完整 OAuth 参数(client_id/scope/response_type),
-- 只剩 redirect_uri 与 state 需要按会话填 —— 用 authorize 规格声明,
-- 避免通用分支再追加一遍参数(造成 redirect_uri 出现两次)。
authorize = {
base = "https://accounts.google.com/o/oauth2/v2/auth",
params = {
client_id = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
-- 回调路径必须是注册值 /oauth-callback(参考 GEMINI_CALLBACK_PATH),
-- 主机是 localhost 而非 127.0.0.1(参考注释明确);路径/主机改一个就是
-- redirect_uri_mismatch。
redirect_uri = "http://localhost:{port}/oauth-callback",
response_type = "code",
-- 六项 scope 逐字照抄参考 GEMINI_SCOPES(cloud-platform 必需)
scope = "openid https://www.googleapis.com/auth/cloud-platform "
.. "https://www.googleapis.com/auth/userinfo.email "
.. "https://www.googleapis.com/auth/userinfo.profile "
.. "https://www.googleapis.com/auth/cclog "
.. "https://www.googleapis.com/auth/experimentsandconfigs",
-- 参考三个固定参数(缺 access_type=offline 拿不到 refresh_token)
access_type = "offline",
prompt = "consent",
include_granted_scopes = "true",
},
},
callback = {
mode = "code",
token_url = "https://oauth2.googleapis.com/token",
-- ⚠️ 必须带 client_secret:token 端点强制校验客户端身份,只发 client_id 会回
-- invalid_request: client_secret is missing,症状正是「浏览器显示授权成功,
-- 但面板里账号一直不出现」。redirect_uri 必须与授权时**完全一致**(含主机名)。
token_body = "grant_type=authorization_code&code={code}"
.. "&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
.. "&client_secret=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"
.. "&redirect_uri=http://localhost:{port}/oauth-callback",
-- 参考明言:上游 Cloud Code 用的是**最朴素的 authorization_code +
-- client_secret**,刻意不带 PKCE。
},
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",
-- 授权页真值:{portal}/portal#/login?source=electron&redirect_uri=<编码>&state=<state>
-- (参考 buildLobsteraiLoginUrl)。注意 login 在 **hash 段**,且三个参数都是
-- query —— 此前我写的 ".../login&redirect_uri=..." 既缺 /portal#、又把 & 当
-- 首个分隔符(应为 ?),授权页必然报参数错误。
-- 授权页真值(照参考 buildLobsteraiLoginUrl):login 在 **hash 段**,
-- 三个 query 参数是 source/redirect_uri/state。
-- 此前我写的 ".../login&redirect_uri=..." 既缺 /portal#、又把 & 当首个
-- 分隔符(应为 ?),授权页必然报参数错误。
login_url = "https://lobsterai.youdao.com/portal#/login",
authorize = {
hash = "https://lobsterai.youdao.com/portal#/login",
params = {
source = "electron",
-- 参考注释:**必须**是 http://127.0.0.1:{port}/auth/callback 形态,
-- 登录页会校验。此处的 redirect_uri 由**本机 loopback 接收器**决定
-- (不是本插件的 /api/plugins/http 挂载点,也不是用户机器的 localhost),
-- 故用 {redirect_uri} 占位,拼装时由会话的实际接收器地址填入。
redirect_uri = "{redirect_uri}",
},
},
callback = {
mode = "code",
-- 参考真值 LOBSTERAI_EXCHANGE_PATH。此前写的 /api/auth/login 是我编的。
token_url = "https://lobsterai-server.youdao.com/api/auth/exchange",
-- 请求体必须含这 5 个字段(照参考 exchangeLobsteraiAuthCode)。
-- 兜底模板(正常路径走 lobsteraiExchange 助手,它会动态解析版本号)。
token_body = '{"authCode":"{code}","firstKeyfrom":"cli","latestKeyfrom":"{now}","uuid":"{uuid}","version":"2026.9.4"}',
},
-- 交换端点不需要 Authorization(此时还没有 token),但要这套匿名头。
callback_headers = {
["User-Agent"] = "LobsterAI/0.1.0",
},
hosts = { "lobsterai.youdao.com", "lobsterai-server.youdao.com",
"api-overmind.youdao.com" },
-- 客户端版本号:exchange 的 `version` 与签到 query 都必填。
-- **不能写死**:2026-10-13 实测该端点返回 2026.9.23,而参考的兜底常量
-- 还停在 2026.9.4 —— 写死就会发出一个过期的版本号。
-- 响应形态非统一信封:外层无 code,值在 data.value.version。
client_version = {
url = "https://api-overmind.youdao.com/openapi/get/luna/hardware/lobsterai/prod/update",
path = "data.value.version",
fallback = "2026.9.4",
},
base_url = "https://lobsterai-server.youdao.com",
-- 远端模型目录(照参考 lobsterai.ts LOBSTERAI_MODELS_PATH)
models_remote = {
-- 参考真值 LOBSTERAI_API_BASE(lobsterai-server.youdao.com)。
-- 此前写的 api.lobster.ai 是我编的(DNS 都不解析)。
endpoints = { { url = "https://lobsterai-server.youdao.com/api/models/available", method = "GET" } },
auth = "bearer",
parse = "id_list",
},
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",
-- 远端模型目录(照参考 opencode-product.ts modelsPath)
models_remote = {
endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } },
auth = "bearer",
parse = "id_list",
},
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 设备码轮询**(照参考 qoder-oauth.ts / qoder.ts 逐行移植)。
-- -- 注意纠正一处长期错误:此前把 Qoder 标成 manual,理由是"登录要 WASM 签名头"。
-- 那是把**聊天加密**(WASM 用于 /algo/ 加密推理端点)与**登录**混为一谈 ——
-- 登录本身是纯 HTTP 的 PKCE 设备码流,Lua 完全能做:
-- ① 打开 {authBase}/device/selectAccounts?challenge&challenge_method=S256
-- &nonce&machine_id&client_id
-- ② 轮询 {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
-- 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
-- 三个必须照抄的细节(参考注释都点了名):
-- · client_id 必须用 **prod 的**(非 prod 会让服务端在授权回调阶段拒绝)
-- · poll 挂在 openApiBase(openapi.qoder.sh),不是 authBase(那返回 401)
-- · nonce/verifier 必须原样回传(PKCE 校验)
{
id = "qoder",
name = "Qoder",
vendor = "qoder",
flow = "device-poll",
device = {
-- 授权页在 {authBase}/device/selectAccounts,query 由 pkce 表单拼装
code_url = "https://qoder.com/device/selectAccounts",
code_method = "GET_QUERY",
-- code_url 的 query 模板:{challenge}/{nonce}/{machine_id}/{client_id}
auth_query = "challenge={challenge}&challenge_method=S256&nonce={nonce}"
.. "&machine_id={machine_id}&client_id=" .. "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
-- 轮询在 openApiBase
token_url = "https://openapi.qoder.sh/api/v1/deviceToken/poll",
token_method = "GET_QUERY",
-- poll 的 query 模板:PKCE 用 verifier(不是 challenge)
poll_query = "nonce={nonce}&verifier={verifier}&challenge_method=S256",
-- 判据:404 = 继续(见 deviceOneStep 的 pending404 分支)
judge = "pending404",
interval = 2,
},
base_url = "https://api2-v2.qoder.sh",
adapter = "openai",
-- 兜底目录(照参考 QODER_FALLBACK_MODELS 的真实目录 key)。
-- 关键:这些是**上游目录 key**(如 qfmodel),不是通用名 —— 公开的
-- /model/v1/chat/completions 不认它们,必须走加密端点 agent_chat_generation。
-- 此前这里写的是 "qoder-default"(我编的占位名,上游不认,选了也调不通)。
-- 远端目录端点 /api/v2/model/list 需 WASM 签名,故参考自己也用兜底表。
models = {
"auto", "ultimate", "performance", "efficient", "smodel", "cmodel",
"qmodel_38max", "qfmodel", "qmodel_latest", "qmodel",
"kmodel_latest", "kmodel", "gmodel", "gfmodel",
"dmodel", "dfmodel", "mmodel",
},
-- 余额(对齐参考 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 —— **两段式服务端中介授权流**(照参考 zcode-login.ts 逐行移植)。
-- -- 注意纠正第二处长期错误:此前标 manual,理由是"登录要解 captcha"。核对参考
-- 后发现**该授权流完全不涉及 captcha** —— captcha 属于另一条已废弃的旁路
-- (zcode-captcha.ts,用于读取本机官方客户端数据),而本流的注释明确写着
-- 「完全不经 zcode:// 自定义协议回调,故普通进程就能走完」:
-- ① POST {origin}/api/v1/oauth/cli/init
-- Authorization: Bearer <本地造的 flow_secret(32 字节 hex)>
-- body {provider:"bigmodel"}
-- → {flow_id, poll_token, authorize_url, expires_at, poll_interval_sec}
-- ② 用户在浏览器打开 authorize_url 完成授权
-- ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id}
-- Authorization: Bearer <同一个 flow_secret>
-- → {status:"pending"} 继续 / {status:"ready", token, user, bigmodel|zai}
-- 判据(参考注释点名,与官方一致):
-- · 4xx(**除 408/429**)才是终态失败;5xx 与网络错误继续重试
-- · code != 0 或 data 缺失 → pending
-- · token(zcode JWT)与 user.user_id 必需;第三方 token 取 bigmodel/zai 二者之一
{
id = "zcode",
name = "ZCode",
vendor = "zcode",
flow = "init-poll",
initpoll = {
init_url = "https://zcode.z.ai/api/v1/oauth/cli/init",
poll_url = "https://zcode.z.ai/api/v1/oauth/cli/poll/{flow_id}",
provider = "bigmodel",
app_version = "3.14.3",
origin = "https://zcode.z.ai",
interval = 2,
},
-- 推理走 Anthropic 协议的免费额度通道(照参考 zcode-upstream.ts):
-- 积分制无论 bigmodel/zai 都走 zcode.z.ai(官方分派表规则 2/5,2026-10-03 实测);
-- 只有订阅制才走 api.z.ai。这里用积分制那条。
base_url = "https://zcode.z.ai",
-- 远端模型目录(照参考 zcode-upstream.ts ZCODE_CLIENT_CONFIGS_URL)
models_remote = {
endpoints = { { url = "https://zcode.z.ai/api/v1/client/configs", method = "GET",
query = "app_version=1.0.0&platform=unknown" } },
auth = "none",
parse = "zcode_configs",
},
endpoint = "/api/v1/zcode-plan/anthropic/v1/messages",
adapter = "anthropic",
-- 余额端点(需要 Authorization;照参考 ZCODE_BILLING_BALANCE_URL)
balance = {
url = "https://zcode.z.ai/api/v1/zcode-plan/billing/balance",
method = "GET",
total_fn = function(rec)
local d = rec.data or rec
-- 优先取明确的余额字段;取不到就不编造(返回 nil 让调用方报错)
return tonumber(d.balance or d.remaining or d.credits or d.total)
end,
},
-- 兜底目录(照参考 ZCODE_FALLBACK_MODELS)。远端可拉:
-- GET /api/v1/client/configs?app_version=...&platform=unknown
-- 此前写的 "zcode-default" 是我编的占位名,上游不认。
models = { "GLM-5.3", "GLM-5.3-Flash" },
},
-- Loomy —— 微信扫码登录(照参考 loomy-oauth.ts 逆向)。
-- 扫码要在微信 App 里完成,纯 Lua 环境拿不到微信凭据,标 manual。
{
id = "loomy",
name = "Loomy",
vendor = "loomy",
-- 注意纠正第三处长期错误:此前标 manual,理由是"扫码在微信里完成,Lua 做不了"。
-- 扫码动作确实在用户手机上,但**协议全是纯 HTTP/Lua 可做**:
-- ① 取 uuid:GET open.weixin.qq.com/connect/qrconnect?...&state=... 从 HTML 抽 uuid
-- ② 二维码图:GET open.weixin.qq.com/connect/qrcode/{uuid}(JPEG,前端渲染)
-- ③ 长轮询:GET long.open.weixin.qq.com/connect/l/qrconnect?uuid&last&_
-- 响应体里 wx_errcode=NNN / wx_code='...'
-- ④ 拿 wx_code 后调讯飞账号端点换凭据(HMAC-SHA1 签名,见 loomyAuthHeaders)
-- 判据(参考点名;早期版本**读反了**导致"扫码后卡在已扫码"):
-- 408=等待扫码;404=已扫码待确认(继续);405=**已确认**(wx_code 在此帧);
-- 403=用户取消;402=二维码失效
flow = "wechat-poll",
wechat = {
-- appid / redirect_uri 照参考常量(原写的 appid 是我编的,上游不认);
-- 末尾 `#wechat_redirect` 是微信要求的固定片段,缺它对某些客户端会跳错页。
auth_url = "https://open.weixin.qq.com/connect/qrconnect"
.. "?appid=wx18d60be432287cf8&scope=snsapi_login&response_type=code"
.. "&redirect_uri=https%3A%2F%2Floomy.xunfei.cn%2Foauth%2Fwechat%2Fcallback"
.. "&state={state}#wechat_redirect",
qr_url = "https://open.weixin.qq.com/connect/qrcode/{uuid}",
poll_url = "https://long.open.weixin.qq.com/connect/l/qrconnect",
-- 逐字照参考 WECHAT_UA(微信对空 UA / 爬虫 UA 可能拒绝)
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
.. " (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
-- 参考真值 LOOMY_ACCOUNT_BASE。此前写的 account.xfyun.cn 是我编的
-- (DNS 都不解析),实际账号域是 xfinfr.com —— 拼音近似导致的误写。
account_base = "https://account.xfinfr.com",
bind_path = "/login/thirdAccount/bind/auth",
access_key_id = "2thryby66wxi53sk",
access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r",
app_id = "GM3LOOMY",
},
base_url = "https://loomy.xunfei.cn",
adapter = "openai",
-- 兜底目录(照参考 LOOMY_FALLBACK_MODELS)。此前 "loomy-default" 是我编的。
models = {
"deepseek-v4-flash-0731", "MiniMax-M3", "Kimi-k2.6", "qwen-3.8-max",
"GLM-5.3-Flash", "qwen3.8-flash", "spark-x", "mimo-v2.5",
},
},
-- Raccoon(商汤小浣熊)—— 扫码登录(照参考 raccoon-oauth.ts 逆向)。
-- 扫码是图片码,纯 Lua 环境无法展示与回取,标 manual。
{
id = "raccoon",
name = "Raccoon(商汤小浣熊)",
vendor = "sensetime",
-- 注意纠正第四处:此前标 manual,说"要扫码,做不了"。**扫码路径纯 HTTP 可做**:
-- ① 本地造 qrcode_code(16 字节随机 hex)
-- ② 二维码 URL {apiBase}/login/mp?code=...&appname=商汤小浣熊官网(前端渲染)
-- ③ 轮询 POST {apiBase}/api/web/auth/v1/login_with_qrcode_code {qrcode_code}
-- → data.status ∈ pending/logging/canceled/success;success 带 token
-- 参考注释:code 由**宿主侧本地随机生成**(服务端接受任意自造 code 并进入
-- pending),故完全绕开收不到的自定义协议回调。
-- 短信路径(/sms/send + /sms/verify)需要阿里云滑块 captcha_param 与手机号
-- AES 加密 —— 那一条才真需要人工,故不走。
flow = "qr-poll",
qr = {
api_base = "https://xiaohuanxiong.com",
auth_prefix = "/api/web/auth/v1",
poll_path = "/login_with_qrcode_code",
login_page = "/login/mp",
app_name = "商汤小浣熊官网",
interval = 2,
},
-- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions)
base_url = "https://xiaohuanxiong.com/api/web/llm/v2",
adapter = "openai",
-- 兜底目录(照参考 RACCOON_FALLBACK_MODELS)。此前 "raccoon-default" 是我编的。
-- 注意 sn- 前缀是本平台的真实命名约定(sensenova 系列)。
models = {
"sn-sensenova-6-8-flash", "sn-sensenova-6-8-flash-lite",
"sn-glm-5-3", "sn-kimi-k3", "sn-glm-5-3-flash",
"sn-deepseek-v4-1-flash",
},
-- 余额(照参考:GET {apiBase}/api/web/points/v1/balance)
balance = {
url = "https://xiaohuanxiong.com/api/web/points/v1/balance",
method = "GET",
headers_fn = function(accessToken, cred)
return {
["Authorization"] = "Bearer " .. accessToken,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["X-Org-Code"] = (cred and cred.office_identity) or "",
["X-Raccoon-Language"] = "zh",
}
end,
total_fn = function(rec)
local d = rec.data or rec
return tonumber(d.available_points or d.availablePoints or d.points)
end,
},
-- 一次性新人奖励(对齐参考 RACCOON_LOGIN_REWARD_POINTS)
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
-- 各 flow 自己的端点域名也要(否则授权页/长轮询/账号端点会被出站白名单拦)。
-- 这些域名与 base_url 往往不同:Loomy 的授权在 open.weixin.qq.com、
-- 长轮询在 long.open.weixin.qq.com、账号在 account.xfyun.cn;
-- ZCode 的 init/poll 在 zcode.z.ai(与 base_url 同域但显式声明更清楚)。
local function addHost(u)
local h = (u 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
if p.wechat then
addHost(p.wechat.auth_url); addHost(p.wechat.qr_url)
addHost(p.wechat.poll_url); addHost(p.wechat.account_base)
end
if p.initpoll then addHost(p.initpoll.init_url); addHost(p.initpoll.poll_url) end
if p.qr then addHost(p.qr.api_base) end
if p.device then addHost(p.device.code_url); addHost(p.device.token_url) end
if p.state then addHost(p.state.state_url); addHost(p.state.token_url); addHost(p.state.account_url) end
if p.models_remote then
for _, m in ipairs(p.models_remote.endpoints or {}) do addHost(m.url) end
end
if p.balance then addHost(p.balance.url) end
if p.checkin_status then addHost(p.checkin_status.url) end
if p.callback then addHost(p.callback.exchange_url) 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
-- ---- 讯飞账号(CAccount)HMAC-SHA1 签名(Loomy 用)----
-- 逐段复刻参考 loomy-sign.ts。任何偏差都会让账号端点返回鉴权失败。
--
-- 内核只有 sha256/hmac_sha256 —— 没有 sha1,也没有 md5。但插件有完整 OS 能力,
-- 直接用 openssl(本机已装)算 HMAC-SHA1 与 MD5,不在 Lua 里重写密码学原语。
--
-- 签名字符串 9 段以 \n 连接,**后两段恒为空串**(不发 x-* 头),
-- 故最终字符串以两个换行结尾 —— 不要"顺手"去掉尾随换行。
-- 认证头前缀是 `account {ak}:{sig}`(**不是** Bearer)。
-- rfc3986Escape:encodeURIComponent 等价 + 补转 ! ' ( ) *
local function rfc3986Escape(v)
v = tostring(v or "")
v = v:gsub("([^A-Za-z0-9%-%._~])", function(c)
return string.format("%%%02X", string.byte(c))
end)
-- encodeURIComponent 不转 ! ' ( ) * ~,但参考的 escapeRfc3986 要转前五个
v = v:gsub("!", "%%21"):gsub("'", "%%27"):gsub("%(", "%%28")
:gsub("%)", "%%29"):gsub("%*", "%%2A")
return v
end
-- opensslDigest:调 openssl 算摘要。mode 是 openssl 的算法名(md5/sha1)。
-- hmacKey 非空时用 -hmac;输出 base64(参考用的就是 base64)。
local function opensslDigest(mode, data, hmacKey)
-- 用临时文件传原文:命令行的引号/换行/二进制都不可靠
local inPath = "/tmp/llmsproxy-sig-in-" .. tostring(math.random(1e12))
local f = io.open(inPath, "wb")
if not f then return nil, "无法写临时文件" end
f:write(data)
f:close()
local cmd
if hmacKey and hmacKey ~= "" then
local keyPath = inPath .. ".key"
local kf = io.open(keyPath, "wb")
if not kf then os.remove(inPath) return nil, "无法写密钥文件" end
kf:write(hmacKey)
kf:close()
-- 外层用单引号包 Lua 字符串,内部才能安全地带 shell 的双引号
cmd = ('openssl dgst -%s -hmac "$(cat %s)" -binary %s 2>/dev/null | base64 -w0')
:format(mode, keyPath, inPath)
local h = io.popen(cmd)
local out = h and h:read("*a") or ""
if h then h:close() end
os.remove(keyPath)
os.remove(inPath)
return (out ~= "" and (out:gsub("%s+$", ""))) or nil
end
cmd = ("openssl dgst -%s -binary %s 2>/dev/null | base64 -w0"):format(mode, inPath)
local h = io.popen(cmd)
local out = h and h:read("*a") or ""
if h then h:close() end
os.remove(inPath)
return (out ~= "" and (out:gsub("%s+$", ""))) or nil
end
-- loomyContentMd5:空 body 返回**空串**(不是空串的 md5),与客户端一致。
local function loomyContentMd5(body)
if not body or body == "" then return "" end
return opensslDigest("md5", body, nil) or ""
end
-- loomySigningString:9 段拼串(method,path,query,md5,ctype,date,nonce,"","")
local function loomySigningString(method, path, query, body, contentType, date, nonce)
local md5 = loomyContentMd5(body)
return table.concat({
method:upper(), path, query or "", md5, contentType or "",
date, nonce, "", "",
}, "\n")
end
-- loomyAuthHeaders:生成讯飞账号请求头。
local function loomyAuthHeaders(ak, aks, method, path, body, contentType)
local date = os.date("!%a, %d %b %Y %H:%M:%S GMT")
-- nonce 只要唯一即可(服务端不校验格式)。这里**不能调 newToken** ——
-- 它定义在文件后段(1518 行),Lua 词法作用域下此处是 nil,调用会崩。
-- 用内核全局 sha256_hex(随时可用)造一个 32 字节 hex,与参考的 UUID 同量级。
local nonce = string.format("%s-%s-%s",
sha256_hex(tostring(os.time()) .. tostring(math.random(1e12))):sub(1, 8),
sha256_hex(tostring(math.random(1e12))):sub(1, 4),
sha256_hex(tostring(math.random(1e12))):sub(1, 12))
local ctype = contentType or "application/json"
local sts = loomySigningString(method, path, "", body, ctype, date, nonce)
local sig, err = opensslDigest("sha1", sts, aks)
if not sig then return nil, err or "签名计算失败" end
local h = {
["Authorization"] = "account " .. ak .. ":" .. sig,
["Date"] = date,
["Nonce"] = nonce,
["Content-Type"] = ctype,
}
local md5 = loomyContentMd5(body)
if md5 ~= "" then h["Content-MD5"] = md5 end
return h
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 url, method, body = d.token_url, d.token_method or "POST", nil
if d.poll_query or (d.token_method == "GET_QUERY") then
-- 形态②(Qoder):GET + query 模板。PKCE 用 verifier(不是 challenge),
-- 且 nonce 必须是**会话创建时那一个**(服务端按它找回待授权会话)。
local q = (d.poll_query or "")
:gsub("{nonce}", sess.device_nonce or sess.nonce or "")
:gsub("{verifier}", sess.device_verifier or "")
:gsub("{challenge}", sess.device_challenge or "")
:gsub("{device_code}", sess.device_code or "")
url = d.token_url .. "?" .. q
method = "GET"
else
body = (d.token_body
:gsub("{device_code}", sess.device_code or "")
:gsub("{verifier}", sess.device_verifier or "")
:gsub("{challenge}", sess.device_challenge or ""))
end
local resp, err = __llmsproxy_http({
url = url,
method = method,
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
-- 「404/pending = 继续轮询」形态(Qoder)。
-- -- 实测依据(参考注释):qoder.com 的 poll 路径返回 401,而
-- openapi.qoder.sh 对「无待授权会话」返回 **404**,语义是"还没有会话,继续等"
-- 而非失败。把 404 当失败会让登录永远失败。
-- 判定由平台声明 judge="pending404" 驱动,不在这里硬编码平台名。
if d.judge == "pending404" then
if resp.status == 404 then return false end
if resp.status == 200 then
-- 2xx 但尚无 token:同样继续轮询
local tok = rec.token or rec.device_token or rec.access_token
if tok == nil or tok == "" then return false end
return true, { ok = true, access_token = tok,
refresh_token = rec.refresh_token or rec.refreshToken,
account = rec.user_name or rec.userName or "",
uid = rec.user_id or rec.userId,
expires_at = rec.expires_at or rec.expiresAt,
refresh_expires_at = rec.refresh_token_expires_at or rec.refreshTokenExpiresAt }
end
return true, { ok = false, reason = "error",
message = "登录服务返回异常(HTTP " .. tostring(resp.status) .. ")" }
end
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
-- wechatExchange:把微信 wx_code 换成 Loomy 凭据(讯飞账号端点,HMAC-SHA1 签名)。
--
-- 参考链路(loomy-oauth.ts 注释):bindAuthThirdAccount(code) → {bind, rcode, nickname}
-- → 若已绑手机号可 bindSkip(rcode) 直接拿 session;未绑则要走 SMS(需人工输入验证码)。
-- 这里做到 bindAuth 一步:拿到 rcode/nickname 即视为可用的登录上下文,
-- 并把 bind 标志带进凭据,让上层知道是否需要补手机号(不假装 SMS 已完成)。
local function wechatExchange(plat, wxCode)
local w = plat.wechat
if not w then return nil, "平台未声明 wechat 配置" end
local path = w.bind_path
local body = json.encode({
tcode = { code = wxCode },
type = "wx",
appId = w.app_id,
})
local headers, herr = loomyAuthHeaders(w.access_key_id, w.access_key_secret,
"POST", path, body, "application/json")
if not headers then return nil, "签名失败: " .. tostring(herr) end
headers["Accept"] = "application/json"
local resp, err = __llmsproxy_http({
url = w.account_base .. path, method = "POST", headers = headers, body = body,
})
if err then return nil, "账号端点请求失败: " .. err end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return nil, "账号端点响应无法解析(HTTP " .. tostring(resp.status) .. ")"
end
-- 参考兼容 data 包裹与顶层两种形态
local data = type(rec.data) == "table" and rec.data or rec
local rcode = data.rcode
if type(rcode) ~= "string" or rcode == "" then
return nil, "微信授权响应缺少 rcode(code 可能已失效)"
end
-- bind 缺失归 0(走绑定流程)—— 保守方向:已绑最多多填一次手机号;
-- 未绑却跳过会拿到没有手机号的账号。
local bind = (data.bind == 1) and 1 or 0
return json.encode({
access_token = rcode, -- 后续所有步骤用 rcode(wx_code 只用这一次)
rcode = rcode,
bind = bind,
session = data.session or data.token,
user_id = data.userid or data.user_id,
nickname = data.nickname,
headpic = data.headpic,
phone_bound = bind == 1,
token_type = "Bearer",
account_type = "personal",
}), nil
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",
-- 远端拉到的模型目录缓存(远端为主、兜底补位的结果)。
-- 缓存它而非每次请求都拉:上游目录是分钟级稳定,UI 却是秒级刷新。
"remoteModels",
}
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`(内核的 <plugin>.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 }
-- ============================================================================
-- 远端模型目录:**远端为主 + 兜底补位**(照参考的 reconcileWithFallback)
-- ============================================================================
--
-- 为什么不只用硬编码名单:上游模型目录是**会变**的(新增/下线/改名),
-- 硬编码必然过期。为什么不只用远端:远端可能拉不到(未登录/网络抖动/端点变更),
-- 那时平台就"没有模型可用"了。
--
-- 故正确形态是**合并**而非"远端失败就用兜底"的简单回退(参考 cline-models.ts
-- 明确写了这一条):远端有的以远端为准,远端没有的用兜底表补。
--
-- 注意:兜底表是**编译期快照**,价格/倍率会变,故兜底条目不携带价格类字段。
-- 取该平台**当前有效**的模型目录(远端优先,兜底补位,剔除黑名单)。
local function effectiveModels(provider)
local plat = platformById(provider)
if not plat then return {} end
local remote = plugin.state.remoteModels and plugin.state.remoteModels[provider]
local out, seen = {}, {}
-- 远端在前(权威顺序)
if type(remote) == "table" and type(remote.ids) == "table" then
for _, id in ipairs(remote.ids) do
if type(id) == "string" and id ~= "" and not seen[id] then
seen[id] = true out[#out+1] = id
end
end
end
-- 兜底补位(只补远端没有的)
for _, id in ipairs(plat.models or {}) do
if not seen[id] then seen[id] = true out[#out+1] = id end
end
return out
end
-- parseRemoteModels(parse, body):把各平台响应解析成 id 列表。
-- 每个分支的字段路径都照参考实现,含注释点名的坑。
local function parseRemoteModels(kind, body)
local ok, rec = pcall(json.decode, body or "")
if not ok or type(rec) ~= "table" then return {} end
local out = {}
if kind == "id_list" then
-- OpenAI 风格 {data:[{id}]};也有平台直接给数组或 {models:[...]}
local arr = rec.data or rec.models or rec
if type(arr) == "table" then
for _, m in ipairs(arr) do
local id = type(m) == "table" and m.id or (type(m) == "string" and m)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
end
end
elseif kind == "buddy_config" then
-- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。
-- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。
local d = rec.data or rec
local arr = d.models or d.agents or rec.models
if type(arr) == "table" then
for _, m in ipairs(arr) do
local id = type(m) == "table" and (m.id or m.modelId or m.name)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
end
end
elseif kind == "zcode_configs" then
-- 照参考 fetchZcodeModels:字段是 data.builtinModels,且它是**对象**
-- (键为序号 "0"/"1"),用 Array.isArray 判定会得到 0 个模型的假阴性。
-- Lua 里表无此区别,但要同时支持数组与 map 两形态。
local raw = rec.data and rec.data.builtinModels
if type(raw) == "table" then
for _, m in pairs(raw) do
local id = type(m) == "table" and (m.modelId or m.id)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
end
end
elseif kind == "cline_merge" then
-- 照参考 cline-models.ts:**必须同时打两个端点**。
-- recommended-models 是**唯一权威的 free 集合**;/api/v1/models 的 460 个
-- id 里根本没有 cline-free/*。故这里把它们合并去重。
local function pushFrom(node)
if type(node) ~= "table" then return end
local arr = node.data or node
if type(arr) ~= "table" then return end
for _, m in ipairs(arr) do
local id = type(m) == "table" and m.id or (type(m) == "string" and m)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
end
end
-- free 数组优先在前(它含 cline-free/* 与 stealth/*)
pushFrom(rec.free)
pushFrom(rec)
end
return out
end
-- refreshRemoteModels(provider):拉取并缓存该平台的远端目录。
-- 返回 (ids, err);**任何失败都不清空既有缓存**(宁可留旧目录也别让平台变空)。
local function refreshRemoteModels(provider)
local plat = platformById(provider)
if not plat then return nil, "未知平台" end
local mr = plat.models_remote
if not mr then return nil, "该平台无远端目录端点" end
-- 取一个可用账号的凭据(无账号则无凭据,仅当 auth=none 时仍可拉)
local tok
for _, a in ipairs(poolList(provider)) do
if a.enabled ~= false then
local t = credentialOf(a.credentialRef)
if t and t ~= "" then tok = t break end
end
end
if (not tok or tok == "") and mr.auth ~= "none" then return nil, "无可用凭据" end
local merged, seen = {}, {}
local lastErr
for _, ep in ipairs(mr.endpoints or {}) do
local url = ep.url
if ep.query then url = url .. "?" .. ep.query end
local headers = { ["Accept"] = "application/json" }
local useAuth = ep.url_auth ~= false and mr.auth ~= "none"
if useAuth and tok and tok ~= "" then
headers["Authorization"] = "Bearer " .. tok
end
-- 平台级身份头(buddy 必须带 X-Product-Code 等,否则会被当成另一个产品)
if mr.headers_fn and plat.headers_fn then
local extra = plat.headers_fn(tok or "", nil)
for k, v in pairs(extra or {}) do headers[k] = v end
end
local resp, err = __llmsproxy_http({
url = url, method = ep.method or "GET", headers = headers,
})
if err or not resp or resp.status ~= 200 then
lastErr = (err or ("HTTP " .. tostring(resp and resp.status)))
else
for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body)) do
if not seen[id] then seen[id] = true merged[#merged+1] = id end
end
end
end
if #merged == 0 then return nil, lastErr or "远端返回空目录" end
if not plugin.state.remoteModels then plugin.state.remoteModels = {} end
plugin.state.remoteModels[provider] = {
ids = merged,
fetched_at = os.time(),
}
markDirty()
return merged, nil
end
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-<provider>-<accountId>。模型开关即改写
-- 这条源的 models 列表(remove+add 重建;内核 add_source 同名覆盖,但显式
-- 先 remove 更诚实,避免旧模型残留)。
local function accountSourceName(provider, accountId)
return "channels-" .. provider .. "-" .. accountId
end
-- syncAccountSource 把某账号的上游源与(平台模型目录 - 黑名单)对齐。
-- 无凭据(占位账号)不动源。
local function syncAccountSource(provider, accountId)
local plat = platformById(provider)
local acct = poolGet(provider, accountId)
if not plat or not acct then return end
-- 凭据可能是裸 token 或完整凭据 JSON;上游源只吃 access token。
local tok = credentialOf(acct.credentialRef)
if not tok or tok == "" then return end
local name = accountSourceName(provider, accountId)
local models = {}
local disabled = disabledModelsOf(provider)
-- 用**有效目录**(远端优先、兜底补位),不是硬编码表
for _, m in ipairs(effectiveModels(provider)) 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
-- 会话过期时要顺带回收它为回调起的 loopback 接收器 —— 否则用户放弃
-- 登录后那些 python 监听会一直挂着(进程泄漏,且白占端口)。
reapLoopback(id)
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 > 环境变量 > 默认值。
-- buildAuthorizeUrl(plat, port, state, redirectUri, vars):按平台 authorize 规格拼授权 URL。
--
-- 规格形如 { base = "...", params = {...}, hash = "/portal#/login" }:
-- - params 里的值支持 {port} / {redirect_uri} 占位,另由 vars 提供
-- 平台特有的占位(如 Trae 的 machine_id / device_id / trace_id);
-- - hash 非空时把参数挂在 **hash 段**之后(LobsterAI 是 /portal#/login?…);
-- - 值一律百分号编码(redirect_uri 含 :// 与 :,不编码会被登录页拒)。
-- 参考注释明确警告过手工拼串漏编码的坑(buildLobsteraiLoginUrl)。
local function buildAuthorizeUrl(plat, port, state, redirectUri, vars)
local a = plat.authorize or {}
local base = a.hash or a.base or plat.login_url
local parts = {}
local function add(k, v)
v = tostring(v):gsub("{port}", tostring(port))
if redirectUri and redirectUri ~= "" then
v = v:gsub("{redirect_uri}", redirectUri)
end
if type(vars) == "table" then
for vk, vv in pairs(vars) do
v = v:gsub("{" .. vk .. "}", tostring(vv))
end
end
if k == "state" and (v == "" or v == "nil") then v = state end
parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v)
end
-- 确定性的顺序:排序键名,保证同一会话每次拼出的 URL 一致(便于对账/复现)
local keys = {}
for k in pairs(a.params or {}) do keys[#keys + 1] = k end
table.sort(keys)
for _, k in ipairs(keys) do add(k, a.params[k]) end
-- state 始终补上(除非 params 里已显式给了)
if not (a.params and a.params.state) then add("state", state) end
return base .. "?" .. table.concat(parts, "&")
end
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: <script> <cdp_port> <expr_file>
-- 打印一行 JSON: {"ok":true,"value":<...>} 或 {"ok":false,"error":"..."}
-- 用文件传表达式,避免 shell 引号地狱(表达式里常有引号/换行)。
local CDP_PY = [[
import json, sys, urllib.request
try:
import websocket
except Exception as e:
print(json.dumps({"ok": False, "error": "websocket-client 未安装: %s" % e}))
sys.exit(0)
port = int(sys.argv[1])
with open(sys.argv[2]) as f:
expr = f.read()
def http(path):
with urllib.request.urlopen("http://127.0.0.1:%d%s" % (port, path), timeout=10) as r:
return json.load(r)
try:
targets = http("/json/list")
except Exception as e:
print(json.dumps({"ok": False, "error": "CDP 不可达: %s" % e})); sys.exit(0)
page = None
for t in targets:
if t.get("type") == "page" and t.get("webSocketDebuggerUrl"):
page = t; break
if page is None:
print(json.dumps({"ok": False, "error": "没有可用的 page target"})); sys.exit(0)
try:
ws = websocket.create_connection(page["webSocketDebuggerUrl"], timeout=20)
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
"params": {"expression": expr, "returnByValue": True,
"awaitPromise": True}}))
while True:
msg = json.loads(ws.recv())
if msg.get("id") == 1:
break
ws.close()
except Exception as e:
print(json.dumps({"ok": False, "error": "CDP 求值失败: %s" % e})); sys.exit(0)
res = msg.get("result", {})
if "exceptionDetails" in res:
print(json.dumps({"ok": False, "error": res["exceptionDetails"].get("text", "JS 异常")}))
sys.exit(0)
print(json.dumps({"ok": True, "value": res.get("result", {}).get("value")}))
]]
local function cdpScriptPath()
local p = "/tmp/llmsproxy-cdp-eval.py"
local f = io.open(p, "r")
if f then f:close() return p end
local w = io.open(p, "w")
if not w then return nil end
w:write(CDP_PY)
w:close()
return p
end
-- cdpEval 在服务端浏览器的当前页上求值一个 JS 表达式。
-- 返回 (value, err)。value 是 JS 返回值(经 returnByValue 序列化)。
local function cdpEval(cdpPort, expr)
local script = cdpScriptPath()
if not script then return nil, "无法写入 CDP 脚本" end
local exprFile = ("/tmp/llmsproxy-cdp-expr-%d.js"):format(cdpPort)
local w = io.open(exprFile, "w")
if not w then return nil, "无法写入表达式文件" end
w:write(expr)
w:close()
local h = io.popen(("python3 %s %d %s 2>/dev/null"):format(script, cdpPort, exprFile))
if not h then os.remove(exprFile) return nil, "python3 执行失败" end
local out = h:read("*a")
h:close()
os.remove(exprFile)
if not out or out == "" then return nil, "CDP 无输出" end
local ok, rec = pcall(json.decode, out)
if not ok or type(rec) ~= "table" then return nil, "CDP 输出无法解析: " .. tostring(out):sub(1, 200) end
if rec.ok ~= true then return nil, tostring(rec.error or "CDP 失败") end
return rec.value, nil
end
-- ============================================================================
-- loopback 回调接收器(平台要求 redirect_uri 落在本机精确路径时用)
-- ============================================================================
--
-- 为什么必需:各回调平台的 redirect_uri 都是**回环地址**(127.0.0.1 / localhost)
-- 且路径是各自注册值(有道 /auth/callback、Trae /authorize、Gemini /oauth-callback),
-- 而本插件的挂载点在 `/api/plugins/http/…`;更关键的是**远程用户机器上的
-- 127.0.0.1 不是本网关** —— 那样重定向根本打不到这里。
--
-- 做法:插件(可信、有 OS 能力)在本机起一个极小的 HTTP 监听,路径前缀就是该
-- 平台的注册路径。**双栈绑回环**(::1 与 127.0.0.1 同一端口):参考注释点明
-- 「浏览器常把 localhost 解析成 IPv6」,而 Gemini 注册的就是 http://localhost:…
-- —— 只绑 IPv4 会让那条回调连不上(但**绝不**绑 :: 对外暴露)。
-- 它把回调 query 原子写进**文件**(不拼 shell,避免引号/特殊字符注入 ——
-- 与 CDP 桥用文件传表达式同一理由),然后给浏览器一个可关闭的页面。
--
-- 校验规则按平台形态分两类(不能一刀切):
-- * Trae:回调把 token 直接放 query(refreshToken/userJwt/…),**不带 state**
-- (参考 parseTraeCallback 从不读 state),故对 /authorize 不要求 state。
-- * Gemini / 有道:标准授权码回调,state 必须匹配,否则 400(防伪造回调)。
--
-- 真正入账前的 state 终校在 poll_login(读文件后再次比对 nonce)。
--
-- 返回 (redirect_url, nil) 或 (nil, err)。进程 pid 记入 plugin.state.loopbacks,
-- 会话结束时由 reapLoopback 回收。
local LOOPBACK_PY = [[
import sys, json, os, socket, tempfile, threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
prefix, out_path, want_state, port_path = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
# Trae 的回调不带 state(它把 token 放在 query 里,靠路径区分);其余平台必须带。
require_state = (prefix != "/authorize")
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
u = urlparse(self.path)
if not u.path.startswith(prefix):
self.send_response(404); self.end_headers(); self.wfile.write(b"Not found"); return
q = parse_qs(u.query)
state = (q.get("state") or [""])[0]
if require_state and (not state or (want_state and state != want_state)):
# state 缺失/不匹配 → 不是本次登录发起的(或伪造),直接拒(对齐参考)。
self.send_response(400); self.send_header("Content-Type","text/plain; charset=utf-8")
self.end_headers(); self.wfile.write("\u767b\u5f55\u56de\u8c03\u53c2\u6570\u65e0\u6548".encode()); return
# 把整个回调 query 原样转成 JSON(含 code/authCode 与 Trae 的
# refreshToken/userJwt/userInfo 等),由 Lua 侧按平台形态取用。
payload = {}
for k, v in q.items():
payload[k] = v[0] if v else ""
code = payload.get("code") or payload.get("authCode") or ""
if require_state and not code:
self.send_response(400); self.send_header("Content-Type","text/plain; charset=utf-8")
self.end_headers(); self.wfile.write("\u767b\u5f55\u56de\u8c03\u7f3a\u5c11\u6388\u6743\u7801".encode()); return
if not payload:
self.send_response(400); self.end_headers(); self.wfile.write(b"No params"); return
# 不经 shell 拼串,直接写成 JSON 文件(避免引号/特殊字符注入)。
d = os.path.dirname(out_path)
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
json.dump(payload, f)
os.replace(tmp, out_path)
self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
self.end_headers()
self.wfile.write("<html><body><h2>\u767b\u5f55\u6210\u529f\uff0c\u53ef\u4ee5\u5173\u95ed\u6b64\u7a97\u53e3\u4e86</h2></body></html>".encode())
# 双栈:两个 HTTP 实例都要主动停(否则非当前那个会挂着不退)。
def _stop():
for s in SERVERS:
try: s.shutdown()
except Exception: pass
threading.Thread(target=_stop, daemon=True).start()
SERVERS = []
# 为何要双栈:参考注释点明「浏览器常把 localhost 解析成 IPv6」——
# 只绑 127.0.0.1 时,Gemini 的 http://localhost:…/oauth-callback 会连不上。
# 不能改成绑 ::(那等于对外暴露);只绑回环地址的两种家族。
# 注意:address_family 必须在**构造前**就在类上定好(socketserver 建 socket
# 时读的就是这个类属性);构造后再赋值无效 —— 那会让 IPv6 分支静默失败。
class V6Server(HTTPServer):
address_family = socket.AF_INET6
port = 0
try:
v6 = V6Server(("::1", 0), H)
port = v6.server_address[1]
SERVERS.append(v6)
except Exception:
pass
try:
v4 = HTTPServer(("127.0.0.1", port), H)
if port == 0:
port = v4.server_address[1]
SERVERS.append(v4)
except Exception:
pass
if not SERVERS:
sys.exit(3)
# 把实际选中的端口写回文件:调用方读它拼 redirect_uri(bind 0 时端口只有 bind 后才知道)。
with open(port_path, "w") as f:
f.write(str(port))
for s in SERVERS:
threading.Thread(target=s.serve_forever, daemon=True).start()
threading.Event().wait()
]]
local function loopbackScriptPath()
local p = "/tmp/llmsproxy-loopback.py"
local w = io.open(p, "w")
if not w then return nil end
w:write(LOOPBACK_PY)
w:close()
return p
end
-- reapLoopback 杀掉某会话的接收器进程并清文件(无需常驻,用完即收)。
-- 必须定义在 loopbackReceiver **之前**:Lua 的 local 作用域只覆盖其后的代码,
-- 反过来写会让 loopbackReceiver 里的调用拿到 nil(真实踩过的坑)。
local function reapLoopback(sessionId)
local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId]
if not l then return end
if l.pid and l.pid ~= "" then
os.execute("kill -9 " .. tostring(l.pid) .. " 2>/dev/null")
end
if l.file then os.remove(l.file) end
if l.port_file then os.remove(l.port_file) end
plugin.state.loopbacks[sessionId] = nil
end
-- loopbackReceiver(session_id, cb_path):起接收器,返回该会话的 redirect_uri。
--
-- cb_path 必须与平台注册值**逐字一致**(改了就是 redirect_uri_mismatch):
-- 有道 /auth/callback(参考注释点名「精确路径」)、
-- Trae /authorize、Gemini /oauth-callback(参考 GEMINI_CALLBACK_PATH)。
local function loopbackReceiver(sessionId, cbPath)
cbPath = cbPath or "/auth/callback"
local script = loopbackScriptPath()
if not script then return nil, "无法写入 loopback 脚本" end
local dir = storeDir()
local outFile = dir .. "/callback-" .. sessionId .. ".json"
local portFile = dir .. "/callback-" .. sessionId .. ".port"
os.remove(outFile)
os.remove(portFile)
local sess = sessions()[sessionId]
local wantState = (sess and sess.nonce) or ""
-- 端口由 python bind(0) 自己选,绑 127.0.0.1(不对外暴露监听面);
-- 它把实际端口写回 portFile,我们轮询读取后拼 redirect_uri。
local cmd = ("python3 %s %s %s %s %s >/tmp/llmsproxy-loopback-%s.log 2>&1 & echo $!"):format(
script, cbPath, outFile, wantState, portFile, sessionId)
local h = io.popen(cmd)
local pid = h and h:read("*l")
if h then h:close() end
if not pid or pid == "" then return nil, "loopback 启动失败(python3 不可用?)" end
if not plugin.state.loopbacks then plugin.state.loopbacks = {} end
plugin.state.loopbacks[sessionId] = { pid = pid, file = outFile, port_file = portFile }
-- 等端口文件出现(就绪探针,不用 sleep 拍脑袋)
local deadline = (os.time and os.time() or 0) + 10
local port = nil
while true do
local f = io.open(portFile, "r")
if f then
local v = f:read("*l")
f:close()
port = tonumber(v)
if port then break end
end
if (os.time and os.time() or 0) >= deadline then
reapLoopback(sessionId)
return nil, "loopback 未就绪(未取得端口)"
end
os.execute("sleep 0.2")
end
-- 主机用 127.0.0.1(不是 localhost,除非平台注册值就是 localhost);
-- 路径按平台注册值一字不差。
local host = (sess and sess.callback_host) or "127.0.0.1"
return "http://" .. host .. ":" .. tostring(port) .. cbPath, nil
end
-- readLoopbackCallback(session_id):读接收器写下的回调 query。
-- 返回 (payload_table, nil) 或 (nil, nil)。未到/文件不存在返回 nil,nil ——
-- 调用方应继续保持 pending(不能把「还没回调」当成「失败」)。
local function readLoopbackCallback(sessionId)
local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId]
local f = l and l.file and io.open(l.file, "r")
if not f then return nil, nil end
local body = f:read("*a")
f:close()
local ok, rec = pcall(json.decode, body or "")
if not ok or type(rec) ~= "table" then return nil, nil end
return rec, nil
end
-- ============================================================================
-- spawnBrowser 拉起一组服务端浏览器。返回
-- { view_url, password, cdp_port, display, vnc, ws } 或 nil, err。
--
-- 与早期实现的差别(都是真实踩过的坑):
-- 1. **就绪探针**:不再 sleep 2 拍脑袋,各端口逐个等(X 起来 → 浏览器起 →
-- VNC 起 → websockify 起),否则 view_url 先于服务可用,iframe 白屏。
-- 2. **CDP 端口**:浏览器带 --remote-debugging-port,登录完成后可读凭据
-- (见 cdpEval / vncCollect),而不是只能人眼看屏幕。
-- 3. **网关端口来自内核**:回调地址拼的是本实例真实端口,不硬编码 8081。
-- 4. **依赖预检**:缺依赖直接给出清单(谁、干什么用、装什么包),
-- 而不是一个含糊的"进程启动失败"。
-- spawnBrowser(start_url):拉起服务端浏览器栈(Xvfb + chromium + x11vnc +
-- websockify),返回 VNC 视图地址与 CDP 端口。
-- start_url 已是**拼装完整的授权页 URL**(redirect_uri/state 由调用方按平台
-- 规格填好),此处不再改写。
local function spawnBrowser(start_url)
local b = browserStack()
if b.session then return nil, "已有浏览器会话在跑,请先停止" end
local deps = browserDeps()
if not deps.ok then
local miss = {}
for _, m in ipairs(deps.missing) do
miss[#miss + 1] = ("%s(%s,装 %s)"):format(m.bin, m.why, m.pkg)
end
return nil, "缺少浏览器栈依赖: " .. table.concat(miss, ", ")
end
b.seq = b.seq + 1
local seq = b.seq
-- display / 端口取号:**探测空闲**,不能按 seq 取模。
-- -- 真实缺陷:原先写 `90 + (seq % 10)`,而 Xvfb 的锁文件(/tmp/.X<n>-lock)在
-- 进程被强杀时会残留。重启后 seq 从头开始 → 又选中同一个号 → Xvfb 直接
-- `Fatal: Server is already active for display 91` 退出,VNC 兜底永远起不来,
-- 而报错只显示"Xvfb 未就绪",看不出是被残留锁挡住。
-- 现在从 91 起找第一个既无锁文件、也无 socket 的号;端口同理探测空闲。
local function pickFree(start, isFree)
for n = start, start + 40 do
if isFree(n) then return n end
end
return start
end
-- 判存在必须用 `[ -e ]` 而不是 io.open:X socket 是 socket 文件,
-- io.open 对它返回 nil(打不开)→ 探测会误判"空闲"→ 又选中被残留 socket
-- 占住的号 → Xvfb `Cannot establish any listening sockets` 退出。
-- 这个坑真实踩过:残留 X91 socket 让显示器探测完全失效。
local function pathExists(path)
local ok = os.execute('[ -e "' .. path .. '" ] 2>/dev/null')
-- Lua 5.1 返回数字(0=成功),5.2+ 返回 true/nil
return ok == true or ok == 0
end
local disp = pickFree(91, function(n)
return not pathExists("/tmp/.X" .. n .. "-lock")
and not pathExists("/tmp/.X11-unix/X" .. n)
end)
local function portFree(p)
-- 用 /proc/net/tcp + tcp6 查是否有 LISTEN(十六进制端口)
for _, f in ipairs({ "/proc/net/tcp", "/proc/net/tcp6" }) do
local h = io.open(f, "r")
if h then
local hex = ("%04X"):format(p)
for line in h:lines() do
local lp = line:match("^%s*%d+:%s*[0-9A-F]+:([0-9A-F]+)")
if lp == hex then h:close() return false end
end
h:close()
end
end
return true
end
local vport = pickFree(59100, portFree)
local wport = pickFree(60100, portFree)
local cdpPort = pickFree(61100, portFree)
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 gwPort = gatewayPort()
-- 不再改写 start_url 里的端口/回调地址:`{port}` 占位已在 buildAuthorizeUrl
-- 拼装时换成 gatewayPort(),而各平台的回调路径**彼此不同**(Trae 用
-- auth_callback_url 指本插件挂载点、LobsterAI 用 /auth/callback 指 loopback
-- 接收器)。此前这里用 gsub 把 "redirect_uri=127.0.0.1:<port>" 一律改写成
-- /api/plugins/http/channels/callback —— 一旦命中就会把需要别的路径的平台
-- (尤其有道,其 login 段还被百分号编码)改错。现为死代码且只可能致害,遂删。
-- 每步起一个进程并等它的端口就绪(X 用 socket 探,别的用 TCP)。
local pids = {}
local function spawn(name, cmd, host, port, timeoutMs)
local h = io.popen(cmd)
local pid = h and h:read("*l")
if h then h:close() end
if not pid or pid == "" then
return false, name .. " 启动失败(cmd=" .. tostring(cmd):sub(1,80) .. ")"
end
pids[#pids + 1] = pid
-- 立刻回写到 plugin.state,**不能只在函数末尾赋值**:
-- 任何一步中途失败都要 reapBrowser(),而它读的是 b.pids。若此时 b.pids 还是
-- 旧值(空),已经起来的进程就不会被杀 → 孤儿 Xvfb/chromium 常驻。
-- 真实踩过:4 个失败的 VNC 会话留下 4 个 orphan Xvfb。
b.pids = pids
if port and port > 0 then
if not waitPort(host or "127.0.0.1", port, timeoutMs or 8000) then
return false, name .. " 端口未就绪(:" .. port .. ")"
end
end
return true, pid
end
local chromium = deps.chromium
local novnc = deps.novnc
local steps = {
{ "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%d.log 2>&1 & echo $!"):format(disp, seq), nil, 0, 0 },
{ "chromium", ("%s --no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/llmsproxy-ch-%d "
.. "--no-first-run --disable-gpu --remote-debugging-port=%d --remote-allow-origins=* "
.. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%d.log 2>&1 & echo $!"):format(
chromium, seq, cdpPort, disp, url, seq), "127.0.0.1", cdpPort, 15000 },
{ "x11vnc", ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet "
.. ">/tmp/llmsproxy-x11vnc-%d.log 2>&1 & echo $!"):format(
disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, seq), "127.0.0.1", vport, 8000 },
{ "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%d.log 2>&1 & echo $!"):format(
novnc, bind, wport, vport, seq), bind, wport, 8000 },
}
-- ① 先起 Xvfb(其余三步依赖 X display)
local xvfbStep = steps[1]
local xok, xerr = spawn(xvfbStep[1], xvfbStep[2], nil, 0, 0)
if not xok then
reapBrowser()
return nil, xerr
end
-- X 的 socket 在 /tmp/.X11-unix/X<n>,等它出现再起浏览器
local unixSock = ("/tmp/.X11-unix/X%d"):format(disp)
local xdeadline = (os.time and os.time() or 0) + 10
while true do
-- 同 pathExists 的理由:X socket 是 socket 文件,io.open 打不开,
-- 用它判"就绪"会永远等不到(这个坑在上面选号处也踩过一次)。
if pathExists(unixSock) then break end
if (os.time and os.time() or 0) >= xdeadline then
reapBrowser()
return nil, "Xvfb 未就绪(" .. unixSock .. " 未出现)"
end
os.execute("sleep 0.3")
end
-- ② 起浏览器/VNC/websockify(三步都有 TCP 端口可探)
for i = 2, #steps do
local st = steps[i]
local ok, err = spawn(st[1], st[2], st[3], st[4], st[5])
if not ok then
reapBrowser()
return nil, err
end
end
b.pids = pids
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq }
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,
cdp_port = cdpPort,
display = disp,
}
end
-- ============================================================================
-- 登录流程:优先级阶梯(① 本机浏览器 → ② 自动化 → ③ VNC 兜底)
-- ============================================================================
-- start_login 按优先级阶梯响应:
-- ① 本机浏览器:直接返回登录 URL(绝大多数场景到此为止 —— 平台重定向打在
-- 用户自己的机器上,插件轮询/收回调拿凭据)
-- ② 自动化:link-poll/callback 流程本身就是自动的(轮询 + nonce 回调),
-- 无需用户之外的动作
-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、
-- 注意回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。
-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、
-- 注意回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。
--
-- 有道 LobsterAI:portal 授权页要求 redirect_uri 是
-- http://127.0.0.1:{port}/auth/callback(**精确路径**,参考注释点名)。
-- 远程用户机器上的 127.0.0.1 **不是本网关** —— portal 重定向打不到本插件的
-- /api/plugins/http/... 挂载点。故插件(可信、有 OS 能力)在本机起一个
-- 轻量的 loopback 接收器,portal 重定向落到它的本地端口;它把 authCode/state
-- 写进一个文件,poll_login 读该文件完成交换。这样远程用户在服务端浏览器(VNC)
-- 里完成交互后,portal 直接重定向到该本机端口,无需远程浏览器与网关同机。
local function initLoginSession(plat, args, now)
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,
}
if plat.id == "lobsterai" then
local seed = newToken()
sess.install_uuid = seed:sub(1, 8) .. "-" .. seed:sub(9, 12) .. "-4"
.. seed:sub(14, 16) .. "-8" .. seed:sub(18, 20) .. "-" .. seed:sub(21, 32)
sess.first_keyfrom = tostring(now * 1000)
elseif plat.id == "trae" then
-- 设备身份:参考生成两个 **hex32**(照 login.sh 的 `openssl rand -hex 16`),
-- 并在登录 URL 里下发。凭据里也要留一份(签到 X-Device-Id 用),
-- 且**每账号必须不同** —— 同一天两账号共用会被「该设备已签到」拦。
-- 此前只发 5 个参数、完全没造这两个值,登录页就停在「授权中」不回来。
sess.machine_id = newToken():sub(1, 32)
sess.device_id = newToken():sub(33, 64)
local joined = sess.machine_id .. sess.device_id
sess.login_trace_id = #joined >= 16 and joined:sub(-16) or joined
end
sessions()[sess.id] = sess
sess.account_id = args and args.account_id
return sess
end
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 = initLoginSession(plat, args, now)
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.id == "lobsterai" or plat.id == "trae" or plat.id == "gemini" then
-- 这三个平台的 redirect_uri 都是**回环地址**(127.0.0.1 / localhost),
-- 且路径各自是注册值:有道 /auth/callback、Trae /authorize、
-- Gemini /oauth-callback。回环地址由「打开授权页的那台机器」解析,而
-- 服务端浏览器就在本机 → 重定向落本机 → 插件自己的接收器能接住它。
-- 远程用户在自己电脑上打开则永远接不住(这正是 callback 流在服务器
-- 部署下必须走服务端浏览器的原因)。
local cbPath = (plat.authorize and plat.authorize.callback_path)
or (plat.id == "gemini" and "/oauth-callback")
or (plat.id == "trae" and "/authorize") or "/auth/callback"
-- Gemini 注册的 redirect_uri 主机是 **localhost**(不是 127.0.0.1),
-- 参考注释明确点名;两者在多数机器上等价,但必须与注册值逐字一致。
if plat.id == "gemini" then sess.callback_host = "localhost" end
local cb_url, cb_err = loopbackReceiver(sess.id, cbPath)
if cb_err then
sess.state = "failed"
out.error = "无法启动回调接收器: " .. cb_err
return { status = 200, json = out }
end
sess.redirect_uri = cb_url
out.redirect_uri = cb_url
end
if plat.flow == "manual" then
-- 无法自动化的登录(WASM/captcha/扫码):给明确说明而不是假装成功。
sess.state = "failed"
sess.message = plat.manual_hint or "该平台不支持自动登录"
out.manual_hint = sess.message
out.url = plat.login_url
logEvent("手动流程 " .. plat.id)
return { status = 200, json = out }
elseif plat.flow == "state-poll" then
-- Buddy:先拿 state + authUrl。页面给 authUrl,轮询 token 每推进一步。
local resp, err = __llmsproxy_http({
url = plat.state.state_url,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
body = "{\"platform\":\"ide\"}",
})
if err or resp.status ~= 200 then
sess.state = "failed"
out.error = "auth/state 失败: " .. (err or ("HTTP " .. tostring(resp.status)))
return { status = 200, json = out }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or type(rec.data) ~= "table"
or not rec.data.state or rec.data.state == "" then
sess.state = "failed"
out.error = "auth/state 响应缺少 state 字段"
return { status = 200, json = out }
end
sess.state_token = rec.data.state
out.url = rec.data.authUrl or plat.login_url
logEvent("state 已申请 " .. plat.id)
elseif plat.flow == "vnc" then
-- ③ 兜底路径:服务端浏览器。重定向打在服务端回环上,回调命中本插件的挂载点。
local bs, berr = spawnBrowser(string.format(plat.login_url, 0))
if berr then
out.browser_error = berr
out.hint = "服务端浏览器不可用;运行 channels/deps/status 看缺什么依赖"
logEvent("服务端浏览器启动失败: " .. berr)
else
out.view_url = bs.view_url
out.session.cdp_port = bs.cdp_port
sess.cdp_port = bs.cdp_port
-- 登录页需要从服务端浏览器完成;用户交互后由 collect_credentials 取凭据
sess.collect_expr = plat.collect_expr
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
end
elseif plat.flow == "qr-poll" then
-- 自带二维码的扫码登录(Raccoon):code 由**本地随机生成**,服务端接受
-- 任意自造 code 并进入 pending(参考注释实测)。二维码图交给前端渲染。
local q = plat.qr
local code = tostring(sha256_hex(tostring(os.time and os.time() or 0) .. tostring(math.random(1e12)))):sub(1, 32)
sess.qr_code = code
out.qr_url = q.api_base .. q.login_page .. "?code=" .. code
.. "&appname=" .. rfc3986Escape(q.app_name)
out.url = out.qr_url
out.session.interval = q.interval or 2
logEvent("二维码已生成 " .. plat.id)
elseif plat.flow == "wechat-poll" then
-- 微信扫码:取 uuid → 把二维码给前端渲染 → 长轮询等扫码确认。
-- uuid 从授权页 HTML 里抽(参考 extractLoomyWechatUuid 的两条正则兜底)。
local w = plat.wechat
local authUrl = w.auth_url:gsub("{state}", sess.nonce)
local resp, err = __llmsproxy_http({
url = authUrl, method = "GET",
headers = { ["Accept"] = "text/html", ["User-Agent"] = w.user_agent,
["Referer"] = "https://open.weixin.qq.com/" },
})
if err or not resp or resp.status ~= 200 then
sess.state = "failed"
out.error = "微信授权页拉取失败: " .. (err or ("HTTP " .. tostring(resp and resp.status)))
return { status = 200, json = out }
end
local html = resp.body or ""
-- 两条路径互为兜底(照参考 extractLoomyWechatUuid 的正则):
-- 主:<img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>
-- 兜底:var fordevtool = "…/connect/l/qrconnect?uuid=<uuid>"
-- 关键:无需执行 JS —— 这是本方案能脱离浏览器的基础。
local uuid = html:match("/connect/qrcode/([%w_%-=+/]+)")
or html:match("l/qrconnect%?uuid=([%w_%-=+/]+)")
if not uuid or uuid == "" then
sess.state = "failed"
out.error = "微信授权页未包含二维码 uuid(页面结构可能已变化)"
return { status = 200, json = out }
end
sess.wechat_uuid = uuid
sess.wechat_last = ""
-- 二维码图交给前端渲染(参考把 JPEG 转 data URL;这里给前端 URL,省一次下载)
out.qr_url = w.qr_url:gsub("{uuid}", uuid)
out.url = authUrl
logEvent("微信二维码已就绪 " .. plat.id)
elseif plat.flow == "init-poll" then
-- 两段式服务端中介授权(ZCode):init 拿 flow_id + authorize_url,
-- 用户授权后轮询 poll/{flow_id} 拿 token。
-- 会话密钥是**本地造的 32 字节 hex**,作 Bearer 用(官方就是这么做的)。
local ip = plat.initpoll
local flowSecret = sha256_hex(newToken() .. tostring(os.time and os.time() or 0))
.. sha256_hex(newToken())
local resp, err = __llmsproxy_http({
url = ip.init_url,
method = "POST",
headers = {
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. flowSecret,
["User-Agent"] = "ZCode/" .. (ip.app_version or "3.14.3"),
["HTTP-Referer"] = ip.origin or "",
["X-ZCode-App-Version"] = ip.app_version or "3.14.3",
["X-Platform"] = "win32",
},
body = json.encode({ provider = ip.provider or "bigmodel" }),
})
if err then
sess.state = "failed"
out.error = "授权初始化失败: " .. err
return { status = 200, json = out }
end
local ok, rec = pcall(json.decode, resp.body or "")
local data = ok and type(rec) == "table" and rec.data or nil
if not data or type(data.flow_id) ~= "string" or data.flow_id == "" then
sess.state = "failed"
out.error = "授权初始化响应缺少 flow_id(HTTP " .. tostring(resp.status) .. ")"
return { status = 200, json = out }
end
sess.init_flow_id = data.flow_id
sess.init_secret = flowSecret
sess.device_interval = tonumber(data.poll_interval_sec) or ip.interval or 2
out.url = data.authorize_url or plat.login_url
out.session.interval = sess.device_interval
logEvent("授权流程已初始化 " .. plat.id .. " flow=" .. tostring(data.flow_id):sub(1, 12))
elseif plat.flow == "device-poll" then
-- 设备码:申请码,把 user_code + 授权 URL 给用户,verifier 存进会话。
-- -- 两种形态:
-- ① OAuth 设备码(Minimax/Cline):POST 拿 device_code + user_code,
-- 再 GET token_url?device_code=... 轮询(code_body / token_body 模板)。
-- ② PKCE 授权页(Qoder):**不申请设备码** —— 本地造 PKCE,直接把
-- {authBase}/...?challenge&nonce&machine_id&client_id 给用户当授权页,
-- 再轮询 token_url?nonce&verifier 由服务端"回填"token(404=继续)。
-- 此时 code_url 带 Q_QUERY 后缀表示"GET + query 模板",无 device_code。
local verifier, challenge = devicePkce()
local machineId = newToken():sub(1, 32)
local dm = plat.device.code_method or "POST"
local rec = nil
if dm == "GET_QUERY" or plat.device.auth_query then
-- 形态②:授权页 URL 由模板拼装,无网络申请
local q = (plat.device.auth_query or "")
:gsub("{challenge}", challenge):gsub("{nonce}", sess.nonce)
:gsub("{machine_id}", machineId):gsub("{verifier}", verifier)
out.url = plat.device.code_url .. "?" .. q
sess.device_verifier = verifier
sess.device_challenge = challenge
sess.device_nonce = sess.nonce
sess.device_machine = machineId
sess.device_interval = plat.device.interval or 2
out.session.interval = sess.device_interval
logEvent("授权页已就绪 " .. plat.id)
return { status = 200, json = out }
end
local body = (plat.device.code_body:gsub("{challenge}", challenge))
local resp, err = __llmsproxy_http({
url = plat.device.code_url,
method = dm,
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, r2 = pcall(json.decode, resp.body or "")
rec = ok and type(r2) == "table" and r2 or nil
if not rec 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 流程的授权 URL 构造有三条硬性要求(此前都错):
-- 1) **每个平台的参数集不同**,不能套通用模板:Trae 用 auth_callback_url
-- (不是 redirect_uri)、Gemini 用标准 OAuth 四参数、LobsterAI 的 login
-- 在 **hash 段**(/portal#/login)。
-- 2) 端口必须用**本机实际监听端口**,不能硬编码 8081(非默认部署会全错)。
-- 3) redirect_uri 必须百分号编码(含 :// 与 :,不编码会被授权页拒)。
local port = gatewayPort()
if plat.flow == "callback" then
if type(plat.authorize) == "table" then
local vars
if sess.machine_id then
vars = { machine_id = sess.machine_id, device_id = sess.device_id,
trace_id = sess.login_trace_id }
end
out.url = buildAuthorizeUrl(plat, port, sess.nonce, sess.redirect_uri, vars)
else
local cb = "http://localhost:" .. tostring(port) .. "/api/plugins/http/channels/callback"
local sep = plat.login_url:find("?", 1, true) and "&" or "?"
out.url = plat.login_url .. sep .. "redirect_uri=" .. rfc3986Escape(cb)
.. "&state=" .. sess.nonce
end
-- callback 流的 redirect_uri 是**回环地址**(127.0.0.1 / localhost)。
-- 回环地址由「打开授权页的那台机器」解析:用户在自己电脑上打开时,重定向
-- 落在**用户机器**,回调永远到不了本网关 —— 这是服务器部署下 callback 流
-- 无法靠「本机浏览器」完成的根本原因(CodeBuddy 能用的原因是它走
-- state-poll,根本不需要回调)。
--
-- 故 callback 流要走**服务端浏览器**:授权页在网关本机打开(经 VNC 呈现给
-- 运维交互),重定向落本机回环,回调命中本插件的 loopback 接收器/挂载点。
-- 没有浏览器栈时**如实说明**,不假装这个 URL 在用户机器上能用。
local deps = browserDeps()
if deps.ok then
local bs, berr = spawnBrowser(out.url)
if not berr then
out.view_url = bs.view_url
out.session.cdp_port = bs.cdp_port
sess.cdp_port = bs.cdp_port
sess.collect_expr = plat.collect_expr or plat.callback_collect_expr
out.server_browser = true
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
else
out.browser_error = berr
logEvent("服务端浏览器启动失败 " .. plat.id .. ": " .. berr)
end
else
local miss = {}
for _, m in ipairs(deps.missing) do miss[#miss + 1] = m.bin end
out.browser_error = "缺浏览器栈依赖: " .. table.concat(miss, ", ")
end
out.hint = out.server_browser
and "请在 VNC 窗口内完成授权(回调会落在网关本机)"
or "callback 流的回调是回环地址,只有与网关同机的浏览器才能完成;请先安装浏览器栈(channels/deps/status 可查缺什么)"
else
out.url = plat.login_url
end
end
return { status = 200, json = out }
end
-- lobsteraiClientVersion(plat):解析有道客户端版本号(带缓存与兜底)。
--
-- 三个消费点都要它:exchange 的 `version`、续期请求体的 `version`、签到 query。
-- 集中一处避免各自拉取。照参考 LobsteraiVersionResolver:
-- - 远程成功 → 用远程值并缓存;
-- - 失败 → 用兜底值但**不缓存**(缓存会让一次瞬时故障在 TTL 内持续发酵)。
-- **绝不能写死**:实测 2026-10-13 上游已是 2026.9.23。
local function lobsteraiClientVersion(plat)
local cv = plat.client_version
if type(cv) ~= "table" then return "2026.9.4" end
local cache = plugin.state.clientVersion
local now = os.time and os.time() or 0
if type(cache) == "table" and cache.value and cache.at
and (now - cache.at) < (cv.ttl or 3600) then
return cache.value
end
local resp, err = __llmsproxy_http({
url = cv.url, method = "GET",
headers = { ["Accept"] = "application/json", ["User-Agent"] = "LobsterAI/0.1.0" },
})
if not err and resp and resp.status == 200 then
local ok, rec = pcall(json.decode, resp.body or "")
local v = ok and rec
-- 按 path(data.value.version)逐层下钻
for seg in tostring(cv.path or ""):gmatch("[^%.]+") do
if type(v) ~= "table" then v = nil break end
v = v[seg]
end
-- 格式校验(照参考的日期式版本号正则):主干点分数字,可选预发布后缀。
-- 不校验而直接采信:上游返 HTML/空值时会拼出一个更费解的失败。
if type(v) == "string" and v:match("^%d+[%d%.]*[%w%.%-]*$") then
plugin.state.clientVersion = { value = v, at = now }
return v
end
logEvent("有道版本号格式异常,改用兜底: " .. tostring(v))
end
-- 失败:兜底且不缓存
return cv.fallback or "2026.9.4"
end
-- lobsteraiExchange(plat, code, s):用 authCode 换有道凭据。
--
-- 同一段交换逻辑有两处入口:① 回调落到本插件挂载点(oauth_callback),
-- ② 回调落到本机 loopback 接收器、由 poll_login 读文件推进。两条路必须
-- 逐字一致 —— 否则改一处漏一处,正是 go:embed 三连坑那种「同一 bug 多副面孔」。
-- 故集中在此,返回 (credential_json, account, err)。
local function lobsteraiExchange(plat, code, s)
if type(code) ~= "string" or code == "" then return nil, nil, "missing authCode" end
local now = tostring(os.time() * 1000)
local resp, err = __llmsproxy_http({
url = plat.callback.token_url, method = "POST",
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json",
["User-Agent"] = "LobsterAI/0.1.0" },
body = json.encode({ authCode = code, firstKeyfrom = s.first_keyfrom,
latestKeyfrom = now, uuid = s.install_uuid, version = lobsteraiClientVersion(plat) }),
})
if err or not resp or resp.status ~= 200 then
return nil, nil, "LobsterAI exchange 请求失败: " .. tostring(err or (resp and resp.status))
end
local ok, rec = pcall(json.decode, resp.body or "")
local d = ok and type(rec) == "table" and rec.data
if not (rec and type(rec) == "table" and rec.code == 0 and type(d) == "table"
and type(d.accessToken) == "string" and d.accessToken ~= "") then
-- 把上游的原因带出来(实测失败形如 code=40102 message="无效或已过期的授权码"),
-- 否则运维只看到「交换失败」而无法区分是码过期、参数错还是版本被拒。
local why = ""
if ok and type(rec) == "table" then
why = tostring(rec.msg or rec.message or ("code=" .. tostring(rec.code)))
end
return nil, nil, "LobsterAI exchange 失败: " .. why
end
local u = type(d.user) == "table" and d.user or {}
local function nonempty(v) return type(v) == "string" and v ~= "" and v or nil end
-- uid 四级回退(照参考 resolveLobsteraiUid):user.id → userId → yid → token 摘要
local uid = nonempty(u.id) or nonempty(u.userId) or nonempty(u.yid)
or sha256_hex(d.accessToken):sub(1, 16)
local account = nonempty(u.nickname) or uid
-- uuid/first_keyfrom 必须随凭据保存:续期请求体要原样回传(参考注释点名)。
local cred = { access_token = d.accessToken, refresh_token = d.refreshToken or "",
uid = uid, user_id = nonempty(u.userId) or nonempty(u.yid) or "", nickname = account,
uuid = s.install_uuid, first_keyfrom = s.first_keyfrom, latest_keyfrom = now }
local exp = tonumber(d.expiresIn)
if exp and exp > 0 then cred.expires_at = tostring(os.time() * 1000 + exp * 1000)
else cred.expires_at = credentialExpiresAtMs(cred) end
return json.encode(cred), account, nil
end
-- plugin.poll_login(session_id) 由页面轮询:会话是否完成。
--
-- 对 device-poll 会话,每次轮询**推进一步**设备码换 token(deviceOneStep):
-- 轮询由页面驱动而不在 Lua 里阻塞,因为插件是单 VM 单锁,阻塞循环会卡死
-- 本插件全部钩子。成功则落凭据+接入源(走 complete_login)。
--
-- 关键:**必须把 session 上的 account_id 传给 complete_login**(真实缺陷):
-- 页面流程是「先 account/add 建占位账号(credentialRef=channels:<plat>:<aid>)
-- → login 带 account_id → 轮询」。若这里不带 account_id,complete_login 会走
-- 兼容旧路径,凭据落到 `channels:<plat>` 这个**与占位账号无关的键**,而账号池
-- 条目的 credentialRef 指向 `channels:<plat>:<aid>` —— 于是 account/list 报
-- has_credential=false、credits/balances 报「无凭据」,表现为「登录成功但无法
-- 获取账号信息」。对齐参考 BuddyAuth.login 的 `flowOptions.accountId` 语义。
-- 前向声明:这些实现都在本文件下方,而 poll_login / oauth_callback(更早的代码)
-- 就要调用它们。Lua 的 local 作用域看不到其后的定义,所以必须在**第一个调用点
-- 之前**占坑。此前它们是全局函数,靠「全局表在运行时才解析」侥幸工作;
-- 改成 local 后这个隐式依赖就断了 —— 表现为
-- attempt to call global 'traeCallback' (a nil value)。
local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId
local traeCheckinHeaders, traeFixNickname
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
-- 回调流(callback)的凭据推进:授权页在**服务端浏览器**里打开,平台把
-- 授权结果重定向回**本机回环地址**,由插件自己的 loopback 接收器(路径按各
-- 平台注册值:有道 /auth/callback、Trae /authorize、Gemini /oauth-callback)
-- 接住并写下 {code,state}。这里读它并完成交换。
--
-- 为什么要服务端浏览器:redirect_uri 是回环地址,由「打开授权页的那台机器」
-- 解析。远程用户在自己电脑上打开时,重定向落在**用户机器**上,永远到不了
-- 本网关。CodeBuddy 之所以一直能用,是因为它走 state-poll,根本不需要回调。
if s.flow == "callback" and s.state == "pending" and s.redirect_uri then
local cb, _ = readLoopbackCallback(s.id)
local gotState = cb and (cb.state or "")
local code = cb and (cb.code or cb.authCode or "")
-- Trae 的回调不带 state(token 直接放在 query 里),非 Trae 必须 state 匹配。
local stateOK = (s.platform == "trae") or (gotState ~= "" and gotState == s.nonce)
-- 只有「回调真到了且 state 对」才推进;文件还没出现时保持 pending,
-- 绝不能把「用户还没操作完」当成失败。
if cb and stateOK then
local plat = platformById(s.platform)
local credential, account, cerr
if s.platform == "lobsterai" then
-- 有道:authCode → exchange(与 oauth_callback 路径共用同一助手,防漂移)
credential, account, cerr = lobsteraiExchange(plat, code, s)
elseif s.platform == "trae" then
-- Trae:回调把 token 直接放在 query 里(refreshToken/userJwt/userInfo),
-- 故把整个回调 query 交给 traeCallback(它在两条路径上共用)。
credential, account, cerr = traeCallback(plat, cb, s)
if not credential and code ~= "" then
-- 回调只给了 code(PKCE 形态)→ 用 exchange 端点的标准交换
local body = (plat.callback.exchange_body or "")
:gsub("{client_id}", plat.callback.client_id or "")
:gsub("{code}", code)
:gsub("{port}", tostring(gatewayPort()))
local resp, err = __llmsproxy_http({ url = plat.callback.exchange_url,
method = "POST", headers = { ["Content-Type"] = "application/json" }, body = body })
if err or not resp or resp.status ~= 200 then
cerr = "Trae 授权码交换失败: " .. tostring(err or (resp and resp.status))
else
local ok, rec = pcall(json.decode, resp.body or "")
local access = ok and type(rec) == "table"
and (rec.Token or rec.token or rec.AccessToken or rec.access_token)
if not access then cerr = "Trae 授权码交换响应缺少 Token"
else credential, account, cerr = json.encode({ access_token = access }), "trae", nil end
end
end
else
-- Gemini 等标准 OAuth:表单交换(必带 client_secret,见 token_body 注释)
local body = (plat.callback.token_body or "")
:gsub("{code}", code)
:gsub("{port}", tostring(gatewayPort()))
:gsub("{verifier}", s.device_verifier or "")
local resp, err = __llmsproxy_http({
url = plat.callback.token_url, method = "POST",
headers = { ["Content-Type"] = "application/x-www-form-urlencoded",
["Accept"] = "application/json" },
body = body,
})
if err or not resp or resp.status ~= 200 then
cerr = "token 交换失败: " .. tostring(err or (resp and resp.status))
if resp and resp.body then cerr = cerr .. "(" .. tostring(resp.body):sub(1, 160) .. ")" end
else
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or not rec.access_token then
cerr = "token 交换响应缺少 access_token"
else
credential, account = json.encode(rec), "oauth"
end
end
end
reapLoopback(s.id)
if cerr then
s.state = "failed"
s.message = cerr
else
local res = plugin.complete_login({ platform = s.platform, credential = credential,
account = account, account_id = s.account_id })
if res.json and (res.json.ok or res.json.account_id) then
s.state = "completed"
s.account = account
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
end
end
-- 条件不能要求 device_code:Qoder 形态是 PKCE 授权页,**没有 device_code**
-- (本地造 verifier/challenge,服务端按 nonce 找回待授权会话)。
-- 用「有 device_code 或有 device_verifier」表达"这是个可推进的设备码/授权页会话"。
if s.flow == "device-poll" and s.state == "pending"
and (s.device_code or s.device_verifier) then
local plat = platformById(s.platform)
local done, payload = deviceOneStep(plat, s)
if done and payload then
if payload.ok then
-- 设备码到 token 后,有的平台还要第二跳(Cline register)换最终凭据
local cred, acct, rerr = deviceRegister(plat, payload)
if rerr then
s.state = "failed"
s.message = rerr
else
-- 必须带 account_id:页面流程先 account/add 建了占位账号,
-- 不带就会落到与占位账号无关的 `channels:<plat>` 键(见 poll_login 顶部注释)。
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = acct or payload.account, account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct or payload.account
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
else
s.state = "failed"
s.message = payload.message
end
end
elseif s.flow == "qr-poll" and s.state == "pending" and s.qr_code then
-- 轮询 login_with_qrcode_code:status ∈ pending/logging/canceled/success。
-- 参考:**任何异常都降级为 pending**(网络抖动/响应畸形/未知 status),
-- success 但缺 token 也视为未完成(否则产出空凭据并卡死)。
local plat = platformById(s.platform)
local q = plat and plat.qr
if q then
local resp, err = __llmsproxy_http({
url = q.api_base .. q.auth_prefix .. q.poll_path,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
body = json.encode({ qrcode_code = s.qr_code }),
})
if not err and resp then
local ok, rec = pcall(json.decode, resp.body or "")
local env = ok and type(rec) == "table" and rec or nil
if env and env.code == 0 and type(env.data) == "table" then
local st = env.data.status
if st == "canceled" then
s.state = "failed"
s.message = "用户取消了扫码授权"
elseif st == "success" then
local at = env.data.access_token
if type(at) ~= "string" or at == "" then
-- 缺 token 的 success 视为未完成(不产出空凭据)
s.wechat_hint = "扫码成功,等待凭据下发"
else
local cred = json.encode({
access_token = at,
refresh_token = env.data.refresh_token,
office_identity = env.data.office_identity,
token_type = "Bearer",
account_type = "personal",
})
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = "raccoon", account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = "raccoon"
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
elseif st == "logging" then
s.wechat_hint = "已扫码,正在登录"
end
-- pending 与未知值:继续等
end
end
end
elseif s.flow == "wechat-poll" and s.state == "pending" and s.wechat_uuid then
-- 长轮询一步:判据照参考(**404 与 405 不可读反**)。
local plat = platformById(s.platform)
local w = plat and plat.wechat
if w then
local last = s.wechat_last or ""
local url = w.poll_url .. "?uuid=" .. s.wechat_uuid
.. (last ~= "" and ("&last=" .. last) or "")
.. "&_=" .. tostring(os.time and os.time() or 0)
local resp, err = __llmsproxy_http({
url = url, method = "GET",
headers = { ["Accept"] = "*/*", ["User-Agent"] = w.user_agent,
["Referer"] = w.auth_url:sub(1, (w.auth_url:find("?")) and w.auth_url:find("?") - 1 or #w.auth_url) },
})
if not err and resp then
local body = resp.body or ""
local errcode = body:match("wx_errcode%s*=%s*(%d+)") or ""
local code = body:match("wx_code%s*=%s*'([^']*)'") or ""
s.wechat_last = errcode
if errcode == "405" then
-- **已确认**(wx_code 在此帧)。405 却没带 code 是异常形态,
-- 保守判"已扫码"继续轮询,**绝不**拿空 code 去换凭据。
if code ~= "" then
local cred, cerr = wechatExchange(plat, code)
if cerr then
s.state = "failed"
s.message = cerr
else
local acct = "wechat"
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = acct, account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
end
elseif errcode == "404" then
-- 已扫码待确认:继续轮询(**不是**完成 —— 早期读反导致卡住)
s.wechat_hint = "已扫码,请在手机上确认"
elseif errcode == "403" then
s.state = "failed"
s.message = "用户取消了授权"
elseif errcode == "402" then
s.state = "failed"
s.message = "二维码已失效,请重新发起登录"
end
-- 408 与未知值一律继续等(保守:绝不误判成功)
end
end
elseif s.flow == "init-poll" and s.state == "pending" and s.init_flow_id then
-- 轮询 poll/{flow_id}:判据照参考 —— 4xx(除 408/429)才是终态失败,
-- 5xx 与网络错误继续重试;code!=0 或 data 缺失 → pending。
local plat = platformById(s.platform)
local ip = plat and plat.initpoll
if ip then
local url = ip.poll_url:gsub("{flow_id}", s.init_flow_id)
local resp, err = __llmsproxy_http({
url = url, method = "GET",
headers = {
["Accept"] = "application/json",
["Authorization"] = "Bearer " .. (s.init_secret or ""),
["User-Agent"] = "ZCode/" .. (ip.app_version or "3.14.3"),
["HTTP-Referer"] = ip.origin or "",
["X-ZCode-App-Version"] = ip.app_version or "3.14.3",
},
})
if not err and resp then
local st = resp.status
local terminal = st >= 400 and st < 500 and st ~= 408 and st ~= 429
if terminal then
s.state = "failed"
s.message = "轮询被拒(HTTP " .. tostring(st) .. ")"
else
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" and rec.code == 0 and type(rec.data) == "table" then
local d = rec.data
local status = d.status
if status == "failed" then
s.state = "failed"
s.message = "用户拒绝了授权或授权失败"
elseif status == "ready" then
-- token(zcode JWT)与 user.user_id 必需;第三方 token 取
-- bigmodel/zai 二者之一(参考按 provider 取对应分支)。
local jwt = d.token
local user = d.user or {}
local pick = function(blk)
if type(blk) ~= "table" then return nil, nil end
return blk.access_token or blk.accessToken, blk.refresh_token or blk.refreshToken
end
local bmAt, bmRt = pick(d.bigmodel)
local zaiAt, zaiRt = pick(d.zai)
local thirdAt = bmAt or zaiAt
if type(jwt) ~= "string" or jwt == "" then
s.state = "failed"
s.message = "授权响应缺少 zcode token"
else
local cred = json.encode({
access_token = jwt,
zcode_jwt = jwt,
-- 第三方 token 仅作备用身份,不参与推理(参考明确)
bigmodel_access_token = bmAt,
zai_access_token = zaiAt,
refresh_token = bmRt or zaiRt,
user_id = user.user_id or user.id,
nickname = user.name or user.email,
token_type = "Bearer",
account_type = "personal",
})
local acct = user.name or user.email or user.user_id or "zcode"
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = acct, account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
end
-- status == "pending" 或其它未知值:继续等(不误报)
end
end
end
end
elseif s.flow == "state-poll" and s.state == "pending" and s.state_token then
-- Buddy(CodeBuddy):state 轮询。状态里的 state_token 是 auth/state 下发的。
local plat = platformById(s.platform)
local sp = plat and plat.state
local resp, err = __llmsproxy_http({
url = (sp.token_url or "") .. "?state=" .. tostring(s.state_token),
method = "GET",
headers = { ["Accept"] = "application/json", ["X-No-Authorization"] = "true" },
})
-- 网络错误:继续轮询不中断(对齐参考 loopGetToken 的 catch 继续)
if not err and resp then
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" then
local code = rec.code
local data = rec.data
if resp.status == 200 and data ~= nil and type(data) == "table" and (data.accessToken or data.access_token) then
-- token 就绪。再拿账号(失败不阻塞,回退默认名)。
local access = data.accessToken or data.access_token
local accountData = nil
local aresp, aerr = __llmsproxy_http({
url = (sp.account_url or "") .. "?state=" .. tostring(s.state_token),
method = "GET",
headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. access },
})
if not aerr and aresp and aresp.status == 200 then
local aok, arec = pcall(json.decode, aresp.body or "")
if aok and type(arec) == "table" and type(arec.data) == "table" then
accountData = arec.data
end
end
-- ① 组合完整凭据 JSON(对齐参考 buildCredential):带 refresh_token /
-- expires_at / domain / user_id / nickname,而非只存裸 access_token。
-- 只存裸 token 会丢掉 refresh_token,导致 refreshable=false、无法静默
-- 续期,且 expires_at 未知。
local credTable = buildCredential(data, accountData)
local credJson = json.encode(credTable)
local acct = credTable.nickname
if acct == nil or acct == "" then
acct = credTable.user_id
end
if acct == nil or acct == "" then acct = "codebuddy" end
-- ② 必须带 account_id(页面流程的占位账号);否则凭据落到无关的
-- `channels:<plat>` 键,账号池查不到。
local res = plugin.complete_login({ platform = s.platform, credential = credJson,
account = acct, account_id = s.account_id,
expires_at = credTable.expires_at, refreshable = credentialIsRefreshable(credTable) })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
elseif code == sp.code_not_ready then
-- 11217 = 未就绪,继续轮询(对齐参考:业务码 11217 继续等)
elseif resp.status == 200 and data == nil then
-- data 为 null,继续轮询(参考明确此分支)
else
s.state = "failed"
s.message = "auth/token HTTP " .. tostring(resp.status) .. " code=" .. tostring(code)
end
end
end
end
return { status = 200, json = { state = s.state, account = s.account, message = s.message, interval = s.device_interval } }
end
-- plugin.complete_login(args) 由页面/回调调用:登录成功,存凭据并接入源。
--
-- 凭据只进保险库(0600、跨插件不可读),不进 state:state 会被
-- PUT /api/plugins/channels/state 整体替换,也会出现在 WebUI 里。
function plugin.complete_login(req)
local args = bodyOf(req)
if args.platform == nil then
-- 内部调用会直接传表
args = req
end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local token = args and args.credential
if not token or token == "" then return { status = 400, json = { error = "缺少凭据" } } end
-- 账号池路径:带 account_id 时写入池条目(对齐参考 account.create 的
-- 占位账号语义 —— 先有账号行,凭据随后填入)。
local aid = args.account_id
if aid and aid ~= "" then
local acct = poolGet(plat.id, aid)
if not acct then return { status = 400, json = { error = "账号不存在: " .. tostring(aid) } } end
local ref = acct.credentialRef
local _, serr = __llmsproxy_secrets.set(ref, token)
if serr then return { status = 400, json = { error = serr } } end
-- 从凭据自动补齐展示/续期字段(调用方漏传也不丢)。
-- 对齐参考 saveCredential:昵称取 credential.nickname,
-- expiresAt 由 credentialExpiresAtMs 推断(expires_at 或 JWT exp),
-- refreshable 看是否携带 refresh_token。
local credTbl = nil
if type(token) == "string" and token:sub(1, 1) == "{" then
local dok, drec = pcall(json.decode, token)
if dok and type(drec) == "table" then credTbl = drec end
end
local derivedNick = credTbl and credTbl.nickname
if derivedNick == nil or derivedNick == "" then derivedNick = credTbl and credTbl.user_id end
local derivedExp = credTbl and credentialExpiresAtMs(credTbl)
acct.nickname = args.account or derivedNick or acct.nickname or aid
acct.expiresAt = args.expires_at or (derivedExp and tostring(derivedExp)) or acct.expiresAt
if args.refreshable ~= nil then
acct.refreshable = args.refreshable and true or false
elseif credTbl ~= nil then
acct.refreshable = credentialIsRefreshable(credTbl)
else
acct.refreshable = acct.refreshable or false
end
poolAdd(plat.id, acct)
-- 终结该账号的未决会话
for id, sess in pairs(sessions()) do
if sess.platform == plat.id and sess.state == "pending"
and (sess.account_id == aid or sess.pool_account_id == aid) then
sess.state = "completed"
end
end
logEvent("账号凭据已入池 " .. plat.id .. "/" .. aid)
syncAccountSource(plat.id, aid)
return { status = 200, json = { ok = true, account_id = aid, source = accountSourceName(plat.id, aid) } }
end
-- 兼容旧路径:单账号模式(e2e 与既有端点仍走它)。
local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token)
if err then return { status = 400, json = { error = err } } end
-- 上游源只吃 access token:凭据可能是完整 JSON(buddy 等 state-poll 平台),
-- 必须经 credentialOf 取出 access_token,不能把整个 JSON 当 api_key 发上去。
local legacyTok = credentialOf("channels:" .. plat.id) or token
plugin.state.accounts[plat.id] = {
account = args.account or "已登录",
added_at = os.time and os.time() or 0,
enabled = true,
}
-- 终结该平台未决会话
for id, sess in pairs(sessions()) do
if sess.platform == plat.id and sess.state == "pending" then sess.state = "completed" end
end
logEvent("已登录 " .. plat.name)
-- 接入为上游源 —— 这是本插件存在的意义
local r, aerr = __llmsproxy_kernel.call("add_source", {
source = {
name = "channels-" .. plat.id,
base_url = plat.base_url,
adapter = "openai",
api_key = legacyTok,
max_concurrent = 2,
models = (function()
local ms = {}
local eff = effectiveModels(plat.id)
for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end
return ms
end)(),
},
})
if aerr then
-- 凭据已存下,但接入源失败。如实分开报告:把两者混成一个 ok=false 会让
-- 调用方以为登录失败从而重复登录。
logEvent("接入源失败 " .. plat.id .. ": " .. aerr)
return { status = 200, json = { ok = true, source_error = aerr } }
end
logEvent("已接入源 " .. r.name)
return { status = 200, json = { ok = true, source = r.name } }
end
-- ============================================================================
-- 额度领取
-- ============================================================================
-- plugin.claim_daily(platform_id) 领每日额度。设计为**可被定时任务调用**。
--
-- 只返回结果,不改 state 之外的东西:真正的额度体现在平台的响应里
-- (已用/剩余),本插件记录以便面板显示。
function plugin.claim_daily(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
if not plat.checkin or not plat.checkin.url then
return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } }
end
-- 账号池路径:claim 某个池账号;否则旧路径单凭据。
-- credArgs 要一并交给 claim_fn:Trae 的签到头要用凭据里的
-- uid/access_token 派生设备身份(仅凭裸 token 拿不到这些字段)。
local tok, acctEntry, credArgs
if args.account_id and args.account_id ~= "" then
acctEntry = poolGet(plat.id, args.account_id)
if not acctEntry then return { status = 400, json = { error = "账号不存在" } } end
local _, _, rec = credentialOf(acctEntry.credentialRef)
tok = credentialOf(acctEntry.credentialRef)
credArgs = { account_id = args.account_id, credential = rec }
else
local _, _, rec = credentialOf("channels:" .. plat.id)
tok = credentialOf("channels:" .. plat.id)
credArgs = { credential = rec }
end
if not tok then return { status = 400, json = { error = "未登录" } } end
-- 平台可自定义领取请求形状;默认 Bearer + POST {}。
-- 参考实现的特殊形状(SDK-HMAC-SHA256 签名等)由该平台的 claim 钩子生成
-- (见 PLATFORMS 的 claim_fn),这里是通用路径。
local spec = {
url = plat.checkin.url,
method = plat.checkin.method or "POST",
headers = plat.checkin.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
body = plat.checkin.body or "{}",
}
if plat.claim_fn then
local custom, cerr = plat.claim_fn(plat, tok, credArgs)
if cerr then return { status = 400, json = { error = cerr } } end
if custom then spec = custom end
end
local resp, err = __llmsproxy_http(spec)
if err then
logEvent("领取失败 " .. plat.id .. ": " .. err)
return { status = 400, json = { error = err } }
end
local acct
if acctEntry then
acct = acctEntry
else
acct = plugin.state.accounts[plat.id] or {}
end
acct.last_claim = os.time and os.time() or 0
acct.last_claim_status = resp.status
-- 平台可提供解析器把响应转成可读摘要(余额/获得额度)
if plat.claim_result_fn and resp.status == 200 then
local ok, summary = pcall(plat.claim_result_fn, resp.body)
if ok and summary then acct.last_claim_summary = summary end
end
if acctEntry then
poolAdd(plat.id, acctEntry)
else
plugin.state.accounts[plat.id] = acct
end
logEvent("领取返回 " .. tostring(resp.status))
return { status = 200, json = { ok = true, upstream_status = resp.status, body = resp.body, summary = acct.last_claim_summary } }
end
-- ============================================================================
-- 定时任务:自动领取
-- ============================================================================
--
-- 声明了 schedule 的插件会得到内核 ticker 驱动(无浏览器也执行),
-- 且任务跑在本插件自己的 VM 上 —— 所以任务里读 state/保险库与钩子看到的是同一份。
plugin.schedule = {
{ fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 },
}
function plugin.auto_claim()
-- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。
plugin.state.settings = plugin.state.settings or {}
if plugin.state.settings.auto_checkin == false then return end
for _, plat in ipairs(allPlatforms()) do
local cap = CHANNEL_CREDITS[plat.id]
if not cap or not cap.claim then goto continue end
local legacy = accountOf(plat.id)
if legacy and legacy.enabled then
plugin.claim_daily({ platform = plat.id })
end
for _, a in ipairs(poolList(plat.id)) do
if a.enabled ~= false and credentialOf(a.credentialRef) then
plugin.claim_daily({ platform = plat.id, account_id = a.id })
end
end
::continue::
end
end
-- ============================================================================
-- 对外 HTTP 端点(设置页调用)
-- ============================================================================
plugin.http = {
{ path = "channels/platforms", method = "GET", handler = "list_platforms",
roles = { "admin" }, description = "平台清单与账号状态" },
{ path = "channels/login", method = "POST", handler = "start_login",
roles = { "admin" }, description = "发起某平台的登录" },
{ path = "channels/login/status", method = "GET", handler = "poll_login",
roles = { "admin" }, description = "轮询登录会话状态" },
{ path = "channels/login/complete", method = "POST", handler = "complete_login",
roles = { "admin" }, description = "登录完成:存凭据并接入源" },
{ path = "channels/claim", method = "POST", handler = "claim_daily",
roles = { "admin" }, description = "手动领取某平台每日额度" },
{ path = "channels/status", method = "GET", handler = "status_page",
roles = { "admin" }, description = "接入状态与最近日志" },
-- OAuth 回调落点:平台重定向回来带 code。
{ path = "channels/callback", method = "GET", handler = "oauth_callback",
public = false, roles = { "admin" }, description = "OAuth 回调" },
-- 账号池 / 模型开关 / 供应商治理 / 备份(对齐参考 channel-pack RPC 面)
{ path = "channels/account/add", method = "POST", handler = "account_add",
roles = { "admin" }, description = "向平台添加账号(占位或带凭据)" },
{ path = "channels/account/list", method = "POST", handler = "account_list",
roles = { "admin" }, description = "列平台账号池" },
{ path = "channels/account/update", method = "POST", handler = "account_update",
roles = { "admin" }, description = "改账号昵称/启用态" },
{ path = "channels/account/delete", method = "POST", handler = "account_delete",
roles = { "admin" }, description = "删除账号(含凭据与上游源)" },
{ path = "channels/account/test", method = "POST", handler = "account_test",
roles = { "admin" }, description = "测账号可达" },
{ path = "channels/account/refresh", method = "POST", handler = "account_refresh",
roles = { "admin" }, description = "刷新账号(重同步源)" },
{ path = "channels/account/reorder", method = "POST", handler = "account_reorder",
roles = { "admin" }, description = "调账号优先级" },
{ path = "channels/model/list", method = "POST", handler = "model_list",
roles = { "admin" }, description = "列平台模型与开关态" },
{ path = "channels/model/refresh", method = "POST", handler = "model_refresh",
roles = { "admin" }, description = "从上游拉取模型目录(远端为主、兜底补位)" },
{ path = "channels/model/set_disabled", method = "POST", handler = "model_set_disabled",
roles = { "admin" }, description = "开关单个模型" },
{ path = "channels/model/set_all_disabled", method = "POST", handler = "model_set_all_disabled",
roles = { "admin" }, description = "全开/全关平台模型" },
{ path = "channels/provider/status", method = "POST", handler = "provider_status",
roles = { "admin" }, description = "全平台状态(账号数/模型数/关闭态)" },
{ path = "channels/provider/set_enabled", method = "POST", handler = "provider_set_enabled",
roles = { "admin" }, description = "供应商整体开关" },
{ path = "channels/credits/claim_all", method = "POST", handler = "credits_claim_all",
roles = { "admin" }, description = "一键领取全平台每日额度" },
{ path = "channels/backup/export", method = "POST", handler = "backup_export",
roles = { "admin" }, description = "导出账号备份(含凭据)" },
{ path = "channels/backup/import", method = "POST", handler = "backup_import",
roles = { "admin" }, description = "导入账号备份" },
{ path = "channels/auto_checkin", method = "POST", handler = "auto_checkin",
roles = { "admin" }, description = "自动签到开关读写" },
{ path = "channels/credits/status", method = "POST", handler = "credits_status",
roles = { "admin" }, description = "逐账号签到状态" },
{ path = "channels/credits/balances", method = "POST", handler = "credits_balances",
roles = { "admin" }, description = "逐账号余额" },
{ path = "channels/credits/permanent_lock", method = "POST", handler = "credits_permanent_lock",
roles = { "admin" }, description = "永久积分锁定" },
{ path = "channels/account/reset", method = "POST", handler = "account_reset",
roles = { "admin" }, description = "清账号限流标记" },
{ path = "channels/account/retest", method = "POST", handler = "account_retest",
roles = { "admin" }, description = "重测账号" },
{ path = "channels/model/set_disabled_many", method = "POST", handler = "model_set_disabled_many",
roles = { "admin" }, description = "批量开关模型" },
{ path = "channels/model/clear_dead", method = "POST", handler = "model_clear_dead",
roles = { "admin" }, description = "清死模型" },
{ path = "channels/provider/get_order", method = "POST", handler = "provider_get_order",
roles = { "admin" }, description = "供应商显示顺序" },
{ path = "channels/provider/set_order", method = "POST", handler = "provider_set_order",
roles = { "admin" }, description = "设置供应商显示顺序" },
{ path = "channels/gateway/get_enabled", method = "POST", handler = "gateway_get_enabled",
roles = { "admin" }, description = "网关开关状态" },
{ path = "channels/gateway/set_enabled", method = "POST", handler = "gateway_set_enabled",
roles = { "admin" }, description = "网关整体开关" },
{ path = "channels/backup/status", method = "POST", handler = "backup_status",
roles = { "admin" }, description = "备份健康度" },
{ path = "channels/usage/token_ledger", method = "POST", handler = "usage_token_ledger",
roles = { "admin" }, description = "token 用量快照" },
{ path = "channels/usage/badge", method = "POST", handler = "usage_badge",
roles = { "admin" }, description = "渠道徽标" },
{ path = "channels/usage/badge_preference", method = "POST", handler = "usage_badge_preference",
roles = { "admin" }, description = "徽标显示偏好" },
{ path = "channels/onboarding/status", method = "POST", handler = "onboarding_status",
roles = { "admin" }, description = "新人任务状态" },
{ path = "channels/onboarding/claim", method = "POST", handler = "onboarding_claim",
roles = { "admin" }, description = "领取新人奖励" },
{ path = "channels/inject_platforms", method = "POST", handler = "inject_platforms",
roles = { "admin" }, description = "注入测试/扩展平台定义(运行时,不落盘)" },
-- 服务端浏览器栈(VNC 兜底)的运维面
{ path = "channels/deps/status", method = "GET", handler = "deps_status",
roles = { "admin" }, description = "浏览器栈依赖预检(缺什么、装哪个包)" },
{ path = "channels/browser/status", method = "GET", handler = "browser_status",
roles = { "admin" }, description = "服务端浏览器会话状态" },
{ path = "channels/browser/collect", method = "POST", handler = "collect_credentials",
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
roles = { "admin" }, description = "回收服务端浏览器栈" },
}
-- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、
-- base_url)。两个用途:
-- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求)
-- 2. 测试把端点指到假上游,行为测试才能不打真厂商
-- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段
-- 会破坏后续逻辑对形状的假设。
-- ============================================================================
-- 账号池 RPC(对齐参考 account.* 契约)
-- ============================================================================
-- account_add:向平台加一个账号(占位或带凭据)。
-- 对应参考 account.create;登录类的流程返回登录 URL,凭据在 login 完成后回填。
function plugin.account_add(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local aid = args.account_id or newToken():sub(1, 12)
if poolGet(plat.id, aid) then return { status = 400, json = { error = "账号已存在: " .. aid } } end
local ref = "channels:" .. plat.id .. ":" .. aid
if args.credential and args.credential ~= "" then
local _, serr = __llmsproxy_secrets.set(ref, args.credential)
if serr then return { status = 400, json = { error = serr } } end
end
local now = os.time and os.time() or 0
local entry = {
id = aid, provider = plat.id,
nickname = args.nickname or aid,
enabled = args.enabled ~= false,
credentialRef = ref,
refreshable = args.refreshable or false,
createdAt = now,
expiresAt = args.expires_at,
last_claim = 0, last_claim_status = 0, last_claim_summary = "",
}
poolAdd(plat.id, entry)
logEvent("账号入池 " .. plat.id .. "/" .. aid)
-- 已带凭据的账号立即同步上游源;未带凭据的等 login 完成
if args.credential and args.credential ~= "" then syncAccountSource(plat.id, aid) end
return { status = 200, json = { ok = true, account_id = aid, credentialRef = ref } }
end
-- account_list:列平台的账号(对齐参考 account.list,含余额状态字段)
function plugin.account_list(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local out = {}
for _, a in ipairs(poolList(plat.id)) do
local has = credentialOf(a.credentialRef)
out[#out+1] = {
id = a.id, nickname = a.nickname, enabled = a.enabled ~= false,
credentialRef = a.credentialRef, refreshable = a.refreshable or false,
createdAt = a.createdAt, expiresAt = a.expiresAt,
has_credential = has ~= nil and has ~= "",
-- 签到/余额展示字段
last_claim = a.last_claim, last_claim_status = a.last_claim_status,
last_claim_summary = a.last_claim_summary,
}
end
return { status = 200, json = { ok = true, accounts = out } }
end
-- account_update:改昵称 / enabled(对齐 account.update)
function plugin.account_update(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
local patch = type(args.patch) == "table" and args.patch or {}
if patch.nickname ~= nil then acct.nickname = tostring(patch.nickname) end
if patch.enabled ~= nil then acct.enabled = patch.enabled and true or false end
poolAdd(plat.id, acct)
-- enabled 变化要同步上游源(禁用即删掉该源)
if patch.enabled ~= nil then
if acct.enabled then syncAccountSource(plat.id, acct.id)
else dropAccountSource(plat.id, acct.id) end
end
return { status = 200, json = { ok = true } }
end
-- account_delete:删账号 + 删凭据 + 删源(对齐 account.delete)
function plugin.account_delete(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
dropAccountSource(plat.id, acct.id)
__llmsproxy_secrets.delete(acct.credentialRef)
poolRemove(plat.id, acct.id)
logEvent("账号删除 " .. plat.id .. "/" .. acct.id)
return { status = 200, json = { ok = true } }
end
-- account_test:测账号(对齐 account.test)—— 做一次上游可达探测。
-- 简化:内核 probe_all 是全局的;这里用 list_sources 里该账号源存在与否 + 凭据非空作为可达判据。
function plugin.account_test(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
local tok = credentialOf(acct.credentialRef)
if not tok or tok == "" then return { status = 200, json = { ok = false, error = "无凭据" } } end
-- 同步一次源(确保最新凭据已入),返回同步结果
syncAccountSource(plat.id, acct.id)
local srcs, _ = __llmsproxy_kernel.call("list_sources", {})
local found = false
for _, s in ipairs(srcs or {}) do
if s.name == accountSourceName(plat.id, acct.id) then found = true break end
end
if not found then return { status = 200, json = { ok = false, error = "源未同步(上游可能不可达)" } } end
return { status = 200, json = { ok = true, ms = 1 } }
end
-- account_refresh:刷新账号(对齐 account.refresh)—— 重新同步源 + 重新查余额
function plugin.account_refresh(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
local tok = credentialOf(acct.credentialRef)
if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end
return { status = 200, json = { ok = true } }
end
-- account_reorder:调账号优先级(对齐 account.reorder)
function plugin.account_reorder(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local ordered = args and args.ordered_ids
if type(ordered) ~= "table" then return { status = 400, json = { error = "ordered_ids 必须是数组" } } end
for i, aid in ipairs(ordered) do
local acct = poolGet(plat.id, tostring(aid))
if acct then acct.priority = #ordered - i + 1 poolAdd(plat.id, acct) end
end
return { status = 200, json = { ok = true } }
end
-- ============================================================================
-- 模型开关 RPC(对齐参考 model.*)
-- ============================================================================
-- plugin.model_refresh(platform?):从上游拉模型目录。
--
-- 不传 platform 就刷新所有**声明了 models_remote** 的平台。失败不清缓存
-- (宁可留旧目录也别让平台变空),逐个返回结果供 UI 提示。
function plugin.model_refresh(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local targets = {}
if args and args.platform then
local plat = platformById(args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
targets = { plat }
else
for _, p in ipairs(allPlatforms()) do
if p.models_remote then targets[#targets+1] = p end
end
end
local results = {}
for _, plat in ipairs(targets) do
local ids, err = refreshRemoteModels(plat.id)
results[#results+1] = {
platform = plat.id,
ok = ids ~= nil,
count = ids and #ids or 0,
error = err,
}
-- 目录变了就重同步该平台所有源(模型清单写进了上游源)
if ids then
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
end
end
return { status = 200, json = { ok = true, results = results } }
end
function plugin.model_list(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local disabled = disabledModelsOf(plat.id)
local out = {}
for i, m in ipairs(effectiveModels(plat.id)) do
out[#out+1] = { id = m, name = m, disabled = disabled[m] and true or false, isFree = false }
end
local remote = plugin.state.remoteModels and plugin.state.remoteModels[plat.id]
return { status = 200, json = {
ok = true, models = out,
remote = remote ~= nil,
fetched_at = remote and remote.fetched_at or nil,
fallbackOnly = remote == nil,
} }
end
function plugin.model_set_disabled(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local mid = tostring(args and args.model_id or "")
if mid == "" then return { status = 400, json = { error = "缺 model_id" } } end
local want = args.disabled and true or false
setModelDisabled(plat.id, mid, want)
-- 重同步该平台的全部源,让模型开关立即生效
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true, disabled = want } }
end
function plugin.model_set_all_disabled(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local disabled = disabledModelsOf(plat.id)
if args.disabled then
for _, m in ipairs(effectiveModels(plat.id)) do disabled[m] = true end
else
for k in pairs(disabled) do disabled[k] = nil end
end
markDirty()
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true } }
end
-- ============================================================================
-- 供应商开关 + 状态(对齐参考 provider.*)
-- ============================================================================
function plugin.provider_status(req)
local out = {}
for _, p in ipairs(allPlatforms()) do
local accts = poolList(p.id)
local enabledN = 0
for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end
local disabled = disabledModelsOf(p.id)
local disabledN = 0
local eff = effectiveModels(p.id)
for _, m in ipairs(eff) do if disabled[m] then disabledN = disabledN + 1 end end
local closed = #eff > 0 and disabledN == #eff
local state_accounts = plugin.state.accounts[p.id]
local legacyN = (state_accounts and credentialOf("channels:" .. p.id)) and 1 or 0
out[#out+1] = {
id = p.id, name = p.name, flow = p.flow, closed = closed,
models = { total = #eff, disabled = disabledN },
accounts = { total = #accts + legacyN, enabled = enabledN + legacyN },
}
end
return { status = 200, json = { ok = true, statuses = out } }
end
function plugin.provider_set_enabled(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local enabled = args.enabled and true or false
-- 对齐参考语义:关闭 = 全关模型 + 停用全部账号;打开 = 清空模型黑名单 + 启用全部账号。
local disabled = disabledModelsOf(plat.id)
if enabled then
for k in pairs(disabled) do disabled[k] = nil end
markDirty()
for _, a in ipairs(poolList(plat.id)) do a.enabled = true poolAdd(plat.id, a) syncAccountSource(plat.id, a.id) end
-- 旧路径也启用
local legacy = plugin.state.accounts[plat.id]
local legacyTok = credentialOf("channels:" .. plat.id)
if legacy and legacyTok then
__llmsproxy_kernel.call("add_source", { source = {
name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai",
api_key = legacyTok, max_concurrent = 2,
models = (function() local ms = {} local eff = effectiveModels(plat.id)
for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(),
} })
end
else
for _, m in ipairs(effectiveModels(plat.id)) do disabled[m] = true end
for _, a in ipairs(poolList(plat.id)) do a.enabled = false poolAdd(plat.id, a) dropAccountSource(plat.id, a.id) end
__llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id })
end
logEvent("供应商" .. (enabled and "启用 " or "关闭 ") .. plat.id)
return { status = 200, json = { ok = true } }
end
-- ============================================================================
-- 一键领取(对齐参考 credits.claimAll)
-- ============================================================================
function plugin.credits_claim_all(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local results = {}
local claimed = 0
for _, a in ipairs(poolList(plat.id)) do
local r = plugin.claim_daily({ platform = plat.id, account_id = a.id, credential_ref = a.credentialRef })
local ok = r.json and r.json.ok and true or false
if ok then claimed = claimed + 1 end
results[#results+1] = { accountId = a.id, outcome = ok
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
end
-- 无池账号的走旧路径
if #poolList(plat.id) == 0 then
local r = plugin.claim_daily({ platform = plat.id })
local ok = r.json and r.json.ok and true or false
if ok then claimed = claimed + 1 end
results[#results+1] = { accountId = "legacy", outcome = ok
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
end
return { status = 200, json = { ok = true, summary = { claimed = claimed }, results = results } }
end
-- ============================================================================
-- 备份导出/导入(对齐参考 backup.*)
-- ============================================================================
function plugin.backup_export(req)
local payload = {}
payload.version = 1
payload.exportedAt = os.time and os.time() or 0
payload.accounts = {}
payload.credentials = {}
payload.modelDisabled = plugin.state.modelDisabled or {}
payload.autoCheckin = plugin.state.settings and plugin.state.settings.auto_checkin or true
for _, p in ipairs(allPlatforms()) do
for _, a in ipairs(poolList(p.id)) do
payload.accounts[#payload.accounts+1] = a
local tok = credentialOf(a.credentialRef)
if tok then payload.credentials[a.credentialRef] = tok end
end
-- 旧路径单账号也备份
local lt = credentialOf("channels:" .. p.id)
if lt then payload.credentials["channels:" .. p.id] = lt end
end
return { status = 200, json = { ok = true, payload = payload } }
end
function plugin.backup_import(req)
local args = bodyOf(req)
local payload = args and args.payload
if type(payload) ~= "table" then return { status = 400, json = { error = "payload 必须是对象" } } end
local n = 0
-- 恢复凭据
if type(payload.credentials) == "table" then
for ref, tok in pairs(payload.credentials) do
if type(ref) == "string" and type(tok) == "string" and tok ~= "" then
__llmsproxy_secrets.set(ref, tok) n = n + 1
end
end
end
-- 恢复账号池
if type(payload.accounts) == "table" then
for _, a in ipairs(payload.accounts) do
if type(a) == "table" and a.id and a.provider then
poolAdd(tostring(a.provider), {
id = tostring(a.id), provider = tostring(a.provider),
nickname = a.nickname or a.id, enabled = a.enabled ~= false,
credentialRef = a.credentialRef or ("channels:" .. a.provider .. ":" .. a.id),
refreshable = a.refreshable or false, createdAt = a.createdAt or (os.time and os.time() or 0),
expiresAt = a.expiresAt, last_claim = 0, last_claim_status = 0, last_claim_summary = "",
})
end
end
end
-- 恢复模型黑名单
if type(payload.modelDisabled) == "table" then
plugin.state.modelDisabled = payload.modelDisabled
markDirty()
end
-- 恢复自动签到开关
if payload.autoCheckin ~= nil then
plugin.state.settings = plugin.state.settings or {}
plugin.state.settings.auto_checkin = payload.autoCheckin and true or false
end
-- 全部账号重新同步源
for _, p in ipairs(allPlatforms()) do
for _, a in ipairs(poolList(p.id)) do syncAccountSource(p.id, a.id) end
end
logEvent("备份已导入,恢复 " .. n .. " 条凭据")
return { status = 200, json = { ok = true, restored = n } }
end
-- auto_checkin 开关读写
function plugin.auto_checkin(req)
plugin.state.settings = plugin.state.settings or {}
local args = bodyOf(req)
if args.enabled ~= nil then
plugin.state.settings.auto_checkin = args.enabled and true or false
markDirty()
logEvent("自动签到" .. (plugin.state.settings.auto_checkin and "开启" or "关闭"))
end
return { status = 200, json = { ok = true, autoCheckin = plugin.state.settings.auto_checkin ~= false } }
end
-- ============================================================================
-- 签到状态 + 逐账号余额(对齐参考 credits.status / credits.balances)
-- ============================================================================
-- fetchAccountJSON 用某账号凭据发一次请求,返回 (rec_table, nil) 或 (nil, err)。
local function fetchAccountJSON(spec, tok, credTable, plat)
local headers
-- 头钩子位:spec(checkin_status / balance)自己的优先,否则用平台级的。
-- buddy 在平台层声明一次,签到与余额复用同一套身份头。
local hfn = spec.headers_fn or (plat and plat.headers_fn)
if hfn then
local okf, h = pcall(hfn, tok, credTable or {})
if okf and type(h) == "table" then headers = h end
end
if not headers then
headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }
end
local resp, err = __llmsproxy_http({
url = spec.url, method = spec.method or "GET",
headers = headers, body = spec.body,
})
if err then return nil, err end
if resp.status ~= 200 then return nil, "HTTP " .. tostring(resp.status) end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil, "响应无法解析" end
return rec, nil
end
-- credits_status:逐账号查签到状态(对齐参考 credits.status;
-- 无状态端点的平台如实返回 null,与参考对 CodeArts/LobsterAI 的处理一致)
function plugin.credits_status(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local out = {}
for _, a in ipairs(poolList(plat.id)) do
local status = nil
local cs = plat.checkin_status
if cs then
local tok = credentialOf(a.credentialRef)
if tok then
local rec, err = fetchAccountJSON(cs, tok, credentialFull(a.credentialRef), plat)
if rec and plat.checkin_status.status_fn then
local okf, st = pcall(plat.checkin_status.status_fn, rec)
if okf then status = st end
elseif err then
status = { error = err }
end
end
end
out[#out+1] = { accountId = a.id, nickname = a.nickname, status = status }
end
return { status = 200, json = { ok = true, accounts = out } }
end
-- credits_balances:逐账号查余额(对齐参考 credits.balances;
-- 无 balance 端点的平台返回 {balance=null, error="平台未声明余额端点"},
-- 不编造数字 —— 与参考注释「不伪装成余额」同语义)
function plugin.credits_balances(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local cap = CHANNEL_CREDITS[plat.id] or {}
local out = {}
for _, a in ipairs(poolList(plat.id)) do
local entry = { accountId = a.id, balance = nil, error = nil }
local b = plat.balance
if not b then
entry.error = "平台未声明余额端点"
else
local tok = credentialOf(a.credentialRef)
if not tok then
entry.error = "无凭据"
else
local rec, err = fetchAccountJSON(b, tok, credentialFull(a.credentialRef), plat)
if err then
entry.error = err
elseif b.total_fn then
local okf, total = pcall(b.total_fn, rec)
if okf and type(total) == "number" then
entry.balance = { total = total, unit = cap.unit or "credits" }
else
entry.error = "余额解析失败"
end
end
end
end
out[#out+1] = entry
end
return { status = 200, json = { ok = true, accounts = out } }
end
-- 永久积分锁定(对齐参考 credits.permanentLock:锁定后仅使用 windowDays 内到期的积分)
function plugin.credits_permanent_lock(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
plugin.state.settings = plugin.state.settings or {}
if args.locked ~= nil then
if type(args.locked) ~= "boolean" then
return { status = 400, json = { error = "locked 必须是布尔值" } }
end
plugin.state.settings["lock_" .. plat.id] = args.locked
markDirty()
logEvent("永久积分" .. (args.locked and "锁定 " or "解除 ") .. plat.id)
end
return { status = 200, json = { ok = true, locked = plugin.state.settings["lock_" .. plat.id] == true,
windowDays = 3 } }
end
-- account.reset / resetAll:清除账号限流标记(对齐参考 account.reset)
function plugin.account_reset(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
if args.account_id == "__all__" then
for _, a in ipairs(poolList(plat.id)) do a.rateLimited = nil poolAdd(plat.id, a) end
return { status = 200, json = { ok = true, reset = #poolList(plat.id) } }
end
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
acct.rateLimited = nil
poolAdd(plat.id, acct)
return { status = 200, json = { ok = true } }
end
-- account.retest / retestAll:重测账号(对齐参考 account.retest:触发条件不同,实现同 test)
function plugin.account_retest(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
if args.account_id == "__all__" then
local results = {}
for _, a in ipairs(poolList(plat.id)) do
local r = plugin.account_test({ platform = plat.id, account_id = a.id })
results[#results+1] = { accountId = a.id, ok = r.json and r.json.ok and true or false,
error = r.json and r.json.error or nil }
end
return { status = 200, json = { ok = true, results = results } }
end
return plugin.account_test({ platform = plat.id, account_id = args.account_id })
end
-- model.setDisabledMany:批量开关(对齐参考 model.setDisabledMany)
function plugin.model_set_disabled_many(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local ids = args and args.model_ids
if type(ids) ~= "table" then return { status = 400, json = { error = "model_ids 必须是数组" } } end
local disabled = disabledModelsOf(plat.id)
for _, mid in ipairs(ids) do
if args.disabled then disabled[tostring(mid)] = true else disabled[tostring(mid)] = nil end
end
markDirty()
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true } }
end
-- model.clearDead:清死模型(对齐参考 model.clearDead:从黑名单移除已下线的 id)
function plugin.model_clear_dead(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local disabled = disabledModelsOf(plat.id)
local removed = {}
for mid in pairs(disabled) do
local alive = false
for _, m in ipairs(effectiveModels(plat.id)) do if m == mid then alive = true break end end
if not alive then disabled[mid] = nil removed[#removed+1] = mid end
end
markDirty()
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true, removed = removed } }
end
-- 供应商排序(对齐参考 provider.getOrder/setOrder)
function plugin.provider_get_order(req)
local order = plugin.state.providerOrder
if type(order) ~= "table" then order = {} end
return { status = 200, json = { ok = true, order = order } }
end
function plugin.provider_set_order(req)
local args = bodyOf(req)
local order = args and args.order
if type(order) ~= "table" then return { status = 400, json = { error = "order 必须是字符串数组" } } end
local cleaned = {}
for _, id in ipairs(order) do
if platformById(tostring(id)) then cleaned[#cleaned+1] = tostring(id) end
end
plugin.state.providerOrder = cleaned
markDirty()
return { status = 200, json = { ok = true } }
end
-- 网关开关(对齐参考 gateway.getEnabled/setEnabled:本插件的一个上游源面开关)
-- 这里网关开关语义=channels 提供的源是否参与调度;用 set_plugin_enabled 内核动作
-- 不合适(那是插件级),我们用「全部模型黑名单+全部账号停用」已由 provider_set_enabled
-- 承担,这里改为读取当前接入状态做查询、写入复用 provider_set_enabled。
function plugin.gateway_get_enabled(req)
local any = false
for _, p in ipairs(allPlatforms()) do
local disabled = disabledModelsOf(p.id)
local total = #effectiveModels(p.id)
local allOff = total > 0
for _, m in ipairs(effectiveModels(p.id)) do if not disabled[m] then allOff = false break end end
if not allOff then any = true break end
end
return { status = 200, json = { ok = true, enabled = any,
apiKey = nil, address = nil, models = { source = "channels" } } }
end
function plugin.gateway_set_enabled(req)
local args = bodyOf(req)
if type(args.enabled) ~= "boolean" then
return { status = 400, json = { error = "非空布尔 enabled 必填" } }
end
-- 全平台整体开关(复用 provider_set_enabled 语义)
for _, p in ipairs(allPlatforms()) do
plugin.provider_set_enabled({ platform = p.id, enabled = args.enabled })
end
logEvent("网关整体" .. (args.enabled and "开启" or "关闭"))
return { status = 200, json = { ok = true } }
end
-- backup.status:备份健康度(对齐参考 backup.status:账号数/无过期时间账号数)
function plugin.backup_status(req)
local total, noExpiry = 0, 0
for _, p in ipairs(allPlatforms()) do
for _, a in ipairs(poolList(p.id)) do
total = total + 1
if a.expiresAt == nil then noExpiry = noExpiry + 1 end
end
end
return { status = 200, json = { ok = true, accounts = total, withoutExpiry = noExpiry } }
end
-- usage.tokenLedger:本网关的 token 用量快照(对齐参考 usage.tokenLedger)。
-- 数据源:网关 /api/stats(插件走回环取,与 usage 插件同思路)。
function plugin.usage_token_ledger(req)
local args = bodyOf(req)
local limit = tonumber(args and args.limit) or 50
local key = (req and req.key) or ""
local url = "http://127.0.0.1:8081/api/stats?period=all&limit=" .. tostring(limit)
if key ~= "" then
url = url .. "&key=" .. (key:len() <= 6 and key or ("***" .. key:sub(-6)))
end
local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000 })
if err or not resp or resp.status ~= 200 then
return { status = 200, json = { ok = true, snapshot = { available = false,
error = err or (resp and tostring(resp.status) or "no response") } } }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return { status = 200, json = { ok = true, snapshot = { available = false, error = "stats 无法解析" } } }
end
-- 汇总 token 总量(兼容 stats 的不同形状)
local prompt, completion, requests = 0, 0, 0
local rows = rec.requests or rec.rows or rec.data or {}
if type(rows) == "table" then
for _, r in ipairs(rows) do
if type(r) == "table" then
prompt = prompt + (tonumber(r.prompt_tokens) or 0)
completion = completion + (tonumber(r.completion_tokens) or 0)
requests = requests + 1
end
end
end
return { status = 200, json = { ok = true, snapshot = { available = true,
tokens = { prompt = prompt, completion = completion, total = prompt + completion },
requests = requests, raw = rec } } }
end
-- usage.badge:渠道徽标(对齐参考 usage.badge;用 credits_balances 的汇总)
function plugin.usage_badge(req)
local args = bodyOf(req)
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "provider 不能为空" } } end
local j = plugin.credits_balances({ platform = plat.id })
local accounts = asArrLua(j.json and j.json.accounts)
local total, known = 0, false
for _, a in ipairs(accounts) do
if type(a.balance) == "table" and type(a.balance.total) == "number" then
total = total + a.balance.total
known = true
end
end
return { status = 200, json = { ok = true, badge = known
and { kind = "credits", total = total, unit = (CHANNEL_CREDITS[plat.id] or {}).unit or "credits" }
or { kind = "unavailable" } } }
end
-- usage.badgePreference:徽标显示偏好(读/写,全局一个;非法值拒绝不回落)
function plugin.usage_badge_preference(req)
plugin.state.settings = plugin.state.settings or {}
local args = bodyOf(req)
if args.preference ~= nil then
local valid = { auto = true, subscription = true, credits = true }
if not valid[args.preference] then
return { status = 400, json = { error = "preference 必须是 auto/subscription/credits 之一" } }
end
plugin.state.settings.badge_preference = args.preference
end
return { status = 200, json = { ok = true, preference = plugin.state.settings.badge_preference or "auto" } }
end
-- onboarding.status / claim:新人任务(对齐参考 onboarding.*;仅 raccoon 有实测端点)
function plugin.onboarding_status(req)
local args = bodyOf(req)
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local ob = plat.onboarding
if not ob then
return { status = 200, json = { ok = true, supported = false,
message = plat.name .. " 未声明新人任务端点(参考实现同样如实返回 null)" } }
end
-- 已领取判定:查 state
local claimed = plugin.state.settings and plugin.state.settings["onboard_" .. plat.id] == true
return { status = 200, json = { ok = true, supported = true,
tasks = { desktop_login_reward = claimed }, earned = claimed and ob.reward_points or 0,
total = ob.reward_points } }
end
function plugin.onboarding_claim(req)
local args = bodyOf(req)
local plat = platformById(args and args.platform)
if not plat or not plat.onboarding then
return { status = 400, json = { error = "该平台无新人任务" } }
end
-- raccoon 实测端点:POST {apiBase}/login/points/grant
local tok = credentialOf("channels:" .. plat.id)
if not tok then return { status = 400, json = { error = "未登录" } } end
local resp, err = __llmsproxy_http({
url = plat.base_url .. "/login/points/grant", method = "POST",
headers = { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
body = "{}",
})
if err then return { status = 400, json = { error = err } } end
if resp.status ~= 200 then return { status = 400, json = { error = "HTTP " .. tostring(resp.status) } } end
plugin.state.settings = plugin.state.settings or {}
plugin.state.settings["onboard_" .. plat.id] = true
logEvent("新人奖励已领 " .. plat.id)
return { status = 200, json = { ok = true, points = plat.onboarding.reward_points } }
end
-- asArrLua(Lua 侧的空表规整):桥接层 Go→Lua 不涉及,但 handler 内部把
-- credits_balances 的 json.accounts 再传给别的 handler 时要能处理空表。
function asArrLua(t)
if type(t) ~= "table" then return {} end
if #t > 0 then return t end
return {}
end
function plugin.set_endpoints(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local allowed = { base_url = true, login_url = true }
local changed = {}
for k in pairs(allowed) do
if args[k] then plat[k] = tostring(args[k]) changed[#changed+1] = k end
end
if type(args.callback) == "table" and plat.callback then
for _, k in ipairs({ "mode", "token_url" }) do
if args.callback[k] then
plat.callback[k] = tostring(args.callback[k])
changed[#changed+1] = "callback." .. k
end
end
end
-- client_version:有道 exchange/续期/签到的必填版本号来源。测试需把它指到假上游;
-- 同时清掉已缓存值(否则上一次测试的值会跨用例泄漏)。
if type(args.client_version) == "table" and plat.client_version then
for _, k in ipairs({ "url", "path", "fallback" }) do
if args.client_version[k] then
plat.client_version[k] = tostring(args.client_version[k])
changed[#changed+1] = "client_version." .. k
end
end
plugin.state.clientVersion = nil
end
if type(args.models_remote) == "table" and plat.models_remote then
-- 测试台/运维把远端目录端点重定向(键形如 endpoint0/endpoint1 对应该平台的
-- 第 N 个端点)。生产上不该改,但没有它就无法在无网环境下断言解析口径。
for i, ep in ipairs(plat.models_remote.endpoints or {}) do
local v = args.models_remote["endpoint" .. (i - 1)]
if v then
ep.url = tostring(v)
changed[#changed+1] = "models_remote.endpoint" .. (i - 1)
end
end
end
if type(args.qr) == "table" and plat.qr then
for _, k in ipairs({ "api_base", "auth_prefix", "poll_path", "login_page", "app_name" }) do
if args.qr[k] then
plat.qr[k] = tostring(args.qr[k])
changed[#changed+1] = "qr." .. k
end
end
end
if type(args.wechat) == "table" and plat.wechat then
for _, k in ipairs({ "auth_url", "qr_url", "poll_url", "account_base",
"bind_path", "user_agent", "access_key_id", "access_key_secret", "app_id" }) do
if args.wechat[k] then
plat.wechat[k] = tostring(args.wechat[k])
changed[#changed+1] = "wechat." .. k
end
end
end
if type(args.initpoll) == "table" and plat.initpoll then
for _, k in ipairs({ "init_url", "poll_url", "provider", "origin" }) do
if args.initpoll[k] then
plat.initpoll[k] = tostring(args.initpoll[k])
changed[#changed+1] = "initpoll." .. k
end
end
end
if type(args.device) == "table" and plat.device then
for _, k in ipairs({ "code_url", "token_url" }) do
if args.device[k] then plat.device[k] = tostring(args.device[k]) changed[#changed+1] = "device." .. k end
end
end
if type(args.register) == "table" and plat.register then
for _, k in ipairs({ "url" }) do
if args.register[k] then plat.register[k] = tostring(args.register[k]) changed[#changed+1] = "register." .. k end
end
end
if type(args.state) == "table" and plat.state then
for _, k in ipairs({ "state_url", "token_url", "account_url" }) do
if args.state[k] then plat.state[k] = tostring(args.state[k]) changed[#changed+1] = "state." .. k end
end
end
if type(args.checkin) == "table" and plat.checkin then
for _, k in ipairs({ "url" }) do
if args.checkin[k] then plat.checkin[k] = tostring(args.checkin[k]) changed[#changed+1] = "checkin." .. k end
end
end
-- checkin_status / balance:声明式端点覆盖。参考把各平台的计费/状态端点当
-- 产品配置,运维要能整体改指(自建代理、镜像、测试假上游)。此前只覆盖了
-- 旧的 plat.checkin,buddy 这类"声明 checkin_status/balance"的平台无从覆盖 ——
-- 自动化测试也就无法把它们的端点指到假上游。
if type(args.checkin_status) == "table" and plat.checkin_status then
for _, k in ipairs({ "url", "method", "body" }) do
if args.checkin_status[k] then
plat.checkin_status[k] = tostring(args.checkin_status[k])
changed[#changed+1] = "checkin_status." .. k
end
end
end
if type(args.balance) == "table" and plat.balance then
for _, k in ipairs({ "url", "method", "body" }) do
if args.balance[k] then
plat.balance[k] = tostring(args.balance[k])
changed[#changed+1] = "balance." .. k
end
end
end
if type(args.callback) == "table" and plat.callback then
for _, k in ipairs({ "exchange_url" }) do
if args.callback[k] then plat.callback[k] = tostring(args.callback[k]) changed[#changed+1] = "callback." .. k end
end
end
if #changed == 0 then return { status = 400, json = { error = "无可识别的端点字段" } } end
logEvent("端点已改写 " .. plat.id .. ": " .. table.concat(changed, ","))
return { status = 200, json = { ok = true, changed = changed } }
end
function plugin.list_platforms(req)
local out = {}
for _, p in ipairs(allPlatforms()) do
local acct = accountOf(p.id) or {}
local has = credentialOf("channels:" .. p.id)
out[#out + 1] = {
id = p.id, name = p.name, flow = p.flow,
logged_in = has ~= nil,
account = acct.account,
enabled = acct.enabled,
last_claim = acct.last_claim,
}
end
return { status = 200, json = { platforms = out } }
end
-- inject_platforms:**运行时**注入平台定义(测试夹具 / 运维扩展)。
--
-- 为何存在:测试夹具(打 example.com 的假平台、验证各 flow 的占位平台)
-- 绝不能写成生产源码 —— PLATFORMS 会被 UI/status/list_platforms 直接展示,
-- 夹具内联进去就是「生产 UI 里实际出现 4 个假平台」(真实踩过)。
-- 正确分层:生产目录只留真实厂商,夹具由测试运行时注入。
--
-- 语义:整表替换(与 set_endpoints 的按字段覆盖不同)—— 注入是声明式的
-- “这就是本轮我要用的目录”,避免测试之间残留状态。传空数组即清空。
-- 容量上限:注入属于运维/测试能力,不承担生产目录职责,限 32 条够用。
local INJECT_PLATFORM_LIMIT = 32
-- ============================================================================
-- 服务端浏览器栈的运维面(依赖预检 / 取凭据 / 停栈)
-- ============================================================================
-- deps_status:如实报告浏览器栈缺什么依赖。插件**不擅自**安装系统包(需要 root
-- 且会改动运维的机器);如实报告 + 给出安装命令,由运维或 deploy.sh 执行。
function plugin.deps_status(req)
local d = browserDeps()
local list = {}
for _, m in ipairs(d.missing) do
list[#list + 1] = { bin = m.bin, pkg = m.pkg, why = m.why }
end
return { status = 200, json = {
ok = d.ok,
missing = list,
present = d.present,
chromium = d.chromium,
novnc = d.novnc,
-- 安装入口:发行包自带脚本(识别 apt/dnf/yum/apk),插件**不执行**它
-- (装系统包需要 root,且 chromium 是安全面较大的组件,不该由插件代劳)。
install_hint = d.ok and "" or
"sudo bash /usr/share/llmsproxy/install-browser-stack.sh"
.. " 或沿源码部署时: sudo bash packaging/install-browser-stack.sh"
.. "(脚本会自动识别 apt/dnf/yum/apk;也可手工装 Xvfb x11vnc websockify noVNC chromium python3-websocket)",
} }
end
-- collect_credentials:从**运行中的**服务端浏览器取凭据(CDP)。
--
-- 为什么需要它:VNC 只把浏览器呈现给人,登录完成后必须把结果**取出来**才能
-- 变成上游源。取法分两种:
-- ① 平台声明了 collect_expr —— 在该页上求值该 JS(返回 token 字符串或对象)
-- ② 未声明 —— 回退到通用提取:localStorage / sessionStorage / cookie 全量
-- 查到什么存什么,由调用方/运维判断哪个是凭据(不猜语义,不编造)。
-- 取到的凭据经 complete_login 走正常入库路径(含 account_id → 池路径)。
function plugin.collect_credentials(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 b = browserStack()
if not b.session then return { status = 400, json = { error = "没有运行中的浏览器会话" } } end
local cdp = b.session.cdp
if not cdp then return { status = 400, json = { error = "浏览器会话未启用 CDP" } } end
local expr = (args and args.expr) or plat.collect_expr
local value, err
if expr then
value, err = cdpEval(cdp, expr)
if err then return { status = 400, json = { error = "取值失败: " .. err } } end
end
-- 通用提取:无论有无 collect_expr 都跑一遍,作为兜底与补充。
local generic, gerr = cdpEval(cdp, [[
(function(){
var out = {localStorage:{}, sessionStorage:{}, cookie:document.cookie, url:location.href};
try { for (var i=0;i<localStorage.length;i++){var k=localStorage.key(i);out.localStorage[k]=localStorage.getItem(k);} } catch(e){}
try { for (var i=0;i<sessionStorage.length;i++){var k=sessionStorage.key(i);out.sessionStorage[k]=sessionStorage.getItem(k);} } catch(e){}
return JSON.stringify(out);
})()
]])
local rec = {}
if value ~= nil then
if type(value) == "table" then
for k, v in pairs(value) do rec[k] = v end
else
rec.collected = value
end
end
if generic then
local ok, parsed = pcall(json.decode, generic)
if ok and type(parsed) == "table" then
rec.browser = parsed
-- 常见位置的启发式提取(找到就用,找不到不强求)
local ls = parsed.localStorage or {}
for _, k in ipairs({ "token", "access_token", "accessToken", "auth_token", "jwt", "id_token", "Authorization" }) do
if type(ls[k]) == "string" and ls[k] ~= "" and rec.token == nil then rec.token = ls[k] end
end
local ss = parsed.sessionStorage or {}
for _, k in ipairs({ "token", "access_token", "accessToken", "auth_token", "jwt", "id_token" }) do
if type(ss[k]) == "string" and ss[k] ~= "" and rec.token == nil then rec.token = ss[k] end
end
if rec.token == nil and type(parsed.cookie) == "string" then
rec.token = parsed.cookie:match("(access_token=[^;]+)")
or parsed.cookie:match("(token=[^;]+)")
end
end
elseif gerr then
rec.generic_error = gerr
end
if next(rec) == nil then
return { status = 200, json = { ok = false, error = "没有取到任何凭据(登录是否真的完成了?)" } }
end
-- 不自动入库:取到什么原样返回,由调用方确认后再 complete_login。
-- 自动写入会把"页面上恰好存在的某个 token"当成凭据,那是最糟的猜测。
return { status = 200, json = { ok = true, collected = rec } }
end
-- stop_browser:回收服务端浏览器栈(会话结束/用户放弃时)。
function plugin.stop_browser(req)
local had = browserStack().session ~= nil
reapBrowser()
if had then logEvent("服务端浏览器已回收") end
return { status = 200, json = { ok = true, stopped = had } }
end
-- browser_status:当前浏览器会话状态(给 UI 显示)。
function plugin.browser_status(req)
local b = browserStack()
local s = b.session
return { status = 200, json = { ok = true, running = s ~= nil, session = s } }
end
function plugin.inject_platforms(req)
local args = bodyOf(req)
if args.platforms == nil then args = req end
local list = args and (args.platforms or args.items)
if type(list) ~= "table" then
return { status = 400, json = { error = "platforms 必须是数组" } }
end
if #list > INJECT_PLATFORM_LIMIT then
return { status = 400, json = { error = "最多注入 " .. INJECT_PLATFORM_LIMIT .. " 个平台" } }
end
-- 校验:每条必须有 id,且不得与内建目录重名(注入不能劫持真实厂商)。
local builtin = {}
for _, p in ipairs(PLATFORMS) do builtin[p.id] = true end
local clean, seen = {}, {}
for _, p in ipairs(list) do
if type(p) ~= "table" or type(p.id) ~= "string" or p.id == "" then
return { status = 400, json = { error = "每个平台必须有 id" } }
end
if builtin[p.id] then
return { status = 400, json = { error = "不得覆盖内建平台: " .. p.id } }
end
if seen[p.id] then
return { status = 400, json = { error = "重复的 id: " .. p.id } }
end
seen[p.id] = true
clean[#clean + 1] = p
end
plugin.state.injected_platforms = clean
markDirty()
if #clean == 0 then
logEvent("已清空注入平台")
else
local ids = {}
for _, p in ipairs(clean) do ids[#ids + 1] = p.id end
logEvent("已注入平台 " .. #clean .. " 个: " .. table.concat(ids, ","))
end
return { status = 200, json = { ok = true, injected = #clean } }
end
function plugin.status_page(req)
local r = __llmsproxy_kernel.call("status", {})
return { status = 200, json = {
accounts = plugin.state.accounts,
log = plugin.state.log,
kernel_ready = r and r.ready or false,
} }
end
-- oauth_callback 处理平台重定向:校验 nonce,然后按平台的 callback 模式解析。
--
-- nonce 校验是必需的:没有它,别人构造一个回调就能把**自己的**凭据塞进
-- 这个网关的登录流程。
--
-- callback 模式(platform.callback.mode)决定如何从回调 query 解出凭据:
-- nil/"code" 标准 OAuth:直接把 code 当凭据交给 complete_login。
-- "trae" 字节 TRAE 的 token 直传形态,解析逐字对齐参考实现
-- parseTraeCallbackDetailed + exchangeTraeCallback(见 traeCallback)。
function plugin.oauth_callback(req)
local nonce = req.query and req.query.state
if not nonce or nonce == "" then
return { status = 400, body = "missing state" }
end
sweepSessions()
local s = nil
for _, sess in pairs(sessions()) do
if sess.nonce == nonce then s = sess break end
end
if not s then
return { status = 400, body = "unknown or expired login session" }
end
if s.flow ~= "callback" then
return { status = 400, body = "session is not a callback flow" }
end
if s.state ~= "pending" then
return { status = 409, body = "login session already consumed" }
end
local plat = platformById(s.platform)
local cb = plat and plat.callback or {}
local mode = cb.mode or "code"
local credential, account, cerr
if mode == "trae" then
credential, account, cerr = traeCallback(plat, req.query, s)
elseif mode == "code" and s.platform == "lobsterai" then
-- 与 poll_login 的 loopback 路径共用同一个交换助手,避免两条路漂移。
credential, account, cerr = lobsteraiExchange(plat, req.query.authCode or req.query.code, s)
elseif mode == "code" and cb.token_url then
-- 标准 OAuth code → token 交换(Gemini / LobsterAI)。凭据是交换回的 access_token。
local code = req.query.code
if not code or code == "" then
cerr = "missing code"
else
local body = (cb.token_body or "")
:gsub("{code}", code)
:gsub("{verifier}", s.device_verifier or "")
-- {now}/{uuid}:LobsterAI 的交换体要求这两个字段(照参考
-- exchangeLobsteraiAuthCode:latestKeyfrom 取当前时刻、uuid 来自会话)。
:gsub("{now}", tostring(os.time and os.time() * 1000 or 0))
:gsub("{uuid}", s.nonce or "")
-- {port}:Gemini 的 redirect_uri 参与 token 交换校验,必须与授权时一致
:gsub("{port}", tostring(gatewayPort()))
-- Content-Type 按请求体形态选:LobsterAI 发 JSON,Gemini 发表单。
local ctype = "application/x-www-form-urlencoded"
if body:sub(1, 1) == "{" then ctype = "application/json" end
local hdrs = { ["Content-Type"] = ctype, ["Accept"] = "application/json" }
-- 平台的匿名头(LobsterAI 的交换端点虽不需 Authorization,但要 UA)
if type(plat.callback_headers) == "table" then
for k, v in pairs(plat.callback_headers) do hdrs[k] = v end
end
local resp, err = __llmsproxy_http({
url = cb.token_url,
method = "POST",
headers = hdrs,
body = body,
})
if err then
cerr = "token 交换失败: " .. err
elseif resp.status ~= 200 then
cerr = "token 交换失败(HTTP " .. tostring(resp.status) .. ")"
else
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or not rec.access_token then
cerr = "token 交换响应缺少 access_token"
else
credential = rec.access_token
account = "oauth"
end
end
end
else
credential = req.query.code
account = "oauth"
if not credential or credential == "" then cerr = "missing code" end
end
if cerr then
s.state = "failed"
s.message = cerr
return { status = 400, body = cerr }
end
-- 与 poll_login 的两条完成分支同一纪律:页面流程先建了占位账号
-- (credentialRef=channels:<plat>:<aid>),完成时必须带 account_id,
-- 否则凭据落到与占位账号无关的 `channels:<plat>` 键 → 池账号永远
-- has_credential=false(同一缺陷在 callback 路径的漏网)。
local res = plugin.complete_login({ platform = s.platform, credential = credential,
account = account, account_id = s.account_id })
if res.json and res.json.error then
return { status = 400, body = res.json.error }
end
s.state = "completed"
s.account = account
return { status = 200, body = "ok" }
end
-- traeCallback 解析 TRAE 的 token 直传回调并(必要时)走 ExchangeToken。
-- 返回 (credential, account, err)。
--
-- credential 是 **JSON**(不是裸 access token):device_id 必须随凭据保存,
-- 因为签到要发 `X-Device-Id`,且该值限定为「每账号互不相同」(同一天两账号
-- 共用会被「该设备已签到」拦,业务码 9074 的限流范围是 device_id 而非账号)。
-- 参考 TraeCredential 即含 deviceId/machineId。凭据的取用端 credentialOf 会
-- 从中取 access_token,故包成 JSON 不影响上游源。
--
-- 行为逐行对齐参考实现 trae-oauth.ts:
-- refreshToken(query 或 userJwt.RefreshToken 回退)存在 → ExchangeToken 换新
-- access(并轮换 refreshToken);凭据 = 新 access。
-- 无 refreshToken 但有 userJwt.Token → 直接用它兜底(不走 ExchangeToken)。
-- 二者皆无、但有 code/authCodeInfo → **PKCE 流**,明确报"暂不支持"而非含糊失败。
-- 都没有 → 回调无效。
local function jsonField(raw, key)
if not raw or raw == "" then return "" end
local ok, v = pcall(json.decode, raw)
if not ok or type(v) ~= "table" then return "" end
return v[key] or ""
end
traeCallback = function(plat, q, s)
q = q or {}
local userJwt = q.userJwt or ""
local refreshToken = q.refreshToken
if not refreshToken or refreshToken == "" then
refreshToken = jsonField(userJwt, "RefreshToken")
end
local jwtToken = jsonField(userJwt, "Token")
-- uid 与昵称都必须从 userInfo 取:uid 是签到设备身份的**派生种子**
-- (参考 trae-oauth.ts:回调字段名是 UserID,不是 user_id);
-- 昵称需经乱码修复(中文实测会变成 Óû§8847309959 这类形态)。
local userInfo = q.userInfo or ""
local uid = jsonField(userInfo, "UserID")
local enterpriseId = jsonField(userInfo, "TenantID")
local account = traeFixNickname(jsonField(userInfo, "ScreenName"), uid)
if account == "" then account = "trae" end
-- PKCE 形态探测:仅在 refreshToken / jwtToken 都空时才判,与参考一致。
local authCode = q.code or q.authCode or jsonField(q.authCodeInfo or "", "code")
if (not refreshToken or refreshToken == "") and (not jwtToken or jwtToken == "") then
if authCode and authCode ~= "" then
return nil, nil, "上游返回了 PKCE 授权码(code/authCodeInfo),本实现暂不支持该流程"
end
return nil, nil, "回调未携带 refreshToken / userJwt.Token / code"
end
-- 凭据包成 JSON:device_id/machine_id 必须留存供签到用(见函数注释)。
local function packTrae(access)
local cred = { access_token = access, uid = uid, enterprise_id = enterpriseId,
nickname = account }
if s then
cred.device_id = s.device_id
cred.machine_id = s.machine_id
cred.login_trace_id = s.login_trace_id
end
return json.encode(cred)
end
-- 分支 1:有 refreshToken → ExchangeToken 换新 access(轮换 refresh)
if refreshToken and refreshToken ~= "" and plat.callback.exchange_url then
local body = (plat.callback.exchange_body
:gsub("{client_id}", plat.callback.client_id or "")
:gsub("{refresh_token}", refreshToken))
local resp, err = __llmsproxy_http({
url = plat.callback.exchange_url,
method = "POST",
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
body = body,
})
if err then return nil, nil, "TRAE ExchangeToken 失败: " .. err end
if resp.status ~= 200 then
return nil, nil, "TRAE ExchangeToken HTTP " .. tostring(resp.status)
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return nil, nil, "TRAE ExchangeToken 响应无法解析"
end
local access = rec.Token or rec.token or rec.AccessToken or rec.access_token
if not access or access == "" then
return nil, nil, "TRAE ExchangeToken 响应缺少 Token"
end
return packTrae(access), account, nil
end
-- 分支 2:无 refreshToken,用 userJwt.Token 兜底
if jwtToken and jwtToken ~= "" then return packTrae(jwtToken), account, nil end
return nil, nil, "回调未携带可用的 token"
end
-- traeDeriveStream(seed, salt, nbytes):SHA-256 确定性伪随机流(返回 hex 串)。
--
-- 逐行对齐参考 seededStream:`SHA256(utf8(salt:seed) ++ counterBE32)` 串联,
-- counter 从 0 递增,取前 nbytes 字节。同样的 (seed,salt) 永远得到同样的字节。
-- 内核只给 sha256_hex(字符串),没有二进制原语,故自己拼大端 counter:
-- 把计数器按 4 字节大端拆成字符,先 tohex 再转义成逐字节的字面量。
local function traeDeriveStream(seed, salt, nbytes)
local prefix = tostring(salt) .. ":" .. tostring(seed)
local out, counter = "", 0
while #out < nbytes * 2 do
-- counter 是大端 4 字节;counter=0 时就是 4 个 NUL。
-- 必须用 string.char 造**真字节**:内核的 sha256_hex 经 GoStringN 按长度取串,
-- NUL 会原样保留。曾经试过用 `\\ddd` 转义串,那是错的 —— Lua 不会对
-- 运行时拼接的字符串重新词法分析,于是哈希到的是「反斜杠+数字」文本。
local be = string.char(
math.floor(counter / 16777216) % 256, math.floor(counter / 65536) % 256,
math.floor(counter / 256) % 256, counter % 256)
out = out .. sha256_hex(prefix .. be)
counter = counter + 1
end
return out:sub(1, nbytes * 2)
end
-- traeDeriveDeviceId15 / traeDeriveMarketUserId / traeDeriveSessionId:
-- 基于 user_id 的确定性设备身份。参考注释点明:每个账号天然不同设备,
-- 所以签到**不再需要**在遇到 9074 时换设备号重试(早期做法已废弃)。
traeDeriveDeviceId15 = function(userId)
local hex = traeDeriveStream(userId, "devid", 15)
local out = {}
for i = 1, 15 do
-- 按字节取模 10:两 hex 字符 = 1 字节
local b = tonumber(hex:sub((i - 1) * 2 + 1, i * 2), 16)
out[#out + 1] = tostring(b % 10)
end
return table.concat(out)
end
traeDeriveMarketUserId = function(userId)
local h = traeDeriveStream(userId, "market", 16)
local bs = {}
for i = 1, 16 do bs[i] = tonumber(h:sub((i - 1) * 2 + 1, i * 2), 16) end
bs[7] = (bs[7] % 16) + 64 -- version 4(bs 下标从 1 起,故是第 7 个)
bs[9] = (bs[9] % 64) + 128 -- variant RFC 4122
local hx = {}
for i = 1, 16 do hx[i] = ("%02x"):format(bs[i]) end
local s = table.concat(hx)
return s:sub(1, 8) .. "-" .. s:sub(9, 12) .. "-" .. s:sub(13, 16)
.. "-" .. s:sub(17, 20) .. "-" .. s:sub(21, 32)
end
traeDeriveSessionId = function(userId)
return traeDeriveStream(userId, "sess", 32)
end
-- utf8Encode(cp):码点 → UTF-8 字节串(Lua 5.1 无 utf8.char)。
local function utf8Encode(cp)
if cp < 0x80 then return string.char(cp) end
if cp < 0x800 then
return string.char(0xC0 + math.floor(cp / 64), 0x80 + cp % 64)
end
if cp < 0x10000 then
return string.char(0xE0 + math.floor(cp / 4096),
0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
end
return string.char(0xF0 + math.floor(cp / 262144),
0x80 + math.floor(cp / 4096) % 64, 0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
end
-- latin1ToUtf8(s):把「每字符=一原始字节」的串重按 UTF-8 解一次。
--
-- 这是参考 fixNicknameMojibake 里 `Buffer.from(raw,'latin1').toString('utf8')`
-- 的等价物:TRAE 回调的中文被错按 latin-1 解了一次,实测昵称变成
-- `Óû§8847309959` 这类乱码。返回 (修复串, true) 或 (nil, false)。
local function latin1ToUtf8(s)
local out, i, n = {}, 1, #s
while i <= n do
local b = string.byte(s, i)
if not b then break end
local cp, len
if b < 0x80 then cp, len = b, 1
elseif b >= 0xC2 and b <= 0xDF then cp, len = b - 0xC0, 2
elseif b >= 0xE0 and b <= 0xEF then cp, len = b - 0xE0, 3
elseif b >= 0xF0 and b <= 0xF4 then cp, len = b - 0xF0, 4
else return nil, false end
if i + len - 1 > n then return nil, false end
for k = 1, len - 1 do
local cb = string.byte(s, i + k)
if not cb or cb < 0x80 or cb > 0xBF then return nil, false end
cp = cp * 64 + (cb - 0x80)
end
-- 拒绝非最短编码、代理区与越界(否则会把垃圾字节当合法字符)
if (len == 2 and cp < 0x80) or (len == 3 and cp < 0x800)
or (len == 4 and cp < 0x10000) or cp > 0x10FFFF
or (cp >= 0xD800 and cp <= 0xDFFF) then
return nil, false
end
out[#out + 1] = utf8Encode(cp)
i = i + len
end
return table.concat(out), true
end
-- hasCJK(s):是否含 CJK 汉字(UTF-8 三字节,U+4E00–U+9FFF)。
-- 照参考 `/\u4e00-\u9fff/`:无 CJK 则判乱码,回退可读占位。
local function hasCJK(s)
if type(s) ~= "string" then return false end
local i = 1
while i <= #s do
local b = string.byte(s, i)
if b and b >= 0xE4 and b <= 0xE9 then
local b2, b3 = string.byte(s, i + 1), string.byte(s, i + 2)
if b2 and b3 and b2 >= 0x80 and b2 <= 0xBF and b3 >= 0x80 and b3 <= 0xBF then
local cp = (b - 0xE0) * 4096 + (b2 - 0x80) * 64 + (b3 - 0x80)
if cp >= 0x4E00 and cp <= 0x9FFF then return true end
end
end
i = i + 1
end
return false
end
-- traeFixNickname(raw, uid):照参考 fixNicknameMojibake 的两级处理。
-- ① 先试 latin1→utf8 回转(结果含 CJK 才采用);
-- ② 不行且无 CJK → 「用户+uid末4位」占位(绝不把乱码写进凭据)。
traeFixNickname = function(raw, uid)
if raw == nil or raw == "" then return raw or "" end
local fixed, ok = latin1ToUtf8(raw)
if ok and fixed ~= "" and hasCJK(fixed) then return fixed end
if not hasCJK(raw) then
return "\231\148\168\230\136\183" .. tostring(uid):sub(-4)
end
return raw
end
-- traeCheckinHeaders(plat, cred, uid):签到/积分请求头。
--
-- 参考注释点明:简化头(仅 Authorization + 少量 Ug 头)**实测签到失败**;
-- 真正成功的是约 20 个客户端头。关键差别:X-Device-Id 用的是
-- **基于 user_id 确定性派生的 15 位数字**(不是凭据里的 hex32)。
-- 授权方案是 `Cloud-IDE-JWT`(不是 Bearer)—— 用错前缀是 401 级失败。
-- 依赖:plat.checkin.auth 声明方案(trae 声明 cloud-ide-jwt)。
traeCheckinHeaders = function(plat, cred, uid)
uid = uid or cred.uid or ""
if uid == "" then return nil, "缺少 user_id,无法构造设备身份" end
local h = {
["Authorization"] = "Cloud-IDE-JWT " .. tostring(cred.access_token or ""),
["Content-Type"] = "application/json",
["Accept"] = "*/*",
["Accept-Language"] = "zh-CN",
["User-Agent"] = "VSCode 1.107.1 (TRAE SOLO CN)",
["X-Market-Client-Id"] = "VSCode 1.107.1",
["X-Market-User-Id"] = traeDeriveMarketUserId(uid),
["X-User-Region"] = "CN",
["X-Device-Id"] = traeDeriveDeviceId15(uid),
["X-Lgw-Req-Sdk-Type"] = "3",
["Package-Type"] = "stable_cn",
["X-Lscbd-Aid"] = "787976",
["X-Lscbd-Platform"] = "windows",
["App-Version"] = plat.ide_version or "0.1.52",
["Vscode-Sessionid"] = traeDeriveSessionId(uid),
["Sec-Fetch-Dest"] = "empty",
}
-- 每次请求独立的 trace/request id(形如 00-<hex16>-01 / uuid v4)
h["X-Tt-Trace-Id"] = "00-" .. newToken():sub(1, 16) .. "-01"
local r = newToken()
h["X-Request-Id"] = r:sub(1, 8) .. "-" .. r:sub(9, 12) .. "-4" .. r:sub(14, 16)
.. "-8" .. r:sub(18, 20) .. "-" .. r:sub(21, 32)
return h, nil
end
-- 表驱动签到推广到需自定义头的平台:在 PLATFORMS 定义后绑定。
--
-- credentialRef 是「秘密引用」,creds 是插件自解密后的 JSON(最可靠)。
do
for _, p in ipairs(PLATFORMS) do
if p.checkin and p.checkin.auth == "cloud-ide-jwt" then
p.claim_fn = function(plat, tok, args)
local cred = (args and args.credential) or { access_token = tok }
local uid = (args and args.uid) or cred.uid
or (type(cred.user_id) == "string" and cred.user_id) or ""
local h, herr = traeCheckinHeaders(plat, cred, uid)
if herr then return nil, herr end
return { url = plat.checkin.url, method = plat.checkin.method or "POST",
headers = h, body = plat.checkin.body or "{}" }
end
end
end
end
-- plugin.__trae_derive_probe 只给测试做差分用(拿参考实现生成的期望值比对)。
-- 不挂 HTTP 路由、不暴露给 UI;它必须在派生函数**定义之后**才存在,
-- 因为它们都是 local,插件的 plugin 表又建得早。
function plugin.__trae_derive_probe(args)
local uid = tostring((type(args) == "table" and args.uid) or "")
return { json = { devid = traeDeriveDeviceId15(uid),
market = traeDeriveMarketUserId(uid),
sess = traeDeriveSessionId(uid) } }
end
-- plugin.on_unload:插件被卸载/重载时回收自己拉起的浏览器栈。进程是插件起的,
-- 收尾也是插件的责任 —— 内核不应知道这些进程的存在。
plugin.on_unload = function()
reapBrowser()
-- 回调接收器也是本插件起的进程,卸载/重载时一并回收。
-- 不能只收浏览器栈:否则每次插件重载都留下一批 python 监听。
for id in pairs(plugin.state.loopbacks or {}) do
reapLoopback(id)
end
end
-- ============================================================================
-- UI:独立标签页(登录 / 认证 / 配置)
-- ============================================================================
plugin.ui = {
page = {
page_id = "channels",
title = "账号接入",
icon = "",
order = 40,
mount = [[
<div id="channels-page">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<h2 style="margin:0">渠道控制台</h2>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<input id="ch-search" placeholder="搜索渠道或供应商…" style="padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);min-width:200px">
<div id="ch-filters" style="display:flex;gap:6px"></div>
<button class="small" onclick="chLedger()">Token 用量</button>
<button class="small" onclick="chBackup(1)">导出备份</button>
<button class="small" onclick="document.getElementById('ch-import-file').click()">导入备份</button>
<input type="file" id="ch-import-file" hidden accept=".json,application/json" onchange="chImportFile(event)">
<label style="display:flex;gap:6px;align-items:center;font-size:12px">
<input type="checkbox" id="ch-auto" onchange="chAutoToggle(this.checked)"> 每日自动签到
</label>
</div>
</div>
<div id="ch-summary" style="display:flex;gap:18px;margin:10px 0;font-size:13px;color:var(--muted,#888)"></div>
<div id="ch-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px"></div>
</div>
<!-- 渠道详情:账号池 + 模型开关(对齐参考 provider-detail) -->
<div class="card" id="ch-detail" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center">
<button class="small" onclick="chSelect(undefined)">← 返回全部渠道</button>
<span class="muted" style="font-size:12px">账号凭据加密保存在服务端,页面永远拿不到明文。</span>
</div>
<h2 id="ch-detail-name" style="margin:10px 0 4px"></h2>
<p class="muted" id="ch-detail-org" style="margin:0 0 12px;font-size:13px"></p>
<div id="ch-detail-actions" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px"></div>
<p id="ch-notice" class="muted" style="font-size:13px;min-height:18px"></p>
<div id="ch-accts"></div>
<div style="display:flex;gap:8px;align-items:center;margin:14px 0 6px">
<b style="font-size:13px">模型</b>
<span id="ch-model-count" class="muted" style="font-size:12px"></span>
<button class="small" onclick="chModelAll(false)">全部启用</button>
<button class="small" onclick="chModelAll(true)">全部关闭</button>
<button class="small" onclick="chModelRefresh()">从上游拉取</button>
<span id="ch-model-src" class="muted" style="font-size:11px"></span>
</div>
<div id="ch-models" style="display:flex;flex-direction:column;gap:4px"></div>
<!-- 登录区:发起登录后显示(device-poll 轮询 / callback URL / vnc / paste / manual) -->
<div id="ch-login-box" style="display:none;margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
<h3 style="margin:4px 0">登录</h3>
<div id="ch-login-body"></div>
<div id="ch-vnc-wrap" style="display:none;margin-top:10px">
<p class="muted" style="font-size:12px">请在下面的窗口里完成登录,完成后本页会自动继续。</p>
<iframe id="ch-vnc" style="width:100%;height:640px;border:1px solid var(--line);border-radius:8px" allow="clipboard-read; clipboard-write"></iframe>
</div>
</div>
<h3 style="margin:16px 0 6px;font-size:13px">最近日志</h3>
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px"></ul>
</div>
</div>
<script>
(function () {
const q = (s) => document.querySelector(s);
const esc = (v) => String(v == null ? "" : v).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
async function call(path, method, body) {
const r = await fetch("/api/plugins/http/" + path, {
method: method || "GET",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: body ? JSON.stringify(body) : undefined,
});
return await r.json().catch(() => ({}));
}
// POST 端点统一走 call(path,"POST",...);账号/模型/供应商操作都按这个形状。
let state = { statuses: [], filter: "all", search: "", selected: undefined,
accounts: undefined, models: undefined, login: null, pollTimer: null };
const stopPoll = () => { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } };
async function loadStatuses() {
const j = await call("channels/provider/status", "POST", {});
state.statuses = j.statuses || [];
renderSummary(); renderGrid();
const sj = await call("channels/status");
const log = q("#ch-log"); if (log) log.innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
const aj = await call("channels/auto_checkin", "POST", {});
q("#ch-auto").checked = aj.autoCheckin !== false;
}
function renderSummary() {
const total = state.statuses.length;
const connected = state.statuses.filter((s) => !s.closed && s.accounts.enabled > 0).length;
const accounts = state.statuses.reduce((n, s) => n + (s.accounts.total || 0), 0);
q("#ch-summary").innerHTML =
"<span>已接入渠道 <b>" + connected + " / " + total + "</b></span>" +
"<span>本机账号 <b>" + accounts + "</b></span>";
const counts = { all: total, connected, pending: total - connected };
const defs = [{ id: "all", label: "全部" }, { id: "connected", label: "已接入" }, { id: "pending", label: "待接入" }];
q("#ch-filters").innerHTML = defs.map((d) =>
'<button class="small" data-f="' + d.id + '" style="' + (state.filter===d.id?"font-weight:bold;":"") + '" onclick="chFilter(\'' + d.id + '\')">' + d.label + ' (' + counts[d.id] + ')</button>').join("");
}
function renderGrid() {
const list = state.statuses.filter((s) => {
const hit = (s.id + " " + s.name).toLowerCase().includes(state.search);
const on = !s.closed && s.accounts.enabled > 0;
return hit && (state.filter === "all" || (state.filter === "connected") === on);
});
q("#ch-grid").innerHTML = list.map((s) => {
const on = !s.closed && s.accounts.enabled > 0;
const st = s.closed ? '<span class="tag">已关闭</span>'
: on ? '<span class="tag tag-green">已接入</span>' : '<span class="tag">待接入</span>';
return '<div class="card" style="margin:0;padding:12px;cursor:pointer" onclick="chSelect(\'' + esc(s.id) + '\')">' +
'<div style="display:flex;justify-content:space-between;align-items:center">' +
'<b>' + esc(s.name) + '</b>' + st + '</div>' +
'<div class="muted" style="font-size:12px;margin:6px 0">' + esc(s.id) + ' · ' + esc(s.flow) + '</div>' +
'<div style="font-size:12px">账号 <b>' + (s.accounts.enabled||0) + '/' + (s.accounts.total||0) + '</b>' +
' · 模型 <b>' + ((s.models.total||0)-(s.models.disabled||0)) + '/' + (s.models.total||0) + '</b></div>' +
'</div>';
}).join("") || '<p class="muted">没有匹配的渠道</p>';
}
async function renderDetail() {
const id = state.selected;
const box = q("#ch-detail");
if (!id) { box.style.display = "none"; q("#ch-grid").parentElement.style.display = "block"; return; }
box.style.display = "block";
q("#ch-grid").parentElement.style.display = "none";
const s = state.statuses.find((x) => x.id === id) || {};
q("#ch-detail-name").textContent = s.name || id;
q("#ch-detail-org").textContent = id + " · " + (s.flow || "");
const cap = (await call("channels/auto_checkin","POST",{})) && true; // noop,保持顺序
// 动作区:登录(加账号)/ 刷新全部 / 一键领取 / 供应商开关
const acts = [];
acts.push('<button class="small" onclick="chStartLogin()">添加账号(登录)</button>');
if ((s.accounts.total||0) > 0) acts.push('<button class="small" onclick="chRefreshAll()">刷新全部账号</button>');
acts.push('<button class="small" onclick="chClaimAll()">一键领取额度</button>');
acts.push('<button class="small" onclick="chResetAll()">重置限流标记</button>');
acts.push('<button class="small" onclick="chClearDead()">清死模型</button>');
acts.push('<button class="small" onclick="chProviderToggle(' + (!s.closed) + ')">' + (s.closed ? "启用渠道" : "关闭渠道") + '</button>');
q("#ch-detail-actions").innerHTML = acts.join(" ");
q("#ch-notice").textContent = "";
await Promise.all([renderAccounts(id), renderModels(id)]);
}
async function renderAccounts(id) {
const j = await call("channels/account/list", "POST", { platform: id });
const accts = j.accounts || [];
state.accounts = accts;
// 逐账号余额(credits.balances 对齐参考;无端点平台如实显示不可用)
let balances = {};
try {
const bj = await call("channels/credits/balances", "POST", { platform: id });
for (const row of (bj.accounts || [])) balances[row.accountId] = row;
} catch {}
q("#ch-accts").innerHTML = accts.length === 0
? '<p class="muted" style="font-size:13px">暂无账号。点「添加账号(登录)」开始。</p>'
: accts.map((a) => {
const badge = a.has_credential
? (a.enabled !== false ? '<span class="tag tag-green">启用</span>' : '<span class="tag">停用</span>')
: '<span class="tag">待凭据</span>';
const claimTxt = a.last_claim ? ('上次领取 ' + new Date(a.last_claim*1000).toLocaleString() + (a.last_claim_summary ? ' · ' + esc(a.last_claim_summary) : '')) : '';
const b = balances[a.id];
const balTxt = !b ? '' : b.balance && typeof b.balance.total === 'number'
? '<span class="tag tag-green">' + esc(b.balance.unit || 'credits') + ' ' + b.balance.total + '</span>'
: '<span class="muted" title="' + esc(b.error || '') + '" style="font-size:12px">余额不可用</span>';
return '<div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px dashed var(--line);flex-wrap:wrap">' +
'<b style="min-width:120px">' + esc(a.nickname || a.id) + '</b>' + badge + balTxt +
'<span class="muted" style="font-size:12px">' + claimTxt + '</span>' +
'<span style="flex:1"></span>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'test\')">测试</button>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'refresh\')">刷新</button>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'toggle\')">' + (a.enabled !== false ? "停用" : "启用") + '</button>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'priority\')">优先</button>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'delete\')">删除</button>' +
'</div>';
}).join("");
}
window.chModelRefresh = async () => {
const id = state.sel && state.sel.id;
if (!id) return;
const r = await call("channels/model/refresh", "POST", { platform: id });
const res = (r.results || [])[0] || {};
if (res.ok) alert("已从上游拉取 " + res.count + " 个模型");
else alert("拉取失败:" + (res.error || "未知错误") + "(保留既有目录)");
await renderModels(id);
};
async function renderModels(id) {
const j = await call("channels/model/list", "POST", { platform: id });
const ms = j.models || [];
state.models = ms;
const on = ms.filter((m) => !m.disabled).length;
q("#ch-model-count").textContent = on + "/" + ms.length + " 启用";
// 目录来源:远端拉到的目录以远端为准、兜底补位;没拉过则只有兜底。
const src = q("#ch-model-src");
if (src) {
if (j.remote) {
const t = j.fetched_at ? new Date(j.fetched_at * 1000).toLocaleString() : "";
src.textContent = "上游目录(" + t + ")";
} else {
src.textContent = "内置兜底目录(未从上游拉取)";
}
}
q("#ch-models").innerHTML = ms.map((m) =>
'<div style="display:flex;gap:8px;align-items:center;padding:4px 0">' +
'<span style="flex:1;font-size:13px" title="' + esc(m.id) + '">' + esc(m.name || m.id) + '</span>' +
'<button class="small" onclick="chModel(\'' + esc(m.id) + '\',' + (!m.disabled) + ')">' + (m.disabled ? "开启" : "关闭") + '</button>' +
'</div>').join("") || '<p class="muted">—</p>';
}
// ---- 动作 ----
window.chFilter = (f) => { state.filter = f; renderSummary(); renderGrid(); };
q("#ch-search").addEventListener("input", (e) => { state.search = e.target.value.toLowerCase(); renderGrid(); });
window.chSelect = async (id) => { state.selected = id; await renderDetail(); };
window.chAcct = async (aid, act) => {
const id = state.selected; if (!id) return;
if (act === "delete" && !confirm("删除账号 " + aid + "?(连同凭据与上游源)")) return;
if (act === "toggle") {
const a = (state.accounts || []).find((x) => x.id === aid);
await call("channels/account/update", "POST", { platform: id, account_id: aid, patch: { enabled: !(a && a.enabled !== false) } });
} else if (act === "priority") {
await call("channels/account/reorder", "POST", { platform: id, ordered_ids: [aid].concat((state.accounts||[]).map((x)=>x.id).filter((x)=>x!==aid)) });
} else {
await call("channels/account/" + act, "POST", { platform: id, account_id: aid });
}
await renderAccounts(id); await loadStatuses();
};
window.chModel = async (mid, disabled) => {
const id = state.selected; if (!id) return;
await call("channels/model/set_disabled", "POST", { platform: id, model_id: mid, disabled });
await renderModels(id); await loadStatuses();
};
window.chModelAll = async (disabled) => {
const id = state.selected; if (!id) return;
await call("channels/model/set_all_disabled", "POST", { platform: id, disabled });
await renderModels(id); await loadStatuses();
};
window.chProviderToggle = async (enable) => {
const id = state.selected; if (!id) return;
if (!enable && !confirm("关闭渠道会停用全部账号与模型,确认?")) return;
await call("channels/provider/set_enabled", "POST", { platform: id, enabled: enable });
await loadStatuses(); await renderDetail();
};
window.chRefreshAll = async () => {
const id = state.selected; if (!id) return;
for (const a of state.accounts || []) await call("channels/account/refresh", "POST", { platform: id, account_id: a.id });
notice("已刷新全部账号"); await renderAccounts(id);
};
window.chClaimAll = async () => {
const id = state.selected; if (!id) return;
notice("领取中…");
const j = await call("channels/credits/claim_all", "POST", { platform: id });
const res = j.results || [];
const okN = res.filter((r) => r.outcome && r.outcome.kind === "success").length;
const failN = res.filter((r) => r.outcome && r.outcome.kind === "failed");
notice("领取完成:成功 " + okN + " 个账号" + (failN.length ? ";失败:" + failN.map((r) => r.outcome.message).join(";") : ""));
await renderAccounts(id);
};
window.chAutoToggle = async (on) => { await call("channels/auto_checkin", "POST", { enabled: on }); notice(on ? "自动签到已开启" : "自动签到已关闭"); };
window.chResetAll = async () => {
const id = state.selected; if (!id) return;
await call("channels/account/reset", "POST", { platform: id, account_id: "__all__" });
notice("已重置全部账号限流标记");
};
window.chClearDead = async () => {
const id = state.selected; if (!id) return;
const j = await call("channels/model/clear_dead", "POST", { platform: id });
const removed = (j.removed || []).length;
notice(removed ? ("已清理 " + removed + " 个死模型") : "没有死模型");
await renderModels(id);
};
// 用量快照(usage.tokenLedger 对齐参考):弹窗展示 prompt/completion/请求数
window.chLedger = async () => {
const j = await call("channels/usage/token_ledger", "POST", { limit: 100 });
const s = j.snapshot || {};
if (!s.available) { alert("用量不可用:" + (s.error || "未知")); return; }
alert("本网关 token 用量\nPrompt: " + (s.tokens?.prompt ?? 0) + "\nCompletion: " + (s.tokens?.completion ?? 0) +
"\nTotal: " + (s.tokens?.total ?? 0) + "\nRequests: " + (s.requests ?? 0));
};
window.chBackup = async () => {
const j = await call("channels/backup/export", "POST", {});
const blob = new Blob([JSON.stringify(j.payload, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "channels-backup-" + new Date().toISOString().slice(0,10) + ".json";
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
};
window.chImportFile = async (ev) => {
const f = ev.target.files[0]; ev.target.value = ""; if (!f) return;
if (f.size > 8*1024*1024) { alert("备份文件不能超过 8MB"); return; }
if (!confirm("导入会合并备份中的账号凭据、模型开关与自动签到设置,确认?")) return;
const payload = JSON.parse(await f.text());
const j = await call("channels/backup/import", "POST", { payload });
alert(j.error ? ("导入失败:" + j.error) : ("已导入,恢复 " + (j.restored||0) + " 条凭据"));
await loadStatuses();
};
function notice(msg) { q("#ch-notice").textContent = msg; }
// ---- 登录(对齐参考两步式:立即拿 URL/弹窗,后台轮询完成)----
window.chStartLogin = async () => {
const id = state.selected; if (!id) return;
// 池路径:先建占位账号,登录完成后回填凭据到这个账号
const aj = await call("channels/account/add", "POST", { platform: id });
if (aj.error) { alert(aj.error); return; }
const aid = aj.account_id;
const j = await call("channels/login", "POST", { platform: id, account_id: aid });
if (j.error) { alert(j.error); return; }
const box = q("#ch-login-box"); box.style.display = "block";
const vnc = q("#ch-vnc-wrap");
if (j.view_url) {
q("#ch-login-body").innerHTML = "<p class='muted' id='ch-login-state'>请在下方窗口完成登录…</p>";
vnc.style.display = "block"; q("#ch-vnc").src = j.view_url;
} else if (j.manual_hint) {
vnc.style.display = "none";
q("#ch-login-body").innerHTML =
"<p class='muted'>" + esc(j.manual_hint) + "</p>" +
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='把凭据粘贴到这里'></textarea></p>" +
"<p><button class='small' onclick='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
"<p class='muted' id='ch-login-state'></p>";
} else if (j.session && j.session.flow === "paste") {
vnc.style.display = "none";
q("#ch-login-body").innerHTML =
"<p>把该平台的 API key / token 粘贴到这里:</p>" +
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='API key / token'></textarea></p>" +
"<p><button class='small' onclick='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
"<p class='muted' id='ch-login-state'></p>";
} else {
vnc.style.display = "none";
q("#ch-login-body").innerHTML =
"<p>请在你自己的浏览器打开下面的地址完成登录:</p>" +
"<p><a href='" + esc(j.url) + "' target='_blank' rel='noopener'>" + esc(j.url) + "</a></p>" +
(j.session && j.session.user_code ? "<p>设备码:<b>" + esc(j.session.user_code) + "</b></p>" : "") +
"<p class='muted' id='ch-login-state'>等待完成…</p>";
}
stopPoll();
if (!(j.manual_hint || (j.session && j.session.flow === "paste"))) {
state.pollTimer = setInterval(async () => {
const st = await call("channels/login/status?id=" + encodeURIComponent(j.session.id));
const el = q("#ch-login-state"); if (el) el.textContent = "状态:" + st.state + (st.account ? "(" + st.account + ")" : "");
if (st.state === "completed" || st.state === "failed" || st.state === "expired") {
stopPoll(); q("#ch-vnc").src = "about:blank"; vnc.style.display = "none";
box.style.display = "none";
if (st.state === "failed") notice("登录未完成:" + (st.message || st.state));
await renderAccounts(id); await loadStatuses();
}
}, 2000);
}
};
// paste / manual 的凭据提交:落到占位账号(池路径),完成即同步源
window.chPaste = async (aid) => {
const id = state.selected; if (!id || !aid) return;
const cred = (q("#ch-cred") && q("#ch-cred").value || "").trim();
if (!cred) { alert("凭据为空"); return; }
const el = q("#ch-login-state"); if (el) el.textContent = "提交中…";
const j = await call("channels/login/complete", "POST", { platform: id, credential: cred, account_id: aid });
if (j.error) { if (el) el.textContent = "失败:" + j.error; return; }
q("#ch-login-box").style.display = "none"; q("#ch-cred").value = "";
notice("账号已接入:" + aid);
await renderAccounts(id); await loadStatuses();
};
loadStatuses();
})();
</script>
]],
},
}
return plugin