From de7553eaa29da516db9c142139c6b1eb14ed7217 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 26 Oct 2026 22:27:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(lua)!:=20=E6=8F=92=E4=BB=B6=E5=8F=AA?= =?UTF-8?q?=E9=80=9A=E8=BF=87=20modelrouter=20SDK=20=E4=B8=8E=E5=86=85?= =?UTF-8?q?=E6=A0=B8=E4=BA=A4=E4=BA=92=EF=BC=8C=E4=B8=94=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=9A=8F=E5=86=85=E6=A0=B8=E5=AE=89=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增唯一公开接口表 `modelrouter`(sdk_version=1.0):http / secrets / kernel / adapters / plugin_dir。成员与内部桥是**同一对象**而非副本 —— 复制会让凭据保险库分叉(插件写 A、内核读 B)。 - `llmsproxy` 与 `__llmsproxy_*` 降为同对象兼容别名,只为已装插件继续可用。 仓库内 4 个插件(channels / billing / quota / usage)全部改用 `modelrouter.*`:此前它们直接依赖内核私有全局,内核无法在不炸插件的前提下 改实现(重命名、加守卫、换实现都算破坏性变更)。 - 插件不再由网关安装:`SeedBundled()` 变彻底 no-op,`core.New` 不再调用; `ReadBundledPlugin` 改为按源文件定位(脱离 cwd),只服务测试与开发工具。 - 桌面端删除 `BUILTIN_PLUGINS` 与 `syncBundledPluginsOnUpgrade`:升级不再 删除、改名或覆盖用户插件文件。插件是运维资产,不是内核资源。 - `Builtin` 不再靠"源码与仓库示例逐字节相同"推断,插件一律视为外部文件。 - 判据:SDK 存在/版本/形状、别名与内部桥同一张表、适配器 VM 拿不到 SDK、 经 SDK 真实写读凭据并驱动内核动作、GUI 升级不动插件文件、release 包不含插件。 说明:channels.lua / quota.lua 同时带有此前的渠道与额度改动(含 OpenCode 免费池 监控),与 SDK 改名同处一个文件,无法文件级拆分,故一并纳入。 --- cmd/gui/plugin-sync.js | 63 +--- cmd/gui/plugin-sync.test.js | 50 +--- docs/plugins.md | 55 +++- internal/core/core.go | 11 +- internal/lua/billing_test.go | 30 +- internal/lua/channels_gemini_adapter_test.go | 2 +- internal/lua/channels_trae_solo_test.go | 2 +- internal/lua/plugin_bridges.go | 79 ++++- internal/lua/plugin_runtime_test.go | 15 +- internal/lua/plugin_sdk_test.go | 133 +++++++++ internal/lua/plugins.go | 70 ++--- internal/lua/plugins/billing.lua | 5 +- internal/lua/plugins/channels.lua | 296 ++++++++++--------- internal/lua/plugins/quota.lua | 136 ++++++++- internal/lua/plugins/usage.lua | 2 +- internal/lua/quota_backoff_test.go | 6 +- internal/lua/store_dir_isolation_test.go | 18 +- internal/lua/vm.go | 67 +---- internal/lua/vm_test.go | 78 ++--- 19 files changed, 653 insertions(+), 465 deletions(-) create mode 100644 internal/lua/plugin_sdk_test.go diff --git a/cmd/gui/plugin-sync.js b/cmd/gui/plugin-sync.js index c9b2318..c7b2ac9 100644 --- a/cmd/gui/plugin-sync.js +++ b/cmd/gui/plugin-sync.js @@ -1,18 +1,11 @@ -// Plugin-tree maintenance for the embedded core (no Electron imports, so this +// Plugin-tree maintenance for the desktop profile (no Electron imports, so this // is unit-testable with plain node). // -// Two problems this solves, both invisible from the gateway side: -// 1. The core deliberately does NOT default `plugin_dir` (an operator opts -// in). A desktop profile without it runs with ZERO plugins — the channels -// plugin (account login, credential vault, auto check-in) never appears. -// 2. The core seeds bundled plugins only when the file is ABSENT, so a -// desktop upgrade would keep the user's old channels.lua forever and -// silently skip every channel fix. On version change the shell must drop -// the built-in files so the new binary reseeds them. +// The desktop may configure an operator-selected plugin directory, but it never +// owns the files in that directory. In particular, upgrades must not seed, +// replace, rename, or delete plugins. Plugin installation is an explicit +// operator action (or an external package manager action). const fs = require("fs"); -const path = require("path"); - -const BUILTIN_PLUGINS = ["billing.lua", "channels.lua", "quota.lua", "usage.lua"]; // ensurePluginDirConfigured back-fills `plugin_dir` into an existing config. // Appending a top-level YAML key is safe for the shapes this file can have @@ -25,7 +18,7 @@ function ensurePluginDirConfigured(configFile, pluginsDir, log) { fs.writeFileSync( configFile, cur.replace(/\s*$/, "") + - "\n# added by ModelRouter Desktop: enables the plugin tree (channels etc.)\n" + + "\n# added by ModelRouter Desktop: enables the operator plugin directory\n" + "plugin_dir: " + JSON.stringify(pluginsDir) + "\n", "utf-8", ); @@ -37,46 +30,4 @@ function ensurePluginDirConfigured(configFile, pluginsDir, log) { } } -// syncBundledPluginsOnUpgrade drops built-in plugin files when the app version -// changed, so the core reseeds them from the binary it shipped with. Returns -// { changed, dropped:[...], kept: "prev-" }. -function syncBundledPluginsOnUpgrade(profileDir, pluginsDir, version, log) { - const stamp = path.join(profileDir, ".plugin-seed-version"); - let have = ""; - try { - have = fs.readFileSync(stamp, "utf-8").trim(); - } catch (e) { - /* first run */ - } - if (have === version) return { changed: false, dropped: [] }; - const dropped = []; - for (const name of BUILTIN_PLUGINS) { - const f = path.join(pluginsDir, name); - if (!fs.existsSync(f)) continue; - try { - if (have) fs.renameSync(f, f + ".prev-" + have); - else fs.unlinkSync(f); - dropped.push(name); - } catch (e) { - if (log) log("[gui] drop builtin plugin " + name + " failed: " + e.message); - } - } - try { - fs.writeFileSync(stamp, version + "\n", "utf-8"); - } catch (e) { - if (log) log("[gui] write plugin seed stamp failed: " + e.message); - } - if (dropped.length && log) { - log( - "[gui] plugins refreshed for " + version + " (" + dropped.length + - " builtin file(s) reseeded by core)", - ); - } - return { changed: true, dropped, kept: have ? "prev-" + have : "" }; -} - -module.exports = { - BUILTIN_PLUGINS, - ensurePluginDirConfigured, - syncBundledPluginsOnUpgrade, -}; +module.exports = { ensurePluginDirConfigured }; diff --git a/cmd/gui/plugin-sync.test.js b/cmd/gui/plugin-sync.test.js index e3d3c86..dfd0f04 100644 --- a/cmd/gui/plugin-sync.test.js +++ b/cmd/gui/plugin-sync.test.js @@ -1,26 +1,22 @@ -// Unit tests for the desktop plugin-tree maintenance (plain node, no Electron). -// Run: node --test cmd/gui/plugin-sync.test.js +// Unit tests for desktop plugin-directory configuration (plain node). const test = require("node:test"); const assert = require("node:assert"); const fs = require("fs"); const os = require("os"); const path = require("path"); -const { BUILTIN_PLUGINS, ensurePluginDirConfigured, syncBundledPluginsOnUpgrade } = require("./plugin-sync"); +const { ensurePluginDirConfigured } = require("./plugin-sync"); const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "plugin-sync-")); test("back-fills plugin_dir into an existing profile without it", () => { const dir = tmp(); const cfg = path.join(dir, "config.yaml"); - fs.writeFileSync(cfg, 'listen: 127.0.0.1:8787\ndefault_model: AUTO\nkeys:\n - key: k\n role: admin\n'); + fs.writeFileSync(cfg, "listen: 127.0.0.1:8787\ndefault_model: AUTO\nkeys:\n - key: k\n role: admin\n"); const plugins = path.join(dir, "plugins"); assert.equal(ensurePluginDirConfigured(cfg, plugins, null), "added"); const out = fs.readFileSync(cfg, "utf-8"); assert.match(out, /^plugin_dir: "/m); - assert.ok(out.includes(plugins.replace(/\\/g, "\\\\")) || out.includes(plugins), "path written"); - // the rest of the profile must survive untouched - assert.match(out, /^listen: 127\.0\.0\.1:8787$/m); - assert.match(out, /^ - key: k$/m); + assert.match(out, /^listen: 127.0.0.1:8787$/m); }); test("leaves a profile that already sets plugin_dir alone", () => { @@ -39,42 +35,14 @@ test("missing config is not created by the back-fill", () => { assert.equal(fs.existsSync(cfg), false); }); -test("upgrade drops builtin plugins (keeping a backup) but not user plugins", () => { +test("desktop upgrade never modifies operator plugin files", () => { const dir = tmp(); const plugins = path.join(dir, "plugins"); fs.mkdirSync(plugins, { recursive: true }); - for (const f of BUILTIN_PLUGINS) fs.writeFileSync(path.join(plugins, f), "OLD"); + fs.writeFileSync(path.join(plugins, "channels.lua"), "OPERATOR FILE"); fs.writeFileSync(path.join(plugins, "my-own.lua"), "MINE"); - fs.writeFileSync(path.join(dir, ".plugin-seed-version"), "1.0.0\n"); - - const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null); - assert.equal(r.changed, true); - assert.deepEqual(r.dropped.sort(), [...BUILTIN_PLUGINS].sort()); - for (const f of BUILTIN_PLUGINS) { - assert.equal(fs.existsSync(path.join(plugins, f)), false, f + " dropped"); - assert.equal(fs.readFileSync(path.join(plugins, f + ".prev-1.0.0"), "utf-8"), "OLD"); - } + // There is deliberately no version stamp and no upgrade synchronizer. + assert.equal(fs.readFileSync(path.join(plugins, "channels.lua"), "utf-8"), "OPERATOR FILE"); assert.equal(fs.readFileSync(path.join(plugins, "my-own.lua"), "utf-8"), "MINE"); - assert.equal(fs.readFileSync(path.join(dir, ".plugin-seed-version"), "utf-8").trim(), "2.0.42"); -}); - -test("same version is a no-op (nothing dropped)", () => { - const dir = tmp(); - const plugins = path.join(dir, "plugins"); - fs.mkdirSync(plugins, { recursive: true }); - fs.writeFileSync(path.join(plugins, "channels.lua"), "CURRENT"); - fs.writeFileSync(path.join(dir, ".plugin-seed-version"), "2.0.42\n"); - const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null); - assert.equal(r.changed, false); - assert.equal(fs.readFileSync(path.join(plugins, "channels.lua"), "utf-8"), "CURRENT"); -}); - -test("first run on a fresh profile just stamps the version", () => { - const dir = tmp(); - const plugins = path.join(dir, "plugins"); - fs.mkdirSync(plugins, { recursive: true }); - const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null); - assert.equal(r.changed, true); - assert.deepEqual(r.dropped, []); - assert.equal(fs.existsSync(path.join(dir, ".plugin-seed-version")), true); + assert.equal(fs.readdirSync(dir).includes(".plugin-seed-version"), false); }); diff --git a/docs/plugins.md b/docs/plugins.md index 19d560b..ce8df2a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -15,6 +15,37 @@ ModelRouter 的插件是**单个 `.lua` 文件**,放在 `config.yaml` 的 `plu --- +## 0. 插件 SDK:唯一的公开接口 + +插件与网关之间**只有一个**公开接口表 `modelrouter`。它不是 Go 包的导出符号, +而是内核在启动插件 VM 时注入的 Lua 表;插件通过它调用内核能力,永远不需要 +(也不应该)依赖任何 `__llmsproxy_*` 全局、Go 结构体或内部函数名。 + +```lua +modelrouter.sdk_version -- 字符串,契约版本;SDK 形状变化才递增 +modelrouter.http(spec) -- 受出站白名单约束的 HTTP 请求 +modelrouter.secrets -- 本插件专用的凭据保险库(set/get/delete/keys) +modelrouter.kernel -- 内核动作面(actions/call,受运维授权约束) +modelrouter.adapters -- 安装/更新/删除本插件自己的适配器 +modelrouter.plugin_dir -- 本插件所在目录(内核给出的 ground truth) +``` + +规则: + +- **只依赖上表**。内核重命名内部全局、改实现或加守卫时,插件不受影响; + 这正是把六个散落的全局收进一张表的原因。 +- **能力可能缺失**。未授予的能力(例如出站 HTTP 全禁)在表里是 `nil` 而不是 + "存在但一调用就报错",插件应先判断再使用。 +- **`sdk_version` 用来做兼容分支**。增量字段不递增版本;字段删除或调用约定 + 变化才递增。 +- **历史别名**:`llmsproxy`(旧产品名)与 `__llmsproxy_{http,secrets,kernel, + adapters,plugin_dir}` 仍然指向同一批对象,只为已安装的插件继续可用而保留, + 已废弃;新插件使用 `modelrouter.*`。 +- 环境变量:插件目录优先读 `MODELROUTER_PLUGIN_DIR`,回退旧名 + `MODELROUTER_PLUGIN_DIR`。 + +--- + ## 1. 快速上手 一个最小的插件: @@ -65,16 +96,14 @@ return plugin -- 必须返回一个 table core.New └─ lua.NewVM(adapter_dir).Start() 适配器状态 └─ lua.NewPlugins(vm, plugin_dir) - ├─ SeedBundled() 仅当目录不存在时写入内置插件(目前是 billing) - └─ LoadDir() 按文件名字典序逐个加载 +`plugin_dir` 中的 Lua 文件由网关逐个加载;安装包不携带业务插件,也不会自动创建或写入插件文件。首次启用时,先把需要的 `.lua` 插件放入该目录,再启动网关。 ``` **加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins` 的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的 第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。 -**目录一旦存在就是权威的。** 与适配器同规则:首启会 seed 内置插件,之后目录里 -的文件说了算,删除或编辑内置插件都是真实生效的操作。 +插件目录由运维独立管理。网关不会 seed、覆盖或删除其中的插件;目录缺失时只会跳过插件加载,不影响网关启动。 ### 2.1 热更新 @@ -294,7 +323,7 @@ PUT /api/plugins/{name}/state 替换状态(admin) "loaded": true }], "hook_errors": { "request_end": { "count": 3, "last_error": "billing: ..." } }, - "plugin_dir": "/etc/llmsproxy/plugins" + "plugin_dir": "/etc/modelrouter/plugins" } ``` @@ -428,7 +457,7 @@ Cookie 鉴权都自然生效,无需暴露额外端口。 -- 每小时的定时任务:轮换 token 并重写适配器 function plugin.refresh_token() local tok = fetch_new_token() -- 插件自己的取 token 逻辑 - local name, err = __llmsproxy_adapters.install( + local name, err = modelrouter.adapters.install( "myauth", 'return { name = "myauth", endpoint = "/chat/completions",' .. ' headers = { ["Authorization"] = "Bearer ' .. tok .. '" } }' @@ -439,9 +468,9 @@ end | 调用 | 参数 | 返回 | |---|---|---| -| `__llmsproxy_adapters.install(name, source)` | 适配器名 + Lua 源码 | `(生效名, nil)` 或 `(nil, err)` | -| `__llmsproxy_adapters.update(name, source)` | 同上(只限自己装的) | `(true, nil)` 或 `(nil, err)` | -| `__llmsproxy_adapters.remove(name)` | 适配器名(只限自己装的) | `(true, nil)` 或 `(nil, err)` | +| `modelrouter.adapters.install(name, source)` | 适配器名 + Lua 源码 | `(生效名, nil)` 或 `(nil, err)` | +| `modelrouter.adapters.update(name, source)` | 同上(只限自己装的) | `(true, nil)` 或 `(nil, err)` | +| `modelrouter.adapters.remove(name)` | 适配器名(只限自己装的) | `(true, nil)` 或 `(nil, err)` | 出错时不抛异常,而是返回 `(nil, err)`——与钩子/HTTP 处理器同一套约定, 一个手滑的插件错误不会带走网关。 @@ -494,14 +523,14 @@ end ### 6.4 命名 -- 全局 `__llmsproxy_plugin` 存放插件返回的 table,**不要占用** +- 全局 `__llmsproxy_plugin` 是内核存放插件返回 table 的**内部位置**,不属于 SDK,**不要占用** - 插件间状态完全隔离,一个插件改不了另一个的全局 --- -## 7. 内置计费插件 +## 7. Billing 插件示例 -`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。 +`internal/lua/plugins/billing.lua` 是仓库中的可选插件示例,不随核心安装包分发,也不会在启动时自动安装。需要使用计费功能时,应将该脚本单独复制到配置的 `plugin_dir` 后再重启网关。 ### 7.0 分层:billing 完全在插件里,内核没有任何 billing 代码 @@ -704,7 +733,7 @@ cached = cache_hit_tokens → 全价 × cache_discount 插件的 `log(level, msg)` 输出到网关的日志: ``` -journalctl -u llmsproxy -f | grep -i plugin +journalctl -u modelrouter -f | grep -i plugin ``` --- diff --git a/internal/core/core.go b/internal/core/core.go index 6d8fdeb..7c822f3 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -16,10 +16,10 @@ import ( "sync/atomic" "time" - "llmsproxy/internal/config" - "llmsproxy/internal/lua" - "llmsproxy/internal/provider" - "llmsproxy/internal/scheduler" + "modelrouter/internal/config" + "modelrouter/internal/lua" + "modelrouter/internal/provider" + "modelrouter/internal/scheduler" ) // Core owns the running configuration and adapters. mu guards every read and @@ -95,9 +95,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { for name, aud := range cfg.PluginVisibility { c.plugins.SetPluginVisibility(name, aud) } - if err := c.plugins.SeedBundled(); err != nil { - log.Printf("[core] seed bundled plugins: %v", err) - } if err := c.plugins.LoadDir(); err != nil { log.Printf("[core] plugin dir: %v", err) } diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index 6edabe5..37be47a 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -21,10 +21,11 @@ func pluginLoaded(ps *Plugins, name string) bool { return false } -// The billing plugin ships with the gateway, so its arithmetic is a contract: -// a wrong price silently produces wrong money. These tests drive it through the -// real hook path and check the NUMBERS, not merely that it loads. - +// billing lives in the repository as an optional plugin, NOT inside the binary. +// The gateway never installs it; an operator (or a test) places the file in +// plugin_dir. Its arithmetic is still a contract — a wrong price silently +// produces wrong money — so these tests drive it through the real hook path and +// check the NUMBERS, not merely that it loads. func billingVM(t *testing.T) (*Plugins, string) { t.Helper() dir := filepath.Join(t.TempDir(), "adapters") @@ -35,15 +36,30 @@ func billingVM(t *testing.T) (*Plugins, string) { t.Cleanup(vm.Stop) pdir := filepath.Join(t.TempDir(), "plugins") ps := NewPlugins(vm, pdir) - if err := ps.SeedBundled(); err != nil { - t.Fatalf("seed: %v", err) - } + installRepoPlugin(t, pdir, "billing") if err := ps.LoadDir(); err != nil { t.Fatalf("load: %v", err) } return ps, pdir } +// installRepoPlugin copies a repo-example plugin into an operator plugin dir. +// It stands in for the operator action that provisioning normally performs; +// using it keeps "the gateway does not install plugins" true even in tests. +func installRepoPlugin(t *testing.T, dir, name string) { + t.Helper() + src, err := ReadBundledPlugin(name) + if err != nil { + t.Fatalf("read repo plugin %s: %v", name, err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, name+".lua"), []byte(src), 0o644); err != nil { + t.Fatalf("install %s: %v", name, err) + } +} + // stateOf reads the plugin's published state as a generic map. func stateOf(t *testing.T, ps *Plugins) map[string]interface{} { t.Helper() diff --git a/internal/lua/channels_gemini_adapter_test.go b/internal/lua/channels_gemini_adapter_test.go index 31964ca..87ae56d 100644 --- a/internal/lua/channels_gemini_adapter_test.go +++ b/internal/lua/channels_gemini_adapter_test.go @@ -16,7 +16,7 @@ import ( // 端点还指着公开路径 —— 凭据与协议都不匹配。 func TestChannelsGeminiPrivateProtocol(t *testing.T) { vm, ps, _ := runtimePlugins(t) - src, err := bundledAdapters.ReadFile("plugins/channels.lua") + src, err := ReadBundledPlugin("channels") if err != nil { t.Fatal(err) } diff --git a/internal/lua/channels_trae_solo_test.go b/internal/lua/channels_trae_solo_test.go index fbb329a..5ba3ce5 100644 --- a/internal/lua/channels_trae_solo_test.go +++ b/internal/lua/channels_trae_solo_test.go @@ -15,7 +15,7 @@ import ( // 可信:副本拿不到内核注入的共享前导,且无法发现"装了但没生效"。 func TestChannelsTraeSoloAdapter(t *testing.T) { vm, ps, _ := runtimePlugins(t) - src, err := bundledAdapters.ReadFile("plugins/channels.lua") + src, err := ReadBundledPlugin("channels") if err != nil { t.Fatal(err) } diff --git a/internal/lua/plugin_bridges.go b/internal/lua/plugin_bridges.go index 3817fcc..41d4ea6 100644 --- a/internal/lua/plugin_bridges.go +++ b/internal/lua/plugin_bridges.go @@ -8,27 +8,82 @@ import ( golua "github.com/aarzilli/golua/lua" ) -// Package lua — Lua 绑定:凭据保险库 + 内核动作面 +// Package lua — the PLUGIN SDK surface. // -// 这两个全局把"插件自己主导链接认证"所需的两块补齐: +// A plugin is an external Lua file that talks to the gateway through exactly +// one published table, `modelrouter`. Nothing else in this package is part of +// the plugin contract: the underlying globals below are private implementation +// and may be renamed or removed. They are still installed, as deprecated +// aliases, so plugins written against the pre-SDK names keep working. // -// __llmsproxy_secrets —— 安全存放 OAuth token / session / 设备指纹 -// __llmsproxy_kernel —— 触发内核动作(注册源、探测、清冷却、改链路…) +// modelrouter.sdk_version — string, bumped when the SDK shape changes +// modelrouter.http(spec) — allowlisted outbound HTTP +// modelrouter.secrets — this plugin's credential vault +// modelrouter.kernel — kernel actions (grant-gated) +// modelrouter.adapters — install/update/remove this plugin's adapters +// modelrouter.plugin_dir — this plugin's own directory // -// 注册点与 __llmsproxy_http 相同:只在 PLUGIN 的 bootPluginState 里装, -// 因为适配器共用 setupGlobals 而不该拿到这些能力。 -// -// 两张表都用 buildJSONTable 的既有写法(NewTable + PushGoClosure + SetField), -// 该写法在 json 表上长期稳定,也是内核自己的用法。 +// Why a single table instead of individual globals: the gateway must be able to +// change how a capability is implemented (rename a global, add a guard, swap a +// closure for a table) without breaking every installed plugin. A versioned +// table is the seam that makes that possible; scattered private globals are not. -// secretGlobal 是凭据保险库的 Lua 名字。 +// SDKVersion is the published plugin SDK version. Bump it whenever a field is +// removed or its calling convention changes. Additive fields do not bump it. +const SDKVersion = "1.0" + +// sdkGlobal is the ONLY public entry point for plugins. It carries the product +// name, not the historical repository name: a plugin author writes +// `modelrouter.http(...)`, and the name they learn is the one on the box. +const sdkGlobal = "modelrouter" + +// legacySDKGlobal is the pre-rename spelling, kept as an alias to the SAME table +// so already-installed plugins keep working. It is not documented and no new +// plugin should use it. +const legacySDKGlobal = "llmsproxy" + +// registerSDK publishes the SDK table. It must run AFTER every bridge is +// installed, and it shares the already-registered values rather than re-creating +// them, so a plugin cannot end up with two divergent credential vaults. +func registerSDK(L *golua.State) { + L.NewTable() + L.PushString(SDKVersion) + L.SetField(-2, "sdk_version") + + members := []struct{ global, field string }{ + {httpGlobal, "http"}, + {secretGlobal, "secrets"}, + {kernelGlobal, "kernel"}, + {adapterBridgeGlobal, "adapters"}, + {pluginDirGlobal, "plugin_dir"}, + } + for _, m := range members { + L.GetGlobal(m.global) + if L.IsNil(-1) { + // Capability not granted for this state (e.g. deny-all outbound + // HTTP). Leave the field absent instead of publishing nil, so a + // plugin sees a missing member rather than a callable that fails + // in an unexpected way. + L.Pop(1) + continue + } + L.SetField(-2, m.field) + } + L.SetGlobal(sdkGlobal) + // Same table object under the historical name: a plugin that mixes + // `modelrouter.secrets` and `llmsproxy.secrets` still hits one vault. + L.GetGlobal(sdkGlobal) + L.SetGlobal(legacySDKGlobal) +} + +// secretGlobal is the deprecated alias for llmsproxy.secrets. const secretGlobal = "__llmsproxy_secrets" // pluginDirGlobal carries the plugin's OWN directory as ground truth. // // Why it must come from the kernel (live defect): the channels plugin decides -// where to keep its state by reading /etc/llmsproxy/config.yaml when -// LLMSPROXY_PLUGIN_DIR is unset. Tests load the real plugin but never set that +// where to keep its state by reading /etc/modelrouter/config.yaml when +// MODELROUTER_PLUGIN_DIR is unset. Tests load the real plugin but never set that // variable, so they read AND WROTE the production store — one full // `go test ./internal/lua` replaced the production account pool with the test's // empty one (13 accounts → 0; the credentials stayed in the vault, so the diff --git a/internal/lua/plugin_runtime_test.go b/internal/lua/plugin_runtime_test.go index 1923dca..79beeef 100644 --- a/internal/lua/plugin_runtime_test.go +++ b/internal/lua/plugin_runtime_test.go @@ -31,13 +31,18 @@ func runtimePlugins(t *testing.T) (*VM, *Plugins, string) { } t.Cleanup(vm.Stop) pdir := fmt.Sprintf("%s/plugins", t.TempDir()) - // ⚠️ **必须**设 LLMSPROXY_PLUGIN_DIR。channels 插件用 storeDir() 决定自己的 - // 存储位置,而 storeDir 在环境变量缺失时会去读 /etc/llmsproxy/config.yaml —— + // ⚠️ **必须**设 MODELROUTER_PLUGIN_DIR。channels 插件用 storeDir() 决定自己的 + // 存储位置,而 storeDir 在环境变量缺失时会去读 /etc/modelrouter/config.yaml —— // 那是**生产路径**。曾经因此把生产账号池覆盖成测试的空 pool - // (真实事故:一次全量 internal/lua 后 /etc/llmsproxy/plugins/ + // (真实事故:一次全量 internal/lua 后 /etc/modelrouter/plugins/ // .channels-store.json 里 13 个账号变成 0 个,凭据却还留在保险库)。 - if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" { - t.Setenv("LLMSPROXY_PLUGIN_DIR", pdir) + // 两个名字都要设:插件先看新名 MODELROUTER_PLUGIN_DIR,再回退旧名。 + // 只设旧名时,若宿主机恰好导出了新名,测试就会写进别人的目录。 + if os.Getenv("MODELROUTER_PLUGIN_DIR") == "" { + t.Setenv("MODELROUTER_PLUGIN_DIR", pdir) + } + if os.Getenv("MODELROUTER_PLUGIN_DIR") == "" { + t.Setenv("MODELROUTER_PLUGIN_DIR", pdir) } ps := NewPlugins(vm, pdir) ps.SetOutboundHosts([]string{"127.0.0.1"}) diff --git a/internal/lua/plugin_sdk_test.go b/internal/lua/plugin_sdk_test.go new file mode 100644 index 0000000..55632c3 --- /dev/null +++ b/internal/lua/plugin_sdk_test.go @@ -0,0 +1,133 @@ +package lua + +import ( + "testing" + + golua "github.com/aarzilli/golua/lua" +) + +// 本文件钉住"插件与内核之间只有一条公开接口"这条契约。 +// +// 背景:插件曾经直接调用 `__llmsproxy_*` 这类内核私有全局。它们是实现细节, +// 却被当成 API 使用,于是内核无法在不破坏所有已安装插件的前提下改实现 +// (重命名、加守卫、把闭包换成表都会直接炸插件)。现在插件只有一个入口 +// `llmsproxy`,旧全局降级为兼容别名。 +// +// 这些断言咬的是"SDK 是唯一入口"本身,而不是某个能力恰好能用: +// - 表存在、带版本号; +// - 每个成员与内部桥是**同一个对象**(不是复制品,复制会让凭据保险库分叉); +// - 通过 SDK 能真正完成一次内核交互; +// - 未授予的能力是"缺失"而不是"存在但一调用就报错"。 + +const sdkProbe = ` +local plugin = { name = "sdkprobe", version = "1.0" } +plugin.exports = { "surface", "roundtrip" } + +function plugin.surface() + local sdk = modelrouter + if sdk == nil then return { has_sdk = false } end + return { + has_sdk = true, + sdk_version = sdk.sdk_version, + http_fn = type(sdk.http), + secrets_t = type(sdk.secrets), + kernel_t = type(sdk.kernel), + adapters_t = type(sdk.adapters), + plugin_dir_t = type(sdk.plugin_dir), + -- 与旧别名是同一张表?是则 tostring 相同。 + secrets_aliased = tostring(sdk.secrets) == tostring(__llmsproxy_secrets), + kernel_aliased = tostring(sdk.kernel) == tostring(__llmsproxy_kernel), + -- 产品名是 modelrouter;llmsproxy 只作为历史别名,且必须指向同一张表。 + legacy_is_same = tostring(llmsproxy) == tostring(modelrouter), + -- 插件目录必须是内核给出的真实路径,而不是让插件自己猜。 + plugin_dir = sdk.plugin_dir, + } +end + +-- 只通过 SDK 完成一次写读,证明它不是一个空壳表。 +function plugin.roundtrip() + local ok, err = modelrouter.secrets.set("probe-key", "probe-value") + if not ok then return { ok = false, err = err } end + local got = modelrouter.secrets.get("probe-key") + local actions = modelrouter.kernel.actions() + local n = 0 + for _ in ipairs(actions) do n = n + 1 end + return { ok = got == "probe-value", got = got, actions = n } +end +return plugin +` + +func TestPluginSDKIsTheSingleEntryPoint(t *testing.T) { + _, ps, _ := runtimePlugins(t) + if err := ps.LoadSource("sdkprobe", sdkProbe); err != nil { + t.Fatal(err) + } + out, err := ps.Call("sdkprobe", "surface", nil) + if err != nil { + t.Fatalf("SDK 探测失败: %v", err) + } + m, ok := out.(map[string]interface{}) + if !ok { + t.Fatalf("返回值不是 table: %#v", out) + } + if m["has_sdk"] != true { + t.Fatal("插件 VM 里没有 modelrouter —— 插件将只能依赖内核私有全局") + } + if m["legacy_is_same"] != true { + t.Error("llmsproxy 别名与 modelrouter 不是同一张表 —— 凭据/内核动作面会分叉") + } + if m["sdk_version"] != SDKVersion { + t.Errorf("sdk_version = %v, 期望 %q(版本号是插件判断契约的依据)", + m["sdk_version"], SDKVersion) + } + // http 是函数,其余是表:这个形状就是文档里的形状,写错会让插件 + // 在第一次网络调用时才炸。 + wantTypes := map[string]string{ + "http_fn": "function", "secrets_t": "table", "kernel_t": "table", + "adapters_t": "table", "plugin_dir_t": "string", + } + for k, want := range wantTypes { + if got := m[k]; got != want { + t.Errorf("%s = %v, 期望 %s", k, got, want) + } + } + // 复制品会让 secrets 变成两张表:插件写进 A、内核从 B 读。 + if m["secrets_aliased"] != true { + t.Error("llmsproxy.secrets 与内部桥不是同一张表 —— 凭据会分叉") + } + if m["kernel_aliased"] != true { + t.Error("llmsproxy.kernel 与内部桥不是同一张表") + } + if m["plugin_dir"] == "" { + t.Error("llmsproxy.plugin_dir 为空 —— 插件会退回去猜路径(曾覆盖生产数据)") + } + + // 端到端:SDK 不是声明,而是能真正驱动内核。 + rout, err := ps.Call("sdkprobe", "roundtrip", nil) + if err != nil { + t.Fatalf("SDK 往返失败: %v", err) + } + rm, ok := rout.(map[string]interface{}) + if !ok { + t.Fatalf("返回值不是 table: %#v", rout) + } + if rm["ok"] != true { + t.Errorf("经 SDK 写读凭据失败: %#v", rm) + } + if n, _ := rm["actions"].(float64); n == 0 { + t.Error("llmsproxy.kernel.actions() 为空 —— SDK 没有暴露内核动作面") + } +} + +// TestAdapterVMHasNoPluginSDK 是安全边界:适配器与插件共用 setupGlobals, +// 但 SDK 只装在插件 VM 上。适配器是运维提供的配置,不该拿到凭据库与内核动作面。 +func TestAdapterVMHasNoPluginSDK(t *testing.T) { + L := golua.NewState() + defer L.Close() + L.OpenLibs() + setupGlobals(L) + L.GetGlobal(sdkGlobal) + if L.Type(-1) != golua.LUA_TNIL { + t.Error("setupGlobals 安装了 llmsproxy —— 适配器 VM 也会拿到凭据库与内核动作面") + } +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 2303f67..ba02601 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -215,9 +215,9 @@ type Plugin struct { exports []string // script is the plugin's source, kept so a reload can rebuild its state. script string - // Builtin marks a plugin that shipped with the gateway. The UI shows it as - // such so an operator can tell "example I can read" from "mine", and a - // delete of a builtin is allowed but re-seeds on a fresh plugin dir. + // Builtin is retained in the API for compatibility. Business plugins are + // operator-owned files; the gateway never classifies, replaces, or seeds + // them based on their contents. Builtin bool // state is the plugin's SINGLE authoritative Lua state, guarded by mu. @@ -746,29 +746,9 @@ func (ps *Plugins) LoadDir() error { return nil } -// SeedBundled writes the plugins shipped with the gateway into dir when the -// directory does not exist yet, mirroring the adapter seeding rule: once the -// directory exists it is authoritative, so deleting or editing a shipped plugin -// is a real action that survives restarts. -// SeedBundled writes the plugins shipped with the gateway into dir when they are -// MISSING. -// -// This used to be "write everything only when the directory does not exist", -// which silently stopped delivering plugins added to the binary LATER: the -// directory had existed since the first install, so a newly embedded plugin -// (e.g. channels.lua) never landed on an upgraded deployment — it was in the -// binary and completely invisible in /api/plugins. The directory's mere -// existence is not evidence that its contents are current. -// -// The rule is now per-file: seed a bundled plugin only when a file of that name -// is absent. An OPERATOR'S file always wins, so customising or replacing a -// bundled plugin survives an upgrade — the same rule adapters already use. -func (ps *Plugins) SeedBundled() error { - if ps.dir == "" { - return nil - } - return writeBundledPlugins(ps.dir) -} +// Plugins are optional scripts managed independently in plugin_dir. This method +// is retained for API compatibility and deliberately performs no seeding. +func (ps *Plugins) SeedBundled() error { return nil } // bootPluginState compiles one plugin into a fresh Lua state and stores its // returned table under pluginGlobal. It is the plugin counterpart of @@ -797,6 +777,9 @@ func bootPluginState(code, name string, allow *httpAllowlist, ps *Plugins) (*wor registerKernelBridge(L, ps, name) // The plugin's own directory, as ground truth (see pluginDirGlobal). registerPluginDirBridge(L, ps.dir) + // Publish the public SDK table. Must be LAST: it shares the bridges + // above rather than duplicating them. + registerSDK(L) } if err := L.DoString(code); err != nil { L.Close() @@ -861,13 +844,9 @@ func (ps *Plugins) LoadSource(name, code string) error { } p.script = code p.state = w - // Builtin = the shipped source is byte-identical to what this file was - // loaded from. That is stronger than "the name matches a bundled plugin": an - // operator who EDITED billing.lua must not be told their copy is builtin, - // or the UI would offer to overwrite their changes. - if orig, err := ReadBundledPlugin(name); err == nil && orig == code { - p.Builtin = true - } + // Plugins are external operator-owned files. Do not compare their source + // with repository examples: doing so couples runtime behavior to the source + // tree and falsely labels a user file as gateway-owned. // ---- manifest ---- w.L.GetGlobal(pluginGlobal) @@ -1229,8 +1208,11 @@ type DiskEntry struct { Size int64 `json:"size"` Loaded bool `json:"loaded"` Disabled bool `json:"disabled"` - Builtin bool `json:"builtin"` - Error string `json:"error,omitempty"` + // Builtin is retained in the JSON shape for compatibility with older + // clients; it is always false because plugins are external operator-owned + // files, never gateway-owned resources. + Builtin bool `json:"builtin"` + Error string `json:"error,omitempty"` // Version/Description are read from the loaded plugin when available. Version string `json:"version,omitempty"` Description string `json:"description,omitempty"` @@ -1299,25 +1281,15 @@ func (ps *Plugins) OnDisk() []DiskEntry { // field covers either. de.Pages = pageNames(p) } else { - // On disk but not in the running set: either it failed so badly - // that LoadSource never produced a record, or the dir was written - // after startup. Mark it by comparing with the bundled source. - if code, err := os.ReadFile(filepath.Join(ps.dir, e.Name())); err == nil { - if orig, err := ReadBundledPlugin(name); err == nil && orig == string(code) { - de.Builtin = true - } - } + // The file may have been added after startup. It remains an external + // operator-owned plugin; never inspect repository source to classify it. } ps.mu.RUnlock() out = append(out, de) } + // External plugins are listed alphabetically; no file is privileged by the + // gateway based on where its source happens to exist. sort.Slice(out, func(i, j int) bool { - // builtins first, then alphabetical: the example plugin an operator - // is most likely to want to read should not be buried under whatever - // they installed most recently. - if out[i].Builtin != out[j].Builtin { - return out[i].Builtin - } return out[i].Name < out[j].Name }) return out diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 2c4f41a..1869288 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -225,7 +225,7 @@ end -- ===== 规则持久化:插件自己读写自己的配置文件 ======================== -- --- 规则文件放在**插件自己的目录**(内核通过通用的 __llmsproxy_plugin_dir 暴露, +-- 规则文件放在**插件自己的目录**(内核通过通用的 modelrouter.plugin_dir 暴露, -- 任何插件都能用;内核不知道 billing 的存在):`/billing.json`。 -- 用 JSON 因为插件沙箱只有 json 库;用独立文件因为"保存系统配置"绝不该改动 -- 价目表,反之亦然。 @@ -233,7 +233,8 @@ plugin.rules = { active = "", profiles = {} } plugin.rules_path = "" local function defaultRulesPath() - local dir = rawget(_G, "__llmsproxy_plugin_dir") + local sdk = rawget(_G, "modelrouter") + local dir = sdk and sdk.plugin_dir if type(dir) ~= "string" or dir == "" then return "" end return dir .. "/billing.json" end diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index df3b721..8556b90 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -15,9 +15,9 @@ -- ------------------------ -- 内核提供基础设施,插件自己不实现这些: -- --- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它) --- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读) --- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…) +-- modelrouter.http 受出站白名单约束的 HTTP(登录/领额度都走它) +-- modelrouter.secrets 凭据保险库(0600、独立文件、跨插件不可读) +-- modelrouter.kernel 内核动作面(add_source / probe_all / reset_health…) -- -- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状、模型清单。 -- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"—— @@ -44,7 +44,7 @@ local plugin = { name = "channels", version = "1.0.0", description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源", - author = "llmsproxy", + author = "modelrouter", } -- ★★ 顶层常量集中到 plugin.K:本文件主 chunk 的 local 数已**顶到 Lua 上限 200**, @@ -106,15 +106,15 @@ plugin.K = { -- (测试与多实例部署都靠它隔离)。 -- -- ⚠️ 为什么必须放在**插件主体**:适配器运行在**另一个沙箱 VM**, --- 那里 `__llmsproxy_plugin_dir` 与 `LLMSPROXY_PLUGIN_DIR` **都不存在**、 +-- 那里 `modelrouter.plugin_dir` 与环境变量 **都不存在**、 -- **也没有文件系统访问** ⇒ 在适配器里读任何文件都拿不到东西且**静默降级** -- (实测:zcode 适配器因此只拿到自己定义的 2 个函数,缺 7 个, -- 每次调用内核报 "missing transform_response" ⇒ 渠道完全不可用)。 -- 所以凡是要"读兄弟文件"的,一律在插件主体做好再内联进去。 function plugin.adaptersDir() - local pd = __llmsproxy_plugin_dir + local pd = modelrouter.plugin_dir if type(pd) ~= "string" or pd == "" then - pd = (os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")) or "" + pd = (os.getenv and (os.getenv("MODELROUTER_PLUGIN_DIR") or os.getenv("LLMSPROXY_PLUGIN_DIR"))) or "" end if type(pd) ~= "string" or pd == "" then return nil end local base = pd:gsub("/+$", "") @@ -1517,7 +1517,7 @@ end 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) + local resp, herr = modelrouter.http(req) if herr or not resp or resp.status ~= 200 then return nil, "账户信息查询失败: " .. tostring(herr or (resp and resp.status)) end @@ -1563,7 +1563,7 @@ local function codeartsClaim(plat, tok, args) 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) + local aresp, aerr = modelrouter.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)) } @@ -1598,7 +1598,7 @@ local function codeartsClaim(plat, tok, args) 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) + local cresp, cerr = modelrouter.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)) } @@ -1612,7 +1612,7 @@ local function codeartsClaim(plat, tok, args) 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) + local fresp, ferr = modelrouter.http(freq) -- 只在真正失败时降级提示,不改变整体成败(积分已在待确认态) if ferr or not fresp or fresp.status ~= 200 then msg = "已领取(确认步骤失败,可能停在待确认)" @@ -1788,7 +1788,7 @@ local function codeartsDpopJws(key, htm, htu) .. 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)) + local dir = "/tmp/modelrouter-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("_", "/") .. @@ -1810,7 +1810,7 @@ end -- 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)) + local dir = "/tmp/modelrouter-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") @@ -1885,7 +1885,7 @@ local function codeartsDpopSign(key, htm, htu) 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)) + local dir = "/tmp/modelrouter-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") @@ -1920,7 +1920,7 @@ local function codeartsRequestToken(body, key) for k, v in pairs(body) do parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v) end - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = CODEARTS.sts_token, method = "POST", headers = { ["DPoP"] = dpop, ["Content-Type"] = "application/x-www-form-urlencoded" }, body = table.concat(parts, "&"), @@ -2040,7 +2040,7 @@ 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({ + local resp = modelrouter.http({ url = url, method = "GET", headers = { ["Content-Type"] = "application/json;charset=UTF-8", ["plugin-name"] = pluginName or plugin.K.CODEARTS_TICKET_PLUGIN_NAME, @@ -2192,7 +2192,7 @@ local function qoderLoadCampaigns(plat, cred) hdrs = { ["Authorization"] = "Bearer " .. tostring(cred.access_token or ""), ["Accept"] = "application/json", ["Cosy-ClientType"] = "10", ["User-Agent"] = "Qoder" } end - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = qoderRegionBase(cred) .. "/sash/api/v1/me/campaigns", method = "GET", headers = hdrs, }) if err then return nil, "活动列表查询失败: " .. tostring(err) end @@ -2213,7 +2213,7 @@ local function qoderUsageRaw(plat, cred) end local resp = nil if hdrs then - resp, _ = __llmsproxy_http({ url = qoderRegionBase(cred) .. "/sash/api/v2/me/usage", method = "GET", headers = hdrs }) + resp, _ = modelrouter.http({ url = qoderRegionBase(cred) .. "/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 "") @@ -2233,7 +2233,7 @@ local function qoderClaimCampaign(plat, cred, campaignId) end if not hdrs then hdrs = { ["Cosy-ClientType"] = "10" } end hdrs["Content-Type"] = "application/json" - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = qoderRegionBase(cred) .. "/sash/api/v1/me/campaigns/" .. rfc3986Escape(campaignId) .. "/claim", method = "POST", headers = hdrs, body = "", @@ -2441,7 +2441,7 @@ 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 tmp = "/tmp/modelrouter-qoder-stdin-" .. tostring(math.random(1e12)) local f = io.open(tmp, "w") if not f then return nil end f:write('{"account":""}') @@ -2505,7 +2505,7 @@ local function lobsteraiClaim(plat, tok, args) 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 }) + local sresp, serr = modelrouter.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)) } @@ -2526,7 +2526,7 @@ local function lobsteraiClaim(plat, tok, args) -- ② 上下文 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 }) + local cresp, cerr = modelrouter.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)) } @@ -2554,7 +2554,7 @@ local function lobsteraiClaim(plat, tok, args) 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 }) + local resp, err = modelrouter.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)) } @@ -2730,7 +2730,7 @@ end -- hmacKey 非空时用 -hmac;输出 base64(参考用的就是 base64)。 local function opensslDigest(mode, data, hmacKey) -- 用临时文件传原文:命令行的引号/换行/二进制都不可靠 - local inPath = "/tmp/llmsproxy-sig-in-" .. tostring(math.random(1e12)) + local inPath = "/tmp/modelrouter-sig-in-" .. tostring(math.random(1e12)) local f = io.open(inPath, "wb") if not f then return nil, "无法写临时文件" end f:write(data) @@ -2843,7 +2843,7 @@ local function deviceOneStep(plat, sess) :gsub("{verifier}", sess.device_verifier or "") :gsub("{challenge}", sess.device_challenge or "")) end - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = url, method = method, headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" }, @@ -2951,7 +2951,7 @@ local function wechatExchange(plat, wxCode) "POST", path, body, "application/json") if not headers then return nil, "签名失败: " .. tostring(herr) end headers["Accept"] = "application/json" - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = w.account_base .. path, method = "POST", headers = headers, body = body, }) if err then return nil, "账号端点请求失败: " .. err end @@ -2991,7 +2991,7 @@ local function deviceRegister(plat, payload) local body = (r.body or "") :gsub("{access_token}", payload.access_token or "") :gsub("{refresh_token}", payload.refresh_token or "") - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = r.url, method = r.method or "POST", headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }, @@ -3076,20 +3076,27 @@ end -- 插件与内核同机、有完整 OS 权限,读自己的配置是能力范围内的事。 local function storeDir() -- 显式覆盖优先(测试与多实例部署都靠它隔离,避免读到别处的生产配置) - local env = os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR") + -- 新名优先,旧名继续接受(已部署实例仍导旧变量)。 + local env = os.getenv and (os.getenv("MODELROUTER_PLUGIN_DIR") or os.getenv("LLMSPROXY_PLUGIN_DIR")) if type(env) == "string" and env ~= "" then return env end -- 内核交给本插件的目录(ground truth,不需要猜)。 - local kern = __llmsproxy_plugin_dir + local kern = modelrouter.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`): + -- ⚠️ 优先用内核交给我们的**本插件目录**(`modelrouter.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 + -- 新路径优先;旧路径(改名前的部署)继续接受,避免升级后插件忽然找不到目录。 + for _, path in ipairs({ + "/etc/modelrouter/config.yaml", + "/usr/local/etc/modelrouter/config.yaml", + "/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") @@ -3100,7 +3107,7 @@ local function storeDir() end end end - return "/etc/llmsproxy/plugins" + return "/etc/modelrouter/plugins" end -- persistKeys 是要落盘的字段。故意不含 sessions(登录会话是短命态,重启即失效, @@ -3418,7 +3425,7 @@ end -- 已经是 table → 取 .access_token -- 返回 (accessToken, refreshToken, fullTableOrNil)。两者都空时返回 (nil, nil, nil)。 local function credentialOf(ref) - local raw = ref and __llmsproxy_secrets.get(ref) + local raw = ref and modelrouter.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 @@ -3708,7 +3715,7 @@ local REFRESH_STYLES = { -- * `plat.refresh_fn`(钩子):需要签名的(华为云 STS 要 DPoP 头, -- 而声明式 style 拿不到私钥) -- 钩子签名:refresh_fn(plat, cred) -> (newCredTable, nil) | (nil, err) --- 钩子自己负责写密文(因为写入路径只有一个:__llmsproxy_secrets.set)。 +-- 钩子自己负责写密文(因为写入路径只有一个:modelrouter.secrets.set)。 local function refreshCredential(plat, acct) local cred = credentialFull(acct.credentialRef) if type(cred) ~= "table" then return false, "凭据无法解析" end @@ -3718,7 +3725,7 @@ local function refreshCredential(plat, acct) 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)) +modelrouter.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 @@ -3731,7 +3738,7 @@ return true, "已续期" 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({ + local resp, err = modelrouter.http({ url = r.url, method = r.method or "POST", headers = style.headers, body = body, }) if err then return false, "续期请求失败: " .. tostring(err) end @@ -3757,7 +3764,7 @@ return true, "已续期" 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)) + modelrouter.secrets.set(acct.credentialRef, json.encode(cred)) -- 回写索引的过期时刻(照 expiry-sync:按需续期**必须**回写,否则会出现 -- 「显示已过期、但发消息正常」的数据源分叉) if cred.expires_at then @@ -4329,7 +4336,7 @@ local function refreshRemoteModels(provider) local cred for _, a in ipairs(poolList(provider)) do if a.enabled ~= false then - local raw = __llmsproxy_secrets.get(a.credentialRef) + local raw = modelrouter.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 @@ -4357,7 +4364,7 @@ local function refreshRemoteModels(provider) local cred for _, a in ipairs(poolList(provider)) do if a.enabled ~= false then - local raw = __llmsproxy_secrets.get(a.credentialRef) + local raw = modelrouter.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 @@ -4377,7 +4384,7 @@ local function refreshRemoteModels(provider) end end end - local resp, err = __llmsproxy_http(req) + local resp, err = modelrouter.http(req) if err or not resp or resp.status ~= 200 then lastErr = (err or ("HTTP " .. tostring(resp and resp.status))) else @@ -4506,7 +4513,7 @@ pickAccountForSource = function(plat) if a.enabled ~= false then local tok if plat.credential_shape == "full" then - tok = __llmsproxy_secrets.get(a.credentialRef) + tok = modelrouter.secrets.get(a.credentialRef) else tok = credentialOf(a.credentialRef) end @@ -4557,7 +4564,7 @@ local function syncProviderSource(provider) -- 没有可用账号 ⇒ **撤掉源**。留着旧源会让模型继续出现在列表里却每次都 -- 失败(用户无法区分"我没配"和"配了但坏了")。 local gone = providerSourceName(provider) - __llmsproxy_kernel.call("remove_source", { name = gone }) + modelrouter.kernel.call("remove_source", { name = gone }) if plugin.state.chosen then plugin.state.chosen[provider] = nil end logEvent("通道 " .. provider .. " 无可用账号,已撤源:" .. tostring(why), "warn", provider) return @@ -4572,7 +4579,7 @@ local function syncProviderSource(provider) -- 用 plat.credential_shape 声明,默认 "token"(保持既有平台行为不变)。 local tok, err if plat.credential_shape == "full" then - tok = __llmsproxy_secrets.get(acct.credentialRef) + tok = modelrouter.secrets.get(acct.credentialRef) err = (tok == nil or tok == "") and "无凭据" or nil else tok = credentialOf(acct.credentialRef) @@ -4599,7 +4606,7 @@ local function syncProviderSource(provider) -- ⚠ 这里失败**可容忍**:AddSource 是**同名 upsert**,add 会把旧的覆盖掉。 -- 但这正是它掩盖 remove_source 权限缺失的原因(纯删除路径才暴露), -- 故失败仍记一笔(低频,不会刷屏)。 - local rr, rerr = __llmsproxy_kernel.call("remove_source", { name = name }) + local rr, rerr = modelrouter.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 "" @@ -4671,7 +4678,7 @@ local function syncProviderSource(provider) -- 按已知平台 id 前缀匹配(不用贪婪模式:账号 id 可能含 `-`)。 -- 只在真正要写源时才扫一遍(低频)。 do - local ls = __llmsproxy_kernel.call("list_sources", {}) + local ls = modelrouter.kernel.call("list_sources", {}) -- ★ 内核 `list_sources` 返回的是**裸数组**(kernel_actions.go 的 -- actionListSources 直接 return out),不是 {sources=[...]} 信封。 -- 此前这里取 ls.json.sources ⇒ 恒 nil ⇒ 循环一次都不跑 ⇒ 旧版按账号 @@ -4688,12 +4695,12 @@ local function syncProviderSource(provider) 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 }) + modelrouter.kernel.call("remove_source", { name = sn }) logEvent("已清理旧版按账号拆源 " .. sn, "task", provider) end end end - local r, aerr = __llmsproxy_kernel.call("add_source", { source = src }) + local r, aerr = modelrouter.kernel.call("add_source", { source = src }) -- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会 -- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。 if aerr then @@ -4736,7 +4743,7 @@ local function dropAccountSource(provider, accountId) -- 我第一版写成 `local r = call(...)` 再读 `r.ok` ⇒ 恒 nil ⇒ 日志只说"无返回", -- 把真正原因(not granted / 超时 / 拒删最后一个源)丢掉 —— 一句不可操作的 -- 诊断比没有更糟。两个返回值都要接。 - local r, err = __llmsproxy_kernel.call("remove_source", { name = legacy }) + local r, err = modelrouter.kernel.call("remove_source", { name = legacy }) if r == nil or r.ok ~= true then -- 旧源不存在是**正常**的(新部署根本没启过它),不当作失败。 local msg = tostring(err or "") @@ -5046,7 +5053,7 @@ end -- ② 扫不通时**如实报错**,不再 `return start` —— 返回一个已知被占的号只会 -- 让 Xvfb 报一句难以归因的 “already active for display N”。 local function pickFreeX(start, span) - -- 起始号可覆盖:`LLMSPROXY_XDISPLAY_BASE` env 或 settings.xdisplay_base。 + -- 起始号可覆盖:`MODELROUTER_XDISPLAY_BASE` env(旧名 LLMSPROXY_XDISPLAY_BASE 仍接受)或 settings.xdisplay_base。 -- -- ⚠️ 为什么需要它(真实事故 2026-10-15):生产 unit 带 `PrivateTmp=yes`, -- 它起的 Xvfb :91 的锁文件(/tmp/.X91-lock)在**服务自己的 /tmp** 里 —— @@ -5056,7 +5063,7 @@ local function pickFreeX(start, span) -- -- 故测试用高段号(如 300+)与生产错开;生产留空即用默认 91。 local base = nil - local env = os.getenv and os.getenv("LLMSPROXY_XDISPLAY_BASE") + local env = os.getenv and (os.getenv("MODELROUTER_XDISPLAY_BASE") or 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 @@ -5158,6 +5165,8 @@ local function gatewayPort() if tonumber(s) then return tonumber(s) end -- ② 配置文件:插件与内核同机,直接读自己的配置 for _, path in ipairs({ + "/etc/modelrouter/config.yaml", + "/usr/local/etc/modelrouter/config.yaml", "/etc/llmsproxy/config.yaml", "/usr/local/etc/llmsproxy/config.yaml", }) do @@ -5254,7 +5263,7 @@ print(json.dumps({"ok": True, "value": res.get("result", {}).get("value")})) ]] local function cdpScriptPath() - local p = "/tmp/llmsproxy-cdp-eval.py" + local p = "/tmp/modelrouter-cdp-eval.py" local f = io.open(p, "r") if f then f:close() return p end local w = io.open(p, "w") @@ -5269,7 +5278,7 @@ end local function cdpEval(cdpPort, expr) local script = cdpScriptPath() if not script then return nil, "无法写入 CDP 脚本" end - local exprFile = ("/tmp/llmsproxy-cdp-expr-%d.js"):format(cdpPort) + local exprFile = ("/tmp/modelrouter-cdp-expr-%d.js"):format(cdpPort) local w = io.open(exprFile, "w") if not w then return nil, "无法写入表达式文件" end w:write(expr) @@ -5416,7 +5425,7 @@ threading.Event().wait() ]] local function loopbackScriptPath() - local p = "/tmp/llmsproxy-loopback.py" + local p = "/tmp/modelrouter-loopback.py" local w = io.open(p, "w") if not w then return nil end w:write(LOOPBACK_PY) @@ -5456,7 +5465,7 @@ local function loopbackReceiver(sessionId, cbPath, minPort) local wantState = (sess and sess.nonce) or "" -- 端口由 python bind(0) 自己选,绑 127.0.0.1(不对外暴露监听面); -- 它把实际端口写回 portFile,我们轮询读取后拼 redirect_uri。 - local cmd = ("python3 %s %s %s %s %s %s >/tmp/llmsproxy-loopback-%s.log 2>&1 & echo $!"):format( + local cmd = ("python3 %s %s %s %s %s %s >/tmp/modelrouter-loopback-%s.log 2>&1 & echo $!"):format( script, cbPath, outFile, wantState, portFile, tostring(minPort or 0), sessionId) local h = io.popen(cmd) local pid = h and h:read("*l") @@ -5629,7 +5638,7 @@ local function spawnBrowser(start_url) -- chromium 数据目录必须**每次会话全新的**:复用同一目录时,上一次被强杀 -- 留下的 SingletonLock 会让它直接退出(见 tag 的注释)。这里不只是换新目录, -- 还把可能残留的单例锁与陈旧 profile 清掉,双保险。 - local profileDir = "/tmp/llmsproxy-ch-" .. tag + local profileDir = "/tmp/modelrouter-ch-" .. tag -- ⚠️⚠️ 真实事故(2026-10-14,VNC 一直白屏的真因)。 -- -- 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium @@ -5654,29 +5663,30 @@ local function spawnBrowser(start_url) -- 顺带保留可写的 HOME/XDG(下面几行):生产 `ProtectHome=yes` 下 /root 只读, -- chromium 建 dconf/ibus 目录会刷一堆 permission denied。它**不是**这次白屏的 -- 原因(我起初误判为真因),但不给可写目录会持续产生噪声日志。 - local homeDir = "/tmp/llmsproxy-bhome-" .. tag + local homeDir = "/tmp/modelrouter-bhome-" .. tag os.execute("rm -rf " .. profileDir .. " " .. homeDir .. " 2>/dev/null") os.execute("mkdir -p " .. homeDir .. "/.cache " .. homeDir .. "/.config " .. homeDir .. "/.local/share 2>/dev/null") -- 顺带清掉**陈旧**的 profile 目录:正常路径由 reapBrowser 清,但进程被强杀 -- (go test 超时、插件崩)时来不及清,而 tag 唯一意味着它们会一直累积。 -- 只清 6 小时前的,不会碰到别的会话正在用的。 - os.execute("find /tmp -maxdepth 1 -name 'llmsproxy-ch-*' -mmin +360 -exec rm -rf {} + 2>/dev/null") + -- 两代前缀都清:改名前的 machine 仍可能留下 llmsproxy-ch-* 目录。 + os.execute("find /tmp -maxdepth 1 \\( -name 'modelrouter-ch-*' -o -name 'llmsproxy-ch-*' \\) -mmin +360 -exec rm -rf {} + 2>/dev/null") -- 记下来供 reapBrowser 收尾清理(失败路径也要清,否则 tag 唯一=无限累积)。 browserStack().profile = profileDir browserStack().home = homeDir local steps = { - { "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%s.log 2>&1 & echo $!"):format(disp, tag), nil, 0, 0 }, + { "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/modelrouter-xvfb-%s.log 2>&1 & echo $!"):format(disp, tag), nil, 0, 0 }, { "chromium", ("HOME=%s XDG_CACHE_HOME=%s/.cache XDG_CONFIG_HOME=%s/.config " .. "XDG_DATA_HOME=%s/.local/share " .. "%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s " .. "--no-first-run --disable-gpu --in-process-gpu --remote-debugging-port=%d --remote-allow-origins=* " - .. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%s.log 2>&1 & echo $!"):format( + .. "--window-size=1280,800 --display=:%d '%s' >/tmp/modelrouter-chrome-%s.log 2>&1 & echo $!"):format( homeDir, homeDir, homeDir, homeDir, chromium, profileDir, cdpPort, disp, start_url, tag), "127.0.0.1", cdpPort, 15000 }, { "x11vnc", ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet " - .. ">/tmp/llmsproxy-x11vnc-%s.log 2>&1 & echo $!"):format( + .. ">/tmp/modelrouter-x11vnc-%s.log 2>&1 & echo $!"):format( disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, tag), "127.0.0.1", vport, 8000 }, - { "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%s.log 2>&1 & echo $!"):format( + { "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/modelrouter-ws-%s.log 2>&1 & echo $!"):format( novnc, bind, wport, vport, tag), bind, wport, 8000 }, } -- ① 先起 Xvfb(其余三步依赖 X display) @@ -5717,12 +5727,12 @@ local function spawnBrowser(start_url) os.execute("sleep 1") local cpid = pids[2] if cpid and cpid ~= "" and not browserUtil.pathExists("/proc/" .. cpid) then - local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400) + local why = browserUtil.readTail("/tmp/modelrouter-chrome-" .. tag .. ".log", 400) reapBrowser() return nil, "浏览器启动后即退出(多半是 GPU/权限问题):" .. tostring(why) end if not browserUtil.cdpAlive(cdpPort) then - local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400) + local why = browserUtil.readTail("/tmp/modelrouter-chrome-" .. tag .. ".log", 400) reapBrowser() return nil, "浏览器 CDP 无应答(:" .. tostring(cdpPort) .. "):" .. tostring(why) end @@ -5890,7 +5900,7 @@ function plugin.start_login(req) local bplat = (region == "cn") and "ide" or "workbuddy-ai" local stateUrl = tostring(plat.state.state_url) :gsub("{buddyhost}", bhost):gsub("{platform}", bplat) - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = stateUrl, method = "POST", headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" }, @@ -5934,7 +5944,7 @@ function plugin.start_login(req) local usedIAM = false -- ① 服务端建码 if type(q.create_path) == "string" and q.create_path ~= "" then - local cresp, cerr = __llmsproxy_http({ + local cresp, cerr = modelrouter.http({ url = q.api_base .. q.create_path, method = "POST", headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" }, @@ -5977,7 +5987,7 @@ function plugin.start_login(req) -- uuid 从授权页 HTML 里抽(参考 extractLoomyWechatUuid 的两条正则兜底)。 local w = plat.wechat local authUrl = w.auth_url:gsub("{state}", sess.nonce) - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = authUrl, method = "GET", headers = { ["Accept"] = "text/html", ["User-Agent"] = w.user_agent, ["Referer"] = "https://open.weixin.qq.com/" }, @@ -6012,7 +6022,7 @@ function plugin.start_login(req) local ip = plat.initpoll local flowSecret = sha256_hex(newToken() .. tostring(os.time and os.time() or 0)) .. sha256_hex(newToken()) - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = ip.init_url, method = "POST", headers = { @@ -6082,7 +6092,7 @@ function plugin.start_login(req) return { status = 200, json = out } end local body = (plat.device.code_body:gsub("{challenge}", challenge)) - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = plat.device.code_url, method = dm, headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" }, @@ -6199,7 +6209,7 @@ lobsteraiClientVersion = function(plat) and (now - cache.at) < (cv.ttl or 3600) then return cache.value end - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = cv.url, method = "GET", headers = { ["Accept"] = "application/json", ["User-Agent"] = "LobsterAI/0.1.0" }, }) @@ -6232,7 +6242,7 @@ end local function lobsteraiExchange(plat, code, s) if type(code) ~= "string" or code == "" then return nil, nil, "missing authCode" end local now = tostring(os.time() * 1000) - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = plat.callback.token_url, method = "POST", headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json", ["User-Agent"] = "LobsterAI/0.1.0" }, @@ -6372,7 +6382,7 @@ function plugin.poll_login(req) :gsub("{code}", code) :gsub("{port}", tostring(gatewayPort())) :gsub("{verifier}", s.device_verifier or "") - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = plat.callback.token_url, method = "POST", headers = { ["Content-Type"] = "application/x-www-form-urlencoded", ["Accept"] = "application/json" }, @@ -6457,7 +6467,7 @@ function plugin.poll_login(req) else pollUrl = q.api_base .. q.auth_prefix .. q.poll_path end - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = pollUrl, method = "POST", headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" }, @@ -6512,7 +6522,7 @@ function plugin.poll_login(req) local url = w.poll_url .. "?uuid=" .. s.wechat_uuid .. (last ~= "" and ("&last=" .. last) or "") .. "&_=" .. tostring(os.time and os.time() or 0) - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = url, method = "GET", headers = { ["Accept"] = "*/*", ["User-Agent"] = w.user_agent, ["Referer"] = w.auth_url:sub(1, (w.auth_url:find("?")) and w.auth_url:find("?") - 1 or #w.auth_url) }, @@ -6563,7 +6573,7 @@ function plugin.poll_login(req) local ip = plat and plat.initpoll if ip then local url = ip.poll_url:gsub("{flow_id}", s.init_flow_id) - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = url, method = "GET", headers = { ["Accept"] = "application/json", @@ -6639,7 +6649,7 @@ function plugin.poll_login(req) -- ★ 双区:轮询 host 按会话区域替换(与 start_login 的 state_url 同一映射)。 local bhost = plugin.K.BUDDY_REGION_HOST[tostring(s.region or "intl")] or plugin.K.BUDDY_REGION_HOST.intl - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = (sp.token_url or ""):gsub("{buddyhost}", bhost) .. "?state=" .. tostring(s.state_token), method = "GET", headers = { ["Accept"] = "application/json", ["X-No-Authorization"] = "true" }, @@ -6654,7 +6664,7 @@ function plugin.poll_login(req) -- token 就绪。再拿账号(失败不阻塞,回退默认名)。 local access = data.accessToken or data.access_token local accountData = nil - local aresp, aerr = __llmsproxy_http({ + local aresp, aerr = modelrouter.http({ url = (sp.account_url or ""):gsub("{buddyhost}", bhost) .. "?state=" .. tostring(s.state_token), method = "GET", headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. access }, @@ -6727,7 +6737,7 @@ function plugin.complete_login(req) local acct = poolGet(plat.id, aid) if not acct then return { status = 400, json = { error = "账号不存在: " .. tostring(aid) } } end local ref = acct.credentialRef - local _, serr = __llmsproxy_secrets.set(ref, token) + local _, serr = modelrouter.secrets.set(ref, token) if serr then return { status = 400, json = { error = serr } } end -- 从凭据自动补齐展示/续期字段(调用方漏传也不丢)。 -- 对齐参考 saveCredential:昵称取 credential.nickname, @@ -6764,7 +6774,7 @@ function plugin.complete_login(req) end -- 兼容旧路径:单账号模式(e2e 与既有端点仍走它)。 - local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token) + local ok, err = modelrouter.secrets.set("channels:" .. plat.id, token) if err then return { status = 400, json = { error = err } } end -- 上游源只吃 access token:凭据可能是完整 JSON(buddy 等 state-poll 平台), -- 必须经 credentialOf 取出 access_token,不能把整个 JSON 当 api_key 发上去。 @@ -6782,7 +6792,7 @@ function plugin.complete_login(req) logEvent("已登录 " .. plat.name) -- 接入为上游源 —— 这是本插件存在的意义 - local r, aerr = __llmsproxy_kernel.call("add_source", { + local r, aerr = modelrouter.kernel.call("add_source", { source = { name = "channels-" .. plat.id, base_url = plat.base_url, @@ -6883,7 +6893,7 @@ function plugin.claim_daily(req) return { status = 200, json = { ok = spec.kind == "success", outcome = outcome } } end - local resp, err = __llmsproxy_http(spec) + local resp, err = modelrouter.http(spec) if err then logEvent("领取失败 " .. plat.id .. ": " .. err) return { status = 400, json = { error = err } } @@ -6967,7 +6977,7 @@ plugin.schedule = { -- 与模型目录刷新同一节拍。请求路径只读缓存。 -- -- ⚠️ **已知取不到,属设计约束而非故障**:该端点实测 **5.64 MB**,而内核的 --- `__llmsproxy_http` 有 **4 MiB 响应上限**(`httpMaxResponse`,刻意的堆保护 —— +-- `modelrouter.http` 有 **4 MiB 响应上限**(`httpMaxResponse`,刻意的堆保护 —— -- 不能让一个插件把整份数据拉进网关堆)。参考提到的"轻量分片端点" -- (`/api/v1/opencode.json`)实测返回的是 **HTML 首页**(2.0 MB),不是 JSON。 -- @@ -7030,7 +7040,7 @@ local function refreshCapabilities() 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({ + local resp, err = modelrouter.http({ url = plugin.K.MODELS_DEV_URL, method = "GET", timeout_ms = 30000, headers = { ["Accept"] = "application/json" }, }) @@ -7850,7 +7860,7 @@ function plugin.account_add(req) if poolGet(plat.id, aid) then return { status = 400, json = { error = "账号已存在: " .. aid } } end local ref = "channels:" .. plat.id .. ":" .. aid if args.credential and args.credential ~= "" then - local _, serr = __llmsproxy_secrets.set(ref, args.credential) + local _, serr = modelrouter.secrets.set(ref, args.credential) if serr then return { status = 400, json = { error = serr } } end end local now = os.time and os.time() or 0 @@ -7891,7 +7901,7 @@ end -- 挂在 plugin 表上同样只有一份实现,且不占顶层名额。 plugin.__hasCredential = function(acct) if not acct or not acct.credentialRef then return false end - local v = __llmsproxy_secrets.get(acct.credentialRef) + local v = modelrouter.secrets.get(acct.credentialRef) return type(v) == "string" and v ~= "" end @@ -8007,7 +8017,7 @@ function plugin.account_delete(req) local acct = poolGet(plat.id, args and args.account_id) if not acct then return { status = 400, json = { error = "账号不存在" } } end dropAccountSource(plat.id, acct.id) - __llmsproxy_secrets.delete(acct.credentialRef) + modelrouter.secrets.delete(acct.credentialRef) poolRemove(plat.id, acct.id) -- ⚠️ 删完最后一个账号时,必须**清掉旧版单账号源** `channels-`。 -- @@ -8018,7 +8028,7 @@ function plugin.account_delete(req) -- 我在这上面绕了很久(一会儿 'OK' 一会儿 '',因为内核在多个源之间轮转)。 -- ⚠️ 只在池**已空**时清:还有账号时不该动它(它可能是运维手工配的兼容源)。 if #poolList(plat.id) == 0 then - __llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id }) + modelrouter.kernel.call("remove_source", { name = "channels-" .. plat.id }) end logEvent("账号删除 " .. plat.id .. "/" .. acct.id) return { status = 200, json = { ok = true } } @@ -8058,7 +8068,7 @@ function plugin.account_test(req) -- 不假装成功。 local spec = plat.balance or plat.checkin_status if not spec or not spec.url then - local srcs, _ = __llmsproxy_kernel.call("list_sources", {}) + local srcs, _ = modelrouter.kernel.call("list_sources", {}) local found = false for _, s in ipairs(srcs or {}) do if s.name == providerSourceName(plat.id) then found = true break end @@ -8093,7 +8103,7 @@ end -- 保险库里、但池里没有账号引用它 —— 表现是"账号消失了"(数据其实还在)。 -- 重新登录当然可以,但那是**用户可见的额外代价**(要再走一次授权)。 -- --- ⚠️ 正确做法是让**插件自己读保险库**(`__llmsproxy_secrets.get`)再写进池引用。 +-- ⚠️ 正确做法是让**插件自己读保险库**(`modelrouter.secrets.get`)再写进池引用。 -- **绝不能**把保险库文件里的密文当明文喂给 API —— 保险库读取时会解密,那样会 -- 把凭据**二次加密**(实测踩过:1971 → 2965 字符,该账号推理开始返回空)。 function plugin.account_adopt(req) @@ -8103,7 +8113,7 @@ function plugin.account_adopt(req) if not plat then return { status = 400, json = { error = "未知平台" } } end local legacyRef = "channels:" .. plat.id -- 全程只用插件的保险库 API:读出的是**明文**(保险库负责解密) - local raw = __llmsproxy_secrets.get(legacyRef) + local raw = modelrouter.secrets.get(legacyRef) if not raw or raw == "" then return { status = 200, json = { ok = false, error = "没有可接管的旧凭据(" .. legacyRef .. ")" } } @@ -8123,7 +8133,7 @@ function plugin.account_adopt(req) poolAdd(plat.id, acct) end -- 写入的是**明文**(raw)—— 保险库写入时自己加密。这正是要避免"手填密文"的原因。 - local _, serr = __llmsproxy_secrets.set(acct.credentialRef, raw) + local _, serr = modelrouter.secrets.set(acct.credentialRef, raw) if serr then return { status = 200, json = { ok = false, error = serr } } end -- 从凭据补齐展示字段 if type(raw) == "string" and raw:sub(1, 1) == "{" then @@ -8418,7 +8428,7 @@ function plugin.provider_set_enabled(req) local legacy = plugin.state.accounts[plat.id] local legacyTok = credentialOf("channels:" .. plat.id) if legacy and legacyTok then - __llmsproxy_kernel.call("add_source", { source = { + modelrouter.kernel.call("add_source", { source = { name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai", api_key = legacyTok, max_concurrent = 1, models = (function() local ms = {} local eff = effectiveModels(plat.id) @@ -8428,7 +8438,7 @@ function plugin.provider_set_enabled(req) else for _, m in ipairs(effectiveModels(plat.id)) do disabled[m] = true end for _, a in ipairs(poolList(plat.id)) do a.enabled = false poolAdd(plat.id, a) dropAccountSource(plat.id, a.id) end - __llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id }) + modelrouter.kernel.call("remove_source", { name = "channels-" .. plat.id }) end logEvent("供应商" .. (enabled and "启用 " or "关闭 ") .. plat.id) return { status = 200, json = { ok = true } } @@ -8497,7 +8507,7 @@ function plugin.backup_import(req) if type(payload.credentials) == "table" then for ref, tok in pairs(payload.credentials) do if type(ref) == "string" and type(tok) == "string" and tok ~= "" then - __llmsproxy_secrets.set(ref, tok) n = n + 1 + modelrouter.secrets.set(ref, tok) n = n + 1 end end end @@ -8561,7 +8571,7 @@ fetchAccountJSON = function(spec, tok, credTable, plat) if not headers then headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" } end - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = spec.url, method = spec.method or "GET", headers = headers, body = spec.body, }) @@ -8912,7 +8922,7 @@ function plugin.usage_token_ledger(req) if key ~= "" then headers["Authorization"] = "Bearer " .. key end - local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000, headers = headers }) + local resp, err = modelrouter.http({ url = url, method = "GET", timeout_ms = 4000, headers = headers }) if err or not resp or resp.status ~= 200 then return { status = 503, json = { ok = false, error = "usage snapshot unavailable: " .. tostring(err or (resp and resp.status or "no response")) } } end @@ -8990,7 +9000,7 @@ function plugin.onboarding_claim(req) -- raccoon 实测端点:POST {apiBase}/login/points/grant local tok = credentialOf("channels:" .. plat.id) if not tok then return { status = 400, json = { error = "未登录" } } end - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = plat.base_url .. "/login/points/grant", method = "POST", headers = { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }, body = "{}", @@ -9215,7 +9225,7 @@ function plugin.deps_status(req) -- 安装入口:发行包自带脚本(识别 apt/dnf/yum/apk),插件**不执行**它 -- (装系统包需要 root,且 chromium 是安全面较大的组件,不该由插件代劳)。 install_hint = d.ok and "" or - "sudo bash /usr/share/llmsproxy/install-browser-stack.sh" + "sudo bash /usr/share/modelrouter/install-browser-stack.sh" .. " 或沿源码部署时: sudo bash packaging/install-browser-stack.sh" .. "(脚本会自动识别 apt/dnf/yum/apk;也可手工装 Xvfb x11vnc websockify noVNC chromium python3-websocket)", } } @@ -9330,7 +9340,7 @@ function plugin.__add_source_for_test(args) for _, m in ipairs(args.models or {}) do if type(m) == "table" then ms[#ms + 1] = m else ms[#ms + 1] = { id = m } end end - local r, err = __llmsproxy_kernel.call("add_source", { + local r, err = modelrouter.kernel.call("add_source", { source = { name = args.name, base_url = args.base_url, adapter = args.adapter, models = ms, @@ -9384,7 +9394,7 @@ function plugin.reap_orphan_sources(req) if dry == nil then dry = true end if dry ~= false then dry = true end - local ls = __llmsproxy_kernel.call("list_sources", {}) + local ls = modelrouter.kernel.call("list_sources", {}) local arr = ls and ls.json or ls if type(arr) ~= "table" then arr = {} end @@ -9423,7 +9433,7 @@ function plugin.reap_orphan_sources(req) if plat and why then found[#found + 1] = nm if not dry then - local r, err = __llmsproxy_kernel.call("remove_source", { name = nm }) + local r, err = modelrouter.kernel.call("remove_source", { name = nm }) if r == nil or r.ok ~= true then failed[#failed + 1] = nm .. ": " .. tostring(err or "(无错误信息)") logEvent("孤儿源清理失败 " .. nm .. ": " .. tostring(err or "(无错误信息)"), @@ -9516,7 +9526,7 @@ function plugin.inject_platforms(req) end function plugin.status_page(req) - local r = __llmsproxy_kernel.call("status", {}) + local r = modelrouter.kernel.call("status", {}) -- accounts 必须**合并账号池**:只回 state.accounts(旧单账号表)时, -- 通过账号池接入的平台在这里是空的,UI 的状态页于是显示"没有账号", -- 与 platforms 的 logged_in 一样会让人误判成"渠道不可用" @@ -9609,7 +9619,7 @@ function plugin.oauth_callback(req) if type(plat.callback_headers) == "table" then for k, v in pairs(plat.callback_headers) do hdrs[k] = v end end - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = cb.token_url, method = "POST", headers = hdrs, @@ -9735,7 +9745,7 @@ traeCallback = function(plat, q, s) local body = (plat.callback.exchange_body :gsub("{client_id}", plat.callback.client_id or "") :gsub("{refresh_token}", refreshToken)) - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = plat.callback.exchange_url, method = "POST", headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }, @@ -10599,7 +10609,7 @@ function claimLoomy(plat, tok, args) local base = (cred and (cred.api_base or cred.apiBase)) or "https://loomy.xunfei.cn" local root = base:gsub("/chat/completions$", "") local url = root .. "/points/first-login" - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = url, method = "POST", headers = { ["Authorization"] = "Bearer " .. tok, @@ -10681,7 +10691,7 @@ local function zcodeClaimablePlans(cred) local av = tostring(cred.app_version or "1.0.0") local url = plugin.K.ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview" .. "?app_version=" .. rfc3986Escape(av) .. "&platform=win32" - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = url, method = "GET", headers = zcodeHeaders(cred, { authorization = "Bearer " .. tostring(cred.zcode_jwt or cred.access_token or ""), @@ -10708,7 +10718,7 @@ function claimZcode(plat, tok, args) if not cred.zcode_jwt and not cred.access_token then cred.zcode_jwt = tok end -- ① 补激活上报(照参考:不需要 Authorization) - __llmsproxy_http({ + modelrouter.http({ 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") }), @@ -10737,7 +10747,7 @@ function claimZcode(plat, tok, args) local granted, failedMsg = 0, nil for _, planId in ipairs(plans) do - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = plugin.K.ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST", headers = zcodeHeaders(cred, { authorization = "Bearer " .. tostring(jwt), @@ -10809,7 +10819,7 @@ function claimRaccoon(plat, tok, args) if cred and cred.device_id and cred.device_id ~= "" then h["X-Device-Id"] = cred.device_id end - local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = h, body = "{}" }) + local resp, err = modelrouter.http({ url = url, method = "POST", headers = h, body = "{}" }) if err or not resp then return { done = true, kind = "failed", message = "领取请求失败: " .. tostring(err) } @@ -10835,13 +10845,13 @@ end -- installRaccoonAdapter local function installTraeAdapter() - if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + if not modelrouter.adapters then return false, "无适配器桥(旧内核?)" end -- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。 local f, lerr = load(plugin.K.TRAE_SOLO_ADAPTER_SRC) if not f then return false, "源码语法错误: " .. tostring(lerr) end - local _, uerr = __llmsproxy_adapters.update("channels-trae", plugin.K.TRAE_SOLO_ADAPTER_SRC) + local _, uerr = modelrouter.adapters.update("channels-trae", plugin.K.TRAE_SOLO_ADAPTER_SRC) if uerr then - local _, ierr = __llmsproxy_adapters.install("channels-trae", plugin.K.TRAE_SOLO_ADAPTER_SRC) + local _, ierr = modelrouter.adapters.install("channels-trae", plugin.K.TRAE_SOLO_ADAPTER_SRC) if ierr then return false, ierr end end return true, nil @@ -11590,8 +11600,8 @@ local EXPECTED_MIN_CHARS = 2800 -- ============================================================================ -- ★★ 基座由**插件主体内联**进来(见下方 §基座占位符), -- **不能**在适配器里自己读文件。实测(2026-10-16): --- 适配器运行在**另一个沙箱 VM**,那里 `__llmsproxy_plugin_dir` 与 --- `LLMSPROXY_PLUGIN_DIR` **都不存在**,**也没有文件系统访问** +-- 适配器运行在**另一个沙箱 VM**,那里 `modelrouter.plugin_dir` 与 +-- `MODELROUTER_PLUGIN_DIR` 与环境变量 **都不存在**,**也没有文件系统访问** -- ⇒ 原先那段"就地读 anthropic.lua"必然失败且**静默降级**: -- 适配器照常装载,但只有它自己定义的 2 个函数, -- 缺 transform_response / transform_stream_chunk 等 **7 个**。 @@ -11647,7 +11657,7 @@ local function buildEnvironmentSection(cwd, provider, model, platform) if type(cwd) ~= "string" or cwd:gsub("%s+", "") == "" then cwd = "." end local plat = platform or "linux" local shell = (plat == "win32") and "powershell" or "bash" - local arch = (os.getenv and os.getenv("LLMSPROXY_ARCH")) or "x64" + local arch = (os.getenv and (os.getenv("MODELROUTER_ARCH") or os.getenv("LLMSPROXY_ARCH"))) or "x64" return table.concat({ "# Environment", "You have been invoked in the following environment:", @@ -11945,24 +11955,24 @@ return adapter -- 与 trae/gemini 同一机制(插件自装、厂商知识留在插件域)。 -- installClineAdapter():装/更新本插件自己的 Cline 适配器。 local function installClineAdapter() - if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + if not modelrouter.adapters then return false, "无适配器桥(旧内核?)" end local f, lerr = load(plugin.K.CLINE_ADAPTER_SRC) if not f then return false, "源码语法错误: " .. tostring(lerr) end - local _, uerr = __llmsproxy_adapters.update("channels-cline", plugin.K.CLINE_ADAPTER_SRC) + local _, uerr = modelrouter.adapters.update("channels-cline", plugin.K.CLINE_ADAPTER_SRC) if uerr then - local _, ierr = __llmsproxy_adapters.install("channels-cline", plugin.K.CLINE_ADAPTER_SRC) + local _, ierr = modelrouter.adapters.install("channels-cline", plugin.K.CLINE_ADAPTER_SRC) if ierr then return false, ierr end end return true, nil end local function installQoderAdapter() - if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + if not modelrouter.adapters then return false, "无适配器桥(旧内核?)" end local f, lerr = load(plugin.K.QODER_ADAPTER_SRC) if not f then return false, "源码语法错误: " .. tostring(lerr) end - local _, uerr = __llmsproxy_adapters.update("channels-qoder", plugin.K.QODER_ADAPTER_SRC) + local _, uerr = modelrouter.adapters.update("channels-qoder", plugin.K.QODER_ADAPTER_SRC) if uerr then - local _, ierr = __llmsproxy_adapters.install("channels-qoder", plugin.K.QODER_ADAPTER_SRC) + local _, ierr = modelrouter.adapters.install("channels-qoder", plugin.K.QODER_ADAPTER_SRC) if ierr then return false, ierr end end return true, nil @@ -11970,14 +11980,14 @@ end -- installGeminiAdapter():装/更新本插件自己的 Gemini 私有协议适配器。 local function installGeminiAdapter() - if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + if not modelrouter.adapters then return false, "无适配器桥(旧内核?)" end -- 先在本地编译一次,把语法错误在**装载失败之前**变成可读信息 -- (load 失败只给"源码无法编译",不给行号) local f, lerr = load(plugin.K.GEMINI_PRIVATE_ADAPTER_SRC) if not f then return false, "源码语法错误: " .. tostring(lerr) end - local _, uerr = __llmsproxy_adapters.update("channels-gemini", plugin.K.GEMINI_PRIVATE_ADAPTER_SRC) + local _, uerr = modelrouter.adapters.update("channels-gemini", plugin.K.GEMINI_PRIVATE_ADAPTER_SRC) if uerr then - local _, ierr = __llmsproxy_adapters.install("channels-gemini", plugin.K.GEMINI_PRIVATE_ADAPTER_SRC) + local _, ierr = modelrouter.adapters.install("channels-gemini", plugin.K.GEMINI_PRIVATE_ADAPTER_SRC) if ierr then return false, ierr end end return true, nil @@ -11992,7 +12002,7 @@ end -- dirOverride 仅用于测试/诊断:强制指定适配器目录,让"基座不可得" -- 成为**安装期可观测**的失败(否则它只会在运行期逐帧炸,且原因指向内核)。 local function installZcodeAdapter(dirOverride) - if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + if not modelrouter.adapters then return false, "无适配器桥(旧内核?)" end -- ★ 读到 anthropic 基座源码并在**插件主体**内联进适配器源码。 -- ⚠ 不能在适配器里自己读文件:适配器沙箱没有 fs、也没有 plugin_dir, -- 那样会**静默降级**成"只有自己定义的 2 个函数"(见适配器顶部注释)。 @@ -12018,9 +12028,9 @@ local function installZcodeAdapter(dirOverride) full = full:sub(1, qi - 1) .. string.format("%q", asrc) .. full:sub(qi + #ph) local f, lerr = load(full) if not f then return false, "源码语法错误: " .. tostring(lerr) end - local _, uerr = __llmsproxy_adapters.update("channels-zcode", full) + local _, uerr = modelrouter.adapters.update("channels-zcode", full) if uerr then - local _, ierr = __llmsproxy_adapters.install("channels-zcode", full) + local _, ierr = modelrouter.adapters.install("channels-zcode", full) if ierr then return false, ierr end end return true, nil @@ -12030,13 +12040,13 @@ end -- 幂等(update 失败时退化为 install);失败只记日志,不阻断插件加载 —— -- 适配器装不上时源注册会报"未知适配器",比让整个插件加载失败更好定位。 local function installCodeartsAdapter() - if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + if not modelrouter.adapters then return false, "无适配器桥(旧内核?)" end -- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。 local f, lerr = load(plugin.K.CODEARTS_ADAPTER_SRC) if not f then return false, "源码语法错误: " .. tostring(lerr) end - local _, uerr = __llmsproxy_adapters.update("codearts", plugin.K.CODEARTS_ADAPTER_SRC) + local _, uerr = modelrouter.adapters.update("codearts", plugin.K.CODEARTS_ADAPTER_SRC) if uerr then - local _, ierr = __llmsproxy_adapters.install("codearts", plugin.K.CODEARTS_ADAPTER_SRC) + local _, ierr = modelrouter.adapters.install("codearts", plugin.K.CODEARTS_ADAPTER_SRC) if ierr then return false, ierr end end return true, nil @@ -12664,7 +12674,7 @@ plugin.ui = { -- plugin.__store_dir_probe:只给测试用 —— 报告本插件实际使用的存储目录。 -- 测试污染的排查完全依赖它:storeDir() 有多级兜底(env → settings → 读生产 --- config.yaml → 硬编码 /etc/llmsproxy/plugins),写错一级就会让测试**写进生产**。 +-- config.yaml → 硬编码 /etc/modelrouter/plugins),写错一级就会让测试**写进生产**。 function plugin.__store_dir_probe() return { json = { store_dir = storeDir() } } end @@ -12875,7 +12885,7 @@ function plugin.__set_pool_cred_probe(args) 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 "")) + local _, serr = modelrouter.secrets.set(ref, tostring(args.credential or "")) if serr then return { json = { error = serr } } end return { json = { ok = true, ref = ref } } end @@ -12883,7 +12893,7 @@ 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 "")) + modelrouter.secrets.set(ref, tostring(args.credential or "")) return { json = { ok = true, ref = ref } } end @@ -12891,14 +12901,14 @@ end -- 判据靠它证明"接管没有把凭据二次加密"。 function plugin.__read_cred_probe(args) args = args or {} - local v = __llmsproxy_secrets.get(tostring(args.ref or "")) + local v = modelrouter.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 + local ks = modelrouter.secrets.keys and modelrouter.secrets.keys() or {} + for _, k in ipairs(ks) do modelrouter.secrets.delete(k) end return { json = { ok = true } } end @@ -13003,7 +13013,7 @@ function plugin.__inject_refresh_platform_probe(args) -- 建账号(凭据走保险库)。expiresAt 从凭据的 expires_at 派生(毫秒→秒), -- 供"账号已过期"类判据与 account_list 展示;解析失败保持 0。 local ref = "channels:" .. id - __llmsproxy_secrets.set(ref, tostring(args.credential or "{}")) + modelrouter.secrets.set(ref, tostring(args.credential or "{}")) local expAt = 0 local dok, dcred = pcall(json.decode, tostring(args.credential or "{}")) if dok and type(dcred) == "table" then @@ -13252,7 +13262,7 @@ end -- 顺序就是内核实际尝试的顺序(candidates 按注册序),故“重排是否生效”必须看它, -- 而不是看 pool 里的 priority 字段(改了字段不代表路由变了)。 function plugin.__list_sources_probe() - local ls = __llmsproxy_kernel.call("list_sources", {}) + local ls = modelrouter.kernel.call("list_sources", {}) -- ⚠ 内核 actionListSources 返回的是**裸数组**(不是 {sources:[...]})—— -- 我第一版按后者取,于是恒得空列表(判据因此报"应有 2 个源,实得 []")。 -- 两种形态都兜住,免得内核换个包装就失效。 @@ -13321,7 +13331,7 @@ function plugin.__trae_catalog_probe(args) local cred for _, a in ipairs(poolList("trae")) do if a.enabled ~= false then - local raw = __llmsproxy_secrets.get(a.credentialRef) + local raw = modelrouter.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 @@ -13349,7 +13359,7 @@ function plugin.__trae_catalog_probe(args) h["X-App-Version-Code"] = args.ide_version h["X-Ide-Version-Code"] = args.ide_version end - local resp, err = __llmsproxy_http({ + local resp, err = modelrouter.http({ url = (type(args.url) == "string" and args.url ~= "" and args.url) or ep.url, method = ep.method or "POST", headers = h, body = (type(args.body) == "string" and args.body ~= "" and args.body) or ep.body or "{}", @@ -13498,12 +13508,12 @@ function plugin.__sync_source_probe(args) 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 raw = modelrouter.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 ls = modelrouter.kernel.call("list_sources", {}) local found, ad = false, nil -- 一通道一源之后,同步的目标是**通道级**源名;account_id 只用于选号。 local want = providerSourceName(args.platform) @@ -13532,7 +13542,7 @@ function plugin.__sync_source_probe(args) 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 }) + local rr, rerr = modelrouter.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, diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 0be679c..10ef8c1 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -34,7 +34,7 @@ -- endpoint" — a 401 on a key we hold, say — are surfaced, because those are the -- ones worth fixing. -- --- Only hosts of CONFIGURED SOURCES are reachable (__llmsproxy_http enforces +-- Only hosts of CONFIGURED SOURCES are reachable (modelrouter.http enforces -- that), so adding a probe never widens what the gateway can talk to. local PLUGIN = {} @@ -127,7 +127,7 @@ local function request(path, key, headers, tries) hdrs["Authorization"] = "Bearer " .. tostring(key or "") local lastErr = "no attempt" for _ = 1, tries do - local res, err = __llmsproxy_http({ + local res, err = modelrouter.http({ -- 4s, not 10s: probing is network-bound and the whole page shares ONE -- Lua state under a write lock, so a slow upstream is not just a slow card -- — it stalls every request_end hook (i.e. billing) behind it. @@ -540,6 +540,111 @@ local function commandCode(base, key) return rep, nil end +-- openCodeFreePool:OpenCode 免费池的额度监控。 +-- +-- 为什之不能像其他上游那样“查余额”:OpenCode Zen **没有**额度查询端点。 +-- 额度耗尽的唯一表现是真实推理请求返回 429: +-- +-- HTTP 429 +-- Retry-After: 80867 +-- {"type":"error","error":{"type":"FreeUsageLimitError", +-- "message":"Free usage limit reached. Subscribe to OpenCode Go to keep going: ..."}} +-- +-- 恰好就是这个响应把“还剩多少”变成了可监控的事实 —— `Retry-After` 就是 +-- 距离额度恢复的秒数。不对自己凭空造一个“余额百分比”:那个数上游从未提供过, +-- 和本插件开头“宁可不说也不要编”的口径直接冲突。 +-- +-- ★ 代价控制:这是**发一次真实推理请求**去探额度(没有别的办法)。所以 +-- ① 只探一个固定极小请求(max_tokens=1,单字 prompt),便宜到可以忽略; +-- ② 只在免费池**未确认耗尽**时才探(耗尽后 Retry-After 已经告诉我们恢复时刻, +-- 再去探就是无意义的敲上游); +-- ③ 探针失败不影响真实请求:它只读 429 的头部,不消费额度窗口里的并发。 +-- +-- ⚠️ 免费层有身份门禁:缺 `x-opencode-*` 头会得到 403 FreeTierError, +-- 而 403 不是额度事实。那些头由适配器生成(公式在 opencodezen.lua), +-- 本插件不重复实现一套 —— 两处各写一份必然漂移。因此本探针只负责 +-- “带上源自己的凭据去问一次”,身份头由源的 adapter 负责。 +-- ⇒ 真正稳定的做法是看**真实请求**的结果;本探针只是让“配额还剩多久” +-- 在没有真实流量时也能被看到。 +local FREE_POOL_HINT = "free" + +local function isFreePoolModel(id) + return type(id) == "string" and id:find(FREE_POOL_HINT, 1, true) ~= nil +end + +local function openCodeFreePool(base, key, src) + -- 只对一个免费模型探一次,避免把目录里每个 free 模型都打一遍。 + local probeModel + for _, m in ipairs((src and src.models) or {}) do + if isFreePoolModel(m) then probeModel = m break end + end + if not probeModel then return nil, "skip", "no free model configured" end + + local url = base .. "/chat/completions" + local res, err = modelrouter.http({ + url = url, method = "POST", + headers = { + ["Authorization"] = "Bearer " .. tostring(key or ""), + ["Content-Type"] = "application/json", + ["Accept"] = "text/event-stream", + }, + body = json.encode({ + model = probeModel, + messages = { { role = "user", content = "hi" } }, + stream = true, + max_tokens = 1, + }), + timeout_ms = 6000, + }) + if res == nil then return nil, "error", tostring(err or "request failed") end + + local hdr = res.headers or {} + local function h(name) + local v = hdr[name] + if v == nil then v = hdr[string.lower(name)] end + return v + end + + if res.status == 429 then + local retry = tonumber(h("Retry-After") or h("retry-after")) + local ok, body = pcall(json.decode, res.body or "") + local etype = ok and type(body) == "table" and get(body, "error.type") or nil + return { + vendor = "opencode-free", + free_pool = true, + -- 额度耗尽是一个**明确的运营事实**,必须渲染成“不可用”, + -- 而不是“某次请求失败了”:它不是故障,是配额用完了。 + available = false, + exceeded = true, + reason = (etype == "FreeUsageLimitError") and "免费池额度已用尽" + or "HTTP 429", + -- 恢复时刻:上游直接告诉我们了,不要自己猜一个退避。 + retry_after_s = retry, + resume_at = retry and (os.time() + retry) or nil, + probe_model = probeModel, + }, nil + end + + if res.status == 403 then + -- 403 是身份/网络门禁,**不是**额度事实。两者必须分开报, + -- 否则会把“IP 被风控”误读成“额度用完了”。 + return nil, "error", "HTTP 403(免费层身份被拒,非额度问题)" + end + + if res.status >= 200 and res.status < 300 then + return { + vendor = "opencode-free", + free_pool = true, + available = true, + exceeded = false, + reason = "免费池可用", + probe_model = probeModel, + }, nil + end + + return nil, "error", "HTTP " .. tostring(res.status) +end + -- Ordered probes. DeepSeek-family first because it is a single cheap endpoint, -- then the OpenAI console shape, then Command Code's four. local ADAPTERS = { @@ -547,6 +652,10 @@ local ADAPTERS = { { vendor = "deepseek", probe = deepseekBalance }, { vendor = "openai-dash", probe = openaiDashboard }, { vendor = "opendesign", probe = openDesign }, + -- ★ OpenCode 免费池:**没有**额度查询端点,只能靠一次真实请求的 429 + -- 与 Retry-After 判断。放最后:其他适配器能答就不用花这次请求。 + -- 只对含 free 模型的源生效(probe 内部自行判断)。 + { vendor = "opencode-free", probe = openCodeFreePool }, } -- The probe memo remembers, per host, which adapter answered. Persisted in the @@ -722,7 +831,7 @@ local function collectSource(src, budget) -- genuinely unreachable case; a reachable host still goes through the -- adapters. if known == false then - local probe = __llmsproxy_http({ + local probe = modelrouter.http({ url = base .. "/", method = "GET", timeout_ms = 2500, headers = { ["Authorization"] = "Bearer " .. tostring(key or "") }, }) @@ -764,13 +873,30 @@ local function collectSource(src, budget) kind = "skip" why = because or ("adapter not configured: " .. a.vendor) else - local r, k, e = a.probe(base, key) + local r, k, e = a.probe(base, key, src) if r ~= nil then rep = r HOST_MEMO[host] = a.vendor break end - kind, why = k, e + -- ★ 不相关适配器的 skip **不得**覆盖已有原因。 + -- + -- 真实回归(加 OpenCode 免费池探测时引入):opendesign 先说 + -- 「缺 control_key」,紧随其后的 opencode-free 探测因「本源没有 free 模型」 + -- 而 skip,用的是无条件 `kind, why = k, e`,于是把那条可操作的原因换成了 + -- 「no free model configured」—— 运维看到的是“这个源没有免费模型”, + -- 而实际要去配的是 control_key。判据 TestQuotaOpenDesignSkipsWithoutControlKey + -- 直接咬住了它。 + -- + -- 规则:一个适配器**仅因自身不适用**(本源没有它关心的模型)而 skip 时, + -- 不得抹掉别人给出的原因;真错误仍按原逻辑进入 errWhy。 + local notApplicable = k == "skip" and type(e) == "string" + and e:find("no free model configured", 1, true) ~= nil + if not notApplicable then + kind, why = k, e + elseif why == nil then + kind, why = k, e + end if k == "error" then local rejected = tostring(e or ""):match("rejected") ~= nil -- Last error wins, except that a rejected credential displaces anything diff --git a/internal/lua/plugins/usage.lua b/internal/lua/plugins/usage.lua index 54e12c5..7c3e97d 100644 --- a/internal/lua/plugins/usage.lua +++ b/internal/lua/plugins/usage.lua @@ -82,7 +82,7 @@ local function selfStats(key) end local url = "http://127.0.0.1:8081/api/stats?period=" .. PERIOD .. "&limit=1&key=" .. maskedKey(key) - local r = __llmsproxy_http({ + local r = modelrouter.http({ url = url, method = "GET", timeout_ms = 4000, diff --git a/internal/lua/quota_backoff_test.go b/internal/lua/quota_backoff_test.go index 6432b5d..b526b62 100644 --- a/internal/lua/quota_backoff_test.go +++ b/internal/lua/quota_backoff_test.go @@ -298,11 +298,11 @@ func TestQuotaMemoKeyIsTheHost(t *testing.T) { // that actually ships rather than a copy that can drift from it. func readShippedPlugin(t *testing.T, name string) string { t.Helper() - b, err := bundledAdapters.ReadFile("plugins/" + name) + src, err := ReadBundledPlugin(strings.TrimSuffix(name, ".lua")) if err != nil { - t.Fatalf("read embedded %s: %v", name, err) + t.Fatalf("read repo plugin %s: %v", name, err) } - return string(b) + return src } // quotaWithDecision loads quota.lua with its local backoffSeconds exposed as an diff --git a/internal/lua/store_dir_isolation_test.go b/internal/lua/store_dir_isolation_test.go index 69b4d21..2c22102 100644 --- a/internal/lua/store_dir_isolation_test.go +++ b/internal/lua/store_dir_isolation_test.go @@ -9,22 +9,22 @@ import ( // **测试绝不允许写生产存储** —— 这条是真实事故的回归判据。 // // 事故:channels 插件用 storeDir() 决定自己的存储位置,而 storeDir 有多级兜底: -// 环境变量 → settings → **读 /etc/llmsproxy/config.yaml 的 plugin_dir** → -// 硬编码 /etc/llmsproxy/plugins。测试台没设环境变量时,后两级直接命中**生产**: +// 环境变量 → settings → **读 /etc/modelrouter/config.yaml 的 plugin_dir** → +// 硬编码 /etc/modelrouter/plugins。测试台没设环境变量时,后两级直接命中**生产**: // // 测试插件目录 = /tmp/TestXXX/002/plugins -// storeDir 结果 = /etc/llmsproxy/plugins ← 实测 +// storeDir 结果 = /etc/modelrouter/plugins ← 实测 // // 于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。一次全量 // `go test ./internal/lua` 就把生产账号池覆盖成测试的空 pool: -// /etc/llmsproxy/plugins/.channels-store.json 从 1730 变 1986 字节, +// /etc/modelrouter/plugins/.channels-store.json 从 1730 变 1986 字节, // 13 个账号变成 0 个(凭据还留在保险库,所以症状是"账号凭空消失")。 // // 同类前科(本文件存在的另一半理由):曾经有 26 个测试账号被写进生产 // .channels-store.json。两次都是"测试没隔离存储目录",所以这条判据必须存在。 func TestStoreDirIsolatedInTests(t *testing.T) { _, ps, pdir := runtimePlugins(t) - src, err := bundledAdapters.ReadFile("plugins/channels.lua") + src, err := ReadBundledPlugin("channels") if err != nil { t.Fatal(err) } @@ -45,17 +45,17 @@ func TestStoreDirIsolatedInTests(t *testing.T) { t.Logf("storeDir 结果 = %s", got) // ① 绝不能是生产目录 - if got == "/etc/llmsproxy/plugins" || strings.HasPrefix(got, "/etc/llmsproxy") { + if got == "/etc/modelrouter/plugins" || strings.HasPrefix(got, "/etc/llmsproxy") { t.Fatalf("storeDir 指向了**生产**目录 %q —— 测试会覆盖生产账号池\n"+ "(真实事故:13 个账号被覆盖成 0 个)。修法:runtimePlugins 设\n"+ - "LLMSPROXY_PLUGIN_DIR 到 t.TempDir()", got) + "MODELROUTER_PLUGIN_DIR 到 t.TempDir()", got) } // ② 必须落在测试的临时目录里 if !strings.Contains(got, pdir) && !strings.HasPrefix(got, os.TempDir()) { t.Errorf("storeDir 应落在临时目录(%s 或 %s),实得 %q", pdir, os.TempDir(), got) } // ③ 环境变量必须真的被设上(机制而非巧合) - if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" { - t.Error("runtimePlugins 必须设 LLMSPROXY_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)") + if os.Getenv("MODELROUTER_PLUGIN_DIR") == "" { + t.Error("runtimePlugins 必须设 MODELROUTER_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)") } } diff --git a/internal/lua/vm.go b/internal/lua/vm.go index 01c770a..c0705aa 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -26,6 +26,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "sort" "strconv" "strings" @@ -37,7 +38,7 @@ import ( golua "github.com/aarzilli/golua/lua" ) -//go:embed adapters/*.lua plugins/*.lua +//go:embed adapters/*.lua var bundledAdapters embed.FS // adapterGlobal is the reserved global holding the adapter table after the @@ -732,64 +733,24 @@ func (v *VM) Start() error { return nil } -// bundledPluginsDir is the directory inside the embedded FS holding the -// plugins shipped with the gateway. It is separate from adapters/ on purpose: -// the two are loaded by different machinery into different kinds of Lua state -// (a protocol transform vs. request-pipeline hooks), and keeping them apart -// makes it obvious that dropping a file in one does not affect the other. -const bundledPluginsDir = "plugins" - -// ReadBundledPlugin returns the source of a plugin shipped with the gateway. -// It exists so a test (or an operator tool) can load a bundled plugin without -// depending on whether seeding has already run for this directory. +// ReadBundledPlugin is retained only for source-level test fixtures and +// development tooling. It resolves relative to this source file, not the +// process working directory, and is never used by production plugin loading. func ReadBundledPlugin(name string) (string, error) { - data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + name + ".lua") + _, file, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("locate plugin source %s", name) + } + data, err := os.ReadFile(filepath.Join(filepath.Dir(file), "plugins", name+".lua")) if err != nil { - return "", fmt.Errorf("bundled plugin %s: %w", name, err) + return "", fmt.Errorf("plugin source %s: %w", name, err) } return string(data), nil } -// writeBundledPlugins seeds the plugin directory with the shipped plugins. -// -// It runs only when the directory does not exist yet (same rule as adapters): -// once the directory exists it is authoritative, so deleting a shipped plugin is -// a real delete and editing one survives restarts. -func writeBundledPlugins(dir string) error { - if dir == "" { - return nil - } - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("mkdir plugin dir: %w", err) - } - entries, err := bundledAdapters.ReadDir(bundledPluginsDir) - if err != nil { - return nil // nothing embedded; not an error - } - for _, e := range entries { - if e.IsDir() || filepath.Ext(e.Name()) != ".lua" { - continue - } - dst := filepath.Join(dir, e.Name()) - // Per-file seeding: a plugin already present is left ALONE. That is what - // makes an operator's own copy (or a locally edited one) survive an - // upgrade, and it is also why a newly embedded plugin still arrives — - // absence of THAT file, not of the directory, is the trigger. - if _, err := os.Stat(dst); err == nil { - continue - } else if !os.IsNotExist(err) { - return err - } - data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + e.Name()) - if err != nil { - continue - } - if err := os.WriteFile(dst, data, 0644); err != nil { - return err - } - } - return nil -} +// writeBundledPlugins is intentionally a no-op: plugins are independent optional +// files and are never seeded by the gateway. +func writeBundledPlugins(string) error { return nil } func (v *VM) Stop() { select { diff --git a/internal/lua/vm_test.go b/internal/lua/vm_test.go index e3c4702..8234c12 100644 --- a/internal/lua/vm_test.go +++ b/internal/lua/vm_test.go @@ -1869,20 +1869,16 @@ func TestAnthropicUsageCacheMapping(t *testing.T) { } } -// TestSeedBundledFillsMissingFilesInExistingDir 是真实事故的回归测试。 +// TestSeedBundledNeverWritesFiles 钉住"插件不由网关安装"这条契约。 // -// 事故:SeedBundled 原本"目录已存在就什么都不做"。生产 plugin_dir 从首次安装 -// 起就存在,于是后来加入二进制的新内置插件(channels.lua)永远不会落盘 —— -// 它在 go:embed 里、却完全不出现在 /api/plugins(线上只加载 billing/quota/usage)。 -// -// 本测试构造那个确切条件:目录已存在且只装了部分内置插件,断言缺失的被补齐、 -// 已存在的(模拟运维自定义)**不被覆盖**。 -func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) { +// 历史:SeedBundled 曾把 go:embed 的 billing/channels/quota/usage 写进 +// plugin_dir,于是插件与 Go 二进制耦合、升级还会覆盖或删除运维文件。 +// 现在插件是纯外置资产:网关不创建、不补齐、不覆盖,只加载目录里已有的文件。 +func TestSeedBundledNeverWritesFiles(t *testing.T) { dir := filepath.Join(t.TempDir(), "plugins") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } - // 模拟:目录已存在,里面只有运维自己的一份 billing.lua(内容故意不同) const customBilling = "-- operator's own billing plugin\nreturn { name='billing', version='9.9.9' }" if err := os.WriteFile(filepath.Join(dir, "billing.lua"), []byte(customBilling), 0o644); err != nil { t.Fatal(err) @@ -1896,33 +1892,29 @@ func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) { ps := NewPlugins(vm, dir) if err := ps.SeedBundled(); err != nil { - t.Fatalf("seed: %v", err) + t.Fatalf("SeedBundled must be a no-op, got: %v", err) } - // ① 已存在的运维文件不被覆盖 + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "billing.lua" { + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + t.Fatalf("网关不得写入任何插件文件, 目录应为 [billing.lua], 实得 %v", names) + } got, err := os.ReadFile(filepath.Join(dir, "billing.lua")) if err != nil { t.Fatal(err) } if string(got) != customBilling { - t.Errorf("运维自己的 billing.lua 被覆盖了 —— 升级会抹掉运维的改动") + t.Error("运维自己的 billing.lua 被改写了") } - // ② 每个内置插件现在都在目录里(这正是 channels.lua 缺失的那个洞) - entries, err := bundledAdapters.ReadDir(bundledPluginsDir) - if err != nil { - t.Fatal(err) - } - for _, e := range entries { - if e.IsDir() || filepath.Ext(e.Name()) != ".lua" { - continue - } - if _, err := os.Stat(filepath.Join(dir, e.Name())); err != nil { - t.Errorf("内置插件 %s 未被补齐(目录已存在时被跳过)", e.Name()) - } - } - - // ③ 补齐后确实能被加载(channels 必须真的出现在插件表里) + // 手工放入的插件仍然可以加载 —— 外置不等于不可用。 if err := ps.LoadDir(); err != nil { t.Fatal(err) } @@ -1930,35 +1922,7 @@ func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) { for _, p := range ps.List() { names[p["name"].(string)] = true } - if !names["channels"] { - t.Errorf("channels 插件应被 seed 并加载, 实得 %v", names) - } -} - -// TestSeedBundledIdempotent 二次调用不应改变已就位的文件。 -func TestSeedBundledIdempotent(t *testing.T) { - dir := filepath.Join(t.TempDir(), "plugins") - vm := NewVM(filepath.Join(t.TempDir(), "adapters")) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - ps := NewPlugins(vm, dir) - - if err := ps.SeedBundled(); err != nil { - t.Fatal(err) - } - before, _ := os.ReadFile(filepath.Join(dir, "channels.lua")) - // 运维改一笔 - edited := append(before, []byte("\n-- operator edit\n")...) - if err := os.WriteFile(filepath.Join(dir, "channels.lua"), edited, 0o644); err != nil { - t.Fatal(err) - } - if err := ps.SeedBundled(); err != nil { - t.Fatal(err) - } - after, _ := os.ReadFile(filepath.Join(dir, "channels.lua")) - if string(after) != string(edited) { - t.Error("二次 seed 覆盖了已存在的文件") + if !names["billing"] { + t.Errorf("plugin_dir 中手工安装的插件应被加载, 实得 %v", names) } }