mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 12:17:23 +00:00
feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B)
此前两类冲突各有问题:
A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默
选一个(装个新插件就可能改变既有路径归属)。
B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。
用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃
或拒绝启动。
## A 类:插件 HTTP 服务路径冲突
- dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决
返回 409 并点名候选;已裁决按赢家分发。
- route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计
loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。
## B 类:适配器名冲突
- InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选
入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。
- ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过
reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。
- 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。
## 持久化 + WebAPI + WebUI
- 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回;
重启后插件再装同名适配器自动套用上次裁决。
- WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。
- WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。
## 测试
- 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、
赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、
自身更新不受影响(token 轮换不走冲突路径)。
- e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409
点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任
保留→裁决 candidate→插件版生效→冲突清空。
- 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。
修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器
重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键
归档,裁决 candidate 后新版本才真正生效。
This commit is contained in:
@ -26,6 +26,7 @@ package lua
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
@ -103,35 +104,65 @@ func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error) {
|
||||
if ps.vm == nil {
|
||||
return "", fmt.Errorf("install_adapter: no adapter VM available")
|
||||
}
|
||||
// Rule 3 first: an adapter another plugin already owns is off-limits.
|
||||
// A name conflict used to be a hard rejection (owned by another plugin, or
|
||||
// shadows a built-in). The user now wants it SUSPENDED for operator
|
||||
// arbitration instead of refused outright: the incumbent keeps serving, the
|
||||
// candidate waits in the conflicts list, and the operator picks a winner in
|
||||
// the WebUI. The install still returns a (typed) error so the caller knows it
|
||||
// did not take effect — but it is ErrAdapterConflictPending, not a dead end.
|
||||
incumbent := ""
|
||||
if owner := ps.adapterOwner().ownedBy(name); owner != "" && owner != plugin {
|
||||
return "", fmt.Errorf("install_adapter: adapter %q is owned by plugin %q and cannot be replaced by %q",
|
||||
name, owner, plugin)
|
||||
incumbent = "plugin:" + owner
|
||||
} else if containsNameFold(ps.vm.bundledNames(), name) {
|
||||
incumbent = "operator (built-in)"
|
||||
}
|
||||
// Rules 1+2 (path safety + no shadowing built-ins) live in
|
||||
// InstallPluginAdapter so there is exactly one enforcement point.
|
||||
if incumbent != "" {
|
||||
if ps.recordAdapterConflict(name, incumbent, plugin, code) {
|
||||
// The operator already chose THIS candidate in a prior session. Install
|
||||
// for real via LoadAdapterSource, which bypasses the reserved-name gate
|
||||
// — that explicit operator override is exactly what the arbitration is
|
||||
// FOR, and re-suspending it on every restart would be a loop.
|
||||
if err := ps.vm.LoadAdapterSource(name, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ps.finishInstall(plugin, name), nil
|
||||
}
|
||||
return "", &ErrAdapterConflictPending{Name: name, Incumbent: incumbent}
|
||||
}
|
||||
// No conflict: normal install path. Rules 1+2 (path safety + no shadowing
|
||||
// built-ins) live in InstallPluginAdapter so there is exactly one
|
||||
// enforcement point. (The shadow check there is now unreachable for a
|
||||
// genuine built-in — it is caught above and suspended — but kept as the
|
||||
// single authority for path safety and any future reserved set.)
|
||||
if err := ps.vm.InstallPluginAdapter(name, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Ownership is recorded under the name sources will actually reference. A
|
||||
// script may declare a name different from the registration key, and it is
|
||||
// the declared name that ends up in the registry — recording it under the
|
||||
// key would leave the adapter un-owned and therefore mutable by anyone.
|
||||
// Ownership is recorded under the name sources will actually reference. A
|
||||
// script may declare a name different from the registration key, and it is
|
||||
// the declared name that ends up in the registry — recording it under the
|
||||
// key would leave the adapter un-owned and therefore mutable by anyone.
|
||||
//
|
||||
// Both names are recorded: the registration key so a later update through
|
||||
// the same key still matches, and the declared name so a lookup by the name
|
||||
// a source references also matches. Both entries are cleared together on
|
||||
// remove.
|
||||
return ps.finishInstall(plugin, name), nil
|
||||
}
|
||||
|
||||
// finishInstall records adapter ownership under BOTH the registration key and
|
||||
// the declared name (a script may declare a name different from the key, and it
|
||||
// is the declared name sources reference — recording only the key would leave
|
||||
// the adapter un-owned and therefore mutable by anyone). Both entries are
|
||||
// cleared together on remove.
|
||||
func (ps *Plugins) finishInstall(plugin, name string) string {
|
||||
effective := ps.vm.EffectiveAdapterName(name)
|
||||
ps.adapterOwner().set(effective, plugin)
|
||||
if effective != name {
|
||||
ps.adapterOwner().set(name, plugin)
|
||||
}
|
||||
return effective, nil
|
||||
return effective
|
||||
}
|
||||
|
||||
// containsNameFold reports whether name is in names (case-insensitive, matching
|
||||
// ValidateAdapterName's reserved-name comparison).
|
||||
func containsNameFold(names []string, name string) bool {
|
||||
for _, n := range names {
|
||||
if strings.EqualFold(n, name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateAdapter replaces an adapter a plugin previously installed — the token
|
||||
|
||||
Reference in New Issue
Block a user