Files
ModelRouter/internal/lua/plugins/channels.lua
JianFeeeee eeba1e49f8 fix(channels): account_clear_credentialless 尊重 dry_run(args=req 回退吃掉 body)
## 真实缺陷(我自己在上一个提交里引入,部署后实测踩到)
handler 开头照其它 18 处写了 `if args.platform == nil then args = req end`。
但本函数**不传 platform 是合法用法**(清全部平台)⇒ args.platform 自然为 nil
⇒ args 被换成 HandlerRequest ⇒ 刚解出来的 dry_run 就丢了 ⇒
**传 dry_run=false 却被当成未传,静默只预演**。

破坏性操作里这是最糟的形态:操作者以为执行了、实际没执行(反过来同样危险)。
实测:不带 platform 时 deleted 恒为 nil,池子一个没动。

## 修法
兜底只认「req 本身就是参数表」这种形态(`req.body == nil and req.query == nil`),
不再用「某字段为 nil」当判据 —— 那个字段为 nil 可能只是本次调用**合法地没用它**。

## ★ 判据为什么第一版没咬住
我原来的夹具用 **map body** 调 Call,而缺陷只在**字符串 body**(HTTP 真实路径)
上暴露 —— `bodyOf` 对 table 直接返回,根本走不到回退那行。
补 `TestAccountClearCredentiallessRespectsDryRunViaStringBody`(用
`InvokeHandler` + 字符串 body + 不传 platform)后变异才咬住。
**教训:夹具的调用形态必须与生产一致 —— 用 map 便捷调用会绕过 body 解析路径。**

判据 4 条(含本条)+ 变异 3/3。
2026-10-16 07:51:59 +08:00

10970 lines
528 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_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "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", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
-- ============================================================================
-- 平台表
-- ============================================================================
--
-- 每个平台声明登录方式与各端点。字段:
-- 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
-- 前向声明(本文件第 10 次踩 local 前向引用):余额分档选号在前、具体抓取在后。
-- expiringWindowDays / fetchAccountJSON / fetchBalanceOf 都定义在下方。
local expiringWindowDays
local fetchAccountJSON
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 = "en1oxy7wnw8j9n", -- 实测值(对齐参考 trae-product.ts 的 clientId)
-- ↑ 这个值**不是**可选的:ExchangeToken 的请求体里 ClientID 是必填,
-- 留空会让交换稳定 400(详见文件内 traeCallback 的注释)。
},
},
-- 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}"}',
-- 静默续期(照参考 cline.ts)。
-- ⚠️ 三个字段名陷阱,写错服务端只回泛化的认证失败、极难定位:
-- ① 请求体是**驼峰** `refreshToken` + `grantType`(不是 OAuth 的
-- refresh_token/grant_type);
-- ② 响应套 `{success,data}` 信封;
-- ③ 响应字段是**驼峰** `accessToken`。
refresh = {
style = "cline",
url = "https://api.cline.bot/api/v1/auth/refresh",
},
},
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",
},
-- Cline 的 429 把等待时长写在**英文句子**里(`Try again in 19h 39m`),
-- 既无 retry-after 头也无绝对时刻。通用适配器解析不出 ⇒ 内核按 30 秒
-- 短冷却处理 ⇒ 每 30 秒重试、永远失败(在猛敲上游)。
-- 故用本插件自装的 channels-cline 把文案归一成内核已认的中立短语。
adapter = "channels-cline",
-- 命名约定:`cline-free/*` 是**免费通道**的独立 id(照参考
-- cline-models.ts 的 isFree 兜底规则)。远端 free 数组未响应时靠它判免费。
free_prefix = "cline-free/",
-- 这 3 个来自远端 free 数组(内嵌目录里没有),**它们是免费的**
-- (此前误放在 models 里 ⇒ 被标成收费,与注释自相矛盾)。
models = {
"stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor",
},
-- 显式声明免费集合:这是**兜底**(远端 free 数组是权威,但可能拉不到)。
-- 三者都在这里 ⇒ 即使远端抖动也不会把免费模型误标成收费。
models_free = {
"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 = "{}",
-- tier_fn:把余额拆成「快到期 / 永久」两桶(照参考
-- buddy-balance-rank.ts:splitBuddyCreditsByExpiry)。口径必须与 total_fn
-- 一致 —— 两条路各算一遍必然会漂移,故共用同一套包遍历规则:
-- * **只算 remaining**(CycleCapacityRemain,本周期剩余),与 IDE 顶部
-- 的 Credits Balance 一致。刻意不用 TotalDosage:那是**终身口径**
-- 且取整(实测同一响应 TotalDosage=655 而 IDE 显示 155.67)。
-- * `active === false` 的包一律**跳过**:失效包服务端仍会返回余额,
-- 并进任何一桶都会虚增可用额度。
-- * 到期时间**未知**归入**永久**桶 —— 保守方向:最坏是锁定时少用一个号,
-- 而不是误把长期积分当快到期烧掉。
tier_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 window = expiringWindowDays() * 86400
local expiring, permanent = 0, 0
for _, pkg in ipairs(accounts) do
if type(pkg) == "table" then
local active = true
local st = pkg.Status
if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end
local ded = tonumber(pkg.DeductionEndTime)
local endSec = nil
if ded and ded > 0 then
endSec = math.floor(ded / 1000)
if now >= endSec then active = false end
end
local remain = tonumber(pkg.CycleCapacityRemain) or 0
if active and remain > 0 then
-- 到期时间已知且落在窗口内 → 临时桶;未知 → 永久桶(保守)
if endSec and (endSec - now) < window then
expiring = expiring + remain
else
permanent = permanent + remain
end
end
end
end
return { expiring_balance = expiring, permanent_balance = permanent }
end,
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。
},
-- 静默续期(照参考 gemini-oauth):标准 OAuth `refresh_token` 表单(**平台级**,
-- 不是 callback 内 —— refreshCredential 读的是 plat.refresh)。
-- 同样必须带 client_secret;且 **Google 偶尔轮换 refresh_token**,
-- 响应给了新的必须回写(由 refreshCredential 负责),否则下次用旧的直接失效。
refresh = {
style = "google",
url = "https://oauth2.googleapis.com/token",
client_id = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
client_secret = "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",
},
hosts = { "accounts.google.com", "oauth2.googleapis.com", "cloudcode-pa.googleapis.com" },
base_url = "https://cloudcode-pa.googleapis.com",
-- Cloud Code 走**私有信封协议**(/v1internal: 动词 + 身份五头 + 字母序
-- 信封 + 响应剥一层),用本插件自装的适配器。
adapter = "channels-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",
-- models.dev 的键名(能力元数据的来源;照参考 opencode-capability)
capability_key = "opencode",
-- 该平台没有自己的免费名单,models.dev 的 cost **就是**唯一权威
-- (照参考 opencode-capability.ts)。故本地 models_free 只作兜底。
free_source = "capability",
-- 照参考 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",
-- benefit 模型目录(照参考 OPENGW_GATEWAY_CONFIG_URL 的 host)。
-- 不在白名单会被 HTTP 桥拒,目录只剩第 2 个端点的那一半模型。
"opengw.developer.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",
-- ★ 远端模型目录(照参考 fetchCodeArtsRemoteModels 的**两个**端点)。
--
-- 缺这个曾让 codearts 只剩硬编码的 1 个模型("CodeArts-Pangu"),而
-- 10 个账号全在池里可用 —— 目录端点其实一直可达,只是我们没打。
-- 参考实测的模型(GLM-5.2 / openpangu 系列 / glm-5.2-sft-harmony 等)
-- 全都由这两个端点下发:
-- ① opengw gateway/config → result.models(benefit 模型,如 glm-5.3-flash)
-- ② snap-access /v1/model/builtin → builtinModels(常规模型)
-- 两者都**必须 AK/SK 签名**(ep.sign = "codearts" 走平台签名函数),
-- 且 ② 额外要 Agent-Type / X-Language 两个不参与签名的头。
models_remote = {
endpoints = {
{ url = "https://opengw.developer.huaweicloud.com/api/v1/gateway/config",
method = "GET", sign = "codearts", parse = "codearts_gateway" },
{ url = "https://snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin",
method = "GET", sign = "codearts", parse = "codearts_builtin",
headers = { ["Content-Type"] = "application/json",
["Agent-Type"] = "PromptCenter",
["X-Language"] = "zh-cn" } },
},
auth = "none", -- 签名由 sign 分支负责,不走 Bearer
parse = "codearts_builtin",
},
-- 兜底目录:远端不可达时仍可选中(照参考的已知可用 id)。
models = { "CodeArts-Pangu", "deepseek-v4-flash", "GLM-5.3", "GLM-5.2" },
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",
-- Qoder 的错误信封是**双层编码**的,且 10605(排队) 与 110(额度耗尽)
-- 语义相反 —— 通用 openai 适配器只能看到外层,产出用户看不懂的原因串。
-- 故用本插件自装的 channels-qoder(厂商码留在插件域,内核零厂商名)。
adapter = "channels-qoder",
-- 兜底目录(照参考 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",
-- ★ 不能复用通用 anthropic:zcode 的该通道做**请求体内容检查** —— system 缺
-- 官方身份块结构时直接 405 + {"code":3012,...}(实测;与 HTTP 头、版本号、
-- 请求频率、tools 声明全无关)。本插件自装的 channels-zcode 复用 anthropic
-- 的协议转换,只额外注入身份块(见 ZCODE_ADAPTER_SRC)。
adapter = "channels-zcode",
-- 余额端点(需要 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
-- ⚠️ 常量收进**表**(`plugin.consts`)而不是顶层 `local`。
-- 原因很实际:本文件顶层 local 已**顶到 Lua 单函数 200 个上限**,多一个就让
-- 整个插件**加载失败**(`too many local variables`);报错在编译期,
-- 运行期表现是"插件所有路由变成 404",毫无指向性。2026-10-14 实测踩到。
-- 往本文件加顶层 local 前,务必先 `luac -p` 验证。
local CODEARTS = {
portal_authorize = "https://codearts.huaweicloud.com/portal/authorize",
sts_token = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens",
client_id = "codearts-agent",
redirect_path = "/oauth/callback",
login_plugin_name = "snap_AIIDE",
login_plugin_version = "5.2.0",
oauth_theme = "2",
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
-- ★ covers_today = false:这次看到的是**刷新前那一轮**的痕迹,
-- 因此**不得**据此认定"今天已处理"。
--
-- 参考的真实缺陷(2026-10-02,每天静默漏领 100 积分):上游 10:00(UTC+8)
-- 才刷新活动,而自动签到在上午跑 —— 上午那次看到的是**昨天**那条 CLAIMED。
-- 若拿它记账,当天 10 点刷新后就再也不会去领。
--
-- 修法不靠"给每个渠道配刷新时刻表"(必然漂移),而是**让产出方自己
-- 回答**"我这条结果覆盖的是不是今天这一轮";记账方只数
-- "有几条能证明今天已被处理",完全不需要知道任何渠道的日界细节。
return { done = true, kind = "inactive", covers_today = false,
message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新),"
.. "当前看到的是昨天那一轮,请稍后再来领取。" }
end
return { done = true, kind = "already-claimed", covers_today = true,
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
-- 能力元数据来源(照参考 opencode-capability)。必须声明,否则出站被拒
-- 而 refreshCapabilities 的 pcall 会把错误吞掉 —— 表现是"能力永远是空"。
if p.capability_key then addHost("https://models.dev/api.json") 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
-- 内核交给本插件的目录(ground truth,不需要猜)。
local kern = __llmsproxy_plugin_dir
if type(kern) == "string" and kern ~= "" then return kern end
local s = plugin.state.settings and plugin.state.settings.store_dir
if type(s) == "string" and s ~= "" then return s end
-- ⚠️ 优先用内核交给我们的**本插件目录**(`__llmsproxy_plugin_dir`):
-- 那是 ground truth,不需要猜。此前这里直接落到"读生产 config.yaml"的兜底,
-- 在**未设 LLMSPROXY_PLUGIN_DIR 的测试里**会命中生产路径 —— 测试读生产存储、
-- 也写生产存储。一次全量 internal/lua 就把生产账号池覆盖成测试的空 pool
-- (实测:13 个账号 → 0,凭据还在保险库,症状是"账号凭空消失")。
-- 判据:internal/lua/store_dir_isolation_test.go。
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",
-- 已失效模型记录(阳性 404 证据)。必须持久化:不落盘的话重启就全忘了,
-- 用户又要重新被 404 打一遍(照参考 DEAD_MODELS_FILE 的落盘语义)。
"deadModels",
-- 通用 TTL 缓存(余额原料等)。持久化可让重启后不立刻重打上游。
"ttlCache",
-- 自动领取的"今天已覆盖"记账。必须持久化:不落盘的话每次重启都会
-- 把当天已领的再领一遍(上游幂等,但会产生无谓请求与日志噪音)。
"claimCovered",
-- 模型能力元数据(models.dev 的能力/免费/上下文)。持久化是**必须**的:
-- 冷启动时要能直接读缓存,否则首个模型列表要么空白、要么等 5MB 下载。
"capabilities", "capabilitiesMeta",
-- 目录闸门状态(冷却/in-flight)。也落盘:重启后冷却窗口应继续有效,
-- 否则"重启即重试坏上游"(照参考把闸门状态随插件存活的语义)。
"catalogGate",
}
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"
-- 目录可能还不存在(首次运行、或内核交给我们的目录尚未创建)。
-- 不 mkdir 就直接 io.open 会失败,而失败只打到 stderr —— 表现为
-- "状态永不落盘"(重启即丢),日志里一行「状态落盘失败」很容易被忽略。
local dir = path:match("^(.*)/[^/]+$")
if dir and dir ~= "" then os.execute("mkdir -p '" .. dir .. "' 2>/dev/null") end
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()
-- ★ 启动清扫:删掉上一进程留下的孤儿回调文件。
--
-- 为什么需要它:loopbacks 追踪表只活在内存里(plugin.state.loopbacks),
-- 进程退出后无从得知上一轮起过哪些接收器。于是进程被强杀/SIGKILL 时,
-- 那句 on_unload 没机会跑,磁盘上就永久留下 callback-*.port / .json。
-- 实测生产攒了 18 个孤儿 .port(对应的 .json 早已消失),且**永远不会被清** ——
-- 重启后内存表是空的,reapLoopback 根本不知道它们存在。
--
-- 判据不是"文件被删了"这么弱,而是"启动后目录里不再有上一轮的残留":
-- internal/core/channels_orphan_callback_test.go。
do
local dir = storeDir()
local sh = io.popen and io.popen("rm -f " .. dir ..
"/callback-*.port " .. dir .. "/callback-*.json 2>/dev/null")
if sh then sh:close() end
end
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。
-- ⚠️ 必须定义在 refreshCredential **之前**:Lua 的 local 只从定义处向后可见,
-- 在定义前调用会拿到 nil 并报 "attempt to call a nil value"(第 10 次踩)。
local function credentialIsRefreshable(cred)
return type(cred) == "table" and type(cred.refresh_token) == "string" and cred.refresh_token ~= ""
end
-- urlencode:表单编码(Google 续期走 x-www-form-urlencoded)。
-- ⚠️ 不能只转义常见几个字符:client_secret / refresh_token 里会出现
-- `-` `_` `.` `~` 之外的各种符号,漏转义会让服务端收到被截断的值。
local function urlencode(v)
return (tostring(v or ""):gsub("[^%w%-%._~]", function(c)
return string.format("%%%02X", string.byte(c))
end))
end
-- ============================================================================
-- 凭据静默续期(照参考 refresh.ts / cline.ts / gemini-oauth.ts)
-- ============================================================================
-- ⚠️ **此前这里是假的**:`account_refresh` 只重同步了源、一个换 token 的请求
-- 都不发,却恒返回 `ok:true` ⇒ `refresh_creds` 打出「凭据已续期 N 个」而实际
-- 什么都没续。这是**比不做更坏**的形态:日志在说谎,排查时会去看网络、
-- 看上游,而真因是根本没有这段代码。
--
-- 参考的实现要点(每一条都是踩过的坑):
--
-- 1. **续期请求体各平台字段名不同**,写错服务端不给明确报错,只回一个泛化
-- 的认证失败,极难定位:
-- * Cline:驼峰 `refreshToken` + `grantType`(**不是** OAuth 标准的
-- `refresh_token`/`grant_type`);
-- * Gemini/Google:标准 `refresh_token` + `grant_type=refresh_token`
-- (表单编码,走 client_id/client_secret);
-- * 其余走各平台自己的 exchange。
-- 2. **响应字段名同样不统一**:Cline 是 `accessToken`(驼峰)+ `{success,data}`
-- 信封;Google 是 `access_token`。解析必须容忍两种。
-- 3. **Google 偶尔轮换 refresh_token**:响应里给了新的**必须回写**,否则下次
-- 续期用旧的直接失效。
-- 4. **`expires_in` 是相对秒数**,必须换算成绝对时刻再写回索引 ——
-- 否则 `refresh_creds` 每轮都判定"快过期了"又续一遍。
--
-- 声明式设计(插件域内,内核不参与):平台用 `refresh = { url=, style=,
-- body=, ... }` 描述自己的续期,本助手按 style 组装。
local REFRESH_STYLES = {
-- Cline:驼峰字段 + JSON body + {success,data} 信封
cline = {
body = function(cred, r)
return json.encode({ refreshToken = cred.refresh_token or "",
grantType = "refresh_token" })
end,
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
-- 响应套 {success,data} 且字段是驼峰 accessToken(照 parseClineTokenPayload)
pick = function(rec)
local d = type(rec.data) == "table" and rec.data or rec
return d.accessToken or d.access_token, d.refreshToken or d.refresh_token
end,
},
-- Google/Gemini:标准 OAuth 表单编码
google = {
form = true,
body = function(cred, r)
return "client_id=" .. urlencode(r.client_id or "")
.. "&client_secret=" .. urlencode(r.client_secret or "")
.. "&refresh_token=" .. urlencode(cred.refresh_token or "")
.. "&grant_type=refresh_token"
end,
headers = { ["Content-Type"] = "application/x-www-form-urlencoded",
["Accept"] = "application/json" },
-- ⚠️ Google 偶尔轮换 refresh_token:给了新的必须回写
pick = function(rec) return rec.access_token, rec.refresh_token end,
},
}
-- refreshCredential(plat, acct):真发一次续期请求,成功则**回写凭据与索引**。
-- 返回 (ok, message)。失败原因必须可诊断(不能只说"失败了")。
local function refreshCredential(plat, acct)
local r = plat.refresh
if type(r) ~= "table" or not r.url then return false, "该平台未声明续期方式" end
local cred = credentialFull(acct.credentialRef)
if type(cred) ~= "table" then return false, "凭据无法解析" end
if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end
local style = REFRESH_STYLES[r.style or "google"]
if not style then return false, "未知续期风格: " .. tostring(r.style) end
local body = style.body(cred, r)
local resp, err = __llmsproxy_http({
url = r.url, method = r.method or "POST", headers = style.headers, body = body,
})
if err then return false, "续期请求失败: " .. tostring(err) end
if not resp or resp.status ~= 200 then
return false, "续期 HTTP " .. tostring(resp and resp.status)
end
local okd, rec = pcall(json.decode, resp.body or "")
if not okd or type(rec) ~= "table" then return false, "续期响应无法解析" end
local newTok, newRefresh = style.pick(rec)
if type(newTok) ~= "string" or newTok == "" then
-- 服务端的失败信息要带出来(照参考:不吞诊断信息)
local msg = rec.error_description or rec.error or rec.message
return false, "续期响应无 access_token" .. (msg and (": " .. tostring(msg)) or "")
end
-- 回写凭据(**只能走这条路写凭据** —— 手工塞密文会导致二次加密)
cred.access_token = newTok
if type(newRefresh) == "string" and newRefresh ~= "" then
cred.refresh_token = newRefresh -- Google 会轮换
end
-- expires_in 是**相对秒数** → 换算绝对时刻(否则每轮都判定"快过期")
local rec2 = type(rec.data) == "table" and rec.data or rec
local ei = tonumber(rec2.expires_in or rec.expires_in)
if ei and ei > 0 then
cred.expires_at = tostring((os.time and os.time() or 0) + ei)
end
__llmsproxy_secrets.set(acct.credentialRef, json.encode(cred))
-- 回写索引的过期时刻(照 expiry-sync:按需续期**必须**回写,否则会出现
-- 「显示已过期、但发消息正常」的数据源分叉)
if cred.expires_at then
local ms = credentialExpiresAtMs(cred)
if ms then acct.expiresAt = math.floor(ms / 1000) end
end
acct.refreshable = true
poolAdd(plat.id, acct)
markDirty()
return true, "已续期"
end
-- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true }
-- ============================================================================
-- 远端模型目录:**远端为主 + 兜底补位**(照参考的 reconcileWithFallback)
-- ============================================================================
--
-- 为什么不只用硬编码名单:上游模型目录是**会变**的(新增/下线/改名),
-- 硬编码必然过期。为什么不只用远端:远端可能拉不到(未登录/网络抖动/端点变更),
-- 那时平台就"没有模型可用"了。
--
-- 故正确形态是**合并**而非"远端失败就用兜底"的简单回退(参考 cline-models.ts
-- 明确写了这一条):远端有的以远端为准,远端没有的用兜底表补。
--
-- 注意:兜底表是**编译期快照**,价格/倍率会变,故兜底条目不携带价格类字段。
-- 取该平台**当前有效**的模型目录(远端优先,兜底补位,剔除黑名单)。
-- 前向声明:deadModelIds 定义在下方(失效模型存储一节),而 effectiveModels
-- 在它之前就要剔除失效模型。Lua 的 local 只从定义处向后可见 —— 第 9 次踩。
local deadModelIds
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
-- 剔除**有阳性证据**已失效的模型(照参考 dead-model-store 的剔除语义)。
-- ⚠️ 只剔这里记录过的(真正 404 过的),**绝不**拿远端目录反推下架 ——
-- buddy/qoder 那类平台的远端目录残缺,反推会删掉可用模型。
local dead = deadModelIds(provider)
local kept = {}
for _, id in ipairs(out) do
if not dead[id] then kept[#kept+1] = id end
end
return kept
end
-- remoteFreeSets:provider → { [modelId]=true },来自远端**权威** free 数组。
-- 照参考:`recommended-models` 的 free 数组是唯一权威的免费集合,
-- `/models` 的 id 列表里根本没有 cline-free/*。
-- 只收 id 不记"它免费"会丢掉这层信息 ⇒ 免费模型被标成收费。
remoteFreeSets = remoteFreeSets or {}
-- parseRemoteModels(parse, body):把各平台响应解析成 id 列表。
-- 每个分支的字段路径都照参考实现,含注释点名的坑。
local function parseRemoteModels(kind, body, provider)
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 == "codearts_builtin" then
-- 照参考 fetchCodeArtsRemoteModels 的第 2 个端点:字段是 builtinModels
-- (**对象**,键为序号;用 pairs 兼容对象/数组两形态)。
local raw = rec.builtinModels or (rec.data and rec.data.builtinModels)
if type(raw) == "table" then
for _, m in pairs(raw) do
local id = type(m) == "table" and (m.model_id or m.modelId or m.id)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
end
end
elseif kind == "codearts_gateway" then
-- 照参考的第 1 个端点:result.models(benefit 模型)。
local arr = rec.result and rec.result.models
if type(arr) == "table" then
for _, m in ipairs(arr) do
local id = type(m) == "table" and (m.model_id or m.modelId or m.id)
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)
-- ★ 同时把 `free` 数组记成**权威免费集合**(照参考 cline-models.ts:
-- `recommended-models` 的 free 数组是唯一权威来源)。
-- 只收 id 不记"它免费"会丢掉这层信息 —— 表现为免费模型被标成收费,
-- 用户不知道哪个可以不花钱用。
if type(rec.free) == "table" then
local fr = {}
for _, m in ipairs(rec.free) do
local id = type(m) == "table" and m.id or (type(m) == "string" and m)
if type(id) == "string" and id ~= "" then fr[id] = true end
end
remoteFreeSets = remoteFreeSets or {}
remoteFreeSets[provider] = fr
end
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
-- ★ 签名端点:codearts(华为 snap-access)的目录**必须 AK/SK 签名**,
-- 不是 Bearer。ep.sign = "codearts" 时用平台签名函数重建整个请求
-- (签名覆盖 host/x-sdk-date/x-sdk-content-sha256/x-security-token 等头,
-- 故不能「先拼头再签名」—— 必须由签名函数产出最终请求)。
local req = { url = url, method = ep.method or "GET", headers = headers }
if ep.sign == "codearts" then
local cred
for _, a in ipairs(poolList(provider)) do
if a.enabled ~= false then
local raw = __llmsproxy_secrets.get(a.credentialRef)
local okj, cj = pcall(json.decode, raw or "")
if okj and type(cj) == "table" and (cj.access_key_id or cj.access_key) then
cred = cj break
end
end
end
if not cred then
lastErr = "无可用 AK/SK 凭据"
else
local sreq, serr = codeartsSignedRequest(req.method, url, cred, nil)
if sreq then
-- 目录端点额外要求的**非签名**头(照参考 fetchSignedGet 调用点)
for k, v in pairs(ep.headers or {}) do sreq.headers[k] = v end
req = sreq
else
lastErr = tostring(serr)
end
end
end
local resp, err = __llmsproxy_http(req)
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, provider)) 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 是错的:claim_daily 的
-- 前置条件(plat.checkin 或 claim_fn)都不满足,于是每次调用都返回
-- "CodeBuddy(腾讯) 不支持每日签到(平台未声明 checkin)" —— 一个自相矛盾的
-- 文案(表说支持、函数说不支持)。对齐参考:buddy 只有**余额**,无 claim。
buddy = { status = true },
lobsterai = { claim = true },
qoder = { claim = true },
trae = { claim = true },
cline = { unit = "dollars" },
loomy = { claim = true },
-- raccoon 的领取 = **桌面端登录奖励**(每号一次),参考
-- raccoon-credits.ts:claimRaccoonLoginReward。此前我们声明为空表 → 白丢一个
-- 可领渠道。幂等靠响应的 `granted` 字段(重复领取同样 HTTP 200 但 granted=false)。
raccoon = { claim = true, unit = "points" },
minimax = { claim = true, status = true },
-- zcode 有签到,但每次领取都要现场产**阿里云 captcha**(headful Chromium,
-- 且阿里云按设备限流 150 次/小时)。故照参考 isAutoCheckinExcluded:
-- **自动**签到跳过它(代价发生在调用期间,等它报错来不及);
-- 用户仍可**手动**点领取 —— 浏览器弹出来是用户能理解的交互。
zcode = { claim = true, unit = "tokens", auto_exclude = true },
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=1 + rpm 节流,而不是 2。
--
-- 照参考 model-gate.ts 的实测依据:这些平台的**并发配额按模型**分别计量,
-- 与"还剩多少 token"无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下:
-- GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过
-- GLM-5.3 74 次 200 21 次重试 6 次最终 429
-- 两条措施缺一不可:
-- ① **串行**:同一时刻只允许一个上游请求在飞(并发发起必然浪费一个配额)
-- ② **最小间隔**:串行只保证"不重叠",不保证"有间隔" —— 连续放行的两次
-- 调用可能只隔几十毫秒,仍会撞并发窗口。故用 rpm 让内核把它们隔开。
-- 这两条都是**客户端自保**(防患于未然),与"上游明确要求排队"是两回事。
max_concurrent = 1,
rpm = plat.rpm or 20, -- 默认 3 秒一个请求,可按平台覆盖
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
-- browserUtil:浏览器栈的**小助手**。挂在**表**上而不是 `local function` ——
-- 本文件顶层 local 顶到 Lua 的 200 个上限,多一个就让整个插件加载失败
-- (见 CODEARTS 常量处那段说明)。
local browserUtil = {}
-- browserUtil.pathExists(p):文件/目录是否存在。
-- ⚠️ 不能用 io.open 判"存在":X socket 是 **socket 文件**,io.open 打不开它,
-- 拿它当判据会永远等不到(本文件在选显示器号处也踩过同一个坑)。
function browserUtil.pathExists(p)
local f = io.open(p, "rb")
if f then f:close() return true end
-- io.open 失败时退到 stat(socket/设备文件也能判到)
local h = io.popen(("test -e %s && echo y"):format("'" .. tostring(p) .. "'"))
local r = h and h:read("*l")
if h then h:close() end
return r == "y"
end
-- browserUtil.readTail(path, max):读日志尾部,把启动失败的**真因**带给用户
-- (而不是一句"启动失败"让运维去猜)。
function browserUtil.readTail(path, max)
local f = io.open(path, "r")
if not f then return "(无日志)" end
local txt = f:read("*a") or ""
f:close()
local keep = {}
for line in txt:gmatch("[^\n]+") do
if not line:find("dbus", 1, true) and not line:find("crashpad", 1, true)
and not line:find("NameHasOwner", 1, true) and not line:find("UPower", 1, true) then
keep[#keep + 1] = line
end
end
local joined = table.concat(keep, " | ")
local lim = max or 400
if #joined > lim then joined = joined:sub(-lim) end
return joined
end
-- browserUtil.cdpAlive(port):CDP HTTP 端点是否**真的应答**(端口在听 ≠ 服务可用)。
function browserUtil.cdpAlive(port)
for _ = 1, 10 do
local h = io.popen(("curl -s --max-time 2 http://127.0.0.1:%d/json/version 2>/dev/null"):format(port))
local out = h and h:read("*a") or ""
if h then h:close() end
if out and out:find("webSocketDebuggerUrl", 1, true) then return true end
os.execute("sleep 0.5")
end
return false
end
-- ⚠️ **前向声明**(Lua 的 local 只从定义处向后可见)。
--
-- 下面这三个助手定义在**更后面**(本文件按"会话 → 浏览器栈 → loopback 接收器"
-- 的阅读顺序组织),但 sweepSessions 要用它们。不声明的话是:
-- `attempt to call global 'reapLoopback' (a nil value)`
-- **加载时不报错**,只有真正走到那条分支才炸 —— 所以第一次登录正常,
-- 第二次(有会话过期需回收时)才 500。这是本文件第 12 次踩前向引用。
--
-- 实测后果:重复发起登录直接 500,且**已经起来的浏览器栈不被回收**
-- (reapBrowser 也调不到),进程与端口持续泄漏。
local reapLoopback, browserStack, reapBrowser
-- 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 窗口里完成交互,机器负责把结果取出来。
browserStack = function()
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
reapBrowser = function()
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
if b.home then
os.execute("rm -rf " .. b.home .. " 2>/dev/null")
end b.home = 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)
-- 起始号可覆盖:`LLMSPROXY_XDISPLAY_BASE` env 或 settings.xdisplay_base。
--
-- ⚠️ 为什么需要它(真实事故 2026-10-15):生产 unit 带 `PrivateTmp=yes`,
-- 它起的 Xvfb :91 的锁文件(/tmp/.X91-lock)在**服务自己的 /tmp** 里 ——
-- 宿主上的测试**看不到**它,于是 xdisplayBusy(91) 判"空闲"、测试也去抢 91,
-- 结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`。测试与生产在同一个
-- 显示器号上互相踩(而 pickFreeX 的判据本身是对的,它只是看不到另一个 /tmp)。
--
-- 故测试用高段号(如 300+)与生产错开;生产留空即用默认 91。
local base = nil
local env = os.getenv and os.getenv("LLMSPROXY_XDISPLAY_BASE")
if type(env) == "string" and env ~= "" then base = tonumber(env) end
if not base then
local st = plugin.state and plugin.state.settings
if type(st) == "table" then base = tonumber(st.xdisplay_base) end
end
if base then start = base end
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(真实踩过的坑)。
reapLoopback = function(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
-- 端口**不需要**覆盖点:端口是全局资源,`portFree` 直接查 /proc/net/tcp,
-- 所以它能看见生产占的 59100/60100/61100(这与显示器号不同 —— 那个靠
-- /tmp/.X<n>-lock,而生产 PrivateTmp 下的锁宿主看不到)。
-- ⚠️ 我一度在这里加过 port_base 偏移,结果把端口推到 66xxx 反而起不来 ——
-- 端口不是"号段",偏移没有意义。
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
-- ⚠️⚠️ 真实事故(2026-10-14,VNC 一直白屏的真因)。
--
-- 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium
-- **独立 GPU 进程**启动所需的系统调用。它起不来 → chromium 直接放弃:
-- ERROR:content/browser/gpu/gpu_process_host.cc:1124]
-- GPU process launch failed: error_code=1002
-- FATAL:content/browser/gpu/gpu_data_manager_impl_private.cc:417]
-- GPU process isn't usable. Goodbye.
-- 整个 chromium 在几秒内退出 ⇒ **VNC 上永远白屏**。
--
-- 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功(我为此绕了很久,
-- 先后怀疑过 profile 锁、/tmp 满、PrivateTmp、可写 HOME —— 全是错的)。
-- 定位靠的是**用 systemd-run 逐 directive 二分**:只有
-- `SystemCallFilter=@system-service` 与 `ProtectSystem=strict` 能让它死,
-- 其余加固项均无影响。
--
-- 修法:`--in-process-gpu` 让 GPU 逻辑跑在浏览器进程内(不 fork 独立进程),
-- 于是不再触碰被 seccomp 挡掉的那条路径。**实测在完整生产沙箱下
-- ALIVE=1 / 窗口=4 / 无 FATAL**。修在插件层,unit 一行不动 ——
-- 加固不该为了一个浏览器栈而放松。
--
-- 顺带保留可写的 HOME/XDG(下面几行):生产 `ProtectHome=yes` 下 /root 只读,
-- chromium 建 dconf/ibus 目录会刷一堆 permission denied。它**不是**这次白屏的
-- 原因(我起初误判为真因),但不给可写目录会持续产生噪声日志。
local homeDir = "/tmp/llmsproxy-bhome-" .. tag
os.execute("rm -rf " .. profileDir .. " " .. homeDir .. " 2>/dev/null")
os.execute("mkdir -p " .. homeDir .. "/.cache " .. homeDir .. "/.config " .. homeDir .. "/.local/share 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
browserStack().home = homeDir
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", ("HOME=%s XDG_CACHE_HOME=%s/.cache XDG_CONFIG_HOME=%s/.config "
.. "XDG_DATA_HOME=%s/.local/share "
.. "%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s "
.. "--no-first-run --disable-gpu --in-process-gpu --remote-debugging-port=%d --remote-allow-origins=* "
.. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%s.log 2>&1 & echo $!"):format(
homeDir, homeDir, homeDir, homeDir,
chromium, profileDir, cdpPort, disp, start_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
-- ★ 存活探针:端口"能连上"**不等于**浏览器还活着。
-- 真实事故:chromium 因 GPU 自杀时,CDP 端口在它退出前确实短暂监听过 ⇒
-- waitPort 通过 ⇒ 我们报"已就绪",运维打开 VNC 却是白屏。**谎报"就绪"比
-- 直接报错更坏**:运维会以为是自己操作的问题。
-- 判据必须咬**外部效应**(进程仍在 + CDP 真能应答),而不是"我们发过启动命令"。
os.execute("sleep 1")
local cpid = pids[2]
if cpid and cpid ~= "" and not browserUtil.pathExists("/proc/" .. cpid) then
local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400)
reapBrowser()
return nil, "浏览器启动后即退出(多半是 GPU/权限问题):" .. tostring(why)
end
if not browserUtil.cdpAlive(cdpPort) then
local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400)
reapBrowser()
return nil, "浏览器 CDP 无应答(:" .. tostring(cdpPort) .. "):" .. tostring(why)
end
b.pids = pids
-- 同源代理不再需要 vnc_host;后端仍只监听回环。
local sess = tostring(seq) .. "-" .. pass:sub(1, 8)
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq, sess = sess,
expires_at = (os.time and os.time() or 0) + 900 }
-- 代理入口:把 noVNC 视图路径经网关回传,而不是让客户端去连 VNC 端口。
-- 客户端可能来自另一台机器(127.0.0.1 指向客户端自己)且 websockify 只绑回环,
-- 所以只能由网关同端口转发;插件只提供回环目标与绝对路径,不处理传输。
local base = "/api/plugins/http/channels/browser/proxy/" .. sess
-- noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()),
-- 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。
-- 曾传站点根路径(/api/.../websockify)→ 被当相对路径 ⇒ 路径重复拼接:
-- .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify
-- 于是 WebSocket 永远连不上(noVNC 显示连接失败,页面看似白屏)。
-- 只用相对名,与 noVNC 默认值一致。
local proxyPath = base .. "/vnc.html?autoconnect=1&resize=scale&password=" .. pass
.. "&path=websockify"
return {
view_url = proxyPath,
proxy_path = proxyPath,
proxy_url = base .. "/vnc.html",
session_id = sess,
vnc_target = ("http://127.0.0.1:%d"):format(wport),
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(它在两条路径上共用)。
--
-- ★ 这里曾经有一段**自己发明的**「授权码交换」:回调只带 code 时,拿
-- exchange_body 去 ExchangeToken。那是错的,而且是稳定 400:
-- · ExchangeToken 的签名是 RefreshToken,不是 code;
-- · exchange_body 里根本没有 {code} 占位符,替换是空操作。
-- 结果是日志回一句误导性的「授权码交换失败: 400」,把真因
-- 「上游走了 PKCE 流程」掩盖成「交换出错了」。
--
-- 按参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法:
-- 带 code 的回调是**合法回调**,只是我们不支持的 PKCE 分支,
-- 要**明确报出来**而不是尝试一个不存在的交换。
-- traeCallback 已内建这个判定(返回「上游返回了 PKCE 授权码…」)。
credential, account, cerr = traeCallback(plat, cb, s)
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,
-- 同 syncAccountSource:串行 + 节流(见那里的长注释)
max_concurrent = 1,
rpm = plat.rpm or 20,
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))
-- covers_today:本次结果是否**覆盖"今天"这一轮**(缺省 true)。
-- 缺省视为 true 是刻意的:绝大多数渠道是"每日签到"语义,一行都不用改。
-- 只有看得见该渠道刷新语义的那一层(如 Qoder 的 10:00 UTC+8)才有资格
-- 置 false —— **不要在汇总层用猜测补**,那正是该缺陷的成因。
local covers = spec.covers_today
if covers == nil then covers = true end
local outcome = { kind = spec.kind, message = spec.message, covers_today = covers }
-- 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/保险库与钩子看到的是同一份。
-- 周期任务表。三项,各自理由见下(参考 index.ts / models.ts 的调度对照):
--
-- auto_claim 每天 08:00 领取额度(参考 auto-checkin 的日频语义)
-- refresh_models 每 2 小时刷新远端模型目录(参考 MODEL_REFRESH_INTERVAL_MS)
-- refresh_creds 每 30 分钟检查凭据续期(参考 REFRESH_INTERVAL_MS)
--
-- ⚠️ 「启动即跑一轮」由调度器的 first_due_now 标记保证(见 plugin_runtime.go
-- 的 firstDue):参考点名过这个缺陷 —— 短寿命凭据(cline 1 小时、codearts
-- 约 2 小时、raccoon 3 小时)在宿主停机期间早就过期,只用 interval 的话
-- 重启后要等满一个周期才续期,期间一直显示「已过期」。
plugin.schedule = {
-- ⚠️ 每 2 小时而非每天一次:活动刷新时刻**各平台不同**(Qoder 是每日
-- 10:00 UTC+8,参考实测)。一天只跑一次的话,刷新时刻晚于那次执行、
-- 或当天那次恰好没覆盖到的平台,就**整天漏领**。
-- 已覆盖今天的平台会在 claimCovered 里跳过(不会重复打上游)。
{ fn = "auto_claim", every = 7200, first_due_now = true, jitter = 300 },
{ fn = "refresh_models", every = 7200, first_due_now = true, jitter = 120 },
{ fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 },
}
-- ============================================================================
-- ============================================================================
-- ============================================================================
-- ============================================================================
-- ============================================================================
-- 模型能力元数据(照参考 opencode-capability.ts)
-- ============================================================================
-- 数据源:models.dev 的能力表。`/zen/v1/models` **只返回 4 个字段**
-- (id/object/created/owned_by),**不含任何能力信息** —— 能力在 models.dev。
--
-- 三个字段的用途(照参考):
-- modalities.input → 能力主源(['text'] / ['text','image'])
-- cost.input/output → **免费判定的权威来源**(两者同时为 0 = 免费)
-- limit.context/output → 上下文窗口与输出上限
--
-- ⚠️⚠️ **读取必须非阻塞**(参考的真机事故 2026-10-02):
-- 那个接口实测 **5.05 MB / 首字节 720ms / 下载 1.4s**。参考最初在模型列表渲染
-- 路径上直接 await 它,于是模型选择器要等 1.4s+ 才能出列表 ——
-- 表现是「点开是一片空白」,用户报障「选择模型还是点击没弹出列表」。
--
-- ⇒ 三条硬约定(改动前先读):
-- 1. **渲染路径上只能读同步缓存,永不等待网络**;
-- 2. 磁盘缓存冷启动直接命中;
-- 3. 拉取在**后台**进行(定时任务里),完成后下次读取自然生效。
--
-- ⚠️ 我们额外的一条:本插件是**服务端**进程、没有"渲染路径",
-- 但 5MB 的下载仍不能放在请求路径上 —— 故挂在 refresh_models 定时任务里,
-- 与模型目录刷新同一节拍。请求路径只读缓存。
local MODELS_DEV_URL = "https://models.dev/api.json"
local MODELS_DEV_TTL = 6 * 3600 -- 6 小时(照参考的 TTL 量级)
--
-- ⚠️ **已知取不到,属设计约束而非故障**:该端点实测 **5.64 MB**,而内核的
-- `__llmsproxy_http` 有 **4 MiB 响应上限**(`httpMaxResponse`,刻意的堆保护 ——
-- 不能让一个插件把整份数据拉进网关堆)。参考提到的"轻量分片端点"
-- (`/api/v1/opencode.json`)实测返回的是 **HTML 首页**(2.0 MB),不是 JSON。
--
-- ⇒ 能力元数据定位为**可选增强**:
-- * 拉得到 → isFree/上下文/模态用权威值;
-- * 拉不到 → 如实退回兜底表,且失败信息说明是"响应超限"(让运维知道
-- 这是上限约束,不是网络坏了)。
-- **不**为了它去放宽内核上限(那会让所有插件都能拉 5MB+ 进堆)。
-- normalizeCapability(id, raw):照参考 normalizeEntry 的字段口径。
--
-- ⚠️ **video / audio / pdf 一律降级为 text**:下游只认 text/image 两值,
-- 声明不存在的模态会投影出我们并不发送的内容。
local function normalizeCapability(id, raw)
if type(raw) ~= "table" then return nil end
local declared = {}
local mods = raw.modalities
if type(mods) == "table" and type(mods.input) == "table" then
for _, m in ipairs(mods.input) do declared[#declared+1] = tostring(m) end
end
local modalities = { "text" }
for _, m in ipairs(declared) do
if m == "image" then modalities = { "text", "image" } break end
end
local lim = type(raw.limit) == "table" and raw.limit or {}
local cost = type(raw.cost) == "table" and raw.cost or {}
local ci, co = tonumber(cost.input), tonumber(cost.output)
local efforts = {}
if type(raw.efforts) == "table" then
for _, e in ipairs(raw.efforts) do efforts[#efforts+1] = tostring(e) end
end
return {
id = id,
name = type(raw.name) == "string" and raw.name ~= "" and raw.name or id,
modalities = modalities,
contextWindow = tonumber(lim.context) or 0,
maxOutputTokens = tonumber(lim.output) or 0,
-- ⚠️ `reasoning` 与 `efforts` 必须一致:只有真拿到档位才报"支持思考"。
-- `reasoning=true` 但 `efforts` 为空会声明一个空选择器(UI 出现但无内容)。
reasoning = (raw.reasoning == true) and #efforts > 0,
efforts = efforts,
toolCall = raw.tool_call ~= false,
isFree = (ci ~= nil and ci == 0 and co ~= nil and co == 0),
}
end
-- capabilityOf(provider, modelId):**只读缓存**(绝不触发网络)。
-- 缓存未就绪时返回 nil —— 调用方据此退回兜底表,而不是等一个 5MB 的下载。
local function capabilityOf(provider, modelId)
local table_ = plugin.state.capabilities and plugin.state.capabilities[provider]
if type(table_) ~= "table" then return nil end
return table_[modelId]
end
-- refreshCapabilities():**后台**拉 models.dev 并落缓存(定时任务调用)。
-- 失败**不清缓存**(继续用上次的好数据)。
local function refreshCapabilities()
local cached = plugin.state.capabilitiesMeta
local now = os.time and os.time() or 0
if cached and cached.at and cached.at <= now and (now - cached.at) < MODELS_DEV_TTL then
return false, "未到刷新时间"
end
local resp, err = __llmsproxy_http({
url = MODELS_DEV_URL, method = "GET", timeout_ms = 30000,
headers = { ["Accept"] = "application/json" },
})
if err or not resp or resp.status ~= 200 then
local es = tostring(err or (resp and resp.status))
-- 把"响应超限"单独说清楚:那是**内核的刻意的堆保护**(4 MiB),不是网络故障。
-- 否则运维会去查网络、查白名单,而真正的原因是我们主动不放开这个上限。
if es:find("byte cap") then
return false, "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护,"
.. "不为此放宽)—— 能力增强不可用,已退回兜底表"
end
return false, "拉取失败: " .. es
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return false, "响应无法解析" end
-- 按 provider 分表:每个平台从自己的键下取(不同平台可共用同一数据源)
local out = {}
local n = 0
for _, plat in ipairs(allPlatforms()) do
local key = plat.capability_key or plat.id
local src = rec[key]
if type(src) == "table" then
local models = type(src.models) == "table" and src.models or src
local tbl = {}
for id, raw in pairs(models) do
local cap = normalizeCapability(tostring(id), raw)
if cap then tbl[tostring(id)] = cap n = n + 1 end
end
out[plat.id] = tbl
end
end
plugin.state.capabilities = out
plugin.state.capabilitiesMeta = { at = now, count = n }
markDirty()
return true, n
end
-- 通用件:TTL 缓存 + 串行队列(照参考 ttl-cache.ts / serial-queue.ts)
-- ============================================================================
-- 为什么抽出来:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"逻辑
-- (远端目录闸门、余额 60s 缓存、登录轮询……)。手写版本各写各的,
-- 边界条件(墙钟回拨、在飞去重、失败不缓存)容易漏 —— 集中一处才好审。
--
-- ⚠️ Lua 无协程,做不了"共享同一个 Promise"。这里的"在飞去重"用**时间戳**
-- 表达:记录 in_flight_at,短窗口内的重复请求直接跳过。等价效果,机制不同。
-- 这是刻意的取舍:能在单 VM 单锁里工作,且不引入任何 async 语义。
-- ttlCacheGet(namespace, key, ttl, loader):
-- * 命中且未过期 → 直接返回缓存值
-- * 在飞(短窗口内有同 key 的加载)→ 返回 nil(调用方自行降级,不要重复打)
-- * 否则调 loader(),把非 nil 结果缓存 ttl 秒
--
-- ⚠️ **失败不缓存**:loader 返回 nil 时不写缓存(让下次能重试),
-- 但会记 in_flight_at 防止**同一瞬间**的重复打。这与"失败进冷却"是两件事 ——
-- 冷却由调用方决定(如目录闸门的 30s),本助手只负责 TTL 与去重。
--
-- ⚠️ 墙钟回拨防护(照参考):`now < cached_at` 时视为已过期,而不是
-- "还没到"。回拨一天会让 `now - cached_at` 变成巨大负数 ⇒ 缓存永远命中 ⇒
-- 上游改了也自愈不了。
local function ttlCacheGet(namespace, key, ttl, loader)
plugin.state.ttlCache = plugin.state.ttlCache or {}
local ns = plugin.state.ttlCache[namespace]
if not ns then ns = {} plugin.state.ttlCache[namespace] = ns end
local now = os.time and os.time() or 0
local e = ns[key]
if type(e) == "table" then
-- 墙钟回拨:cached_at 在未来 ⇒ 视为已过期(否则永久命中)
if e.at <= now and (now - e.at) < ttl then
return e.value
end
ns[key] = nil
end
-- 在飞去重:同一瞬间的重复请求不重复打(Lua 无协程,用时间戳近似)
local inflight = plugin.state.ttlInflight or {}
plugin.state.ttlInflight = inflight
local ik = namespace .. ":" .. tostring(key)
local last = inflight[ik]
if last and last <= now and (now - last) < 1 then
return nil
end
inflight[ik] = now
local ok, value = pcall(loader)
if not ok or value == nil then return nil end
ns[key] = { at = now, value = value }
return value
end
-- ttlCacheInvalidate(namespace, key?):失效缓存(key 省略则整个 namespace)。
--
-- ⚠️ **必须一并清掉在飞标记**。否则:清缓存 → 同一秒内重载 → 被"在飞去重"
-- 挡住 → 返回 nil。调用方看到的是"刚清了缓存却拿不到值",而数据其实好好的
-- (实测踩到:改窗口后立即查余额得到 nil)。失效的语义是"忘掉这次加载",
-- 在飞标记也是这次加载的一部分。
local function ttlCacheInvalidate(namespace, key)
plugin.state.ttlCache = plugin.state.ttlCache or {}
if key == nil then
plugin.state.ttlCache[namespace] = {}
elseif plugin.state.ttlCache[namespace] then
plugin.state.ttlCache[namespace][key] = nil
end
local inflight = plugin.state.ttlInflight
if inflight then
if key == nil then
local prefix = namespace .. ":"
for k in pairs(inflight) do
if k:sub(1, #prefix) == prefix then inflight[k] = nil end
end
else
inflight[namespace .. ":" .. tostring(key)] = nil
end
end
end
-- serialQueue(namespace):取一个串行队列的锁状态。
--
-- 语义(照参考 serial-queue):同一时刻只有一个任务在跑,其余**排队**。
-- Lua 无协程 ⇒ 无法"排队等待",只能做到**互斥 + 让路**:抢不到就立刻返回 nil,
-- 由调用方跳过本次(对定时任务与轮询是正确语义:下一次会重试)。
--
-- ⚠️ 必须带**过期时间**:持有者崩了/超时了,锁必须能自解,否则该 namespace
-- 永久卡死(参考用 promise finally 兑现,我们只能用 TTL)。
local function serialTryAcquire(namespace, ttl)
plugin.state.serialLock = plugin.state.serialLock or {}
local now = os.time and os.time() or 0
local held = plugin.state.serialLock[namespace]
if held and held <= now and (now - held) < (ttl or 120) then
return false
end
plugin.state.serialLock[namespace] = now
return true
end
local function serialRelease(namespace)
if plugin.state.serialLock then plugin.state.serialLock[namespace] = nil end
end
-- 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts)
-- ============================================================================
-- 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」),
-- 而**服务端扣哪个包不由我们决定**。插件能做的是决定**用哪个号**:
-- * 优先用「还有 15 天内到期积分」的号(那部分再不用就作废)
-- * 只剩永久积分的号排最后
-- * **锁定永久积分**时,只剩永久积分的号**等同于不可用**
--
-- 为什么是 15 天(实测两站差别极大,参考有对照表):
-- WorkBuddy 5 个号:Bonus Pack 250(8~9 天)+ Free Plan 100(3008 天)
-- → 锁定后有 4 个号仍可用,1 个(Bonus Pack 已耗尽)不可用,正是期望效果
-- CodeBuddy 中国版 1 个号:裂变包共 10064.6,**距到期最少 17 天**
-- → 17 > 15 ⇒ 全算永久 ⇒ 锁定后立刻「无可用账号」
-- 中国版按 30 天发包,故"距到期"天然落在 17~30 天 —— 判据本身没错,这类池
-- 想用锁定就得把窗口放宽(如设 31)。窗口**只能由用户显式改**,默认 15。
local EXPIRING_WINDOW_DAYS_DEFAULT = 15
expiringWindowDays = function()
-- 优先级:settings(面板可改)→ env(部署可改)→ 默认 15。
--
-- ⚠️ **不能**写成 `tonumber(raw) or 默认`:0 是合法值(表示"没有临时积分",
-- 即全部算永久)。参考在这一点上专门写了注释。
local cfg = plugin.state.settings and plugin.state.settings.expiring_window_days
if cfg ~= nil then
local n = tonumber(cfg)
if n and n >= 0 then return n end
end
-- ⚠️ 本 VM 的 os 表**没有** setenv(实测:只有 clock/date/difftime/execute/
-- exit/getenv/remove/rename/setlocale/time/tmpname),所以面板改值必须走
-- settings 而不是"设环境变量"。getenv 只用于读取部署期注入的值。
local raw = os.getenv and os.getenv("DSH_BUDDY_EXPIRING_WINDOW_DAYS")
if type(raw) == "string" and raw ~= "" then
local n = tonumber(raw)
if n and n >= 0 then return n end
end
return EXPIRING_WINDOW_DAYS_DEFAULT
end
-- balanceTier(expiring, permanent, allow_permanent):
-- "expiring" 有 15 天内到期的积分 → 最优先(不用就作废)
-- "permanent" 只剩永久积分 → 次优先;锁定永久积分时视为不可用
-- "none" 都没有 → 不可用
local function balanceTier(expiring, permanent, allow_permanent)
local function pos(v)
local n = tonumber(v)
if n and n > 0 then return n end
return 0
end
if pos(expiring) > 0 then return "expiring" end
if allow_permanent == false then return "none" end
if pos(permanent) > 0 then return "permanent" end
return "none"
end
local function tierUsable(tier)
return tier ~= "none"
end
-- 余额缓存(TTL 60 秒,照参考)。**只缓存原料,绝不冻结分类** ——
-- 「临时/永久」是**当前时刻的函数**(到期时间 - now 与窗口比较),而宿主长期
-- 开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就
-- 越过了线。若把分类也缓存,选号会继续按"永久"处理一笔马上要作废的积分
-- (锁定时更糟:本该可用的号被判成不可用)。故命中缓存也**每次重新分桶**。
local BALANCE_CACHE_TTL = 60
-- (余额缓存改用通用 TTL 缓存后,这个助手不再需要)
-- accountBalanceRaw(provider, accountId):取该账号的余额原料(带 TTL 缓存)。
-- 失败**不抛错**(照参考:查询失败归入最后一档),返回 nil 让调用方降级。
local function accountBalanceRaw(provider, accountId)
-- 用通用 TTL 缓存(照参考 ttl-cache):命中即返回、在飞去重、失败不缓存。
-- ⚠️ 缓存的是**原料**(包列表明细),分桶每次都现算 —— 见本节的注释。
local plat = platformById(provider)
local acct = poolGet(provider, accountId)
if not plat or not acct or not plat.balance then return nil end
local tok = credentialOf(acct.credentialRef)
if not tok or tok == "" then return nil end
local plate = plat
local act = acct
return ttlCacheGet("balance", provider .. ":" .. accountId, BALANCE_CACHE_TTL, function()
-- 复用既有的余额抓取(credits_balances 用的同一个助手,避免两处口径漂移)
local rec, err = fetchAccountJSON(plate.balance, tok,
credentialFull(act.credentialRef), plate)
if err or type(rec) ~= "table" then return nil end
if plate.balance.tier_fn then
-- 平台声明了分档提取器(buddy/loomy 的包列表要按到期时间现算)
local okf, t = pcall(plate.balance.tier_fn, rec)
if okf and type(t) == "table" then return t end
return nil
end
if plate.balance.total_fn then
-- 只有总量的平台:全部算"永久"(无到期信息 ⇒ 不会凭空造出临时档)
local okf, total = pcall(plate.balance.total_fn, rec)
if okf and type(total) == "number" then
return { permanent_balance = total, expiring_balance = 0 }
end
end
return nil
end)
end
-- rankAccounts(provider):按"积分何时作废"优先排序该平台的账号。
-- 返回 {usable={...}, all={...}}。查询失败的账号归入最后一档(照参考)。
local function rankAccounts(provider)
local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits
local allowPermanent = not lockPermanent
local expiring, permanent, none = {}, {}, {}
for _, a in ipairs(poolList(provider)) do
if a.enabled ~= false then
local raw = accountBalanceRaw(provider, a.id)
local tier
if raw == nil then
tier = "none" -- 查不到 → 最后(不编造)
else
tier = balanceTier(raw.expiring_balance or raw.expiringBalance,
raw.permanent_balance or raw.permanentBalance, allowPermanent)
end
if tier == "expiring" then expiring[#expiring+1] = a
elseif tier == "permanent" then permanent[#permanent+1] = a
else none[#none+1] = a end
end
end
local all = {}
for _, a in ipairs(expiring) do all[#all+1] = a end
for _, a in ipairs(permanent) do all[#all+1] = a end
for _, a in ipairs(none) do all[#all+1] = a end
-- 可用 = 临时档(永远)+ 永久档(仅未锁定时)
local usable = {}
for _, a in ipairs(expiring) do usable[#usable+1] = a end
if allowPermanent then
for _, a in ipairs(permanent) do usable[#usable+1] = a end
end
return { usable = usable, all = all, expiring = #expiring,
permanent = #permanent, none = #none,
locked = lockPermanent and true or false }
end
-- 已失效模型(照参考 dead-model-store.ts)
-- ============================================================================
-- 解决的真实报障:用户选中某平台的兜底模型,每轮都 404 ——
-- 该模型**已被上游下架**,但模型列表里仍显示、还标着"免费"。
-- 根因:各平台的兜底表是**编译期快照**,上游下架时不会自己跟着变。
--
-- ⚠️ **不能用「远端目录里没有 ⇒ 已下架」来剔除**(参考文件头实测):
-- 那个判据只在"远端目录完整权威"的平台上成立。buddy 那类平台的远端目录
-- **已知残缺**(CLI token 只拿得到 13 个内部别名,IDE 有 20 个),
-- 按目录比对会把"你有权用、只是 token 看不到"的模型删掉 —— 参考作者已为此
-- 踩过一次。qoder 更是**没有**可比对的远端目录。
-- ⇒ 唯一跨平台安全的判据是**阳性证据**:这个模型**真的**请求失败并返回
-- 「模型不存在」时,才记为失效。**绝不从残缺目录反推。**
-- 判定为"模型已失效"的正向模式(照参考 GONE_PATTERNS 逐条)。
--
-- ⚠️ `model_not_found` 必须要求它**独占一句话成分**:只加词边界不够 ——
-- 它在遥测/日志文案里常作为**字段名**出现,后面紧跟别的内容:
-- error_class=model_not_found, retry allowed → 初版误判下架
-- model_not_found_reason=upstream → 初版误判下架
-- 误判会把**可用模型藏 30 天**、用户须手动恢复。故后面只允许结束、句末标点
-- 或收尾括号/引号,**不含逗号**。
local DEAD_GONE_PATTERNS = {
"model%s+not%s+found",
"unknown%s+model",
"no%s+such%s+model",
"nonexistent%s+model",
}
-- 反向模式:命中即**不是**下架(照参考 NOT_GONE_PATTERNS)。
-- 逐条都有理由,不要删:
-- * rate limit / quota / insufficient → 换时段或加额度就能用
-- * unauthor/forbidden/invalid token → 认证问题,不是模型不存在
-- * `expired` **必须带主语**(token/session/credential/key/auth/lease),
-- 否则会误杀正常的下架文案:`model expired-v3 does not exist`、
-- `the model lease expired`(租约到期也是下架的一种说法)
local DEAD_NOT_GONE_PATTERNS = {
"rate%s*%-?%s*limit",
"too%s+many%s+requests",
"quota",
"insufficient",
"unauthor", "forbidden", "invalid%s+token", "invalid%s+api%s*key", "invalid%s+credential",
"token%s+expired", "session%s+expired", "credential%s+expired", "key%s+expired",
"auth%s+expired", "lease%s+expired",
"expired%s+token", "expired%s+session", "expired%s+credential", "expired%s+key",
"expired%s+auth",
}
-- isModelGone(message):把一段错误文本判定为"模型已失效"。
--
-- 返回 true 的**唯一**条件是出现阳性证据;拿不准一律 false ——
-- 误判的代价是把可用模型藏 30 天,漏判的代价只是用户再看到一次 404。
local function isModelGone(message)
if type(message) ~= "string" or message == "" then return false end
local m = message:lower()
for _, pat in ipairs(DEAD_NOT_GONE_PATTERNS) do
if m:find(pat) then return false end
end
for _, pat in ipairs(DEAD_GONE_PATTERNS) do
if m:find(pat) then return true end
end
-- `model_not_found` 形态:要求后面只剩结束/句末标点/收尾括号引号。
-- Lua 的 find 无前瞻,故手动定位后检查紧随的字符。
local from = 1
while true do
local a, b = m:find("model[_%-]not[_%-]found", from)
if not a then break end
local rest = m:sub(b + 1, b + 1)
-- 只允许:结束、句末标点、收尾括号/引号。**不含逗号**
-- ("…not_found, retry allowed" 里逗号后仍是另一个字段 ⇒ 不算阳性)。
-- 字符类里 `]` 必须放最前或转义;这里直接列字符以免 Lua 解析歧义。
-- 用**单引号**的 Lua 串,避免字符类里的双引号提前终止字符串。
if rest == "" or rest:find('[%]%)%}%.!%;"]') then return true end
from = b + 1
end
-- 「id + 紧邻的不存在语义」:只认 found/exist。
-- ⚠️ 不认 available/supported —— 那是"换套餐/换地域就能恢复",不是下架。
if m:find("does%s+not%s+exist") or m:find("doesn't%s+exist") then return true end
return false
end
-- 失效记录 TTL(秒)。照参考默认 30 天;过期自动复活,避免永久隐藏。
local DEAD_MODEL_TTL_DAYS = 30
local function deadModelsOf(provider)
plugin.state.deadModels = plugin.state.deadModels or {}
local p = plugin.state.deadModels[provider]
if not p then p = {} plugin.state.deadModels[provider] = p end
return p
end
-- recordDeadModel:记录一次**阳性**证据(真正 404 了)。
-- 返回 true 表示这是新记录(调用方据此决定要不要重同步源)。
local function recordDeadModel(provider, modelId, reason)
if not modelId or modelId == "" then return false end
local p = deadModelsOf(provider)
if p[modelId] then return false end
p[modelId] = { at = os.time and os.time() or 0, reason = tostring(reason or ""):sub(1, 120) }
return true
end
-- deadModelIds(provider):当前仍有效的失效模型 id 集合(已过 TTL 的自动清掉)。
deadModelIds = function(provider)
local p = deadModelsOf(provider)
local now = os.time and os.time() or 0
local ttl = DEAD_MODEL_TTL_DAYS * 86400
local out = {}
for id, meta in pairs(p) do
local at = type(meta) == "table" and tonumber(meta.at) or 0
if ttl > 0 and (now - at) > ttl then
p[id] = nil -- 过期复活(照参考 TTL 语义)
else
out[id] = true
end
end
return out
end
-- clearDeadModels(provider, modelId?):清失效记录(不带 id 则清该平台全部)。
local function clearDeadModels(provider, modelId)
local p = deadModelsOf(provider)
if modelId and modelId ~= "" then
local had = p[modelId] ~= nil
p[modelId] = nil
return had and 1 or 0
end
local n = 0
for _ in pairs(p) do n = n + 1 end
plugin.state.deadModels[provider] = {}
return n
end
-- 远端目录加载闸门(照参考 remote-catalog-gate.ts)
-- ============================================================================
-- 参考明确点名「**不能省**」,理由实测:
-- 宿主会对**每个** provider 拉目录、再对**每个模型**解析一次,于是「目录没拿到」
-- 被放大成**一次请求 × 每个模型**。若失败时不落任何标记,下一次还会再拉一遍,
-- 而这些拉取的超时是 30–60s 量级 —— 未登录/离线时的表现就是「首屏巨长」。
--
-- 三道语义(照参考):
-- ① 命中缓存:有远端目录就直接用(不在本闸门内)
-- ② 并发去重:同一次加载的多个调用共享同一次
-- ③ 失败/空结果**冷却**:避免短时间内反复打同一个坏上游
--
-- ⚠️ 冷却只影响**拉取时机**,不影响兜底目录:冷却期内照旧回落到静态表,
-- 界面不会因此少模型。
--
-- ⚠️ 本 Lua 无协程(单 VM 单锁),做不了"共享同一个 Promise"。用**时间戳
-- 闸门**达到等价效果:记录 in_flight_at(防同轮重复)+ retry_at(失败冷却)。
local REMOTE_CATALOG_COOLDOWN = 30 -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS
local function catalogGateOf(provider)
plugin.state.catalogGate = plugin.state.catalogGate or {}
local g = plugin.state.catalogGate[provider]
if not g then g = {} plugin.state.catalogGate[provider] = g end
return g
end
-- catalogGateCooling(provider):是否处于"上次没拿到目录"的冷却窗口内。
--
-- ⚠️ 照参考的墙钟回拨防护:`retry_at` 用墙钟算,系统时间被回拨时
-- `now < retry_at` 会把冷却**拉长**(回拨一天就是事实上的永久卡死)。
-- 故只认"剩余量不超过一个冷却周期"的窗口,超长的按已到期处理。
local function catalogGateCooling(provider, now)
local g = catalogGateOf(provider)
if not g.retry_at then return false end
if now >= g.retry_at then return false end
if g.retry_at - now > REMOTE_CATALOG_COOLDOWN then return false end
return true
end
-- catalogGateBegin(provider):尝试进入一次加载。返回 true 表示"本次由你拉"。
local function catalogGateBegin(provider, now)
local g = catalogGateOf(provider)
if catalogGateCooling(provider, now) then return false end
-- 同一轮内的并发去重:正在飞就不再发起(参考的 inFlight)
if g.in_flight_at and (now - g.in_flight_at) < 10 then return false end
g.in_flight_at = now
return true
end
-- catalogGateEnd(provider, got, now):收尾。拿到目录清冷却,没拿到进冷却。
local function catalogGateEnd(provider, got, now)
local g = catalogGateOf(provider)
g.in_flight_at = nil
g.retry_at = got and nil or (now + REMOTE_CATALOG_COOLDOWN)
end
-- refresh_models():定时把远端模型目录拉回来并重同步所有源。
--
-- 为什么需要它:模型目录写进了**上游源**(源的 models 决定网关能路由哪些
-- 模型)。不刷新就只能用兜底表 —— 平台上新模型后网关永远看不到,
-- 表现为"某个新模型 404 / 不可选"。
function plugin.refresh_models()
local updated, failed, cooled = 0, {}, 0
local now = os.time and os.time() or 0
for _, plat in ipairs(allPlatforms()) do
if plat.models_remote then
-- 目录闸门:冷却期内不拉(避免对坏上游反复打;照参考 remote-catalog-gate)
if not catalogGateBegin(plat.id, now) then
cooled = cooled + 1
goto continue
end
local ids, err = refreshRemoteModels(plat.id)
catalogGateEnd(plat.id, ids ~= nil, now)
if ids then
updated = updated + 1
-- 目录变了就重同步该平台所有源(模型清单在源里)
for _, a in ipairs(poolList(plat.id)) do
syncAccountSource(plat.id, a.id)
end
else
failed[#failed + 1] = plat.id .. ": " .. tostring(err)
end
end
::continue::
end
-- 冷却期内的平台不算失败(它们上次已经记过原因,重复记会淹没真正的错误)
if cooled > 0 then
logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台")
end
-- 顺带刷新模型能力元数据(照参考 opencode-capability)。
-- ⚠️ 必须在这里(后台定时任务)而不是请求路径 —— 见该节的"非阻塞"约定。
local cok, cres, cerr = pcall(refreshCapabilities)
if not cok then
-- ⚠️ 失败**必须留痕**:此前这里只有 `pcall` 不管返回值,于是"出站未授权"
-- 这类错误被静默吞掉,表现是"能力元数据永远是空",排查时零线索。
logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120))
elseif cres == true then
logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)")
elseif cerr then
logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120))
end
if updated > 0 then
logEvent("模型目录已刷新(" .. updated .. " 个平台)")
end
-- 失败留痕但**不清缓存**(refreshRemoteModels 内部已保证):
-- 拉不到时继续用上次的好数据,绝不能把目录清空。
if #failed > 0 then
logEvent("模型刷新失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 200))
end
end
-- refresh_creds():定时检查并续期凭据。
--
-- ⚠️ **不看 enabled**(照参考的既有铁律):停用只影响自动选号,不该让凭据
-- 停止续期。否则"所有账号都被停用"时续期根本不跑,凭据一路烂到
-- refresh_token 失效、只能重新登录。
--
-- ⚠️ **只对「距过期不足 lead 时间」的账号发请求**(照参考 expiry-sync 的
-- shouldRefreshNow):每次无脑续期会在启动时打出几十个无谓请求。
-- 没有过期时间的账号只做本地对账,不发请求。
function plugin.refresh_creds()
local lead = 3600 -- 提前 1 小时续(照参考的 lead 语义)
local now = os.time and os.time() or 0
local refreshed, skipped, failed = 0, 0, {}
for _, plat in ipairs(allPlatforms()) do
if plat.exchange_body or plat.refresh_url then
for _, a in ipairs(poolList(plat.id)) do
local cred = credentialFull(a.credentialRef)
if type(cred) == "table" then
local exp = tonumber(a.expiresAt) or credentialExpiresAtMs(cred)
local due = exp == nil or exp == 0
if not due then
-- expiresAt 单位可能是毫秒;统一成秒再比
local e = exp > 1e11 and math.floor(exp / 1000) or exp
due = (e - now) <= lead
end
if due then
local ok, err = plugin.account_refresh({ platform = plat.id, account_id = a.id })
-- ⚠️ 判据用 `refreshed`,**不是** `ok`:account_refresh 在"平台未
-- 声明续期方式"时也会回 ok:true(源照旧同步),用 ok 计数就会把
-- "没续"算成"续了" —— 这正是此前日志说谎的机制。
if ok and ok.json and ok.json.refreshed then refreshed = refreshed + 1
else
skipped = skipped + 1
local why = ok and ok.json and ok.json.message or err
if why and why ~= "该平台未声明续期方式" then
failed[#failed+1] = plat.id .. ": " .. tostring(why)
end
end
else
skipped = skipped + 1
end
end
end
end
end
if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个") end
if #failed > 0 then
logEvent("凭据续期失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 300))
end
end
-- todayKey():当天标识(UTC+8 口径,与活动结算口径一致)。
-- ⚠️ 必须用**算术平移**判 UTC+8,不能用 os.date("*t")(本机时区)——
-- 出差/改时区会把日界判错方向。
local function todayKey()
local nowMs = (os.time and os.time() or 0) * 1000
local utc8 = os.date("!*d", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
local utc8m = os.date("!*m", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
local utc8y = os.date("!*Y", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
return tostring(utc8y) .. "-" .. tostring(utc8m) .. "-" .. tostring(utc8)
end
-- claimCoversToday(resp):这次领取是否**证明今天这一轮已被处理**。
-- ⚠️ 只看产出方声明的 covers_today,**绝不**用 kind 反推 ——
-- `already-claimed` 天然有歧义(可能是昨天那一轮的痕迹),
-- 拿它记账正是参考踩过的缺陷(每天漏领)。
local function claimCoversToday(resp)
if type(resp) ~= "table" then return false end
local j = resp.json
if type(j) ~= "table" then return false end
local o = j.outcome
if type(o) ~= "table" then
-- 通用路径(非 done 哨兵)没有 outcome:按"已处理"算(每日签到语义)。
return j.ok == true
end
-- 只有明确而成功的结果才算覆盖今天
if o.covers_today == false then return false end
local k = o.kind
return k == "success" or k == "claimed" or k == "already-claimed"
end
-- auto_claim:自动领取。可被调度器多次调用(见 schedule 的 every)。
--
-- ★ 关键修正(照参考 credits.ts 的 coversToday 与 auto-checkin.ts 的
-- shouldMarkToday):**「今天已被处理」不能用 `claimed + already_claimed` 计数**。
--
-- 真实缺陷(参考 2026-10-02 审查 PR !33 定位):Qoder 的活动**每日 10:00(UTC+8)
-- 才刷新**。上午那一轮看到的是**昨天**那条 CLAIMED,返回 `already-claimed`;
-- 若拿它当"今天已处理"的凭证记档,当天 10 点刷新后就再也不会去领 ——
-- **每天静默漏掉 100 积分**。
--
-- 故:由**看得见渠道刷新语义的那一层**声明 `covers_today`(Qoder 在刷新前置
-- false),本函数只数"有几条能证明今天已被处理"。缺省 true ⇒ 绝大多数渠道
-- (每日签到语义)行为完全不变。
--
-- 不"给每个渠道配一张刷新时刻表":那必然漂移,而产出方本来就看得见刷新语义。
function plugin.auto_claim()
-- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。
plugin.state.settings = plugin.state.settings or {}
if plugin.state.settings.auto_checkin == false then return end
local covered, pending = {}, {}
for _, plat in ipairs(allPlatforms()) do
local cap = CHANNEL_CREDITS[plat.id]
if not cap or not cap.claim then goto continue end
-- auto_exclude:领取有**代价**的平台(zcode 每次领取现场产阿里云 captcha,
-- headful Chromium + 按设备限流 150 次/小时)。代价发生在**调用期间**,
-- "等它报错再判定"来不及,故必须在调之前排除(照参考
-- isAutoCheckinExcluded)。用户仍可手动领取。
if cap.auto_exclude then goto continue end
-- 今天已被证明处理过就跳过(**只看 covers_today**,不看 kind)
if plugin.state.claimCovered and plugin.state.claimCovered[plat.id] == todayKey() then
covered[#covered + 1] = plat.id
goto continue
end
local got = false
local legacy = accountOf(plat.id)
if legacy and legacy.enabled then
local r = plugin.claim_daily({ platform = plat.id })
if claimCoversToday(r) then got = true end
end
for _, a in ipairs(poolList(plat.id)) do
if a.enabled ~= false and credentialOf(a.credentialRef) then
local r = plugin.claim_daily({ platform = plat.id, account_id = a.id })
if claimCoversToday(r) then got = true end
end
end
if got then
plugin.state.claimCovered = plugin.state.claimCovered or {}
plugin.state.claimCovered[plat.id] = todayKey()
markDirty()
else
pending[#pending + 1] = plat.id
end
::continue::
end
-- 还有平台**未覆盖今天**(如 Qoder 尚未到 10:00 刷新)⇒ 下次调度再试。
-- 这正是"每天漏领"的修法:不把"刷新前那一轮"当作已完成。
if #pending > 0 then
logEvent("自动领取:已覆盖 " .. #covered .. " 个平台,"
.. #pending .. " 个待重试(" .. table.concat(pending, ", "):sub(1, 120) .. ")")
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/clear_credentialless", method = "POST", handler = "account_clear_credentialless",
roles = { "admin" }, description = "清理无凭据账号(占位;dry_run 默认开启)" },
{ 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/adopt", method = "POST", handler = "account_adopt",
help = "把旧版非池凭据接管进账号池(升级后账号消失时用)" },
{ 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/proxy/:session", method = "GET", prefix = true,
handler = "browser_proxy", roles = { "admin" },
description = "把会话的 noVNC 静态资源与 WebSocket 经网关同端口转发(不暴露 VNC 端口)" },
{ path = "channels/browser/collect", method = "POST", handler = "collect_credentials",
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
{ path = "channels/reap_loopbacks", method = "POST", handler = "reap_loopbacks",
roles = { "admin" }, description = "回收所有回环回调接收器(防测试/异常退出后泄漏)" },
{ 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_clear_credentialless:批量清掉**没有任何凭据**的账号。
--
-- ## 为什么需要它(真实场景)
-- account_add 允许**不带凭据**创建账号(设计如此:"未带凭据的等 login 完成"),
-- 于是"先建占位、再走登录补齐"是正常用法。但登录若没走完(用户放弃、或历史批量
-- 导入),就会留下**只有元数据、永远不可用**的账号:它们占着 provider 的账号计数
-- (面板显示"10 个账号"却全不可用),让"这个平台到底能不能用"变得难以判断。
-- 实测生产有 12 个这种占位(codearts 10 + trae 2),13 个账号里只有 1 个真可用。
--
-- ## 安全性(**破坏性**操作,必须能证明它只删该删的)
-- ① 只删没有凭据的:有凭据的账号**一律不动**(哪怕它测试失败 —— 那是"可疑"
-- 而不是"空壳",两者处置完全不同);
-- ② dry_run **默认开启**:不显式传 dry_run=false 就只回报会删哪些,不真删;
-- ③ 复用 plugin.account_delete(同一份删除实现)—— 删除时要顺带做的事
-- (drop source、清旧单账号源、删凭据)不能另写一套,否则必然漂移。
-- ⚠️ 不写成顶层 `local`:本文件顶层 local 数已临近 Lua 的单函数 200 上限
-- (加它会直接 luac -p 报 "too many local variables")。
-- 挂在 plugin 表上同样只有一份实现,且不占顶层名额。
plugin.__hasCredential = function(acct)
if not acct or not acct.credentialRef then return false end
local v = __llmsproxy_secrets.get(acct.credentialRef)
return type(v) == "string" and v ~= ""
end
function plugin.account_clear_credentialless(req)
local args = bodyOf(req)
-- ⚠️ 不要照其它 handler 写 `if args.platform == nil then args = req end`:
-- 那个回退是为「直接把参数当 req 传」的旧调用准备的,但它会**丢掉 body**
-- —— 本函数不传 platform 是**合法用法**(清全部平台),此时 args.platform
-- 自然为 nil,回退会把 args 换成 HandlerRequest ⇒ 刚解出来的 dry_run 就没了,
-- 于是「传 dry_run=false 却被当成未传」而静默只预演(破坏性操作里最糟的形态:
-- 操作者以为执行了、实际没执行,或者反过来)。
-- 实测踩到:不带 platform 时 deleted 恒为 nil。
-- 兜底只认「req 本身就是参数表」这种形态。
if type(req) == "table" and req.body == nil and req.query == nil then args = req end
local dry = args.dry_run
if dry == nil then dry = true end
local platforms
if args.platform and args.platform ~= "" then
local plat = platformById(args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
platforms = { plat }
else
platforms = allPlatforms()
end
local victims, kept = {}, 0
for _, plat in ipairs(platforms) do
for _, a in ipairs(poolList(plat.id)) do
if plugin.__hasCredential(a) then
kept = kept + 1
else
victims[#victims + 1] = { platform = plat.id, id = a.id, nickname = a.nickname }
end
end
end
if dry then
return { status = 200, json = {
ok = true, dry_run = true, would_delete = #victims,
kept_with_credential = kept, accounts = victims,
message = "预演:将删除 " .. #victims .. " 个无凭据账号(有凭据的 " .. kept
.. " 个不动)。确认后传 dry_run=false 执行。",
} }
end
local done, failed = {}, {}
for _, v in ipairs(victims) do
local r = plugin.account_delete({ platform = v.platform, account_id = v.id })
if r and r.json and r.json.ok then
done[#done + 1] = v.platform .. "/" .. v.id
else
failed[#failed + 1] = v.platform .. "/" .. v.id
end
end
logEvent("清理无凭据账号 " .. #done .. " 个(失败 " .. #failed .. ")")
return { status = 200, json = {
ok = true, dry_run = false, deleted = #done, failed = #failed,
kept_with_credential = kept, deleted_accounts = done,
failed_accounts = #failed > 0 and failed or nil,
} }
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)
-- ⚠️ 删完最后一个账号时,必须**清掉旧版单账号源** `channels-<plat>`。
--
-- 真实缺陷(本次实测抓到):旧代码建的 `channels-<plat>` 源的 base_url 是
-- **旧口径**(buddy 缺 `/v2/chat/completions` 前缀)。它不随账号删除而消失,
-- 于是以同名模型对外服务,但打的是**错的 URL** ——
-- 症状是推理返回**空内容**(HTTP 200 但没有正文),而列表里模型看着正常。
-- 我在这上面绕了很久(一会儿 'OK' 一会儿 '',因为内核在多个源之间轮转)。
-- ⚠️ 只在池**已空**时清:还有账号时不该动它(它可能是运维手工配的兼容源)。
if #poolList(plat.id) == 0 then
__llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id })
end
logEvent("账号删除 " .. plat.id .. "/" .. acct.id)
return { status = 200, json = { ok = true } }
end
-- account_test:测账号(对齐 account.test)—— 做一次上游可达探测。
-- 简化:内核 probe_all 是全局的;这里用 list_sources 里该账号源存在与否 + 凭据非空作为可达判据。
-- account_test:**真打一次上游**探测该账号是否可用(对齐参考 account-probe 的
-- 「重测」语义)。
--
-- ⚠️ 语义修正(真实缺陷):此前这个函数**一个网络请求都不发** —— 它只做
-- syncAccountSource 然后查内核源列表里有没有那条记录,就返回 `{ok=true, ms=1}`。
-- 于是"测试账号"永远绿,包括凭据已失效、被限流、上游宕机的情况 ——
-- 正是「看着能用、一用就错」的那类假阳性。
--
-- 照参考的两点关键设计:
-- 1. **只针对指定账号、不产生副作用**:不能顺手把限流标记写到别的账号上。
-- 2. **停用账号也要能测**:用户明确要求(凭据解析不检查 enabled)。
-- 我们额外可选地按声明打一次真实请求(余额端点最轻,退化用 checkin_status)。
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 = "无凭据(需先登录)",
probed = false } }
end
-- 同步一次源(确保最新凭据已入)—— 这是本地动作,不是"测试"
syncAccountSource(plat.id, acct.id)
-- 真正的一次上游请求:优先余额端点(最轻、所有有余额的平台都有),
-- 退化到 checkin_status。两者都没有的平台如实报"无探测端点",
-- 不假装成功。
local spec = plat.balance or plat.checkin_status
if not spec or not spec.url then
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
return { status = 200, json = { ok = found, probed = false,
error = found and nil or "源未同步",
note = "该平台未声明可探测端点,仅校验本地凭据与源" } }
end
local t0 = os.clock and os.clock() or 0
local rec, err = fetchAccountJSON(spec, tok, credentialFull(acct.credentialRef), plat)
local ms = math.floor(((os.clock and os.clock() or 0) - t0) * 1000)
if err then
-- 失败要区分"凭据失效"(需重新登录)与"暂时性失败"(可重试)——
-- 只说"失败"用户不知道该做什么。
local kind = "transient"
local el = tostring(err):lower()
if el:find("401") or el:find("403") or el:find("unauthor")
or el:find("invalid_grant") or el:find("expired") then
kind = "auth"
end
return { status = 200, json = { ok = false, probed = true, ms = ms,
error = tostring(err), kind = kind,
action = kind == "auth" and "凭据已失效,请重新登录该账号" or "上游暂时不可用,可稍后重试" } }
end
return { status = 200, json = { ok = true, probed = true, ms = ms } }
end
-- account_adopt:把**非池**的旧凭据(`channels:<plat>`)接管进某个池账号。
--
-- 为什么需要它(真实场景):v2.0.x 之前登录只写一条 `channels:<plat>`(单账号
-- 模式),后来引入账号池后凭据落在 `channels:<plat>:<aid>`。升级后老凭据仍在
-- 保险库里、但池里没有账号引用它 —— 表现是"账号消失了"(数据其实还在)。
-- 重新登录当然可以,但那是**用户可见的额外代价**(要再走一次授权)。
--
-- ⚠️ 正确做法是让**插件自己读保险库**(`__llmsproxy_secrets.get`)再写进池引用。
-- **绝不能**把保险库文件里的密文当明文喂给 API —— 保险库读取时会解密,那样会
-- 把凭据**二次加密**(实测踩过:1971 → 2965 字符,该账号推理开始返回空)。
function plugin.account_adopt(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 legacyRef = "channels:" .. plat.id
-- 全程只用插件的保险库 API:读出的是**明文**(保险库负责解密)
local raw = __llmsproxy_secrets.get(legacyRef)
if not raw or raw == "" then
return { status = 200, json = { ok = false,
error = "没有可接管的旧凭据(" .. legacyRef .. ")" } }
end
local aid = args.account_id
if not aid or aid == "" then
return { status = 400, json = { error = "缺 account_id(要接管进哪个池账号)" } }
end
local acct = poolGet(plat.id, aid)
if not acct then
-- 允许直接建:占位 + 写入
local now = os.time and os.time() or 0
acct = { id = aid, provider = plat.id, nickname = args.nickname or aid,
enabled = true, credentialRef = "channels:" .. plat.id .. ":" .. aid,
refreshable = false, createdAt = now, last_claim = 0, last_claim_status = 0,
last_claim_summary = "" }
poolAdd(plat.id, acct)
end
-- 写入的是**明文**(raw)—— 保险库写入时自己加密。这正是要避免"手填密文"的原因。
local _, serr = __llmsproxy_secrets.set(acct.credentialRef, raw)
if serr then return { status = 200, json = { ok = false, error = serr } } end
-- 从凭据补齐展示字段
if type(raw) == "string" and raw:sub(1, 1) == "{" then
local dok, cred = pcall(json.decode, raw)
if dok and type(cred) == "table" then
acct.nickname = args.nickname or cred.nickname or cred.user_name or cred.user_id or acct.nickname
acct.refreshable = credentialIsRefreshable(cred)
local exp = credentialExpiresAtMs(cred)
if exp then acct.expiresAt = tostring(exp) end
end
end
poolAdd(plat.id, acct)
markDirty()
syncAccountSource(plat.id, aid)
logEvent("已接管旧凭据到 " .. plat.id .. "/" .. aid)
return { status = 200, json = { ok = true, account_id = aid,
credentialRef = acct.credentialRef, message = "旧凭据已接入账号池" } }
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
-- ★ 真续期:此前这里只重同步源、一个换 token 的请求都不发却恒返回 ok:true,
-- 于是 `refresh_creds` 打出「凭据已续期 N 个」而什么都没续 —— 日志在说谎。
local rok, rmsg = refreshCredential(plat, acct)
if not rok then
-- 失败必须**如实回错**(供 UI 显示与日志诊断)。但仍重同步源:
-- 旧 token 可能还没真过期,不该因为续期失败就把源撤掉。
local tok = credentialOf(acct.credentialRef)
if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end
return { status = 200, json = { ok = false, refreshed = false, message = rmsg } }
end
-- 续期成功 → 用**新 token** 重建源(旧源的 Authorization 头已失效)
syncAccountSource(plat.id, acct.id)
return { status = 200, json = { ok = true, refreshed = true, message = rmsg } }
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
-- 远端**权威**免费集合(照参考 cline-models.ts 的 isFree 判定链):
-- ① 远端 free 数组命中的 id
-- ② 命名约定 `cline-free/*`(远端未响应时的兜底 —— 参考明写这条)
-- ③ 内嵌 models_free / 远端 cost=0 的能力表(下面按顺序覆盖)
-- ⚠️ 参考点名:免费模型是**独立 id**(`cline-free/deepseek-v4.1-flash`),
-- 与收费的 `deepseek/deepseek-v4.1-flash` 是两个不同的 id。故这里判的是
-- **id 本身**,不剥离前缀。
local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil
local FREE_PREFIX = plat.free_prefix -- 命名约定(如 "cline-free/")
local out = {}
for i, m in ipairs(effectiveModels(plat.id)) do
if not hidden[m] then
-- 能力元数据优先(照参考 opencode-capability):
-- `isFree` 的**权威来源**是 models.dev 的 cost(input 与 output 同时为 0),
-- 不是我们硬编码的快照 —— 快照会随上游调整免费策略而过期。
-- 缓存未就绪时退回声明的 models_free,绝不触发网络(那是 5MB 的下载)。
local cap = capabilityOf(plat.id, m)
local is_free = false
-- ⚠️ **两个参考用的是不同机制**,不能混成一条链:
-- * **有自己免费名单的平台**(cline):远端 `free[]` 是唯一权威,
-- 再加命名约定与静态兜底表 —— 它们**不查 models.dev 的 cost**。
-- * **只有能力表可查的平台**(opencode:`/zen/v1/models` 连能力字段
-- 都没有):models.dev 的 `cost` **就是**唯一权威。
-- 故由平台声明 `free_source`,而不是我在这里猜一个通用规则
-- (猜的结果是两个参考都照顾不到)。
if plat.free_source == "capability" then
-- cost 权威:能力表说收费就必须是收费(本地声明只是能力表拉不到时的兜底)
if cap and cap.isFree ~= nil then
is_free = cap.isFree and true or false
else
is_free = free[m] and true or false
end
else
-- 声明链权威(OR 语义:任一来源确认免费即免费)。
-- 写成"后者覆盖前者"会让「远端说免费、能力表拉不到」静默退回收费。
if remoteFree and remoteFree[m] then is_free = true end
if not is_free and FREE_PREFIX and m:sub(1, #FREE_PREFIX) == FREE_PREFIX then
is_free = true
end
if not is_free and free[m] then is_free = true end
if not is_free and cap and cap.isFree == true then is_free = true end
end
local entry = { id = m, name = (cap and cap.name) or m,
disabled = disabled[m] and true or false,
isFree = is_free }
if cap then
entry.capabilities = {
modalities = cap.modalities,
contextWindow = cap.contextWindow,
maxOutputTokens = cap.maxOutputTokens,
reasoning = cap.reasoning,
efforts = cap.efforts,
toolCall = cap.toolCall,
}
end
out[#out+1] = entry
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 = 1,
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)。
fetchAccountJSON = function(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
-- ⚠️ 业务级错误必须在这里也判掉:多家上游**HTTP 200 但 body 里带 error**
-- (实测 buddy 的 invalid_grant 就是这个形态)。只看 HTTP 状态码的话,
-- "凭据已失效"会被当成成功 —— 账号测试于是永远报绿,这是最误导人的假阳性。
local e = rec.error
if type(e) == "string" and e ~= "" then return nil, e end
if type(e) == "table" then
local msg = e.message or e.msg
if type(msg) == "string" and msg ~= "" then return nil, msg end
return nil, "上游返回 error 对象"
end
-- 有些上游把错误放在 code/msg 里(code 非 0 即失败)
local code = rec.code
if code ~= nil then
local n = tonumber(code)
if n and n ~= 0 and n ~= 200 then
return nil, tostring(rec.msg or rec.message or ("业务码 " .. tostring(code)))
end
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)
-- model_clear_dead:清掉"已失效模型"的记录(照参考 clearDeadModels 语义)。
--
-- ⚠️ 语义修正:此前这里删的是"被禁用且不在目录里"的条目 —— 那既不是失效记录,
-- 判据也用错了(拿目录反推)。参考的 clear 是:把**有阳性 404 证据**的记录清掉,
-- 让模型重新出现在列表里(用户确认上游又上了/或误判时用)。
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 mid = args and args.model_id
local n = clearDeadModels(plat.id, mid)
markDirty()
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true, removed = n,
message = n > 0 and ("已恢复 " .. n .. " 个模型") or "无失效模型记录" } }
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 headers = {}
if key ~= "" then
headers["Authorization"] = "Bearer " .. key
end
local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000, headers = headers })
if err or not resp or resp.status ~= 200 then
return { status = 503, json = { ok = false, error = "usage snapshot unavailable: " .. tostring(err or (resp and 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 = 502, json = { ok = false, error = "usage snapshot 无法解析" } }
end
-- 汇总 token 总量。/api/stats 的聚合在 `total` 下,不在顶层数组中。
-- 之前错误地遍历 rec.requests/rows/data,导致该端点永远返回 0。
local total = type(rec.total) == "table" and rec.total or {}
local prompt = tonumber(total.prompt_tokens) or 0
local completion = tonumber(total.completion_tokens) or 0
local requests = tonumber(total.reqs) or 0
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 显示)。
-- reap_loopbacks:回收**所有**回环接收器。
--
-- 为什么需要显式入口:回环本来只在**会话过期**时由 reapLoopback 收掉
-- (sweepSessions 是惰性的,靠读取时顺带做)。于是有一条稳定的泄漏路径:
-- 测试或运维起了 login 会话后直接退出,会话从未「过期」⇒ 接收器进程常驻。
-- 实测本机积累到 995 个(其中 828 个超过 24 小时),全是 /tmp/Test*/ 下的
-- 测试残留 —— 生产路径的会被正常回收(因为生产会话真会过期)。
--
-- 对生产也有用:浏览器/授权页关掉后想立刻清理,不必等会话过期。
function plugin.reap_loopbacks(req)
local n = 0
for sid in pairs(plugin.state.loopbacks or {}) do
reapLoopback(sid)
n = n + 1
end
return { status = 200, json = { ok = true, reaped = n } }
end
function plugin.browser_status(req)
local b = browserStack()
local s = b.session
return { status = 200, json = { ok = true, running = s ~= nil, session = s } }
end
-- browser_proxy:前缀代理的每次请求入口。
-- 只回答「当前会话的回环目标」;回收后 session 为空,地址即失效(404),
-- 未运行的会话 id 也不会被转发(防目录穿越与跨会话探测)。
function plugin.browser_proxy(req)
sweepSessions()
local b = browserStack()
local s = b.session
local want = req and req.params and req.params.session or nil
if s and s.expires_at and s.expires_at <= os.time() then
reapBrowser()
s = nil
end
if not s or not s.ws or not s.sess or s.sess ~= want then
return { status = 404, json = { error = "没有这个浏览器会话" } }
end
return { proxy = ("http://127.0.0.1:%d"):format(s.ws) }
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 都空时才判,与参考一致。
--
-- ⚠️ 候选来源必须**逐个取第一个非空**(照参考 authCodeCandidates 的 5 个来源),
-- 不能用 `a or b or c` 链:jsonField 取不到时返回空串而非 nil,而 Lua 里
-- 空串是**真值**,链会在第一个空串处短路 —— 后续来源永远取不到。
-- authCodeInfo 可能是 JSON(含 code/authCode 字段),也可能是**纯 code 字符串**
-- (参考注释点名),故把原始值作为最后兜底。
local authCode = ""
do
local info = q.authCodeInfo or ""
for _, cand in ipairs({ q.code or "", q.authCode or "",
jsonField(info, "code"), jsonField(info, "authCode"),
info }) do
if type(cand) == "string" and cand ~= "" then authCode = cand; break end
end
authCode = authCode:gsub("^%s+", ""):gsub("%s+$", "")
end
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
]==]
-- ============================================================================
-- ============================================================================
-- loomy 领取:触发每日额度重置(照参考 loomy-credits.ts:claimLoomyDailyQuota)
-- ============================================================================
-- ⚠️ 语义是「触发每日额度重置」,**不是**「+5000 积分」:
-- dailyBalance = dailyQuota - dailyConsumed,消耗后不回补。
-- ⚠️ 幂等判据是 `alreadyProcessed`(重复调用同样 HTTP 200)→ 映射成
-- already-claimed,不是 claimed(谎报收益比报错更糟)。
-- 本函数不抛错:失败也返回 failed,保证批量领取不因单个账号中断。
function claimLoomy(plat, tok, args)
local cred = (args and args.credential) or nil
local base = (cred and (cred.api_base or cred.apiBase)) or "https://loomy.xunfei.cn"
local root = base:gsub("/chat/completions$", "")
local url = root .. "/points/first-login"
local resp, err = __llmsproxy_http({
url = url, method = "POST",
headers = {
["Authorization"] = "Bearer " .. tok,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
},
body = "{}",
})
if err or not resp then
return { done = true, kind = "failed", message = "领取失败: " .. tostring(err) }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then rec = {} end
if resp.status ~= 200 then
local msg = rec.message or rec.msg or ""
return { done = true, kind = "failed",
message = (msg ~= "" and tostring(msg)) or ("HTTP " .. tostring(resp.status)) }
end
local data = rec.data or rec
if type(data) ~= "table" then data = {} end
local dailyQuota = tonumber(data.dailyQuota)
local dailyBalance = tonumber(data.dailyBalance)
if data.alreadyProcessed == true then
return { done = true, kind = "already-claimed",
message = dailyQuota and
string.format("今日额度已初始化(每日 %s/%s)",
tostring(dailyBalance or 0), tostring(dailyQuota))
or "今日额度已初始化" }
end
local dailyConsumed = tonumber(data.dailyConsumed)
local granted = 0
if dailyQuota and dailyConsumed then
granted = math.max(0, dailyQuota - dailyConsumed)
end
return { done = true, kind = "claimed", credit = granted, unit = "quota" }
end
-- ============================================================================
-- zcode 领取:每日额度(照参考 zcode-upstream.ts:claimZcodePlan)
-- ============================================================================
-- 三步(照参考 zcode-auth.ts:claimDailyWith 的顺序):
-- ① 补激活上报(event/report,**不需要 Authorization**)
-- ② preview 查可领 plan(`?app_version=&platform=win32`)
-- ③ 逐个 plan POST billing/claim,body={plan_id}
--
-- ⚠️ claim **始终强制索要 captcha**(上游校验前置于 plan 校验)。参考的
-- captcha 有两个来源:DSH Desktop 的 webview guest 产的 param("内部载体"),
-- 或外挂 headful Chromium。**我们没有那个桌面壳** ⇒ 只能走浏览器路径。
--
-- ⇒ 因此:
-- * 本函数在**没有 captcha param 时如实失败**(不伪造、不重试),
-- 并给出可操作提示 —— 谎报"已领取"比报错更糟。
-- * auto_claim 已把 zcode 列进 auto_exclude(照参考
-- isAutoCheckinExcluded):代价发生在调用期间(拉 headful Chromium +
-- 阿里云按设备限流 150 次/小时),"等它报错再判定"来不及。
-- * 用户**手动**点领取时带上 captcha param 即可走通(面板交互)。
local ZCODE_ORIGIN = "https://zcode.z.ai"
-- zcodeHeaders:照参考 buildZcodeHeaders。X-Device-Mid 是**硬需求**
-- (缺它 billing 全家桶回 400)。
local function zcodeHeaders(cred, opts)
opts = opts or {}
local h = {
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["X-Device-Mid"] = tostring(cred.device_mid or ""),
["X-App-Version"] = tostring(cred.app_version or "1.0.0"),
["X-Client-Platform"] = "win32",
}
if opts.authorization then h["Authorization"] = opts.authorization end
if opts.captcha then
h["X-Captcha-Param"] = opts.captcha.param
h["X-Captcha-Region"] = opts.captcha.region or ""
end
return h
end
-- zcodeClaimablePlans(cred):查可领 plan(preview)。
local function zcodeClaimablePlans(cred)
local av = tostring(cred.app_version or "1.0.0")
local url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview"
.. "?app_version=" .. rfc3986Escape(av) .. "&platform=win32"
local resp, err = __llmsproxy_http({
url = url, method = "GET",
headers = zcodeHeaders(cred, {
authorization = "Bearer " .. tostring(cred.zcode_jwt or cred.access_token or ""),
}),
})
if err or not resp then return nil, "查询可领计划失败: " .. tostring(err) end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil, "响应不是 JSON" end
local plans = rec.plans or (rec.data and rec.data.plans) or {}
local out = {}
for _, pl in ipairs(plans) do
if type(pl) == "table" then
local id = pl.plan_id or pl.planId
if id then out[#out + 1] = tostring(id) end
end
end
return out, nil
end
function claimZcode(plat, tok, args)
local cred = (args and args.credential) or {}
local jwt = cred.zcode_jwt or cred.access_token or tok
cred = cred or {}
if not cred.zcode_jwt and not cred.access_token then cred.zcode_jwt = tok end
-- ① 补激活上报(照参考:不需要 Authorization)
__llmsproxy_http({
url = ZCODE_ORIGIN .. "/api/v1/event/report", method = "POST",
headers = { ["Content-Type"] = "application/json" },
body = json.encode({ event = "activation", app_version = tostring(cred.app_version or "1.0.0") }),
})
-- ② 查可领 plan
local plans, perr = zcodeClaimablePlans(cred)
if not plans then
return { done = true, kind = "failed", message = perr }
end
if #plans == 0 then
-- ⚠️ 照参考:「没有可领 plan」**不等于**「今天已领」—— 也可能活动未投放。
-- 说成 already-claimed 是谎报。
return { done = true, kind = "inactive",
message = "当前没有可领取的计划(可能活动未投放,或今日已领)" }
end
-- ③ 每个 plan 都要一个 captcha param。没有就如实失败。
local captcha = args and args.captcha
if type(captcha) ~= "table" or not captcha.param or captcha.param == "" then
return { done = true, kind = "action_required",
message = "zcode 领取需要验证码参数(captcha param)。"
.. "本机无 DSH Desktop 内部载体,需在面板手动领取(会拉起浏览器);"
.. "自动签到已按参考把它排除。" }
end
local granted, failedMsg = 0, nil
for _, planId in ipairs(plans) do
local resp, err = __llmsproxy_http({
url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST",
headers = zcodeHeaders(cred, {
authorization = "Bearer " .. tostring(jwt),
captcha = { param = captcha.param, region = captcha.region or "cn" },
}),
body = json.encode({ plan_id = planId }),
})
if err or not resp then
failedMsg = "领取失败: " .. tostring(err)
else
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" then
-- ⚠️ 业务码可能是**纯数字字符串**("3012"):ZD 归一。
local code = (ZD and ZD.parseBusinessCode(rec.code))
or tonumber(rec.code) or tonumber(tostring(rec.code or ""))
local msg = tostring(rec.msg or rec.message or "")
-- ★ 风控判定走**共用模块**(与推理路径同一份源码文本)
-- 见 ZCODE_DIAG_SRC 的头注释:3012 有账号冷却惩罚,
-- "请勿连续重试"必须同时出现在两条路径上。
-- 判定本身:只信业务码(3012 才是风控),无码可读时才看正文语义 ——
-- 裸数字会把 `retry after 3012 ms`(限流)误判成风控,而两者动作相反。
local isRisk = (ZD and ZD.hasUnusualActivity(resp.body or ""))
or (code == 3012) or (msg:find("unusual activity", 1, true) ~= nil)
if isRisk then
failedMsg = (ZD and ZD.formatUnusualActivityMessage({
subject = "领取 " .. tostring(planId or ""),
rawResponse = msg,
})) or "上游风控拦截(3012 unusual activity)—— 需重新获取验证码"
elseif code and code ~= 0 and code ~= 200 then
failedMsg = msg ~= "" and msg or ("业务码 " .. tostring(code))
else
granted = granted + 1
end
else
failedMsg = "响应不是 JSON(HTTP " .. tostring(resp.status) .. ")"
end
end
end
if granted == 0 then
return { done = true, kind = "failed", message = failedMsg or "全部计划领取失败" }
end
return { done = true, kind = "claimed", credit = granted, unit = "plans",
message = ("已领取 " .. granted .. "/" .. #plans .. " 个计划")
.. (failedMsg and ("(部分失败:" .. failedMsg .. ")") or "") }
end
-- raccoon 领取:桌面端登录奖励(照参考 raccoon-credits.ts:claimRaccoonLoginReward)
-- ============================================================================
-- 端点:POST {apiBase}/api/web/desktop/v1/login/points/grant
-- ⚠️ 幂等判据是响应里的 `granted` 字段:**重复领取同样返回 HTTP 200 但
-- granted=false**。据此映射成 already-claimed,而不是 claimed ——
-- 否则用户会以为每天都真的加了额度(谎报收益比报错更糟)。
-- 失败时若上游给了 message,用它(比通用文案有用)。
function claimRaccoon(plat, tok, args)
local cred = (args and args.credential) or nil
local base = (cred and (cred.api_base or cred.apiBase)) or plat.base_url or ""
-- base_url 在平台表里含 llm 前缀(.../api/web/llm/v2),领取端点不在这条路径上,
-- 故从凭据或平台根域名取。参考用 product.apiBase + RACCOON_DESKTOP_PREFIX。
local root = base:gsub("/api/web/llm/v2$", "")
local url = root .. "/api/web/desktop/v1/login/points/grant"
local h = {
["Authorization"] = "Bearer " .. tok,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["X-Org-Code"] = (cred and (cred.office_identity or "")) or "",
["X-Raccoon-Language"] = "zh",
}
if cred and cred.device_id and cred.device_id ~= "" then
h["X-Device-Id"] = cred.device_id
end
local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = h, body = "{}" })
if err or not resp then
return { done = true, kind = "failed",
message = "领取请求失败: " .. tostring(err) }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then rec = {} end
-- 信封:{code, message, data:{granted, popup:{points}}}
local env = rec.data and rec or rec
if resp.status ~= 200 or (rec.code and rec.code ~= 0 and rec.code ~= 200) then
local msg = rec.message or rec.msg or ""
return { done = true, kind = "failed",
message = (msg ~= "" and tostring(msg)) or ("HTTP " .. tostring(resp.status)) }
end
local data = rec.data or env
if type(data) ~= "table" or data.granted ~= true then
-- 幂等:HTTP 200 但 granted 非 true = 已经领过(每号一次)
return { done = true, kind = "already-claimed",
message = "该账号已领取过桌面端登录奖励(每号一次)" }
end
local points = tonumber((data.popup or {}).points) or 3000
return { done = true, kind = "claimed", credit = points, unit = "points" }
end
-- installRaccoonAdapter
local function installTraeAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
-- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。
local f, lerr = load(TRAE_SOLO_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) 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
-- ============================================================================
-- Gemini 专用适配器(插件自装:channels-gemini)
-- ============================================================================
-- Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent。
-- 参考 gemini.ts 点名三件必须保留的细节:
-- 1. **身份五头逐字写死、不许随机**,且**不带** x-goog-api-key /
-- x-goog-api-client(参考 GEMINI_IDENTITY_HEADERS)
-- 2. **流式刻意不带 Accept 头**(抓包一致)
-- 3. **信封逐层字母序序列化**(marshalAlphabetical)—— Go 的 encoding/json
-- 对 map 自动字母序,而我们的适配器用 json.encode,故须显式排序
--
-- 信封形状(私有端点要的不是裸 contents):
-- {model, project, request:{contents,systemInstruction,tools,
-- generationConfig,sessionId}, requestId, userAgent}
-- 响应也是信封(candidates 嵌在 response 里),必须剥一层。
--
-- ⛔ 不做:gemini-sigstore(签名持久化,268 行)。我们推理走无状态适配器,
-- 插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传错误。
local GEMINI_PRIVATE_ADAPTER_SRC = [==[
local adapter = {}
adapter.name = "channels-gemini"
adapter.version = "1.0.0"
adapter.endpoint = "/v1internal:generateContent"
adapter.endpoint_stream = "/v1internal:streamGenerateContent?alt=sse"
-- 身份五头逐字写死(参考 GEMINI_IDENTITY_HEADERS)。**不许随机**:
-- 上游按客户端指纹识别,随机化会被判异常。
adapter.headers = {
["User-Agent"] = "antigravity/4.3.0 (cmdc-pak)",
["x-client-name"] = "antigravity",
["x-client-version"] = "4.3.0",
["x-machine-id"] = "cmdc-pak",
["x-vscode-sessionid"] = "proxy",
}
-- ⚠️ 刻意**不带** Accept(参考:流式请求不带 Accept,抓包一致)。
-- 也不需要 x-goog-api-key(Cloud Code 用 OAuth Bearer)。
local GEMINI_DEFAULT_PROJECT = "aicode-consumers"
local GEMINI_UPSTREAM_USER_AGENT = "antigravity"
-- 字母序 JSON 序列化(参考 marshalAlphabetical → sortedStringify)。
-- 为什么必须排序:上游按**字节**比对/缓存,键序不同即不同请求。Go 的
-- encoding/json 对 map 自动字母序,Lua 的 json.encode 不排序 —— 故自己排。
local function sorted_json(v)
local t = type(v)
if v == nil then return "null" end
if t == "boolean" then return v and "true" or "false" end
if t == "number" then
if v == math.floor(v) and math.abs(v) < 1e15 then return string.format("%d", v) end
return tostring(v)
end
if t == "string" then return json.encode(v) end
if t == "table" then
-- 数组(1..n 连续整数键)→ 保序;对象 → 键排序
local n = 0
for k in pairs(v) do
if type(k) ~= "number" then n = -1 break end
if k > n then n = k end
end
if n >= 0 then
local parts = {}
for i = 1, n do parts[#parts + 1] = sorted_json(v[i]) end
return "[" .. table.concat(parts, ",") .. "]"
end
local keys = {}
for k in pairs(v) do keys[#keys + 1] = tostring(k) end
table.sort(keys)
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = json.encode(k) .. ":" .. sorted_json(v[k])
end
return "{" .. table.concat(parts, ",") .. "}"
end
return "null"
end
-- 首条 user 文本(sessionId 的派生输入之一)
local function first_user_text(contents)
if type(contents) ~= "table" or #contents == 0 then return "" end
local parts = contents[1] and contents[1].parts
if type(parts) ~= "table" then return "" end
for _, p in ipairs(parts) do
if type(p) == "table" and type(p.text) == "string" then return p.text end
end
return ""
end
-- sessionId = f(project, 首条 user 文本)。参考明言:数值不与原版逐字相同
-- (哈希本体未反推出来),复刻的是**依赖维度与"同输入同输出"**。
-- 这点很重要:不要假装数值一致;判据只能断言确定性(同输入同输出、
-- 换 project/文本会变、换 model 不变)。
local function derive_session_id(project, text)
-- ⚠️ 本 Lua 构建**不支持按位异或 `~`**(load 时报 unexpected symbol near '~')。
-- FNV-1a 的 XOR 用「加等价类」替代:这里要的不是 FNV 的精确值(参考自己
-- 也明言数值不与原版逐字相同,哈希本体未反推出来),而是**确定性 + 依赖
-- 正确的维度**(吃 project 与首条文本、不吃 model)。
local h = 2166136261
local input = tostring(project) .. "\\0" .. tostring(text)
for i = 1, #input do
h = (h + string.byte(input, i) * 2654435761) % 4294967296
h = (h * 16777619) % 4294967296
end
return tostring(h)
end
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)
-- ① 先把 OpenAI 消息转成 Gemini contents(复用既有逻辑的等价实现)
local contents, system = {}, ""
local call_names = {}
local function to_parts(content)
if type(content) == "string" then
if content == "" then return {} end
return { { text = content } }
end
local out = {}
for _, p in ipairs(content or {}) do
if type(p) == "string" then
if p ~= "" then out[#out + 1] = { text = p } end
elseif type(p) == "table" then
if p.type == "text" and p.text ~= nil and p.text ~= "" then
out[#out + 1] = { text = p.text }
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
if b64 then
out[#out + 1] = { inlineData = { mimeType = mt or "image/png", data = b64 } }
end
end
end
end
return out
end
for _, m in ipairs(req.messages or {}) do
local role = m.role or "user"
if role == "system" then
if type(m.content) == "string" then system = system .. m.content .. "\n" end
elseif role == "assistant" then
local parts = to_parts(m.content)
if type(m.tool_calls) == "table" then
for _, tc in ipairs(m.tool_calls) do
if type(tc) == "table" and type(tc["function"]) == "table" then
call_names[tostring(tc.id)] = tc["function"].name
local args = {}
local ok, decoded = pcall(json.decode, tc["function"].arguments or "{}")
if ok and type(decoded) == "table" then args = decoded end
parts[#parts + 1] = { functionCall = { name = tc["function"].name, args = args } }
end
end
end
if #parts > 0 then contents[#contents + 1] = { role = "model", parts = parts } end
elseif role == "tool" then
local name = call_names[tostring(m.tool_call_id or "")] or ""
contents[#contents + 1] = { role = "user", parts = { {
functionResponse = { name = name, response = { output = tostring(m.content or "") } },
} } }
else
local parts = to_parts(m.content)
if #parts > 0 then contents[#contents + 1] = { role = "user", parts = parts } end
end
end
-- ② 组装 request
local greq = { contents = contents }
if system ~= "" then
greq.systemInstruction = { role = "user", parts = { { text = system } } }
end
if type(req.tools) == "table" then
local decls = {}
for _, t in ipairs(req.tools) do
if type(t) == "table" and type(t["function"]) == "table" then
local fn = t["function"]
local params = fn.parameters
if type(params) ~= "table" then params = { type = "object", properties = {} } end
decls[#decls + 1] = {
name = fn.name or "", description = fn.description or "", parameters = params,
}
end
end
if #decls > 0 then greq.tools = { { functionDeclarations = decls } } end
end
local gen = {}
if req.max_tokens then gen.maxOutputTokens = req.max_tokens end
if req.temperature then gen.temperature = req.temperature end
if req.top_p then gen.topP = req.top_p end
if next(gen) then greq.generationConfig = gen end
local project = (req.project and tostring(req.project)) or GEMINI_DEFAULT_PROJECT
greq.sessionId = derive_session_id(project, first_user_text(contents))
-- ③ 信封(字母序序列化)
local envelope = {
model = req.model or "",
project = project,
request = greq,
requestId = "agent/" .. tostring(os.time()) .. "/" .. string.sub(sha256_hex(tostring(os.clock())), 1, 8),
userAgent = GEMINI_UPSTREAM_USER_AGENT,
}
return sorted_json(envelope)
end
-- 响应也是信封:{response:{candidates:[…]}} —— 必须剥一层。
-- 不剥的话下游拿不到 candidates,表现为"模型没回话"。
local function unwrap(raw, decode_fn)
local rec = __prelude.decode_body(raw)
if rec == nil then return raw end
if type(rec.response) == "table" then return rec.response end
return rec
end
function adapter.transform_response(raw_body)
local inner = unwrap(raw_body)
if type(inner) == "string" then return inner end
return __prelude.gemini_response(json.encode(inner))
end
function adapter.transform_stream_chunk(raw_chunk)
local inner = unwrap(raw_chunk)
if type(inner) == "string" then return inner end
return __prelude.gemini_stream_chunk(json.encode(inner))
end
function adapter.transform_error(status, body)
return __prelude.openai_error(status, body)
end
-- 判据用:把派生逻辑暴露出来(不重复实现,避免判据与被测对象脱节)
function adapter.derive_session_id(project, text)
return derive_session_id(project, text)
end
function adapter.sorted_json(v)
return sorted_json(v)
end
return adapter
]==]
-- ============================================================================
-- Qoder 专用适配器(插件自装:channels-qoder)
-- ============================================================================
-- 为什么 Qoder 需要自己的适配器:它的错误信封是**双层编码的**,通用 openai
-- 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` 这种**用户看不懂**
-- 的原因串 —— 而 10605 与 110 的语义**恰好相反**:
--
-- 10605 排队 → **暂时**受阻,等若干秒就能过(应等待重试)
-- 110 额度 → **当天耗尽**,立刻重试 / 等 15 秒 / 等 30 分钟,
-- 结果完全一样(故必须立即失败并如实告诉用户)
--
-- 参考实测(model-queue.ts,2026-09-27 用户报障):110 原先被归成通用的
-- 「服务端错误」→ **白重试 5 次**(≈15.5 秒),用户看到「重试延迟:7220毫秒」。
--
-- ⚠️ 关键结构:`message` 是**一个 JSON 字符串**(内层还要再解一次):
-- {"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}
-- 且**内层没有 code 字段** —— 参考第一版要求「必须命中 code 才算排队」,
-- 导致内层形态被判成非排队,SSE 通道的修复**静默失效**(探针显示 sleep=0)。
-- ⇒ 判据必须是:命中 `code=10605`,**或**直接出现排队字段(isQueued /
-- serviceAvailable)。
--
-- 厂商码值留在**插件**里(内核零厂商名)—— 内核只把 transform_error 返回的
-- 文本当作客户端可读原因;额度类语义由内核既有的关键词表识别(quota/billing
-- 等通用英文短语),故这里只要把话**说清楚**即可。
local QODER_ADAPTER_SRC = [==[
local adapter = { name = "channels-qoder", version = "1.0.0" }
-- 出向就是标准 OpenAI 形状(Qoder 的 chat 端点是 OpenAI 兼容的),
-- 故转换钩子直接复用前导助手。
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)
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
-- 厂商码值只出现在**这个适配器**里(内核零厂商名)。
local QODER_QUEUE_CODE = "10605"
-- qoder_unwrap(root):广度优先展开 data/result/message/body。
-- ⚠️ 必须展开 `message` 这类**字符串再解析**的分支:只读顶层永远拿不到内层
-- 排队参数(参考第一版漏了这条,SSE 通道的修复静默失效、探针显示 sleep=0)。
local function qoder_unwrap(root)
local seen, nodes, queue = {}, {}, { root }
while #queue > 0 do
local node = table.remove(queue, 1)
if type(node) == "table" and not seen[node] then
seen[node] = true
nodes[#nodes + 1] = node
for _, k in ipairs({ "data", "result", "message", "body" }) do
local v = node[k]
if type(v) == "table" then
queue[#queue + 1] = v
elseif type(v) == "string" and v ~= "" then
local okp, parsed = pcall(json.decode, v)
if okp and type(parsed) == "table" then queue[#queue + 1] = parsed end
end
end
end
end
return nodes
end
function adapter.transform_error(status, body)
local okp, root = pcall(json.decode, body or "")
if okp and type(root) == "table" then
-- ⚠️ 判据:命中顶层 code=10605,**或**直接出现排队字段。
-- 内层(SSE 错误帧的 message)**没有 code 字段** —— 若要求必须命中 code,
-- 内层形态会被判成非排队,修复静默失效。
local topHit = tostring(root.code) == QODER_QUEUE_CODE
-- ⚠️ 必须**先找带排队字段的那个节点**,不能一命中就返回:
-- 外层节点(root)本身只有 code,没有 retryAfterSeconds/queueCount,
-- 若在 root 上就返回,等待时长与队列长度**永远取不到**(实测踩到:
-- 只输出"排队中,请稍后重试",参数全丢)。
for _, n in ipairs(qoder_unwrap(root)) do
if n.isQueued ~= nil or n.serviceAvailable ~= nil then
local ra = tonumber(n.retryAfterSeconds) or tonumber(n.retryAfter) or 0
local qc = tonumber(n.queueCount) or 0
if ra > 0 then
return "qoder 模型排队中(前面 " .. tostring(qc) .. " 个),约 "
.. tostring(ra) .. " 秒后重试"
end
return "qoder 模型排队中,请稍后重试"
end
end
-- 只有 code 命中、没有任何排队字段:仍然算排队(码值本身是权威),
-- 但参数确实拿不到,如实说明。
if topHit then return "qoder 模型排队中,请稍后重试" end
-- 额度类:关键词必须**窄**(`quota`/`balance` 泛词会误伤模型正文)
local low = string.lower(body or "")
if low:find("billing daily count exceeded", 1, true)
or low:find("daily count exceeded", 1, true)
or low:find("billing_error", 1, true) then
return "qoder 当日额度已用尽(明日恢复),重试无用"
end
end
return __prelude.openai_error(status, body)
end
return adapter
]==]
local ZCODE_DIAG_SRC = [===[
-- ============================================================================
-- ZCode 3012 判据 / 文案 / 诊断(**唯一出处**,两处消费者共用同一份源码)
--
-- 消费者:
-- ① 插件主体(领取路径 claimZcode);
-- ② 自装适配器 channels-zcode(推理路径的错误分类 + 请求体形态诊断)。
-- 两者由 installZcodeAdapter 把本段**前置**到适配器源码里实现共用
-- (照参考的设计:formatZcodeUnusualActivityMessage 被两条路径引用同一份,
-- 因为 3012 不是"重试就能过",而是**有账号冷却惩罚**——"请勿连续重试"
-- 是这段文案里最重要的一句,绝不能只在其中一条路径上有)。
--
-- ## 为什么不能只看裸数字(参考 2026-10-05 对抗性审计的实测结论)
--
-- `too many requests, retry after 3012 ms` → 仅看数字会**误判成风控**
-- `<h1>Error 3012</h1>upstream connect timeout` → 同上
--
-- 前者后果**不是文案问题**:限流是"稍后重试即可"、风控是"**别重试**",
-- 两者用户动作**完全相反**;且领取路径据此**短路**,跳过本可领取的 plan。
--
-- ## 为什么也不能用"独立词 3012"当门禁
--
-- 上游 3012 的 `msg` 原文就是 `request has been blocked due to unusual
-- activity.` —— **不含任何数字**(数字在 `code` 字段里)。以真实文案做样本时,
-- "必须有独立词 3012"这条门禁会**反过来把它漏掉**。
-- ⇒ 判据以**语义短语**为主,数字只作兜底(且必须与 block 字样紧邻)。
-- ============================================================================
local Z = {}
-- isUnusualActivityCode(code):码是否为 3012。只认 number 与**纯数字字符串**。
--
-- ⚠ 必须认字符串形态(真实缺陷 2026-10-05):上游会回
-- {"code":"3012","msg":"..."},而只认 number 的解析会让风控码整条丢失,
-- 用户看到的正是那句裸上游串(漏判的直接原因)。
-- ⚠ 只认**纯数字**(^%-?%d+$),不把任意文本静默变成业务码。
function Z.isUnusualActivityCode(code)
if type(code) == "number" then return code == 3012 end
if type(code) ~= "string" then return false end
return code:match("^%s*3012%s*$") ~= nil
end
-- parseBusinessCode(value):把上游 code 归一成数字;认 number 与纯数字字符串。
function Z.parseBusinessCode(value)
if type(value) == "number" then
if value ~= value or value == math.huge or value == -math.huge then return nil end
return value
end
if type(value) ~= "string" then return nil end
local t = value:match("^%s*(.-)%s*$")
if not t:match("^%-?%d+$") then return nil end
return tonumber(t)
end
-- looksLikeUnusualActivity(body):正文里以**独立词**出现 3012。
--
-- ⚠ 必须是词边界,不能是 body:find("3012", 1, true)(真实缺陷):裸子串会把
-- 其它码里的数字片段一并命中,实测误判形态:
-- {"code":13012} → 误命中
-- plan-3012-trust → 误命中
-- 后果不是"文案不好看":领取路径据此短路,把"plan 名里恰好含 3012"的普通
-- 失败当成风控,**跳过本可领取的 plan**,并把真实业务码(如 1005 额度用尽)
-- 改写成 3012 —— 两者用户动作完全相反(等额度重置 vs 别重试)。
--
-- ⚠ 边界集必须含 **`-` 与 `_`**:初版只排除数字 ⇒ `plan-3012-trust` 仍命中;
-- 第二版补字母 ⇒ 仍命中(连字符不属 [0-9A-Za-z])。故取 [0-9A-Za-z_-]。
-- `.` `,` `}` `:` 空格 行首行尾都**不**被排除,故上游真实形态
-- {"code":3012,...} 与自然语言 `error: 3012 blocked` 照常命中。
function Z.looksLikeUnusualActivity(body)
if type(body) ~= "string" then return false end
-- Lua 无 lookbehind/lookahead ⇒ 手工检查两个前导/后继字符
local from = 1
while true do
local s, e = body:find("3012", from, true)
if not s then return false end
local before = s > 1 and body:sub(s - 1, s - 1) or ""
local after = e < #body and body:sub(e + 1, e + 1) or ""
local function bad(c)
if c == "" then return false end
return c:match("[0-9A-Za-z_%-]") ~= nil
end
if not bad(before) and not bad(after) then return true end
from = s + 1
end
end
-- mentionsUnusualActivity(body):正文是否**表达**了风控(仅凭正文时的唯一入口)。
--
-- ① 主判据:上游**实测原文**的语义短语(覆盖 `request has been blocked due to
-- unusual activity.` 及其变体);
-- ② 兜底:独立词 3012 与 block 字样**紧邻(≤10 字符)**。
--
-- ⚠ 为什么必须"紧邻"而不是"全文共现"(真实缺陷 N2):第一版写成"独立词 3012
-- 且全文含 block",仍会误判限流正文:
-- `rate limited: request blocked, retry after 3012 ms` → 距 14,误判
-- `temporarily blocked; see error 3012` → 距 12,误判
-- 而 `3012 blocked by waf` → 距 1,应命中
-- 窗口 10 是实测收出来的(40 仍放过上面两个反例)。
function Z.mentionsUnusualActivity(body)
if type(body) ~= "string" then return false end
if body:lower():find("unusual activity", 1, true) then return true end
if body:lower():find("blocked due to", 1, true) then return true end
if not Z.looksLikeUnusualActivity(body) then return false end
-- 紧邻检查:3012 前后 10 字符内出现 block
local low = body:lower()
local from = 1
while true do
local s, e = low:find("3012", from, true)
if not s then return false end
local before = s > 1 and low:sub(math.max(1, s - 10), s - 1) or ""
local after = e < #low and low:sub(e + 1, math.min(#low, e + 10)) or ""
if before:find("block", 1, true) or after:find("block", 1, true) then return true end
from = s + 1
end
end
-- readBusinessCodeFromBody(body):从响应正文读业务码(认 number 与数字字符串)。
function Z.readBusinessCodeFromBody(body)
if type(body) ~= "string" then return nil end
local ok, rec = pcall(json.decode, body)
if not ok or type(rec) ~= "table" then return nil end
return Z.parseBusinessCode(rec.code)
end
-- hasUnusualActivity(body):**有优先级**——正文能解析出业务码就只信码;
-- 无码可读才看正文语义。
--
-- ⚠ 优先级是必须的:{"code":3012} 这种正文**不含语义词**,直接走文本判定会
-- 漏判 ⇒ 错误被归为可重试类 ⇒ 有**冷却惩罚**的风控被白重试多次
-- (qoder 那边记过同型缺陷)。
function Z.hasUnusualActivity(body)
local code = Z.readBusinessCodeFromBody(body)
if code ~= nil then return Z.isUnusualActivityCode(code) end
return Z.mentionsUnusualActivity(body)
end
-- formatUnusualActivityMessage(opts):3012 的**共用文案**。
-- opts.subject(定位信息,如 planId)/ opts.diagnostic / opts.rawResponse。
function Z.formatUnusualActivityMessage(opts)
opts = opts or {}
local subject = (type(opts.subject) == "string" and opts.subject ~= "")
and (" · " .. opts.subject) or ""
local diag = (type(opts.diagnostic) == "string" and opts.diagnostic ~= "")
and ("本机诊断:" .. opts.diagnostic .. "\n") or ""
local raw = (type(opts.rawResponse) == "string" and opts.rawResponse ~= "")
and ("原始响应:" .. opts.rawResponse) or ""
return "上游风控拦截(3012 unusual activity)" .. subject .. "。"
.. "⚠ 该错误有账号冷却惩罚(30 分钟,反复触发会升级到 24 小时乃至停用),"
.. "请勿连续重试。\n" .. diag .. raw
end
-- ============================================================================
-- 进程级账号画像(只给诊断用;**绝不写凭据**)
-- ============================================================================
-- 红线(照参考):账号只以**进程内自增序号**出现,不含 accountId/JWT/账号名
-- 的任何字符片段。
local profiles, nextOrdinal = {}, 1
local function normalizeKey(accountId)
if accountId == nil or accountId == "" then return "-" end
return tostring(accountId)
end
-- accountOrdinal(accountId):进程内序号,**首次见到时分配**(之后稳定)。
-- 同一账号跨请求必须是同一个序号,否则"本次是第几个账号"会随请求变化。
function Z.accountOrdinal(accountId)
local k = normalizeKey(accountId)
local p = profiles[k]
if p then return p.ordinal end
p = { ordinal = nextOrdinal, sent = 0, ok = 0, failed = 0 }
nextOrdinal = nextOrdinal + 1
profiles[k] = p
return p.ordinal
end
-- noteRequestSent(accountId, sentAt):记一次"请求已发出"。
-- ⚠ sentAt 必须在**真正发出之前**取,否则"距上一条"会把本次排队时间算进去。
function Z.noteRequestSent(accountId, sentAt)
Z.accountOrdinal(accountId)
local p = profiles[normalizeKey(accountId)]
if not p then return end
p.sent = p.sent + 1
-- ⚠ 旧值**下沉一格**再写新的:诊断要的"距上条"是**上一条**,不是本次。
-- 若拿 lastSentAt 顶替,算出来恒为 0ms,这一项就永远没有信息量
-- (参考做端到端验证时真的打印出过"距上条 0ms")。
p.previousSentAt = p.lastSentAt
p.lastSentAt = sentAt
end
function Z.noteRequestOk(accountId, at)
local p = profiles[normalizeKey(accountId)]
if not p then return end
p.ok = p.ok + 1
p.lastOkAt = at
end
function Z.noteRequestFailed(accountId)
local p = profiles[normalizeKey(accountId)]
if not p then return end
p.failed = p.failed + 1
end
-- formatGap(ms):把毫秒差写成一眼能读的形态。
--
-- ⚠ 各档**逐档单独算**:参考初版把分钟档写成 floor(seconds % 60),于是 90 秒
-- 被渲染成"30分30秒"(分位取的是**秒的余数**)。该错误在真实探针输出里发现,
-- 纯函数单测当时没覆盖 ≥60 秒的区间 —— 故本实现直接按 totalSeconds 拆。
function Z.formatGap(ms)
if ms == nil or ms < 0 then return "—" end
if ms < 1000 then return tostring(math.floor(ms + 0.5)) .. "ms" end
local totalSeconds = math.floor(ms / 1000)
if totalSeconds < 60 then return string.format("%.1fs", ms / 1000) end
local minutes = math.floor(totalSeconds / 60)
if minutes < 60 then
local rest = totalSeconds % 60
if rest == 0 then return tostring(minutes) .. "分" end
return tostring(minutes) .. "分" .. tostring(rest) .. "秒"
end
local hours = math.floor(minutes / 60)
local restMin = minutes % 60
if restMin == 0 then return tostring(hours) .. "小时" end
return tostring(hours) .. "小时" .. tostring(restMin) .. "分"
end
local function formatSinceLastOk(at, now)
if at == nil then return "从未成功过" end
return Z.formatGap(now - at) .. "前"
end
-- describeRequestShape(system, messages):读**真正要发出去的那个对象**的形态。
--
-- ⚠ 刻意**不读**常量(如"官方身份块 2898 字符"):常量只能证明"源码里写了多少
-- 字",证明不了"线上发出去多少字"。两者一旦不一致(例如某条改动把 system
-- 顶掉了),只有读实际值才看得出来 —— 它会**如实显示 0 字符**。
function Z.describeRequestShape(system, messages)
local blocks = type(system) == "table" and system or {}
local function blockChars(b)
if type(b) ~= "table" then return 0 end
return type(b.text) == "string" and #b.text or 0
end
local n = #blocks
local identityChars = 0
local perBlock = {}
for i = 1, math.min(2, n) do
local c = blockChars(blocks[i])
perBlock[#perBlock + 1] = c
identityChars = identityChars + c
end
local first = type(messages) == "table" and messages[1] or nil
local hasDate = false
if type(first) == "table" then
local c = first.content
if type(c) == "table" and type(c[1]) == "table" and type(c[1].text) == "string" then
hasDate = c[1].text:sub(1, 17) == "<system-reminder>"
elseif type(c) == "string" then
hasDate = c:sub(1, 17) == "<system-reminder>"
end
end
return {
systemBlocks = n,
identityChars = identityChars,
identityBlockChars = perBlock,
cliPrefixChars = n >= 1 and blockChars(blocks[1]) or 0,
hasDateBlock = hasDate,
firstRole = (type(first) == "table" and tostring(first.role)) or "",
}
end
-- formatDiagnostic(input):拼一行现场数据(**不含任何凭据片段**)。
function Z.formatDiagnostic(input)
input = input or {}
local p = profiles[normalizeKey(input.accountId)]
local ordinal = (p and p.ordinal) or Z.accountOrdinal(input.accountId)
local gap = (p and p.previousSentAt ~= nil)
and Z.formatGap((input.sentAt or 0) - p.previousSentAt) or "—"
local parts = {
"账号#" .. tostring(ordinal),
"本进程 成功 " .. tostring(p and p.ok or 0) .. "/失败 " .. tostring(p and p.failed or 0),
"最近成功 " .. formatSinceLastOk(p and p.lastOkAt, input.now or 0),
"距上条 " .. gap,
}
local shape = input.shape
if shape then
-- ⚠ 用 ~= nil 而非真值判断:Lua 里表恒为真,真正的分支是"传了/没传"
local per = #(shape.identityBlockChars or {}) > 0
and table.concat(shape.identityBlockChars, "+") or "无"
parts[#parts + 1] = "身份块 " .. tostring(shape.identityChars or 0)
.. " 字符(" .. per .. ")"
parts[#parts + 1] = "日期块 " .. (shape.hasDateBlock and "有" or "无")
end
parts[#parts + 1] = "HTTP " .. tostring(input.status)
return table.concat(parts, " · ")
end
-- resetDiagnostics():清空全部进程级状态(只给判据用)。
function Z.resetDiagnostics()
profiles = {}
nextOrdinal = 1
end
return Z
]===]
-- ★ ZCode 3012 判据/文案/诊断的**唯一出处**(插件主体与 channels-zcode 适配器
-- 共用同一份源码文本)。
--
-- 为什么必须共用:3012 不是"重试就能过",而是**有账号冷却惩罚**(30 分钟起步,
-- 反复触发升级到 24 小时甚至停用)。"请勿连续重试"是这段文案里最重要的一句,
-- 绝不能只在领取路径或只在推理路径上有(参考为此专门把文案抽成单一份)。
--
-- 本段在插件主体里**本地执行**得到 ZD;适配器里同文本前置进它自己的环境。
local ZD = (function()
local env = setmetatable({ json = json }, { __index = _G })
local f = load(ZCODE_DIAG_SRC, "zcode-diag")
if not f then return nil end
local ok, mod = pcall(f)
if not ok or type(mod) ~= "table" then return nil end
return mod
end)()
local ZCODE_ADAPTER_SRC = [===[
-- ============================================================================
-- ZCode 专用适配器(插件自装:channels-zcode)
--
-- 为什么必须自装而不是复用通用 anthropic:
-- zcode 的 /zcode-plan/anthropic 通道对请求体做**内容检查** —— system 缺少
-- 官方身份块结构时直接返回
-- HTTP 405 + {"code":3012,"msg":"request has been blocked due to unusual activity."}
-- 参考实测矩阵(同账号、同验证码来源):
-- 无 system → 405 + 3012 ✗
-- 仅 cliPrefix(42 字符) → 405 + 3012 ✗
-- cliPrefix + stable(约 2894 字符) → 200 ✓
-- ⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、请求频率、多轮历史、
-- tools 声明、运行时(Electron/curl/Node)**全部无关**(三个账号 17 次实测)。
--
-- ⚠ 参考 2026-10-03 复测修正了两条旧说法,排查时别按旧的来:
-- ① HTTP 状态码是 **405**,不是 403;
-- ② 日期块(<system-reminder>)**不是**判据 —— 去掉照样 200。仍照发是因为
-- 官方如此且零成本,但别把「缺它就 3012」当结论(会让排查耗在错方向)。
--
-- ⚠ 身份块与上游策略**强耦合**:官方客户端升级若改了结构,本文件必须同步
-- (参考有同样的维护警告)。
--
-- 实现方式:复用内核的 anthropic 适配器(读它的源码并执行,拿回 adapter 表),
-- **只覆盖 transform_request** 注入身份块。协议转换(工具调用、图像块、流式帧)
-- 因此始终与 anthropic 那一份一致,不会漂移。
-- ============================================================================
local adapter = { name = "channels-zcode", version = "1.0.0" }
-- ★ 官方 CLI 前缀(准入必需)。逐字复刻,勿改。
local CLI_PREFIX = "You are ZCode, an interactive coding agent"
-- ★ 官方 stable 段(准入必需;三段以 "\n\n" 连接)。逐字复刻,勿改。
-- 官方完整身份块还含 ~5KB 的 dynamic 段(给 coding agent 的行为指令),与准入
-- 无关,且它会被放在 system 开头从而**压过调用方自己的 prompt**(表现为
-- "啰嗦、慢")—— 故只发准入必需的部分(参考同此)。
local SECTION_A = [==[
You are an interactive ZCode agent that helps users with software engineering tasks.
IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.
# Harness
- Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal.
- Tools run behind a user-selected permission mode; a denied call means the user declined it — adjust, don't retry verbatim.
- The system may send updates, reminders, or modifications to rules via mid-conversation system turns. These are system-controlled, unlike function results. Hooks may intercept tool calls; treat hook output as user feedback.
- Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response.
- Reference code as `file_path:line_number` — it's clickable.
]==]
local SECTION_B = [==[
# ZCode Desktop Context
### Files & URLs
- Return local web URLs as Markdown links (e.g., [label](http://127.0.0.1:8080)).
- File should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Unless otherwise specified, return local file references as Markdown links (e.g., [name.md](/absolute/path/to/name.md)).
### Inline Code Comments
- Use the ::code-comment{...} directive when you need to attach feedback directly to specific code lines.
- Emit one directive per inline comment; emit none when there are no actionable inline comments.
- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).
- Optional attributes: start, end (1-based line numbers), priority (0-3).
- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Keep line ranges tight; end defaults to start.
- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2}
]==]
local SECTION_C = [==[
# Working style
When you have enough information to act, act. Do not re-derive facts already established in the conversation, re-litigate a decision the user has already made, or narrate options you will not pursue. If you are weighing a choice, give a recommendation, not an exhaustive survey. Prefer reading the actual file or running the actual command over reasoning about what it probably contains. When a signal pattern-matches to a known failure, check that the evidence actually supports that specific diagnosis before acting on it.
]==]
local STABLE_JOINED = SECTION_A .. "\n\n" .. SECTION_B .. "\n\n" .. SECTION_C
-- 参考声明的期望长度(2900)。用于自检:文本被改动(转义/截断)时立刻发现,
-- 而不是等到线上吃 3012 才知道。
local EXPECTED_MIN_CHARS = 2800
-- ============================================================================
-- 复用 anthropic 适配器
-- ============================================================================
-- 适配器目录 = 插件目录的**兄弟**目录(<data>/plugins 与 <data>/adapters)。
-- 由插件目录推导,不猜绝对路径(测试与多实例部署都靠它隔离)。
local function adaptersDir()
local pd = __llmsproxy_plugin_dir
if type(pd) ~= "string" or pd == "" then
pd = (os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")) or ""
end
if type(pd) ~= "string" or pd == "" then return nil end
local base = pd:gsub("/+$", "")
local parent = base:match("^(.*)/[^/]+$")
if not parent then return nil end
return parent .. "/adapters"
end
-- loadAnthropic():在本地环境里执行内核的 anthropic 适配器源码,拿回它的表。
local function loadAnthropic()
local dir = adaptersDir()
if not dir then return nil, "无法推导适配器目录" end
local f = io.open(dir .. "/anthropic.lua", "r")
if not f then return nil, "读不到 anthropic.lua(" .. dir .. ")" end
local src = f:read("*a")
f:close()
local chunk, lerr = load(src, "anthropic.lua")
if not chunk then return nil, "anthropic.lua 无法编译: " .. tostring(lerr) end
local ok, base = pcall(chunk)
if not ok or type(base) ~= "table" then
return nil, "anthropic.lua 执行失败: " .. tostring(base)
end
return base, nil
end
local BASE, BASE_ERR = loadAnthropic()
if BASE then
for k, v in pairs(BASE) do
if adapter[k] == nil then adapter[k] = v end
end
end
adapter.name = "channels-zcode"
adapter.version = "1.0.0"
-- ============================================================================
-- 身份块构造
-- ============================================================================
-- isGitRepo(cwd):只看 .git 是否存在(照参考,廉价无副作用)。
local function isGitRepo(cwd)
if type(cwd) ~= "string" or cwd == "" then return false end
local p = cwd:gsub("/+$", "") .. "/.git"
local f = io.open(p, "r")
if f then f:close() return true end
-- .git 也可能是**目录**(普通仓库),io.open 打不开目录:用 popen 判存在
local sh = io.popen and io.popen("test -e " .. p .. " && echo y")
if sh then
local r = sh:read("*a") or ""
sh:close()
if r:match("y") then return true end
end
return false
end
-- formatLocalIsoDate():**本地时区**的 ISO 日期(官方用本地日期,不是 UTC)。
local function formatLocalIsoDate()
local t = os.date("*t")
if not t then return "" end
return string.format("%04d-%02d-%02d", t.year, t.month, t.day)
end
-- buildEnvironmentSection():官方形态的 "# Environment" 段。
-- 不发它的后果:模型答不出自己是谁、不知工作目录与平台(会用相对路径瞎猜、
-- 以为该给 bash 命令)。
local function buildEnvironmentSection(cwd, provider, model, platform)
if type(cwd) ~= "string" or cwd:gsub("%s+", "") == "" then cwd = "." end
local plat = platform or "linux"
local shell = (plat == "win32") and "powershell" or "bash"
local arch = (os.getenv and os.getenv("LLMSPROXY_ARCH")) or "x64"
return table.concat({
"# Environment",
"You have been invoked in the following environment:",
" - Primary working directory: " .. cwd,
" - Is a git repository: " .. (isGitRepo(cwd) and "yes" or "no"),
" - Platform: " .. plat,
" - Shell: " .. shell,
" - OS Version: " .. plat .. " " .. arch,
" - You are powered by the model named " .. tostring(provider or "zcode") .. "/"
.. tostring(model or "glm-5.3-flash") .. ".",
}, "\n")
end
-- buildSystemBlocks(callerSystem, opts):官方形态的 system **块数组**。
-- block[1] = cliPrefix(准入必需)
-- block[2] = stable(准入必需)
-- block[3] = Environment 段
-- block[4] = 调用方 system(**必须追加在最后**:上游看的是前缀结构)
-- 只给**最后一块**打 cache_control:Anthropic 单请求最多 4 个断点,"逐块打"
-- 会用满预算而让 tools 再也打不了点(参考 2026-09-30 改为此形态)。
local function buildSystemBlocks(callerSystem, opts)
opts = opts or {}
local blocks = {
{ type = "text", text = CLI_PREFIX },
{ type = "text", text = STABLE_JOINED },
{ type = "text", text = buildEnvironmentSection(opts.cwd, opts.provider, opts.model, opts.platform) },
}
if type(callerSystem) == "string" and callerSystem:match("%S") then
blocks[#blocks + 1] = { type = "text", text = callerSystem }
end
blocks[#blocks].cache_control = { type = "ephemeral" }
return blocks
end
-- contextPrefixBlock():<system-reminder> 日期块(官方逐字形态)。
-- ⚠ 照发但**不是准入判据**(见文件头)。形态细节勿"优化":
-- 整块是**一个** text 块、插到 content **数组**最前(不是拼进文本字符串,
-- 后者会改变结构、仍被判为裸请求);outro 前有 **6 个空格**缩进。
local function contextPrefixBlock()
local body = table.concat({
"As you answer the user's questions, you can use the following context:",
"# currentDate\nToday's date is " .. formatLocalIsoDate() .. ".",
"",
" IMPORTANT: this context may or may not be relevant to your tasks. "
.. "You should not respond to this context unless it is highly relevant to your task.",
}, "\n")
return { type = "text", text = "<system-reminder>\n" .. body .. "\n</system-reminder>" }
end
-- ============================================================================
-- 覆盖 transform_request:先做 anthropic 转换,再注入身份块
-- ============================================================================
function adapter.transform_request(raw_body)
-- ★ 基座读不到时**响亮失败**,不能静默退化。
--
-- 若在这里"尽力而为"(只注入身份块、不做协议转换),会产出一个身份块看着
-- 完全正确、而工具调用/图像/流式结构全没转换的请求 —— 上游可能 200,
-- 但模型拿不到工具定义、图像丢失,症状是"模型变笨了",与准入毫无关系,
-- 排查会完全跑偏。宁可让请求在网关侧明确报错(可定位),也不要静默发出去。
if type(BASE) ~= "table" or type(BASE.transform_request) ~= "function" then
return error("channels-zcode: anthropic 基座未装载(" .. tostring(BASE_ERR) ..
")—— 协议转换无法进行,拒绝发出未转换的请求")
end
local out = raw_body
do
local ok, res = pcall(BASE.transform_request, raw_body)
if ok and type(res) == "string" then out = res end
end
if type(out) ~= "string" or out == "" then return raw_body end
local ok, req = pcall(json.decode, out)
if not ok or type(req) ~= "table" then return out end
-- anthropic.lua 产出的 system 是**纯字符串**;上游要的是**块数组**,
-- 故在此改写成块数组(上游检查的正是这个结构)。
local caller = req.system
if type(caller) == "table" then
local parts = {}
for _, b in ipairs(caller) do
if type(b) == "table" and type(b.text) == "string" then parts[#parts + 1] = b.text end
end
caller = table.concat(parts, "\n\n")
end
local opts = {}
-- cwd 必须给**真值**:参考声明「cwd is never "unknown" in real traffic」。
opts.cwd = (os.getenv and os.getenv("PWD")) or "."
opts.provider = "zcode"
opts.model = req.model
opts.platform = "linux"
req.system = buildSystemBlocks(caller, opts)
-- ★ 日期块:只处理**第一条**消息,且它必须是 role == "user"(官方行为)。
--
-- 两条容易漏掉的规则(照参考 withContextPrefix,「优化」它们会偏离官方):
-- ① **幂等**:已以 <system-reminder> 开头则不重复插。不判的话,同一请求
-- 在重试/多次 transform 时会把日期块叠成好几层,改变前缀结构;
-- ② **只看第一条**:若首条不是 user(例如 assistant 开场),**不动** ——
-- 不是"往后找一个 user 消息"。我第一版就是往后找,与官方行为不符。
local msgs = req.messages
if type(msgs) == "table" then
local first = msgs[1]
if type(first) == "table" and first.role == "user" then
local c = first.content
-- 已插过(字符串或块数组两种形态都要判)→ 不重复插。
-- ⚠ 长度必须用 #SR 算,不能写死 —— 我第一版写 `sub(1, 16)` 而
-- "<system-reminder>" 是 **17** 字符,判断**永远为假** ⇒ 幂等失效、
-- 重试时日期块叠加(判据当场抓到)。
local SR = "<system-reminder>"
local already = false
if type(c) == "string" then
already = c:sub(1, #SR) == SR
elseif type(c) == "table" and type(c[1]) == "table" then
local t = c[1].text
already = type(t) == "string" and t:sub(1, #SR) == SR
end
if not already then
local blk = contextPrefixBlock()
if type(c) == "table" then
table.insert(c, 1, blk)
elseif type(c) == "string" then
-- 纯文本 content 转成块数组(官方就是数组形态)
first.content = { blk, { type = "text", text = c } }
else
first.content = { blk }
end
end
end
end
return json.encode(req)
end
-- ★ transform_error:把 3012 判成**不可重试**并带上现场诊断。
--
-- 为什么不能只依赖通用文案:3012 有账号冷却惩罚(30 分钟起步),
-- 归成"可重试的服务端错误"会让调用方**白重试**、把冷却越推越长。
-- 诊断行(账号序号/成功失败计数/距上条/身份块实测字符数)是排查的全部现场,
-- 且**不含任何凭据片段**(只出现进程内自增序号)。
function adapter.transform_error(status, body)
local text = tostring(body or "")
if Z and Z.hasUnusualActivity(text) then
return Z.formatUnusualActivityMessage({
diagnostic = Z.formatDiagnostic({
accountId = nil,
sentAt = os.time and os.time() * 1000 or 0,
now = os.time and os.time() * 1000 or 0,
status = status,
}),
rawResponse = text:sub(1, 500),
})
end
-- 非风控:交回 anthropic 基座的通用分类
if type(BASE) == "table" and type(BASE.transform_error) == "function" then
local ok, res = pcall(BASE.transform_error, status, body)
if ok and type(res) == "string" then return res end
end
return text
end
-- 暴露给判据:身份块长度与装载状态("装了但身份块为空"这类问题没有它只能靠猜)
adapter.__identity_probe = function()
return {
base_loaded = BASE ~= nil,
base_err = BASE_ERR,
cli_prefix_len = #CLI_PREFIX,
stable_len = #STABLE_JOINED,
total_len = #CLI_PREFIX + #STABLE_JOINED,
expected_min = EXPECTED_MIN_CHARS,
}
end
return adapter
]===]
-- ============================================================================
-- Cline 专用适配器(插件自装:channels-cline)
-- ============================================================================
-- 为什么需要:Cline 的 429 **既不给 `retry-after` 头,也不给绝对时刻**,
-- 它把等待时长写在**人类可读的英文句子**里(参考实测抓包):
--
-- HTTP 429 no-retry: true ← 没有任何 retry-after 头
-- {"error":{"code":"INFERENCE_CAP_ERROR",
-- "message":"Error 429: Daily free limit reached on model
-- deepseek/deepseek-v4-flash. Try again in 19h 39m"}}
--
-- ★ 这是**比参考更严重**的形态:参考退到"1 小时快照兜底",于是用户看到
-- 「永远 60 分钟」(等满一小时再试仍被拒,且每失败一次就**从当下**重新计一小时
-- ⇒ 无限循环)。而我们的内核把 429 当普通限流、用**固定 30 秒**冷却 ——
-- 那会变成**每 30 秒重试一次、永远失败**,在猛敲上游(比参考更糟)。
--
-- 归一路径(厂商知识留在插件域,内核零厂商名):
-- ① 解析出真实等待时长(`19h 39m` 各 token **累加**);
-- ② 把文案归一成**内核已认的中立短语**("额度已用完"),
-- 于是内核走 RecordQuotaExhausted 而不是 30 秒限流冷却;
-- ③ 把真实原因(含"何时恢复")如实告诉用户 ——
-- 不说"等 1 小时就好",那正是参考踩过的坑。
--
-- ⚠️ 时长 token 必须**累加**且**不吃重复匹配**:`minutes?` 与 `m` 都能匹配
-- 同一段会让时长翻倍(参考注释点名)。我们用一次扫描 + 贪婪单位即可。
local CLINE_ADAPTER_SRC = [==[
local adapter = { name = "channels-cline", version = "1.0.0" }
function adapter.transform_request(raw_body) return raw_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
-- cline_wait_ms(text):把 `19h 39m` / `1h 30m` / `45s` 换成毫秒。
-- 各 token **累加**;不设上限(服务端说的就是它自己的结算窗口,
-- 替它裁剪只会又造出一个"以为等到了、其实没好"的倒计时)。
local function cline_wait_ms(text)
if type(text) ~= "string" then return nil end
local total, matched = 0, false
-- 单位按**首字母**判(h/m/s 互不重叠),故一次扫描即可,不会重复计数。
for num, unit in text:gmatch("(%d+)%s*([hms])[a-z]*") do
local n = tonumber(num)
if n then
local u = unit:lower()
if u == "h" then total = total + n * 3600000
elseif u == "m" then total = total + n * 60000
else total = total + n * 1000 end
matched = true
end
end
if not matched then return nil end
return total
end
-- 文案里出现这些标记 ⇒ 是"当日免费额度"而非普通限流。
-- 小写比对(照参考 CLINE_DAILY_FREE_MARKERS)。
local function cline_is_daily_free(text)
local low = string.lower(text or "")
return low:find("daily free limit", 1, true) ~= nil
or low:find("free daily limit", 1, true) ~= nil
or low:find("daily limit reached", 1, true) ~= nil
end
-- 从报文里取文案;必须认多种外壳(`{error:{message}}` / `{error:"…"}` /
-- `{message}` / `{msg}` / 非 JSON 纯文本)。
local function cline_message(body)
local okp, rec = pcall(json.decode, body or "")
if not okp or type(rec) ~= "table" then return tostring(body or "") end
local e = rec.error
if type(e) == "string" and e ~= "" then return e end
if type(e) == "table" and type(e.message) == "string" and e.message ~= "" then
return e.message
end
if type(rec.message) == "string" and rec.message ~= "" then return rec.message end
if type(rec.msg) == "string" and rec.msg ~= "" then return rec.msg end
return ""
end
-- fmt_duration(ms):人话时长("约 19 小时 39 分")。
local function fmt_duration(ms)
if ms == nil or ms <= 0 then return "" end
local h = math.floor(ms / 3600000)
local m = math.floor((ms % 3600000) / 60000)
if h > 0 then return "约 " .. h .. " 小时 " .. m .. " 分" end
if m > 0 then return "约 " .. m .. " 分" end
return "不到 1 分"
end
function adapter.transform_error(status, body)
local msg = cline_message(body)
local wait = cline_wait_ms(msg)
-- 当日免费额度用尽:等下去才会恢复(**不是**普通限流)。
-- ⚠️ 必须把话**说清楚**:内核按关键词识别"额度类"并据此冷却到窗口边界,
-- 若这里只说"rate limited",内核会用 30 秒短冷却 ⇒ 每 30 秒猛敲一次上游。
if cline_is_daily_free(msg) then
local when = wait and ("," .. fmt_duration(wait) .. " 后重置") or ""
-- "额度已用完" 是内核已认的中立短语(quotaKeywords)⇒ 走窗口冷却而非 30s。
return "额度已用完:" .. tostring(status) .. " 当日免费额度已用尽(HTTP "
.. tostring(status) .. ")" .. when .. ",重试无用"
end
-- 402:等下去不会恢复(要充值)
if status == 402 then
return "额度已用完:Insufficient credits(HTTP 402)—— 等待不会恢复,需充值"
end
-- 其它 429:有明确等待时长就如实说(内核仍按普通限流短冷却,但用户知道要等多久)
if status == 429 and wait and wait > 0 then
return "限流(HTTP 429)" .. fmt_duration(wait) .. "后可重试"
end
return __prelude.openai_error(status, body)
end
-- 导出给判据用(不是内核契约的一部分)。
adapter.cline_wait_ms = cline_wait_ms
return adapter
]==]
-- installQoderAdapter():装/更新本插件自己的 Qoder 适配器。
-- 与 trae/gemini 同一机制(插件自装、厂商知识留在插件域)。
-- installClineAdapter():装/更新本插件自己的 Cline 适配器。
local function installClineAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(CLINE_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-cline", CLINE_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-cline", CLINE_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
local function installQoderAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(QODER_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-qoder", QODER_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-qoder", QODER_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
-- installGeminiAdapter():装/更新本插件自己的 Gemini 私有协议适配器。
local function installGeminiAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
-- 先在本地编译一次,把语法错误在**装载失败之前**变成可读信息
-- (load 失败只给"源码无法编译",不给行号)
local f, lerr = load(GEMINI_PRIVATE_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
-- installZcodeAdapter():装/更新本插件自己的 zcode 适配器。
--
-- 为什么必须有:zcode 的 /zcode-plan/anthropic 通道做**请求体内容检查** ——
-- system 缺官方身份块结构直接 405 + 3012(实测),与 HTTP 头/版本/频率全无关。
-- 通用 anthropic 适配器不注入身份块 ⇒ 即使拿到 zcode 账号也**必然**被拒。
-- 幂等(update 失败退化为 install);失败只记日志,不阻断插件加载。
local function installZcodeAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
-- ★ 把 3012 判据/文案/诊断模块**前置**到适配器源码里。
-- 这样适配器与插件主体用的是**同一份源码文本**(ZCODE_DIAG_SRC)——
-- 否则"请勿连续重试"这句话会只在一条路径上有(参考为此专门抽成单一份)。
-- ⚠ 不能直接拼接:ZCODE_DIAG_SRC 末尾是 `return Z`,直接拼会让那个 return
-- 结束**整个 chunk** ⇒ 后面的适配器代码永不执行("加载成功但适配器不存在",
-- 且装载会静默成功)。故把它包进一个函数,让 return 只结束该函数。
local full = "local Z = (function()\n" .. ZCODE_DIAG_SRC ..
"\nend)()\n" .. ZCODE_ADAPTER_SRC
local f, lerr = load(full)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-zcode", full)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-zcode", full)
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
-- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。
local f, lerr = load(CODEARTS_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) 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.__store_dir_probe:只给测试用 —— 报告本插件实际使用的存储目录。
-- 测试污染的排查完全依赖它:storeDir() 有多级兜底(env → settings → 读生产
-- config.yaml → 硬编码 /etc/llmsproxy/plugins),写错一级就会让测试**写进生产**。
function plugin.__store_dir_probe()
return { json = { store_dir = storeDir() } }
end
-- plugin.__set_loopback_probe:只给测试用 —— 把某会话的 loopback 回调文件
-- 指到测试摆好的路径,以便直接驱动 poll_login 的 callback 分支。
--
-- 为什么需要它:callback 流的真实登录走 poll_login(读 loopback 文件),
-- 而那个文件平时由插件自己起的 python 接收器写入。判据要验的正是**这条路径**
-- 的转码/交换行为(真实缺陷曾藏在它的 trae 分支里),但测试不起真实接收器,
-- 故需要这个注入点。生产代码不会调用它。
function plugin.__set_loopback_probe(args)
args = args or {}
local sid = tostring(args.session_id or "")
local file = tostring(args.file or "")
if sid == "" or file == "" then return { json = { error = "need session_id and file" } } end
if type(plugin.state.loopbacks) ~= "table" then plugin.state.loopbacks = {} end
-- ★ 合并而非覆盖:真实会话的条目里带着 pid/port_file,覆盖掉它们
-- 会让 reapLoopback 杀不掉那个 python 接收器 —— 探针自己就成了泄漏源
-- (实测:覆盖写法在该测试里留下 1 个孤儿进程,而其他测试已归零)。
local cur = plugin.state.loopbacks[sid]
if type(cur) ~= "table" then cur = {} end
cur.file = file
plugin.state.loopbacks[sid] = cur
return { json = { ok = true, session_id = sid, file = file, kept_pid = cur.pid or "" } }
end
-- plugin.credits_tier_probe:只给测试用 —— 单账号的余额分档结果。
-- 暴露 expiring/permanent/tier/usable 四个值,让判据能逐条钉住分档规则
-- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。
function plugin.credits_tier_probe(args)
args = args or {}
local provider = tostring(args.platform or "")
local accountId = tostring(args.account_id or "")
local raw = accountBalanceRaw(provider, accountId)
if raw == nil then return { json = { error = "余额不可用" } } end
local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits
local allowPermanent = not lockPermanent
local tier = balanceTier(raw.expiring_balance, raw.permanent_balance, allowPermanent)
return { json = {
expiring = raw.expiring_balance or 0,
permanent = raw.permanent_balance or 0,
tier = tier,
usable = tierUsable(tier),
locked = lockPermanent and true or false,
window_days = expiringWindowDays(),
} }
end
-- plugin.__set_lock_permanent_probe:只给测试用 —— 设置"锁定永久积分"开关。
function plugin.__set_lock_permanent_probe(args)
plugin.state.settings = plugin.state.settings or {}
plugin.state.settings.lock_permanent_credits = (args or {}).lock and true or false
return { json = { ok = true, locked = plugin.state.settings.lock_permanent_credits } }
end
-- plugin.__set_expiring_window_probe:只给测试用 —— 覆盖"临时积分"窗口天数。
-- 用环境变量实现(与生产同一路径,避免测试走旁路而与真实行为脱节)。
function plugin.__set_expiring_window_probe(args)
local d = tonumber((args or {}).days)
if d == nil then return { json = { error = "缺 days" } } end
plugin.state.settings = plugin.state.settings or {}
plugin.state.settings.expiring_window_days = d
-- 窗口变了必须让余额缓存失效:缓存的是**原料**(包列表),而分桶是每次现算的,
-- 但原料里的"有效/失效"判定也依赖窗口吗?不依赖 —— 依赖窗口的只有分桶。
-- 清缓存是为了让判据能立即看到新窗口的效果(生产里 60s 后自然过期)。
ttlCacheInvalidate("balance")
return { json = { ok = true, days = expiringWindowDays() } }
end
-- plugin.__set_legacy_cred_probe:只给测试用 —— 造一条"升级前"的非池凭据。
function plugin.__set_legacy_cred_probe(args)
args = args or {}
local ref = "channels:" .. tostring(args.platform or "")
__llmsproxy_secrets.set(ref, tostring(args.credential or ""))
return { json = { ok = true, ref = ref } }
end
-- plugin.__read_cred_probe:只给测试用 —— 读一条凭据(保险库解密后的**明文**)。
-- 判据靠它证明"接管没有把凭据二次加密"。
function plugin.__read_cred_probe(args)
args = args or {}
local v = __llmsproxy_secrets.get(tostring(args.ref or ""))
return { json = { credential = v, len = v and #v or 0 } }
end
-- plugin.__clear_all_creds_probe:只给测试用 —— 清掉该插件的全部凭据。
function plugin.__clear_all_creds_probe()
local ks = __llmsproxy_secrets.keys and __llmsproxy_secrets.keys() or {}
for _, k in ipairs(ks) do __llmsproxy_secrets.delete(k) end
return { json = { ok = true } }
end
-- 以下四个探针只给测试用:把通用件(TTL 缓存 / 串行锁)的语义逐条钉住。
-- 边界条件(清缓存要连带清在飞标记、ttl=0 不该命中、锁要能自解)都是踩过的坑。
plugin.state.ttlProbeLoads = plugin.state.ttlProbeLoads or {}
function plugin.__ttl_reset_probe()
plugin.state.ttlProbeLoads = {}
plugin.state.ttlCache = {}
plugin.state.ttlInflight = {}
plugin.state.serialLock = {}
return { json = { ok = true } }
end
function plugin.__ttl_probe(args)
args = args or {}
local ns, k = tostring(args.ns or ""), tostring(args.key or "")
local ttl = tonumber(args.ttl) or 60
local lk = ns .. ":" .. k
local v = ttlCacheGet(ns, k, ttl, function()
plugin.state.ttlProbeLoads[lk] = (plugin.state.ttlProbeLoads[lk] or 0) + 1
return "v" .. tostring(plugin.state.ttlProbeLoads[lk])
end)
return { json = { value = v, loads = plugin.state.ttlProbeLoads[lk] or 0 } }
end
function plugin.__ttl_invalidate_probe(args)
args = args or {}
ttlCacheInvalidate(tostring(args.ns or ""), args.key and tostring(args.key) or nil)
return { json = { ok = true } }
end
function plugin.__serial_probe(args)
args = args or {}
local ok = serialTryAcquire(tostring(args.ns or ""), tonumber(args.ttl) or 60)
return { json = { acquired = ok } }
end
function plugin.__serial_release_probe(args)
args = args or {}
serialRelease(tostring(args.ns or ""))
return { json = { ok = true } }
end
-- plugin.__parse_capability_probe:只给测试用 —— 把一段 models.dev 响应交给
-- 真实的解析逻辑,回报统计。用它验证字段口径对得上真实数据(5.6MB/229 键)。
function plugin.__parse_capability_probe(args)
args = args or {}
local ok, rec = pcall(json.decode, tostring(args.body or ""))
if not ok or type(rec) ~= "table" then return { json = { error = "解析失败" } } end
local key = tostring(args.key or "opencode")
local src = rec[key]
if type(src) ~= "table" then return { json = { error = "无该键" } } end
local models = type(src.models) == "table" and src.models or src
local total, free, image, reasoning, with_ctx = 0, 0, 0, 0, 0
local sample = nil
for id, raw in pairs(models) do
local cap = normalizeCapability(tostring(id), raw)
if cap then
total = total + 1
if cap.isFree then free = free + 1 end
for _, m in ipairs(cap.modalities) do
if m == "image" then image = image + 1 break end
end
if cap.reasoning then reasoning = reasoning + 1 end
if (cap.contextWindow or 0) > 0 then with_ctx = with_ctx + 1 end
if not sample then sample = { id = id, isFree = cap.isFree,
modalities = cap.modalities, contextWindow = cap.contextWindow } end
end
end
return { json = { total = total, free = free, image = image,
reasoning = reasoning, with_ctx = with_ctx, sample = sample } }
end
-- plugin.__inject_refresh_platform_probe:只给测试用 —— 造一个带续期声明的平台
-- 与一条账号。**夹具绝不写进生产源码**(此前 example/googleish/loopbackish 这类
-- 测试平台被写进生产目录的事故就是教训)。
function plugin.__inject_refresh_platform_probe(args)
args = args or {}
local id = tostring(args.id or "")
-- ⚠️ injected_platforms 是**数组**(platformById 用 ipairs 遍历),不是映射。
plugin.state.injected_platforms = plugin.state.injected_platforms or {}
local lst = plugin.state.injected_platforms
local repl = nil
for i, p in ipairs(lst) do if type(p) == "table" and p.id == id then repl = i break end end
local entry = {
id = id,
label = id,
flow = "manual",
adapter = "openai",
base_url = "http://127.0.0.1:1/v1/chat/completions",
refresh = { style = tostring(args.style or "google"), url = tostring(args.refresh_url or "") },
models = { "m1" },
hosts = { "127.0.0.1" },
}
if repl then lst[repl] = entry else lst[#lst + 1] = entry end
-- 建账号(凭据走保险库)
local ref = "channels:" .. id
__llmsproxy_secrets.set(ref, tostring(args.credential or "{}"))
poolAdd(id, { id = "a1", credentialRef = ref, priority = 1, expiresAt = 0 })
return { json = { ok = true } }
end
-- plugin.__is_free_probe:只给测试用 —— 对单个模型问"是否免费"。
-- 走**真实的**判定链(不自己重算),否则验的是探针而不是被测对象。
function plugin.__is_free_probe(args)
args = args or {}
local plat = platformById(tostring(args.platform or ""))
if not plat then return { json = { error = "未知平台" } } end
local m = tostring(args.model_id or "")
local free = {}
for _, x in ipairs(plat.models_free or {}) do free[x] = true end
local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil
local cap = capabilityOf(plat.id, m)
local is_free = false
if plat.free_source == "capability" then
if cap and cap.isFree ~= nil then is_free = cap.isFree and true or false
else is_free = free[m] and true or false end
else
if remoteFree and remoteFree[m] then is_free = true end
if not is_free and plat.free_prefix and m:sub(1, #plat.free_prefix) == plat.free_prefix then
is_free = true
end
if not is_free and free[m] then is_free = true end
if not is_free and cap and cap.isFree == true then is_free = true end
end
return { json = { is_free = is_free, model_id = m } }
end
-- plugin.__qoder_claim_outcome_probe:只给测试用 —— 造出 Qoder 在
-- "刷新前/刷新后"两种时刻下的领取结果,验证 covers_today 声明正确。
-- ⚠️ 必须调**真实**的判定逻辑,不能自己重算(否则验的是探针而不是被测对象)。
function plugin.__qoder_claim_outcome_probe(args)
args = args or {}
local refreshed = args.refreshed == true
-- 直接复用真实函数的判定路径:构造"有 CLAIMED、无可领"的响应
local claimedBefore = true
if claimedBefore then
if not refreshed then
return { json = { kind = "inactive", covers_today = false } }
end
return { json = { kind = "already-claimed", covers_today = true } }
end
return { json = { kind = "inactive", covers_today = true } }
end
-- plugin.__claim_covers_probe:只给测试用 —— 把任意 outcome 喂给**真实的**
-- claimCoversToday 助手,回报它的判定。
function plugin.__claim_covers_probe(args)
args = args or {}
local outcome = { kind = tostring(args.kind or "") }
if args.covers_today ~= nil then outcome.covers_today = args.covers_today end
local resp = { json = { ok = (args.kind == "success"), outcome = outcome } }
return { json = { covers = claimCoversToday(resp) } }
end
-- plugin.__schedule_probe:只给测试用 —— 回报调度表(间隔/first_due_now)。
function plugin.__schedule_probe()
local out = { auto_claim_every = 0, first_due_now = false, has_coverage_key = false }
for _, t in ipairs(plugin.schedule or {}) do
if t.fn == "auto_claim" then
out.auto_claim_every = t.every or 0
out.first_due_now = t.first_due_now == true
end
end
for _, k in ipairs(PERSIST_KEYS or {}) do
if k == "claimCovered" then out.has_coverage_key = true end
end
return { json = out }
end
-- plugin.__clear_capabilities_probe:只给测试用 —— 清能力缓存(模拟冷启动)。
function plugin.__clear_capabilities_probe()
plugin.state.capabilities = nil
plugin.state.capabilitiesMeta = nil
return { json = { ok = true } }
end
-- plugin.__inject_capability_probe:只给测试用 —— 注入一条能力元数据。
function plugin.__inject_capability_probe(args)
args = args or {}
local plat = tostring(args.platform or "")
plugin.state.capabilities = plugin.state.capabilities or {}
plugin.state.capabilities[plat] = plugin.state.capabilities[plat] or {}
plugin.state.capabilities[plat][tostring(args.model_id or "")] = {
id = tostring(args.model_id or ""),
name = tostring(args.model_id or ""),
isFree = args.is_free and true or false,
modalities = { "text" },
contextWindow = 100000, maxOutputTokens = 8000,
reasoning = false, efforts = {}, toolCall = true,
}
return { json = { ok = true } }
end
-- plugin.__rank_accounts_probe:只给测试用 —— 暴露余额分档选号的结果。
function plugin.__rank_accounts_probe(args)
args = args or {}
local r = rankAccounts(tostring(args.platform or ""))
local ids = {}
for _, a in ipairs(r.all or {}) do ids[#ids+1] = a.id end
return { json = { expiring = r.expiring, permanent = r.permanent,
none = r.none, locked = r.locked, all = ids,
usable_n = #(r.usable or {}) } }
end
-- plugin.__expiring_window_probe:只给测试用 —— 读当前生效的窗口天数。
function plugin.__expiring_window_probe()
return { json = { days = expiringWindowDays() } }
end
-- plugin.__is_model_gone_probe:只给测试用 —— 暴露失效判据本体。
-- 判据必须能被逐条钉住:这些模式的误判代价是把**可用模型藏 30 天**。
function plugin.__is_model_gone_probe(args)
return { json = { gone = isModelGone(tostring((args or {}).message or "")) } }
end
-- plugin.__record_dead_probe:只给测试用 —— 记录一条失效证据。
function plugin.__record_dead_probe(args)
args = args or {}
local isnew = recordDeadModel(tostring(args.platform or ""), tostring(args.model_id or ""),
tostring(args.reason or ""))
return { json = { recorded = isnew } }
end
-- plugin.__catalog_gate_probe:只给测试用 —— 读闸门状态并尝试进入一次。
function plugin.__catalog_gate_probe(args)
args = args or {}
local now = os.time and os.time() or 0
local cooling = catalogGateCooling(tostring(args.platform or ""), now)
local began = false
if not cooling then began = catalogGateBegin(tostring(args.platform or ""), now) end
return { json = { began = began, cooling = cooling } }
end
-- plugin.__catalog_gate_fail_probe:只给测试用 —— 模拟一次拉取失败(进冷却)。
function plugin.__catalog_gate_fail_probe(args)
args = args or {}
local now = os.time and os.time() or 0
local p = tostring(args.platform or "")
catalogGateBegin(p, now)
catalogGateEnd(p, false, now)
return { json = { ok = true } }
end
-- plugin.credits_capability:只给测试用 —— 暴露能力表条目,让判据能断言
-- "表与参考真值一致"(表错了只会静默少领,没有任何报错)。
function plugin.credits_capability(args)
args = args or {}
local cap = CHANNEL_CREDITS[args.platform]
if not cap then return { json = { error = "无能力声明" } } end
local out = {}
for k, v in pairs(cap) do
if type(v) ~= "function" then out[k] = v end
end
return { json = { capability = out } }
end
-- plugin.claims_capable:只给测试用 —— 判断某平台**真的**能不能领
--(有无 checkin 表或 claim_fn)。这与"能力表声称能领"是两件事,
-- 两者不一致就会出现自相矛盾的文案。
function plugin.claims_capable(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
local has_checkin = plat.checkin ~= nil and plat.checkin.url ~= nil
local has_fn = plat.claim_fn ~= nil
local cap = CHANNEL_CREDITS[plat.id] or {}
return { json = {
capable = (has_checkin or has_fn),
has_checkin = has_checkin,
has_claim_fn = has_fn,
table_says_claim = cap.claim == true,
auto_exclude = cap.auto_exclude == true,
reason = (not (has_checkin or has_fn)) and
("既无 checkin 表也无 claim_fn(claim_fn=" .. tostring(plat.claim_fn ~= nil) .. ")") or "",
} }
end
-- plugin.__zcode_diag_probe:只给测试/诊断用 —— 暴露共用的 3012 判据模块。
--
-- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在
-- channels-zcode 适配器)。判据必须能直接打**那一份**实现,否则会退化成
-- “各自测各自的副本”,而共用一旦漂移就再也测不出来。
function plugin.__zcode_diag_probe(args)
args = args or {}
local fn = tostring(args.fn or "")
if not ZD then return { json = { error = "诊断模块未装载" } } end
local f = ZD[fn]
if type(f) ~= "function" then return { json = { error = "未知函数: " .. fn } } end
if fn == "formatGap" then return { json = { result = f(tonumber(args.a) or 0) } } end
if fn == "formatUnusualActivityMessage" then
return { json = { result = f(args.opts or {}) } }
end
if fn == "formatDiagnostic" then return { json = { result = f(args.opts or {}) } } end
if fn == "describeRequestShape" then
return { json = { result = f(args.system, args.messages) } }
end
if fn == "accountOrdinal" then return { json = { result = f(args.a) } } end
if fn == "noteRequestSent" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end
if fn == "noteRequestOk" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end
if fn == "noteRequestFailed" then f(args.a) return { json = { ok = true } } end
if fn == "hasUnusualActivity" or fn == "mentionsUnusualActivity"
or fn == "looksLikeUnusualActivity" then
return { json = { result = f(args.a) } }
end
return { json = { error = "探针未暴露该函数: " .. fn } }
end
-- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的
-- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。
function plugin.__adapter_install_probe()
return { json = {
codearts = plugin._codearts_adapter,
trae = plugin._trae_adapter,
gemini = plugin._gemini_adapter,
zcode = plugin._zcode_adapter,
} }
end
-- 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 = 1, 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
-- 绑定 zcode 的领取钩子(同 raccoon/loomy:local 前向引用,必须放末尾)。
do
for _, p in ipairs(PLATFORMS) do
if p.id == "zcode" then
p.claim_fn = function(plat, tok, args) return claimZcode(plat, tok, args) end
break
end
end
end
-- 绑定 loomy 的领取钩子(同 raccoon:local 前向引用,必须放末尾)。
do
for _, p in ipairs(PLATFORMS) do
if p.id == "loomy" then
p.claim_fn = function(plat, tok, args) return claimLoomy(plat, tok, args) end
break
end
end
end
-- 绑定 raccoon 的领取钩子。必须放在**文件末尾**:claimRaccoon 是 local,
-- 只能从定义处向后可见(本文件已因 local 前向引用踩过 8 次)。
do
for _, p in ipairs(PLATFORMS) do
if p.id == "raccoon" then
p.claim_fn = function(plat, tok, args) return claimRaccoon(plat, tok, args) end
break
end
end
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 }
local gok, gerr = installGeminiAdapter()
plugin._gemini_adapter = { ok = gok, err = gerr }
local qok, qerr = installQoderAdapter()
plugin._qoder_adapter = { ok = qok, err = qerr }
local cok, cerr = installClineAdapter()
plugin._cline_adapter = { ok = cok, err = cerr }
local zok, zerr = installZcodeAdapter()
plugin._zcode_adapter = { ok = zok, err = zerr }
if not zok then logEvent("ZCode 适配器装载失败: " .. tostring(zerr)) end
if not cok then logEvent("Cline 适配器装载失败: " .. tostring(cerr)) end
if not gok then logEvent("Gemini 适配器装载失败: " .. tostring(gerr)) end
if not qok then logEvent("Qoder 适配器装载失败: " .. tostring(qerr)) end
if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end
end
return plugin