-- 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.K:本文件主 chunk 的 local 数已**顶到 Lua 上限 200**, -- 再加一个顶层 local 就编译失败(实测 +1 即 `main function has more than 200 -- local variables`),而且炸的是**整个渠道插件**(插件加载失败 = 全渠道不可用)。 -- 挂表字段**不占** local 槽位,故把纯常量收进这里腾出余量。 -- -- ⚠️ 只搬**纯常量**(字符串/数字/自闭合字面量):它们没有前向引用问题, -- 搬迁是纯机械的。函数保持 local(它们之间的调用顺序是真实依赖)。 plugin.K = { CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com", CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect", HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html", CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains", CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3", QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000, QODER_REFRESH_HOUR_UTC8 = 10, QODER_MACHINE_ENVIRONMENT = "3", STORE_NAME = ".channels-store.json", B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", B64_LOOKUP = {}, SESSION_TTL = 900, -- 15 分钟,过期会话在读取时惰性清理 MODELS_DEV_URL = "https://models.dev/api.json", MODELS_DEV_TTL = 6 * 3600, -- 6 小时(照参考的 TTL 量级) EXPIRING_WINDOW_DAYS_DEFAULT = 15, BALANCE_CACHE_TTL = 60, DEAD_MODEL_TTL_DAYS = 30, REMOTE_CATALOG_COOLDOWN = 30, -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS INJECT_PLATFORM_LIMIT = 32, ZCODE_ORIGIN = "https://zcode.z.ai", } -- ★★ plugin.adaptersDir():适配器目录 = 插件目录的**兄弟**目录 -- (/plugins 与 /adapters)。由插件目录推导,不猜绝对路径 -- (测试与多实例部署都靠它隔离)。 -- -- ⚠️ 为什么必须放在**插件主体**:适配器运行在**另一个沙箱 VM**, -- 那里 `__llmsproxy_plugin_dir` 与 `LLMSPROXY_PLUGIN_DIR` **都不存在**、 -- **也没有文件系统访问** ⇒ 在适配器里读任何文件都拿不到东西且**静默降级** -- (实测:zcode 适配器因此只拿到自己定义的 2 个函数,缺 7 个, -- 每次调用内核报 "missing transform_response" ⇒ 渠道完全不可用)。 -- 所以凡是要"读兄弟文件"的,一律在插件主体做好再内联进去。 function plugin.adaptersDir() local pd = __llmsproxy_plugin_dir if type(pd) ~= "string" or pd == "" then pd = (os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")) or "" end if type(pd) ~= "string" or pd == "" then return nil end local base = pd:gsub("/+$", "") local parent = base:match("^(.*)/[^/]+$") if not parent then return nil end return parent .. "/adapters" end -- 申请的内核权限。设置页会把"申请中但未授权"标红。 -- 声明本插件要用的内核动作(运维据此授予;declared 与 granted 成对展示)。 -- -- ★ 这份清单必须与实际调用**逐一对齐**:`remove_source` 曾被漏声明, -- 而插件在 5 处调用它(syncAccountSource 的重建、dropAccountSource、 -- account_delete 收尾、两处清旧单账号源)⇒ 生产只授了 add_source, -- 于是**撤源全部被拒**。更坏的是它**静默**:AddSource 是**同名 upsert**, -- 所以"先 remove 再 add"的 remove 失败看不出来,只有**纯删除路径** -- (删账号/停用)才暴露 —— 表现是「账号删了,它的源还在对外服务」。 -- ⇒ 加动作时**同时**改这里;判据见 channels_kernel_declaration_test.go。 plugin.kernel = { "add_source", "remove_source", "list_sources", "probe_all", "reset_health", "status", } -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "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", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } -- ============================================================================ -- 平台表 -- ============================================================================ -- -- 每个平台声明登录方式与各端点。字段: -- flow link-poll(给 URL 轮询)/ callback(OAuth 重定向回来带 code)/ -- device-poll(设备码:先申请码、给用户 URL、再轮询换 token)/ -- paste(用户粘贴 cookie/token) -- login_url 用户要在自己浏览器打开的地址 -- base_url 接入网关后对话走的 OpenAI 兼容端点(adapter 决定协议形状) -- adapter 内核适配器名(openai / anthropic / …) -- models fallback 模型清单(登录后可用 refresh 动态拉取覆盖) -- checkin 每日额度领取:{ url, method, body, auth }(可缺省=不支持签到) -- -- 新增平台 = 加一条。不改内核,也不改本文件其它部分。 -- -- 注意:真实厂商端点逆向自参考实现(dsh-our-free-model/vendor/channel-pack)。 -- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。 -- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua -- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。 -- 前向声明:本文件里**所有**会被「更早定义的代码」引用的 local 助手都要在此 -- 统一占坑。Lua 的 local 作用域只覆盖其后的代码 —— 漏一个就是运行时的 -- "attempt to call global 'x' (a nil value)",而编译期完全静默(本文件已踩多次: -- qoderMachineIdentity / rfc3986Escape / newToken / lobsteraiClientVersion / -- traeCallback 各一次)。 local qoderMachineIdentity, rfc3986Escape, newToken local lobsteraiClientVersion -- 前向声明(本文件第 10 次踩 local 前向引用):余额分档选号在前、具体抓取在后。 -- expiringWindowDays / fetchAccountJSON / fetchBalanceOf 都定义在下方。 local expiringWindowDays local fetchAccountJSON -- ★ 选号链路的前向声明(第 11 次踩同一个坑) -- pickAccountForSource 定义在 syncProviderSource 旁边(~4160),但它依赖 -- accountBalanceRaw / balanceTier(~6690/6716)。不声明的话那两个名字在 -- 定义处会被解析成**全局**(nil),选号时抛 -- `attempt to call global 'accountBalanceRaw' (a nil value)` —— account_add -- 直接 500,源根本注册不出来(已实测踩过)。 local pickAccountForSource, accountBalanceRaw, balanceTier -- ★★ 这批前向声明必须在 PLATFORMS **之前**:平台表里的 headers_fn / claim_fn -- 闭包会在**运行时**调用这些函数,而闭包捕获的是**词法作用域里的那个变量**。 -- 若声明写在后面(例如 line ~5479),PLATFORMS 段里的引用会被解析成**全局** -- `traeCheckinHeaders`(nil),而后面的 `local` 是**另一个变量**,赋不到它 -- ⇒ 调用点永远拿到 nil。 -- -- 真实缺陷(2026-10-17 用户报「显示额度不可用」): -- trae 的 checkin_status.headers_fn 调 traeCheckinHeaders(...) 返回 nil, -- fetchAccountJSON 于是退回默认 `Authorization: Bearer ` -- ⇒ 上游回「not able to authenticate you」;而同一账号的**签到领取是成功的** -- (claim_fn 那条路同样拿到 nil,但它内部有 Bearer 兜底且 claim 端点接受)。 -- ⇒ 教训:Lua 的 local 只对**其后的**代码可见;闭包不是延迟绑定的魔法。 local traeCheckinHeaders, traeFixNickname local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId 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" }, -- ★ 远端模型目录:POST /api/ide/v1/batch_get_detail_param(通过 models_remote)。 -- -- 为什么要有它:**只有这个端点能告诉我们每个模型的真实元数据** —— -- 上下文窗口、输出上限、是否支持图像/工具响应图像、以及**思考档位**。 -- 硬编码的 models 兜底表只有名字,于是 UI 上所有模型都显示"未提供推理等级", -- 且多模态能力无从得知。 -- -- ⚠ 端点选择:**必须用 batch**(多通道)而不是单通道 get_detail_param。 -- 真实 CN IDE 用 batch 一次查 22 个 function,每个 function 各自一套目录; -- 只传聊天通道会让非聊天通道的条目在响应里**位置错乱甚至被跳过**(参考实测)。 -- -- ⚠ 请求体必须传**全部 22 个 function**(与真实 IDE 一致),不是只传我们关心的 -- 那几个 —— 理由同上:响应分组与请求顺序相关。解析侧再用白名单做**准入**。 models_remote = { endpoints = { { url = "https://trae-api-cn.mchost.guru/api/ide/v1/batch_get_detail_param", method = "POST", body = '{"functions":["ui_builder_v2","solo_coder","chat_v3","solo_builder",' .. '"builder_v3","builder","chat","inline_chat","git_ai",' .. '"custom_agent_generation","utils","code_reviewer","code_review_summary",' .. '"solo_agent","solo_agent_remote","solo_work_remote","solo_agent_lite",' .. '"solo_work_lite","solo_design_lite","solo_design_remote","multimodal",' .. '"system_diagnosis"],"agent_type":"",' .. '"current_config_info":{"config_name":"","is_custom_model":false},' .. '"mode_type":0,"access_type":0,"ab_force_vids":"",' .. '"ab_autotest_advanced_mode":0,"show_custom_model":true}', -- ★ 鉴权方案是 Cloud-IDE-JWT + 约 20 个客户端头(含按 user_id 派生的 -- 设备身份),**不能只发 Bearer** —— 参考注释点明简化头实测失败。 -- 走 sign="trae_solo"(复用平台既有的 traeCheckinHeaders 实现, -- 与 codearts 的 ep.sign 同一模式,不另写一套头部构造)。 sign = "trae_solo", } }, auth = "none", -- 鉴权头由 headers_fn 负责 parse = "trae_batch", }, -- 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", -- ★ 让 syncAccountSource 把凭据里的设备身份放进源的 meta —— -- trae 的推理头需要 X-Uid / X-Device-Id / X-Machine-Id 派生, -- 而适配器沙箱里读不到凭据(只能用内核传进来的 meta)。 source_meta_from_credential = true, -- 照参考 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 = "{}", -- ★ 鉴权方案与 checkin 相同:Cloud-IDE-JWT + 设备身份头。 -- 参考 trae.ts 端点表:签到状态 / 积分余额 皆 Cloud-IDE-JWT。 -- headers_fn 在**文件末尾的绑定循环**里接上(auth="cloud-ide-jwt" 段): -- 内联定义会因作用域顺序拿不到 traeCheckinHeaders / platformById, -- 闭包捕获 nil、错误被 pcall 吞掉 ⇒ **静默退回 Bearer** ⇒ -- 上游「not able to authenticate you」⇒ 用户看到「额度不可用」。2026-10-17。 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}', -- 同上:积分余额同为 Cloud-IDE-JWT;headers_fn 在末尾绑定循环里接上。 -- ★★ 字段在**两层嵌套**里,不在 pack 顶层: -- pack.entitlement_base_info.quota.credits_limit ← 额度上限 -- pack.usage.credits_amount ← 已用 -- 参考 trae-credits.ts:341 `const quota = base.quota` + -- `readNumber(quota,'credits_limit')` 逐字如此。 -- -- 真实缺陷(2026-10-17 用户报「额度显示依旧异常」): -- 我此前读的是 `p.credits_limit`(pack 顶层)—— 该字段**根本不存在** -- ⇒ 每个包都算 0 ⇒ 总余额恒 0,而账号里其实有 600(每月登录 500 + 签到 100)。 -- 实测上游响应(2026-10-17): -- pack[0] 免费 quota 无 credits_limit → 跳过 -- pack[1] 每月登录赠送 quota.credits_limit=500 usage={} -- pack[2] 签到奖励 quota.credits_limit=100 usage={} -- usage_summary: {total_amount:600, consumed_amount:0} 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 base = p.entitlement_base_info or p.entitlementBaseInfo local quota = (type(base) == "table" and (base.quota or base.Quota)) or nil local lim, amt = 0, 0 if type(quota) == "table" then lim = tonumber(quota.credits_limit) or tonumber(quota.creditsLimit) or 0 end -- 兼容:少数响应把额度放在 pack 顶层(保留旧路径,不丢) if lim == 0 then lim = tonumber(p.credits_limit) or tonumber(p.creditsLimit) or 0 end local usage = p.usage or p.Usage if type(usage) == "table" then amt = tonumber(usage.credits_amount) or tonumber(usage.creditsAmount) or 0 end if amt == 0 then amt = tonumber(p.credits_amount) or tonumber(p.creditsAmount) or 0 end if lim > 0 then sum = sum + math.max(0, lim - amt) end end -- 兜底:所有包都没有 credits_limit 时,用 usage_summary 的总量减消耗。 -- (实测「免费」包就是这样:quota 里只有 enable_* 布尔,无额度字段。) if sum == 0 then local us = rec.usage_summary or rec.usageSummary if type(us) == "table" then local tot = tonumber(us.total_amount) or tonumber(us.totalAmount) or 0 local used = tonumber(us.consumed_amount) or tonumber(us.consumedAmount) or 0 if tot > 0 then sum = math.max(0, tot - used) end end 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", -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL)。 -- 此前 base_url 只写 host、adapter=openai,于是请求打到 -- https://agent.minimax.cn/chat/completions —— 那是 **Web 前端**, -- 返回 404 首页文案「MiniMax Agent: 简单指令,无限可能」。 -- 实测(2026-10-19):/mavis/api/v1/llm/v1/messages → 401 invalid -- access token(=路径正确、凭据问题);裸 /chat/completions 与 -- /mavis/api/v1/chat/completions 都是 404。 -- 照参考 minimax-product.ts MINIMAX_INFER_PATH。 endpoint = "/mavis/api/v1/llm/v1/messages", -- 远端模型目录(照参考 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", }, -- ★ 推理端点是 **Anthropic Messages 协议**(不是 OpenAI): -- 参考 minimax-product.ts 的 `MINIMAX_INFER_PATH = '/mavis/api/v1/llm/v1/messages'` -- 与 anthropic 基座 `adapter.endpoint = "/v1/messages"` 同族。 -- 此前写成 openai ⇒ 即使路径改对,请求体形状也不对。 adapter = "anthropic", -- 照参考 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", -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 -- 完整路径,实际请求就变成 .../api/v1/chat/completions/chat/completions ⇒ 上游 -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 endpoint = "/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", -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 -- 完整路径,实际请求就变成 .../v2/chat/completions/chat/completions ⇒ 上游 -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 endpoint = "/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", }, -- ★ 用内置 buddy 适配器(而非通用 openai):buddy 的 glm-5.1 要求请求体里 -- 必须有非空 reasoning_effort(否则 400 + 11150),且流式请求必须带 -- Accept: text/event-stream(否则上游只回空帧、completion_tokens=0)。 -- 该适配器复用 openai 的全部转换,只补这两处厂商要求。 adapter = "buddy", -- 计费/状态请求头:逐行移植参考 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 = { -- 兜底目录(远端不可用时才用)。照参考 CODEBUDDY_FALLBACK_MODELS 的取舍: -- **只列实测可调用的**。hy4-preview-x 已实测恒 11102(后端未开放)故剔除 -- —— 参考的兜底表里同样没有它(它靠白名单达到同样效果)。 "deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "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", -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 -- 完整路径,实际请求就变成 .../api/proxy/v1/chat/completions/chat/completions ⇒ 上游 -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 endpoint = "/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 必须保留 `/zen` 段:参考 opencode-product.ts:56 的 -- `baseUrl: 'https://opencode.ai/zen'` + chatPath `/v1/chat/completions`。 -- 实测(无凭据): -- https://opencode.ai/zen/v1/chat/completions → 500(路由存在,鉴权前先炸) -- https://opencode.ai/v1/chat/completions → 404 + HTML(网页) base_url = "https://opencode.ai/zen", -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是 -- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是 -- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成 -- .../v1/chat/completions/chat/completions ⇒ 404 Route Not Found。 endpoint = "/v1/chat/completions", -- 远端模型目录(照参考 opencode-product.ts modelsPath)。 -- ★ 端点必须是 /zen/v1/models(带 UA+x-opencode-client 头):裸打 -- /v1/models 回的是 HTML 网页(2026-10-19 实测),id_list 解析必空。 models_remote = { endpoints = { { url = "https://opencode.ai/zen/v1/models", method = "GET", headers = { ["User-Agent"] = "opencode/1.19.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14", ["x-opencode-client"] = "cli" } } }, auth = "none", 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)。 -- ⚠️ 2026-10-19 实测目录更新(/zen/v1/models): -- * space-bunny-free 已 410 下架(官方指路 minimax-m3.1-flash-free) -- * nemotron-3-ultra-free 已不在目录 -- * 新增 beam-free / nemotron-3.5-super-vl-free / jev-1.13-free / -- glm-5.3-flash-free / minimax-m3.1-flash-free models = { "big-pickle", "beam-free", "longcat-2.5-preview-free", "mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free", "nemotron-3.5-lightning-free", "jev-1.13-free", "glm-5.3-flash-free", "minimax-m3.1-flash-free", "deepseek-v4.1-flash-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", "beam-free", "longcat-2.5-preview-free", "mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free", "nemotron-3.5-lightning-free", "jev-1.13-free", "glm-5.3-flash-free", "minimax-m3.1-flash-free", "deepseek-v4.1-flash-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", -- ★ 续期:华为云 STS 的 security_token 只活 1 小时,不续就会 -- `APIG.0602: the security token has expired` 拒掉每个请求。 -- -- 为什么必须在这里声明(真实缺陷,2026-10-19 用户报「华为云源用不了」): -- account_refresh 走 refreshCredential(),它要求 `plat.refresh.url` 存在; -- codearts 只声明了 refreshable=true(在 accountView 里硬编码)却没声明 -- **怎么续** ⇒ 手动刷新恒出 `该平台未声明续期方式`,自动续期链空转, -- 日志里反复 `模型刷新失败 codearts: HTTP 400`。 -- -- 为什么不能走 REFRESH_STYLES 的声明式风格: -- 华为云 STS 要 **DPoP 头**(ES256 JWS,jwk 用凭据里存的 dpop_private_key_jwk -- 签),而声明式 style 只发普通 JSON/form 且拿不到私钥。已有实现 -- `codeartsRequestToken(body, key)` 做完整这件事 —— 故用 refresh_fn -- 挂钩它,而非新增声明式 style(保持内核/通用层不理解厂商)。 -- -- 请求体真值(照参考 channel-pack/src/oauth.ts exchangeRefreshToken): -- client_id=codearts-agent grant_type=refresh_token -- refresh_token=<凭据里的> code_verifier=<登录时的> -- ⚠️ client_id 是 **codearts-agent**,不是凭据里的 access_key_id -- (我实测把 AK 当 client_id 会得到 `invalid client id`)。 -- 实际绑定在文件末尾的绑定循环(refresh_fn),不在本表内联。 }, -- 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", -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 -- 完整路径,实际请求就变成 .../model/v1/chat/completions/chat/completions ⇒ 上游 -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 endpoint = "/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)。 -- -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: -- 内核 URL = `base_url + endpoint`(provider.go ChatURL)。 -- 此前 base_url 已含 /api/v1/zcode-plan/anthropic,而下面又声明了 -- endpoint = "/api/v1/zcode-plan/anthropic/v1/messages" ⇒ 路径被拼两遍: -- https://zcode.z.ai/api/v1/zcode-plan/anthropic/api/v1/zcode-plan/anthropic/v1/messages -- → 404 page not found(生产实测 2026-10-19)。 -- 与 v2.0.30「12 平台 base_url 侵占 endpoint」同族缺陷。 base_url = "https://zcode.z.ai", -- 远端模型目录(照参考 zcode-upstream.ts ZCODE_CLIENT_CONFIGS_URL) models_remote = { endpoints = { { url = "https://zcode.z.ai/api/v1/client/configs", method = "GET", query = "app_version=1.0.0&platform=unknown" } }, auth = "none", parse = "zcode_configs", }, endpoint = "/api/v1/zcode-plan/anthropic/v1/messages", -- ★ 不能复用通用 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)。 -- -- ★ host 是 **loomyad.xunfei.cn/api/v1**(参考 LOOMY_API_BASE),不是 -- `loomy.xunfei.cn` —— 后者是 **Web 前端/OAuth 回调**域(本块的 -- wechat.redirect_uri 正用它)。实测: -- POST https://loomy.xunfei.cn/chat/completions -- → 200 + ``(网页!根本不是 API) -- POST https://loomyad.xunfei.cn/api/v1/chat/completions -- → 200 + `{"code":"100002","desc":"缺少 token"}`(正常 JSON) -- 写成前端域时,无论路径怎么拼都不可能是 API。 -- -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是 -- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是 -- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成 -- .../chat/completions/chat/completions ⇒ 上游回 -- `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 base_url = "https://loomyad.xunfei.cn/api/v1", endpoint = "/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", -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 -- 完整路径,实际请求就变成 .../api/web/llm/v2/chat/completions/chat/completions ⇒ 上游 -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 endpoint = "/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 引擎域名(连接与签名都用它)。 -- 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", plugin.K.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", plugin.K.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", plugin.K.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", plugin.K.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 -- b64urlDecode:base64url → 二进制。 -- -- 为什么自己写:内核只导出 `base64_encode`(vm.go:1363),**没有**decode。 -- 而续期需要把 JWK 的 d/x/y(都是 base64url 字符串)转回原始字节。 local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local b64rev = {} for i = 1, #b64chars do b64rev[b64chars:sub(i, i)] = i - 1 end local function b64urlDecode(s) s = tostring(s or ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "") local out, buf, nbits = {}, 0, 0 for i = 1, #s do local v = b64rev[s:sub(i, i)] if v then buf = buf * 64 + v nbits = nbits + 6 -- ★★ `buf = buf % (2 ^ nbits)` 是必须的:只在取字节时 % 256 而不清掉 -- 已用掉的高位,buf 会持续累积。短输入(32 字节)碰巧全对,长输入 -- 则 double 精度耗尽、math.floor 给出越界值 ⇒ string.char 抛 -- invalid value。判据 TestB64urlDecodeHandlesLongInput 专钉这条。 while nbits >= 8 do nbits = nbits - 8 out[#out + 1] = string.char(math.floor(buf / (2 ^ nbits)) % 256) buf = buf % (2 ^ nbits) end end end return table.concat(out) end -- derLen:DER 长度字段(短/长格式)。 local function derLen(n) if n < 128 then return string.char(n) end local bytes = {} while n > 0 do table.insert(bytes, 1, string.char(n % 256)) n = math.floor(n / 256) end return string.char(0x80 + #bytes) .. table.concat(bytes) end -- derTLV(tag, value):最小 DER 编码单元。 local function derTLV(tag, value) return tag .. derLen(#value) .. value end -- codeartsKeyFromJwk(jwk):把持久化的 DPoP 私钥 JWK **重建**成可签名 key。 -- -- 为什么需要它(真实缺陷,2026-10-19): -- 登录时 `codeartsNewDpop()` 生成 PEM,返回 { pem, public, private }; -- 但凭据里**只持久化了 private(JWK)** —— pem 是临时文件,登录后即删。 -- 而 `codeartsDpopSign(key,...)` 走 openssl 命令行,**必须**要 `key.pem` -- 字符串。直接把 { private = jwk } 丢进去会在 `pf:write(key.pem)` 报 -- `bad argument #1 to 'write' (string expected, got nil)`。 -- -- 做法:用 d/x/y 手工组 SEC1 `EC PRIVATE KEY` 的 DER,再 PEM 包装。 -- 结构与 openssl 生成的一致(已用实测 PEM 对照过字段顺序): -- SEQUENCE -- INTEGER 1 (version) -- OCTET STRING <32B d> (privateKey) -- [0] OID 1.2.840.10045.3.1.7 (prime256v1) -- [1] BIT STRING <04||X||Y> (publicKey) -- -- 输出形状与 codeartsNewDpop() **完全一致**(pem + public + private), -- 于是 codeartsDpopSign / codeartsCredentialFrom 无需分叉。 local function codeartsKeyFromJwk(jwk) local d = b64urlDecode(jwk.d) local x = b64urlDecode(jwk.x) local y = b64urlDecode(jwk.y) if #d ~= 32 or #x ~= 32 or #y ~= 32 then return nil, "DPoP 私钥 JWK 字段长度异常(d=" .. #d .. " x=" .. #x .. " y=" .. #y .. ")" end -- OID 1.2.840.10045.3.1.7 (prime256v1) 的 DER 字节 -- OID 1.2.840.10045.3.1.7(prime256v1)的**完整 TLV**(\6=tag \8=len)。 -- ★ 这里只需一次 TLV:字符串里已有 tag+len,再套一层会得到 -- 06 0a 06 08 … —— DER 长了 2 字节,openssl 报 -- `Could not find private key` / `provider=default`,而**不**说结构错。 -- 我第一版就是这样(真实 PEM 121 字节 vs 我的 123 字节)。 local oid = "\6\8*\134H\206=\3\1\7" local pub = "\4" .. x .. y -- 未压缩点:04 || X || Y local body = derTLV("\2", "\1") .. -- INTEGER 1 derTLV("\4", d) .. -- OCTET STRING d derTLV("\160", oid) .. -- [0] parameters(oid 已含 tag+len) derTLV("\161", derTLV("\3", "\0" .. pub)) -- [1] publicKey(BIT STRING 前导 0 位) local der = derTLV("\48", body) -- SEQUENCE -- PEM 包装:EC PRIVATE KEY + base64 + 64 字符换行 local b64 = base64_encode(der) local lines = { "-----BEGIN EC PRIVATE KEY-----" } for i = 1, #b64, 64 do lines[#lines + 1] = b64:sub(i, i + 63) end lines[#lines + 1] = "-----END EC PRIVATE KEY-----" return { pem = table.concat(lines, "\n") .. "\n", public = { kty = "EC", crv = "P-256", x = b64urlRaw(x), y = b64urlRaw(y) }, private = { kty = "EC", crv = "P-256", x = b64urlRaw(x), y = b64urlRaw(y), d = b64urlRaw(d) }, }, nil 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):plugin.K.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 -- refreshCodeArts(plat, cred):用已有 refresh_token 静默续一次华为云 STS token。 -- -- 照参考 channel-pack/src/oauth.ts `exchangeRefreshToken`: -- requestToken({ client_id: CLIENT_ID, code_verifier, -- grant_type: GRANT_REFRESH_TOKEN, refresh_token }, keyPair) -- 请求体四个字段缺一不可(少 code_verifier 会被拒)。 -- -- ★ 两个易错点(我自己踩过,2026-10-19): -- 1. client_id 是 **codearts-agent**(CODEARTS.client_id), -- **不是**凭据里的 access_key_id —— 把 AK 当 client_id 会得到 -- `STS5.1806 invalid refresh token: 'invalid client id: HSTACS...'`。 -- 2. **refresh_token 是一次性的**:换成功一次后旧值立即作废 -- (再发一次得 `the refresh token has been used`)⇒ 响应里的新 -- refresh_token **必须**回写,否则下一轮永久失去续期能力。 -- 这正是本函数用 codeartsCredentialFrom 重组整个凭据的原因。 -- -- 返回 (newCredTable, nil) | (nil, err)。失败信息要能定位(带上游 error_code)。 local function refreshCodeArts(plat, cred) local rtoken = cred.refresh_token local verifier = cred.code_verifier if type(rtoken) ~= "string" or rtoken == "" then return nil, "无 refresh_token,需重新登录" end if type(verifier) ~= "string" or verifier == "" then return nil, "无 code_verifier,需重新登录" end local jwk = cred.dpop_private_key_jwk if type(jwk) ~= "table" then return nil, "无 DPoP 私钥,需重新登录" end -- DPoP 私钥必须从凭据里恢复(同一个 key 才能让 STS 认这是同一客户端)。 -- -- ★ 这里有一个真实的形状差异(我自己踩过): -- `codeartsNewDpop()` 返回 { pem, public, private },但凭据里 -- **只保存了 private(JWK)** —— pem 是登录时的临时文件,没有持久化。 -- 而 `codeartsDpopSign(key, ...)` 要的是 `key.pem` 字符串(它走 -- openssl 命令行签名)。所以直接传 { private = jwk } 会在 -- `pf:write(key.pem)` 报 `bad argument #1 to 'write' (string expected, got nil)`。 -- -- 修法:用 JWK 的 d/x/y 重建 PEM(EC PRIVATE KEY 的 SEC1 格式, -- openssl 认得)。重建后的 key 同时含 pem / public / private, -- 与 codeartsNewDpop() 的返回形状一致,后续逻辑无需分叉。 local key, kerr = codeartsKeyFromJwk(jwk) if not key then return nil, tostring(kerr) end local token, terr = codeartsRequestToken({ client_id = CODEARTS.client_id, code_verifier = verifier, grant_type = "refresh_token", refresh_token = rtoken, }, key) if not token then return nil, tostring(terr) end -- token.refresh_token 若上游给了新的就用新的;没给则沿用旧值 -- (华为云实测会返回新的;保留旧值做兜底不会更差) if type(token.refresh_token) ~= "string" or token.refresh_token == "" then token.refresh_token = rtoken end return codeartsCredentialFrom(token, { verifier = verifier }, key) 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 plugin.K.CODEARTS_TICKET_PLUGIN_NAME, ["plugin-version"] = pluginVersion or plugin.K.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_CREDENTIAL_ENDPOINT = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/login/ticket" -- 生成 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 结算,口径与参考一致。 -- 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 + plugin.K.QODER_UTC8_OFFSET_MS) / 1000)) return (utc8.hour or 0) >= plugin.K.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):完整活动领取(多活动逐个领)。 -- ★ 凭据形状必须兼容**裸 token**:qoder 是 PKCE 设备码流且无 register 第二跳, -- complete_login 存进凭据库的就是裸 access_token 字符串(credentialOf 走裸快路 -- 返回 (tok, nil, nil)),credArgs.credential 因此是 nil。此前在这里硬要求 -- table,导致"测试通过(token 路径)、领取失败(缺凭据对象)"的自相矛盾。 -- 活动接口只要 Bearer token,裸 tok 非空时就地包一层。 local function qoderClaim(plat, tok, args) local cred = (args and args.credential) or nil if type(cred) ~= "table" then if type(tok) == "string" and tok ~= "" then cred = { access_token = tok } else return nil, "缺少凭据对象" end 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 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, plugin.K.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 -- 绑定 codearts 的续期钩子。 -- -- ★ 为何必须在这里挂而不是在平台表里内联: -- PLATFORMS 在第 ~900 行,而 refreshCodeArts 定义在第 ~1720 行。 -- 在表里写 `refresh_fn = function(...) return refreshCodeArts(...) end` -- 会闭包捕获**当时的全局 nil**(该名还是 nil)—— 调用时抛错, -- 与 trae 那次 headers_fn 同一坑(当时只加 auth 无效、表内内联也无效)。 -- 在文件末尾绑定则天然拿到已定义的值。 for _, p in ipairs(PLATFORMS) do if p.id == "codearts" then p.refresh_fn = function(plat, cred) return refreshCodeArts(plat, cred) 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 原子替换。读:缺失/损坏 → 用内置默认值(不阻塞启动)。 -- 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 .. "/" .. plugin.K.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 .. "/" .. plugin.K.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] 已迁移遗留状态到 " .. plugin.K.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 -- utf8Clip 按**字符**截断(不是字节),并保证结果仍是合法 UTF-8。 -- -- ★ 为什么不能用 `s:sub(1, N)`:Lua 的 sub 按**字节**切,而中文字符占 3 字节 -- ⇒ 切在字符中间就产生**无效 UTF-8**,前端显示成 `能力增强不�` 这种乱码尾巴。 -- 实测(生产能力元数据那条)就是这样 —— 一句本来完整的诊断被切出替换字符, -- 看起来像数据损坏,实际是截断方式错。 -- -- 做法:先按字节切,再回退到最后一个完整 UTF-8 序列边界。 -- 只在必要时回退(最多退 2 字节),所以不会显著缩短内容。 plugin.utf8Clip = function(s, maxBytes) s = tostring(s or "") if #s <= maxBytes then return s end local cut = s:sub(1, maxBytes) -- 回退掉尾部不完整的 UTF-8 续字节(10xxxxxx)与被切半的首字节 for _ = 1, 4 do local b = cut:byte(-1) if not b then break end if b < 0x80 then break -- ASCII,完整 elseif b >= 0xC0 then -- 首字节但没有续字节收尾 ⇒ 整字符不完整 cut = cut:sub(1, -2) break else -- 续字节,继续往前退 cut = cut:sub(1, -2) end end return cut end -- logEvent 写一条插件事件日志(带级别与渠道归属)。 -- -- ★ 为什么需要「级别 + 折叠」,而不是继续往一个裸数组里追加: -- 实测生产 50 条日志里 **48 条(96%)** 是三个定时任务 -- (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now) -- 的重复回音,唯一模式只有 8 种 ⇒ 约 **2 小时**就把 50 条环形缓冲冲满一轮, -- **真正的用户动作**(如「账号删除」)被挤到看不见 —— 面板沦为 -- 「最近两小时的定时任务回音」。 -- -- 三条措施: -- ① `level` ∈ info|task|warn|error。定时任务的**常规**结果记 "task" -- (前端默认折叠),只有异常才 warn/error。 -- ② **紧邻同文折叠**:与**上一条**完全同文时不新增条目,只 n=n+1 -- 并刷新时间戳。周期任务的连续回音于是合并成一条(显示 ×9), -- 而无需给每个调用点判定「这次结果变了没」(那要引入状态比较)。 -- ⚠️ 只在**紧邻**时折叠:否则一条每小时出现的消息会不断"跳"到最前, -- 时间线就失去意义。 -- ③ `platform`:记录归属,前端按当前选中渠道过滤。 -- ⚠️ 历史 state 里的老条目没有这个字段,前端必须容忍 nil。 local function logEvent(msg, level, platform) local log = plugin.state.log local now = os.time and os.time() or 0 local top = log[1] if top and top.msg == msg and (top.level or "info") == (level or "info") then top.n = (top.n or 1) + 1 top.at = now markDirty() return end table.insert(log, 1, { at = now, msg = msg, level = level or "info", platform = platform, }) while #log > 50 do table.remove(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 语义) local cx, cy = x.createdAt or 0, y.createdAt or 0 if cx ~= cy then return cx < cy end -- ★ 必须给**确定性**兜底:createdAt 是**秒级**,同一秒建的两个账号比较相等, -- 而相等时 table.sort 的结果取决于 pairs() 的遍历顺序 —— **不确定** -- (实测:按 a,b,c 顺序建的三个账号,poolList 回的是 [a,c,b])。 -- 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = 内核尝试顺序, -- 故同秒创建的账号里"先试哪个"是随机的,且可能每次进程重启都变。 -- 用 id 作最后的稳定键(id 唯一且不变),让顺序完全确定。 return tostring(x.id or "") < tostring(y.id or "") 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 两种字母表,容忍缺失填充。 for i = 1, #plugin.K.B64_ALPHABET do plugin.K.B64_LOOKUP[plugin.K.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 = plugin.K.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)。失败原因必须可诊断(不能只说"失败了")。 -- -- 两种续期方式: -- * `plat.refresh`(声明式):普通 JSON/form OAuth —— 大多数平台 -- * `plat.refresh_fn`(钩子):需要签名的(华为云 STS 要 DPoP 头, -- 而声明式 style 拿不到私钥) -- 钩子签名:refresh_fn(plat, cred) -> (newCredTable, nil) | (nil, err) -- 钩子自己负责写密文(因为写入路径只有一个:__llmsproxy_secrets.set)。 local function refreshCredential(plat, acct) local cred = credentialFull(acct.credentialRef) if type(cred) ~= "table" then return false, "凭据无法解析" end -- ★ 钩子优先:需要签名的平台无法用声明式 style 表达 -- (DPoP 私钥不在 style 的可见范围内)。 if type(plat.refresh_fn) == "function" then if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end local newCred, ferr = plat.refresh_fn(plat, cred) if not newCred then return false, tostring(ferr or "续期失败") end __llmsproxy_secrets.set(acct.credentialRef, json.encode(newCred)) if newCred.expires_at then local ms = credentialExpiresAtMs(newCred) if ms then acct.expiresAt = math.floor(ms / 1000) end end return true, "已续期" end local r = plat.refresh if type(r) ~= "table" or not r.url 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),因为该端点历史上变过形状。 -- -- ★★ 必须过滤「不可调用」的条目 —— 远端 data.models 里混着三类**不是对话 -- 模型**的东西,列进模型选择器只会让用户选中后报错(实测生产:glm-5.1 -- 与 glm-5.2 之外,codewise-* / nes-gf / hunyuan-image-alpha 同样调不通)。 -- 判据与命名逐条照参考 buddy.ts 的 isChatModel / isAutoSelectAlias: -- ① 自动选择类内部别名:`auto` / `default` -- ⚠️ 只按**这两个字面量**过滤,不做前缀匹配 —— 前缀会误伤 -- `default-model` / `fast-model`(国际版抽象别名,但被 craft agent -- 引用,是官方推荐入口)。 -- ② 补全/NES 专用:前缀 `nes-` / `completion-` / `codewise-`, -- 或带 `supportsExtra` 标记。参考实测 codewise-default-model-v2 -- 返回 `code 11102 model service info not found`(后端未开放)。 -- ③ 输出上限过小(>0 且 ≤256):补全用途,对话模型普遍 ≥24000。 -- ④ 带 `text-to-image` 标签的生成式模型(如 hunyuan-image-alpha)。 -- ⚠️ ③④ 需要元数据,故先把 models 建成 id→meta 映射。 local d = rec.data or rec local arr = d.models or d.agents or rec.models local metaById = {} if type(arr) == "table" then for _, m in ipairs(arr) do if type(m) == "table" and type(m.id) == "string" and m.id ~= "" then metaById[m.id] = m end end end -- 被 agent 引用的模型(craft/ask/plan/cli)。参考据此给 `agentReferenced` -- 打标:`hy4-preview-f` 只由本端点下发且被 agent 引用,白名单重建会丢掉它。 local agentRef = {} if type(d.agents) == "table" then for _, ag in ipairs(d.agents) do if type(ag) == "table" and type(ag.models) == "table" then for _, mid in ipairs(ag.models) do if type(mid) == "string" then agentRef[mid] = true end end end end end -- ★ 已实测**不可调用**的模型(上游恒回 11102 "model service info not found")。 -- -- 为什么需要它:这类模型既**不是**前缀可判(不像 nes-/codewise-),也**不在** -- agent 引用清单之外 —— 实测 `hy4-preview-x` 被服务端 agents 引用, -- 于是会被下面的 agentRef 例外重新放回目录,用户选中就 400。 -- 参考的做法是**白名单重建**(只保留编译期实测过的 id),本仓没有那份表, -- 故用等价的**黑名单**精确排除:只放实测确认调不通的,不做任何猜测。 -- -- ⚠️ 判据来源必须是**真实请求**,不是元数据: -- 实测 hy4-preview-x 加/不加 reasoning_effort 都是 11102 ⇒ 与 effort 无关, -- 是后端未开放(与 glm-5.1 的 11150 是**不同**原因,后者已被 adapter 修好)。 -- 参考 product.ts 的兜底白名单里同样**没有** hy4-preview-x。 local NOT_CALLABLE = { ["hy4-preview-x"] = true, } local function buddyIsChatModel(id) if NOT_CALLABLE[id] then return false end if id == "auto" or id == "default" then return false end if id:sub(1, 4) == "nes-" or id:sub(1, 11) == "completion-" or id:sub(1, 9) == "codewise-" then return false end local m = metaById[id] if type(m) == "table" then if m.supportsExtra == true then return false end local mo = tonumber(m.maxOutputTokens) if mo and mo > 0 and mo <= 256 then return false end if type(m.tags) == "table" then for _, t in ipairs(m.tags) do if t == "text-to-image" then return false end end end end return true end local seen = {} local function pushId(id) if type(id) ~= "string" or id == "" then return end if seen[id] or not buddyIsChatModel(id) then return end seen[id] = true out[#out + 1] = id end -- models 与 agents 两处都收(agents 里是被引用的清单,可能含远端 -- data.models 没有的新变体)。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) or nil if type(id) == "string" then pushId(id) end end end -- agentRef 的例外也必须过 NOT_CALLABLE 闸:否则"被 agent 引用"会把 -- 实测不可调的模型重新放回目录(hy4-preview-x 正是这样漏进来的)。 for id in pairs(agentRef) do pushId(id) 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 == "trae_batch" then -- ★ TRAE 的 batch_get_detail_param(多通道模型目录)。 -- -- 参考实现(parseTraeBatchModelList)记录了**三个真实缺陷**,移植时必须一起 -- 带上,否则等于把已修好的 bug 再引进一遍: -- -- ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7): -- 22 个 function 里有一半在本插件推理端点下**不可调用** -- (实测 chat→4023 model unknown、builder→4001、inline_chat→3003)。 -- 旧实现只把白名单用于"同模型择优",于是"目录声明了什么就发什么" —— -- 用户选中那些模型必然失败,而错误文案指向**模型**,极易被误判成 -- "这个模型坏了"。现在不在表内的通道**整组丢弃**。 -- -- ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7): -- 旧实现无条件"后面的覆盖前面的",其假设(后面的配置更全)与真实数据 -- **相反** —— 上游把空档位的 solo_work_lite/solo_design_remote 排在最后, -- 实测 13 个模型因此丢掉 reasoning_effort_config,全部显示"未提供推理等级"。 -- -- ③ **is_custom_model 是「仅可见但不可调用」的权威判据**: -- 该标志为 true 的条目被上游以流内 event:error + 4001 拒绝。 -- ⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志会随服务端下发变化 -- (某次快照里 5 个条目全部失效、全目录 true 的条目数为 0)。 -- -- 另有硬性过滤:usage 必须 chat_completion、config_switch 不得 false、 -- is_invisible_to_user 不得 true。 local TRAE_CHANNELS = { "solo_agent", "solo_work_lite", "solo_agent_remote", "solo_work_remote", "solo_agent_lite", "chat_v3", "builder_v3", "solo_coder", "solo_design_lite", "solo_design_remote", "git_ai", "code_reviewer", "code_review_summary", "multimodal", "system_diagnosis", } local rankOf = {} for i, c in ipairs(TRAE_CHANNELS) do rankOf[c] = i end -- 取布尔字段(两种大小写) local function rbool(t, a, b) local v = t[a] if v == nil and b then v = t[b] end return v end local function rstr(t, a, b) local v = t[a] if v == nil and b then v = t[b] end return type(v) == "string" and v or nil end local function rnum(t, a, b) local v = t[a] if v == nil and b then v = t[b] end return tonumber(v) end -- declaresEffort(entry):该条目**能否真正声明出**思考档位。 -- ⚠ 必须与适配器的档位判定完全一致(配置存在 + 未显式 support_thinking:false -- + options 非空)。只判"配置存在"不够:{support_thinking:false, options:[]} -- 与 {...options:['high']} 都"存在配置",但前者在 UI 上仍显示"未提供推理等级" -- —— 按"存在即优先"合并等于没修。 local function declaresEffort(e) -- ⚠ 字段名必须与 parseEntry 存的一致(e.reasoning)。写成 e.reasoning_effort_config -- 会让整个判定**恒为 false** ⇒ 规则①永不生效 ⇒ 空档位照样覆盖有档位 -- (正是该规则要防的缺陷;判据当场抓到)。 local rc = e.reasoning if type(rc) ~= "table" then return false end local st = rbool(rc, "support_thinking", "supportThinking") if st == false then return false end local opts = rc.options or rc.Options return type(opts) == "table" and #opts > 0 end -- parseEntry(it, channel):把一条 config_info_list 条目解成我们关心的字段。 local function parseEntry(it, chan) local id = rstr(it, "config_name", "ConfigName") if not id or id == "" then return nil end local disp = it.display_config or it.DisplayConfig if type(disp) ~= "table" then disp = {} end local e = { id = id, name = rstr(disp, "display_name", "displayName") or id, chan = chan, usage = rstr(it, "usage", "Usage"), enabled = rbool(it, "config_switch", "ConfigSwitch"), hidden = rbool(it, "is_invisible_to_user", "IsInvisibleToUser"), custom = rbool(disp, "is_custom_model", "IsCustomModel"), reasoning = it.reasoning_effort_config or it.reasoningEffortConfig, } -- ⚠ contextWindow 取 `dev` 而非 `max`:真实条目形如 {dev:200000, max:1000000}, -- 而 max 需开启 display_config.max_mode 才可用(我们不实现该开关)。 -- 采信 max 会让上层以为有 1M 窗口、实际请求被上游拒。 local cw = it.context_window_tokens or it.contextWindowTokens if type(cw) == "table" then e.contextWindow = rnum(cw, "dev", "Dev") end -- 输出上限:model_detail_list[].max_tokens(取首条的 max_tokens) local mdl = it.model_detail_list or it.modelDetailList if type(mdl) == "table" and type(mdl[1]) == "table" then e.maxOutputTokens = rnum(mdl[1], "max_tokens", "maxTokens") end -- 计费:display_contact_config 是 **JSON 字符串**(需二次解析) local dcc = it.display_contact_config or it.DisplayContactConfig if type(dcc) == "string" and dcc ~= "" then local okj, cfg = pcall(json.decode, dcc) if okj and type(cfg) == "table" then local rate = cfg.consumption_rate or cfg.ConsumptionRate if type(rate) == "table" then local enable = rbool(rate, "enable", "Enable") if enable ~= false then local data = rate.data or rate.Data if type(data) == "table" then local v = rnum(data, "rate", "Rate") if v and v >= 0 then e.creditsRate = v end end end end -- 活动折扣:**不能只看 enable** —— 实测 enable=true 但当前无折扣的形态 -- 形如 {type:"none", before:0.13, after:0.13, discount:100} -- (discount=100 表示无折扣,before==after)。照显会让用户以为有活动。 local disc = cfg.activity_discount or cfg.ActivityDiscount if type(disc) == "table" and rbool(disc, "enable", "Enable") ~= false then local ddata = disc.data or disc.Data if type(ddata) == "table" then local cur = ddata.current or ddata.Current if type(cur) == "table" then local dtype = (rstr(cur, "discount_type", "discountType") or "") dtype = dtype:lower():gsub("^%s+", ""):gsub("%s+$", "") if dtype ~= "" and dtype ~= "none" then local before = rnum(cur, "before_consumption_rate", "beforeConsumptionRate") local after = rnum(cur, "consumption_rate", "consumptionRate") -- 必须是真的降价:before 有值、为正、且严格大于 after if before and before > 0 and (not after or before > after) then -- 截止时间:data 下任意带 end_at 的子对象 local endsAt for _, v in pairs(ddata) do if type(v) == "table" then local endv = rnum(v, "end_at", "endAt") if endv and endv > 0 then endsAt = endv break end end end local nowS = os.time and os.time() or 0 if not endsAt or endsAt > nowS then e.origCreditsRate = before e.discountEndsAt = endsAt end end end end end end end end return e end local groups = rec.function_configs or rec.functionConfigs if type(groups) == "table" then local byId, chosenRank, order = {}, {}, {} for _, g in ipairs(groups) do if type(g) == "table" then local chan = rstr(g, "function", "Function") -- 准入:未声明 function 或不在白名单 → 整组丢弃 if chan and rankOf[chan] then local list = g.config_info_list or g.ConfigInfoList if type(list) == "table" then for _, it in ipairs(list) do if type(it) == "table" then local e = parseEntry(it, chan) if e then -- 硬性过滤(不设开关) local ok = true if e.usage ~= nil and tostring(e.usage) ~= "chat_completion" then ok = false end if e.enabled == false then ok = false end if e.hidden == true then ok = false end -- is_custom_model: 仅可见但不可调用(上游流内 4001 拒绝) if e.custom == true then ok = false end if ok then local inc = byId[e.id] if inc == nil then byId[e.id] = e chosenRank[e.id] = rankOf[chan] order[#order + 1] = e.id else local incEff = declaresEffort(inc) local candEff = declaresEffort(e) local take = true if incEff and not candEff then take = false -- 规则①:空档位不得覆盖有档位 elseif incEff and candEff then -- 规则②:两侧都有档位 → 取通道优先级更靠前的 take = rankOf[chan] < (chosenRank[e.id] or 0) end if take then byId[e.id] = e chosenRank[e.id] = rankOf[chan] end end end end end end end end end end for _, id in ipairs(order) do out[#out + 1] = id -- 元数据另存(model_list 用它报出上下文窗口/档位/倍率): -- ⚠ 只报 id 会把参考能提供的元数据全部丢掉(档位信息在解析阶段就没了, -- 上层只能显示"未提供推理等级")。 if not plugin.state.modelMeta then plugin.state.modelMeta = {} end plugin.state.modelMeta[provider] = plugin.state.modelMeta[provider] or {} plugin.state.modelMeta[provider][id] = byId[id] 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" } -- ★ ep.body:POST 型目录端点(如 trae 的 batch_get_detail_param 必须 POST + -- 指定请求体)。缺失时按 GET。 local method = ep.method or "GET" if ep.body then headers["Content-Type"] = "application/json" end 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 = method, headers = headers } if ep.body then req.body = ep.body end if ep.sign == "trae_solo" then -- TRAE 的鉴权是 Cloud-IDE-JWT + 约 20 个客户端头(含按 user_id 派生的 -- 设备身份)—— 不能只发 Bearer。这里复用**平台既有的** traeCheckinHeaders -- 实现(它已经过实测:简化头会被拒),不另写一套头部构造, -- 否则“chat 能通、目录 401”这类差异极难查。 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_token then cred = cj break end end end if not cred then lastErr = "无可用 TRAE 凭据" elseif type(traeCheckinHeaders) ~= "function" then lastErr = "traeCheckinHeaders 未就绪(前向引用顺序问题)" else local uid = cred.uid or "" local h, herr = traeCheckinHeaders(plat, cred, uid) if herr then lastErr = tostring(herr) else -- 融平台头(后者覆盖前面的 Accept,但保留 Authorization 等) for k, v in pairs(h or {}) do req.headers[k] = v end end end end 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 更诚实,避免旧模型残留)。 -- -- ★ 现在**一个通道 = 一个适配器 = 一个源**,源名只跟通道绑定。 -- 旧设计是「一账号一源」(channels--),它有三个 -- 结构性后果,全都在生产里出现过: -- 1. 三个 trae 账号 ⇒ 三个源、三份同名模型。用户在模型下拉框里看到三行 -- 无法区分的东西(channels-trae-aaa · glm-5.2 / ...bbb · glm-5.2)。 -- 2. 谁先谁后**取决于注册顺序,不是余额**。余额用完的号可能排在前面, -- 每次都先撞它再回退。 -- 3. 换号要改源名 ⇒ 用户书签/脚本里的 `源:模型` 全部失效。 -- 新设计下换号 = 用同名 add_source 覆盖 api_key(内核 AddSource 是 -- UpsertSourceInYAML + 内存替换),对客户端完全透明。 local function providerSourceName(provider) return "channels-" .. provider end -- legacy 源名(旧版按账号拆源的产物)。保留只为了迁移时能删掉旧源。 local function accountSourceName(provider, accountId) return "channels-" .. provider .. "-" .. accountId end -- syncProviderSource(provider):把该通道的**唯一**上游源与模型目录对齐。 -- -- ★ 这是「一个通道 = 一个适配器 = 一个源」的落点。换号完全在插件内部完成, -- 内核只看到一个源(同名 upsert),客户端完全无感。 -- -- 幂等:可在任何“该用哪个号可能变了”的时刻重复调(账号增删改 / 余额刷新 / -- 领取完成 / 续期成功 / 30 分钟心跳巡检)。 -- pickAccountForSource(plat):从该平台的账号池里挑一个**现在能用**的号。 -- -- 判据顺序(每一级都是真实故障的产物): -- 1. 凭据读得出来(占位号 / 密文损坏的号直接跳过) -- 2. 余额可查且有余量(与 rankAccounts 同口径:先用到快作废的,再用永久的) -- • 没有 balance 声明的平台不据此排除 —— 不能因为"我们不会查"就把可用号判死 -- • 查询失败同理归入"未知",保留可用性 -- 3. 排除已打失效标记的号(request_end 钩子根据**真实请求失败**打的) -- -- 返回 (acct, reason)。reason 用于日志,让"为什么选它"可复查。 pickAccountForSource = function(plat) local accounts = poolList(plat.id) if #accounts == 0 then return nil, "无账号" end local withCred = {} for _, a in ipairs(accounts) do if a.enabled ~= false then local tok if plat.credential_shape == "full" then tok = __llmsproxy_secrets.get(a.credentialRef) else tok = credentialOf(a.credentialRef) end if tok ~= nil and tok ~= "" then withCred[#withCred + 1] = a end end end if #withCred == 0 then return nil, "无可用凭据(占位账号或密文不可读)" end local ranked = withCred if plat.balance then local allowPermanent = not (plugin.state.settings and plugin.state.settings.lock_permanent_credits) local expiring, permanent, unknown = {}, {}, {} for _, a in ipairs(withCred) do local raw = accountBalanceRaw(plat.id, a.id) if raw == nil then unknown[#unknown + 1] = a -- 查不到 ⇒ 不据此排除 else local tier = balanceTier(raw.expiring_balance or raw.expiringBalance, raw.permanent_balance or raw.permanentBalance, allowPermanent) if tier == "expiring" then expiring[#expiring + 1] = a elseif tier == "permanent" then permanent[#permanent + 1] = a else unknown[#unknown + 1] = a end end end ranked = {} for _, a in ipairs(expiring) do ranked[#ranked + 1] = a end for _, a in ipairs(permanent) do ranked[#ranked + 1] = a end for _, a in ipairs(unknown) do ranked[#ranked + 1] = a end if #ranked == 0 then return nil, "所有账号余额均不可用" end end for _, a in ipairs(ranked) do if not a.termErr then return a, "选中账号 " .. tostring(a.id) .. (a.nickname and a.nickname ~= "" and ("(" .. a.nickname .. ")") or "") end end return ranked[1], "所有账号均标记失效,回退到 " .. tostring(ranked[1].id) end local function syncProviderSource(provider) local plat = platformById(provider) if not plat then return end -- ★ 自动选号:凭据可用 + 余额分档 + 跳过已标失效的号。 -- 判据全部在插件内(内核不知道账号概念)。 local acct, why = pickAccountForSource(plat) if not acct then -- 没有可用账号 ⇒ **撤掉源**。留着旧源会让模型继续出现在列表里却每次都 -- 失败(用户无法区分"我没配"和"配了但坏了")。 local gone = providerSourceName(provider) __llmsproxy_kernel.call("remove_source", { name = gone }) if plugin.state.chosen then plugin.state.chosen[provider] = nil end logEvent("通道 " .. provider .. " 无可用账号,已撤源:" .. tostring(why), "warn", provider) 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 logEvent("通道 " .. provider .. " 凭据读取失败,跳过同步", "warn", provider) return end -- ★ 记录"当前为该通道服务的账号"。request_end 钩子靠它把真实请求的 -- 失败原因归因到具体账号(否则只能知道“这个通道坏了”,不知道是哪个号)。 plugin.state.chosen = plugin.state.chosen or {} plugin.state.chosen[provider] = acct.id local name = providerSourceName(provider) 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。 -- ⚠ 这里失败**可容忍**:AddSource 是**同名 upsert**,add 会把旧的覆盖掉。 -- 但这正是它掩盖 remove_source 权限缺失的原因(纯删除路径才暴露), -- 故失败仍记一笔(低频,不会刷屏)。 local rr, rerr = __llmsproxy_kernel.call("remove_source", { name = name }) if rr == nil or rr.ok ~= true then local l = plugin.state.log local last = type(l) == "table" and l[1] and l[1].msg or "" -- 只在**上一次不是同一条**时记,避免每次同步都刷一条 if not tostring(last):find("撤源(重建)失败", 1, true) then logEvent("撤源(重建)失败 " .. tostring(name) .. ": " .. tostring(rerr or "(无错误信息)"), "warn", provider) end end 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 -- ★ 把**设备身份**随源带给适配器:有些平台的头不是简单 Bearer, -- 需要 uid / device_id / machine_id 派生(trae 的 X-Uid / X-Device-Id / -- X-Machine-Id)。适配器沙箱里**读不到凭据**,只能靠 meta 传。 -- -- 真实缺陷(2026-10-17 用户报「无法使用 channel 注册的模型」): -- 此前 meta 为空 ⇒ 适配器没法构造 Cloud-IDE-JWT 头族 ⇒ 内核兜底发 -- `Authorization: Bearer ` ⇒ 上游 401 -- 「We're sorry, but we are not able to authenticate you」。 -- 这与「额度不可用」是**同一根因的两个出口**(额度端点修了、推理端点没修)。 if plat.source_meta_from_credential then local cmeta = credentialFull(acct.credentialRef) if type(cmeta) == "table" then src.meta = {} for _, mk in ipairs({ "uid", "device_id", "machine_id", "enterprise_id" }) do if cmeta[mk] ~= nil and cmeta[mk] ~= "" then src.meta[mk] = cmeta[mk] end end end end -- ★ 先清掉旧版的**按账号拆的源**(迁移)。不清的话升级后旧源还在, -- 模型列表里会有重复项,且旧源指向已经不该用的账号。 -- 按已知平台 id 前缀匹配(不用贪婪模式:账号 id 可能含 `-`)。 -- 只在真正要写源时才扫一遍(低频)。 do local ls = __llmsproxy_kernel.call("list_sources", {}) local arr = (ls and ls.json and ls.json.sources) or (ls and ls.sources) or {} if type(arr) ~= "table" then arr = {} end local legacyPrefix = "channels-" .. provider .. "-" for _, exist in ipairs(arr) do local sn = type(exist) == "table" and exist.name or nil if type(sn) == "string" and sn ~= name and sn:sub(1, #legacyPrefix) == legacyPrefix then __llmsproxy_kernel.call("remove_source", { name = sn }) logEvent("已清理旧版按账号拆源 " .. sn, "task", provider) end end end local r, aerr = __llmsproxy_kernel.call("add_source", { source = src }) -- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会 -- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。 if aerr then logEvent("接入源失败 " .. name .. ": " .. tostring(aerr), "error", provider) elseif r and r.json and r.json.ok == false then logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error), "error", provider) else logEvent("已同步源 " .. name .. "(" .. #models .. " 模型,账号 " .. tostring(acct.id) .. ")", "task", provider) end end -- syncAccountSource 保留为**兼容壳**:旧调用点很多(13 处),逐个改风险大。 -- 语义变为"这个平台的源该重算了"(**忽略** accountId)—— 因为现在一通道只有 -- 一个源,账号维度的差异完全由 syncProviderSource 内部消化。 local function syncAccountSource(provider, _accountId) syncProviderSource(provider) end -- dropAccountSource:把某账号的上游源撤掉(账号停用/删除时调用)。 -- -- ★ 必须**检查返回值**:内核的 remove_source 需要**单独授予**(它是 Destructive 级, -- 不在 add_source 的授权里)。生产权限集曾只有 add_source ⇒ 这个调用被**拒绝**, -- 而调用方**忽略返回值** ⇒ 静默失败的正确后果是:**账号已删/已停用,它的源仍在 -- 对外服务**(用户看到"删了却还在用")。 -- 更隐蔽的是:AddSource 是**同名 upsert**,所以 syncAccountSource 里"先 remove 再 add" -- 的 remove 失败**看不出来**(add 会把同名覆盖掉)—— 只有纯删除路径才暴露。 -- 故这里把失败**记进事件日志**,让它在面板上可见,而不是只存在于一个被丢弃的返回值里。 local function dropAccountSource(provider, accountId) -- ★ 语义变了(一通道一源之后):账号被删/停用**不能**直接把通道源撤掉 —— -- 那个源现在代表**整个通道**,池里可能还有别的号能用。正确做法是重新 -- 选号:syncProviderSource 会挑下一个可用账号并用同名覆盖,没有可用 -- 账号时它自己会撤源。 -- -- 迁移期要兼顾旧状态:如果库里还有旧版按账号拆的源(channels-p-), -- 那个才是该删的。两步都做。 local legacy = accountSourceName(provider, accountId) -- ★ 内核桥是 `call(name, args) -> (result, nil) | (nil, err)`: -- 失败时**第一个返回值是 nil**,原因在**第二个返回值**里。 -- 我第一版写成 `local r = call(...)` 再读 `r.ok` ⇒ 恒 nil ⇒ 日志只说"无返回", -- 把真正原因(not granted / 超时 / 拒删最后一个源)丢掉 —— 一句不可操作的 -- 诊断比没有更糟。两个返回值都要接。 local r, err = __llmsproxy_kernel.call("remove_source", { name = legacy }) if r == nil or r.ok ~= true then -- 旧源不存在是**正常**的(新部署根本没启过它),不当作失败。 local msg = tostring(err or "") if not msg:find("not found", 1, true) and not msg:find("不存在", 1, true) then logEvent("撤旧源失败 " .. tostring(legacy) .. ": " .. tostring(err or "(无错误信息)") .. " | 若为 not granted,需给 channels 插件授予 remove_source(声明在 plugin.kernel)", "error", provider) return false, err end end -- 重算通道源:池里还有别的号就自动接上,没了就撤掉整个通道源。 syncProviderSource(provider) return true 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 响应里出现一次,存侧不外泄。 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 四个值,让判据能逐条钉住分档规则 -- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。 -- ============================================================================ -- 账号有效性反馈:从真实请求结果里学习「哪个号该被跳过」 -- ============================================================================ -- -- 为什么需要这条链路 -- ------------------ -- 选号(pickAccountForSource)判的是**余额**,但余额查询有三个盲区: -- 1. 有些平台没有余额端点 ⇒ accountBalanceRaw 恒 nil -- 2. 余额查询有 TTL 缓存(60s)⇒ 用尽的瞬间不会立刻反映 -- 3. 余额 > 0 但账号被风控/封禁 ⇒ 余额看起来完好,实际全失败 -- -- 唯一能覆盖这三点的证据是**真实请求的失败原因**。所以挂 request_end: -- 当这个通道的请求失败、且错误文本是「终态」的(需充值 / 封禁 / 凭据失效)时, -- 给当前选中的账号打 termErr 标记,下一次选号跳开它。 -- -- ⚠️ 只认**终态**错误。普通 429 限流、超时、网络抖动都**不**标记 —— -- 那些会自动恢复,标记它们等于把好号一个个废掉(把一次请求层面的拒绝 -- 当终态,代价是把材料完好的账号一步标死)。 -- -- 解除标记的路径:请求成功 / 账号重新登录 / 手动 account_reset。 -- plugin.term_error_kind(msg):把错误文本分类为终态错误,返回原因或 nil。 function plugin.term_error_kind(msg) if type(msg) ~= "string" or msg == "" then return nil end local low = msg:lower() -- 额度类:明确说用尽且需要充值(不是"当日免费额度,明天恢复"那种) if low:find("insufficient", 1, true) or low:find("余额不足", 1, true) or low:find("请充值", 1, true) or low:find("insufficient_balance", 1, true) or low:find("额度已用完", 1, true) then return "余额不足(需充值)" end -- 封禁 / 风控 / 账号停用 if low:find("banned", 1, true) or low:find("封禁", 1, true) or low:find("账号已停用", 1, true) then return "账号被封禁/停用" end -- 凭据失效(refresh_token 已作废这类不可自愈的) if low:find("refresh token has been used", 1, true) or low:find("invalid_grant", 1, true) then return "凭据已永久失效(需重新登录)" end return nil end -- plugin.mark_account_term_error(provider, kind):给**当前为该通道服务**的号打标记。 function plugin.mark_account_term_error(provider, kind) local plat = platformById(provider) if not plat then return end local cur = plugin.state.chosen and plugin.state.chosen[provider] if not cur then return end local acct = poolGet(provider, cur) if not acct then return end if acct.termErr == kind then return end -- 已经标过,别刷日志 acct.termErr = kind acct.termErrAt = os.time and os.time() or 0 poolAdd(provider, acct) markDirty() logEvent("账号 " .. tostring(cur) .. " 标记失效:" .. tostring(kind) .. " —— 下次选号将跳过它", "warn", provider) end -- 插件钩子:request_end 在每次请求结束时触发。 -- ⚠️ 声明 hooks 不妨碍自有 HTTP 路由 —— 本插件的状态变更仍走自己的路由。 plugin.hooks = { request_end = "on_request_end" } function plugin.on_request_end(payload) if type(payload) ~= "table" then return nil end local src = tostring(payload.source or "") -- 只处理本插件管辖的通道(`channels-`),其他源不碰。 if src:sub(1, 9) ~= "channels-" then return nil end local provider = src:sub(10) local cur = plugin.state.chosen and plugin.state.chosen[provider] if not cur then return nil end local acct = poolGet(provider, cur) if not acct then return nil end if payload.ok then -- 成功 ⇒ 清掉失效标记(它显然能用)。 if acct.termErr then acct.termErr = nil acct.termErrAt = nil poolAdd(provider, acct) markDirty() logEvent("账号 " .. tostring(cur) .. " 恢复正常(请求成功)", "info", provider) end return nil end -- 失败 ⇒ 只有"终态"错误才标记。payload 里的错误文本字段名不定, -- 三个都看(内核不同路径填的字段不同)。 local msg = tostring(payload.error or payload.message or payload.error_message or "") local kind = plugin.term_error_kind(msg) if kind then plugin.mark_account_term_error(provider, kind) end -- ★ 模型级死亡记录(照参考 dead-model-store.ts):判据命中"下架"措辞时记一笔。 -- 此前 isModelGone/recordDeadModel 只有测试 probe 在调(基础设施丢失挂接点), -- 真实 404(如 codearts 的 GLM-5.3 "The model is not registered")永远不会 -- 进 deadModels,模型在 UI 里一直可选中、每轮 404。 if msg ~= "" then local modelId = tostring(payload.model or payload.model_id or "") if modelId ~= "" and isModelGone(msg) then local isnew = recordDeadModel(provider, modelId, msg) if isnew then logEvent("模型已失效,已标记 " .. provider .. "/" .. modelId, "warn", provider) markDirty() end end end return nil end 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 function plugin.__cred_headers_probe(args) args = args or {} local plat = platformById(args.platform) if not plat then return { json = { error = "未知平台" } } end local cred = {} local okc, dec = pcall(json.decode, tostring(args.credential or "")) if okc and type(dec) == "table" then cred = dec end local tok = cred.access_token or "" local out = {} for _, which in ipairs({ "checkin_status", "balance" }) do local spec = plat[which] if spec then local h = nil local hfn = spec.headers_fn or plat.headers_fn if hfn then local okf, got = pcall(hfn, tok, cred) if okf and type(got) == "table" then h = got end end if not h then h = { Authorization = "Bearer " .. tok, ["Content-Type"] = "application/json" } end out[which] = h end end return { json = out } end function plugin.__set_pool_cred_probe(args) args = args or {} local plat = platformById(args.platform) if not plat then return { json = { error = "未知平台" } } end local ref = "channels:" .. plat.id .. ":" .. tostring(args.account_id or "") local _, serr = __llmsproxy_secrets.set(ref, tostring(args.credential or "")) if serr then return { json = { error = serr } } end return { json = { ok = true, ref = ref } } end 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", -- 照正式平台同样的分工:base_url 只到路径前缀,路径放 endpoint -- (内核 URL = base_url + endpoint)。测试夹具也照此写, -- 免得将来有人把这行当范例复制进正式平台。 endpoint = "/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.__clear_priorities_probe:只给测试用 —— 把所有账号的 priority 拉平。 -- 用于构造"排序键完全相同"的场景(同优先级 + 同秒 createdAt), -- 检验排序是否有确定性兜底。 function plugin.__clear_priorities_probe(args) args = args or {} local plat = platformById(args.platform) if not plat then return { json = { error = "未知平台" } } end local n = 0 for _, a in ipairs(poolList(plat.id)) do a.priority = nil poolAdd(plat.id, a) n = n + 1 end return { json = { ok = true, n = n } } end -- plugin.__resync_sources_probe:只给测试用 —— 按 poolList 顺序批量重注册源。 -- 「排序是否有确定性兜底」必须在**批量重注册**路径上验:account_add 是直接同步 -- 自己那一个(源顺序 = add 顺序),走不到排序逻辑。 function plugin.__resync_sources_probe(args) args = args or {} local plat = platformById(args.platform) if not plat then return { json = { error = "未知平台" } } end local n = 0 for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) n = n + 1 end return { json = { ok = true, n = n } } end -- plugin.__utf8_clip_probe:只给测试用 —— 直接验证 utf8Clip 的语义 -- (截断后仍是合法 UTF-8、未超长时原样返回)。静态断言看不出这些性质。 function plugin.__utf8_clip_probe(args) args = args or {} return { json = { out = plugin.utf8Clip(args.s, tonumber(args.n) or 120) } } end -- plugin.__event_log_probe:只给测试/诊断用 —— 插件事件日志的文案列表。 -- logEvent 写进 plugin.state.log,但它没有对外的读出口(`status` 是 HTTP 路由、 -- 不在 exports 里),判据要断言"某件事被记录了"就得有它。 function plugin.__event_log_probe() local out = {} for _, e in ipairs(plugin.state.log or {}) do if type(e) == "table" and type(e.msg) == "string" then out[#out + 1] = e.msg end end return { json = { log = out } } end -- plugin.__list_sources_probe:只给测试/诊断用 —— 内核里当前登记的源**及其顺序**。 -- 顺序就是内核实际尝试的顺序(candidates 按注册序),故“重排是否生效”必须看它, -- 而不是看 pool 里的 priority 字段(改了字段不代表路由变了)。 function plugin.__list_sources_probe() local ls = __llmsproxy_kernel.call("list_sources", {}) -- ⚠ 内核 actionListSources 返回的是**裸数组**(不是 {sources:[...]})—— -- 我第一版按后者取,于是恒得空列表(判据因此报"应有 2 个源,实得 []")。 -- 两种形态都兜住,免得内核换个包装就失效。 local arr = ls and ls.json or ls if type(arr) ~= "table" then arr = {} end local out = {} for _, x in ipairs(arr) do if type(x) == "table" then out[#out + 1] = { name = x.name, adapter = x.adapter } end end return { json = { sources = out } } end -- plugin.__trae_entry_probe:只给测试/诊断用 —— 报告某模型最终选中条目的关键字段。 -- “合并规则选错了条目”(空档位覆盖有档位,issue IKI7WT/IKILR7)这类问题 -- 没有它只能靠猜。 function plugin.__trae_entry_probe(args) args = args or {} local id = tostring(args.model or "") local meta = plugin.state.modelMeta and plugin.state.modelMeta.trae local e = meta and meta[id] if not e then return { json = { error = "无该模型元数据" } } end local rc = e.reasoning local opts = type(rc) == "table" and (rc.options or rc.Options) or nil return { json = { id = id, chan = e.chan, usage = e.usage, enabled = e.enabled, hidden = e.hidden, custom = e.custom, has_reasoning = rc ~= nil, n_options = type(opts) == "table" and #opts or 0, support_thinking = type(rc) == "table" and (rc.support_thinking or rc.supportThinking) or nil, } } end -- plugin.__platform_def_probe:只给测试用 —— 把平台定义的**关键字段**暴露成值。 -- -- 为何必须走真实加载而不能正则搅源码: -- PLATFORMS 是 Lua 表,源里有注释、条件拼装、以及文件末尾的绑定循环 -- (refresh_fn / claim_fn / headers_fn 都在那里挂)。用正则在源文本上找 -- "有没有 refresh 段"会被注释里的字样骗 —— 我上一轮就差点这么错。 -- 本探针返回的是**插件运行时看到的真相**。 function plugin.__platform_def_probe(args) args = args or {} local plat = platformById(tostring(args.id or "")) if not plat then return { json = { error = "未知平台" } } end return { json = { id = plat.id, base_url = plat.base_url or "", endpoint = plat.endpoint or "", endpoint_stream = plat.endpoint_stream or "", adapter = plat.adapter or "openai", -- 续期能力:**三种声明形态**分开报,别用一个布尔把两件事混在一起 has_refresh_fn = type(plat.refresh_fn) == "function", has_refresh_decl = type(plat.refresh) == "table" and plat.refresh.url ~= nil, } } end -- plugin.__b64_probe:只给测试用 —— 暴露 b64urlDecode 的字节输出(hex)。 -- -- 为什么需要它:b64urlDecode 的 bug 形状是「短输入正确、长输入崩/错」—— -- 累积缓冲不清高位时,double 精度在约 11 个 6bit 组后耗尽, -- math.floor 会给出越界值(string.char 直接报 invalid value)。 -- 只用 43 字符的 JWK 字段测不出来,必须有一条**长输入**判据。 function plugin.__b64_probe(args) args = args or {} local bin = b64urlDecode(tostring(args.s or "")) local hx = {} for i = 1, #bin do hx[#hx + 1] = string.format("%02x", bin:byte(i)) end return { json = { len = #bin, hex = table.concat(hx) } } end -- plugin.__codearts_jwk_pem_probe:只给测试/诊断用 —— 验证 JWK→PEM 重建。 -- -- 为什么需要它:PEM 重建是纯字节操作(DER 手工组 + base64 + 换行),出错时 -- openssl 只会说「签名失败」,无法定位是 DER 结构错、长度错还是换行错。 -- 这个探针把中间产物(PEM 文本)暴露出来,并直接跑一次真实签名。 function plugin.__codearts_jwk_pem_probe(args) args = args or {} local jwk = args.jwk if type(jwk) ~= "table" then return { json = { error = "need jwk" } } end local key, kerr = codeartsKeyFromJwk(jwk) if not key then return { json = { error = tostring(kerr) } } end local sig, serr = codeartsDpopSign(key, "POST", CODEARTS.sts_token) return { json = { pem = key.pem, signed = sig ~= nil, sign_error = serr and tostring(serr) or nil, } } end -- plugin.__zcode_diag_probe:只给测试/诊断用 —— 暴露共用的 3012 判据模块。 -- -- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在 -- channels-zcode 适配器)。判据必须能直接打**那一份**实现,否则会退化成 -- “各自测各自的副本”,而共用一旦漂移就再也测不出来。 function plugin.__zcode_diag_probe(args) args = args or {} local fn = tostring(args.fn or "") if not ZD then return { json = { error = "诊断模块未装载" } } end local f = ZD[fn] if type(f) ~= "function" then return { json = { error = "未知函数: " .. fn } } end if fn == "formatGap" then return { json = { result = f(tonumber(args.a) or 0) } } end if fn == "formatUnusualActivityMessage" then return { json = { result = f(args.opts or {}) } } end if fn == "formatDiagnostic" then return { json = { result = f(args.opts or {}) } } end if fn == "describeRequestShape" then return { json = { result = f(args.system, args.messages) } } end if fn == "accountOrdinal" then return { json = { result = f(args.a) } } end if fn == "noteRequestSent" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end if fn == "noteRequestOk" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end if fn == "noteRequestFailed" then f(args.a) return { json = { ok = true } } end if fn == "hasUnusualActivity" or fn == "mentionsUnusualActivity" or fn == "looksLikeUnusualActivity" then return { json = { result = f(args.a) } } end return { json = { error = "探针未暴露该函数: " .. fn } } end -- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的 -- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。 function plugin.__adapter_install_probe(args) args = args or {} -- 带 adapters_dir 时**重新安装** zcode:用来验证「基座不可得 ⇒ 安装期报错」。 -- 之所以要这个缝隙:基座现在由**插件主体内联**(适配器沙箱无 fs), -- 安装失败若不在此暴露,就只会在运行期逐帧炸,且错误指向内核而非配置。 if args.adapters_dir then local ok, err = installZcodeAdapter(args.adapters_dir) return { json = { zcode_reinstall = { ok = ok and true or false, err = err } } } end 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 -- 一通道一源之后,同步的目标是**通道级**源名;account_id 只用于选号。 local want = providerSourceName(args.platform) 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