Files
ModelRouter/internal/lua/plugin_sdk_test.go
JianFeeeee de7553eaa2 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 改名同处一个文件,无法文件级拆分,故一并纳入。
2026-10-26 22:27:12 +08:00

134 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 也会拿到凭据库与内核动作面")
}
}