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 改名同处一个文件,无法文件级拆分,故一并纳入。
341 lines
11 KiB
Go
341 lines
11 KiB
Go
package lua
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
golua "github.com/aarzilli/golua/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.
|
|
//
|
|
// 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
|
|
//
|
|
// 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.
|
|
|
|
// 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/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
|
|
// symptom was "accounts vanished"). The kernel already knows the true directory
|
|
// (ps.dir), so handing it over removes the guess entirely: a plugin no longer
|
|
// has to infer its own location from a config file it might not own.
|
|
const pluginDirGlobal = "__llmsproxy_plugin_dir"
|
|
|
|
// registerPluginDirBridge installs the plugin's own directory as a plain string
|
|
// global. Deliberately a string, not a table: there is nothing to call, and a
|
|
// bare value cannot be shadowed by a later SetField on a shared table.
|
|
func registerPluginDirBridge(L *golua.State, dir string) {
|
|
L.PushString(dir)
|
|
L.SetGlobal(pluginDirGlobal)
|
|
}
|
|
|
|
// kernelGlobal 是内核动作面的 Lua 名字。
|
|
const kernelGlobal = "__llmsproxy_kernel"
|
|
|
|
// registerSecretBridge installs the credential vault onto a plugin's state.
|
|
//
|
|
// The plugin name is bound HERE, at boot, rather than read from the Lua state at
|
|
// call time. That is what makes "a plugin can only read its OWN credentials" a
|
|
// mechanism instead of a convention: a shard or any other holder of the compiled
|
|
// script cannot pass a different name.
|
|
func registerSecretBridge(L *golua.State, ps *Plugins, name string) {
|
|
L.NewTable()
|
|
L.PushGoClosure(luaSecretSet(ps, name))
|
|
L.SetField(-2, "set")
|
|
L.PushGoClosure(luaSecretGet(ps, name))
|
|
L.SetField(-2, "get")
|
|
L.PushGoClosure(luaSecretDelete(ps, name))
|
|
L.SetField(-2, "delete")
|
|
L.PushGoClosure(luaSecretKeys(ps, name))
|
|
L.SetField(-2, "keys")
|
|
L.SetGlobal(secretGlobal)
|
|
}
|
|
|
|
// luaSecretSet: set(key, value) -> (true, nil) | (nil, err)
|
|
func luaSecretSet(ps *Plugins, name string) golua.LuaGoFunction {
|
|
return func(L *golua.State) int {
|
|
key := L.ToString(1)
|
|
val := L.ToString(2)
|
|
if err := ps.SetSecret(name, key, val); err != nil {
|
|
L.PushNil()
|
|
L.PushString(err.Error())
|
|
return 2
|
|
}
|
|
L.PushBoolean(true)
|
|
L.PushNil()
|
|
return 2
|
|
}
|
|
}
|
|
|
|
// luaSecretGet: get(key) -> (value, nil) | (nil, err). A missing key yields
|
|
// (nil, nil) so a plugin can test `if tok == nil then login() end` — the "no
|
|
// credential stored yet" case is the common one for a fresh install, and making
|
|
// it an error would force every caller to string-match on a message.
|
|
func luaSecretGet(ps *Plugins, name string) golua.LuaGoFunction {
|
|
return func(L *golua.State) int {
|
|
key := L.ToString(1)
|
|
v, ok, err := ps.GetSecret(name, key)
|
|
if err != nil {
|
|
L.PushNil()
|
|
L.PushString(err.Error())
|
|
return 2
|
|
}
|
|
if !ok {
|
|
L.PushNil()
|
|
L.PushNil()
|
|
return 2
|
|
}
|
|
L.PushString(v)
|
|
L.PushNil()
|
|
return 2
|
|
}
|
|
}
|
|
|
|
// luaSecretDelete: delete(key) -> (true, nil) | (nil, err)
|
|
func luaSecretDelete(ps *Plugins, name string) golua.LuaGoFunction {
|
|
return func(L *golua.State) int {
|
|
key := L.ToString(1)
|
|
if err := ps.DeleteSecret(name, key); err != nil {
|
|
L.PushNil()
|
|
L.PushString(err.Error())
|
|
return 2
|
|
}
|
|
L.PushBoolean(true)
|
|
L.PushNil()
|
|
return 2
|
|
}
|
|
}
|
|
|
|
// luaSecretKeys: keys() -> (array of key names, nil). Names only — a plugin can
|
|
// render "which accounts are configured" without ever echoing token bytes.
|
|
func luaSecretKeys(ps *Plugins, name string) golua.LuaGoFunction {
|
|
return func(L *golua.State) int {
|
|
keys, err := ps.SecretKeys(name)
|
|
if err != nil {
|
|
L.PushNil()
|
|
L.PushString(err.Error())
|
|
return 2
|
|
}
|
|
L.NewTable()
|
|
for i, k := range keys {
|
|
L.PushString(k)
|
|
L.RawSeti(-2, i+1)
|
|
}
|
|
L.PushNil()
|
|
return 2
|
|
}
|
|
}
|
|
|
|
// registerKernelBridge installs the kernel action surface onto a plugin's state.
|
|
func registerKernelBridge(L *golua.State, ps *Plugins, name string) {
|
|
L.NewTable()
|
|
L.PushGoClosure(luaKernelActions(ps, name))
|
|
L.SetField(-2, "actions")
|
|
L.PushGoClosure(luaKernelCall(ps, name))
|
|
L.SetField(-2, "call")
|
|
L.SetGlobal(kernelGlobal)
|
|
}
|
|
|
|
// luaKernelActions: actions() -> (array of {name,title,danger,description,params,
|
|
// granted}, nil)
|
|
//
|
|
// It reports GRANTED alongside each action so a plugin can degrade gracefully
|
|
// (skip the part that needs a permission it does not have) instead of
|
|
// discovering it by hitting an error mid-operation.
|
|
func luaKernelActions(ps *Plugins, name string) golua.LuaGoFunction {
|
|
return func(L *golua.State) int {
|
|
perm := ps.PluginKernelPermission(name)
|
|
L.NewTable()
|
|
for i, a := range KernelActions() {
|
|
L.NewTable()
|
|
L.PushString(a.Name)
|
|
L.SetField(-2, "name")
|
|
L.PushString(a.Title)
|
|
L.SetField(-2, "title")
|
|
L.PushString(a.Danger)
|
|
L.SetField(-2, "danger")
|
|
L.PushString(a.Description)
|
|
L.SetField(-2, "description")
|
|
L.PushBoolean(perm.allows(a))
|
|
L.SetField(-2, "granted")
|
|
L.NewTable()
|
|
for j, p := range a.Params {
|
|
L.PushString(p)
|
|
L.RawSeti(-2, j+1)
|
|
}
|
|
L.SetField(-2, "params")
|
|
L.RawSeti(-2, i+1)
|
|
}
|
|
L.PushNil()
|
|
return 2
|
|
}
|
|
}
|
|
|
|
// luaKernelCall: call(name, args) -> (result, nil) | (nil, err)
|
|
//
|
|
// One entry point rather than a function per action: the action set will change,
|
|
// and a table field per action would mean editing bootPluginState — the exact
|
|
// place that produced a cgo crash during earlier work.
|
|
func luaKernelCall(ps *Plugins, name string) golua.LuaGoFunction {
|
|
return func(L *golua.State) int {
|
|
action := L.ToString(1)
|
|
args := map[string]interface{}{}
|
|
if L.Type(2) == golua.LUA_TTABLE {
|
|
var raw interface{}
|
|
if err := luaToJSON(L, 2, &raw); err == nil {
|
|
if m, ok := raw.(map[string]interface{}); ok {
|
|
args = m
|
|
}
|
|
}
|
|
}
|
|
v, err := ps.callKernel(name, action, args)
|
|
if err != nil {
|
|
L.PushNil()
|
|
L.PushString(err.Error())
|
|
return 2
|
|
}
|
|
if v == nil {
|
|
L.PushNil()
|
|
} else {
|
|
pushGoValue(L, v)
|
|
}
|
|
L.PushNil()
|
|
return 2
|
|
}
|
|
}
|
|
|
|
// readPluginKernelDeclared reads `plugin.kernel`, the actions a plugin asks for.
|
|
//
|
|
// The shape is a list of names:
|
|
//
|
|
// plugin.kernel = { "status", "reset_health" }
|
|
//
|
|
// Unknown names are kept rather than dropped so the settings page can show a
|
|
// typo as "requested but not a real action" — silently discarding it would make
|
|
// a plugin's intent invisible at exactly the moment the operator is deciding
|
|
// what to grant.
|
|
func readPluginKernelDeclared(L *golua.State) []string {
|
|
raw, ok := pluginFieldValue(L, "kernel")
|
|
if !ok {
|
|
return nil
|
|
}
|
|
items, isList := raw.([]interface{})
|
|
if !isList {
|
|
if one, isTable := raw.(map[string]interface{}); isTable {
|
|
// A single string field or a set-style table; accept both spellings.
|
|
if v, ok := one["actions"].([]interface{}); ok {
|
|
items = v
|
|
} else {
|
|
return nil
|
|
}
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
out := make([]string, 0, len(items))
|
|
seen := map[string]bool{}
|
|
for _, it := range items {
|
|
name, ok := it.(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
out = append(out, name)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// luaTableArg decodes a Lua table argument into a Go map, reporting a clear error
|
|
// when the caller passed something else (a common mistake that would otherwise
|
|
// surface as a silent zero value).
|
|
func luaTableArg(L *golua.State, idx int) (map[string]interface{}, error) {
|
|
if L.Type(idx) != golua.LUA_TTABLE {
|
|
return nil, fmt.Errorf("expected a table argument, got %s", normalizeString(L, idx))
|
|
}
|
|
var raw interface{}
|
|
if err := luaToJSON(L, idx, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
m, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("argument must be a keyed table")
|
|
}
|
|
return m, nil
|
|
}
|