mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:
1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
- `main` 独有的**文件**:0 个;
- `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
main 独有行数:0;
- `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
- `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
- main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
(`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
且不会丢任何 main 独有的修复。
本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth
校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
402 lines
12 KiB
Go
402 lines
12 KiB
Go
package core
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"llmsproxy/internal/config"
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// 内核动作实现(R4-c)
|
||
//
|
||
// 把 Core 的公开动作暴露给插件 Lua,走 lua.KernelAPI 接口注入(依赖方向是
|
||
// core → lua,所以接口定义在 lua 包、实现放在这里)。
|
||
//
|
||
// # 为什么是"动作面"而不是"给插件一个 Core 指针"
|
||
//
|
||
// 直接暴露 *Core 会让插件能碰任何未导出的细节,而且每一次内核重构都会变成
|
||
// 对插件作者的破坏性变更。动作面是一份**显式的契约**:每个动作有名字、危险级、
|
||
// 超时预算,加动作是一次有意的行为,而不是顺手多导出一个方法。
|
||
//
|
||
// # 凭据边界
|
||
//
|
||
// 这里**不实现** list_keys / find_key。Core.ListKeys() 返回的是**明文**网关
|
||
// 密钥,把它交给第三方 Lua 等于把部署的整个凭据面交出去。key 相关的读走掩码
|
||
// 形态(create_key 的返回是唯一一次明文,且是"刚创建的那一个")。
|
||
var _ lua.KernelAPI = (*Core)(nil)
|
||
|
||
// Actions implements lua.KernelAPI.
|
||
//
|
||
// The catalog lives in the lua package because permissions must be checkable even
|
||
// when no Core is wired. This method exists so the interface is satisfied by the
|
||
// real kernel rather than by a separate adapter type.
|
||
func (c *Core) Actions() []lua.KernelAction { return lua.KernelActions() }
|
||
|
||
// Invoke implements lua.KernelAPI. It dispatches one action.
|
||
//
|
||
// Every action is expected to honour ctx. The caller (lua.callKernel) gives up on
|
||
// a deadline and returns an error to the plugin, but a kernel action that keeps
|
||
// running afterwards would be an invisible leak, so the long ones (probe_all)
|
||
// pass ctx down into the network layer.
|
||
func (c *Core) Invoke(ctx context.Context, name string, args map[string]interface{}) (interface{}, error) {
|
||
switch name {
|
||
// ---- 只读 ----
|
||
case "status":
|
||
return c.actionStatus(), nil
|
||
case "list_sources":
|
||
return c.actionListSources(), nil
|
||
case "list_models":
|
||
return c.actionListModels(), nil
|
||
case "list_adapters":
|
||
return c.actionListAdapters(), nil
|
||
case "list_auto_rules":
|
||
return c.actionListAutoRules(), nil
|
||
case "adapter_owner":
|
||
return c.actionAdapterOwner(args)
|
||
|
||
// ---- 写 ----
|
||
case "reload":
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{"ok": true}, c.Reload()
|
||
case "reset_health":
|
||
// ResetHealth already no-ops on a nil registry (it ranges over
|
||
// Providers()), so this needs no guard beyond the nil check inside.
|
||
c.ResetHealth()
|
||
return map[string]interface{}{"ok": true}, nil
|
||
case "probe_all":
|
||
r := c.Registry()
|
||
if r == nil {
|
||
return nil, fmt.Errorf("the registry is not built yet")
|
||
}
|
||
r.ProbeAll(ctx)
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, fmt.Errorf("probe_all was cut short: %w", err)
|
||
}
|
||
return map[string]interface{}{"ok": true}, nil
|
||
case "save_auto_rules":
|
||
return c.actionSaveAutoRules(args)
|
||
case "save_key_auto":
|
||
return c.actionSaveKeyAuto(args)
|
||
case "add_source":
|
||
return c.actionAddSource(args)
|
||
case "create_key":
|
||
return c.actionCreateKey(args)
|
||
|
||
// ---- 破坏性 ----
|
||
case "remove_source":
|
||
return c.actionRemoveSource(args)
|
||
case "delete_key":
|
||
return c.actionDeleteKey(args)
|
||
case "update_key":
|
||
return c.actionUpdateKey(args)
|
||
case "remove_adapter":
|
||
return c.actionRemoveAdapter(args)
|
||
case "set_plugin_enabled":
|
||
return c.actionSetPluginEnabled(args)
|
||
default:
|
||
return nil, fmt.Errorf("unknown kernel action %q", name)
|
||
}
|
||
}
|
||
|
||
// ---- 只读实现 ----
|
||
|
||
func (c *Core) actionStatus() interface{} {
|
||
// A plugin can call this DURING LoadDir (its own __init or first hook), which
|
||
// runs before rebuildRegistry builds the registry. Returning an empty status
|
||
// rather than panicking keeps a load-time call from taking the process down;
|
||
// the plugin sees an empty list and the next call gives real data.
|
||
out := map[string]interface{}{
|
||
"sources": []interface{}{},
|
||
"auto_slots": []interface{}{},
|
||
"default": c.cfg.DefaultModel,
|
||
"ready": false,
|
||
}
|
||
if r := c.Registry(); r != nil {
|
||
out["sources"] = r.Status()
|
||
out["ready"] = true
|
||
}
|
||
if slots := c.AutoSlotStates(); slots != nil {
|
||
out["auto_slots"] = slots
|
||
}
|
||
return out
|
||
}
|
||
|
||
// actionListSources returns the sources with api_key MASKED.
|
||
//
|
||
// Masking is not optional: a plugin has no legitimate need for another source's
|
||
// key, and a plugin that wrote the source itself already has it in its own
|
||
// credential vault.
|
||
func (c *Core) actionListSources() interface{} {
|
||
srcs := c.Sources()
|
||
out := make([]map[string]interface{}, 0, len(srcs))
|
||
for _, s := range srcs {
|
||
models := make([]string, 0, len(s.Models))
|
||
for _, m := range s.Models {
|
||
models = append(models, m.ID)
|
||
}
|
||
out = append(out, map[string]interface{}{
|
||
"name": s.Name,
|
||
"base_url": s.BaseURL,
|
||
"adapter": s.Adapter,
|
||
"models": models,
|
||
"has_key": s.APIKey != "" || s.APIKeyEnv != "",
|
||
"key_hint": maskSecretHint(s.APIKey),
|
||
"max_concurrent": s.MaxConcurrent,
|
||
// rpm must be exposed too: it carries the per-model minimum gap, and
|
||
// max_concurrent alone cannot distinguish "serial WITH a gap" from
|
||
// "serial with no gap" — the latter still collides with the
|
||
// upstream's per-model concurrency window.
|
||
"rpm": s.RPM,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// maskSecretHint shows only a short suffix so an operator (and a plugin) can tell
|
||
// two keys apart without either getting the secret.
|
||
func maskSecretHint(k string) string {
|
||
if k == "" {
|
||
return ""
|
||
}
|
||
if len(k) <= 4 {
|
||
return "***"
|
||
}
|
||
return "***" + k[len(k)-4:]
|
||
}
|
||
|
||
func (c *Core) actionListModels() interface{} {
|
||
if r := c.Registry(); r != nil {
|
||
return r.ModelList()
|
||
}
|
||
return []string{}
|
||
}
|
||
|
||
func (c *Core) actionListAdapters() interface{} {
|
||
return c.ListAdapters()
|
||
}
|
||
|
||
func (c *Core) actionListAutoRules() interface{} {
|
||
return map[string]interface{}{
|
||
"auto": c.AutoRules(),
|
||
"auto_image": c.AutoImageRules(),
|
||
}
|
||
}
|
||
|
||
func (c *Core) actionAdapterOwner(args map[string]interface{}) (interface{}, error) {
|
||
name, _ := args["name"].(string)
|
||
if name == "" {
|
||
return nil, fmt.Errorf("adapter_owner requires a name")
|
||
}
|
||
owner := ""
|
||
if ps := c.Plugins(); ps != nil {
|
||
owner = ps.AdapterOwnerOf(name)
|
||
}
|
||
return map[string]interface{}{
|
||
"name": name,
|
||
"owner": owner, // "" means operator-installed
|
||
}, nil
|
||
}
|
||
|
||
// ---- 写实现 ----
|
||
|
||
func (c *Core) actionSaveAutoRules(args map[string]interface{}) (interface{}, error) {
|
||
entries, err := decodeScopes(args["entries"])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := c.SaveAutoRules(entries); err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{"ok": true, "count": len(entries)}, nil
|
||
}
|
||
|
||
func (c *Core) actionSaveKeyAuto(args map[string]interface{}) (interface{}, error) {
|
||
keyName, _ := args["key_name"].(string)
|
||
if keyName == "" {
|
||
return nil, fmt.Errorf("save_key_auto requires key_name")
|
||
}
|
||
entries, err := decodeScopes(args["entries"])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := c.SaveKeyAuto(keyName, entries); err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{"ok": true, "count": len(entries)}, nil
|
||
}
|
||
|
||
// actionAddSource is the capability that makes "plugin-driven upstream" real: a
|
||
// plugin that has just obtained credentials can wire its endpoint in as a source
|
||
// without the operator editing config.yaml.
|
||
func (c *Core) actionAddSource(args map[string]interface{}) (interface{}, error) {
|
||
raw, ok := args["source"]
|
||
if !ok {
|
||
return nil, fmt.Errorf("add_source requires a source table")
|
||
}
|
||
b, err := json.Marshal(raw)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("source is not encodable: %w", err)
|
||
}
|
||
var src config.Source
|
||
if err := json.Unmarshal(b, &src); err != nil {
|
||
return nil, fmt.Errorf("source shape is invalid: %w", err)
|
||
}
|
||
if strings.TrimSpace(src.Name) == "" {
|
||
return nil, fmt.Errorf("source requires a name")
|
||
}
|
||
if strings.TrimSpace(src.BaseURL) == "" {
|
||
return nil, fmt.Errorf("source %s requires a base_url", src.Name)
|
||
}
|
||
if err := c.AddSource(src); err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{"ok": true, "name": src.Name}, nil
|
||
}
|
||
|
||
// actionCreateKey creates a gateway key.
|
||
//
|
||
// The plaintext key is returned ONCE, in this response — the same posture as the
|
||
// admin API. It is not retrievable later, so a plugin that needs it must store it
|
||
// in its own credential vault immediately.
|
||
func (c *Core) actionCreateKey(args map[string]interface{}) (interface{}, error) {
|
||
name, _ := args["name"].(string)
|
||
role, _ := args["role"].(string)
|
||
if role == "" {
|
||
role = "user"
|
||
}
|
||
models, err := decodeScopes(args["models"])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
k, err := c.CreateKey(name, role, models, "created by plugin")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{
|
||
"key": k.Key,
|
||
"name": k.Name,
|
||
"role": k.Role,
|
||
"models": k.Models,
|
||
}, nil
|
||
}
|
||
|
||
// ---- 破坏性实现 ----
|
||
|
||
func (c *Core) actionRemoveSource(args map[string]interface{}) (interface{}, error) {
|
||
name, _ := args["name"].(string)
|
||
if name == "" {
|
||
return nil, fmt.Errorf("remove_source requires a name")
|
||
}
|
||
// The last source is not removable: an empty registry turns every request
|
||
// into a 503, and the operator would have to fix it from the filesystem.
|
||
if len(c.Sources()) <= 1 {
|
||
return nil, fmt.Errorf("refusing to remove the last source: the gateway would have nothing to route to")
|
||
}
|
||
if err := c.RemoveSource(name); err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{"ok": true, "name": name}, nil
|
||
}
|
||
|
||
func (c *Core) actionDeleteKey(args map[string]interface{}) (interface{}, error) {
|
||
key, _ := args["key"].(string)
|
||
if key == "" {
|
||
return nil, fmt.Errorf("delete_key requires the key to delete")
|
||
}
|
||
ok, err := c.DeleteKey(key)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !ok {
|
||
return nil, fmt.Errorf("no such key")
|
||
}
|
||
return map[string]interface{}{"ok": true}, nil
|
||
}
|
||
|
||
func (c *Core) actionUpdateKey(args map[string]interface{}) (interface{}, error) {
|
||
key, _ := args["key"].(string)
|
||
if key == "" {
|
||
return nil, fmt.Errorf("update_key requires the key")
|
||
}
|
||
name, _ := args["name"].(string)
|
||
role, _ := args["role"].(string)
|
||
models, err := decodeScopes(args["models"])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
k, err := c.UpdateKey(key, name, role, models, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{"ok": true, "name": k.Name, "role": k.Role}, nil
|
||
}
|
||
|
||
// actionRemoveAdapter deletes an adapter, but only one the CALLER plugin owns —
|
||
// the ownership check lives in Plugins.RemoveAdapter (see the adapter bridge).
|
||
// A plugin can always remove its own; removing an operator's or a built-in is
|
||
// refused there, and that refusal is the security property.
|
||
func (c *Core) actionRemoveAdapter(args map[string]interface{}) (interface{}, error) {
|
||
name, _ := args["name"].(string)
|
||
if name == "" {
|
||
return nil, fmt.Errorf("remove_adapter requires a name")
|
||
}
|
||
ps := c.Plugins()
|
||
if ps == nil {
|
||
return nil, fmt.Errorf("no plugins loaded")
|
||
}
|
||
plugin, _ := args["plugin"].(string)
|
||
if plugin == "" {
|
||
return nil, fmt.Errorf("remove_adapter needs the calling plugin's name")
|
||
}
|
||
if err := ps.RemoveAdapter(plugin, name); err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{"ok": true, "name": name}, nil
|
||
}
|
||
|
||
func (c *Core) actionSetPluginEnabled(args map[string]interface{}) (interface{}, error) {
|
||
name, _ := args["name"].(string)
|
||
if name == "" {
|
||
return nil, fmt.Errorf("set_plugin_enabled requires a name")
|
||
}
|
||
enabled, ok := args["enabled"].(bool)
|
||
if !ok {
|
||
return nil, fmt.Errorf("set_plugin_enabled requires enabled (boolean)")
|
||
}
|
||
ps := c.Plugins()
|
||
if ps == nil {
|
||
return nil, fmt.Errorf("no plugins loaded")
|
||
}
|
||
if err := ps.SetEnabled(name, enabled); err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{"ok": true, "name": name, "enabled": enabled}, nil
|
||
}
|
||
|
||
// decodeScopes converts a Lua-decoded array of model-scope tables into
|
||
// []config.ModelScope. It accepts both an explicit array and a single table so a
|
||
// one-entry chain does not need a wrapper.
|
||
func decodeScopes(v interface{}) ([]config.ModelScope, error) {
|
||
if v == nil {
|
||
return nil, nil
|
||
}
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("entries are not encodable: %w", err)
|
||
}
|
||
var list []config.ModelScope
|
||
if err := json.Unmarshal(b, &list); err == nil {
|
||
return list, nil
|
||
}
|
||
var one config.ModelScope
|
||
if err := json.Unmarshal(b, &one); err != nil {
|
||
return nil, fmt.Errorf("entries shape is invalid: %w", err)
|
||
}
|
||
return []config.ModelScope{one}, nil
|
||
}
|