mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
此前两类冲突各有问题:
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 后新版本才真正生效。
262 lines
8.5 KiB
Go
262 lines
8.5 KiB
Go
package e2e
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// writeE2EPlugin 落一个插件文件到 e2e 的 plugin_dir(SeedBundled 见空目录才
|
||
// 落地内置插件,所以先放自定义插件再启动网关)。
|
||
func writeE2EPlugin(t *testing.T, dir, name, code string) {
|
||
t.Helper()
|
||
p := filepath.Join(dir, "plugins", name)
|
||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(p, []byte(code), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// e2eConfig 返回一份最小 e2e 配置文本(含 plugin_dir)。
|
||
func e2eConfig(dir, listen string) string {
|
||
return "listen: " + listen + "\n" +
|
||
"gateway_keys:\n - sk-e2e-0001\n" +
|
||
"default_model: AUTO\n" +
|
||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||
"sources: []\n"
|
||
}
|
||
|
||
// TestConflictArbitrationE2E(用户要求:服务路径冲突不崩溃不拒启 + WebAPI 裁决)
|
||
//
|
||
// 真二进制全链路:
|
||
// 1. 两个插件声明同一条 /api/plugins/http/ 路径 → 两插件都 loaded,启动正常
|
||
// 2. 请求该路径 → 409,错误信息点名两个候选插件(不静默选边)
|
||
// 3. GET /api/plugins/conflicts → 列出冲突(路径 + 候选 + 未裁决)
|
||
// 4. POST /api/plugins/conflicts 裁决 → 持久化
|
||
// 5. 再请求该路径 → 200,赢家应答({"from":"B"})
|
||
// 6. /v1/models 等既有路由全程不受影响(旧通路无回归)
|
||
func TestConflictArbitrationE2E(t *testing.T) {
|
||
bin := buildBinary(t)
|
||
dir := t.TempDir()
|
||
addr := mustAddr(t)
|
||
|
||
writeE2EPlugin(t, dir, "plugA.lua", `
|
||
local P = {}
|
||
P.name = "plugA"
|
||
P.version = "1"
|
||
P.exports = {}
|
||
P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } }
|
||
function P.h(req) return { status = 200, json = { from = "A" } } end
|
||
return P
|
||
`)
|
||
writeE2EPlugin(t, dir, "plugB.lua", `
|
||
local P = {}
|
||
P.name = "plugB"
|
||
P.version = "1"
|
||
P.exports = {}
|
||
P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } }
|
||
function P.h(req) return { status = 200, json = { from = "B" } } end
|
||
return P
|
||
`)
|
||
|
||
cfgPath := filepath.Join(dir, "config.yaml")
|
||
if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
g := startGateway(t, bin, addr, cfgPath)
|
||
|
||
// ① 两个插件都加载成功(冲突不拒载)
|
||
resp, body := g.do(http.MethodGet, "/api/plugins", "", true)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("GET /api/plugins: %d %s", resp.StatusCode, body)
|
||
}
|
||
var list struct {
|
||
Plugins []struct {
|
||
Name string `json:"name"`
|
||
Loaded bool `json:"loaded"`
|
||
} `json:"plugins"`
|
||
}
|
||
if err := json.Unmarshal([]byte(body), &list); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
loaded := map[string]bool{}
|
||
for _, p := range list.Plugins {
|
||
loaded[p.Name] = p.Loaded
|
||
}
|
||
if !loaded["plugA"] || !loaded["plugB"] {
|
||
t.Fatalf("冲突的两个插件都应 loaded: %v", loaded)
|
||
}
|
||
|
||
// ② 请求冲突路径 → 409 + 点名候选(不静默选边)
|
||
resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true)
|
||
if resp.StatusCode != http.StatusConflict {
|
||
t.Fatalf("未裁决冲突路径应 409, 实得 %d (%s)", resp.StatusCode, body)
|
||
}
|
||
if !strings.Contains(body, "plugA") || !strings.Contains(body, "plugB") {
|
||
t.Fatalf("409 应点名两个候选: %s", body)
|
||
}
|
||
|
||
// ③ 冲突列表可见
|
||
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body)
|
||
}
|
||
var cl struct {
|
||
Routes []struct {
|
||
Path string `json:"path"`
|
||
Candidates []string `json:"candidates"`
|
||
Resolved string `json:"resolved"`
|
||
} `json:"routes"`
|
||
}
|
||
if err := json.Unmarshal([]byte(body), &cl); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(cl.Routes) != 1 || cl.Routes[0].Path != "contested" || cl.Routes[0].Resolved != "" {
|
||
t.Fatalf("冲突形态不对: %+v", cl)
|
||
}
|
||
|
||
// ④ 裁决 plugB 赢
|
||
arbit, _ := json.Marshal(map[string]string{
|
||
"kind": "route", "path": "contested", "winner": "plugB",
|
||
})
|
||
resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("POST conflicts 裁决: %d %s", resp.StatusCode, body)
|
||
}
|
||
|
||
// ⑤ 再请求 → 200 且赢家应答
|
||
resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("裁决后请求冲突路径应 200, 实得 %d (%s)", resp.StatusCode, body)
|
||
}
|
||
if !strings.Contains(body, `"from":"B"`) {
|
||
t.Fatalf("赢家 plugB 应答, 实得 %s", body)
|
||
}
|
||
|
||
// ⑥ 旧通路无回归:/v1/models 正常
|
||
resp, _ = g.do(http.MethodGet, "/v1/models", "", true)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("旧通路 /v1/models 应 200, 实得 %d", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
// TestAdapterConflictE2E:插件装与内置同名适配器 → 挂起(现任保留),
|
||
// /api/plugins/conflicts 列出,裁决 candidate 后插件版生效。
|
||
func TestAdapterConflictE2E(t *testing.T) {
|
||
bin := buildBinary(t)
|
||
dir := t.TempDir()
|
||
addr := mustAddr(t)
|
||
|
||
writeE2EPlugin(t, dir, "shadow.lua", `
|
||
local P = {}
|
||
P.name = "shadow"
|
||
P.version = "1"
|
||
P.exports = { "install" }
|
||
function P.install(args)
|
||
local src = 'local a={} a.name="openai" a.version="SHADOW_V1" a.endpoint="/x" return a'
|
||
local name, err = __llmsproxy_adapters.install("openai", src)
|
||
return { name = name, err = err }
|
||
end
|
||
return P
|
||
`)
|
||
cfgPath := filepath.Join(dir, "config.yaml")
|
||
if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
g := startGateway(t, bin, addr, cfgPath)
|
||
|
||
// ① 安装 → 返回 err(pending 语义),内置版本不变
|
||
resp, body := g.do(http.MethodPost, "/api/plugins/shadow/call/install", "{}", true)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("call install: %d %s", resp.StatusCode, body)
|
||
}
|
||
// Call 端点把插件返回值包在 {"result": …} 里;挂起语义的断言在 result 层做。
|
||
var wrapped struct {
|
||
Result struct {
|
||
Name *string `json:"name"`
|
||
Err string `json:"err"`
|
||
} `json:"result"`
|
||
}
|
||
if err := json.Unmarshal([]byte(body), &wrapped); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out := wrapped.Result
|
||
if out.Name != nil {
|
||
t.Fatalf("挂起时不得返回生效名: %s", body)
|
||
}
|
||
if !strings.Contains(out.Err, "openai") || !strings.Contains(out.Err, "conflict") {
|
||
t.Fatalf("错误应点名冲突: %q", out.Err)
|
||
}
|
||
|
||
// ② 冲突列表可见
|
||
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body)
|
||
}
|
||
var cl struct {
|
||
Adapters []struct {
|
||
Name string `json:"name"`
|
||
Candidate string `json:"candidate"`
|
||
} `json:"adapters"`
|
||
}
|
||
if err := json.Unmarshal([]byte(body), &cl); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(cl.Adapters) != 1 || cl.Adapters[0].Name != "openai" || cl.Adapters[0].Candidate != "shadow" {
|
||
t.Fatalf("冲突形态不对: %+v", cl)
|
||
}
|
||
|
||
// ③ 适配器列表里 openai 仍是内置版本(现任继续服务,不被静默覆盖)
|
||
assertOpenAIVersion := func(want string, msg string) {
|
||
t.Helper()
|
||
resp, body := g.do(http.MethodGet, "/api/adapters", "", true)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("GET /api/adapters: %d %s", resp.StatusCode, body)
|
||
}
|
||
var al struct {
|
||
Adapters []struct {
|
||
Name string `json:"name"`
|
||
Version string `json:"version"`
|
||
} `json:"adapters"`
|
||
}
|
||
if err := json.Unmarshal([]byte(body), &al); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, a := range al.Adapters {
|
||
if a.Name == "openai" {
|
||
if a.Version != want {
|
||
t.Fatalf("%s: openai 版本应 %q, 实得 %q", msg, want, a.Version)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
t.Fatalf("%s: openai 适配器不存在: %s", msg, body)
|
||
}
|
||
assertOpenAIVersion("2.0.0", "现任内置必须继续服务(不被静默覆盖)")
|
||
|
||
// ④ 裁决 candidate → 插件版生效
|
||
arbit, _ := json.Marshal(map[string]string{
|
||
"kind": "adapter", "name": "openai", "winner": "candidate",
|
||
})
|
||
if resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true); resp.StatusCode != 200 {
|
||
t.Fatalf("裁决 candidate: %d %s", resp.StatusCode, body)
|
||
}
|
||
|
||
// ⑤ 适配器版本已切换 + 冲突清空
|
||
assertOpenAIVersion("SHADOW_V1", "裁决后插件版应生效")
|
||
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatal(resp.StatusCode)
|
||
}
|
||
if strings.Contains(body, `"name":"openai"`) && strings.Contains(body, `"adapters":[{`) {
|
||
t.Fatalf("裁决后冲突应清除: %s", body)
|
||
}
|
||
}
|