mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
- 新增唯一公开接口表 `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 改名同处一个文件,无法文件级拆分,故一并纳入。
134 lines
4.9 KiB
Go
134 lines
4.9 KiB
Go
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 也会拿到凭据库与内核动作面")
|
||
}
|
||
}
|