-- channels:多平台账号接入(登录认证 → 凭据 → 自动领取额度 → 注册为上游源) -- -- 为什么需要它 -- ------------ -- 网关的上游源(sources)一直是**运维手工配置**的:填 base_url、填 api_key。 -- 而现实里有大量平台是"注册个账号、每天领免费额度"的形态,它们的接入方式是 -- **登录**而不是"申请一个 key"。这类平台要接入网关,就需要:驱动登录 → 拿到 -- 凭据 → 定时领取额度 → 把端点当源用。 -- -- 本插件把这套流程做成通用的:一个插件内建平台表,每个平台声明自己的 -- 登录方式(link-poll / callback / paste)、额度领取接口、上游端点形状。 -- 新增一个平台是往 PLATFORMS 里加一条,不改内核。 -- -- 设计边界(与内核的约定) -- ------------------------ -- 内核提供基础设施,插件自己不实现这些: -- -- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它) -- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读) -- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…) -- -- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状、模型清单。 -- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"—— -- 内核里出现厂商名就是分层被破坏了。登录会话、服务端浏览器栈也全归插件自持。 -- -- 权限(本插件需要,全部可在设置页看到"申请中") -- ------------------------------------------------ -- add_source 把平台端点注册为上游源 -- list_sources 显示当前已接入的平台 -- probe_all 接入后立刻验证可达 -- reset_health 上游恢复后清冷却 -- status 面板显示运行状态 -- -- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被 -- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。 -- -- 登录会话归本插件自持(内核无"登录"概念):会话表在 plugin.state.sessions, -- 带 nonce 与过期时间;浏览器栈(Xvfb/chromium/x11vnc/websockify)由本插件用 -- os.execute 直接拉起并记录 pid,插件卸载/会话结束时自己回收。 -- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化流程(轮询/回调) -- → ③ VNC 无头浏览器兜底(无浏览器系统 / 自动化也无法完成的场景)。 local plugin = { name = "channels", version = "1.0.0", description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源", author = "llmsproxy", } -- 申请的内核权限。设置页会把"申请中但未授权"标红。 plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" } -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "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", "__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= -- (参考 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://.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`(内核的 .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--。模型开关即改写 -- 这条源的 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**(不能把整个 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-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/,**不用 os.execute("kill -0 …")**: -- 后者靠 shell 退出码,而这个 VM 里 os.execute 的返回值形态不可靠 -- (判据实测咬了:陈旧锁没被回收 → 号仍然占着)。 -- /proc/ 存在即为进程存在(僵尸也在,偏保守,不会误回收活着的号)。 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: ]], }, } -- 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.__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