From 8a4153d18a914493b914a7ede581901deb565dc3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 16 Oct 2026 21:49:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor(channels):=20=E7=BA=AF=E5=B8=B8?= =?UTF-8?q?=E9=87=8F=E6=94=B6=E8=BF=9B=20plugin.K=EF=BC=8C=E8=85=BE?= =?UTF-8?q?=E5=87=BA=E9=A1=B6=E5=B1=82=20local=20=E4=BD=99=E9=87=8F?= =?UTF-8?q?=EF=BC=88=E6=AD=A4=E5=89=8D=E4=BD=99=E9=87=8F=E4=B8=BA=200?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题(实测) `channels.lua` 的主 chunk **顶到 Lua 上限 200 个 local** —— 往文件顶部加**哪怕 1 个**顶层 local 就报: `main function has more than 200 local variables` 实测(LuaJIT 与 luac 5.4 一致,**响亮失败**,不是静默降级)。 后果不是"某个功能出错",而是**整个渠道插件编译失败 ⇒ 全渠道不可用**。 换句话说:这个文件此前**一个顶层 local 都加不了**,任何后续改动都会立刻炸。 ## 修法 把 20 个**纯常量**(字符串/数字/自闭合字面量)从裸 `local` 收进 `plugin.K` 表 —— **挂表字段不占 local 槽位**。只搬常量,**函数一律保留 local**: 函数之间存在真实的调用顺序依赖(前向引用),搬迁风险与收益不成比例。 搬迁是纯机械的,且脚本带**长字符串保护**:替换时逐行跳过 `[===[ ... ]===]` 区间,绝不改动适配器源码文本 (本文件里那 7 段长字符串是适配器代码,误改会直接弄坏适配器)。 ### 实测结果 | 指标 | 前 | 后 | |---|---|---| | 加 1 个顶层 local | ✗ 编译失败 | ✓ 通过 | | 加 10 / 20 个 | ✗ | ✓ 通过 | | 字节数 | 589195 | 590042 | ## 判据(2 条 + 变异 2/2) - `TestChannelsLuaHasLocalHeadroom`:注入 1 与 10 个顶层 local 后**仍能编译**。 为什么用 **luajit** 而不是 `luac`:内核链接 `libluajit-5.1.so.2`, 而 luac(Lua 5.4)对同一文件给出**不同结论**,用错工具会得到假绿。 为什么不"数一遍 local":主 chunk 的 local 数受作用域嵌套影响, 静态计数不可靠(同一文件被不同脚本数出 228/232/248 三个数); 而"能不能再加"是**决定性**的,也正是我们需要的性质。 - `TestChannelsConstantsLiveInPluginK`(配对):抽样断言这些常量确实在 `plugin.K` 内、且**没有**变回裸 local —— 否则余量会再次归零。 - 变异:用重构前的文件 → 报红;把常量改回裸 local → 报红。 ## ★ 顺带更正两处我先前的错误说法(本轮实测推翻) 1. **不是静默失败**。我先前说"超出的 local 静默退化为全局" —— 错。 LuaJIT 与 luac 都在第 201 个报**编译错误**。危险度远低于我所说。 2. **`adaptersDir` 为 nil 的真实原因不是"local 被顶掉"** —— 它定义在 **`ZCODE_ADAPTER_SRC` 长字符串内部**(适配器源码文本), 在**适配器沙箱**里执行,而那里没有 `__llmsproxy_plugin_dir`、没有文件系统。 所以 zcode 的 `loadAnthropic` 必然返回 nil、BASE=nil (它自写了全部转换所以功能没问题,但"复用 anthropic、协议不漂移"的 设计意图从未生效)。 ⇒ **教训**:读 10000+ 行的 Lua 文件时,先扫出所有长字符串区间; 落在里面的"函数定义"只是文本,不属于插件作用域。 --- internal/core/channels_lua_locals_test.go | 73 ++++++++++++++ internal/lua/plugins/channels.lua | 112 ++++++++++++---------- 2 files changed, 134 insertions(+), 51 deletions(-) create mode 100644 internal/core/channels_lua_locals_test.go diff --git a/internal/core/channels_lua_locals_test.go b/internal/core/channels_lua_locals_test.go new file mode 100644 index 0000000..1e1e0ff --- /dev/null +++ b/internal/core/channels_lua_locals_test.go @@ -0,0 +1,73 @@ +package core + +import ( + "os/exec" + "regexp" + "strings" + "testing" +) + +// ★ channels.lua 必须留出顶层 local 余量 —— 否则**下一次顶层改动就编译失败**。 +// +// 实测事实(2026-10-16):该文件的主 chunk 曾**顶到 Lua 上限 200**, +// 往顶部加**哪怕 1 个**顶层 local 就报 +// +// main function has more than 200 local variables +// +// 而这炸的不是某个功能 —— 是**整个渠道插件**(插件加载失败 ⇒ 全渠道不可用)。 +// +// ⚠️ 判据为什么用 luajit 而不是 luac: +// +// 内核链接的是 libluajit-5.1.so.2;而 `luac`(Lua 5.4)对同一文件给出 +// **不同的**结论。用错工具会得到假绿。 +// +// ⚠️ 判据为什么是「注入 N 个 local 仍能编译」而不是「数一遍 local」: +// +// 主 chunk 的 local 数受**作用域嵌套**影响,静态计数不可靠(同一个文件 +// 用不同脚本数出 228/232/248 三个数)。而"能不能再加"是**决定性**的, +// 也正是我们真正需要的性质。 +func TestChannelsLuaHasLocalHeadroom(t *testing.T) { + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if _, err := exec.LookPath("luajit"); err != nil { + t.Skip("luajit 不在 PATH(判据需要与内核相同的 Lua 实现)") + } + + const headroom = 10 + for _, n := range []int{1, headroom} { + probe := strings.Repeat("local __headroom = 1\n", n) + src + cmd := exec.Command("luajit", "-e", "local f,e=loadfile(arg[1]); if not f then io.stderr:write(tostring(e)) os.exit(1) end", "/dev/stdin") + cmd.Stdin = strings.NewReader(probe) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("★ 顶层 local 余量不足:加 %d 个 local 就无法编译 ⇒ "+ + "下一次顶层改动会**整插件加载失败**(全渠道不可用)\n %s\n"+ + "修法:把纯常量收进 plugin.K 表(挂表字段不占 local 槽位)", n, strings.TrimSpace(string(out))) + } + } + t.Logf("✓ channels.lua 仍有 ≥%d 个顶层 local 余量", headroom) +} + +// 配对判据:常量必须确实在 plugin.K 里(防止有人把余量"省"出来的同时又 +// 把常量改回裸 local —— 那会让下一个改动的余量再次归零)。 +func TestChannelsConstantsLiveInPluginK(t *testing.T) { + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + s := string(src) + if !strings.Contains(s, "plugin.K = {") { + t.Fatal("必须有 plugin.K 常量表(它是 local 余量的来源)") + } + // 抽样:这些曾占用 local 槽位的纯常量必须已挂表 + for _, n := range []string{"STORE_NAME", "SESSION_TTL", "B64_ALPHABET", "MODELS_DEV_URL"} { + if !regexp.MustCompile(`(?m)^\s+` + n + ` = `).MatchString(s) { + t.Errorf("%s 应定义在 plugin.K 表内", n) + } + if regexp.MustCompile(`(?m)^local ` + n + ` = `).MatchString(s) { + t.Errorf("%s 又变回裸 local 了(会吃掉 local 余量)", n) + } + } + t.Log("✓ 纯常量已集中在 plugin.K") +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 7a01302..6b3805f 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -47,6 +47,36 @@ local plugin = { 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", +} + -- 申请的内核权限。设置页会把"申请中但未授权"标红。 -- 声明本插件要用的内核动作(运维据此授予;declared 与 granted 成对展示)。 -- @@ -1139,7 +1169,6 @@ local PLATFORMS = { } -- 华为云 snap-access 引擎域名(连接与签名都用它)。 -local CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com" -- CodeArts(华为云 snap-access)的请求签名与每日领取。 -- @@ -1218,7 +1247,7 @@ end -- codeartsAccountInfo(cred):账号信息(statistics/plugin),判断是否积分账户。 local function codeartsAccountInfo(cred) - local req, err = codeartsSignedRequest("GET", CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil) + 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 @@ -1264,7 +1293,7 @@ local function codeartsClaim(plat, tok, args) -- ② 活动列表 local areq = codeartsSignedRequest("GET", - CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil) + 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 @@ -1299,7 +1328,7 @@ local function codeartsClaim(plat, tok, args) -- ④ claim local body = json.encode({ campaignId = campaignId, channel = "IDE" }) - local creq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/claim", cred, body) + 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 @@ -1313,7 +1342,7 @@ local function codeartsClaim(plat, tok, args) local benefit = cok and type(crec) == "table" and (((crec.data or {}).benefit) or nil) or nil if type(benefit) == "table" and benefit.id then local fbody = json.encode({ id = benefit.id, channel = "IDE" }) - local freq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody) + local freq = codeartsSignedRequest("POST", plugin.K.CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody) if freq then local fresp, ferr = __llmsproxy_http(freq) -- 只在真正失败时降级提示,不改变整体成败(积分已在待确认态) @@ -1360,7 +1389,7 @@ end -- OAUTH_THEME = 2(Dark) OAUTH_LOCALE = zh-cn -- code_challenge_method = **SHA-256**(不是 S256!portal 以此识别 OAuth, -- 错值会回退到旧 ticket 流程) --- 旧流程回退(ticket):CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket +-- 旧流程回退(ticket):plugin.K.CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket -- ⚠️ 常量收进**表**(`plugin.consts`)而不是顶层 `local`。 -- 原因很实际:本文件顶层 local 已**顶到 Lua 单函数 200 个上限**,多一个就让 -- 整个插件**加载失败**(`too many local variables`);报错在编译期, @@ -1591,8 +1620,8 @@ local function codeartsTicketPoll(ticketId, secret, pluginName, pluginVersion) local resp = __llmsproxy_http({ url = url, method = "GET", headers = { ["Content-Type"] = "application/json;charset=UTF-8", - ["plugin-name"] = pluginName or CODEARTS_TICKET_PLUGIN_NAME, - ["plugin-version"] = pluginVersion or CODEARTS_TICKET_PLUGIN_VERSION }, + ["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 "") @@ -1621,12 +1650,8 @@ local function codeartsTicketPoll(ticketId, secret, pluginName, pluginVersion) end -- 旧 ticket 流程(portal 判定新式不可用时回退) -local CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect" -local HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html" local CODEARTS_CREDENTIAL_ENDPOINT = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/login/ticket" -local CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains" -local CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3" -- 生成 PKCE 配对(照参考 generatePkcePair:verifier = 48 随机字节的 base64url, -- challenge = sha256(verifier) 的 base64url)。 @@ -1665,8 +1690,6 @@ end QODER_SASH_BASE = "https://openapi.qoder.sh" -- UTC+8 偏移(毫秒)。活动按 UTC+8 结算,口径与参考一致。 -local QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000 -local QODER_REFRESH_HOUR_UTC8 = 10 -- hasQoderCampaignRefreshedToday():今天的活动列表是否已刷新。 local function qoderCampaignRefreshed(nowMs) @@ -1676,8 +1699,8 @@ local function qoderCampaignRefreshed(nowMs) -- **必须用算术平移**判 UTC+8,不能用 os.date("*t")(本机时区): -- 活动按 UTC+8 结算,取本机时区在出差/改时区时会判错方向 -- (偏东提前把当天记为已处理、真漏领;偏西一天判两次)。 - local utc8 = os.date("!*t", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000)) - return (utc8.hour or 0) >= QODER_REFRESH_HOUR_UTC8 + 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 @@ -1895,7 +1918,6 @@ end -- 4. 两者都拿不到时**照常发请求**(不带这两个头)—— 保守降级, -- 纯插件登录、未装桌面端的用户没有该文件,不能让整个积分功能报错。 -- 5. 缓存解析结果:拿不到时也记 null,不反复读盘/反复 spawn。 -local QODER_MACHINE_ENVIRONMENT = "3" local function qoderMachineCache() plugin.state.qoderMachine = plugin.state.qoderMachine or { done = false } @@ -1972,7 +1994,7 @@ local function qoderReadFromRuntimeInfo() f:write('{"account":""}') f:close() local cmd = ("timeout 20 %s %s %s < %s 2>/dev/null"):format( - exe, QODER_MACHINE_ENVIRONMENT, "--account-stdin", tmp) + 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 @@ -2566,7 +2588,6 @@ end -- 即便这个文件泄露,也不含任何 token。 -- -- 写入:tmp + rename 原子替换。读:缺失/损坏 → 用内置默认值(不阻塞启动)。 -local STORE_NAME = ".channels-store.json" -- storeDir 求插件目录:优先 config.yaml 的 plugin_dir,否则用本文件所在目录。 -- 插件与内核同机、有完整 OS 权限,读自己的配置是能力范围内的事。 @@ -2625,7 +2646,7 @@ local PERSIST_KEYS = { local function persistState() local dir = storeDir() - local path = dir .. "/" .. STORE_NAME + 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 @@ -2680,7 +2701,7 @@ end -- 迁移后删除遗留文件:留着它只会让「哪个文件权威」变成永久的疑团。 local function loadOwnSnapshot() local dir = storeDir() - local f = io.open(dir .. "/" .. STORE_NAME, "r") + local f = io.open(dir .. "/" .. plugin.K.STORE_NAME, "r") if f then local txt = f:read("*a") f:close() @@ -2716,7 +2737,7 @@ local function migrateLegacyKernelState() local written, werr = persistState() if written then os.remove(legacy) - io.stderr:write("[channels] 已迁移遗留状态到 " .. STORE_NAME .. "\n") + io.stderr:write("[channels] 已迁移遗留状态到 " .. plugin.K.STORE_NAME .. "\n") else io.stderr:write("[channels] 遗留状态迁移写入失败(保留原文件): " .. tostring(werr) .. "\n") @@ -2946,16 +2967,14 @@ end -- 为读一个 JWT 声明而给内核加一个通用解码函数,是把插件的事推给内核 —— -- 插件能做就该自己做。纯 Lua 实现约 20 行,无依赖、无内核改动。 -- 接受标准/URL-safe 两种字母表,容忍缺失填充。 -local B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" -local B64_LOOKUP = {} -for i = 1, #B64_ALPHABET do B64_LOOKUP[B64_ALPHABET:sub(i, i)] = i - 1 end +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 = B64_LOOKUP[s:sub(i, i)] + local v = plugin.K.B64_LOOKUP[s:sub(i, i)] if v == nil then return nil end -- 非法字符:如实失败,不猜 acc = acc * 64 + v bits = bits + 6 @@ -3948,7 +3967,6 @@ end -- 登录会话放 plugin.state.sessions:内核没有"登录"概念,这正是插件该自己干的 -- 事。表结构 { id, platform, flow, url, user_code, nonce, state, account, -- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。 -local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理 newToken = function() -- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex) @@ -4967,7 +4985,7 @@ local function initLoginSession(plat, args, now) nonce = newToken():sub(1, 32), state = "pending", created_at = now, - expires_at = now + SESSION_TTL, + expires_at = now + plugin.K.SESSION_TTL, } if plat.id == "lobsterai" then local seed = newToken() @@ -6077,8 +6095,6 @@ plugin.schedule = { -- ⚠️ 我们额外的一条:本插件是**服务端**进程、没有"渲染路径", -- 但 5MB 的下载仍不能放在请求路径上 —— 故挂在 refresh_models 定时任务里, -- 与模型目录刷新同一节拍。请求路径只读缓存。 -local MODELS_DEV_URL = "https://models.dev/api.json" -local MODELS_DEV_TTL = 6 * 3600 -- 6 小时(照参考的 TTL 量级) -- -- ⚠️ **已知取不到,属设计约束而非故障**:该端点实测 **5.64 MB**,而内核的 -- `__llmsproxy_http` 有 **4 MiB 响应上限**(`httpMaxResponse`,刻意的堆保护 —— @@ -6141,11 +6157,11 @@ end local function refreshCapabilities() local cached = plugin.state.capabilitiesMeta local now = os.time and os.time() or 0 - if cached and cached.at and cached.at <= now and (now - cached.at) < MODELS_DEV_TTL then + if cached and cached.at and cached.at <= now and (now - cached.at) < plugin.K.MODELS_DEV_TTL then return false, "未到刷新时间" end local resp, err = __llmsproxy_http({ - url = MODELS_DEV_URL, method = "GET", timeout_ms = 30000, + url = plugin.K.MODELS_DEV_URL, method = "GET", timeout_ms = 30000, headers = { ["Accept"] = "application/json" }, }) if err or not resp or resp.status ~= 200 then @@ -6296,7 +6312,6 @@ end -- → 17 > 15 ⇒ 全算永久 ⇒ 锁定后立刻「无可用账号」 -- 中国版按 30 天发包,故"距到期"天然落在 17~30 天 —— 判据本身没错,这类池 -- 想用锁定就得把窗口放宽(如设 31)。窗口**只能由用户显式改**,默认 15。 -local EXPIRING_WINDOW_DAYS_DEFAULT = 15 expiringWindowDays = function() -- 优先级:settings(面板可改)→ env(部署可改)→ 默认 15。 @@ -6316,7 +6331,7 @@ expiringWindowDays = function() local n = tonumber(raw) if n and n >= 0 then return n end end - return EXPIRING_WINDOW_DAYS_DEFAULT + return plugin.K.EXPIRING_WINDOW_DAYS_DEFAULT end -- balanceTier(expiring, permanent, allow_permanent): @@ -6344,7 +6359,6 @@ end -- 开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就 -- 越过了线。若把分类也缓存,选号会继续按"永久"处理一笔马上要作废的积分 -- (锁定时更糟:本该可用的号被判成不可用)。故命中缓存也**每次重新分桶**。 -local BALANCE_CACHE_TTL = 60 -- (余额缓存改用通用 TTL 缓存后,这个助手不再需要) @@ -6360,7 +6374,7 @@ local function accountBalanceRaw(provider, accountId) if not tok or tok == "" then return nil end local plate = plat local act = acct - return ttlCacheGet("balance", provider .. ":" .. accountId, BALANCE_CACHE_TTL, function() + return ttlCacheGet("balance", provider .. ":" .. accountId, plugin.K.BALANCE_CACHE_TTL, function() -- 复用既有的余额抓取(credits_balances 用的同一个助手,避免两处口径漂移) local rec, err = fetchAccountJSON(plate.balance, tok, credentialFull(act.credentialRef), plate) @@ -6500,7 +6514,6 @@ local function isModelGone(message) end -- 失效记录 TTL(秒)。照参考默认 30 天;过期自动复活,避免永久隐藏。 -local DEAD_MODEL_TTL_DAYS = 30 local function deadModelsOf(provider) plugin.state.deadModels = plugin.state.deadModels or {} @@ -6523,7 +6536,7 @@ end deadModelIds = function(provider) local p = deadModelsOf(provider) local now = os.time and os.time() or 0 - local ttl = DEAD_MODEL_TTL_DAYS * 86400 + local ttl = plugin.K.DEAD_MODEL_TTL_DAYS * 86400 local out = {} for id, meta in pairs(p) do local at = type(meta) == "table" and tonumber(meta.at) or 0 @@ -6567,7 +6580,6 @@ end -- -- ⚠️ 本 Lua 无协程(单 VM 单锁),做不了"共享同一个 Promise"。用**时间戳 -- 闸门**达到等价效果:记录 in_flight_at(防同轮重复)+ retry_at(失败冷却)。 -local REMOTE_CATALOG_COOLDOWN = 30 -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS local function catalogGateOf(provider) plugin.state.catalogGate = plugin.state.catalogGate or {} @@ -6585,7 +6597,7 @@ local function catalogGateCooling(provider, now) local g = catalogGateOf(provider) if not g.retry_at then return false end if now >= g.retry_at then return false end - if g.retry_at - now > REMOTE_CATALOG_COOLDOWN then return false end + if g.retry_at - now > plugin.K.REMOTE_CATALOG_COOLDOWN then return false end return true end @@ -6603,7 +6615,7 @@ end local function catalogGateEnd(provider, got, now) local g = catalogGateOf(provider) g.in_flight_at = nil - g.retry_at = got and nil or (now + REMOTE_CATALOG_COOLDOWN) + g.retry_at = got and nil or (now + plugin.K.REMOTE_CATALOG_COOLDOWN) end -- refresh_models():定时把远端模型目录拉回来并重同步所有源。 @@ -6725,9 +6737,9 @@ end -- 出差/改时区会把日界判错方向。 local function todayKey() local nowMs = (os.time and os.time() or 0) * 1000 - local utc8 = os.date("!*d", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000)) - local utc8m = os.date("!*m", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000)) - local utc8y = os.date("!*Y", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000)) + local utc8 = os.date("!*d", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000)) + local utc8m = os.date("!*m", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000)) + local utc8y = os.date("!*Y", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000)) return tostring(utc8y) .. "-" .. tostring(utc8m) .. "-" .. tostring(utc8) end @@ -8260,7 +8272,6 @@ end -- 语义:整表替换(与 set_endpoints 的按字段覆盖不同)—— 注入是声明式的 -- “这就是本轮我要用的目录”,避免测试之间残留状态。传空数组即清空。 -- 容量上限:注入属于运维/测试能力,不承担生产目录职责,限 32 条够用。 -local INJECT_PLATFORM_LIMIT = 32 -- ============================================================================ -- 服务端浏览器栈的运维面(依赖预检 / 取凭据 / 停栈) -- ============================================================================ @@ -8420,8 +8431,8 @@ function plugin.inject_platforms(req) if type(list) ~= "table" then return { status = 400, json = { error = "platforms 必须是数组" } } end - if #list > INJECT_PLATFORM_LIMIT then - return { status = 400, json = { error = "最多注入 " .. INJECT_PLATFORM_LIMIT .. " 个平台" } } + if #list > plugin.K.INJECT_PLATFORM_LIMIT then + return { status = 400, json = { error = "最多注入 " .. plugin.K.INJECT_PLATFORM_LIMIT .. " 个平台" } } end -- 校验:每条必须有 id,且不得与内建目录重名(注入不能劫持真实厂商)。 local builtin = {} @@ -9256,7 +9267,6 @@ end -- isAutoCheckinExcluded):代价发生在调用期间(拉 headful Chromium + -- 阿里云按设备限流 150 次/小时),"等它报错再判定"来不及。 -- * 用户**手动**点领取时带上 captcha param 即可走通(面板交互)。 -local ZCODE_ORIGIN = "https://zcode.z.ai" -- zcodeHeaders:照参考 buildZcodeHeaders。X-Device-Mid 是**硬需求** -- (缺它 billing 全家桶回 400)。 @@ -9280,7 +9290,7 @@ end -- zcodeClaimablePlans(cred):查可领 plan(preview)。 local function zcodeClaimablePlans(cred) local av = tostring(cred.app_version or "1.0.0") - local url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview" + local url = plugin.K.ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview" .. "?app_version=" .. rfc3986Escape(av) .. "&platform=win32" local resp, err = __llmsproxy_http({ url = url, method = "GET", @@ -9310,7 +9320,7 @@ function claimZcode(plat, tok, args) -- ① 补激活上报(照参考:不需要 Authorization) __llmsproxy_http({ - url = ZCODE_ORIGIN .. "/api/v1/event/report", method = "POST", + url = plugin.K.ZCODE_ORIGIN .. "/api/v1/event/report", method = "POST", headers = { ["Content-Type"] = "application/json" }, body = json.encode({ event = "activation", app_version = tostring(cred.app_version or "1.0.0") }), }) @@ -9339,7 +9349,7 @@ function claimZcode(plat, tok, args) local granted, failedMsg = 0, nil for _, planId in ipairs(plans) do local resp, err = __llmsproxy_http({ - url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST", + url = plugin.K.ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST", headers = zcodeHeaders(cred, { authorization = "Bearer " .. tostring(jwt), captcha = { param = captcha.param, region = captcha.region or "cn" },