Files
ModelRouter/internal/lua/plugins/channels.lua
JianFeeeee ff9b460eba feat(channels): TRAE 用插件自装的 SOLO 适配器(channels-trae)—— 载荷真转换
TRAE **不是 OpenAI 兼容**。参考 trae.ts:30 点名:「载荷必须转换:OpenAI 的
{model,messages,tools,tool_choice,stream} 需要映射为 SOLO 格式(function、
config_name 等字段),不能透传」。上一轮我把端点改对了(/api/agent/v3/
llm_utils_chat)但**载荷仍原样直发** —— 上游会回流内 4001,而 HTTP 状态码
仍是 200,属静默失败。

本次补齐 transformToSOLOBody 的 7 条规则(逐条对齐参考):
  1. messages.content 字符串 → [{type:"text",text:…}];数组透传
  2. stream 强制 true(非流式由服务端聚合)
  3. model → config_name + model **双字段**(支持 __dev 后缀消除)
  4. function 通道名,缺省 solo_work_lite
  5. tool_choice 归一化:none → 删 tool_choice **与** tools;
     auto/required 保留;{type:function,function:{name}} → 字符串 name
  6. assistant 的 tool_calls: function → function_call,并剔除无 name 的条目
     (孤儿 tool_call 会让整条会话被拒)
  7. tools[].function.parameters: object → JSON **字符串**(SOLO 要求)

★ 命名为什么是 channels-trae 而不是 trae:内置 trae.lua 服务的是**运维自建
的 trae-local-api 反代**(base_url 127.0.0.1:19900,生产 config.yaml 在用),
与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)。
判据里专门断言"内置 trae 未被顶掉"。

★ 踩到的坑:`function` 是 Lua 关键字,写 `body.function = x` 是**语法错误**
('<name>' expected near 'function'),必须 `body["function"]`。它只在 load()
时才暴露 —— 插件脚本本身能编译通过,适配器源码要运行期才报错。

判据(internal/lua/channels_trae_solo_test.go):走**真实路径**
(装插件 → 调已安装的适配器),而不是在插件 VM 里 load 一份副本 ——
副本拿不到内核注入的共享前导,也发现不了"装了但没生效"。逐条断言 7 条规则
+ 三处反向断言(extra_body 必剥离、无 name 的 tool_call 必剔除、
tool_choice=none 时 tools 与 tool_choice 都必须消失)。

回归:internal/{lua,core,provider,gateway} 全绿。
2026-10-13 23:28:27 +08:00

7433 lines
354 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", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_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 助手都要在此
-- 统一占坑。Lua 的 local 作用域只覆盖其后的代码 —— 漏一个就是运行时的
-- "attempt to call global 'x' (a nil value)",而编译期完全静默(本文件已踩多次:
-- qoderMachineIdentity / rfc3986Escape / newToken / lobsteraiClientVersion /
-- traeCallback 各一次)。
local qoderMachineIdentity, rfc3986Escape, newToken
local lobsteraiClientVersion
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" },
-- trae 非 OpenAI 兼容:端点是 SOLO 的 /api/agent/v3/llm_utils_chat,
-- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。
base_url = "https://trae-api-cn.mchost.guru",
-- TRAE 非 OpenAI 兼容,用**本插件自装**的 SOLO 适配器
-- (见 TRAE_SOLO_ADAPTER_SRC)。端点由适配器声明 /api/agent/v3/llm_utils_chat。
adapter = "channels-trae",
-- 照参考 TRAE_FALLBACK_MODELS 全量(32 项)。其中 4 项带 isHidden
-- (内部子 agent)—— 不进用户可选列表,单列在 models_hidden。
models = {
"DeepSeek-V4-Flash-Official", "Doubao-Seed-2.1-Pro", "seed-code-pro-0430",
"Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.0-Code", "glm-5.2", "glm-5-turbo",
"glm-5", "DeepSeek-V4-Pro", "DeepSeek-V4-Flash", "kimi-k3", "kimi-k2.7-code",
"kimi-k2.6", "minimax-m3", "qwen-3.7-plus", "sagitta", "aquila",
"custom_model_gemini", "custom_model_placeholder", "custom_model_1M_text",
"custom_model_1M", "custom_model_kimi", "custom_model_claude",
"custom_model_gpt-5", "custom_model_no-fc", "custom_model_deepseek_chat",
"custom_model_deepseek_reasoner", "custom_model_deepseek_v4",
},
models_hidden = {
"browser_use_subagent", "explore_sub_agent_v13", "explore_sub_agent_v2", "summary",
},
-- 每日签到: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",
-- 照参考 MINIMAX_FALLBACK_MODELS(4 项)。远端权威:
-- GET /mavis/api/v1/models?region=cn&buildEnv=prod
models = { "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7" },
-- 每日签到(两步:状态预检 + 领取;幂等靠服务端状态)
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" },
-- cline 的 chat 路径是 /api/v1/chat/completions(参考 CLINE_CHAT_PATH)。
base_url = "https://api.cline.bot/api/v1/chat/completions",
-- 远端模型目录(照参考 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" },
-- buddy 的 chat 端点含 /v2 前缀(参考 CHAT_API_BASE = .../v2 + /chat/completions)。
base_url = "https://copilot.tencent.com/v2/chat/completions",
-- 远端模型目录(照参考 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",
-- ⚠️ Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent;
-- 参考 gemini.ts:78 明确走私有动词路径 /v1internal:generateContent
-- (流式 /v1internal:streamGenerateContent?alt=sse)。用声明式 endpoint
-- 覆盖,内核只当不透明字符串。
endpoint = "/v1internal:generateContent",
endpoint_stream = "/v1internal:streamGenerateContent?alt=sse",
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",
},
-- lobsterai 的 chat 路径是 /api/proxy/v1/chat/completions(参考 LOBSTERAI_CHAT_PATH)。
base_url = "https://lobsterai-server.youdao.com/api/proxy/v1/chat/completions",
-- 远端模型目录(照参考 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",
-- 照参考 LOBSTERAI_FALLBACK_MODELS 全量(19 项)。远端权威:
-- GET /api/models/available(**必须带 model_flag 参数**,否则只返回 25 个
-- 且没有 kimi-k3 —— 参考注释点名)。
models = {
"deepseek-v4-flash", "deepseek-v4-pro", "MiniMax-M3", "MiniMax-M2.7",
"qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus-2026-04-20",
"kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5",
"doubao-seed-2-1-pro-260628", "doubao-seed-2-1-turbo-260628",
"doubao-seed-2-0-code-preview-260215", "glm-5.2", "glm-5.1",
"glm-5v-turbo", "glm-5",
},
-- 余额(对齐参考: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",
-- opencode 的 chat 路径是 /v1/chat/completions(参考 chatPath)。
base_url = "https://opencode.ai/zen/v1/chat/completions",
-- 远端模型目录(照参考 opencode-product.ts modelsPath)
models_remote = {
endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } },
auth = "bearer",
parse = "id_list",
},
adapter = "openai",
-- 照参考 OPENCODE_FALLBACK_MODELS 全量(14 项)。isFree 是**官方语义
-- 字段**(匿名可用与否):7 个 free 无需 key,其余需付费 key(实测匿名 401)。
models = {
"big-pickle", "space-bunny-free", "longcat-2.5-preview-free",
"mimo-v2.6-flash-free", "mimo-v2.5-free", "nemotron-3-ultra-free",
"nemotron-3.5-lightning-free", "deepseek-v4-flash", "deepseek-v4.1-flash",
"glm-5.2", "kimi-k2.5", "minimax-m2.7", "minimax-m3", "qwen3.8-max",
},
models_free = {
"big-pickle", "space-bunny-free", "longcat-2.5-preview-free",
"mimo-v2.6-flash-free", "mimo-v2.5-free", "nemotron-3-ultra-free",
"nemotron-3.5-lightning-free",
},
},
-- CodeArts(华为)—— 凭据 + SDK-HMAC-SHA256 签到(照参考 codearts-credits.ts)。
-- 登录是常规企业凭据(paste);签到走 snap-access 的 SDK-HMAC-SHA256 签名,
-- 放在 claim_fn 钩子里(checkin 表驱动驱动不动签名请求,参考注释也点明这一点)。
{
id = "codearts",
name = "CodeArts(华为)",
vendor = "huawei",
-- 不是 paste!参考默认走**新式 IAM OAuth**(PKCE + DPoP),
-- 旧 ticket 流程仅作回退。我此前标成手填凭据是错的。
flow = "callback",
-- 回调路径必须是 /oauth/callback(参考 REDIRECT_PATH),且端口须 ≥10000
-- (portal 拒绝低端口)。
authorize = {
base = "https://codearts.huaweicloud.com/portal/authorize",
callback_path = "/oauth/callback",
min_port = 10000,
-- 参数由 buildCodeartsAuthorizeUrl 按参考逐字拼(含 SHA-256 而非 S256)
},
hosts = { "codearts.huaweicloud.com", "sts.cn-north-4.myhuaweicloud.com",
"snap-access.cn-north-4.myhuaweicloud.com",
"devcloud.cn-north-4.huaweicloud.com", "auth.huaweicloud.com" },
-- codearts 的 chat 端点含 /api/v2 前缀(参考 CHAT_API_BASE = .../api/v2)。
base_url = "https://snap-access.cn-north-4.myhuaweicloud.com",
-- 本插件**自己装的**适配器(见 installCodeartsAdapter):华为云 AK/SK 签名
adapter = "codearts",
-- 签名平台:源注册要交**完整凭据 JSON**(适配器自己解 AK/SK 签名),
-- 不是裸 access token。见 syncAccountSource 的注释。
credential_shape = "full",
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,
},
-- qoder 的 chat 路径是 /model/v1/chat/completions(参考 QODER_CHAT_PATH)。
base_url = "https://api2-v2.qoder.sh/model/v1/chat/completions",
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",
-- CN 独有(参考注释点名:`q37fmodel` / `gm51model` 仅国区下发,
-- 两者都是 1M 上下文、支持图像与思考)
"q37fmodel", "gm51model",
},
-- 余额(对齐参考 qoder-credits.ts:GET /sash/api/v2/me/usage,余额=userQuota.remaining
-- + addOnQuota.remaining,走 openApiBase)
-- 身份头:cosy-clienttype 是必要但**不充分**条件,服务端还校验设备身份
-- (见 qoderMachineIdentity 的函数注释:缺 Cosy-MachineToken/MachineType
-- 时不下发可领取的活动)。拿不到身份时照常发(保守降级)。
headers_fn = function(accessToken, cred)
local h = {
["Authorization"] = "Bearer " .. accessToken,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["Cosy-ClientType"] = "10",
}
local id = qoderMachineIdentity()
if id then
-- 必须成对出现(消融表:缺一即失效)
h["Cosy-MachineToken"] = id.token
h["Cosy-MachineType"] = id.type
end
return h
end,
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。这里用积分制那条。
-- zcode 只存在 /api/v1/zcode-plan/anthropic/v1/messages 这一个 chat 路径
-- (参考 zcode-anthropic.ts:7 注释:openai / 裸 v1/chat/completions 一律
-- 404 page not found)。适配器 anthropic 补 /v1/messages。
base_url = "https://zcode.z.ai/api/v1/zcode-plan/anthropic",
-- 远端模型目录(照参考 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",
},
-- loomy 的 chat 路径是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494
-- 实测标准 OpenAI 兼容)。
base_url = "https://loomy.xunfei.cn/chat/completions",
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)
-- raccoon 的 chat 端点是 /api/web/llm/v2/chat/completions。
base_url = "https://xiaohuanxiong.com/api/web/llm/v2/chat/completions",
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 },
},
}
-- 华为云 snap-access 引擎域名(连接与签名都用它)。
local CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com"
-- CodeArts(华为云 snap-access)的请求签名与每日领取。
--
-- 逐行对齐参考 `sign.ts:signRequestHuawei` + `codearts-credits.ts`。
-- 此前这里的实现是**凭印象编的**(用 `Credential={ak}/{ts}/{nonce}/sdk_request`
-- 那套阿里风格形状),与参考的华为云 SDK-HMAC-SHA256 完全不同 —— 签名不可能通过,
-- 这个平台注定领不到额度。对齐要点:
-- 1. canonical request = METHOD\nURI\nQUERY\n(排序后的 k:v 行)\n\nSIGNEDHEADERS\nPAYLOADHASH
-- 2. string to sign = "SDK-HMAC-SHA256\n{dateStamp}\n{sha256(canonical)}"
-- 3. signature = HMAC-SHA256(sk, stringToSign)
-- 4. Authorization = "SDK-HMAC-SHA256 Access={ak},SignedHeaders=...,Signature=..."
-- (**是 Access= 不是 Credential=**)
-- 5. 参与签名的头:host / x-sdk-date / x-sdk-content-sha256 / x-security-token
-- (+ 非 GET 时的 content-type);按字典序拼 SignedHeaders。
-- 6. Agent-Type / X-Language 是**签名后**追加的,绝不能进 canonical(否则验签失败)。
-- 7. URI 末尾必须补 '/'。
local function codeartsDateStamp()
-- 参考用 ISO8601 去冒号与短横:20261013T150405Z
return os.date("!%Y%m%dT%H%M%SZ")
end
-- codeartsSignedRequest(method, url, cred, body):返回可直接发出的 {url,method,headers,body}。
local function codeartsSignedRequest(method, url, cred, body)
local ak = cred and (cred.access_key_id or cred.access_key) or ""
local sk = cred and (cred.secret_access_key or cred.secret_key) or ""
local st = cred and (cred.security_token or "") or ""
if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end
local host = url:match("^https?://([^/]+)")
local uri = url:match("^https?://[^/]+(/[^?]*)") or "/"
if uri:sub(-1) ~= "/" then uri = uri .. "/" end
local query = url:match("%?([^#]*)$") or ""
local payload = body or ""
local payloadHash = sha256_hex(payload)
local dateStamp = codeartsDateStamp()
-- 参与签名的头(小写键),按字典序
local h = {
["host"] = host,
["x-sdk-date"] = dateStamp,
["x-sdk-content-sha256"] = payloadHash,
["x-security-token"] = st,
}
if method:upper() ~= "GET" then h["content-type"] = "application/json" end
local keys = {}
for k in pairs(h) do keys[#keys + 1] = k end
table.sort(keys)
local lines, signedNames = {}, {}
for _, k in ipairs(keys) do
lines[#lines + 1] = k .. ":" .. h[k]
signedNames[#signedNames + 1] = k
end
local signedHeaders = table.concat(signedNames, ";")
local canonical = table.concat({
method:upper(), uri, query, table.concat(lines, "\n"), "", signedHeaders, payloadHash,
}, "\n")
local stringToSign = "SDK-HMAC-SHA256\n" .. dateStamp .. "\n" .. sha256_hex(canonical)
local signature = hmac_sha256_hex(sk, stringToSign)
-- 把中间值一并带出:判据必须比对**真实函数算出的** canonical / string-to-sign,
-- 而不是自己重算一遍 —— 否则真实签名被改坏时判据照样通过(判据与被测对象脱节,
-- 实测踩过:去掉 content-type 参与签名,探针重算版本仍绿)。
-- 发出去的头:签名头 + Authorization(host 由 HTTP 栈生成,不手填)
local out = {}
for _, k in ipairs(keys) do
if k ~= "host" then out[k] = h[k] end
end
out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak
.. ",SignedHeaders=" .. signedHeaders .. ",Signature=" .. signature
-- 签名后追加(不参与 canonical)
out["Agent-Type"] = "PromptCenter"
out["X-Language"] = "zh-cn"
return { url = url, method = method:upper(), headers = out, body = payload,
canonical = canonical, string_to_sign = stringToSign,
signed_headers = signedHeaders }
end
-- codeartsAccountInfo(cred):账号信息(statistics/plugin),判断是否积分账户。
local function codeartsAccountInfo(cred)
local req, err = codeartsSignedRequest("GET", CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil)
if not req then return nil, err end
local resp, herr = __llmsproxy_http(req)
if herr or not resp or resp.status ~= 200 then
return nil, "账户信息查询失败: " .. tostring(herr or (resp and resp.status))
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil, "账户信息响应无法解析" end
local d = rec.data or rec
local metrics = d.metrics or {}
local total = nil
for _, m in ipairs(metrics) do
-- IDE 从 usageTotalPackageCredit 读总额(参考 codearts-credits.ts 注释)
if type(m) == "table" and m.name == "usageTotalPackageCredit" then
total = tonumber(m.value) or tonumber(m.credit) or total
end
end
local billing = d.billingMode or d.billing_mode or d.chargingMode or ""
return {
isCreditPackage = tostring(billing):upper():find("CREDIT") ~= nil,
isTokenPackage = tostring(billing):upper():find("TOKEN") ~= nil,
total = total,
raw = d,
}, nil
end
-- codeartsClaim(plat, tok, args):完整领取流程(照参考的 6 步判定顺序)。
-- 返回 (requestSpec, nil) 给通用发送路径,或 (nil, err) 提前失败。
-- 注意:参考的流程要发**多个**请求(账户→活动→claim→confirm),而 claim_daily
-- 的契约只发一个。故这里把整段流程跑完并直接落结果,返回一个「不发送」的哨兵,
-- 由 claim_daily 识别 —— 见 CLAIM_DONE 约定。
local function codeartsClaim(plat, tok, args)
local cred = (args and args.credential) or nil
if type(cred) ~= "table" then return nil, "缺少凭据对象(AK/SK 需要结构化字段)" end
-- ① 账户类型:非积分账户 → inactive(Token 账户不该被报成"领取失败")
local info, ierr = codeartsAccountInfo(cred)
if not info then return nil, ierr end
if not info.isCreditPackage then
return { done = true, kind = "inactive",
message = info.isTokenPackage and "Token 计费账户,不在积分活动范围"
or "非积分计费账户,不在积分活动范围" }
end
-- ② 活动列表
local areq = codeartsSignedRequest("GET",
CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil)
if not areq then return nil, "活动列表请求构造失败" end
local aresp, aerr = __llmsproxy_http(areq)
if aerr or not aresp or aresp.status ~= 200 then
return { done = true, kind = "failed",
message = "活动列表查询失败: " .. tostring(aerr or (aresp and aresp.status)) }
end
local aok, arec = pcall(json.decode, aresp.body or "")
local items = aok and type(arec) == "table" and ((arec.data or {}).items) or nil
if type(items) ~= "table" then
return { done = true, kind = "failed", message = "活动列表响应缺少 items" }
end
local act = nil
for _, it in ipairs(items) do
if type(it) == "table" and (it.type or "") == "USER_LOGIN" then act = it break end
end
if not act then
return { done = true, kind = "inactive", message = "未找到每日签到活动" }
end
-- ③ 幂等保护:本协议没有幂等键,预检不可省
if act.claimable ~= true then
local st = tostring(act.status or "")
-- 已领取态(参考 CLAIMED_STATUSES)
local claimed = st == "CLAIMED" or st == "RECEIVED" or st == "COMPLETED"
return { done = true, kind = claimed and "already-claimed" or "inactive",
message = claimed and "今天已领取" or ("当前不可领取(status=" .. st .. ")") }
end
local campaignId = act.campaignId or act.campaign_id or ""
if campaignId == "" then
return { done = true, kind = "failed", message = "活动缺少 campaignId,无法领取" }
end
-- ④ claim
local body = json.encode({ campaignId = campaignId, channel = "IDE" })
local creq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/claim", cred, body)
if not creq then return nil, "claim 请求构造失败" end
local cresp, cerr = __llmsproxy_http(creq)
if cerr or not cresp or cresp.status ~= 200 then
return { done = true, kind = "failed",
message = "领取失败: " .. tostring(cerr or (cresp and cresp.status)) }
end
-- ⑤ 服务端要求确认时补 confirm(判据 benefit.id 非空);confirm 失败不算整体失败
local msg = "已领取"
local cok, crec = pcall(json.decode, cresp.body or "")
local benefit = cok and type(crec) == "table" and (((crec.data or {}).benefit) or nil) or nil
if type(benefit) == "table" and benefit.id then
local fbody = json.encode({ id = benefit.id, channel = "IDE" })
local freq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody)
if freq then
local fresp, ferr = __llmsproxy_http(freq)
-- 只在真正失败时降级提示,不改变整体成败(积分已在待确认态)
if ferr or not fresp or fresp.status ~= 200 then
msg = "已领取(确认步骤失败,可能停在待确认)"
end
end
end
return { done = true, kind = "success", message = msg }
end
-- ---- 基础编码原语(必须在**所有使用者之前**定义)----
-- ⚠️ 本文件反复踩「local 前向引用」:local function 只从定义处向后可见,
-- 更早的代码引用它就是 nil。这三个被 CodeArts 的 PKCE/DPoP 通过 base64url
-- 与 hex 互转调用,必须放在 CodeArts 块之前。
local function hexToBin(h)
return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end))
end
local function b64url(s)
return (base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", ""))
end
-- b64urlRaw:无填充 base64url(JWS/JWK 用)
local function b64urlRaw(bin)
local e = base64_encode(bin)
return (e:gsub("+", "-"):gsub("/", "_"):gsub("=", ""))
end
-- ============================================================================
-- CodeArts(华为)登录:新式 IAM OAuth(PKCE + DPoP ES256)
-- ============================================================================
-- 逐段对齐参考 login.ts:runOAuthFlow + oauth.ts + channel-pack-rpc.ts 的
-- `codearts.startLogin()`。此前我把它当成"手填凭据"(paste 流),**是错的**:
-- 参考是完整 OAuth —— 起本地回调服务器(端口须 ≥10000)、打开 portal 授权页、
-- 收到 code 后走华为 STS 端点换 AK/SK/securityToken(带 DPoP 证明)。
--
-- 端点与常量(照抄):
-- PORTAL_AUTHORIZE_BASE = https://codearts.huaweicloud.com/portal/authorize
-- STS_TOKEN_ENDPOINT = https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens
-- CLIENT_ID = codearts-agent(也是 uri_scheme)
-- REDIRECT_PATH = /oauth/callback
-- LOGIN_PLUGIN_NAME = snap_AIIDE LOGIN_PLUGIN_VERSION = 5.2.0
-- OAUTH_THEME = 2(Dark) OAUTH_LOCALE = zh-cn
-- code_challenge_method = **SHA-256**(不是 S256!portal 以此识别 OAuth,
-- 错值会回退到旧 ticket 流程)
-- 旧流程回退(ticket):CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket
local CODEARTS_PORTAL_AUTHORIZE = "https://codearts.huaweicloud.com/portal/authorize"
local CODEARTS_STS_TOKEN = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"
local CODEARTS_CLIENT_ID = "codearts-agent"
local CODEARTS_REDIRECT_PATH = "/oauth/callback"
local CODEARTS_LOGIN_PLUGIN_NAME = "snap_AIIDE"
local CODEARTS_LOGIN_PLUGIN_VERSION = "5.2.0"
local CODEARTS_OAUTH_THEME = "2"
local CODEARTS_OAUTH_LOCALE = "zh-cn"
-- ES256 JWS 签名(照参考 signDpopJws):
-- header = {alg:"ES256", typ:"dpop+jwt", jwk:{kty,crv,x,y}}
-- payload = {htm, htu, iat, jti(32字节hex)}
-- sig = ES256(privKey, base64url(header) .. "." .. base64url(payload))
-- ⚠️ JWS 的 ES256 签名是 **raw r||s(各 32 字节)**,而 openssl 输出的是
-- DER(SEQUENCE{INTEGER r, INTEGER s})。必须转格式,否则服务端验签失败。
-- 用 openssl 签:把私钥写成 PEM,签名,再把 DER 转 raw。
local function codeartsDpopJws(key, htm, htu)
-- 1) 组 header/payload
local header = json.encode({ alg = "ES256", typ = "dpop+jwt", jwk = key.public })
local payload = json.encode({ htm = htm, htu = htu,
iat = (os.time and os.time() or 0),
jti = sha256_hex(tostring(os.time()) .. tostring(math.random(1e12)))
.. sha256_hex(tostring(math.random(1e12))):sub(1, 32) })
local signingInput = b64urlRaw(header) .. "." .. b64urlRaw(payload)
-- 2) openssl 签(PEM 私钥从 JWK 的 d 与 x/y 重建)
local dir = "/tmp/llmsproxy-codeartsdpop-" .. tostring(math.random(1e12))
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
local pem = dir .. "/k.pem"
local dh = hexToBin((key.private.d:gsub("-", "+"):gsub("_", "/") ..
string.rep("=", (4 - #key.private.d % 4) % 4)))
-- 用 openssl 的 ecparam -genkey 无法指定 d;改用 asn1 构造太脆。
-- 改为:生成新私钥后**用它的 d** 组 JWK(调用方须用同一把)。见 codeartsNewDpop。
os.execute("rm -rf " .. dir)
return nil, "内部错误:DPoP 签名需与密钥同时生成"
end
-- codeartsNewDpop():生成密钥对**并返回可签名的 PEM**(两者必须同源 ——
-- 公钥 JWK 必须来自签 JWS 的那把私钥,否则服务端按 header.jwk 验签会失败)。
-- ⚠️ 两个本机实测的坑(都已踩):
-- 1. **本机没有 xxd**(`command -v xxd` 为空)→ 用它取十六进制会静默得到
-- 空串。改用 `od -An -tx1`。
-- 2. `openssl ec -text` 打印 32 字节 priv 时**会截断**(实测只给 30 字节)→
-- `d` 必须用 `openssl asn1parse` 的 `[HEX DUMP]`(精确 32 字节);
-- 而 `tail -c 32` 拿到的是 SEC1 结构里**公钥 y**,不是 d。
-- 3. 公钥点用 `openssl ec -text` 的 pub 段(完整 04||x||y,130 hex),
-- 与 `-pubout` DER 末尾 65 字节交叉一致。
local function codeartsNewDpop()
local dir = "/tmp/llmsproxy-codeartsdpop-" .. tostring(math.random(1e12))
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
local pem = dir .. "/k.pem"
os.execute("openssl ecparam -name prime256v1 -genkey -noout -out " .. pem .. " 2>/dev/null")
local f = io.open(pem, "r")
if not f then os.execute("rm -rf " .. dir) return nil, "openssl EC 生成失败" end
local pemTxt = f:read("*a")
f:close()
-- d:asn1parse 的 OCTET STRING(权威、精确)
local dh = io.popen("openssl asn1parse -in " .. pem
.. " 2>/dev/null | grep -m1 'OCTET STRING' | awk -F': ' '{print $NF}'")
local dHex = dh and (dh:read("*a") or ""):gsub("%s", "") or ""
if dh then dh:close() end
-- 公钥点:ec -text 的 pub 段
local ph = io.popen("openssl ec -in " .. pem
.. " -text -noout 2>/dev/null | awk '/^pub:/{f=1;next} /^ASN1/{f=0} f' | tr -d ' :' | tr -d '\n'")
local pubHex = ph and (ph:read("*a") or ""):gsub("%s", "") or ""
if ph then ph:close() end
if #pubHex < 130 then
-- 兜底:-pubout DER 的末尾 65 字节(未压缩点)
local pf = io.popen("openssl ec -in " .. pem
.. " -pubout -outform DER 2>/dev/null | tail -c 65 | od -An -tx1 | tr -d ' \n'")
pubHex = pf and (pf:read("*a") or ""):gsub("%s", "") or ""
if pf then pf:close() end
end
if #dHex < 64 or #pubHex < 130 then
os.execute("rm -rf " .. dir)
return nil, "无法解析 EC 密钥(d=" .. #dHex .. " pub=" .. #pubHex .. ")"
end
local x = pubHex:sub(3, 66)
local y = pubHex:sub(67, 130)
local key = {
pem = pemTxt,
public = { kty = "EC", crv = "P-256", x = b64urlRaw(hexToBin(x)), y = b64urlRaw(hexToBin(y)) },
private = { kty = "EC", crv = "P-256", x = b64urlRaw(hexToBin(x)),
y = b64urlRaw(hexToBin(y)), d = b64urlRaw(hexToBin(dHex:sub(-64))) },
}
os.execute("rm -rf " .. dir)
return key, nil
end
-- DER(EC signature) → raw r||s(各 32 字节)。JWS ES256 必须是这个格式。
local function codeartsDerToRawSig(der)
-- SEQUENCE(0x30 len) INTEGER(0x02 rlen r) INTEGER(0x02 slen s)
local i = 1
if der:byte(i) ~= 0x30 then return nil end
i = i + 1
local l = der:byte(i)
if l and l > 0x80 then i = i + (l - 0x80) end
i = i + 1
if der:byte(i) ~= 0x02 then return nil end
i = i + 1
local rlen = der:byte(i); i = i + 1
local r = der:sub(i, i + rlen - 1); i = i + rlen
if der:byte(i) ~= 0x02 then return nil end
i = i + 1
local slen = der:byte(i); i = i + 1
local s = der:sub(i, i + slen - 1)
local function pad(v)
-- 去掉前导 0x00(DER 的正数填充),再左侧补零到 32 字节
while #v > 32 and v:byte(1) == 0 do v = v:sub(2) end
if #v < 32 then v = string.rep("\0", 32 - #v) .. v end
return v
end
return pad(r) .. pad(s)
end
-- codeartsDpopSign(key, htm, htu):产出 dpop+jwt(走真实 openssl 签名)。
local function codeartsDpopSign(key, htm, htu)
local header = json.encode({ alg = "ES256", typ = "dpop+jwt", jwk = key.public })
local payload = json.encode({ htm = htm, htu = htu,
iat = (os.time and os.time() or 0),
jti = sha256_hex(tostring(os.time()) .. tostring(math.random(1e12)))
.. sha256_hex(tostring(math.random(1e12))):sub(1, 32) })
local signingInput = b64urlRaw(header) .. "." .. b64urlRaw(payload)
local dir = "/tmp/llmsproxy-dpopsig-" .. tostring(math.random(1e12))
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
local pemPath, inPath = dir .. "/k.pem", dir .. "/in"
local pf = io.open(pemPath, "w")
if not pf then os.execute("rm -rf " .. dir) return nil, "无法写临时 PEM" end
pf:write(key.pem)
pf:close()
local inf = io.open(inPath, "w")
if not inf then os.execute("rm -rf " .. dir) return nil, "无法写签名输入" end
inf:write(signingInput)
inf:close()
local h = io.popen("openssl dgst -sha256 -sign " .. pemPath .. " " .. inPath
.. " 2>/dev/null | od -An -tx1 | tr -d ' \n'")
local derHex = h and (h:read("*a") or ""):gsub("%s", "") or ""
if h then h:close() end
os.execute("rm -rf " .. dir)
if derHex == "" then return nil, "openssl 签名失败" end
local raw = codeartsDerToRawSig(hexToBin(derHex))
if not raw then return nil, "DER→raw 转换失败" end
return signingInput .. "." .. b64urlRaw(raw), nil
end
-- codeartsRequestToken(body, key):向华为 STS 端点带 DPoP 换 token。
-- 照参考 requestToken:POST 表单 + DPoP 头;响应 credentials{access_key_id,
-- secret_access_key, security_token, expiration} + refresh_token。
-- 终态判定只认两件事(照参考):`invalid_grant` 或 error_code 含
-- `ExpiredRefreshToken`;**不认 InvalidDPoPHeader**(那是一次请求层面的拒绝,
-- 时钟偏差/重放/抖动都会触发,当终态会把完好账号一步标死)。
local function codeartsRequestToken(body, key)
local dpop, derr = codeartsDpopSign(key, "POST", CODEARTS_STS_TOKEN)
if not dpop then return nil, derr end
local parts = {}
for k, v in pairs(body) do
parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v)
end
local resp, err = __llmsproxy_http({
url = CODEARTS_STS_TOKEN, method = "POST",
headers = { ["DPoP"] = dpop, ["Content-Type"] = "application/x-www-form-urlencoded" },
body = table.concat(parts, "&"),
})
if err then return nil, "network error: " .. tostring(err) end
if not resp then return nil, "无响应" end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then rec = nil end
if resp.status ~= 200 or type(rec) ~= "table" or type(rec.credentials) ~= "table" then
local code = tostring(rec and rec.error_code or "")
local emsg = tostring(rec and (rec.error_msg or rec.error) or "")
local terminal = (rec and rec.error == "invalid_grant")
or code:find("ExpiredRefreshToken", 1, true) ~= nil
return nil, "token 请求失败: HTTP " .. tostring(resp.status) .. " " .. code .. " " .. emsg,
terminal
end
return rec, nil
end
-- codeartsCredentialFrom(token, pkce, key):组装持久化凭据(照参考
-- credentialFromTokenResponse:含 refresh 所需的 code_verifier 与 DPoP 私钥 JWK)。
local function codeartsCredentialFrom(token, pkce, key)
local c = token.credentials or {}
return {
access_key_id = c.access_key_id or "",
secret_access_key = c.secret_access_key or "",
security_token = c.security_token or "",
expires_at = c.expiration or "",
refresh_token = token.refresh_token,
code_verifier = pkce.verifier,
dpop_private_key_jwk = key.private,
}
end
-- buildCodeartsAuthorizeUrl(port, pkce, ticketId):照参考 buildOAuthLoginUrl 逐字拼。
-- ⚠️ 三处易错(参考注释都点名了):
-- 1. `code_challenge_method=SHA-256` —— 不是 RFC 的 S256!portal 以此识别
-- OAuth 授权,错值会**回退到旧 ticket 流程**(看起来"也能用",但不是我们要的)。
-- 2. 参数里**不得**出现 `auth_callback_url` —— portal 仅凭 port 参数构造回调,
-- 多余的回调地址会被视为异常并回退旧流程。
-- 3. 端口必须 ≥10000。
local function buildCodeartsAuthorizeUrl(port, pkce, ticketId)
return CODEARTS_PORTAL_AUTHORIZE
.. "?theme=" .. CODEARTS_OAUTH_THEME
.. "&locale=" .. CODEARTS_OAUTH_LOCALE
.. "&uri_scheme=" .. CODEARTS_CLIENT_ID
.. "&client_id=" .. CODEARTS_CLIENT_ID
.. "&port=" .. tostring(port)
.. "&code_challenge=" .. pkce.challenge
.. "&code_challenge_method=SHA-256"
.. "&ticket_id=" .. rfc3986Escape(ticketId)
.. "&plugin-name=" .. CODEARTS_LOGIN_PLUGIN_NAME
.. "&plugin-version=" .. CODEARTS_LOGIN_PLUGIN_VERSION
end
-- codeartsTicketPoll(ticketId, secret):旧流程回退时轮询 ticket 换凭据。
-- 照参考 pollForCredential:GET ?ticket_id&secret,头带 plugin-name/version,
-- 最多 120 次、每次间隔 1s,瞬时失败跳过。
local function codeartsTicketPoll(ticketId, secret, pluginName, pluginVersion)
local url = CODEARTS_CREDENTIAL_ENDPOINT .. "?ticket_id=" .. rfc3986Escape(ticketId)
.. "&secret=" .. rfc3986Escape(secret)
for _ = 1, 120 do
local resp = __llmsproxy_http({
url = url, method = "GET",
headers = { ["Content-Type"] = "application/json;charset=UTF-8",
["plugin-name"] = pluginName or CODEARTS_TICKET_PLUGIN_NAME,
["plugin-version"] = pluginVersion or CODEARTS_TICKET_PLUGIN_VERSION },
})
if resp and resp.status == 200 then
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" then
-- 两种形状(照参考 parseCredentialResponse)
local c = rec.credential
if type(c) == "table" and c.access and (c.securitytoken or c.securityToken) then
return { access_key_id = c.access, secret_access_key = c.secret or "",
security_token = c.securitytoken or c.securityToken,
expires_at = c.expires_at or c.expiresAt or "",
domain_id = rec.domain_id or "", user_id = rec.user_id or "",
user_name = rec.user_name or "" }
end
local r = rec.result
if type(r) == "table" and r.accessKeyId and r.securityToken then
return { access_key_id = r.accessKeyId, secret_access_key = r.secretAccessKey or "",
security_token = r.securityToken,
expires_at = r.expiration or r.expiresAt or "" }
end
end
end
-- 参考的 sleep(1000);单 VM 里不能长阻塞,故用较短的等待并显式让出
os.execute("sleep 1")
end
return nil, "CodeArts 登录超时"
end
-- 旧 ticket 流程(portal 判定新式不可用时回退)
local CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect"
local HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html"
local CODEARTS_CREDENTIAL_ENDPOINT =
"https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/login/ticket"
local CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains"
local CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3"
-- 生成 PKCE 配对(照参考 generatePkcePair:verifier = 48 随机字节的 base64url,
-- challenge = sha256(verifier) 的 base64url)。
-- 48 字节 base64url = 64 字符,符合 RFC 7636 的 43..128。
local function codeartsPkcePair()
-- 用 openssl 取 48 字节随机(Lua 的 math.random 熵不够)
local h = io.popen("openssl rand 48 2>/dev/null | base64 -w0")
local raw = h and h:read("*a") or ""
if h then h:close() end
raw = (raw:gsub("%s", ""))
local verifier = (raw:gsub("+", "-"):gsub("/", "_"):gsub("=", ""))
if #verifier < 43 then
return nil, "无法生成 PKCE verifier"
end
local challenge = b64urlRaw(hexToBin(sha256_hex(verifier)))
return { verifier = verifier, challenge = challenge }
end
-- ---- Qoder 每日活动领取(照参考 qoder-credits.ts 逐行移植)----
--
-- 参考用**抓包 + 用户报障**定位过三条会「误报」的陷阱,全部在此钉死:
-- ① 「无可领活动」必须报 inactive,**不能**报 already-claimed。
-- 旧实现 targets 为空就说"今天已领取" ⇒ 只要服务端没下发可领项
-- (含请求头不完整 / 未到刷新时间 / 本就无活动)界面就谎报已领,
-- 与官方 IDE 的"可领取"直接矛盾(用户报障原文:
-- 「没领过却显示今日已领取,去 IDE 看还能领」)。
-- ② 活动每日 **10:00(UTC+8)**刷新。刷新**之前**看到的 CLAIMED 属于
-- **昨天** —— 此时说"今天已领取"是谎报,且不可逆(用户以为今天不必再领)。
-- 必须用**算术平移**判 UTC+8,不能用本机时区(出差/改时区会判错)。
-- ③ 未开通的账号要给出**可操作**提示(去官方客户端登录一次),
-- 而不是含糊的"没有可领取的活动"。
-- 另:一个账号可能同时有**多个** CLAIM_BENEFIT 活动,逐个领取而非只领第一个。
-- 可被 set_endpoints 覆盖(测试指到假上游用)。
-- 可被 set_endpoints 覆盖(测试指到假上游用)。
QODER_SASH_BASE = "https://openapi.qoder.sh"
-- UTC+8 偏移(毫秒)。活动按 UTC+8 结算,口径与参考一致。
local QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000
local QODER_REFRESH_HOUR_UTC8 = 10
-- hasQoderCampaignRefreshedToday():今天的活动列表是否已刷新。
local function qoderCampaignRefreshed(nowMs)
-- nowMs 可注入(判据要覆盖"刷新前/刷新后"两个方向;本机恰是 UTC+8,
-- 只用真实时刻会让「用本机时区判」这个变异在这台机上不可判定 —— 判据不可达)。
nowMs = nowMs or ((os.time and os.time() or 0) * 1000)
-- **必须用算术平移**判 UTC+8,不能用 os.date("*t")(本机时区):
-- 活动按 UTC+8 结算,取本机时区在出差/改时区时会判错方向
-- (偏东提前把当天记为已处理、真漏领;偏西一天判两次)。
local utc8 = os.date("!*t", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
return (utc8.hour or 0) >= QODER_REFRESH_HOUR_UTC8
end
-- 读数字/字符串(容忍字符串形式与缺失),照参考 readNumber/readString
local function qReadNum(src, key)
if type(src) ~= "table" then return nil end
local v = src[key]
if type(v) == "number" then return v end
if type(v) == "string" and v ~= "" then return tonumber(v) end
return nil
end
local function qReadStr(src, key)
if type(src) ~= "table" then return nil end
local v = src[key]
if type(v) == "string" and v ~= "" then return v end
return nil
end
-- parseQoderCampaigns:解析活动列表响应
local function qoderParseCampaigns(body)
if type(body) ~= "table" or type(body.campaigns) ~= "table" then
-- 形状非法时仍可取 showCampaign/claimable(参考对非数组容忍)
if type(body) ~= "table" then return nil end
end
local out = { campaigns = {}, claimable = body.claimable == true, showCampaign = body.showCampaign == true }
for _, it in ipairs(body.campaigns or {}) do
local id = qReadStr(it, "campaignId")
if id then
out.campaigns[#out.campaigns + 1] = {
campaignId = id,
actionType = qReadStr(it, "actionType"),
claimStatus = qReadStr(it, "claimStatus"),
amount = qReadNum(it.benefit, "amount"),
}
end
end
return out
end
-- qoderLoadCampaigns(plat, cred):拉活动列表,返回 (parsed, nil) 或 (nil, err)
local function qoderLoadCampaigns(plat, cred)
local hdrs = nil
if plat.headers_fn then
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
if okh and type(h) == "table" then hdrs = h end
end
if not hdrs then
hdrs = { ["Authorization"] = "Bearer " .. tostring(cred.access_token or ""),
["Accept"] = "application/json", ["Cosy-ClientType"] = "10", ["User-Agent"] = "Qoder" }
end
local resp, err = __llmsproxy_http({
url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns", method = "GET", headers = hdrs,
})
if err then return nil, "活动列表查询失败: " .. tostring(err) end
if not resp or resp.status ~= 200 then
return nil, "活动列表查询失败: HTTP " .. tostring(resp and resp.status)
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil, "活动列表响应无法解析" end
return qoderParseCampaigns(rec), nil
end
-- qoderUsageRaw(plat, cred):取用量原始响应(判"未开通"用)
local function qoderUsageRaw(plat, cred)
local hdrs = nil
if plat.headers_fn then
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
if okh and type(h) == "table" then hdrs = h end
end
local resp = nil
if hdrs then
resp, _ = __llmsproxy_http({ url = QODER_SASH_BASE .. "/sash/api/v2/me/usage", method = "GET", headers = hdrs })
end
if not resp or resp.status ~= 200 then return nil end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil end
return rec
end
-- qoderClaimCampaign(plat, cred, campaignId):领取单个活动。
-- ⚠️ 幂等判据是响应体的 `replayed`,**不是** HTTP 状态码:重复领取同样回 200,
-- 但 replayed=true 且不含 benefit(只看状态码会把"今天已领"误报成"成功 +100")。
-- ⚠️ 请求体必须是**空串**(抓包实测 content-length: 0)。
local function qoderClaimCampaign(plat, cred, campaignId)
local hdrs = nil
if plat.headers_fn then
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
if okh and type(h) == "table" then hdrs = h end
end
if not hdrs then hdrs = { ["Cosy-ClientType"] = "10" } end
hdrs["Content-Type"] = "application/json"
local resp, err = __llmsproxy_http({
url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns/"
.. rfc3986Escape(campaignId) .. "/claim",
method = "POST", headers = hdrs, body = "",
})
if err then return { kind = "failed", message = tostring(err) } end
if not resp or resp.status ~= 200 then
return { kind = "failed", message = "领取失败: HTTP " .. tostring(resp and resp.status) }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return { kind = "failed", message = "领取响应无法解析" } end
if rec.replayed == true then return { kind = "already-claimed", message = "今天已领取" } end
local st = rec.status
if st ~= nil and st ~= "CLAIMED" then
return { kind = "failed", message = "领取未成功(status=" .. tostring(st) .. ")" }
end
local amount = qReadNum(rec, "amount") or qReadNum(rec.benefit, "amount") or 0
return { kind = "claimed", amount = amount }
end
-- qoderClaim(plat, tok, args):完整活动领取(多活动逐个领)。
local function qoderClaim(plat, tok, args)
local cred = (args and args.credential) or nil
if type(cred) ~= "table" then return nil, "缺少凭据对象" end
local parsed, lerr = qoderLoadCampaigns(plat, cred)
if not parsed then return { done = true, kind = "failed", message = lerr } end
local refreshed = qoderCampaignRefreshed()
local targets = {}
for _, c in ipairs(parsed.campaigns) do
if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMABLE" then
targets[#targets + 1] = c
end
end
if #targets == 0 then
-- ① 领过?—— 但**仅限刷新之后**(刷新前那条 CLAIMED 属于昨天)
local claimedBefore = false
for _, c in ipairs(parsed.campaigns) do
if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMED" then claimedBefore = true break end
end
if claimedBefore then
if not refreshed then
return { done = true, kind = "inactive",
message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新),"
.. "当前看到的是昨天那一轮,请稍后再来领取。" }
end
return { done = true, kind = "already-claimed", message = "今天已领取" }
end
-- ② 未开通?(两条判据同时满足,避免把"正在刷新"误报成未开通)
local usage = qoderUsageRaw(plat, cred)
local hasBenefit = false
for _, c in ipairs(parsed.campaigns) do
if c.actionType == "CLAIM_BENEFIT" then hasBenefit = true break end
end
local addOnMissing = false
if type(usage) == "table" then
-- ⚠️ 判「字段是否存在」而非「remaining 是否为 0」:
-- 后者对"额度用光"与"从未开通"不可区分。
local d = type(usage.data) == "table" and usage.data or usage
addOnMissing = (d.addOnQuota == nil)
end
if (not hasBenefit) and addOnMissing then
return { done = true, kind = "inactive", action_required = true,
message = "该账号尚未在 Qoder 侧开通每日领取(每日 100 Credits)。"
.. "请先用 Qoder 官方客户端登录一次该账号,开通后再回来领取。" }
end
return { done = true, kind = "inactive", message = "当前没有可领取的活动" }
end
-- 逐个领取
local total, firstErr, anyClaimed, allReplayed = 0, nil, false, true
for _, t in ipairs(targets) do
local r = qoderClaimCampaign(plat, cred, t.campaignId)
if r.kind == "claimed" then
anyClaimed = true
allReplayed = false
total = total + (tonumber(r.amount) or 0)
elseif r.kind == "already-claimed" then
-- 保持 allReplayed(这个活动此前已领)
else
allReplayed = false
if not firstErr then firstErr = r.message end
end
end
if anyClaimed then
return { done = true, kind = "success", message = "已领取 " .. tostring(total) .. " Credits" }
end
if allReplayed then
return { done = true, kind = "already-claimed", message = "今天已领取" }
end
return { done = true, kind = "failed", message = firstErr or "领取失败" }
end
-- ---- 有道 LobsterAI 每日活动领取(照参考 lobsterai-credits.ts 逐行移植)----
--
-- 三步流程,判定顺序把「业务正常状态」与「真失败」严格分开:
-- ① 槽位查询失败 → failed;slotState≠available 或无 activityCode → inactive
-- ② 上下文查询失败 → failed;claimedToday → already-claimed;
-- actions 不含 check_in → inactive
-- ③ POST .../{activityCode}/actions/check_in(带客户端幂等键)→ 成功
-- 参考注释特别强调:无可用活动是**正常业务状态**,不能显示成"领取失败"。
-- ---- Qoder 设备身份(Cosy-MachineToken / Cosy-MachineType)----
--
-- 逐行对齐参考 qoder-machine.ts。参考用抓包 + 消融实验定位过:**缺这对头时
-- 服务端不下发「可领取」的活动**(只回 1 条 VIEW_DETAILS 而非
-- CLAIM_BENEFIT/CLAIMABLE/amount:100),症状正是用户报的
-- 「没领过却显示今日已领取,去 IDE 看还能领」。要点:
-- 1. 两值**必须成对**出现,缺一即失效(消融表:全套 − 任一个 → 退回 1 条)。
-- 2. 主路径是实时调 `runtime-info`:参数顺序**必须是 `[environment, '--account-stdin']`**,
-- environment 取 '3'(对应 asar 的 'global' 分支)。漏环境值会拿到另一套身份。
-- stdin 传 `{"account":""}`,输出是含 `{` 的单行 JSON(取 machineToken/machineType)。
-- 3. 退路是磁盘缓存 `machine_token.json`(token/type 字段)。
-- 4. 两者都拿不到时**照常发请求**(不带这两个头)—— 保守降级,
-- 纯插件登录、未装桌面端的用户没有该文件,不能让整个积分功能报错。
-- 5. 缓存解析结果:拿不到时也记 null,不反复读盘/反复 spawn。
local QODER_MACHINE_ENVIRONMENT = "3"
local function qoderMachineCache()
plugin.state.qoderMachine = plugin.state.qoderMachine or { done = false }
return plugin.state.qoderMachine
end
-- 读单个 machine_token.json(形状不符/读取失败返回 nil)。
local function qoderParseMachineFile(path)
local f = io.open(path, "r")
if not f then return nil end
local raw = f:read("*a")
f:close()
local ok, rec = pcall(json.decode, raw or "")
if not ok or type(rec) ~= "table" then return nil end
local token, typ = rec.token, rec.type
if type(token) ~= "string" or token == "" then return nil end
if type(typ) ~= "string" or typ == "" then return nil end
return { token = token, type = typ }
end
-- 候选路径(照参考 candidatePaths:env 覆盖 → APPDATA → macOS → Linux)
local function qoderMachineCandidatePaths()
local out = {}
local override = os.getenv and os.getenv("QODER_MACHINE_TOKEN_PATH")
if type(override) == "string" and override ~= "" then return { override } end
local appdata = os.getenv and os.getenv("APPDATA")
if type(appdata) == "string" and appdata ~= "" then
for _, d in ipairs({ "Qoder", "Qoder CN" }) do
out[#out + 1] = appdata .. "/" .. d .. "/SharedClientCache/cache/machine_token.json"
end
end
local home = os.getenv and (os.getenv("HOME") or os.getenv("USERPROFILE")) or nil
if type(home) == "string" and home ~= "" then
for _, d in ipairs({ "Qoder", "Qoder CN" }) do
out[#out + 1] = home .. "/Library/Application Support/" .. d
.. "/SharedClientCache/cache/machine_token.json"
out[#out + 1] = home .. "/.config/" .. d .. "/SharedClientCache/cache/machine_token.json"
end
end
return out
end
-- 定位 runtime-info:<home>/<dataDir>/.bin/umid-*/runtime-info(目录名带哈希,必须枚举)
local function qoderFindRuntimeInfo()
if not (os.getenv and os.getenv("HOME")) then return nil end
local exeName = "runtime-info"
for _, dirName in ipairs({ ".qoder", ".qoder-cn" }) do
local binDir = os.getenv("HOME") .. "/" .. dirName .. "/.bin"
local h = io.popen("ls -1 " .. binDir .. " 2>/dev/null")
if h then
local found
for line in h:lines() do
if line:sub(1, 5) == "umid-" then found = line break end
end
h:close()
if found then
local exe = binDir .. "/" .. found .. "/" .. exeName
local f = io.open(exe, "rb")
if f then f:close() return exe end
end
end
end
return nil
end
-- 实时生成(照参考:参数顺序 [env, '--account-stdin'],stdin 传 {"account":""})
local function qoderReadFromRuntimeInfo()
local exe = qoderFindRuntimeInfo()
if not exe then return nil end
-- 用临时文件喂 stdin:命令行传 JSON 的引号在 shell 下不可靠
local tmp = "/tmp/llmsproxy-qoder-stdin-" .. tostring(math.random(1e12))
local f = io.open(tmp, "w")
if not f then return nil end
f:write('{"account":""}')
f:close()
local cmd = ("timeout 20 %s %s %s < %s 2>/dev/null"):format(
exe, QODER_MACHINE_ENVIRONMENT, "--account-stdin", tmp)
local h = io.popen(cmd)
local out = h and h:read("*a") or ""
if h then h:close() end
os.remove(tmp)
if out == "" then return nil end
-- 输出形如(单行 JSON):取第一个 '{' 起
local i = out:find("{", 1, true)
if not i then return nil end
local ok, rec = pcall(json.decode, out:sub(i))
if not ok or type(rec) ~= "table" then return nil end
local token, typ = rec.machineToken, rec.machineType
if type(token) ~= "string" or token == "" then return nil end
if type(typ) ~= "string" or typ == "" then return nil end
return { token = token, type = typ }
end
-- qoderMachineIdentity():返回 {token,type} 或 nil(拿不到时降级,不报错)。
qoderMachineIdentity = function()
local c = qoderMachineCache()
if c.done then return c.identity end
local id = qoderReadFromRuntimeInfo()
if not id then
for _, path in ipairs(qoderMachineCandidatePaths()) do
id = qoderParseMachineFile(path)
if id then break end
end
end
c.done = true
c.identity = id
return id
end
-- 可被 set_endpoints 覆盖(测试指到假上游用)。
LOBSTERAI_ACTIVITY_BASE = "https://lobsterai-server.youdao.com"
local function lobsteraiActivityHeaders(cred)
return {
["Authorization"] = "Bearer " .. tostring(cred.access_token or ""),
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["User-Agent"] = "LobsterAI/0.1.0",
}
end
-- lobsteraiClaim(plat, tok, args):完整 3 步领取。
-- 与 codearts 同款:自己发多个请求,用 done 哨兵回给 claim_daily。
local function lobsteraiClaim(plat, tok, args)
local cred = (args and args.credential) or nil
if type(cred) ~= "table" then return nil, "缺少凭据对象" end
local hdrs = lobsteraiActivityHeaders(cred)
-- clientVersion 必须是**动态真值**(与交换/续期/签到同一口径)
local cver = lobsteraiClientVersion(plat)
-- ① 槽位
local slotURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/slot?placement=desktop_sidebar"
.. "&clientVersion=" .. rfc3986Escape(cver)
.. "&containerApiVersion=2&platform=win32"
local sresp, serr = __llmsproxy_http({ url = slotURL, method = "GET", headers = hdrs })
if serr or not sresp or sresp.status ~= 200 then
return { done = true, kind = "failed",
message = "活动槽位查询失败: " .. tostring(serr or (sresp and sresp.status)) }
end
local sok, srec = pcall(json.decode, sresp.body or "")
if not sok or type(srec) ~= "table" or srec.code ~= 0 or type(srec.data) ~= "table" then
return { done = true, kind = "failed", message = "活动槽位信封异常" }
end
local act = type(srec.data.activity) == "table" and srec.data.activity or {}
local slotState = tostring(srec.data.slotState or "")
local activityCode = tostring(act.activityCode or "")
local configRevision = tonumber(act.configRevision) or 0
if slotState ~= "available" or activityCode == "" then
-- 正常业务状态,不是失败
return { done = true, kind = "inactive", message = "无可用活动(slotState=" .. slotState .. ")" }
end
-- ② 上下文
local ctxURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/"
.. rfc3986Escape(activityCode) .. "/context?configRevision=" .. tostring(configRevision)
local cresp, cerr = __llmsproxy_http({ url = ctxURL, method = "GET", headers = hdrs })
if cerr or not cresp or cresp.status ~= 200 then
return { done = true, kind = "failed",
message = "活动上下文查询失败: " .. tostring(cerr or (cresp and cresp.status)) }
end
local cok, crec = pcall(json.decode, cresp.body or "")
if not cok or type(crec) ~= "table" or crec.code ~= 0 or type(crec.data) ~= "table" then
return { done = true, kind = "failed", message = "活动上下文信封异常" }
end
local state = type(crec.data.state) == "table" and crec.data.state or {}
if state.claimedToday == true then
return { done = true, kind = "already-claimed", message = "今天已签到" }
end
local hasCheckIn = false
for _, a in ipairs(crec.data.actions or {}) do
if a == "check_in" then hasCheckIn = true break end
end
if not hasCheckIn then
return { done = true, kind = "inactive", message = "当前不可签到" }
end
-- ③ 领取(客户端幂等键)
local body = json.encode({ configRevision = configRevision,
idempotencyKey = newToken():sub(1, 8) .. "-" .. newToken():sub(9, 12) .. "-4"
.. newToken():sub(14, 16) .. "-8" .. newToken():sub(18, 20) .. "-" .. newToken():sub(21, 32),
payload = {} })
local url = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/"
.. rfc3986Escape(activityCode) .. "/actions/check_in"
local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = hdrs, body = body })
if err or not resp or resp.status ~= 200 then
return { done = true, kind = "failed",
message = "领取请求失败: " .. tostring(err or (resp and resp.status)) }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or rec.code ~= 0 then
local why = (ok and type(rec) == "table") and tostring(rec.msg or rec.message or rec.code) or "信封异常"
return { done = true, kind = "failed", message = "领取失败: " .. why }
end
-- 积分字段三级回退(参考:creditsGranted → rewardCredits → credits)
local result = type(rec.data) == "table" and type(rec.data.result) == "table" and rec.data.result or {}
local credit = tonumber(result.creditsGranted) or tonumber(result.rewardCredits) or tonumber(result.credits)
local msg = "已签到"
if credit then msg = msg .. ",获得 " .. tostring(credit) .. " 积分" end
return { done = true, kind = "success", message = msg }
end
-- 绑定 codearts 的领取钩子。它自己跑完 4 步流程(账户→活动→claim→confirm),
-- 用 done 哨兵返回,claim_daily 识别后不再发送单个请求。
do
for _, p in ipairs(PLATFORMS) do
if p.id == "codearts" then
p.claim_fn = function(plat, tok, args) return codeartsClaim(plat, tok, args) end
break
end
end
for _, p in ipairs(PLATFORMS) do
if p.id == "lobsterai" then
p.claim_fn = function(plat, tok, args) return lobsteraiClaim(plat, tok, args) end
break
end
end
for _, p in ipairs(PLATFORMS) do
if p.id == "qoder" then
p.claim_fn = function(plat, tok, args) return qoderClaim(plat, tok, args) end
break
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(无 +/= )。
-- ---- 讯飞账号(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 等价 + 补转 ! ' ( ) *
rfc3986Escape = function(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
-- 凭据有两种消费方式,取决于平台的适配器:
-- * 多数平台:适配器发 `Authorization: Bearer <access_token>`
-- → 这里必须交出**裸 access token**(不能把整个 JSON 当 key 发上去)
-- * 签名平台(codearts):适配器在 build_headers 里**自己从完整凭据 JSON
-- 解 AK/SK 并签名** → 这里必须交出完整凭据 JSON,交出裸 token 反而
-- 让适配器签不出来(真实缺陷:codearts 账号加了却不出源,因为
-- credentialOf 找不到 access_token 而静默 return)
-- 用 plat.credential_shape 声明,默认 "token"(保持既有平台行为不变)。
local tok, err
if plat.credential_shape == "full" then
tok = __llmsproxy_secrets.get(acct.credentialRef)
err = (tok == nil or tok == "") and "无凭据" or nil
else
tok = credentialOf(acct.credentialRef)
err = (tok == nil or tok == "") and "无凭据" or nil
end
if err 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 src = {
name = name,
base_url = plat.base_url,
adapter = plat.adapter or "openai",
api_key = tok,
max_concurrent = 2,
models = models,
}
-- 平台的 chat 端点常与 base_url 不是简单拼接(Gemini 走私有
-- /v1internal: 动词、zcode 只有一条 plan 路径)。声明式覆盖,内核只当
-- 不透明字符串用 —— 不需要内核知道任何厂商知识。
if plat.endpoint then src.endpoint = plat.endpoint end
if plat.endpoint_stream then src.endpoint_stream = plat.endpoint_stream end
if plat.headers_extra then src.headers = plat.headers_extra end
local r, aerr = __llmsproxy_kernel.call("add_source", { source = src })
-- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会
-- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。
if aerr then
logEvent("接入源失败 " .. name .. ": " .. tostring(aerr))
elseif r and r.json and r.json.ok == false then
logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error))
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 分钟,过期会话在读取时惰性清理
newToken = function()
-- 用 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)
-- 同一道理:callback 流会在服务端起一整套浏览器栈(Xvfb/chromium/VNC)。
-- 会话过期就是「用户已经放弃」,必须把栈收掉 —— 否则每次放弃登录都留一套
-- 进程常驻(实测:重复跑测试/重复发起登录会攒出 7 套 websockify+x11vnc)。
if s.owns_browser then
local cur = browserStack()
if cur.owner == id then
reapBrowser()
end
end
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 会撞端口。
-- killProcsMatching(needle):杀掉 cmdline 里含 needle 的进程(含自己不算)。
--
-- 为什么不用记录的 PID:`setsid` 在「已是进程组长」时会 fork,shell 的作业控制
-- 也会另开进程组 —— 于是 `$!` 未必是真正的服务进程。实测后果:kill 记录的 PID
-- 杀不掉 chromium 的 zygote/renderer,它们继续活着并重建 profile 目录,
-- 一次 core 全包跑出 7 套残留栈(websockify×7 + x11vnc×7 + 7 个 profile 目录)。
--
-- 为什么不用 pkill -f:那个模式会出现在**我们自己这条 shell 命令行**里 →
-- pkill 把执行它的 shell 一起杀掉(真实踩过两次,命令链直接中断)。
-- 这里直接读 /proc(不经 shell 传模式),因此不会自匹配。
local function killProcsMatching(needle)
if not needle or needle == "" then return 0 end
local h = io.popen("ls /proc 2>/dev/null")
if not h then return 0 end
local victims = {}
for entry in h:lines() do
if entry:match("^%d+$") then
local f = io.open("/proc/" .. entry .. "/cmdline", "r")
if f then
local raw = f:read("*a") or ""
f:close()
-- cmdline 以 NUL 分隔;换成空格以便匹配
local cmd = raw:gsub("%z", " ")
if cmd ~= "" and cmd:find(needle, 1, true) then
victims[#victims + 1] = entry
end
end
end
end
h:close()
-- 同样先 TERM 再 KILL:chromium 也持有 X 的 SHM 段,强杀会留孤儿段
-- (见 reapBrowser 关于 shmmni 耗尽的说明)。
for _, pid in ipairs(victims) do
os.execute("kill -TERM " .. pid .. " 2>/dev/null")
end
if #victims > 0 then
os.execute("sleep 0.3")
end
for _, pid in ipairs(victims) do
os.execute("kill -9 " .. pid .. " 2>/dev/null")
end
return #victims
end
local function reapBrowser()
local b = browserStack()
local sess = b.session
local disp = b.display or (sess and sess.display)
local prof = b.profile
-- 按**唯一标识**回收(显示器号 / profile 目录),不依赖记录的 PID ——
-- 理由见 killProcsMatching 注释($! 未必是真正的服务进程,只信它会留下
-- zygote/renderer 与 x11vnc/websockify 残留)。这些标识都是本次会话独有的,
-- 因此不会误杀别的会话。
-- 按 **profile 目录**匹配杀 chromium 及其子进程:tag 是「时间+随机」故全局唯一,
-- 不会误杀别的会话。**不能**按显示器号匹配 —— 显示器号会在空出后被复用,
-- 于是 A 会话的收尾会把 B 会话正在用的 `Xvfb :91` 一起杀掉
-- (实测后果:chromium 报 "X connection error",栈起不来)。
if prof then
killProcsMatching("user-data-dir=" .. prof)
end
-- Xvfb / x11vnc / websockify 命令行里没有唯一标识,只信记录的 PID。
--
-- **先 SIGTERM 再 SIGKILL**,不能直接 -9:X 服务的 MIT-SHM 共享内存段
-- 被强杀时无人释放,会变成系统级孤儿段(nattch=0 但占着 shmmni 名额)。
-- 实测:单个 Xvfb 约占 84 段,内核 kernel.shmmni=4096 → 约 48 次异常退出
-- 就把它用满,此后**宿主上任何**新进程 shmget 都返回
-- “No space left on device”(x11vnc 就从 0.2s 能起变成永远起不来)。
-- 优雅终止让 X 自己清理这些段。
for _, pid in ipairs(b.pids or {}) do
os.execute("kill -TERM " .. tostring(pid) .. " 2>/dev/null")
end
if #(b.pids or {}) > 0 then
os.execute("sleep 0.4")
end
for _, pid in ipairs(b.pids or {}) do
os.execute("kill -9 " .. tostring(pid) .. " 2>/dev/null")
end
-- 杀掉自己占的显示器后必须清掉它的锁与 socket。
-- 为什么必需:Xvfb 被 SIGKILL(本函数就是这么杀的)来不及清理
-- /tmp/.X<n>-lock,留着就成了**陈旧锁**。而选号逻辑是「锁/socket 存在就当被占」,
-- 于是每个被 KILL 的显示器都会永久烧掉一个号 —— 反复跑测试/反复登录,
-- 91 起始的号会被耗光,之后每次都挑中 91 并报
-- “Server is already active for display 91”(真实踩过:41/41 全占)。
-- 用 b.display 而不是 b.session.display:**失败路径也在 session 赋值之前就
-- 调本函数**(Xvfb 起不来、端口未就绪…),那时只有 display 已知。
local disp = b.display or (b.session and b.session.display)
if disp then
os.remove("/tmp/.X" .. tostring(disp) .. "-lock")
os.remove("/tmp/.X11-unix/X" .. tostring(disp))
end
-- profile 目录也一并清:tag 是唯一的,不清就会无限累积(每次登录留一份
-- 几十 MB 的 chromium profile 在 /tmp)。
if b.profile then
os.execute("rm -rf " .. b.profile .. " 2>/dev/null")
end b.profile = nil
b.display = nil
b.pids = {}
b.session = nil
end
-- xdisplayBusy(n):显示器号是否真被占。
-- 判存在用 `[ -e ]` 而不是 io.open:X socket 是 socket 文件,io.open 对它
-- 返回 nil(打不开)→ 会误判“空闲”→ 又选中被占的号(这个坑真实踩过)。
local function xdisplayBusy(n)
local lock = "/tmp/.X" .. tostring(n) .. "-lock"
local ok = os.execute('[ -e "' .. lock .. '" ] 2>/dev/null')
local hasLock = (ok == true or ok == 0)
if not hasLock then
local sok = os.execute('[ -e "/tmp/.X11-unix/X' .. tostring(n) .. '" ] 2>/dev/null')
return sok == true or sok == 0
end
-- 锁在,但记录的 PID 可能早死了 —— 那就是陈旧锁,**可以回收**。
-- 不回收的话号会越用越少(见 reapBrowser 注释)。
local f = io.open(lock, "r")
if not f then return true end
local pid = (f:read("*l") or ""):gsub("%s", "")
f:close()
if pid == "" then return true end
-- 存活判定用 /proc/<pid>,**不用 os.execute("kill -0 …")**:
-- 后者靠 shell 退出码,而这个 VM 里 os.execute 的返回值形态不可靠
-- (判据实测咬了:陈旧锁没被回收 → 号仍然占着)。
-- /proc/<pid> 存在即为进程存在(僵尸也在,偏保守,不会误回收活着的号)。
if pid:match("^%d+$") then
local alive = io.open("/proc/" .. pid .. "/stat", "r")
if alive then
alive:close()
return true
end
else
return true -- 锁里不是数字(异常内容),当被占处理
end
-- 陈旧锁:清掉它和可能残留的 socket,让这个号重新可用。
os.remove(lock)
os.remove("/tmp/.X11-unix/X" .. tostring(n))
return false
end
-- pickFreeX(start, span):挑一个真正空闲的显示器号。
-- 与旧 pickFree(91, 40) 的差别:① 陈旧锁会被回收(而不是永久占号);
-- ② 扫不通时**如实报错**,不再 `return start` —— 返回一个已知被占的号只会
-- 让 Xvfb 报一句难以归因的 “already active for display N”。
local function pickFreeX(start, span)
for n = start, start + span do
if not xdisplayBusy(n) then return n end
end
return 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, random
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 里,靠路径区分);其余平台必须带。
# 各平台的回调查验规则不同,**不能一刀切**(照参考逐个平台看):
# /authorize Trae:token 直接放 query,**不带 state**
# /oauth/callback CodeArts:带 code + ticket_id(无 state),旧流程回退带 secret
# 其余(有道 /auth/callback、Gemini /oauth-callback):标准授权码 + state 校验
require_state = (prefix != "/authorize" and prefix != "/oauth/callback")
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 ""
# codearts 旧流程回退只带 secret;authorize 只带 token —— 都不要求 code
need_code = (prefix != "/authorize" and prefix != "/oauth/callback")
if need_code 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
# minPort:某些平台(CodeArts)要求回调端口 >= 10000,低于此值 portal 会拒绝。
# 照参考 listenOnCallbackPort:bind(0) 拿到的端口若过小,关掉后用 [minPort,65535]
# 的随机端口重试。
min_port = int(sys.argv[5]) if len(sys.argv) > 5 else 0
port = 0
try:
v6 = V6Server(("::1", 0), H)
port = v6.server_address[1]
if min_port and port < min_port:
v6.server_close()
port = 0
else:
SERVERS.append(v6)
except Exception:
pass
if port == 0:
for _ in range(6):
cand = random.randint(min_port, 65535) if min_port else 0
try:
v6 = V6Server(("::1", cand), H)
port = v6.server_address[1]
SERVERS.append(v6)
break
except Exception:
port = 0
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, minPort)
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 %s >/tmp/llmsproxy-loopback-%s.log 2>&1 & echo $!"):format(
script, cbPath, outFile, wantState, portFile, tostring(minPort or 0), 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
-- 每次会话一个唯一标记:用于 chromium 的 user-data-dir 与日志文件名。
-- 为何不能用 seq:seq 在插件重启后从 1 重新开始,于是会**复用同一个
-- user-data-dir**,而里面残留的 `SingletonLock` 会让 chromium 认为
-- “已有实例在跑”——它只打印一句 `Opening in existing browser session.` 就退出,
-- 新端口永不监听 → 15s 后报「chromium 端口未就绪」(真实踩过)。
-- 显示器锁是同一类坑(见 pickFreeX),此处用不可复用的标记从根上避开。
local tag = tostring(os.time and os.time() or 0) .. "-" .. newToken():sub(1, 8)
-- 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
-- 显示器号:用 pickFreeX(会回收陈旧锁,且扫不到就如实报错),
-- 不再用“存在就当被占”的 inline 判定 —— 那会把每个被 KILL 的显示器永久烧掉。
local disp = pickFreeX(91, 200)
if not disp then
return nil, "没有可用的显示器号(/tmp/.X*-lock 全被占用)"
end
-- 立即记下:后续任何一步失败都会 reapBrowser(),它靠 b.display 才能清掉
-- 这个号的锁(否则 Xvfb 未起来就失败时,残留锁会把号白烧掉)。
local bs = browserStack()
bs.display = disp
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
-- chromium 数据目录必须**每次会话全新的**:复用同一目录时,上一次被强杀
-- 留下的 SingletonLock 会让它直接退出(见 tag 的注释)。这里不只是换新目录,
-- 还把可能残留的单例锁与陈旧 profile 清掉,双保险。
local profileDir = "/tmp/llmsproxy-ch-" .. tag
os.execute("rm -rf " .. profileDir .. " 2>/dev/null")
-- 顺带清掉**陈旧**的 profile 目录:正常路径由 reapBrowser 清,但进程被强杀
-- (go test 超时、插件崩)时来不及清,而 tag 唯一意味着它们会一直累积。
-- 只清 6 小时前的,不会碰到别的会话正在用的。
os.execute("find /tmp -maxdepth 1 -name 'llmsproxy-ch-*' -mmin +360 -exec rm -rf {} + 2>/dev/null")
-- 记下来供 reapBrowser 收尾清理(失败路径也要清,否则 tag 唯一=无限累积)。
browserStack().profile = profileDir
local steps = {
{ "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%s.log 2>&1 & echo $!"):format(disp, tag), nil, 0, 0 },
{ "chromium", ("%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s "
.. "--no-first-run --disable-gpu --remote-debugging-port=%d --remote-allow-origins=* "
.. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%s.log 2>&1 & echo $!"):format(
chromium, profileDir, cdpPort, disp, url, tag), "127.0.0.1", cdpPort, 15000 },
{ "x11vnc", ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet "
.. ">/tmp/llmsproxy-x11vnc-%s.log 2>&1 & echo $!"):format(
disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, tag), "127.0.0.1", vport, 8000 },
{ "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%s.log 2>&1 & echo $!"):format(
novnc, bind, wport, vport, tag), 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" or plat.id == "codearts" 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
-- CodeArts:生成 PKCE 与 ticket_id,回调端口须 ≥10000(portal 拒绝低端口),
-- 登录 URL 用**专用构建函数**(含 SHA-256 而非 S256,且不得有 auth_callback_url)。
if plat.id == "codearts" then
local pkce, perr = codeartsPkcePair()
if not pkce then
sess.state = "failed"
out.error = "PKCE 生成失败: " .. tostring(perr)
return { status = 200, json = out }
end
local key, kerr = codeartsNewDpop()
if not key then
sess.state = "failed"
out.error = "DPoP 密钥生成失败: " .. tostring(kerr)
return { status = 200, json = out }
end
sess.codearts = { pkce = pkce, key = key, ticket_id = newToken():sub(1, 64) }
sess.min_port = (plat.authorize and plat.authorize.min_port) or 10000
end
local minPort = nil
if plat.id == "codearts" then
minPort = (plat.authorize and plat.authorize.min_port) or 10000
end
local cb_url, cb_err = loopbackReceiver(sess.id, cbPath, minPort)
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 plat.id == "codearts" and sess.codearts then
-- 用回调服务器**实际监听的端口**(≥10000),而不是网关端口 ——
-- portal 凭 port 参数构造回调地址,错了就收不到 code。
local cbPort = tonumber(sess.redirect_uri and sess.redirect_uri:match(":(%d+)/"))
out.url = buildCodeartsAuthorizeUrl(cbPort or port, sess.codearts.pkce,
sess.codearts.ticket_id)
elseif 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 cur = browserStack()
if cur.session then reapBrowser() end
cur.owner = sess.id
sess.owns_browser = true
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。
lobsteraiClientVersion = function(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 里);CodeArts 也只带 code
-- (无 state)—— 两者都不校验 state。其余平台必须 state 匹配。
local stateOK = (s.platform == "trae") or (s.platform == "codearts")
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 == "codearts" then
-- CodeArts 新式 IAM OAuth:回调带 code → 走 STS 换 AK/SK(带 DPoP);
-- 旧流程回退:回调带 secret → 轮询 ticket 端点。
local ca = s.codearts or {}
local secret = cb and (cb.secret or "")
if secret ~= "" then
local credTbl, terr = codeartsTicketPoll(ca.ticket_id, secret,
CODEARTS_LOGIN_TICKET_NAME or nil, nil)
if not credTbl then
cerr = terr
else
credential, account = json.encode(credTbl), (credTbl.user_name or "codearts")
end
elseif code ~= "" and ca.pkce and ca.key then
local cbPort = tonumber(s.redirect_uri and s.redirect_uri:match(":(%d+)/")) or 0
local token, terr = codeartsRequestToken({
client_id = CODEARTS_CLIENT_ID,
code = code,
code_verifier = ca.pkce.verifier,
grant_type = "authorization_code",
redirect_uri = "http://127.0.0.1:" .. tostring(cbPort) .. CODEARTS_REDIRECT_PATH,
}, ca.key)
if not token then
cerr = terr
else
local cred = codeartsCredentialFrom(token, ca.pkce, ca.key)
credential = json.encode(cred)
account = ca.ticket_id and ("codearts-" .. tostring(ca.ticket_id):sub(1, 8)) or "codearts"
end
else
cerr = "回调既无 code 也无 secret(无法完成 CodeArts 登录)"
end
elseif 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
-- 有 claim_fn 的平台即使没声明 checkin 端点也能领(如 codearts 的 4 步流程、
-- 有道的 3 步活动领取 —— 它们自己发多个请求,不走 checkin 表驱动)。
-- 此前这里硬要求 plat.checkin,导致这两个平台的钩子**永远走不到**。
if (not plat.checkin or not plat.checkin.url) and not plat.claim_fn 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 and plat.checkin.url or "",
method = (plat.checkin and plat.checkin.method) or "POST",
headers = (plat.checkin and plat.checkin.headers)
or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
body = (plat.checkin and 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
-- done 哨兵:平台自己跑完了多步流程(如 codearts 的 账户→活动→claim→confirm),
-- 不再由通用路径发送单个请求。
if type(spec) == "table" and spec.done then
local acct0 = acctEntry or plugin.state.accounts[plat.id] or {}
acct0.last_claim = os.time and os.time() or 0
acct0.last_claim_status = spec.kind == "success" and 200 or 0
acct0.last_claim_summary = spec.message
if acctEntry then poolAdd(plat.id, acctEntry) else plugin.state.accounts[plat.id] = acct0 end
logEvent("领取结束 " .. plat.id .. ": " .. tostring(spec.kind))
local outcome = { kind = spec.kind, message = spec.message }
-- action_required 必须透传:它是给 UI 的**显式信号**("这一步需要用户去
-- 官方客户端操作"),不能靠文案猜。参考 qoder-credits 明确定义了这个字段。
if spec.action_required then outcome.action_required = true end
return { status = 200, json = { ok = spec.kind == "success", outcome = outcome } }
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)
-- models_hidden:内部子 agent(照参考 isHidden 语义)不进用户可选列表。
local hidden = {}
for _, m in ipairs(plat.models_hidden or {}) do hidden[m] = true end
-- models_free:参考的 isFree 字段(官方语义:匿名/免费可用)。
local free = {}
for _, m in ipairs(plat.models_free or {}) do free[m] = true end
local out = {}
for i, m in ipairs(effectiveModels(plat.id)) do
if not hidden[m] then
out[#out+1] = { id = m, name = m, disabled = disabled[m] and true or false,
isFree = free[m] and true or false }
end
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.qoder_sash_base) == "table" and args.qoder_sash_base.url then
QODER_SASH_BASE = tostring(args.qoder_sash_base.url)
changed[#changed+1] = "qoder_sash_base.url"
end
if type(args.lobster_activity_base) == "table" and args.lobster_activity_base.url then
LOBSTERAI_ACTIVITY_BASE = tostring(args.lobster_activity_base.url)
changed[#changed+1] = "lobster_activity_base.url"
end
-- codearts_sts:CodeArts 的华为 STS token 端点(PKCE 换 AK/SK 用)。
-- 测试把它指到假上游,才能端到端验证 code 交换而不打真华为云。
if type(args.codearts_sts) == "table" and args.codearts_sts.url then
CODEARTS_STS_TOKEN = tostring(args.codearts_sts.url)
changed[#changed+1] = "codearts_sts.url"
end
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()
browserStack().owner = nil
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.__qoder_machine_probe:只给测试用 —— 走真实的 headers_fn 路径,
-- 返回它给出的设备身份头,便于验证「成对出现」与「拿不到时降级」。
-- plugin.__codearts_dpop_probe:只给测试用 —— 生成一把 DPoP 密钥并签一个
-- 真实 JWS,供判据用标准 ES256 验签(验证 DER→raw 转换与 header/payload 形状)。
-- ============================================================================
-- CodeArts 专用适配器(插件在加载时**自己装**)
-- ============================================================================
-- 为什么需要它:CodeArts 的推理端点要 **华为云 AK/SK 签名**
-- (SDK-HMAC-SHA256),且签名必须覆盖 method/uri/query/canonical-headers/
-- payload-hash —— 这不是"改个 endpoint 或加个静态头"能表达的,通用 openai
-- 适配器做不了。
--
-- 为什么由插件装而不是做成内置适配器:CodeArts 的接入是本插件引入的能力
-- (登录 + 凭据 + 源注册都在这里),且内核纪律是"零厂商名"——内置适配器
-- 里出现 codearts 就破了这条线。插件自装正好把厂商知识留在插件域。
--
-- 与内置适配器的差别(照参考 llm-adapter.ts 的 CodeArtsAdapter):
-- * build_headers 动态签名(凭据从 meta.api_key 解出 AK/SK/ST)
-- * 端点是 /api/v2/chat/completions(含 /api/v2 前缀)
-- * Agent-Type: PromptCenter / X-Language: zh-cn(**签名后**追加,
-- 不参与 canonical —— 参考注释点名)
local CODEARTS_ADAPTER_SRC = [==[
local adapter = {}
adapter.name = "codearts"
adapter.version = "1.0.0"
-- ⚠️ 源注册时会用平台声明的 endpoint 覆盖;这里保持一致以免两处漂移。
adapter.endpoint = "/api/v2/chat/completions"
-- 华为云 SDK-HMAC-SHA256 签名。逐条对齐参考 sign.ts:signRequestHuawei:
-- canonical = METHOD\nURI\nQUERY\nCANONICAL_HEADERS\n\nSIGNED_HEADERS\nPAYLOAD_HASH
-- string_to_sign = "SDK-HMAC-SHA256\n" .. x-sdk-date .. "\n" .. sha256(canonical)
-- signature = HMAC-SHA256(SK, string_to_sign)
-- 三个易错点(参考注释都点名了):
-- 1. URI 末尾**必须补 /**(否则签名不匹配)
-- 2. Authorization 用 `Access=` 不是 `Credential=`
-- 3. Agent-Type / X-Language 是**签名后追加**的,不进 canonical
-- signed_parts:返回 (headers, canonical, string_to_sign)。
local function signed_parts(method, url, cred, body)
local ak = cred.access_key_id or cred.access_key or ""
local sk = cred.secret_access_key or cred.secret_key or ""
local st = cred.security_token or ""
if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end
local host = url:match("^https?://([^/]+)")
local uri = url:match("^https?://[^/]+(/[^?]*)") or "/"
if uri:sub(-1) ~= "/" then uri = uri .. "/" end
local query = url:match("%?([^#]*)$") or ""
local payload = body or ""
local payload_hash = sha256_hex(payload)
local date = os.date("!%Y%m%dT%H%M%SZ")
local h = {
["host"] = host,
["x-sdk-date"] = date,
["x-sdk-content-sha256"] = payload_hash,
}
if st ~= "" then h["x-security-token"] = st end
if method:upper() ~= "GET" then h["content-type"] = "application/json" end
local keys = {}
for k in pairs(h) do keys[#keys + 1] = k end
table.sort(keys)
local lines, names = {}, {}
for _, k in ipairs(keys) do
lines[#lines + 1] = k .. ":" .. h[k]
names[#names + 1] = k
end
local signed = table.concat(names, ";")
local canonical = table.concat({
method:upper(), uri, query, table.concat(lines, "\n"), "",
signed, payload_hash,
}, "\n")
local sts = "SDK-HMAC-SHA256\n" .. date .. "\n" .. sha256_hex(canonical)
local sig = hmac_sha256_hex(sk, sts)
local out = {}
for _, k in ipairs(keys) do
if k ~= "host" then out[k] = h[k] end
end
out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak
.. ",SignedHeaders=" .. signed .. ",Signature=" .. sig
-- 签名后追加:不参与 canonical(参考注释点名)
out["Agent-Type"] = "PromptCenter"
out["X-Language"] = "zh-cn"
return out, canonical, sts
end
-- 兼容旧调用名(build_headers 只用 headers)
local function signed_headers(method, url, cred, body)
local out = signed_parts(method, url, cred, body)
return out
end
-- sign_parts(method,url,cred,body):把签名**中间值**返回给判据用。
-- 与 build_headers 共用同一个 signed_headers(不重复实现 —— 判据必须比对
-- 真实函数算出的 canonical / string_to_sign,自算版会掩盖真实签名被改坏)。
function adapter.sign_parts(method, url, cred, body)
local out, canonical, sts = signed_parts(method, url, cred, body)
if not out then return nil, canonical end
return out, canonical, sts
end
-- build_headers:内核按源注入 meta{url,method,body,api_key,...}。
-- 凭据是完整 JSON(登录时存的就是它),从中解出 AK/SK/ST。
function adapter.build_headers(meta)
local h = { ["Content-Type"] = "application/json" }
local raw = tostring(meta.api_key or "")
local cred = nil
if raw:sub(1, 1) == "{" then
local ok, rec = pcall(json.decode, raw)
if ok and type(rec) == "table" then cred = rec end
end
if cred == nil then
-- 裸 token 形态(兼容旧数据):无法签名,如实说明
h["X-Channels-Error"] = "codearts 需要完整凭据 JSON(AK/SK),当前是裸 token"
return h
end
local signed, err = signed_headers(meta.method or "POST", meta.url or "", cred, meta.body)
if not signed then
h["X-Channels-Error"] = tostring(err)
return h
end
for k, v in pairs(signed) do h[k] = v end
return h
end
-- 请求体:CodeArts 是标准 OpenAI 兼容,只需剥内核私有字段。
function adapter.transform_request(raw_body)
local req = __prelude.decode_body(raw_body)
if req == nil then return raw_body end
__prelude.strip_control(req)
req.model = req.model or "CodeArts-Pangu"
req.stream = req.stream or false
return json.encode(req)
end
function adapter.transform_response(raw_body)
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
return __prelude.openai_stream_chunk(raw_chunk)
end
function adapter.transform_error(status, body)
return __prelude.openai_error(status, body)
end
return adapter
]==]
-- ============================================================================
-- TRAE 专用适配器(插件自装:channels-trae)
-- ============================================================================
-- TRAE **不是 OpenAI 兼容**。参考 trae.ts:30 注释点名:
-- 「载荷必须转换:OpenAI 的 {model,messages,tools,tool_choice,stream} 需要
-- 映射为 SOLO 格式(function、config_name 等字段),不能透传」
--
-- 为什么名字不叫 "trae":内置适配器 `trae.lua` 服务的是**运维自建的
-- trae-local-api 反代**(base_url 127.0.0.1:19900,生产 config.yaml 在用),
-- 与本插件的直连通道是两回事。重名会被内核的冲突仲裁挂起(正确的护栏),
-- 所以本插件用 `channels-trae`。
--
-- SOLO 转换规则(逐条对齐参考 trae.ts:transformToSOLOBody):
-- 1. messages.content 字符串 → [{type:"text",text:...}];已是数组则透传
-- 2. stream 强制 true(非流式由服务端聚合)
-- 3. model → config_name + model **双字段**
-- 4. function: 通道名,缺省 "solo_work_lite"
-- 5. tool_choice 归一化("none" 删 tools;auto/required 保留;
-- {type:"function",function:{name}} → 字符串 name)
-- 6. assistant 消息的 tool_calls: function → function_call(SOLO 字段名),
-- 并剔除没有 name 的条目(孤儿会让整条会话被拒)
-- 7. tools[].function.parameters: object → JSON **字符串**(SOLO 要求)
local TRAE_SOLO_ADAPTER_SRC = [==[
local adapter = {}
adapter.name = "channels-trae"
adapter.version = "1.0.0"
adapter.endpoint = "/api/agent/v3/llm_utils_chat"
local TRAE_DEFAULT_MODEL = "glm-5.2"
local TRAE_FUNCTION = "solo_work_lite"
local TRAE_MAX_MODE_TYPE = 1
local TRAE_MAX_PROMPT_TOKENS = 936000
local TRAE_MAX_OUTPUT_TOKENS = 64000
-- normalizeToolChoice:照参考 normalizeToolChoice(Go payload.go:111-154)
local function normalize_tool_choice(body)
local tc = body.tool_choice
if tc == nil then return end
local function suppress()
body.tools = nil
body.functions = nil
end
if type(tc) == "string" then
if string.lower(tc):gsub("%s+", "") == "none" then
body.tool_choice = nil
suppress()
end
return
end
if type(tc) == "table" then
local typ = type(tc.type) == "string" and string.lower(tc.type):gsub("%s+", "") or ""
if typ == "none" then
body.tool_choice = nil
suppress()
elseif typ == "auto" or typ == "required" then
body.tool_choice = typ
elseif typ == "function" then
local name = ""
local fn = tc["function"]
if type(fn) == "table" and type(fn.name) == "string" then name = fn.name end
if name == "" and type(tc.name) == "string" then name = tc.name end
if #(name:gsub("%s+", "")) > 0 then body.tool_choice = name
else body.tool_choice = "auto" end
else
body.tool_choice = nil
end
return
end
body.tool_choice = nil
end
-- normalizeTools:parameters 必须是**字符串**(SOLO 要求),OpenAI 标准是对象。
local function normalize_tools(body)
local raw = body.tools
if type(raw) ~= "table" or #raw == 0 then return end
local out = {}
for _, item in ipairs(raw) do
if type(item) == "table" then
local fn = item["function"]
if type(fn) == "table" then
if type(fn.parameters) == "table" then
fn.parameters = json.encode(fn.parameters)
end
out[#out + 1] = item
end
end
end
if #out > 0 then body.tools = out end
end
-- transformSOLOMessage:单条消息(content 字符串→parts;tool_calls 字段改名)
local function transform_solo_message(msg)
if type(msg) ~= "table" then return msg end
local result = {}
for k, v in pairs(msg) do result[k] = v end
if result.role == "assistant" and type(result.tool_calls) == "table" then
local kept = {}
for _, tc in ipairs(result.tool_calls) do
if type(tc) == "table" then
-- function → function_call(SOLO 字段名)
if type(tc["function"]) == "table" then
tc.function_call = tc["function"]
tc["function"] = nil
end
local fc = tc.function_call
local name = (type(fc) == "table" and type(fc.name) == "string") and fc.name or ""
-- 剔除没有 name 的条目(孤儿 tool_call 会让整条会话被拒)
if #(name:gsub("%s+", "")) > 0 then kept[#kept + 1] = tc end
end
end
if #kept > 0 then result.tool_calls = kept else result.tool_calls = nil end
end
-- content:字符串 → [{type:"text",text:...}];数组/其它原样透传
if type(result.content) == "string" then
result.content = { { type = "text", text = result.content } }
end
return result
end
-- transform_request:OpenAI 请求体 → SOLO 格式
function adapter.transform_request(raw_body)
local body = __prelude.decode_body(raw_body)
if body == nil then return raw_body end
__prelude.strip_control(body)
body.stream = true
-- ⚠️ `function` 是 Lua 关键字:必须用 body["function"],写 body.function
-- 是语法错误('<name>' expected near 'function'),且只在 load() 时才暴露
-- —— 模块级 local function 不会在编译期报出来。
if body["function"] == nil then body["function"] = TRAE_FUNCTION end
if type(body["function"]) == "string" and body["function"] == "" then
body["function"] = TRAE_FUNCTION
end
-- messages 转换
if type(body.messages) == "table" then
local msgs = {}
for _, m in ipairs(body.messages) do msgs[#msgs + 1] = transform_solo_message(m) end
body.messages = msgs
end
-- model → config_name + model(双字段)。支持 __dev 后缀消除。
local model = type(body.model) == "string" and body.model or ""
local base = model
local sep = string.find(model, "__", 1, true)
if sep then base = string.sub(model, 1, sep - 1) end
local config_name = (base ~= "" and base) or TRAE_DEFAULT_MODEL
body.config_name = config_name
body.model = config_name
normalize_tool_choice(body)
normalize_tools(body)
return json.encode(body)
end
function adapter.transform_response(raw_body)
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
return __prelude.openai_stream_chunk(raw_chunk)
end
function adapter.transform_error(status, body)
return __prelude.openai_error(status, body)
end
return adapter
]==]
-- installTraeAdapter():装/更新本插件自己的 TRAE 直连适配器。
local function installTraeAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local _, uerr = __llmsproxy_adapters.update("channels-trae", TRAE_SOLO_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-trae", TRAE_SOLO_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
-- installCodeartsAdapter():装/更新本插件自己的 codearts 适配器。
-- 幂等(update 失败时退化为 install);失败只记日志,不阻断插件加载 ——
-- 适配器装不上时源注册会报"未知适配器",比让整个插件加载失败更好定位。
local function installCodeartsAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local _, uerr = __llmsproxy_adapters.update("codearts", CODEARTS_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("codearts", CODEARTS_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
-- plugin.__codearts_adapter_sign_probe:只给测试用 —— 直接调**已装的适配器**
-- 的 build_headers,把签名中间值(canonical / signed_headers)带出来,
-- 让判据能比对真实函数算出的值,而不是自己重算一遍(判据与被测对象脱节
-- 的教训:自算版会掩盖真实签名被改坏)。
function plugin.__codearts_adapter_sign_probe(args)
args = args or {}
-- 从适配器池里取 build_headers 的结果不易(插件 VM 与适配器 VM 分离),
-- 故用**同一份源码**在本 VM 里求值后调用 —— 源码是单一定义(上面那个
-- CODEARTS_ADAPTER_SRC 常量),不存在两处漂移。
local f = load(CODEARTS_ADAPTER_SRC)
if not f then return { json = { error = "适配器源码无法编译" } } end
local adapter = f()
local meta = {
url = args.url, method = args.method or "POST",
body = args.body or "", api_key = args.api_key or "",
}
local h = adapter.build_headers(meta)
local out = { authorization = h["Authorization"] }
for k, v in pairs(h) do out["hdr_" .. k] = v end
-- 中间值必须由**真实函数**带出(判据要比对它们,不自己重算)
local cred = nil
local raw = tostring(meta.api_key or "")
if raw:sub(1, 1) == "{" then
local ok2, rec = pcall(json.decode, raw)
if ok2 and type(rec) == "table" then cred = rec end
end
if cred and adapter.sign_parts then
local _, canon, sts = adapter.sign_parts(meta.method, meta.url, cred, meta.body)
out.canonical = canon
out.string_to_sign = sts
end
return { json = out }
end
-- plugin.__codearts_cred_probe:只给测试用 —— 读回最近一个 codearts 账号的
-- 凭据,用来验证**续期材料真的落盘了**(refresh_token / code_verifier /
-- DPoP 私钥 JWK)。只断言"登录返回成功"无法防止凭据被截断成裸 AK/SK。
function plugin.__codearts_cred_probe(args)
-- 先看账号池条目(新路径),再退到旧单账号引用(channels:<plat>)。
local refs = {}
local list = plugin.account_list({ platform = "codearts" })
local j = list and list.json
for _, a in ipairs((j and j.accounts) or {}) do
if a.credentialRef then refs[#refs + 1] = a.credentialRef end
end
refs[#refs + 1] = "channels:codearts"
for _, ref in ipairs(refs) do
local cred = credentialFull(ref)
if type(cred) == "table" then
return { json = {
ref = ref,
access_key_id = cred.access_key_id,
refresh_token = cred.refresh_token,
code_verifier = cred.code_verifier,
dpop_private_key_jwk = cred.dpop_private_key_jwk,
} }
end
end
return { json = { error = "未找到 codearts 凭据", refs = refs } }
end
function plugin.__codearts_dpop_probe(args)
args = args or {}
local key, kerr = codeartsNewDpop()
if not key then return { json = { error = tostring(kerr) } } end
local jws, serr = codeartsDpopSign(key, tostring(args.htm or "POST"),
tostring(args.htu or "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"))
if not jws then return { json = { error = tostring(serr) } } end
return { json = { jws = jws } }
end
-- plugin.__qoder_refresh_probe(nowMs):只给测试用 —— 用**注入的时刻**验证
-- UTC+8 刷新判定(本机恰是 UTC+8,否则无法区分"算术平移"与"本机时区")。
function plugin.__qoder_refresh_probe(args)
local ms = tonumber((type(args) == "table" and args.now_ms) or 0) or 0
return { json = { refreshed = qoderCampaignRefreshed(ms) } }
end
function plugin.__qoder_machine_probe(args)
local ph = nil
for _, p in ipairs(PLATFORMS) do
if p.id == "qoder" then ph = p break end
end
if not ph or not ph.headers_fn then return { json = { error = "qoder headers_fn 缺失" } } end
local h = ph.headers_fn("TOK", {})
return { json = { token = h["Cosy-MachineToken"], type = h["Cosy-MachineType"],
client_type = h["Cosy-ClientType"] } }
end
-- plugin.__codearts_sign_probe:只给测试做差分(与参考生成的期望值比对)。
-- 允许传入 date 以对齐参考的固定时间戳。
function plugin.__codearts_sign_probe(args)
args = args or {}
local date = tostring(args.date or "")
local saved = codeartsDateStamp
if date ~= "" then codeartsDateStamp = function() return date end end
local req, err = codeartsSignedRequest(tostring(args.method or "GET"), tostring(args.url or ""),
{ access_key_id = tostring(args.ak or ""), secret_access_key = tostring(args.sk or ""),
security_token = tostring(args.st or "") }, tostring(args.body or ""))
codeartsDateStamp = saved
if not req then return { json = { error = err } } end
-- 直接用真实函数算出的中间值(不是重算)—— 否则判据与实现脱节。
return { json = { canonical = req.canonical, string_to_sign = req.string_to_sign,
signature = hmac_sha256_hex(tostring(args.sk or ""), req.string_to_sign),
signed_headers = req.signed_headers,
authorization = req.headers["Authorization"] } }
end
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>
]],
},
}
-- plugin.__sync_source_probe:只给测试/诊断用 —— 手动触发一次源同步并
-- 返回内核 add_source 的原始结果。把"静默不出源"变成可观测的结果。
function plugin.__sync_source_probe(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
local acct = poolGet(args.platform, args.account_id)
if not acct then return { json = { error = "账号不存在" } } end
local raw = __llmsproxy_secrets.get(acct.credentialRef)
local tok = nil
if plat.credential_shape == "full" then tok = raw else tok = credentialOf(acct.credentialRef) end
syncAccountSource(args.platform, args.account_id)
-- 回报同步后内核里该源是否存在
local ls = __llmsproxy_kernel.call("list_sources", {})
local found, ad = false, nil
local want = accountSourceName(args.platform, args.account_id)
local arr = (ls and ls.json and ls.json.sources) or (ls and ls.sources) or {}
for _, x in ipairs(arr) do
if x.name == want then found = true ad = x.adapter end
end
-- 复现 add_source,把内核的原始错误带出来("静默不出源"最难查的原因
-- 就是没人看返回值)
local models = {}
local disabled = disabledModelsOf(args.platform)
for _, m in ipairs(effectiveModels(args.platform)) do
if not disabled[m] then models[#models+1] = { id = m, kind = "chat", priority = 20 } end
end
local test_src = {
name = want, base_url = plat.base_url, adapter = plat.adapter or "openai",
api_key = tok, max_concurrent = 2, models = models,
}
if plat.endpoint then test_src.endpoint = plat.endpoint end
local rr, rerr = __llmsproxy_kernel.call("add_source", { source = test_src })
return { json = { credential_shape = plat.credential_shape or "token",
tok_len = tok and #tok or -1,
source_name = want, found = found, adapter = ad,
adapter_decl = plat.adapter,
add_ok = rr and rr.json and rr.json.ok or false,
add_err = tostring(rerr),
add_json = rr and json.encode(rr.json or {}) or "nil",
models_n = #models } }
end
-- 装本插件自己的专用适配器。必须放在**文件末尾**(installCodeartsAdapter 是
-- local,只能从定义处向后可见 —— 本文件已因 local 前向引用踩过 7 次)。
-- 放在脚本执行末尾即可:适配器装载与源注册都发生在脚本执行完之后。
do
local ok, err = installCodeartsAdapter()
plugin._codearts_adapter = { ok = ok, err = err }
local tok2, terr2 = installTraeAdapter()
plugin._trae_adapter = { ok = tok2, err = terr2 }
if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end
end
return plugin