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:
JianFeeeee
2026-10-26 22:27:12 +08:00
parent 79bc55cfbe
commit de7553eaa2
19 changed files with 653 additions and 465 deletions

View File

@ -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