mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(lua)!: 插件只通过 modelrouter SDK 与内核交互,且不再随内核安装
- 新增唯一公开接口表 `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 改名同处一个文件,无法文件级拆分,故一并纳入。
This commit is contained in:
@ -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).
|
// is unit-testable with plain node).
|
||||||
//
|
//
|
||||||
// Two problems this solves, both invisible from the gateway side:
|
// The desktop may configure an operator-selected plugin directory, but it never
|
||||||
// 1. The core deliberately does NOT default `plugin_dir` (an operator opts
|
// owns the files in that directory. In particular, upgrades must not seed,
|
||||||
// in). A desktop profile without it runs with ZERO plugins — the channels
|
// replace, rename, or delete plugins. Plugin installation is an explicit
|
||||||
// plugin (account login, credential vault, auto check-in) never appears.
|
// operator action (or an external package manager action).
|
||||||
// 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.
|
|
||||||
const fs = require("fs");
|
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.
|
// ensurePluginDirConfigured back-fills `plugin_dir` into an existing config.
|
||||||
// Appending a top-level YAML key is safe for the shapes this file can have
|
// 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(
|
fs.writeFileSync(
|
||||||
configFile,
|
configFile,
|
||||||
cur.replace(/\s*$/, "") +
|
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",
|
"plugin_dir: " + JSON.stringify(pluginsDir) + "\n",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
@ -37,46 +30,4 @@ function ensurePluginDirConfigured(configFile, pluginsDir, log) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncBundledPluginsOnUpgrade drops built-in plugin files when the app version
|
module.exports = { ensurePluginDirConfigured };
|
||||||
// changed, so the core reseeds them from the binary it shipped with. Returns
|
|
||||||
// { changed, dropped:[...], kept: "prev-<ver>" }.
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,26 +1,22 @@
|
|||||||
// Unit tests for the desktop plugin-tree maintenance (plain node, no Electron).
|
// Unit tests for desktop plugin-directory configuration (plain node).
|
||||||
// Run: node --test cmd/gui/plugin-sync.test.js
|
|
||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
const path = require("path");
|
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-"));
|
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "plugin-sync-"));
|
||||||
|
|
||||||
test("back-fills plugin_dir into an existing profile without it", () => {
|
test("back-fills plugin_dir into an existing profile without it", () => {
|
||||||
const dir = tmp();
|
const dir = tmp();
|
||||||
const cfg = path.join(dir, "config.yaml");
|
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");
|
const plugins = path.join(dir, "plugins");
|
||||||
assert.equal(ensurePluginDirConfigured(cfg, plugins, null), "added");
|
assert.equal(ensurePluginDirConfigured(cfg, plugins, null), "added");
|
||||||
const out = fs.readFileSync(cfg, "utf-8");
|
const out = fs.readFileSync(cfg, "utf-8");
|
||||||
assert.match(out, /^plugin_dir: "/m);
|
assert.match(out, /^plugin_dir: "/m);
|
||||||
assert.ok(out.includes(plugins.replace(/\\/g, "\\\\")) || out.includes(plugins), "path written");
|
assert.match(out, /^listen: 127.0.0.1:8787$/m);
|
||||||
// the rest of the profile must survive untouched
|
|
||||||
assert.match(out, /^listen: 127\.0\.0\.1:8787$/m);
|
|
||||||
assert.match(out, /^ - key: k$/m);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("leaves a profile that already sets plugin_dir alone", () => {
|
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);
|
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 dir = tmp();
|
||||||
const plugins = path.join(dir, "plugins");
|
const plugins = path.join(dir, "plugins");
|
||||||
fs.mkdirSync(plugins, { recursive: true });
|
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(plugins, "my-own.lua"), "MINE");
|
||||||
fs.writeFileSync(path.join(dir, ".plugin-seed-version"), "1.0.0\n");
|
// There is deliberately no version stamp and no upgrade synchronizer.
|
||||||
|
assert.equal(fs.readFileSync(path.join(plugins, "channels.lua"), "utf-8"), "OPERATOR FILE");
|
||||||
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");
|
|
||||||
}
|
|
||||||
assert.equal(fs.readFileSync(path.join(plugins, "my-own.lua"), "utf-8"), "MINE");
|
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");
|
assert.equal(fs.readdirSync(dir).includes(".plugin-seed-version"), false);
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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. 快速上手
|
## 1. 快速上手
|
||||||
|
|
||||||
一个最小的插件:
|
一个最小的插件:
|
||||||
@ -65,16 +96,14 @@ return plugin -- 必须返回一个 table
|
|||||||
core.New
|
core.New
|
||||||
└─ lua.NewVM(adapter_dir).Start() 适配器状态
|
└─ lua.NewVM(adapter_dir).Start() 适配器状态
|
||||||
└─ lua.NewPlugins(vm, plugin_dir)
|
└─ lua.NewPlugins(vm, plugin_dir)
|
||||||
├─ SeedBundled() 仅当目录不存在时写入内置插件(目前是 billing)
|
`plugin_dir` 中的 Lua 文件由网关逐个加载;安装包不携带业务插件,也不会自动创建或写入插件文件。首次启用时,先把需要的 `.lua` 插件放入该目录,再启动网关。
|
||||||
└─ LoadDir() 按文件名字典序逐个加载
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins`
|
**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins`
|
||||||
的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的
|
的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的
|
||||||
第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。
|
第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。
|
||||||
|
|
||||||
**目录一旦存在就是权威的。** 与适配器同规则:首启会 seed 内置插件,之后目录里
|
插件目录由运维独立管理。网关不会 seed、覆盖或删除其中的插件;目录缺失时只会跳过插件加载,不影响网关启动。
|
||||||
的文件说了算,删除或编辑内置插件都是真实生效的操作。
|
|
||||||
|
|
||||||
### 2.1 热更新
|
### 2.1 热更新
|
||||||
|
|
||||||
@ -294,7 +323,7 @@ PUT /api/plugins/{name}/state 替换状态(admin)
|
|||||||
"loaded": true
|
"loaded": true
|
||||||
}],
|
}],
|
||||||
"hook_errors": { "request_end": { "count": 3, "last_error": "billing: ..." } },
|
"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 并重写适配器
|
-- 每小时的定时任务:轮换 token 并重写适配器
|
||||||
function plugin.refresh_token()
|
function plugin.refresh_token()
|
||||||
local tok = fetch_new_token() -- 插件自己的取 token 逻辑
|
local tok = fetch_new_token() -- 插件自己的取 token 逻辑
|
||||||
local name, err = __llmsproxy_adapters.install(
|
local name, err = modelrouter.adapters.install(
|
||||||
"myauth",
|
"myauth",
|
||||||
'return { name = "myauth", endpoint = "/chat/completions",' ..
|
'return { name = "myauth", endpoint = "/chat/completions",' ..
|
||||||
' headers = { ["Authorization"] = "Bearer ' .. tok .. '" } }'
|
' headers = { ["Authorization"] = "Bearer ' .. tok .. '" } }'
|
||||||
@ -439,9 +468,9 @@ end
|
|||||||
|
|
||||||
| 调用 | 参数 | 返回 |
|
| 调用 | 参数 | 返回 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `__llmsproxy_adapters.install(name, source)` | 适配器名 + Lua 源码 | `(生效名, nil)` 或 `(nil, err)` |
|
| `modelrouter.adapters.install(name, source)` | 适配器名 + Lua 源码 | `(生效名, nil)` 或 `(nil, err)` |
|
||||||
| `__llmsproxy_adapters.update(name, source)` | 同上(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
|
| `modelrouter.adapters.update(name, source)` | 同上(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
|
||||||
| `__llmsproxy_adapters.remove(name)` | 适配器名(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
|
| `modelrouter.adapters.remove(name)` | 适配器名(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
|
||||||
|
|
||||||
出错时不抛异常,而是返回 `(nil, err)`——与钩子/HTTP 处理器同一套约定,
|
出错时不抛异常,而是返回 `(nil, err)`——与钩子/HTTP 处理器同一套约定,
|
||||||
一个手滑的插件错误不会带走网关。
|
一个手滑的插件错误不会带走网关。
|
||||||
@ -494,14 +523,14 @@ end
|
|||||||
|
|
||||||
### 6.4 命名
|
### 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 代码
|
### 7.0 分层:billing 完全在插件里,内核没有任何 billing 代码
|
||||||
|
|
||||||
@ -704,7 +733,7 @@ cached = cache_hit_tokens → 全价 × cache_discount
|
|||||||
插件的 `log(level, msg)` 输出到网关的日志:
|
插件的 `log(level, msg)` 输出到网关的日志:
|
||||||
|
|
||||||
```
|
```
|
||||||
journalctl -u llmsproxy -f | grep -i plugin
|
journalctl -u modelrouter -f | grep -i plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -16,10 +16,10 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"llmsproxy/internal/config"
|
"modelrouter/internal/config"
|
||||||
"llmsproxy/internal/lua"
|
"modelrouter/internal/lua"
|
||||||
"llmsproxy/internal/provider"
|
"modelrouter/internal/provider"
|
||||||
"llmsproxy/internal/scheduler"
|
"modelrouter/internal/scheduler"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Core owns the running configuration and adapters. mu guards every read and
|
// 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 {
|
for name, aud := range cfg.PluginVisibility {
|
||||||
c.plugins.SetPluginVisibility(name, aud)
|
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 {
|
if err := c.plugins.LoadDir(); err != nil {
|
||||||
log.Printf("[core] plugin dir: %v", err)
|
log.Printf("[core] plugin dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,10 +21,11 @@ func pluginLoaded(ps *Plugins, name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// The billing plugin ships with the gateway, so its arithmetic is a contract:
|
// billing lives in the repository as an optional plugin, NOT inside the binary.
|
||||||
// a wrong price silently produces wrong money. These tests drive it through the
|
// The gateway never installs it; an operator (or a test) places the file in
|
||||||
// real hook path and check the NUMBERS, not merely that it loads.
|
// 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) {
|
func billingVM(t *testing.T) (*Plugins, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := filepath.Join(t.TempDir(), "adapters")
|
dir := filepath.Join(t.TempDir(), "adapters")
|
||||||
@ -35,15 +36,30 @@ func billingVM(t *testing.T) (*Plugins, string) {
|
|||||||
t.Cleanup(vm.Stop)
|
t.Cleanup(vm.Stop)
|
||||||
pdir := filepath.Join(t.TempDir(), "plugins")
|
pdir := filepath.Join(t.TempDir(), "plugins")
|
||||||
ps := NewPlugins(vm, pdir)
|
ps := NewPlugins(vm, pdir)
|
||||||
if err := ps.SeedBundled(); err != nil {
|
installRepoPlugin(t, pdir, "billing")
|
||||||
t.Fatalf("seed: %v", err)
|
|
||||||
}
|
|
||||||
if err := ps.LoadDir(); err != nil {
|
if err := ps.LoadDir(); err != nil {
|
||||||
t.Fatalf("load: %v", err)
|
t.Fatalf("load: %v", err)
|
||||||
}
|
}
|
||||||
return ps, pdir
|
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.
|
// stateOf reads the plugin's published state as a generic map.
|
||||||
func stateOf(t *testing.T, ps *Plugins) map[string]interface{} {
|
func stateOf(t *testing.T, ps *Plugins) map[string]interface{} {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import (
|
|||||||
// 端点还指着公开路径 —— 凭据与协议都不匹配。
|
// 端点还指着公开路径 —— 凭据与协议都不匹配。
|
||||||
func TestChannelsGeminiPrivateProtocol(t *testing.T) {
|
func TestChannelsGeminiPrivateProtocol(t *testing.T) {
|
||||||
vm, ps, _ := runtimePlugins(t)
|
vm, ps, _ := runtimePlugins(t)
|
||||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
src, err := ReadBundledPlugin("channels")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import (
|
|||||||
// 可信:副本拿不到内核注入的共享前导,且无法发现"装了但没生效"。
|
// 可信:副本拿不到内核注入的共享前导,且无法发现"装了但没生效"。
|
||||||
func TestChannelsTraeSoloAdapter(t *testing.T) {
|
func TestChannelsTraeSoloAdapter(t *testing.T) {
|
||||||
vm, ps, _ := runtimePlugins(t)
|
vm, ps, _ := runtimePlugins(t)
|
||||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
src, err := ReadBundledPlugin("channels")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,27 +8,82 @@ import (
|
|||||||
golua "github.com/aarzilli/golua/lua"
|
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 / 设备指纹
|
// modelrouter.sdk_version — string, bumped when the SDK shape changes
|
||||||
// __llmsproxy_kernel —— 触发内核动作(注册源、探测、清冷却、改链路…)
|
// 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 里装,
|
// Why a single table instead of individual globals: the gateway must be able to
|
||||||
// 因为适配器共用 setupGlobals 而不该拿到这些能力。
|
// 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
|
||||||
// 两张表都用 buildJSONTable 的既有写法(NewTable + PushGoClosure + SetField),
|
// table is the seam that makes that possible; scattered private globals are not.
|
||||||
// 该写法在 json 表上长期稳定,也是内核自己的用法。
|
|
||||||
|
|
||||||
// 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"
|
const secretGlobal = "__llmsproxy_secrets"
|
||||||
|
|
||||||
// pluginDirGlobal carries the plugin's OWN directory as ground truth.
|
// pluginDirGlobal carries the plugin's OWN directory as ground truth.
|
||||||
//
|
//
|
||||||
// Why it must come from the kernel (live defect): the channels plugin decides
|
// 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
|
// where to keep its state by reading /etc/modelrouter/config.yaml when
|
||||||
// LLMSPROXY_PLUGIN_DIR is unset. Tests load the real plugin but never set that
|
// 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
|
// variable, so they read AND WROTE the production store — one full
|
||||||
// `go test ./internal/lua` replaced the production account pool with the test's
|
// `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
|
// empty one (13 accounts → 0; the credentials stayed in the vault, so the
|
||||||
|
|||||||
@ -31,13 +31,18 @@ func runtimePlugins(t *testing.T) (*VM, *Plugins, string) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(vm.Stop)
|
t.Cleanup(vm.Stop)
|
||||||
pdir := fmt.Sprintf("%s/plugins", t.TempDir())
|
pdir := fmt.Sprintf("%s/plugins", t.TempDir())
|
||||||
// ⚠️ **必须**设 LLMSPROXY_PLUGIN_DIR。channels 插件用 storeDir() 决定自己的
|
// ⚠️ **必须**设 MODELROUTER_PLUGIN_DIR。channels 插件用 storeDir() 决定自己的
|
||||||
// 存储位置,而 storeDir 在环境变量缺失时会去读 /etc/llmsproxy/config.yaml ——
|
// 存储位置,而 storeDir 在环境变量缺失时会去读 /etc/modelrouter/config.yaml ——
|
||||||
// 那是**生产路径**。曾经因此把生产账号池覆盖成测试的空 pool
|
// 那是**生产路径**。曾经因此把生产账号池覆盖成测试的空 pool
|
||||||
// (真实事故:一次全量 internal/lua 后 /etc/llmsproxy/plugins/
|
// (真实事故:一次全量 internal/lua 后 /etc/modelrouter/plugins/
|
||||||
// .channels-store.json 里 13 个账号变成 0 个,凭据却还留在保险库)。
|
// .channels-store.json 里 13 个账号变成 0 个,凭据却还留在保险库)。
|
||||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
// 两个名字都要设:插件先看新名 MODELROUTER_PLUGIN_DIR,再回退旧名。
|
||||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pdir)
|
// 只设旧名时,若宿主机恰好导出了新名,测试就会写进别人的目录。
|
||||||
|
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 := NewPlugins(vm, pdir)
|
||||||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||||
|
|||||||
133
internal/lua/plugin_sdk_test.go
Normal file
133
internal/lua/plugin_sdk_test.go
Normal file
@ -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 也会拿到凭据库与内核动作面")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -215,9 +215,9 @@ type Plugin struct {
|
|||||||
exports []string
|
exports []string
|
||||||
// script is the plugin's source, kept so a reload can rebuild its state.
|
// script is the plugin's source, kept so a reload can rebuild its state.
|
||||||
script string
|
script string
|
||||||
// Builtin marks a plugin that shipped with the gateway. The UI shows it as
|
// Builtin is retained in the API for compatibility. Business plugins are
|
||||||
// such so an operator can tell "example I can read" from "mine", and a
|
// operator-owned files; the gateway never classifies, replaces, or seeds
|
||||||
// delete of a builtin is allowed but re-seeds on a fresh plugin dir.
|
// them based on their contents.
|
||||||
Builtin bool
|
Builtin bool
|
||||||
|
|
||||||
// state is the plugin's SINGLE authoritative Lua state, guarded by mu.
|
// state is the plugin's SINGLE authoritative Lua state, guarded by mu.
|
||||||
@ -746,29 +746,9 @@ func (ps *Plugins) LoadDir() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedBundled writes the plugins shipped with the gateway into dir when the
|
// Plugins are optional scripts managed independently in plugin_dir. This method
|
||||||
// directory does not exist yet, mirroring the adapter seeding rule: once the
|
// is retained for API compatibility and deliberately performs no seeding.
|
||||||
// directory exists it is authoritative, so deleting or editing a shipped plugin
|
func (ps *Plugins) SeedBundled() error { return nil }
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// bootPluginState compiles one plugin into a fresh Lua state and stores its
|
// bootPluginState compiles one plugin into a fresh Lua state and stores its
|
||||||
// returned table under pluginGlobal. It is the plugin counterpart of
|
// 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)
|
registerKernelBridge(L, ps, name)
|
||||||
// The plugin's own directory, as ground truth (see pluginDirGlobal).
|
// The plugin's own directory, as ground truth (see pluginDirGlobal).
|
||||||
registerPluginDirBridge(L, ps.dir)
|
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 {
|
if err := L.DoString(code); err != nil {
|
||||||
L.Close()
|
L.Close()
|
||||||
@ -861,13 +844,9 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
|||||||
}
|
}
|
||||||
p.script = code
|
p.script = code
|
||||||
p.state = w
|
p.state = w
|
||||||
// Builtin = the shipped source is byte-identical to what this file was
|
// Plugins are external operator-owned files. Do not compare their source
|
||||||
// loaded from. That is stronger than "the name matches a bundled plugin": an
|
// with repository examples: doing so couples runtime behavior to the source
|
||||||
// operator who EDITED billing.lua must not be told their copy is builtin,
|
// tree and falsely labels a user file as gateway-owned.
|
||||||
// or the UI would offer to overwrite their changes.
|
|
||||||
if orig, err := ReadBundledPlugin(name); err == nil && orig == code {
|
|
||||||
p.Builtin = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- manifest ----
|
// ---- manifest ----
|
||||||
w.L.GetGlobal(pluginGlobal)
|
w.L.GetGlobal(pluginGlobal)
|
||||||
@ -1229,8 +1208,11 @@ type DiskEntry struct {
|
|||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Loaded bool `json:"loaded"`
|
Loaded bool `json:"loaded"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
Builtin bool `json:"builtin"`
|
// Builtin is retained in the JSON shape for compatibility with older
|
||||||
Error string `json:"error,omitempty"`
|
// 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/Description are read from the loaded plugin when available.
|
||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
@ -1299,25 +1281,15 @@ func (ps *Plugins) OnDisk() []DiskEntry {
|
|||||||
// field covers either.
|
// field covers either.
|
||||||
de.Pages = pageNames(p)
|
de.Pages = pageNames(p)
|
||||||
} else {
|
} else {
|
||||||
// On disk but not in the running set: either it failed so badly
|
// The file may have been added after startup. It remains an external
|
||||||
// that LoadSource never produced a record, or the dir was written
|
// operator-owned plugin; never inspect repository source to classify it.
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ps.mu.RUnlock()
|
ps.mu.RUnlock()
|
||||||
out = append(out, de)
|
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 {
|
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[i].Name < out[j].Name
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|||||||
@ -225,7 +225,7 @@ end
|
|||||||
|
|
||||||
-- ===== 规则持久化:插件自己读写自己的配置文件 ========================
|
-- ===== 规则持久化:插件自己读写自己的配置文件 ========================
|
||||||
--
|
--
|
||||||
-- 规则文件放在**插件自己的目录**(内核通过通用的 __llmsproxy_plugin_dir 暴露,
|
-- 规则文件放在**插件自己的目录**(内核通过通用的 modelrouter.plugin_dir 暴露,
|
||||||
-- 任何插件都能用;内核不知道 billing 的存在):`<plugin_dir>/billing.json`。
|
-- 任何插件都能用;内核不知道 billing 的存在):`<plugin_dir>/billing.json`。
|
||||||
-- 用 JSON 因为插件沙箱只有 json 库;用独立文件因为"保存系统配置"绝不该改动
|
-- 用 JSON 因为插件沙箱只有 json 库;用独立文件因为"保存系统配置"绝不该改动
|
||||||
-- 价目表,反之亦然。
|
-- 价目表,反之亦然。
|
||||||
@ -233,7 +233,8 @@ plugin.rules = { active = "", profiles = {} }
|
|||||||
plugin.rules_path = ""
|
plugin.rules_path = ""
|
||||||
|
|
||||||
local function defaultRulesPath()
|
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
|
if type(dir) ~= "string" or dir == "" then return "" end
|
||||||
return dir .. "/billing.json"
|
return dir .. "/billing.json"
|
||||||
end
|
end
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -34,7 +34,7 @@
|
|||||||
-- endpoint" — a 401 on a key we hold, say — are surfaced, because those are the
|
-- endpoint" — a 401 on a key we hold, say — are surfaced, because those are the
|
||||||
-- ones worth fixing.
|
-- 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.
|
-- that), so adding a probe never widens what the gateway can talk to.
|
||||||
|
|
||||||
local PLUGIN = {}
|
local PLUGIN = {}
|
||||||
@ -127,7 +127,7 @@ local function request(path, key, headers, tries)
|
|||||||
hdrs["Authorization"] = "Bearer " .. tostring(key or "")
|
hdrs["Authorization"] = "Bearer " .. tostring(key or "")
|
||||||
local lastErr = "no attempt"
|
local lastErr = "no attempt"
|
||||||
for _ = 1, tries do
|
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
|
-- 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
|
-- 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.
|
-- — it stalls every request_end hook (i.e. billing) behind it.
|
||||||
@ -540,6 +540,111 @@ local function commandCode(base, key)
|
|||||||
return rep, nil
|
return rep, nil
|
||||||
end
|
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,
|
-- Ordered probes. DeepSeek-family first because it is a single cheap endpoint,
|
||||||
-- then the OpenAI console shape, then Command Code's four.
|
-- then the OpenAI console shape, then Command Code's four.
|
||||||
local ADAPTERS = {
|
local ADAPTERS = {
|
||||||
@ -547,6 +652,10 @@ local ADAPTERS = {
|
|||||||
{ vendor = "deepseek", probe = deepseekBalance },
|
{ vendor = "deepseek", probe = deepseekBalance },
|
||||||
{ vendor = "openai-dash", probe = openaiDashboard },
|
{ vendor = "openai-dash", probe = openaiDashboard },
|
||||||
{ vendor = "opendesign", probe = openDesign },
|
{ 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
|
-- 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
|
-- genuinely unreachable case; a reachable host still goes through the
|
||||||
-- adapters.
|
-- adapters.
|
||||||
if known == false then
|
if known == false then
|
||||||
local probe = __llmsproxy_http({
|
local probe = modelrouter.http({
|
||||||
url = base .. "/", method = "GET", timeout_ms = 2500,
|
url = base .. "/", method = "GET", timeout_ms = 2500,
|
||||||
headers = { ["Authorization"] = "Bearer " .. tostring(key or "") },
|
headers = { ["Authorization"] = "Bearer " .. tostring(key or "") },
|
||||||
})
|
})
|
||||||
@ -764,13 +873,30 @@ local function collectSource(src, budget)
|
|||||||
kind = "skip"
|
kind = "skip"
|
||||||
why = because or ("adapter not configured: " .. a.vendor)
|
why = because or ("adapter not configured: " .. a.vendor)
|
||||||
else
|
else
|
||||||
local r, k, e = a.probe(base, key)
|
local r, k, e = a.probe(base, key, src)
|
||||||
if r ~= nil then
|
if r ~= nil then
|
||||||
rep = r
|
rep = r
|
||||||
HOST_MEMO[host] = a.vendor
|
HOST_MEMO[host] = a.vendor
|
||||||
break
|
break
|
||||||
end
|
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
|
if k == "error" then
|
||||||
local rejected = tostring(e or ""):match("rejected") ~= nil
|
local rejected = tostring(e or ""):match("rejected") ~= nil
|
||||||
-- Last error wins, except that a rejected credential displaces anything
|
-- Last error wins, except that a rejected credential displaces anything
|
||||||
|
|||||||
@ -82,7 +82,7 @@ local function selfStats(key)
|
|||||||
end
|
end
|
||||||
local url = "http://127.0.0.1:8081/api/stats?period=" .. PERIOD ..
|
local url = "http://127.0.0.1:8081/api/stats?period=" .. PERIOD ..
|
||||||
"&limit=1&key=" .. maskedKey(key)
|
"&limit=1&key=" .. maskedKey(key)
|
||||||
local r = __llmsproxy_http({
|
local r = modelrouter.http({
|
||||||
url = url,
|
url = url,
|
||||||
method = "GET",
|
method = "GET",
|
||||||
timeout_ms = 4000,
|
timeout_ms = 4000,
|
||||||
|
|||||||
@ -298,11 +298,11 @@ func TestQuotaMemoKeyIsTheHost(t *testing.T) {
|
|||||||
// that actually ships rather than a copy that can drift from it.
|
// that actually ships rather than a copy that can drift from it.
|
||||||
func readShippedPlugin(t *testing.T, name string) string {
|
func readShippedPlugin(t *testing.T, name string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := bundledAdapters.ReadFile("plugins/" + name)
|
src, err := ReadBundledPlugin(strings.TrimSuffix(name, ".lua"))
|
||||||
if err != nil {
|
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
|
// quotaWithDecision loads quota.lua with its local backoffSeconds exposed as an
|
||||||
|
|||||||
@ -9,22 +9,22 @@ import (
|
|||||||
// **测试绝不允许写生产存储** —— 这条是真实事故的回归判据。
|
// **测试绝不允许写生产存储** —— 这条是真实事故的回归判据。
|
||||||
//
|
//
|
||||||
// 事故:channels 插件用 storeDir() 决定自己的存储位置,而 storeDir 有多级兜底:
|
// 事故:channels 插件用 storeDir() 决定自己的存储位置,而 storeDir 有多级兜底:
|
||||||
// 环境变量 → settings → **读 /etc/llmsproxy/config.yaml 的 plugin_dir** →
|
// 环境变量 → settings → **读 /etc/modelrouter/config.yaml 的 plugin_dir** →
|
||||||
// 硬编码 /etc/llmsproxy/plugins。测试台没设环境变量时,后两级直接命中**生产**:
|
// 硬编码 /etc/modelrouter/plugins。测试台没设环境变量时,后两级直接命中**生产**:
|
||||||
//
|
//
|
||||||
// 测试插件目录 = /tmp/TestXXX/002/plugins
|
// 测试插件目录 = /tmp/TestXXX/002/plugins
|
||||||
// storeDir 结果 = /etc/llmsproxy/plugins ← 实测
|
// storeDir 结果 = /etc/modelrouter/plugins ← 实测
|
||||||
//
|
//
|
||||||
// 于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。一次全量
|
// 于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。一次全量
|
||||||
// `go test ./internal/lua` 就把生产账号池覆盖成测试的空 pool:
|
// `go test ./internal/lua` 就把生产账号池覆盖成测试的空 pool:
|
||||||
// /etc/llmsproxy/plugins/.channels-store.json 从 1730 变 1986 字节,
|
// /etc/modelrouter/plugins/.channels-store.json 从 1730 变 1986 字节,
|
||||||
// 13 个账号变成 0 个(凭据还留在保险库,所以症状是"账号凭空消失")。
|
// 13 个账号变成 0 个(凭据还留在保险库,所以症状是"账号凭空消失")。
|
||||||
//
|
//
|
||||||
// 同类前科(本文件存在的另一半理由):曾经有 26 个测试账号被写进生产
|
// 同类前科(本文件存在的另一半理由):曾经有 26 个测试账号被写进生产
|
||||||
// .channels-store.json。两次都是"测试没隔离存储目录",所以这条判据必须存在。
|
// .channels-store.json。两次都是"测试没隔离存储目录",所以这条判据必须存在。
|
||||||
func TestStoreDirIsolatedInTests(t *testing.T) {
|
func TestStoreDirIsolatedInTests(t *testing.T) {
|
||||||
_, ps, pdir := runtimePlugins(t)
|
_, ps, pdir := runtimePlugins(t)
|
||||||
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
|
src, err := ReadBundledPlugin("channels")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -45,17 +45,17 @@ func TestStoreDirIsolatedInTests(t *testing.T) {
|
|||||||
t.Logf("storeDir 结果 = %s", got)
|
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"+
|
t.Fatalf("storeDir 指向了**生产**目录 %q —— 测试会覆盖生产账号池\n"+
|
||||||
"(真实事故:13 个账号被覆盖成 0 个)。修法:runtimePlugins 设\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()) {
|
if !strings.Contains(got, pdir) && !strings.HasPrefix(got, os.TempDir()) {
|
||||||
t.Errorf("storeDir 应落在临时目录(%s 或 %s),实得 %q", pdir, os.TempDir(), got)
|
t.Errorf("storeDir 应落在临时目录(%s 或 %s),实得 %q", pdir, os.TempDir(), got)
|
||||||
}
|
}
|
||||||
// ③ 环境变量必须真的被设上(机制而非巧合)
|
// ③ 环境变量必须真的被设上(机制而非巧合)
|
||||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
if os.Getenv("MODELROUTER_PLUGIN_DIR") == "" {
|
||||||
t.Error("runtimePlugins 必须设 LLMSPROXY_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)")
|
t.Error("runtimePlugins 必须设 MODELROUTER_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -37,7 +38,7 @@ import (
|
|||||||
golua "github.com/aarzilli/golua/lua"
|
golua "github.com/aarzilli/golua/lua"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed adapters/*.lua plugins/*.lua
|
//go:embed adapters/*.lua
|
||||||
var bundledAdapters embed.FS
|
var bundledAdapters embed.FS
|
||||||
|
|
||||||
// adapterGlobal is the reserved global holding the adapter table after the
|
// adapterGlobal is the reserved global holding the adapter table after the
|
||||||
@ -732,64 +733,24 @@ func (v *VM) Start() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// bundledPluginsDir is the directory inside the embedded FS holding the
|
// ReadBundledPlugin is retained only for source-level test fixtures and
|
||||||
// plugins shipped with the gateway. It is separate from adapters/ on purpose:
|
// development tooling. It resolves relative to this source file, not the
|
||||||
// the two are loaded by different machinery into different kinds of Lua state
|
// process working directory, and is never used by production plugin loading.
|
||||||
// (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.
|
|
||||||
func ReadBundledPlugin(name string) (string, error) {
|
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 {
|
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
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeBundledPlugins seeds the plugin directory with the shipped plugins.
|
// writeBundledPlugins is intentionally a no-op: plugins are independent optional
|
||||||
//
|
// files and are never seeded by the gateway.
|
||||||
// It runs only when the directory does not exist yet (same rule as adapters):
|
func writeBundledPlugins(string) error { return nil }
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *VM) Stop() {
|
func (v *VM) Stop() {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@ -1869,20 +1869,16 @@ func TestAnthropicUsageCacheMapping(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSeedBundledFillsMissingFilesInExistingDir 是真实事故的回归测试。
|
// TestSeedBundledNeverWritesFiles 钉住"插件不由网关安装"这条契约。
|
||||||
//
|
//
|
||||||
// 事故:SeedBundled 原本"目录已存在就什么都不做"。生产 plugin_dir 从首次安装
|
// 历史:SeedBundled 曾把 go:embed 的 billing/channels/quota/usage 写进
|
||||||
// 起就存在,于是后来加入二进制的新内置插件(channels.lua)永远不会落盘 ——
|
// plugin_dir,于是插件与 Go 二进制耦合、升级还会覆盖或删除运维文件。
|
||||||
// 它在 go:embed 里、却完全不出现在 /api/plugins(线上只加载 billing/quota/usage)。
|
// 现在插件是纯外置资产:网关不创建、不补齐、不覆盖,只加载目录里已有的文件。
|
||||||
//
|
func TestSeedBundledNeverWritesFiles(t *testing.T) {
|
||||||
// 本测试构造那个确切条件:目录已存在且只装了部分内置插件,断言缺失的被补齐、
|
|
||||||
// 已存在的(模拟运维自定义)**不被覆盖**。
|
|
||||||
func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "plugins")
|
dir := filepath.Join(t.TempDir(), "plugins")
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// 模拟:目录已存在,里面只有运维自己的一份 billing.lua(内容故意不同)
|
|
||||||
const customBilling = "-- operator's own billing plugin\nreturn { name='billing', version='9.9.9' }"
|
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 {
|
if err := os.WriteFile(filepath.Join(dir, "billing.lua"), []byte(customBilling), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@ -1896,33 +1892,29 @@ func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) {
|
|||||||
ps := NewPlugins(vm, dir)
|
ps := NewPlugins(vm, dir)
|
||||||
|
|
||||||
if err := ps.SeedBundled(); err != nil {
|
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"))
|
got, err := os.ReadFile(filepath.Join(dir, "billing.lua"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if string(got) != customBilling {
|
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 {
|
if err := ps.LoadDir(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -1930,35 +1922,7 @@ func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) {
|
|||||||
for _, p := range ps.List() {
|
for _, p := range ps.List() {
|
||||||
names[p["name"].(string)] = true
|
names[p["name"].(string)] = true
|
||||||
}
|
}
|
||||||
if !names["channels"] {
|
if !names["billing"] {
|
||||||
t.Errorf("channels 插件应被 seed 并加载, 实得 %v", names)
|
t.Errorf("plugin_dir 中手工安装的插件应被加载, 实得 %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 覆盖了已存在的文件")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user